diff --git a/.gitignore b/.gitignore index c3c1758..a3f02be 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ backend/target/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +frontend/test-results/ +frontend/playwright-report/ # IDE .idea/ diff --git a/AGENTS.md b/AGENTS.md index dae2a0d..299611b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,31 @@ Pull requests should include a short summary, testing performed, linked issues w Do not commit secrets, local credentials, generated build output, or machine-specific configuration. Store required environment variables in an ignored `.env` file and document safe example values in `.env.example`. +## Standard architektoniczny: routing (obowiązkowy) + +Każdy nowy widok, ekran, zakładka lub podstrona **musi mieć własny, jednoznaczny adres URL** i być obsługiwana przez React Router. To twardy standard całego projektu, nie sugestia. + +Zasady: + +- **Nie buduj nawigacji na stanie.** Zakazane jako mechanizm przełączania stron: `useState('view')`, `setView`, `activeView`, `selectedPage`, `selectedTab` i pochodne. +- **Nie przełączaj całych stron warunkowym renderowaniem** bez zmiany adresu (`{view === 'x' && }`). +- **Do nawigacji używaj** `Link`, `NavLink`, `useNavigate` oraz tras zadeklarowanych w `Routes`. +- **Identyfikatory obiektów trzymaj w parametrach URL**: `/oferta/:id`, `/profil/:id`, `/wiadomosci/:conversationId` — nie w stanie Reacta. +- **Filtry, sortowanie, wyszukiwanie i paginację zapisuj w query string** wszędzie tam, gdzie użytkownik powinien móc skopiować lub odświeżyć widok. +- **Zakładki paneli twórz jako trasy zagnieżdżone**: `/konto/ustawienia`, `/admin/uzytkownicy`. +- **Trasy wymagające logowania zabezpieczaj `ProtectedRoute`** (`frontend/src/ProtectedRoute.tsx`). Pamiętaj, że to zabezpieczenie interfejsu — autoryzację egzekwuje backend. +- **Nie używaj `href="#"`** ani przycisków imitujących linki. +- **Każdy link musi działać** po odświeżeniu (F5), przy bezpośrednim wejściu z adresu, po użyciu Wstecz/Dalej i po otwarciu w nowej karcie. + +Gdzie co leży: + +- `frontend/src/routes.ts` — centralna mapa adresów (`ROUTES`) i helpery (`listingPath`, `mapPath`, `adminTabPath`). Nowy widok zaczynaj od wpisu tutaj, nie od literału ścieżki w JSX. +- `frontend/src/ProtectedRoute.tsx` — bramka autoryzacji, obsługuje stan `loading` z `AuthProvider`. +- `frontend/src/App.tsx` — deklaracja ``. +- `nginx/default.conf` — `try_files $uri $uri/ /index.html` (SPA fallback, już skonfigurowany). + +Przed zakończeniem każdego zadania sprawdź, czy dodane widoki mają trasy i czy adres zmienia się podczas nawigacji. Jeżeli istniejący kod łamie ten standard, nie powielaj starego rozwiązania — dostosuj go do React Routera. + ## Behavioral Guidelines To Reduce Common LLM Coding Mistakes Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment. diff --git a/backend/pom.xml b/backend/pom.xml index c0bc397..5e07ec0 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-mail + io.jsonwebtoken jjwt-api diff --git a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java index 39c5476..c48c380 100644 --- a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java +++ b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java @@ -3,9 +3,12 @@ package pl.polskalokalnie; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.scheduling.annotation.EnableScheduling; // Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego // uzytkownika Spring Security (i mylacy log "Using generated security password"). +// @EnableScheduling wlacza workera wysylajacego kampanie z outboxu. +@EnableScheduling @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) public class PolskaLokalnieApplication { diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index 2967327..ee2b6d6 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -27,6 +27,7 @@ import pl.polskalokalnie.listing.ListingStatus; import pl.polskalokalnie.message.MessageResponse; import pl.polskalokalnie.message.MessageService; import pl.polskalokalnie.message.SendMessageRequest; +import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.report.ListingReportResponse; import pl.polskalokalnie.report.ListingReportService; import pl.polskalokalnie.report.ResolveListingReportRequest; @@ -52,13 +53,15 @@ public class AdminController { private final TextModerationService textModerationService; private final AdminStatsService adminStatsService; private final PasswordEncoder passwordEncoder; + private final NotificationService notificationService; public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, ListingService listingService, ListingReportService listingReportService, MessageService messageService, TextModerationService textModerationService, AdminStatsService adminStatsService, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + NotificationService notificationService) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; this.listingService = listingService; @@ -67,6 +70,7 @@ public class AdminController { this.textModerationService = textModerationService; this.adminStatsService = adminStatsService; this.passwordEncoder = passwordEncoder; + this.notificationService = notificationService; } // --- Uzytkownicy --- @@ -93,7 +97,12 @@ public class AdminController { public UserResponse verify(@PathVariable Long id) { AppUser user = requireUser(id); user.setVerified(true); - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + notificationService.create(saved.getEmail(), "system", "check", + "Konto zweryfikowane", + "Twoje konto zostało zweryfikowane przez administratora. Masz dostęp do wszystkich funkcji serwisu.", + "accountSecurity", "account-verified"); + return UserResponse.from(saved); } @PostMapping("/users/{id}/grant-admin") @@ -104,7 +113,12 @@ public class AdminController { } user.setRole(Role.ADMIN); user.setVerified(true); - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + notificationService.create(saved.getEmail(), "system", "shield", + "Nadano uprawnienia administratora", + "Twoje konto otrzymało rolę administratora.", + "account", null); + return UserResponse.from(saved); } @PostMapping("/users/{id}/revoke-admin") @@ -137,6 +151,10 @@ public class AdminController { String temporaryPassword = generateTemporaryPassword(); user.setPasswordHash(passwordEncoder.encode(temporaryPassword)); userRepository.save(user); + notificationService.create(user.getEmail(), "system", "lock", + "Hasło zostało zresetowane", + "Administrator zresetował hasło do Twojego konta. Zaloguj się nowym hasłem i ustaw własne w ustawieniach bezpieczeństwa.", + "accountSecurity", null); return new ResetPasswordResponse(temporaryPassword); } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 69341f0..8f9bddd 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.lead.LeadSyncService; import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.auth.dto.AuthResponse; import pl.polskalokalnie.auth.dto.LoginRequest; @@ -31,19 +32,22 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final TextModerationService textModerationService; + private final LeadSyncService leadSyncService; public AuthService( UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, PasswordEncoder passwordEncoder, JwtService jwtService, - TextModerationService textModerationService + TextModerationService textModerationService, + LeadSyncService leadSyncService ) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; this.passwordEncoder = passwordEncoder; this.jwtService = jwtService; this.textModerationService = textModerationService; + this.leadSyncService = leadSyncService; } public AuthResponse register(RegisterRequest request) { @@ -69,7 +73,9 @@ public class AuthService { // Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora. user.setVerified(false); - return buildAuthResponse(userRepository.save(user)); + AppUser saved = userRepository.save(user); + leadSyncService.syncUser(saved); + return buildAuthResponse(saved); } public AuthResponse login(LoginRequest request) { @@ -121,6 +127,7 @@ public class AuthService { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane"); } + leadSyncService.syncUser(user); return buildAuthResponse(user); } @@ -151,7 +158,9 @@ public class AuthService { user.setBirthDate(null); } - return UserResponse.from(userRepository.save(user)); + AppUser saved = userRepository.save(user); + leadSyncService.syncUser(saved); + return UserResponse.from(saved); } private AuthResponse buildAuthResponse(AppUser user) { diff --git a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java index 6bb9446..28a847f 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/JwtAuthFilter.java @@ -30,9 +30,8 @@ public class JwtAuthFilter extends OncePerRequestFilter { @NonNull HttpServletResponse response, @NonNull FilterChain filterChain ) throws ServletException, IOException { - String header = request.getHeader("Authorization"); - if (header != null && header.startsWith("Bearer ")) { - String token = header.substring(7); + String token = resolveToken(request); + if (token != null) { try { Claims claims = jwtService.parse(token); String email = claims.getSubject(); @@ -48,4 +47,17 @@ public class JwtAuthFilter extends OncePerRequestFilter { } filterChain.doFilter(request, response); } + + // Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token. + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + String param = request.getParameter("access_token"); + if (param != null && !param.isBlank()) { + return param; + } + return null; + } } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 12f9e3b..6769d69 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -37,6 +37,7 @@ public class SecurityConfig { .requestMatchers("/error").permitAll() .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll() .requestMatchers("/api/i18n/translate").permitAll() + .requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll() .requestMatchers("/api/auth/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated() .requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll() diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java b/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java new file mode 100644 index 0000000..c272deb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java @@ -0,0 +1,171 @@ +package pl.polskalokalnie.campaign; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.time.LocalDate; +import org.hibernate.annotations.ColumnDefault; + +/** + * Kampania marketingowa wysylana jednym kanalem (e-mail lub SMS) do wskazanej grupy docelowej. + * Outbox (pozycje {@link CampaignRecipient}) budowany jest przy starcie wysylki; worker + * przetwarza go z dziennym limitem. Tresc zawiera znaczniki personalizacji i stopke z rezygnacja. + */ +@Entity +@Table(name = "campaigns") +public class Campaign { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 160) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Channel channel; + + // Temat dotyczy tylko kanalu e-mail. + @Column(length = 200) + private String subject; + + @Column(nullable = false, length = 4000) + private String body; + + @Column(nullable = false) + private Long targetGroupId; + + private Instant scheduledAt; + + @Column(nullable = false) + @ColumnDefault("200") + private Integer dailyLimit = 200; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'DRAFT'") + private CampaignStatus status = CampaignStatus.DRAFT; + + // Ile wyslano dzisiaj (do egzekwowania dziennego limitu) i data resetu licznika. + @Column(nullable = false) + @ColumnDefault("0") + private Integer sentToday = 0; + + private LocalDate sentTodayDate; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @Column(length = 180) + private String createdBy; + + @jakarta.persistence.PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Channel getChannel() { + return channel; + } + + public void setChannel(Channel channel) { + this.channel = channel; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public Long getTargetGroupId() { + return targetGroupId; + } + + public void setTargetGroupId(Long targetGroupId) { + this.targetGroupId = targetGroupId; + } + + public Instant getScheduledAt() { + return scheduledAt; + } + + public void setScheduledAt(Instant scheduledAt) { + this.scheduledAt = scheduledAt; + } + + public Integer getDailyLimit() { + return dailyLimit; + } + + public void setDailyLimit(Integer dailyLimit) { + this.dailyLimit = dailyLimit; + } + + public CampaignStatus getStatus() { + return status; + } + + public void setStatus(CampaignStatus status) { + this.status = status; + } + + public Integer getSentToday() { + return sentToday; + } + + public void setSentToday(Integer sentToday) { + this.sentToday = sentToday; + } + + public LocalDate getSentTodayDate() { + return sentTodayDate; + } + + public void setSentTodayDate(LocalDate sentTodayDate) { + this.sentTodayDate = sentTodayDate; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java new file mode 100644 index 0000000..6ce6e6c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java @@ -0,0 +1,72 @@ +package pl.polskalokalnie.campaign; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Kampanie marketingowe w panelu administratora. + */ +@RestController +@RequestMapping("/api/admin/campaigns") +public class CampaignController { + + private final CampaignService campaignService; + + public CampaignController(CampaignService campaignService) { + this.campaignService = campaignService; + } + + @GetMapping + public List list() { + return campaignService.list(); + } + + @GetMapping("/{id}") + public CampaignResponse get(@PathVariable Long id) { + return campaignService.get(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CampaignResponse create(@Valid @RequestBody CampaignRequest request, Authentication authentication) { + return campaignService.create(request, authentication.getName()); + } + + @PutMapping("/{id}") + public CampaignResponse update(@PathVariable Long id, @Valid @RequestBody CampaignRequest request) { + return campaignService.update(id, request); + } + + @PostMapping("/{id}/schedule") + public CampaignResponse schedule(@PathVariable Long id) { + return campaignService.schedule(id); + } + + @PostMapping("/{id}/pause") + public CampaignResponse pause(@PathVariable Long id) { + return campaignService.pause(id); + } + + @GetMapping("/{id}/stats") + public CampaignStatsResponse stats(@PathVariable Long id) { + return campaignService.stats(id); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + campaignService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java new file mode 100644 index 0000000..5a3f495 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java @@ -0,0 +1,147 @@ +package pl.polskalokalnie.campaign; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +/** + * Pojedyncza pozycja outboxu kampanii - jeden odbiorca. Adres (e-mail/telefon) jest zapisany jako + * migawka w chwili budowy outboxu, zeby pozniejsze zmiany leada nie zmienialy juz zakolejkowanej + * wysylki. + */ +@Entity +@Table(name = "campaign_recipients", indexes = { + @Index(name = "idx_campaign_recipients_campaign", columnList = "campaignId"), + @Index(name = "idx_campaign_recipients_status", columnList = "status") +}) +public class CampaignRecipient { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long campaignId; + + private Long leadId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Channel channel; + + @Column(nullable = false, length = 200) + private String address; + + @Column(length = 160) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private RecipientStatus status = RecipientStatus.QUEUED; + + @Column(length = 500) + private String error; + + @Column(length = 200) + private String providerMessageId; + + private Instant sentAt; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public Long getCampaignId() { + return campaignId; + } + + public void setCampaignId(Long campaignId) { + this.campaignId = campaignId; + } + + public Long getLeadId() { + return leadId; + } + + public void setLeadId(Long leadId) { + this.leadId = leadId; + } + + public Channel getChannel() { + return channel; + } + + public void setChannel(Channel channel) { + this.channel = channel; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public RecipientStatus getStatus() { + return status; + } + + public void setStatus(RecipientStatus status) { + this.status = status; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public void setProviderMessageId(String providerMessageId) { + this.providerMessageId = providerMessageId; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java new file mode 100644 index 0000000..b3a8647 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.campaign; + +import java.util.List; +import org.springframework.data.domain.Limit; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CampaignRecipientRepository extends JpaRepository { + + List findByCampaignId(Long campaignId); + + long countByCampaignIdAndStatus(Long campaignId, RecipientStatus status); + + List findByCampaignIdAndStatusOrderByIdAsc(Long campaignId, RecipientStatus status, Limit limit); + + boolean existsByCampaignId(Long campaignId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java new file mode 100644 index 0000000..e84620c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.campaign; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CampaignRepository extends JpaRepository { + + List findByStatus(CampaignStatus status); +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java new file mode 100644 index 0000000..7ebf1d6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java @@ -0,0 +1,17 @@ +package pl.polskalokalnie.campaign; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; + +public record CampaignRequest( + @NotBlank String name, + @NotNull Channel channel, + String subject, + @NotBlank String body, + @NotNull Long targetGroupId, + Instant scheduledAt, + @NotNull @Min(1) Integer dailyLimit +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java new file mode 100644 index 0000000..822f49a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java @@ -0,0 +1,39 @@ +package pl.polskalokalnie.campaign; + +import java.time.Instant; + +public record CampaignResponse( + Long id, + String name, + Channel channel, + String subject, + String body, + Long targetGroupId, + Instant scheduledAt, + Integer dailyLimit, + CampaignStatus status, + Integer sentToday, + Instant createdAt, + String createdBy, + long totalRecipients, + long sentCount +) { + public static CampaignResponse from(Campaign campaign, long totalRecipients, long sentCount) { + return new CampaignResponse( + campaign.getId(), + campaign.getName(), + campaign.getChannel(), + campaign.getSubject(), + campaign.getBody(), + campaign.getTargetGroupId(), + campaign.getScheduledAt(), + campaign.getDailyLimit(), + campaign.getStatus(), + campaign.getSentToday(), + campaign.getCreatedAt(), + campaign.getCreatedBy(), + totalRecipients, + sentCount + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java new file mode 100644 index 0000000..081d78c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java @@ -0,0 +1,213 @@ +package pl.polskalokalnie.campaign; + +import java.time.Instant; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.lead.Lead; +import pl.polskalokalnie.lead.LeadQueryService; +import pl.polskalokalnie.lead.TargetGroup; +import pl.polskalokalnie.lead.TargetGroupRepository; + +/** + * Logika kampanii marketingowych: CRUD, budowa outboxu z grupy docelowej oraz sterowanie + * wysylka (harmonogram/pauza). Outbox respektuje zgody marketingowe i trwale rezygnacje - + * odbiorcy bez zgody lub po opt-oucie nie trafiaja do kolejki. Sama wysylka odbywa sie w workerze. + */ +@Service +public class CampaignService { + + private final CampaignRepository campaignRepository; + private final CampaignRecipientRepository recipientRepository; + private final TargetGroupRepository targetGroupRepository; + private final LeadQueryService leadQueryService; + + public CampaignService(CampaignRepository campaignRepository, + CampaignRecipientRepository recipientRepository, + TargetGroupRepository targetGroupRepository, + LeadQueryService leadQueryService) { + this.campaignRepository = campaignRepository; + this.recipientRepository = recipientRepository; + this.targetGroupRepository = targetGroupRepository; + this.leadQueryService = leadQueryService; + } + + public List list() { + return campaignRepository.findAll().stream() + .sorted(Comparator.comparing(Campaign::getCreatedAt).reversed()) + .map(this::toResponse) + .toList(); + } + + public CampaignResponse get(Long id) { + return toResponse(requireCampaign(id)); + } + + @Transactional + public CampaignResponse create(CampaignRequest request, String createdBy) { + requireGroup(request.targetGroupId()); + if (request.channel() == Channel.EMAIL && (request.subject() == null || request.subject().isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Kampania e-mail wymaga tematu"); + } + Campaign campaign = new Campaign(); + apply(campaign, request); + campaign.setStatus(CampaignStatus.DRAFT); + campaign.setCreatedBy(createdBy); + return toResponse(campaignRepository.save(campaign)); + } + + @Transactional + public CampaignResponse update(Long id, CampaignRequest request) { + Campaign campaign = requireCampaign(id); + if (campaign.getStatus() != CampaignStatus.DRAFT) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Edytować można tylko kampanię w wersji roboczej"); + } + requireGroup(request.targetGroupId()); + if (request.channel() == Channel.EMAIL && (request.subject() == null || request.subject().isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Kampania e-mail wymaga tematu"); + } + apply(campaign, request); + return toResponse(campaignRepository.save(campaign)); + } + + @Transactional + public void delete(Long id) { + Campaign campaign = requireCampaign(id); + recipientRepository.deleteAll(recipientRepository.findByCampaignId(campaign.getId())); + campaignRepository.delete(campaign); + } + + /** + * Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania + * z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera + * wysylajacego dodajemy w kolejnej fazie. + */ + @Transactional + public CampaignResponse schedule(Long id) { + Campaign campaign = requireCampaign(id); + if (campaign.getStatus() == CampaignStatus.COMPLETED || campaign.getStatus() == CampaignStatus.CANCELLED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Kampania jest już zakończona"); + } + if (!recipientRepository.existsByCampaignId(campaign.getId())) { + int built = buildOutbox(campaign); + if (built == 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Brak odbiorców spełniających kryteria (uwzględniono zgody i rezygnacje)"); + } + } + boolean future = campaign.getScheduledAt() != null && campaign.getScheduledAt().isAfter(Instant.now()); + campaign.setStatus(future ? CampaignStatus.SCHEDULED : CampaignStatus.SENDING); + return toResponse(campaignRepository.save(campaign)); + } + + @Transactional + public CampaignResponse pause(Long id) { + Campaign campaign = requireCampaign(id); + if (campaign.getStatus() != CampaignStatus.SENDING && campaign.getStatus() != CampaignStatus.SCHEDULED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Wstrzymać można tylko kampanię w toku lub zaplanowaną"); + } + campaign.setStatus(CampaignStatus.PAUSED); + return toResponse(campaignRepository.save(campaign)); + } + + public CampaignStatsResponse stats(Long id) { + Campaign campaign = requireCampaign(id); + Long cid = campaign.getId(); + return new CampaignStatsResponse( + recipientRepository.findByCampaignId(cid).size(), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.QUEUED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.SENT), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.DELIVERED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.FAILED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.BOUNCED), + recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.OPTED_OUT) + ); + } + + // Buduje pozycje outboxu dla leadow z grupy, z poszanowaniem zgod, rezygnacji i obecnosci adresu. + private int buildOutbox(Campaign campaign) { + TargetGroup group = requireGroup(campaign.getTargetGroupId()); + List members = leadQueryService.resolve(group); + Set seenAddresses = new LinkedHashSet<>(); + int built = 0; + for (Lead lead : members) { + String address = eligibleAddress(lead, campaign.getChannel()); + if (address == null) { + continue; + } + if (!seenAddresses.add(address.toLowerCase(Locale.ROOT))) { + continue; + } + CampaignRecipient recipient = new CampaignRecipient(); + recipient.setCampaignId(campaign.getId()); + recipient.setLeadId(lead.getId()); + recipient.setChannel(campaign.getChannel()); + recipient.setAddress(address); + recipient.setName(lead.getName()); + recipient.setStatus(RecipientStatus.QUEUED); + recipientRepository.save(recipient); + built++; + } + return built; + } + + // Zwraca adres kwalifikujacy leada do wysylki na danym kanale albo null, gdy nie kwalifikuje sie + // (brak adresu, brak zgody marketingowej lub trwala rezygnacja). + private static String eligibleAddress(Lead lead, Channel channel) { + if (channel == Channel.EMAIL) { + if (hasText(lead.getEmail()) && lead.isEmailConsent() && !lead.isEmailOptOut()) { + return lead.getEmail().trim(); + } + return null; + } + if (hasText(lead.getPhone()) && lead.isSmsConsent() && !lead.isSmsOptOut()) { + return lead.getPhone().trim(); + } + return null; + } + + private void apply(Campaign campaign, CampaignRequest request) { + campaign.setName(request.name().trim()); + campaign.setChannel(request.channel()); + campaign.setSubject(trimToNull(request.subject())); + campaign.setBody(request.body()); + campaign.setTargetGroupId(request.targetGroupId()); + campaign.setScheduledAt(request.scheduledAt()); + campaign.setDailyLimit(request.dailyLimit()); + } + + private Campaign requireCampaign(Long id) { + return campaignRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kampania nie istnieje")); + } + + private TargetGroup requireGroup(Long id) { + return targetGroupRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Wskazana grupa docelowa nie istnieje")); + } + + private CampaignResponse toResponse(Campaign campaign) { + long total = recipientRepository.findByCampaignId(campaign.getId()).size(); + long sent = recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.SENT) + + recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.DELIVERED); + return CampaignResponse.from(campaign, total, sent); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java new file mode 100644 index 0000000..1e258f4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.campaign; + +/** + * Statystyki kampanii - agregat licznosci pozycji outboxu wg statusu. + */ +public record CampaignStatsResponse( + long total, + long queued, + long sent, + long delivered, + long failed, + long bounced, + long optedOut +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java new file mode 100644 index 0000000..bf1d074 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java @@ -0,0 +1,14 @@ +package pl.polskalokalnie.campaign; + +/** + * Cykl zycia kampanii. SENDING oznacza, ze outbox jest przetwarzany przez workera z limitem + * dziennym; PAUSED wstrzymuje wysylke bez utraty postepu; COMPLETED - caly outbox rozliczony. + */ +public enum CampaignStatus { + DRAFT, + SCHEDULED, + SENDING, + PAUSED, + COMPLETED, + CANCELLED +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java b/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java new file mode 100644 index 0000000..cc5aa1e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.campaign; + +public enum Channel { + EMAIL, + SMS +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java b/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java new file mode 100644 index 0000000..d1b0c50 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.campaign; + +/** + * Status pojedynczej pozycji outboxu kampanii. QUEUED czeka na wysylke; SENT wyslane do + * dostawcy; DELIVERED potwierdzone; FAILED/BOUNCED bledy; OPTED_OUT - odbiorca zrezygnowal + * przed wysylka i zostal pominiety. + */ +public enum RecipientStatus { + QUEUED, + SENT, + DELIVERED, + FAILED, + BOUNCED, + OPTED_OUT +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java b/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java new file mode 100644 index 0000000..0c1eebd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java @@ -0,0 +1,39 @@ +package pl.polskalokalnie.config; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.ApplicationArguments; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.lead.LeadRepository; +import pl.polskalokalnie.lead.LeadSyncService; +import pl.polskalokalnie.user.UserRepository; + +/** + * Jednorazowy backfill: zakłada leady REGISTERED_USER dla istniejących kont, które ich jeszcze + * nie mają. Kolejne rejestracje/aktualizacje profilu utrzymują synchronizację na bieżąco. + * Uruchamia się po SchemaFixer, żeby tabele leadów były już gotowe. + */ +@Component +@Order(100) +public class LeadBackfillRunner implements ApplicationRunner { + + private final UserRepository userRepository; + private final LeadRepository leadRepository; + private final LeadSyncService leadSyncService; + + public LeadBackfillRunner(UserRepository userRepository, LeadRepository leadRepository, + LeadSyncService leadSyncService) { + this.userRepository = userRepository; + this.leadRepository = leadRepository; + this.leadSyncService = leadSyncService; + } + + @Override + public void run(ApplicationArguments args) { + userRepository.findAll().forEach(user -> { + if (leadRepository.findByUserId(user.getId()).isEmpty()) { + leadSyncService.syncUser(user); + } + }); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java index 705aaa7..e88672b 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java +++ b/backend/src/main/java/pl/polskalokalnie/config/SchemaFixer.java @@ -48,6 +48,16 @@ public class SchemaFixer { "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT" ); + // Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci. + // Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika). + jdbcTemplate.execute( + "ALTER TABLE property_listings DROP CONSTRAINT IF EXISTS property_listings_status_check" + ); + jdbcTemplate.execute( + "ALTER TABLE property_listings ADD CONSTRAINT property_listings_status_check " + + "CHECK (status IN ('PENDING','APPROVED','REJECTED','PAUSED'))" + ); + String dataUrlType = jdbcTemplate.query( "SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'", rs -> rs.next() ? rs.getString(1) : null diff --git a/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java new file mode 100644 index 0000000..dd79db3 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Podsumowanie importu: ile dodano, ile pominieto (duplikaty/braki) oraz komunikaty diagnostyczne. + */ +public record ImportResultResponse( + int imported, + int skipped, + List messages +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/Lead.java b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java new file mode 100644 index 0000000..8f9ce06 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java @@ -0,0 +1,211 @@ +package pl.polskalokalnie.lead; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.hibernate.annotations.ColumnDefault; + +/** + * Kontakt marketingowy (lead) - jedno zrodlo prawdy dla odbiorcow kampanii. Zasilany z trzech + * zrodel: zarejestrowani uzytkownicy (synchronizacja), reczne dodanie/import oraz formularze. + * Zgody ({@code emailConsent}/{@code smsConsent}) i trwale rezygnacje ({@code emailOptOut}/ + * {@code smsOptOut}) decyduja o kwalifikacji leada do wysylki na danym kanale. + */ +@Entity +@Table(name = "leads", indexes = { + @Index(name = "idx_leads_email", columnList = "email"), + @Index(name = "idx_leads_phone", columnList = "phone"), + @Index(name = "idx_leads_user_id", columnList = "userId") +}) +public class Lead { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 160) + private String name; + + @Column(length = 180) + private String email; + + @Column(length = 30) + private String phone; + + @Column(length = 120) + private String city; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private LeadSource source = LeadSource.MANUAL; + + // Powiazanie z kontem uzytkownika, gdy source = REGISTERED_USER. + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'NEW'") + private LeadStatus status = LeadStatus.NEW; + + // EAGER: tagi sa male i zawsze potrzebne w filtrach segmentow oraz odpowiedziach DTO + // (mapowanie odbywa sie poza sesja, bo open-in-view=false). + @ElementCollection(fetch = jakarta.persistence.FetchType.EAGER) + @CollectionTable(name = "lead_tags", joinColumns = @JoinColumn(name = "lead_id")) + @Column(name = "tag", length = 60) + private List tags = new ArrayList<>(); + + @Column(nullable = false) + @ColumnDefault("true") + private boolean emailConsent = true; + + @Column(nullable = false) + @ColumnDefault("true") + private boolean smsConsent = true; + + // Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje. + @Column(nullable = false) + @ColumnDefault("false") + private boolean emailOptOut = false; + + @Column(nullable = false) + @ColumnDefault("false") + private boolean smsOptOut = false; + + @Column(length = 2000) + private String notes; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public LeadSource getSource() { + return source; + } + + public void setSource(LeadSource source) { + this.source = source; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public LeadStatus getStatus() { + return status; + } + + public void setStatus(LeadStatus status) { + this.status = status; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public boolean isEmailConsent() { + return emailConsent; + } + + public void setEmailConsent(boolean emailConsent) { + this.emailConsent = emailConsent; + } + + public boolean isSmsConsent() { + return smsConsent; + } + + public void setSmsConsent(boolean smsConsent) { + this.smsConsent = smsConsent; + } + + public boolean isEmailOptOut() { + return emailOptOut; + } + + public void setEmailOptOut(boolean emailOptOut) { + this.emailOptOut = emailOptOut; + } + + public boolean isSmsOptOut() { + return smsOptOut; + } + + public void setSmsOptOut(boolean smsOptOut) { + this.smsOptOut = smsOptOut; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java new file mode 100644 index 0000000..dfa9c87 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java @@ -0,0 +1,61 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Zarzadzanie baza leadow z panelu administratora. Ochrona przez regule /api/admin/** w + * SecurityConfig (rola ADMIN). + */ +@RestController +@RequestMapping("/api/admin/leads") +public class LeadController { + + private final LeadService leadService; + + public LeadController(LeadService leadService) { + this.leadService = leadService; + } + + @GetMapping + public List list( + @RequestParam(required = false) LeadSource source, + @RequestParam(required = false) String search + ) { + return leadService.list(source, search); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public LeadResponse create(@Valid @RequestBody LeadCreateRequest request) { + return leadService.create(request); + } + + @PostMapping("/import") + public ImportResultResponse importCsv(@Valid @RequestBody LeadImportRequest request) { + return leadService.importCsv(request); + } + + @PatchMapping("/{id}") + public LeadResponse update(@PathVariable Long id, @Valid @RequestBody LeadUpdateRequest request) { + return leadService.update(id, request); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + leadService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java new file mode 100644 index 0000000..35bc08b --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java @@ -0,0 +1,19 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Reczne dodanie leada. Wymagany jest przynajmniej e-mail lub telefon - walidacja w serwisie, + * bo zalezy od obu pol naraz. + */ +public record LeadCreateRequest( + String name, + String email, + String phone, + String city, + List tags, + Boolean emailConsent, + Boolean smsConsent, + String notes +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java new file mode 100644 index 0000000..b744cf6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java @@ -0,0 +1,14 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.constraints.NotBlank; + +/** + * Import listy kontaktow. Klient przesyla surowa tresc CSV (lub liste wierszy oddzielonych + * srednikami/przecinkami). Parsowanie i deduplikacja odbywa sie w serwisie. + * Oczekiwane kolumny: name;email;phone;city (naglowek opcjonalny). + */ +public record LeadImportRequest( + @NotBlank String csv, + String defaultTag +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java new file mode 100644 index 0000000..ea310c1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java @@ -0,0 +1,77 @@ +package pl.polskalokalnie.lead; + +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Wylicza czlonkow grupy docelowej (dynamicznego segmentu) na podstawie zapisanych kryteriow. + * Puste kryterium = brak ograniczenia. Nie wyklucza tu rezygnacji (opt-out) ani braku adresu - + * to robi budowa kampanii, bo zalezy od kanalu (e-mail vs SMS). + */ +@Service +public class LeadQueryService { + + private final LeadRepository leadRepository; + + public LeadQueryService(LeadRepository leadRepository) { + this.leadRepository = leadRepository; + } + + public List resolve(TargetGroup group) { + return leadRepository.findAll().stream() + .filter(lead -> matches(lead, group)) + .toList(); + } + + public long count(TargetGroup group) { + return leadRepository.findAll().stream() + .filter(lead -> matches(lead, group)) + .count(); + } + + private boolean matches(Lead lead, TargetGroup group) { + if (group.getSources() != null && !group.getSources().isEmpty() + && !group.getSources().contains(lead.getSource())) { + return false; + } + if (hasText(group.getCity()) + && (lead.getCity() == null || !lead.getCity().trim().equalsIgnoreCase(group.getCity().trim()))) { + return false; + } + if (hasText(group.getTag()) && !containsTagIgnoreCase(lead.getTags(), group.getTag().trim())) { + return false; + } + if (group.getStatus() != null && lead.getStatus() != group.getStatus()) { + return false; + } + if (group.isRequireEmail() && !hasText(lead.getEmail())) { + return false; + } + if (group.isRequirePhone() && !hasText(lead.getPhone())) { + return false; + } + if (group.isRequireEmailConsent() && !lead.isEmailConsent()) { + return false; + } + if (group.isRequireSmsConsent() && !lead.isSmsConsent()) { + return false; + } + return true; + } + + private static boolean containsTagIgnoreCase(List tags, String tag) { + if (tags == null) { + return false; + } + for (String candidate : tags) { + if (candidate != null && candidate.trim().equalsIgnoreCase(tag)) { + return true; + } + } + return false; + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java new file mode 100644 index 0000000..41353a4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.lead; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LeadRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + Optional findFirstByEmailIgnoreCase(String email); + + Optional findFirstByPhone(String phone); + + boolean existsByEmailIgnoreCase(String email); +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java new file mode 100644 index 0000000..45b0576 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java @@ -0,0 +1,42 @@ +package pl.polskalokalnie.lead; + +import java.time.Instant; +import java.util.List; + +public record LeadResponse( + Long id, + String name, + String email, + String phone, + String city, + LeadSource source, + Long userId, + LeadStatus status, + List tags, + boolean emailConsent, + boolean smsConsent, + boolean emailOptOut, + boolean smsOptOut, + String notes, + Instant createdAt +) { + public static LeadResponse from(Lead lead) { + return new LeadResponse( + lead.getId(), + lead.getName(), + lead.getEmail(), + lead.getPhone(), + lead.getCity(), + lead.getSource(), + lead.getUserId(), + lead.getStatus(), + List.copyOf(lead.getTags()), + lead.isEmailConsent(), + lead.isSmsConsent(), + lead.isEmailOptOut(), + lead.isSmsOptOut(), + lead.getNotes(), + lead.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java new file mode 100644 index 0000000..c43add4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java @@ -0,0 +1,228 @@ +package pl.polskalokalnie.lead; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +/** + * Logika bazy leadow: listowanie z filtrami, reczne dodawanie, import listy, edycja i usuwanie. + * Deduplikacja po e-mailu i telefonie, dzieki czemu import i synchronizacja userow nie tworza + * podwojnych kontaktow. + */ +@Service +public class LeadService { + + private final LeadRepository leadRepository; + + public LeadService(LeadRepository leadRepository) { + this.leadRepository = leadRepository; + } + + public List list(LeadSource source, String search) { + String needle = search == null ? "" : search.trim().toLowerCase(Locale.ROOT); + return leadRepository.findAll().stream() + .filter(lead -> source == null || lead.getSource() == source) + .filter(lead -> needle.isEmpty() || matchesSearch(lead, needle)) + .sorted(Comparator.comparing(Lead::getCreatedAt).reversed()) + .map(LeadResponse::from) + .toList(); + } + + @Transactional + public LeadResponse create(LeadCreateRequest request) { + String email = normalizeEmail(request.email()); + String phone = normalizePhone(request.phone()); + if (email == null && phone == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj co najmniej e-mail lub telefon"); + } + if (email != null && leadRepository.findFirstByEmailIgnoreCase(email).isPresent()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Lead z tym adresem e-mail już istnieje"); + } + if (phone != null && leadRepository.findFirstByPhone(phone).isPresent()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Lead z tym numerem telefonu już istnieje"); + } + + Lead lead = new Lead(); + lead.setSource(LeadSource.MANUAL); + lead.setName(trimToNull(request.name())); + lead.setEmail(email); + lead.setPhone(phone); + lead.setCity(trimToNull(request.city())); + lead.setTags(cleanTags(request.tags())); + lead.setEmailConsent(request.emailConsent() == null || request.emailConsent()); + lead.setSmsConsent(request.smsConsent() == null || request.smsConsent()); + lead.setNotes(trimToNull(request.notes())); + return LeadResponse.from(leadRepository.save(lead)); + } + + @Transactional + public LeadResponse update(Long id, LeadUpdateRequest request) { + Lead lead = requireLead(id); + if (request.name() != null) { + lead.setName(trimToNull(request.name())); + } + if (request.email() != null) { + String email = normalizeEmail(request.email()); + if (email != null) { + leadRepository.findFirstByEmailIgnoreCase(email) + .filter(other -> !other.getId().equals(lead.getId())) + .ifPresent(other -> { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Inny lead ma już ten e-mail"); + }); + } + lead.setEmail(email); + } + if (request.phone() != null) { + lead.setPhone(normalizePhone(request.phone())); + } + if (request.city() != null) { + lead.setCity(trimToNull(request.city())); + } + if (request.status() != null) { + lead.setStatus(request.status()); + } + if (request.tags() != null) { + lead.setTags(cleanTags(request.tags())); + } + if (request.emailConsent() != null) { + lead.setEmailConsent(request.emailConsent()); + } + if (request.smsConsent() != null) { + lead.setSmsConsent(request.smsConsent()); + } + // Opt-out to trwala rezygnacja - ustawiamy wylacznie na true, nie cofamy z panelu. + if (Boolean.TRUE.equals(request.emailOptOut())) { + lead.setEmailOptOut(true); + } + if (Boolean.TRUE.equals(request.smsOptOut())) { + lead.setSmsOptOut(true); + } + if (request.notes() != null) { + lead.setNotes(trimToNull(request.notes())); + } + return LeadResponse.from(leadRepository.save(lead)); + } + + @Transactional + public void delete(Long id) { + Lead lead = requireLead(id); + leadRepository.delete(lead); + } + + @Transactional + public ImportResultResponse importCsv(LeadImportRequest request) { + List messages = new ArrayList<>(); + int imported = 0; + int skipped = 0; + String defaultTag = trimToNull(request.defaultTag()); + + String[] lines = request.csv().replace("\r\n", "\n").replace("\r", "\n").split("\n"); + int lineNumber = 0; + for (String rawLine : lines) { + lineNumber++; + String line = rawLine.trim(); + if (line.isEmpty()) { + continue; + } + String[] parts = line.split("[;,\t]", -1); + String header = parts.length > 0 ? parts[0].trim().toLowerCase(Locale.ROOT) : ""; + // Pomijamy wiersz naglowka, jesli wyglada na etykiety kolumn. + if (lineNumber == 1 && (header.equals("name") || header.equals("imie") || header.equals("imię") + || header.equals("nazwa") || header.contains("email") || header.contains("e-mail"))) { + continue; + } + + String name = parts.length > 0 ? trimToNull(parts[0]) : null; + String email = normalizeEmail(parts.length > 1 ? parts[1] : null); + String phone = normalizePhone(parts.length > 2 ? parts[2] : null); + String city = parts.length > 3 ? trimToNull(parts[3]) : null; + + if (email == null && phone == null) { + skipped++; + messages.add("Wiersz " + lineNumber + ": pominięto (brak e-maila i telefonu)"); + continue; + } + if (email != null && leadRepository.findFirstByEmailIgnoreCase(email).isPresent()) { + skipped++; + messages.add("Wiersz " + lineNumber + ": pominięto (e-mail już istnieje)"); + continue; + } + if (phone != null && leadRepository.findFirstByPhone(phone).isPresent()) { + skipped++; + messages.add("Wiersz " + lineNumber + ": pominięto (telefon już istnieje)"); + continue; + } + + Lead lead = new Lead(); + lead.setSource(LeadSource.IMPORT); + lead.setName(name); + lead.setEmail(email); + lead.setPhone(phone); + lead.setCity(city); + if (defaultTag != null) { + lead.setTags(new ArrayList<>(List.of(defaultTag))); + } + leadRepository.save(lead); + imported++; + } + return new ImportResultResponse(imported, skipped, messages); + } + + private Lead requireLead(Long id) { + return leadRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Lead nie istnieje")); + } + + private static boolean matchesSearch(Lead lead, String needle) { + return contains(lead.getName(), needle) + || contains(lead.getEmail(), needle) + || contains(lead.getPhone(), needle) + || contains(lead.getCity(), needle); + } + + private static boolean contains(String value, String needle) { + return value != null && value.toLowerCase(Locale.ROOT).contains(needle); + } + + private static List cleanTags(List tags) { + List cleaned = new ArrayList<>(); + if (tags != null) { + Set seen = new LinkedHashSet<>(); + for (String tag : tags) { + String trimmed = trimToNull(tag); + if (trimmed != null && seen.add(trimmed.toLowerCase(Locale.ROOT))) { + cleaned.add(trimmed); + } + } + } + return cleaned; + } + + private static String normalizeEmail(String email) { + String trimmed = trimToNull(email); + return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); + } + + private static String normalizePhone(String phone) { + if (phone == null) { + return null; + } + String cleaned = phone.replaceAll("[\\s\\-()]", "").trim(); + return cleaned.isEmpty() ? null : cleaned; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java new file mode 100644 index 0000000..6958e36 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.lead; + +/** + * Zrodlo pochodzenia leada. REGISTERED_USER jest synchronizowany z kontem uzytkownika, + * pozostale powstaja recznie, przez import listy lub z formularzy na stronie. + */ +public enum LeadSource { + REGISTERED_USER, + MANUAL, + IMPORT, + FORM +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java new file mode 100644 index 0000000..cc9c57d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.lead; + +/** + * Status kontaktu w lejku marketingowym. RESPONDED oznacza, ze lead odpowiedzial - takie + * kontakty mozna wykluczyc z kolejnych wysylek. + */ +public enum LeadStatus { + NEW, + CONTACTED, + RESPONDED, + UNSUBSCRIBED +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java new file mode 100644 index 0000000..6e208e1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java @@ -0,0 +1,63 @@ +package pl.polskalokalnie.lead; + +import java.util.Locale; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.polskalokalnie.user.AppUser; + +/** + * Utrzymuje leady typu REGISTERED_USER w zgodzie z kontami uzytkownikow. Wolane przy rejestracji + * i aktualizacji profilu oraz przez backfill przy starcie aplikacji. Zgody marketingowe (mail/SMS) + * uzytkownika przechowujemy na jego leadzie - to jedno zrodlo prawdy dla kwalifikacji do kampanii. + */ +@Service +public class LeadSyncService { + + private final LeadRepository leadRepository; + + public LeadSyncService(LeadRepository leadRepository) { + this.leadRepository = leadRepository; + } + + @Transactional + public Lead syncUser(AppUser user) { + if (user == null || user.getId() == null) { + return null; + } + Lead lead = leadRepository.findByUserId(user.getId()) + .orElseGet(() -> leadRepository.findFirstByEmailIgnoreCase(user.getEmail()).orElseGet(Lead::new)); + + lead.setSource(LeadSource.REGISTERED_USER); + lead.setUserId(user.getId()); + lead.setName(user.getFullName()); + lead.setEmail(user.getEmail() == null ? null : user.getEmail().toLowerCase(Locale.ROOT)); + lead.setPhone(normalizePhone(user.getPhone())); + return leadRepository.save(lead); + } + + /** + * Zwraca lead powiazany z uzytkownikiem, tworzac go w razie potrzeby - uzywane przez ekran + * zgod marketingowych uzytkownika. + */ + @Transactional + public Lead getOrCreateForUser(AppUser user) { + Lead lead = leadRepository.findByUserId(user.getId()).orElse(null); + return lead != null ? lead : syncUser(user); + } + + @Transactional + public Lead updateConsentForUser(AppUser user, boolean emailConsent, boolean smsConsent) { + Lead lead = getOrCreateForUser(user); + lead.setEmailConsent(emailConsent); + lead.setSmsConsent(smsConsent); + return leadRepository.save(lead); + } + + private static String normalizePhone(String phone) { + if (phone == null) { + return null; + } + String cleaned = phone.replaceAll("[\\s\\-()]", "").trim(); + return cleaned.isEmpty() ? null : cleaned; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java new file mode 100644 index 0000000..2ade1ff --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java @@ -0,0 +1,22 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Aktualizacja leada z panelu. Pola null pozostawiaja dotychczasowa wartosc; kolekcja tagow i + * flagi zgod, gdy podane, nadpisuja stan. Opt-out ustawiamy tylko na true (trwala rezygnacja). + */ +public record LeadUpdateRequest( + String name, + String email, + String phone, + String city, + LeadStatus status, + List tags, + Boolean emailConsent, + Boolean smsConsent, + Boolean emailOptOut, + Boolean smsOptOut, + String notes +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java b/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java new file mode 100644 index 0000000..7bc69fc --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java @@ -0,0 +1,74 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; + +/** + * Zgody marketingowe zalogowanego uzytkownika. Zapisywane na leadzie powiazanym z kontem, dzieki + * czemu realnie steruja kwalifikacja uzytkownika jako odbiorcy kampanii. Endpoint wymaga + * uwierzytelnienia (regula anyRequest().authenticated() w SecurityConfig). + */ +@RestController +@RequestMapping("/api/me/marketing") +public class MarketingConsentController { + + private final UserRepository userRepository; + private final LeadSyncService leadSyncService; + + public MarketingConsentController(UserRepository userRepository, LeadSyncService leadSyncService) { + this.userRepository = userRepository; + this.leadSyncService = leadSyncService; + } + + @GetMapping + public MarketingConsentResponse get(Authentication authentication) { + AppUser user = currentUser(authentication); + Lead lead = leadSyncService.getOrCreateForUser(user); + return MarketingConsentResponse.from(lead); + } + + @PutMapping + public MarketingConsentResponse update(Authentication authentication, @RequestBody MarketingConsentRequest request) { + AppUser user = currentUser(authentication); + Lead lead = leadSyncService.updateConsentForUser(user, + Boolean.TRUE.equals(request.emailConsent()), + Boolean.TRUE.equals(request.smsConsent())); + return MarketingConsentResponse.from(lead); + } + + private AppUser currentUser(Authentication authentication) { + return userRepository.findByEmailIgnoreCase(authentication.getName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); + } + + public record MarketingConsentRequest( + @NotNull Boolean emailConsent, + @NotNull Boolean smsConsent + ) { + } + + public record MarketingConsentResponse( + boolean emailConsent, + boolean smsConsent, + boolean emailOptOut, + boolean smsOptOut + ) { + static MarketingConsentResponse from(Lead lead) { + return new MarketingConsentResponse( + lead.isEmailConsent(), + lead.isSmsConsent(), + lead.isEmailOptOut(), + lead.isSmsOptOut() + ); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java new file mode 100644 index 0000000..3058aab --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java @@ -0,0 +1,165 @@ +package pl.polskalokalnie.lead; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Grupa docelowa jako dynamiczny segment - przechowuje zestaw kryteriow filtra, a nie zamrozona + * liste leadow. Czlonkowie wyliczaja sie na biezaco w {@link LeadQueryService} przy podgladzie + * i przy starcie kampanii. Puste kryterium oznacza brak ograniczenia dla danego pola. + */ +@Entity +@Table(name = "target_groups") +public class TargetGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 140) + private String name; + + @Column(length = 400) + private String description; + + // EAGER: zbior zrodel jest maly i zawsze uzywany przy wyliczaniu segmentu oraz w DTO + // (mapowanie poza sesja - open-in-view=false). + @ElementCollection(fetch = jakarta.persistence.FetchType.EAGER) + @CollectionTable(name = "target_group_sources", joinColumns = @JoinColumn(name = "group_id")) + @Column(name = "source", length = 20) + @Enumerated(EnumType.STRING) + private Set sources = new LinkedHashSet<>(); + + @Column(length = 120) + private String city; + + @Column(length = 60) + private String tag; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private LeadStatus status; + + @Column(nullable = false) + private boolean requireEmail = false; + + @Column(nullable = false) + private boolean requirePhone = false; + + @Column(nullable = false) + private boolean requireEmailConsent = false; + + @Column(nullable = false) + private boolean requireSmsConsent = false; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Set getSources() { + return sources; + } + + public void setSources(Set sources) { + this.sources = sources; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getTag() { + return tag; + } + + public void setTag(String tag) { + this.tag = tag; + } + + public LeadStatus getStatus() { + return status; + } + + public void setStatus(LeadStatus status) { + this.status = status; + } + + public boolean isRequireEmail() { + return requireEmail; + } + + public void setRequireEmail(boolean requireEmail) { + this.requireEmail = requireEmail; + } + + public boolean isRequirePhone() { + return requirePhone; + } + + public void setRequirePhone(boolean requirePhone) { + this.requirePhone = requirePhone; + } + + public boolean isRequireEmailConsent() { + return requireEmailConsent; + } + + public void setRequireEmailConsent(boolean requireEmailConsent) { + this.requireEmailConsent = requireEmailConsent; + } + + public boolean isRequireSmsConsent() { + return requireSmsConsent; + } + + public void setRequireSmsConsent(boolean requireSmsConsent) { + this.requireSmsConsent = requireSmsConsent; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java new file mode 100644 index 0000000..9e77d26 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java @@ -0,0 +1,56 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Grupy docelowe (dynamiczne segmenty) w panelu administratora. + */ +@RestController +@RequestMapping("/api/admin/target-groups") +public class TargetGroupController { + + private final TargetGroupService targetGroupService; + + public TargetGroupController(TargetGroupService targetGroupService) { + this.targetGroupService = targetGroupService; + } + + @GetMapping + public List list() { + return targetGroupService.list(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public TargetGroupResponse create(@Valid @RequestBody TargetGroupRequest request) { + return targetGroupService.create(request); + } + + @PutMapping("/{id}") + public TargetGroupResponse update(@PathVariable Long id, @Valid @RequestBody TargetGroupRequest request) { + return targetGroupService.update(id, request); + } + + @GetMapping("/{id}/preview") + public TargetGroupPreviewResponse preview(@PathVariable Long id) { + return targetGroupService.preview(id); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + targetGroupService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java new file mode 100644 index 0000000..2372d85 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.lead; + +import java.util.List; + +/** + * Podglad segmentu: laczna liczba dopasowanych leadow oraz krotka probka do wyswietlenia w panelu. + */ +public record TargetGroupPreviewResponse( + long total, + List sample +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java new file mode 100644 index 0000000..79d1e88 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.lead; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TargetGroupRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java new file mode 100644 index 0000000..25951c2 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java @@ -0,0 +1,18 @@ +package pl.polskalokalnie.lead; + +import jakarta.validation.constraints.NotBlank; +import java.util.Set; + +public record TargetGroupRequest( + @NotBlank String name, + String description, + Set sources, + String city, + String tag, + LeadStatus status, + boolean requireEmail, + boolean requirePhone, + boolean requireEmailConsent, + boolean requireSmsConsent +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java new file mode 100644 index 0000000..1b8917d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java @@ -0,0 +1,39 @@ +package pl.polskalokalnie.lead; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; + +public record TargetGroupResponse( + Long id, + String name, + String description, + Set sources, + String city, + String tag, + LeadStatus status, + boolean requireEmail, + boolean requirePhone, + boolean requireEmailConsent, + boolean requireSmsConsent, + long memberCount, + Instant createdAt +) { + public static TargetGroupResponse from(TargetGroup group, long memberCount) { + return new TargetGroupResponse( + group.getId(), + group.getName(), + group.getDescription(), + new LinkedHashSet<>(group.getSources()), + group.getCity(), + group.getTag(), + group.getStatus(), + group.isRequireEmail(), + group.isRequirePhone(), + group.isRequireEmailConsent(), + group.isRequireSmsConsent(), + memberCount, + group.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java new file mode 100644 index 0000000..72c9662 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java @@ -0,0 +1,88 @@ +package pl.polskalokalnie.lead; + +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class TargetGroupService { + + private static final int PREVIEW_SAMPLE_SIZE = 20; + + private final TargetGroupRepository targetGroupRepository; + private final LeadQueryService leadQueryService; + + public TargetGroupService(TargetGroupRepository targetGroupRepository, LeadQueryService leadQueryService) { + this.targetGroupRepository = targetGroupRepository; + this.leadQueryService = leadQueryService; + } + + public List list() { + return targetGroupRepository.findAll().stream() + .sorted(Comparator.comparing(TargetGroup::getCreatedAt).reversed()) + .map(group -> TargetGroupResponse.from(group, leadQueryService.count(group))) + .toList(); + } + + @Transactional + public TargetGroupResponse create(TargetGroupRequest request) { + TargetGroup group = new TargetGroup(); + apply(group, request); + TargetGroup saved = targetGroupRepository.save(group); + return TargetGroupResponse.from(saved, leadQueryService.count(saved)); + } + + @Transactional + public TargetGroupResponse update(Long id, TargetGroupRequest request) { + TargetGroup group = requireGroup(id); + apply(group, request); + TargetGroup saved = targetGroupRepository.save(group); + return TargetGroupResponse.from(saved, leadQueryService.count(saved)); + } + + @Transactional + public void delete(Long id) { + TargetGroup group = requireGroup(id); + targetGroupRepository.delete(group); + } + + public TargetGroupPreviewResponse preview(Long id) { + TargetGroup group = requireGroup(id); + List members = leadQueryService.resolve(group); + List sample = members.stream() + .limit(PREVIEW_SAMPLE_SIZE) + .map(LeadResponse::from) + .toList(); + return new TargetGroupPreviewResponse(members.size(), sample); + } + + TargetGroup requireGroup(Long id) { + return targetGroupRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Grupa docelowa nie istnieje")); + } + + private void apply(TargetGroup group, TargetGroupRequest request) { + group.setName(request.name().trim()); + group.setDescription(trimToNull(request.description())); + group.setSources(request.sources() == null ? new LinkedHashSet<>() : new LinkedHashSet<>(request.sources())); + group.setCity(trimToNull(request.city())); + group.setTag(trimToNull(request.tag())); + group.setStatus(request.status()); + group.setRequireEmail(request.requireEmail()); + group.setRequirePhone(request.requirePhone()); + group.setRequireEmailConsent(request.requireEmailConsent()); + group.setRequireSmsConsent(request.requireSmsConsent()); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java index 26c8a4e..020ca46 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -5,7 +5,9 @@ import java.net.URI; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -55,4 +57,20 @@ public class ListingController { .created(URI.create("/api/listings/" + response.id())) .body(response); } + + // Wstrzymanie/wznowienie wlasnego ogloszenia (status=PAUSED lub APPROVED). + @PatchMapping("/{id}/status") + public ListingResponse updateStatus( + @PathVariable Long id, + @RequestParam ListingStatus status, + Authentication authentication + ) { + return listingService.setOwnStatus(id, authentication.getName(), status); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, Authentication authentication) { + listingService.deleteOwn(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java index 7b30a4d..5da8292 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java @@ -26,6 +26,7 @@ public record ListingResponse( String coverPhoto, String ownerEmail, ListingStatus status, + Long viewsCount, Instant createdAt ) { public static ListingResponse from(PropertyListing listing) { @@ -48,6 +49,7 @@ public record ListingResponse( listing.getCoverPhoto(), listing.getOwnerEmail(), listing.getStatus(), + listing.getViewsCount(), listing.getCreatedAt() ); } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index a8c740f..25b58ee 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.notification.NotificationService; @Service public class ListingService { @@ -18,10 +19,13 @@ public class ListingService { private final ListingRepository listingRepository; private final TextModerationService textModerationService; + private final NotificationService notificationService; - public ListingService(ListingRepository listingRepository, TextModerationService textModerationService) { + public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, + NotificationService notificationService) { this.listingRepository = listingRepository; this.textModerationService = textModerationService; + this.notificationService = notificationService; } public List search(String city, OfferType offerType, PropertyType propertyType) { @@ -56,6 +60,39 @@ public class ListingService { .toList(); } + // --- Operacje wlasciciela na wlasnym ogloszeniu --- + + // Wstrzymanie/wznowienie widocznosci wlasnego ogloszenia (tylko miedzy APPROVED i PAUSED). + @Transactional + public ListingResponse setOwnStatus(Long id, String ownerEmail, ListingStatus status) { + if (status != ListingStatus.APPROVED && status != ListingStatus.PAUSED) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieobslugiwana zmiana statusu"); + } + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + requireOwner(listing, ownerEmail); + if (listing.getStatus() != ListingStatus.APPROVED && listing.getStatus() != ListingStatus.PAUSED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic"); + } + listing.setStatus(status); + return ListingResponse.from(listingRepository.save(listing)); + } + + @Transactional + public void deleteOwn(Long id, String ownerEmail) { + PropertyListing listing = listingRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); + requireOwner(listing, ownerEmail); + listingRepository.delete(listing); + } + + private void requireOwner(PropertyListing listing, String ownerEmail) { + if (ownerEmail == null || listing.getOwnerEmail() == null + || !ownerEmail.equalsIgnoreCase(listing.getOwnerEmail())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak uprawnien do tego ogloszenia"); + } + } + @Transactional public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) { textModerationService.validateOrThrow( @@ -123,7 +160,32 @@ public class ListingService { PropertyListing listing = listingRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); listing.setStatus(status); - return ListingResponse.from(listingRepository.save(listing)); + PropertyListing saved = listingRepository.save(listing); + notifyOwnerStatusChange(saved, status); + return ListingResponse.from(saved); + } + + // Powiadomienie dla wlasciciela po decyzji moderacji (approve/reject). + private void notifyOwnerStatusChange(PropertyListing listing, ListingStatus status) { + if (listing.getOwnerEmail() == null) { + return; + } + String title; + String body; + String icon; + if (status == ListingStatus.APPROVED) { + title = "Ogłoszenie opublikowane"; + body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało zatwierdzone i jest już widoczne w serwisie."; + icon = "check"; + } else if (status == ListingStatus.REJECTED) { + title = "Ogłoszenie odrzucone"; + body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało odrzucone przez moderację."; + icon = "warning"; + } else { + return; + } + notificationService.create(listing.getOwnerEmail(), "listing", icon, title, body, + "listing:" + listing.getId(), "status-" + listing.getId() + "-" + status); } public void delete(Long id) { diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java index 8a0c0d5..5e03db3 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingStatus.java @@ -3,5 +3,6 @@ package pl.polskalokalnie.listing; public enum ListingStatus { PENDING, APPROVED, - REJECTED + REJECTED, + PAUSED } diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java new file mode 100644 index 0000000..7c85161 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java @@ -0,0 +1,80 @@ +package pl.polskalokalnie.mailconfig; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import pl.polskalokalnie.send.EmailSender; +import pl.polskalokalnie.send.SendResult; +import pl.polskalokalnie.send.SmsSender; + +/** + * Konfiguracja poczty SMTP i bramki SMS w panelu administratora oraz kontrolowana wysylka testowa + * do pojedynczego odbiorcy. Sekrety nigdy nie sa zwracane - w odpowiedzi jedynie flaga "ustawione". + */ +@RestController +@RequestMapping("/api/admin/mail-config") +public class MailConfigController { + + private final MailConfigService mailConfigService; + private final EmailSender emailSender; + private final SmsSender smsSender; + + public MailConfigController(MailConfigService mailConfigService, EmailSender emailSender, SmsSender smsSender) { + this.mailConfigService = mailConfigService; + this.emailSender = emailSender; + this.smsSender = smsSender; + } + + @GetMapping("/smtp") + public SmtpConfigResponse getSmtp() { + return SmtpConfigResponse.from(mailConfigService.getSmtp()); + } + + @PutMapping("/smtp") + public SmtpConfigResponse updateSmtp(@RequestBody SmtpConfigRequest request) { + return SmtpConfigResponse.from(mailConfigService.updateSmtp(request)); + } + + @GetMapping("/sms") + public SmsConfigResponse getSms() { + return SmsConfigResponse.from(mailConfigService.getSms()); + } + + @PutMapping("/sms") + public SmsConfigResponse updateSms(@RequestBody SmsConfigRequest request) { + return SmsConfigResponse.from(mailConfigService.updateSms(request)); + } + + @PostMapping("/smtp/test") + public TestResultResponse testSmtp(@Valid @RequestBody TestSendRequest request) { + SendResult result = emailSender.send( + mailConfigService.getSmtp(), + request.recipient().trim(), + "Test konfiguracji SMTP – Polska Lokalnie", + "

To jest wiadomość testowa potwierdzająca poprawną konfigurację poczty SMTP.

"); + return TestResultResponse.from(result); + } + + @PostMapping("/sms/test") + public TestResultResponse testSms(@Valid @RequestBody TestSendRequest request) { + SendResult result = smsSender.send( + mailConfigService.getSms(), + request.recipient().trim(), + "Test konfiguracji bramki SMS – Polska Lokalnie"); + return TestResultResponse.from(result); + } + + public record TestSendRequest(@NotBlank String recipient) { + } + + public record TestResultResponse(boolean ok, String providerMessageId, String error) { + static TestResultResponse from(SendResult result) { + return new TestResultResponse(result.ok(), result.providerMessageId(), result.error()); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java new file mode 100644 index 0000000..baa314a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java @@ -0,0 +1,91 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Dostep do jednorekordowej konfiguracji SMTP i bramki SMS. Sekrety (haslo, klucz API) sa + * nadpisywane tylko przy jawnej zmianie; puste pole w zadaniu zachowuje dotychczasowa wartosc. + * Metody get* zwracaja encje z sekretami wylacznie na uzytek wewnetrzny (sendery) - nie sa + * serializowane do klienta. + */ +@Service +public class MailConfigService { + + private static final long SINGLETON_ID = 1L; + + private final SmtpConfigRepository smtpConfigRepository; + private final SmsGatewayConfigRepository smsGatewayConfigRepository; + + public MailConfigService(SmtpConfigRepository smtpConfigRepository, + SmsGatewayConfigRepository smsGatewayConfigRepository) { + this.smtpConfigRepository = smtpConfigRepository; + this.smsGatewayConfigRepository = smsGatewayConfigRepository; + } + + @Transactional + public SmtpConfig getSmtp() { + return smtpConfigRepository.findById(SINGLETON_ID).orElseGet(() -> { + SmtpConfig config = new SmtpConfig(); + config.setId(SINGLETON_ID); + return smtpConfigRepository.save(config); + }); + } + + @Transactional + public SmsGatewayConfig getSms() { + return smsGatewayConfigRepository.findById(SINGLETON_ID).orElseGet(() -> { + SmsGatewayConfig config = new SmsGatewayConfig(); + config.setId(SINGLETON_ID); + return smsGatewayConfigRepository.save(config); + }); + } + + @Transactional + public SmtpConfig updateSmtp(SmtpConfigRequest request) { + SmtpConfig config = getSmtp(); + config.setHost(trimToNull(request.host())); + config.setPort(request.port()); + if (request.sslEnabled() != null) { + config.setSslEnabled(request.sslEnabled()); + } + config.setUsername(trimToNull(request.username())); + config.setFromName(trimToNull(request.fromName())); + config.setContactFormRecipient(trimToNull(request.contactFormRecipient())); + if (request.enabled() != null) { + config.setEnabled(request.enabled()); + } + // Haslo zmieniamy tylko gdy podane (niepuste) - inaczej zachowujemy dotychczasowe. + String password = request.password(); + if (password != null && !password.isBlank()) { + config.setPassword(password); + } + return smtpConfigRepository.save(config); + } + + @Transactional + public SmsGatewayConfig updateSms(SmsConfigRequest request) { + SmsGatewayConfig config = getSms(); + config.setEndpointUrl(trimToNull(request.endpointUrl())); + config.setCreator(trimToNull(request.creator())); + if (request.timeoutSeconds() != null) { + config.setTimeoutSeconds(request.timeoutSeconds()); + } + if (request.enabled() != null) { + config.setEnabled(request.enabled()); + } + String apiKey = request.apiKey(); + if (apiKey != null && !apiKey.isBlank()) { + config.setApiKey(apiKey); + } + return smsGatewayConfigRepository.save(config); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java new file mode 100644 index 0000000..7b76afa --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Zapis konfiguracji bramki SMS. Pole {@code apiKey} nadpisuje klucz tylko gdy niepuste. + */ +public record SmsConfigRequest( + String endpointUrl, + String apiKey, + String creator, + Integer timeoutSeconds, + Boolean enabled +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java new file mode 100644 index 0000000..de6dafb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java @@ -0,0 +1,23 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Odpowiedz konfiguracji bramki SMS - bez klucza API. {@code apiKeySet} sygnalizuje jedynie, czy + * klucz jest zapisany (badge "ustawione"). + */ +public record SmsConfigResponse( + String endpointUrl, + String creator, + Integer timeoutSeconds, + boolean enabled, + boolean apiKeySet +) { + public static SmsConfigResponse from(SmsGatewayConfig config) { + return new SmsConfigResponse( + config.getEndpointUrl(), + config.getCreator(), + config.getTimeoutSeconds(), + config.isEnabled(), + config.getApiKey() != null && !config.getApiKey().isBlank() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java new file mode 100644 index 0000000..1357b88 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java @@ -0,0 +1,81 @@ +package pl.polskalokalnie.mailconfig; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Konfiguracja bramki SMS (SoftSPM) - pojedynczy rekord (id = 1). Klucz API trzymany w bazie, + * nigdy zwracany w odpowiedziach API (maskowany) ani logowany. + */ +@Entity +@Table(name = "sms_gateway_config") +public class SmsGatewayConfig { + + @Id + private Long id = 1L; + + @Column(length = 400) + private String endpointUrl; + + @Column(length = 400) + private String apiKey; + + // Pole "creator" bramki - znacznik zrodla wiadomosci, max 50 znakow. + @Column(length = 50) + private String creator; + + private Integer timeoutSeconds = 10; + + @Column(nullable = false) + private boolean enabled = false; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEndpointUrl() { + return endpointUrl; + } + + public void setEndpointUrl(String endpointUrl) { + this.endpointUrl = endpointUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getCreator() { + return creator; + } + + public void setCreator(String creator) { + this.creator = creator; + } + + public Integer getTimeoutSeconds() { + return timeoutSeconds; + } + + public void setTimeoutSeconds(Integer timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java new file mode 100644 index 0000000..cd2d90d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SmsGatewayConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java new file mode 100644 index 0000000..9cd39e5 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java @@ -0,0 +1,114 @@ +package pl.polskalokalnie.mailconfig; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Konfiguracja konta e-mail (SMTP) - pojedynczy rekord (id = 1). Haslo trzymane w bazie, nigdy + * zwracane w odpowiedziach API (maskowane) ani logowane. + */ +@Entity +@Table(name = "smtp_config") +public class SmtpConfig { + + @Id + private Long id = 1L; + + @Column(length = 200) + private String host; + + private Integer port; + + @Column(nullable = false) + private boolean sslEnabled = true; + + // Login SMTP = adres nadawcy. + @Column(length = 200) + private String username; + + @Column(length = 160) + private String fromName; + + @Column(length = 400) + private String password; + + @Column(length = 200) + private String contactFormRecipient; + + @Column(nullable = false) + private boolean enabled = false; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public Integer getPort() { + return port; + } + + public void setPort(Integer port) { + this.port = port; + } + + public boolean isSslEnabled() { + return sslEnabled; + } + + public void setSslEnabled(boolean sslEnabled) { + this.sslEnabled = sslEnabled; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFromName() { + return fromName; + } + + public void setFromName(String fromName) { + this.fromName = fromName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getContactFormRecipient() { + return contactFormRecipient; + } + + public void setContactFormRecipient(String contactFormRecipient) { + this.contactFormRecipient = contactFormRecipient; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java new file mode 100644 index 0000000..bbaa406 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SmtpConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java new file mode 100644 index 0000000..c15cc26 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java @@ -0,0 +1,17 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Zapis konfiguracji SMTP. Pole {@code password} nadpisuje haslo tylko gdy niepuste - puste + * pozostawia dotychczasowe (wzorzec "wpisz, aby zmienic"). + */ +public record SmtpConfigRequest( + String host, + Integer port, + Boolean sslEnabled, + String username, + String fromName, + String password, + String contactFormRecipient, + Boolean enabled +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java new file mode 100644 index 0000000..5cd2da4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java @@ -0,0 +1,29 @@ +package pl.polskalokalnie.mailconfig; + +/** + * Odpowiedz konfiguracji SMTP - bez hasla. {@code passwordSet} mowi tylko, czy haslo jest + * zapisane (badge "ustawione"), sama wartosc nigdy nie opuszcza serwera. + */ +public record SmtpConfigResponse( + String host, + Integer port, + boolean sslEnabled, + String username, + String fromName, + String contactFormRecipient, + boolean enabled, + boolean passwordSet +) { + public static SmtpConfigResponse from(SmtpConfig config) { + return new SmtpConfigResponse( + config.getHost(), + config.getPort(), + config.isSslEnabled(), + config.getUsername(), + config.getFromName(), + config.getContactFormRecipient(), + config.isEnabled(), + config.getPassword() != null && !config.getPassword().isBlank() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java index 1faefe8..f3de50c 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.moderation.TextModerationService; +import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.Role; import pl.polskalokalnie.user.UserRepository; @@ -15,15 +16,18 @@ public class MessageService { private final MessageRepository messageRepository; private final UserRepository userRepository; private final TextModerationService textModerationService; + private final NotificationService notificationService; public MessageService( MessageRepository messageRepository, UserRepository userRepository, - TextModerationService textModerationService + TextModerationService textModerationService, + NotificationService notificationService ) { this.messageRepository = messageRepository; this.userRepository = userRepository; this.textModerationService = textModerationService; + this.notificationService = notificationService; } public AppUser resolveAdmin() { @@ -46,9 +50,38 @@ public class MessageService { message.setRecipientId(recipientId); message.setContent(content.trim()); Message saved = messageRepository.save(message); + + notifyRecipient(senderId, recipientId, saved.getContent()); + return MessageResponse.from(saved, senderId); } + // Powiadomienie dla odbiorcy kazdej wiadomosci: admin->user ("Wiadomosc od obslugi"), + // user->admin traktujemy jako zapytanie/wiadomosc do obslugi. + private void notifyRecipient(Long senderId, Long recipientId, String content) { + AppUser recipient = userRepository.findById(recipientId).orElse(null); + if (recipient == null) { + return; + } + AppUser sender = userRepository.findById(senderId).orElse(null); + String senderName = sender != null && sender.getFullName() != null && !sender.getFullName().isBlank() + ? sender.getFullName() + : "użytkownika"; + String preview = content.length() > 90 ? content.substring(0, 90) + "…" : content; + + if (recipient.getRole() == Role.ADMIN) { + notificationService.create(recipient.getEmail(), "messages", "message", + "Nowa wiadomość od użytkownika", + senderName + ": " + preview, + "messages", null); + } else { + notificationService.create(recipient.getEmail(), "messages", "mail", + "Wiadomość od obsługi Mieszko", + preview, + "messages:admin", null); + } + } + public long countUnreadFrom(Long recipientId, Long senderId) { return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId); } diff --git a/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java b/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java new file mode 100644 index 0000000..b068e9c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/CreateNotificationRequest.java @@ -0,0 +1,19 @@ +package pl.polskalokalnie.notification; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Zadanie utworzenia powiadomienia dla zalogowanego uzytkownika (zdarzenia liczone po stronie klienta: + * alerty cenowe, spotkania, ulubione, weryfikacja telefonu, dopasowane oferty). + * userEmail NIE jest przyjmowany z ciala - serwer ustawia go z tokenu. + */ +public record CreateNotificationRequest( + @NotBlank @Size(max = 20) String category, + @Size(max = 40) String icon, + @NotBlank @Size(max = 200) String title, + @Size(max = 600) String body, + @Size(max = 200) String link, + @Size(max = 160) String dedupeKey +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/Notification.java b/backend/src/main/java/pl/polskalokalnie/notification/Notification.java new file mode 100644 index 0000000..5c1c028 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/Notification.java @@ -0,0 +1,128 @@ +package pl.polskalokalnie.notification; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "notifications", indexes = { + @Index(name = "idx_notifications_user", columnList = "userEmail"), + @Index(name = "idx_notifications_user_dedupe", columnList = "userEmail,dedupeKey") +}) +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userEmail; + + // Kategoria zgodna z frontendem: listing | messages | system. + @Column(nullable = false, length = 20) + private String category; + + @Column(nullable = false, length = 40) + private String icon; + + @Column(nullable = false, length = 200) + private String title; + + @Column(length = 600) + private String body; + + // Deskryptor deep-linku, np. "listing:12", "messages:admin", "priceAlerts", "meetings". + @Column(length = 200) + private String link; + + // Klucz idempotencji - blokuje duplikaty tego samego zdarzenia (np. jeden spadek ceny). + @Column(length = 160) + private String dedupeKey; + + @Column(nullable = false) + private boolean read = false; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); + + public Long getId() { + return id; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public String getDedupeKey() { + return dedupeKey; + } + + public void setDedupeKey(String dedupeKey) { + this.dedupeKey = dedupeKey; + } + + public boolean isRead() { + return read; + } + + public void setRead(boolean read) { + this.read = read; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java new file mode 100644 index 0000000..709ae73 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationController.java @@ -0,0 +1,71 @@ +package pl.polskalokalnie.notification; + +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + public NotificationController(NotificationService notificationService) { + this.notificationService = notificationService; + } + + @GetMapping + public List list(Authentication authentication) { + return notificationService.listOwn(authentication.getName()); + } + + @GetMapping("/unread-count") + public UnreadCountResponse unreadCount(Authentication authentication) { + return new UnreadCountResponse(notificationService.unreadCount(authentication.getName())); + } + + @PostMapping("/{id}/read") + public ResponseEntity markRead(@PathVariable Long id, Authentication authentication) { + notificationService.markRead(id, authentication.getName()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/read-all") + public ResponseEntity markAllRead(Authentication authentication) { + notificationService.markAllRead(authentication.getName()); + return ResponseEntity.noContent().build(); + } + + // Zdarzenia liczone po stronie klienta (alerty cenowe, spotkania, ulubione, weryfikacja telefonu, + // dopasowane oferty). Powiadomienie zawsze trafia do zalogowanego uzytkownika (serwer ustawia odbiorce). + @PostMapping + public NotificationResponse create(@Valid @RequestBody CreateNotificationRequest request, Authentication authentication) { + Notification created = notificationService.create( + authentication.getName(), + request.category(), + request.icon(), + request.title(), + request.body(), + request.link(), + request.dedupeKey() + ); + return created == null ? null : NotificationResponse.from(created); + } + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(Authentication authentication) { + return notificationService.subscribe(authentication.getName()); + } + + public record UnreadCountResponse(long count) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java new file mode 100644 index 0000000..018a293 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationRepository.java @@ -0,0 +1,20 @@ +package pl.polskalokalnie.notification; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationRepository extends JpaRepository { + + List findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail); + + long countByUserEmailIgnoreCaseAndReadFalse(String userEmail); + + boolean existsByUserEmailIgnoreCaseAndDedupeKey(String userEmail, String dedupeKey); + + @Modifying + @Query("update Notification n set n.read = true where lower(n.userEmail) = lower(:email) and n.read = false") + int markAllRead(@Param("email") String email); +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java new file mode 100644 index 0000000..03cefc6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationResponse.java @@ -0,0 +1,27 @@ +package pl.polskalokalnie.notification; + +import java.time.Instant; + +public record NotificationResponse( + Long id, + String category, + String icon, + String title, + String body, + String link, + boolean read, + Instant createdAt +) { + public static NotificationResponse from(Notification n) { + return new NotificationResponse( + n.getId(), + n.getCategory(), + n.getIcon(), + n.getTitle(), + n.getBody(), + n.getLink(), + n.isRead(), + n.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java b/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java new file mode 100644 index 0000000..87615b6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/notification/NotificationService.java @@ -0,0 +1,143 @@ +package pl.polskalokalnie.notification; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Centralny serwis powiadomien. Zapisuje powiadomienia per uzytkownik i wypycha je w czasie + * rzeczywistym przez SSE. Inne moduly (wiadomosci, ogloszenia, administracja) wolaja {@link #create}. + */ +@Service +public class NotificationService { + + private static final long SSE_TIMEOUT_MS = 30L * 60L * 1000L; + + private final NotificationRepository repository; + + // email uzytkownika -> otwarte polaczenia SSE (moze byc kilka kart/urzadzen). + private final Map> emitters = new ConcurrentHashMap<>(); + + public NotificationService(NotificationRepository repository) { + this.repository = repository; + } + + @Transactional + public Notification create(String userEmail, String category, String icon, String title, + String body, String link, String dedupeKey) { + if (userEmail == null || userEmail.isBlank() || title == null || title.isBlank()) { + return null; + } + if (dedupeKey != null && !dedupeKey.isBlank() + && repository.existsByUserEmailIgnoreCaseAndDedupeKey(userEmail, dedupeKey)) { + return null; + } + Notification notification = new Notification(); + notification.setUserEmail(userEmail); + notification.setCategory(normalizeCategory(category)); + notification.setIcon(icon == null || icon.isBlank() ? defaultIcon(category) : icon); + notification.setTitle(title.trim()); + notification.setBody(body == null ? null : body.trim()); + notification.setLink(link == null || link.isBlank() ? null : link.trim()); + notification.setDedupeKey(dedupeKey == null || dedupeKey.isBlank() ? null : dedupeKey.trim()); + + Notification saved = repository.save(notification); + push(userEmail, NotificationResponse.from(saved)); + return saved; + } + + public List listOwn(String email) { + return repository.findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(email).stream() + .map(NotificationResponse::from) + .toList(); + } + + public long unreadCount(String email) { + return repository.countByUserEmailIgnoreCaseAndReadFalse(email); + } + + @Transactional + public void markRead(Long id, String email) { + Notification notification = repository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Powiadomienie nie istnieje")); + if (email == null || !email.equalsIgnoreCase(notification.getUserEmail())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do tego powiadomienia"); + } + if (!notification.isRead()) { + notification.setRead(true); + repository.save(notification); + } + } + + @Transactional + public void markAllRead(String email) { + repository.markAllRead(email); + } + + // --- SSE --- + + public SseEmitter subscribe(String email) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + CopyOnWriteArrayList list = emitters.computeIfAbsent(email, key -> new CopyOnWriteArrayList<>()); + list.add(emitter); + + emitter.onCompletion(() -> remove(email, emitter)); + emitter.onTimeout(() -> remove(email, emitter)); + emitter.onError(error -> remove(email, emitter)); + + try { + emitter.send(SseEmitter.event().name("ready").data("ok")); + } catch (IOException ex) { + remove(email, emitter); + } + return emitter; + } + + private void push(String email, NotificationResponse payload) { + CopyOnWriteArrayList list = emitters.get(email); + if (list == null) { + return; + } + for (SseEmitter emitter : list) { + try { + emitter.send(SseEmitter.event().name("notification").data(payload)); + } catch (Exception ex) { + remove(email, emitter); + } + } + } + + private void remove(String email, SseEmitter emitter) { + CopyOnWriteArrayList list = emitters.get(email); + if (list != null) { + list.remove(emitter); + if (list.isEmpty()) { + emitters.remove(email, list); + } + } + } + + private String normalizeCategory(String category) { + if ("listing".equals(category) || "messages".equals(category) || "system".equals(category)) { + return category; + } + return "system"; + } + + private String defaultIcon(String category) { + if ("messages".equals(category)) { + return "message"; + } + if ("listing".equals(category)) { + return "house"; + } + return "bell"; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java b/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java new file mode 100644 index 0000000..26d1caa --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java @@ -0,0 +1,140 @@ +package pl.polskalokalnie.send; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Limit; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.campaign.Campaign; +import pl.polskalokalnie.campaign.CampaignRecipient; +import pl.polskalokalnie.campaign.CampaignRecipientRepository; +import pl.polskalokalnie.campaign.CampaignRepository; +import pl.polskalokalnie.campaign.CampaignStatus; +import pl.polskalokalnie.campaign.Channel; +import pl.polskalokalnie.campaign.RecipientStatus; +import pl.polskalokalnie.mailconfig.MailConfigService; +import pl.polskalokalnie.mailconfig.SmsGatewayConfig; +import pl.polskalokalnie.mailconfig.SmtpConfig; + +/** + * Worker wysylajacy kampanie z outboxu. Cyklicznie promuje zaplanowane kampanie, ktorych termin + * nadszedl, a nastepnie wysyla zakolejkowane wiadomosci respektujac dzienny limit. Wysylka + * odbywa sie tylko gdy odpowiednia konfiguracja (SMTP/SMS) jest wlaczona - w przeciwnym razie + * pozycje pozostaja w kolejce (tryb sandbox). Jeden przebieg przetwarza ograniczona partie, by + * nie blokowac watku schedulera. + */ +@Component +public class CampaignDispatcher { + + private static final int BATCH_PER_TICK = 25; + + private final CampaignRepository campaignRepository; + private final CampaignRecipientRepository recipientRepository; + private final MailConfigService mailConfigService; + private final EmailSender emailSender; + private final SmsSender smsSender; + private final MessageComposer messageComposer; + + public CampaignDispatcher(CampaignRepository campaignRepository, + CampaignRecipientRepository recipientRepository, + MailConfigService mailConfigService, + EmailSender emailSender, + SmsSender smsSender, + MessageComposer messageComposer) { + this.campaignRepository = campaignRepository; + this.recipientRepository = recipientRepository; + this.mailConfigService = mailConfigService; + this.emailSender = emailSender; + this.smsSender = smsSender; + this.messageComposer = messageComposer; + } + + @Scheduled(fixedDelayString = "${app.campaign.dispatch-interval-ms:15000}", initialDelay = 20000) + public void dispatch() { + promoteScheduled(); + for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) { + processCampaign(campaign); + } + } + + private void promoteScheduled() { + Instant now = Instant.now(); + for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SCHEDULED)) { + if (campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now)) { + campaign.setStatus(CampaignStatus.SENDING); + campaignRepository.save(campaign); + } + } + } + + private void processCampaign(Campaign campaign) { + // Reset dziennego licznika przy zmianie daty. + LocalDate today = LocalDate.now(); + if (!today.equals(campaign.getSentTodayDate())) { + campaign.setSentToday(0); + campaign.setSentTodayDate(today); + campaignRepository.save(campaign); + } + + int dailyLimit = campaign.getDailyLimit() != null ? campaign.getDailyLimit() : Integer.MAX_VALUE; + int remainingToday = dailyLimit - (campaign.getSentToday() != null ? campaign.getSentToday() : 0); + if (remainingToday <= 0) { + return; + } + + // Konfiguracja wlaczona? Jesli nie - tryb sandbox, nic nie wychodzi (pozycje zostaja w kolejce). + SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null; + SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null; + if (campaign.getChannel() == Channel.EMAIL && (smtp == null || !smtp.isEnabled())) { + return; + } + if (campaign.getChannel() == Channel.SMS && (sms == null || !sms.isEnabled())) { + return; + } + + int batch = Math.min(BATCH_PER_TICK, remainingToday); + List queued = recipientRepository + .findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(batch)); + + int sentNow = 0; + for (CampaignRecipient recipient : queued) { + SendResult result = campaign.getChannel() == Channel.EMAIL + ? sendEmail(campaign, recipient, smtp) + : sendSms(campaign, recipient, sms); + + recipient.setSentAt(Instant.now()); + if (result.ok()) { + recipient.setStatus(RecipientStatus.SENT); + recipient.setProviderMessageId(result.providerMessageId()); + recipient.setError(null); + sentNow++; + } else { + recipient.setStatus(RecipientStatus.FAILED); + recipient.setError(result.error()); + } + recipientRepository.save(recipient); + } + + if (sentNow > 0) { + campaign.setSentToday((campaign.getSentToday() != null ? campaign.getSentToday() : 0) + sentNow); + campaignRepository.save(campaign); + } + + // Zakonczenie kampanii, gdy nie ma juz nic w kolejce. + if (recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.QUEUED) == 0) { + campaign.setStatus(CampaignStatus.COMPLETED); + campaignRepository.save(campaign); + } + } + + private SendResult sendEmail(Campaign campaign, CampaignRecipient recipient, SmtpConfig smtp) { + String html = messageComposer.composeEmail(campaign.getBody(), recipient.getName(), recipient.getLeadId()); + return emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), html); + } + + private SendResult sendSms(Campaign campaign, CampaignRecipient recipient, SmsGatewayConfig sms) { + String text = messageComposer.composeSms(campaign.getBody(), recipient.getName(), recipient.getLeadId()); + return smsSender.send(sms, recipient.getAddress(), text); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java new file mode 100644 index 0000000..d1c9759 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java @@ -0,0 +1,83 @@ +package pl.polskalokalnie.send; + +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.mailconfig.SmtpConfig; + +/** + * Wysylka pojedynczego e-maila przez konto SMTP z konfiguracji. JavaMailSender budowany jest per + * wywolanie, bo konfiguracja moze zmienic sie w czasie dzialania aplikacji (niski wolumen). + * Dla portu 465 wlacza SSL, w przeciwnym razie STARTTLS. + */ +@Component +public class EmailSender { + + public SendResult send(SmtpConfig config, String to, String subject, String htmlBody) { + if (config == null || config.getHost() == null || config.getHost().isBlank() + || config.getUsername() == null || config.getUsername().isBlank()) { + return SendResult.failure("Konfiguracja SMTP jest niekompletna"); + } + try { + JavaMailSenderImpl mailSender = buildMailSender(config); + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name()); + helper.setFrom(fromAddress(config)); + helper.setTo(to); + helper.setSubject(subject == null ? "" : subject); + helper.setText(htmlBody, true); + mailSender.send(message); + String messageId = message.getMessageID(); + return SendResult.ok(messageId); + } catch (Exception ex) { + return SendResult.failure(shorten(ex.getMessage())); + } + } + + private JavaMailSenderImpl buildMailSender(SmtpConfig config) { + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(config.getHost()); + int port = config.getPort() != null ? config.getPort() : (config.isSslEnabled() ? 465 : 587); + mailSender.setPort(port); + mailSender.setUsername(config.getUsername()); + mailSender.setPassword(config.getPassword()); + mailSender.setDefaultEncoding(StandardCharsets.UTF_8.name()); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + props.put("mail.smtp.connectiontimeout", "10000"); + props.put("mail.smtp.timeout", "15000"); + props.put("mail.smtp.writetimeout", "15000"); + if (config.isSslEnabled()) { + props.put("mail.smtp.ssl.enable", "true"); + props.put("mail.smtp.ssl.protocols", "TLSv1.2 TLSv1.3"); + } else { + props.put("mail.smtp.starttls.enable", "true"); + } + return mailSender; + } + + private InternetAddress fromAddress(SmtpConfig config) throws UnsupportedEncodingException { + try { + if (config.getFromName() != null && !config.getFromName().isBlank()) { + return new InternetAddress(config.getUsername(), config.getFromName(), StandardCharsets.UTF_8.name()); + } + return new InternetAddress(config.getUsername()); + } catch (Exception ex) { + throw new UnsupportedEncodingException("Nieprawidłowy adres nadawcy"); + } + } + + private static String shorten(String message) { + if (message == null) { + return "Nieznany błąd wysyłki"; + } + return message.length() > 480 ? message.substring(0, 480) : message; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java new file mode 100644 index 0000000..bf9b796 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java @@ -0,0 +1,69 @@ +package pl.polskalokalnie.send; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.unsubscribe.UnsubscribeService; + +/** + * Sklada finalna tresc wiadomosci: personalizacja znacznikow ({{name}}) oraz obowiazkowa stopka z + * linkiem rezygnacji. Kazda wiadomosc marketingowa musi umozliwiac wypisanie sie - link generuje + * podpisany token per lead i kanal. + */ +@Component +public class MessageComposer { + + private final UnsubscribeService unsubscribeService; + private final String baseUrl; + + public MessageComposer(UnsubscribeService unsubscribeService, + @Value("${app.public-base-url:http://localhost}") String baseUrl) { + this.unsubscribeService = unsubscribeService; + this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", ""); + } + + /** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */ + public String composeEmail(String rawBody, String recipientName, Long leadId) { + String personalized = personalize(rawBody, recipientName); + String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized); + String link = unsubscribeLink(leadId, "email"); + String footer = "
" + + "

" + + "Otrzymujesz tę wiadomość, ponieważ wyraziłeś zgodę na kontakt marketingowy. " + + "Zrezygnuj z otrzymywania wiadomości.

"; + return "
" + html + footer + "
"; + } + + /** Tresc SMS z personalizacja i krotka informacja o rezygnacji. */ + public String composeSms(String rawBody, String recipientName, Long leadId) { + String personalized = personalize(rawBody, recipientName).trim(); + String link = unsubscribeLink(leadId, "sms"); + return personalized + "\nRezygnacja: " + link; + } + + private String unsubscribeLink(Long leadId, String channel) { + return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(leadId, channel); + } + + private static String personalize(String body, String recipientName) { + String name = recipientName == null || recipientName.isBlank() ? "" : recipientName.trim(); + String value = body == null ? "" : body; + // {{name}} -> imie/nazwa; gdy brak, usuwamy podwojne spacje po podstawieniu pustej wartosci. + return value.replace("{{name}}", name).replace("{{ name }}", name); + } + + private static boolean looksLikeHtml(String value) { + return value != null && value.contains("<") && value.contains(">"); + } + + private static String textToHtml(String text) { + return escapeHtml(text).replace("\r\n", "\n").replace("\n", "
"); + } + + private static String escapeHtml(String value) { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/SendResult.java b/backend/src/main/java/pl/polskalokalnie/send/SendResult.java new file mode 100644 index 0000000..9677de7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/SendResult.java @@ -0,0 +1,18 @@ +package pl.polskalokalnie.send; + +/** + * Wynik proby wysylki pojedynczej wiadomosci przez sender (e-mail/SMS). + */ +public record SendResult( + boolean ok, + String providerMessageId, + String error +) { + public static SendResult ok(String providerMessageId) { + return new SendResult(true, providerMessageId, null); + } + + public static SendResult failure(String error) { + return new SendResult(false, null, error); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java new file mode 100644 index 0000000..506f8d1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java @@ -0,0 +1,73 @@ +package pl.polskalokalnie.send; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.springframework.stereotype.Component; +import pl.polskalokalnie.mailconfig.SmsGatewayConfig; + +/** + * Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). Zadanie HTTP POST + * (form-urlencoded) z kluczem API, polem creator, numerem i trescia; timeout z konfiguracji. + * + * UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z + * dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac. + */ +@Component +public class SmsSender { + + private static final String PARAM_API_KEY = "apiKey"; + private static final String PARAM_CREATOR = "creator"; + private static final String PARAM_TO = "to"; + private static final String PARAM_MESSAGE = "message"; + + public SendResult send(SmsGatewayConfig config, String phone, String message) { + if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank() + || config.getApiKey() == null || config.getApiKey().isBlank()) { + return SendResult.failure("Konfiguracja bramki SMS jest niekompletna"); + } + try { + int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0 + ? config.getTimeoutSeconds() : 10; + String form = PARAM_API_KEY + "=" + enc(config.getApiKey()) + + "&" + PARAM_CREATOR + "=" + enc(config.getCreator()) + + "&" + PARAM_TO + "=" + enc(phone) + + "&" + PARAM_MESSAGE + "=" + enc(message); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(timeout)) + .build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.getEndpointUrl())) + .timeout(Duration.ofSeconds(timeout)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String body = response.body() == null ? "" : response.body().trim(); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return SendResult.ok(shorten(body)); + } + return SendResult.failure("Bramka SMS zwróciła status " + response.statusCode() + + (body.isEmpty() ? "" : ": " + shorten(body))); + } catch (Exception ex) { + return SendResult.failure(shorten(ex.getMessage())); + } + } + + private static String enc(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String shorten(String value) { + if (value == null) { + return ""; + } + return value.length() > 200 ? value.substring(0, 200) : value; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java new file mode 100644 index 0000000..bfcbe42 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java @@ -0,0 +1,44 @@ +package pl.polskalokalnie.unsubscribe; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej. + * Musi byc na liscie permitAll w SecurityConfig. Zwraca prosta strone potwierdzenia po polsku. + */ +@RestController +@RequestMapping("/api/unsubscribe") +public class UnsubscribeController { + + private final UnsubscribeService unsubscribeService; + + public UnsubscribeController(UnsubscribeService unsubscribeService) { + this.unsubscribeService = unsubscribeService; + } + + @GetMapping(produces = MediaType.TEXT_HTML_VALUE) + public String unsubscribe(@RequestParam("token") String token) { + UnsubscribeService.Result result = unsubscribeService.unsubscribe(token); + String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail"; + return page("Rezygnacja przyjęta", + "Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")") + + " został wypisany z wiadomości marketingowych (" + kanal + "). " + + "Nie będziemy już wysyłać Ci kampanii tym kanałem."); + } + + private static String page(String title, String message) { + return "" + + "" + + "" + title + "" + + "" + + "

" + title + "

" + message + "

"; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java new file mode 100644 index 0000000..c2c80b8 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java @@ -0,0 +1,112 @@ +package pl.polskalokalnie.unsubscribe; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Locale; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.lead.Lead; +import pl.polskalokalnie.lead.LeadRepository; +import pl.polskalokalnie.lead.LeadStatus; + +/** + * Generuje i weryfikuje podpisane (HMAC-SHA256) tokeny rezygnacji doklejane do kazdej wiadomosci + * marketingowej. Rezygnacja jest trwala: ustawia opt-out i zdejmuje zgode na danym kanale, dzieki + * czemu kontakt nie trafi do zadnej kolejnej kampanii. + */ +@Service +public class UnsubscribeService { + + private static final String HMAC_ALGO = "HmacSHA256"; + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); + + private final LeadRepository leadRepository; + private final byte[] secret; + + public UnsubscribeService(LeadRepository leadRepository, @Value("${app.jwt.secret}") String secret) { + this.leadRepository = leadRepository; + this.secret = secret.getBytes(StandardCharsets.UTF_8); + } + + /** Token dla leada na danym kanale ("email" albo "sms"). */ + public String generateToken(Long leadId, String channel) { + String payload = leadId + ":" + normalizeChannel(channel); + String encodedPayload = URL_ENCODER.encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + return encodedPayload + "." + sign(encodedPayload); + } + + @Transactional + public Result unsubscribe(String token) { + if (token == null || !token.contains(".")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji"); + } + int dot = token.lastIndexOf('.'); + String encodedPayload = token.substring(0, dot); + String signature = token.substring(dot + 1); + if (!sign(encodedPayload).equals(signature)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł"); + } + + String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8); + String[] parts = payload.split(":", 2); + if (parts.length != 2) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji"); + } + Long leadId = parseLeadId(parts[0]); + String channel = normalizeChannel(parts[1]); + + Lead lead = leadRepository.findById(leadId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje")); + + if ("sms".equals(channel)) { + lead.setSmsOptOut(true); + lead.setSmsConsent(false); + } else { + lead.setEmailOptOut(true); + lead.setEmailConsent(false); + } + lead.setStatus(LeadStatus.UNSUBSCRIBED); + leadRepository.save(lead); + return new Result(channel, maskContact(lead, channel)); + } + + private String sign(String data) { + try { + Mac mac = Mac.getInstance(HMAC_ALGO); + mac.init(new SecretKeySpec(secret, HMAC_ALGO)); + byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return URL_ENCODER.encodeToString(raw); + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Błąd podpisu tokenu"); + } + } + + private static Long parseLeadId(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji"); + } + } + + private static String normalizeChannel(String channel) { + return channel != null && channel.trim().toLowerCase(Locale.ROOT).startsWith("sms") ? "sms" : "email"; + } + + private static String maskContact(Lead lead, String channel) { + String value = "sms".equals(channel) ? lead.getPhone() : lead.getEmail(); + if (value == null || value.length() < 4) { + return ""; + } + return value.substring(0, 2) + "***" + value.substring(value.length() - 2); + } + + public record Result(String channel, String maskedContact) { + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 3ffcf1f..368e451 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,6 +19,8 @@ spring: format_sql: true app: + # Publiczny adres bazowy uzywany do budowy linkow (np. rezygnacja z kampanii). + public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost} jwt: # W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa. secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 417c18f..1553223 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,9 +11,11 @@ "@types/pdfmake": "^0.3.3", "pdfmake": "^0.3.11", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-router-dom": "^7.18.1" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "6.0.2", @@ -108,6 +110,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -536,6 +554,19 @@ "node": ">=0.8" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -977,6 +1008,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/png-js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", @@ -1039,6 +1117,44 @@ "react": "^18.3.1" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/restructure": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", @@ -1097,6 +1213,12 @@ "loose-envify": "^1.1.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 755e641..061f929 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,15 +6,18 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc && vite build", - "preview": "vite preview --host 0.0.0.0" + "preview": "vite preview --host 0.0.0.0", + "test:e2e": "playwright test" }, "dependencies": { "@types/pdfmake": "^0.3.3", "pdfmake": "^0.3.11", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-router-dom": "^7.18.1" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "6.0.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..51eac2e --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '@playwright/test'; + +// Testy routingu uruchamiamy na systemowym Chrome (bez pobierania przegladarek Playwright). +// Domyslnie startujemy wlasny dev server; ustaw E2E_BASE_URL, zeby przetestowac +// build produkcyjny za nginx, np. E2E_BASE_URL=http://localhost npm run test:e2e +const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +export default defineConfig({ + testDir: './tests', + timeout: 60_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + baseURL, + channel: 'chrome', + viewport: { width: 1680, height: 1050 }, + actionTimeout: 15_000, + }, + webServer: process.env.E2E_BASE_URL + ? undefined + : { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37a5291..1d1bb6f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,22 @@ import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; +import { Link, NavLink, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; +import { ROUTES, listingPath, mapPath, negotiationPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ProtectedRoute } from './ProtectedRoute'; import { apiFetch, useAuth } from './auth'; import type { AuthUser, ContactPreference, PreferredLanguage } from './auth'; +import { useNotifications, toDisplayNotifications } from './notifications'; +import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; +import { AdminLeadsView, AdminCampaignsView, AdminMailConfigView } from './admin/marketing'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; const loginRoomImage = new URL('./assets/login-room.png', import.meta.url).href; const SMS_VERIFICATION_CODE = '123456'; -type View = 'home' | 'buy' | 'rent' | 'sell' | 'valuation' | 'priceHistory' | 'districtRanking' | 'negotiation' | 'comparison' | 'add' | 'services' | 'guides' | 'map' | 'login' | 'account' | 'accountSearches' | 'accountPriceAlerts' | 'accountMeetings' | 'accountListings' | 'accountSettings' | 'accountSecurity' | 'accountProfileEdit' | 'accountPublicProfile' | 'accountHelpContact' | 'notifications' | 'favorites' | 'messages' | 'admin' | 'listingDetail'; +// Podswietlenie aktywnej pozycji menu wyznacza NavLink na podstawie adresu. +const navLinkClass = ({ isActive }: { isActive: boolean }) => (isActive ? 'active' : ''); type MessageThreadListing = { listingId: number; @@ -33,7 +40,7 @@ type MessageThreadTarget = { export type ApiOfferType = 'SALE' | 'RENT'; export type ApiPropertyType = 'APARTMENT' | 'HOUSE'; -export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; +export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED'; // Lekka reprezentacja z list (Kupuje/Wynajmuje, moje ogloszenia) - bez pelnej galerii. export type ApiListingSummary = { @@ -55,6 +62,7 @@ export type ApiListingSummary = { coverPhoto: string | null; ownerEmail: string | null; status: ApiListingStatus; + viewsCount: number; createdAt: string; }; @@ -109,11 +117,6 @@ type LiveListingSummary = ApiListingSummary & { const LIVE_LISTINGS_REFRESH_MS = 15000; -const PROTECTED_VIEWS = new Set([ - 'add', 'admin', 'account', 'accountSearches', 'accountPriceAlerts', 'accountMeetings', 'accountListings', 'accountSettings', - 'accountSecurity', 'accountProfileEdit', 'accountPublicProfile', 'accountHelpContact', 'notifications', 'favorites', 'messages', 'comparison', -]); - type NotificationCategory = 'listing' | 'messages' | 'system'; type UserNotification = { @@ -699,28 +702,28 @@ const insightCards = [ title: 'AI wycena mieszkania', text: 'Sprawdź ile warte jest Twoje mieszkanie w 2 minuty', action: 'Wycena za darmo', - view: 'valuation' as const, + path: ROUTES.valuation, }, { icon: 'trend', title: 'Historia cen', text: 'Sprawdź jak zmieniały się ceny w okolicy', action: 'Zobacz wykresy', - view: 'priceHistory' as const, + path: ROUTES.priceHistory, }, { icon: 'house', title: 'Ranking dzielnic', text: 'Porównaj dzielnice pod kątem życia i inwestycji', action: 'Zobacz ranking', - view: 'districtRanking' as const, + path: ROUTES.districtRanking, }, { icon: 'coin', title: 'Ile możesz utargować?', text: 'AI podpowie realistyczną cenę transakcyjną', action: 'Sprawdź teraz', - view: 'negotiation' as const, + path: ROUTES.negotiation, }, ]; @@ -1507,49 +1510,60 @@ const rentListings: RentOffer[] = [ }, ]; +// slug trafia do adresu listy firm: /firmy?kategoria=remonty const serviceCategories = [ { + slug: 'finanse', icon: 'credit-card', title: 'Finanse', text: 'Kredyty hipoteczne, doradcy, ubezpieczenia', }, { + slug: 'prawo', icon: 'scale', title: 'Prawo i dokumenty', text: 'Notariusze, prawnicy, rzeczoznawcy', }, { + slug: 'remonty', icon: 'paint', title: 'Remont i wykończenie', text: 'Firmy remontowe, fachowcy, projekty wnętrz', }, { + slug: 'przeprowadzka', icon: 'truck', title: 'Przeprowadzka', text: 'Firmy przeprowadzkowe, magazyny, transport', }, { + slug: 'wyposazenie', icon: 'sofa', title: 'Wyposażenie wnętrz', text: 'Meble, AGD, kuchnie na wymiar, oświetlenie', }, { + slug: 'sprzedaz', icon: 'camera', title: 'Sprzedaż nieruchomości', text: 'Fotografia, home staging, wirtualne spacery 3D', }, { + slug: 'deweloperzy', icon: 'city', title: 'Deweloperzy i inwestycje', text: 'Nowe inwestycje, deweloperzy, biura sprzedaży', }, { + slug: 'inne', icon: 'dots', title: 'Inne usługi', text: 'Usługi dodatkowe związane z nieruchomościami', }, ]; +type ServiceCategorySlug = (typeof serviceCategories)[number]['slug']; + const serviceCompanies = [ { icon: 'house', @@ -1608,6 +1622,36 @@ const serviceDirectory = [ }, ]; +// Pelna baza firm dla listy /firmy. Kazda pozycja ma kategorie, wiec filtrowanie +// po ?kategoria= dziala na realnych danych, a nie tylko podswietla zakladke. +const companyDirectory: Array<{ + title: string; + category: ServiceCategorySlug; + rating: string; + text: string; + city: string; + image: string; + position: string; +}> = [ + { title: 'Finanse Expert', category: 'finanse', rating: '4.9 (128 opinii)', text: 'Kredyty hipoteczne i doradztwo finansowe', city: 'Warszawa, cała Polska', image: cityImage, position: '50% 40%' }, + { title: 'Kredyt Partner', category: 'finanse', rating: '4.6 (57 opinii)', text: 'Porównanie ofert kredytowych, ubezpieczenia', city: 'Gdańsk, pomorskie', image: heroImage, position: '30% 55%' }, + { title: 'Notariusz Nowak', category: 'prawo', rating: '4.9 (74 opinie)', text: 'Kancelaria notarialna, akty i umowy', city: 'Wrocław, dolnośląskie', image: heroImage, position: '62% 45%' }, + { title: 'Lex Nieruchomości', category: 'prawo', rating: '4.7 (41 opinii)', text: 'Obsługa prawna transakcji, księgi wieczyste', city: 'Warszawa, mazowieckie', image: cityImage, position: '70% 60%' }, + { title: 'Remont Masters', category: 'remonty', rating: '4.8 (96 opinii)', text: 'Kompleksowe remonty mieszkań i domów', city: 'Kraków, małopolskie', image: heroImage, position: '72% 62%' }, + { title: 'Perfect House', category: 'remonty', rating: '4.7 (64 opinie)', text: 'Wykończenia wnętrz pod klucz', city: 'Warszawa, mazowieckie', image: heroImage, position: '92% 50%' }, + { title: 'Home Finish', category: 'remonty', rating: '4.6 (52 opinie)', text: 'Remonty, wykończenia, projekty wnętrz', city: 'Wrocław, dolnośląskie', image: heroImage, position: '48% 58%' }, + { title: 'SuperPrzeprowadzki', category: 'przeprowadzka', rating: '4.7 (111 opinii)', text: 'Przeprowadzki lokalne i krajowe', city: 'Poznań, wielkopolskie', image: cityImage, position: '40% 50%' }, + { title: 'Transport 24', category: 'przeprowadzka', rating: '4.5 (38 opinii)', text: 'Transport mebli, magazynowanie, pakowanie', city: 'Łódź, łódzkie', image: heroImage, position: '20% 70%' }, + { title: 'Kuchnie na Wymiar', category: 'wyposazenie', rating: '4.8 (83 opinie)', text: 'Meble kuchenne i zabudowy na wymiar', city: 'Katowice, śląskie', image: heroImage, position: '85% 40%' }, + { title: 'Salon Oświetlenia Lumo', category: 'wyposazenie', rating: '4.4 (29 opinii)', text: 'Oświetlenie, AGD, wyposażenie wnętrz', city: 'Szczecin, zachodniopomorskie', image: cityImage, position: '60% 35%' }, + { title: 'FotoNieruchomości', category: 'sprzedaz', rating: '4.9 (67 opinii)', text: 'Fotografia ofert, home staging, spacery 3D', city: 'Warszawa, cała Polska', image: cityImage, position: '35% 65%' }, + { title: 'Studio Wirtualnych Spacerów', category: 'sprzedaz', rating: '4.7 (45 opinii)', text: 'Wirtualne spacery 3D i plany mieszkań', city: 'Kraków, małopolskie', image: heroImage, position: '55% 48%' }, + { title: 'Nowa Inwestycja Development', category: 'deweloperzy', rating: '4.6 (58 opinii)', text: 'Nowe inwestycje mieszkaniowe, biuro sprzedaży', city: 'Gdynia, pomorskie', image: cityImage, position: '80% 55%' }, + { title: 'Osiedle Park Deweloper', category: 'deweloperzy', rating: '4.5 (34 opinie)', text: 'Mieszkania z rynku pierwotnego', city: 'Wrocław, dolnośląskie', image: heroImage, position: '45% 42%' }, + { title: 'Serwis Domowy Fix', category: 'inne', rating: '4.6 (72 opinie)', text: 'Drobne naprawy, złota rączka, przeglądy', city: 'Cała Polska', image: heroImage, position: '68% 68%' }, + { title: 'Czysto i Gotowe', category: 'inne', rating: '4.8 (91 opinii)', text: 'Sprzątanie po remoncie i przed wprowadzeniem', city: 'Warszawa, mazowieckie', image: cityImage, position: '25% 45%' }, +]; + const serviceSteps = [ { icon: 'group', @@ -2614,12 +2658,9 @@ function preferredLanguageLabel(language: PreferredLanguage | null | undefined): function App() { const { user, logout } = useAuth(); - const [view, setView] = useState(() => (window.location.hash === '#login' ? 'login' : 'home')); - const [mapCity, setMapCity] = useState(''); - const [mapOfferType, setMapOfferType] = useState('SALE'); - const [selectedNegotiationOffer, setSelectedNegotiationOffer] = useState(buyListings[0] ?? null); - const [selectedListingId, setSelectedListingId] = useState(null); - const [messageThreadTarget, setMessageThreadTarget] = useState(null); + const navigate = useNavigate(); + const location = useLocation(); + const navigationType = useNavigationType(); const [favoriteListingsState, setFavoriteListingsState] = useState(favoriteListings); const [favoritesPreferredTab, setFavoritesPreferredTab] = useState('buy'); const [listingPriceAlertSubscriptionsState, setListingPriceAlertSubscriptionsState] = useState([]); @@ -2646,55 +2687,32 @@ function App() { const translationTimerRef = useRef(null); const translationInFlightRef = useRef(false); const filters = useFilterState(); + const notificationsCenter = useNotifications(); const [savedSearchesState, setSavedSearchesState] = useState([]); - const clearLoginHash = () => { - if (window.location.hash === '#login') { - window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`); - } - }; - - // Nawigacja z bramka autoryzacji: widoki chronione wymagaja zalogowania, - // panel admina wymaga roli ADMIN. - const navigate = (nextView: View) => { - let target = nextView; - if (target === 'admin' && user?.role !== 'ADMIN') { - target = user ? 'account' : 'login'; - } else if (PROTECTED_VIEWS.has(target) && !user) { - target = 'login'; - } - - setView(target); - if (target === 'login') { - window.location.hash = 'login'; - return; - } - clearLoginHash(); - }; - - // Po udanym logowaniu kierujemy admina do panelu, uzytkownika do konta - // (pomijamy bramke navigate, bo stan user moze byc jeszcze swiezo ustawiany). + // Po udanym logowaniu wracamy tam, gdzie uzytkownik zmierzal (ProtectedRoute zapisuje cel + // w location.state.from). Gdy wszedl na logowanie wprost - admin do panelu, uzytkownik do konta. const handleAuthenticated = (loggedUser: AuthUser) => { - setView(loggedUser.role === 'ADMIN' ? 'admin' : 'account'); - clearLoginHash(); + const from = (location.state as { from?: string } | null)?.from; + const fallback = loggedUser.role === 'ADMIN' ? ROUTES.admin : ROUTES.account; + navigate(from && from !== ROUTES.login ? from : fallback, { replace: true }); }; const handleLogout = () => { logout(); - setView('home'); - clearLoginHash(); + navigate(ROUTES.home); }; - useEffect(() => { - const onHashChange = () => { - if (window.location.hash === '#login') { - setView('login'); - } - }; - - window.addEventListener('hashchange', onHashChange); - return () => window.removeEventListener('hashchange', onHashChange); - }, []); + // Z kalkulatora zdolności: ustaw maksymalną cenę ofert na obliczony budżet i przejdź do wyszukiwarki. + const handleSearchWithBudget = (maxPrice: number) => { + if (maxPrice > 0) { + filters.setCity(''); + filters.setCityInput(''); + filters.setPriceMin(''); + filters.setPriceMax(String(Math.round(maxPrice))); + } + navigate(ROUTES.buy); + }; useEffect(() => { if (!user) { @@ -2816,10 +2834,10 @@ function App() { }, [user]); useEffect(() => { - if (view === 'favorites') { + if (location.pathname === ROUTES.favorites) { setUnreadFavoriteIds(new Set()); } - }, [view]); + }, [location.pathname]); useEffect(() => { const preferred = user?.preferredLanguage ?? 'PL'; @@ -3017,30 +3035,72 @@ function App() { translationTimerRef.current = null; } }; - }, [user?.preferredLanguage, view]); + }, [user?.preferredLanguage, location.pathname]); + // Miasto i typ oferty trafiaja do query stringa - wynik na mapie da sie udostepnic linkiem. const openMap = (name: string, source: 'buy' | 'rent' = 'buy') => { - setMapCity(name); - setMapOfferType(source === 'rent' ? 'RENT' : 'SALE'); - navigate('map'); + navigate(mapPath(name, source === 'rent' ? 'RENT' : 'SALE')); }; + + // Wybrana oferta to dane przejsciowe miedzy trasami - przekazujemy je w location.state, + // dzieki czemu nie zostaja w globalnym stanie po opuszczeniu widoku. const openNegotiation = (offer: BuyOffer) => { - setSelectedNegotiationOffer(offer); - navigate('negotiation'); + navigate(negotiationPath(), { state: { offer } }); }; + const openListing = (id: number) => { - setSelectedListingId(id); - navigate('listingDetail'); + navigate(listingPath(id)); }; const openMessagesWithTarget = (target: MessageThreadTarget) => { - setMessageThreadTarget(target); - navigate('messages'); + navigate(ROUTES.messages, { state: { threadTarget: target } }); + }; + + // Klik w powiadomienie: oznacz jako przeczytane (znika z dzwonka, licznik -1) i przejdź do właściwego miejsca. + const handleOpenNotification = (notification: ServerNotification | DisplayNotification) => { + notificationsCenter.markRead(notification.id); + const link = notification.link; + if (!link) { + navigate(ROUTES.notifications); + return; + } + if (link.startsWith('listing:')) { + const id = Number(link.slice('listing:'.length)); + if (Number.isFinite(id) && id > 0) { + openListing(id); + return; + } + } + if (link.startsWith('messages')) { + navigate(ROUTES.messages); + return; + } + if (link === 'priceAlerts') { + navigate(ROUTES.accountPriceAlerts); + return; + } + if (link === 'meetings') { + navigate(ROUTES.accountMeetings); + return; + } + if (link === 'savedSearches') { + navigate(ROUTES.accountSearches); + return; + } + if (link === 'favorites') { + navigate(ROUTES.favorites); + return; + } + if (link === 'accountSecurity' || link === 'account' || link === 'accountListings') { + navigate(ROUTES[link]); + return; + } + navigate(ROUTES.notifications); }; const toggleSavedSearch = (mode: SavedSearchMode) => { if (!user) { - navigate('login'); + navigate(ROUTES.login); return; } @@ -3063,7 +3123,7 @@ function App() { const openSavedSearch = (savedSearch: SavedSearchRecord) => { applySavedSearchToFilters(filters, savedSearch); - navigate(savedSearch.mode === 'rent' ? 'rent' : 'buy'); + navigate(savedSearch.mode === 'rent' ? ROUTES.rent : ROUTES.buy); }; const removeSavedSearch = (savedSearchId: string) => { @@ -3079,6 +3139,17 @@ function App() { const toggleListingFavorite = (listing: ApiListingDetail) => { const listingKey = favoriteListingKey(listing.id); + const alreadyFavorite = favoriteListingsState.some((item) => item.id === listingKey); + if (!alreadyFavorite) { + notificationsCenter.notify({ + category: 'listing', + icon: 'heart', + title: 'Ogłoszenie zapisane', + body: `„${listing.title}” zostało zapisane do ulubionych.`, + link: `listing:${listing.id}`, + dedupeKey: `favorite-${listing.id}`, + }); + } setFavoriteListingsState((current) => { const exists = current.some((item) => item.id === listingKey); if (exists) { @@ -3292,6 +3363,21 @@ function App() { const existingEvents = readUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, []); const merged = mergePriceAlertEvents(incomingEvents, existingEvents); writeUserJsonStorage(user, FAVORITE_PRICE_ALERTS_STORAGE_SUFFIX, merged); + + // Powiadomienie tylko o spadku ceny obserwowanego ogloszenia. + incomingEvents.forEach((event) => { + if (event.currentPrice < event.previousPrice) { + const diff = event.previousPrice - event.currentPrice; + notificationsCenter.notify({ + category: 'listing', + icon: 'coin', + title: 'Zmiana ceny', + body: `Cena „${event.title}” spadła o ${diff.toLocaleString('pl-PL')} zł.`, + link: `listing:${event.listingId}`, + dedupeKey: `price-${event.listingId}-${event.currentPrice}`, + }); + } + }); } if (refreshedFavorites.size > 0) { @@ -3321,106 +3407,187 @@ function App() { }; }, [user, monitoredListingIdList, monitoredListingIdsSignature, listingPriceAlertIdList, listingPriceAlertIdsSignature]); + // Wejscie na nowa trase zaczynamy od gory strony. Przy cofaniu (POP) zostawiamy + // pozycje przegladarce, zeby powrot do listy ofert wracal w to samo miejsce. + useEffect(() => { + if (navigationType !== 'POP') { + window.scrollTo({ top: 0, behavior: 'auto' }); + } + }, [location.pathname, navigationType]); + return ( -
-
0} /> +
+
0} onOpenNotification={handleOpenNotification} />
- {view === 'login' && } - {view === 'admin' && } - {view === 'account' && } - {view === 'accountSearches' && ( - + {/* Trasy publiczne */} + } /> + } /> + toggleSavedSearch('buy')} + /> + )} /> - )} - {view === 'accountPriceAlerts' && } - {view === 'comparison' && } - {view === 'accountMeetings' && } - {view === 'accountListings' && } - {view === 'accountSettings' && } - {view === 'accountSecurity' && } - {view === 'accountProfileEdit' && } - {view === 'accountPublicProfile' && } - {view === 'accountHelpContact' && } - {view === 'notifications' && } - {view === 'messages' && ( - setMessageThreadTarget(null)} + toggleSavedSearch('rent')} + /> + )} /> - )} - {view === 'favorites' && ( - + )} /> - )} - {view === 'buy' && ( - toggleSavedSearch('buy')} - /> - )} - {view === 'rent' && ( - toggleSavedSearch('rent')} - /> - )} - {view === 'sell' && } - {view === 'valuation' && } - {view === 'priceHistory' && } - {view === 'districtRanking' && } - {view === 'negotiation' && ( - - )} - {view === 'add' && } - {view === 'listingDetail' && ( - - )} - {view === 'services' && } - {view === 'guides' && } - {view === 'map' && } - {view === 'home' && } + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Trasy wymagajace zalogowania */} + }> + } /> + + )} + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + )} + /> + + + {/* Panel administratora - zakladki jako podsciezki /admin/* */} + }> + } /> + + + } /> +
); } +// Szczegoly oferty: identyfikator pochodzi z adresu (/oferta/:id), nie ze stanu Reacta. +// Dzieki temu link do oferty da sie wyslac, odswiezyc i otworzyc w nowej karcie. +function ListingDetailRoute(props: { + onStartChat: (target: MessageThreadTarget) => void; + isFavorite: (listingId: number) => boolean; + onToggleFavorite: (listing: ApiListingDetail) => void; + isListingAlertEnabled: (listingId: number) => boolean; + onToggleListingAlert: (listing: ApiListingDetail) => void; +}) { + const { id } = useParams<{ id: string }>(); + const listingId = Number(id); + + if (!Number.isFinite(listingId) || listingId <= 0) { + return ; + } + + return ; +} + +// Mapa czyta miasto i typ oferty z query stringa (/mapa?miasto=Krakow&typ=wynajem). +function MapSearchRoute() { + const [searchParams] = useSearchParams(); + const city = searchParams.get('miasto') ?? ''; + const offerType: ApiOfferType = searchParams.get('typ') === 'wynajem' ? 'RENT' : 'SALE'; + + return ; +} + +// Negocjacje: oferta wskazana z listy ofert przychodzi w location.state (dane przejsciowe), +// wybor z rozwijanej listy to juz zwykly stan formularza. +function NegotiationRoute() { + const location = useLocation(); + const offerFromState = (location.state as { offer?: BuyOffer } | null)?.offer ?? null; + const [selectedOffer, setSelectedOffer] = useState(offerFromState ?? buyListings[0] ?? null); + + useEffect(() => { + if (offerFromState) { + setSelectedOffer(offerFromState); + } + }, [offerFromState]); + + return ; +} + +// Nieznany adres: czytelny komunikat zamiast pustego ekranu. +function NotFoundPage() { + return ( +
+
+

404

+

Nie znaleźliśmy tej strony

+

Adres jest nieprawidłowy albo strona została przeniesiona.

+
+ Wróć na stronę główną + Przeglądaj oferty +
+
+
+ ); +} + type AdminListing = { id: number; title: string; @@ -3549,9 +3716,8 @@ function accountStorageKey(suffix: string, user: AuthUser | null): string { return `accountProfile:${user?.id ?? 'guest'}:${suffix}`; } -type AdminTab = - | 'dashboard' | 'listings' | 'users' | 'messages' | 'reports' | 'payments' - | 'stats' | 'settings' | 'moderation' | 'categories' | 'locations' | 'promotions' | 'forbiddenWords'; +// Zakladki panelu maja odpowiedniki w sciezkach /admin/* - jedno zrodlo prawdy w routes.ts. +type AdminTab = AdminTabKey; const ADMIN_TAB_META: Record = { dashboard: { title: 'Panel administratora', subtitle: 'Zarządzaj ogłoszeniami i użytkownikami serwisu.' }, @@ -3567,6 +3733,9 @@ const ADMIN_TAB_META: Record = { locations: { title: 'Lokalizacje', subtitle: 'Zarządzaj obsługiwanymi miastami i dzielnicami.' }, promotions: { title: 'Banery i promocje', subtitle: 'Zarządzaj banerami reklamowymi i promocjami.' }, forbiddenWords: { title: 'Zakazane słowa', subtitle: 'Lista wszystkich niedozwolonych słów używanych przez globalny filtr treści.' }, + leads: { title: 'Leady', subtitle: 'Baza kontaktów marketingowych i grupy docelowe (dynamiczne segmenty).' }, + campaigns: { title: 'Kampanie marketingowe', subtitle: 'Twórz i wysyłaj kampanie e-mail oraz SMS do grup docelowych.' }, + mailConfig: { title: 'Konfiguracja wysyłki', subtitle: 'Ustawienia poczty SMTP i bramki SMS dla kampanii i powiadomień.' }, }; const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ @@ -3575,6 +3744,8 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'users', icon: 'user', label: 'Użytkownicy' }, { id: 'messages', icon: 'message', label: 'Wiadomości' }, { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, + { id: 'leads', icon: 'user', label: 'Leady' }, + { id: 'campaigns', icon: 'mail', label: 'Kampanie marketingowe' }, { id: 'payments', icon: 'credit-card', label: 'Płatności' }, { id: 'stats', icon: 'chart', label: 'Statystyki' }, { id: 'settings', icon: 'gear', label: 'Ustawienia serwisu' }, @@ -3582,6 +3753,7 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ const ADMIN_TOOL_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'moderation', icon: 'check', label: 'Weryfikacja ogłoszeń' }, + { id: 'mailConfig', icon: 'gear', label: 'Konfiguracja wysyłki' }, { id: 'forbiddenWords', icon: 'shield', label: 'Zakazane słowa' }, { id: 'categories', icon: 'list', label: 'Kategorie' }, { id: 'locations', icon: 'pin', label: 'Lokalizacje' }, @@ -3722,9 +3894,13 @@ function AdminMessageModal({ user, onClose }: { user: AuthUser; onClose: () => v ); } -function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { +function AdminPage() { const { user } = useAuth(); - const [tab, setTab] = useState('dashboard'); + // Aktywna zakladka wynika z adresu (/admin/leady), nie ze stanu komponentu - + // dzieki temu kazda sekcja panelu ma wlasny URL, dziala F5 i przycisk Wstecz. + const adminSegment = useParams()['*'] ?? ''; + const tab: AdminTab = ADMIN_PATH_TO_TAB[adminSegment] ?? 'dashboard'; + const isUnknownAdminSection = adminSegment !== '' && !(adminSegment in ADMIN_PATH_TO_TAB); const [users, setUsers] = useState([]); const [listings, setListings] = useState([]); const [reports, setReports] = useState([]); @@ -4069,6 +4245,10 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { } }; + if (isUnknownAdminSection) { + return ; + } + return (
@@ -4077,31 +4257,26 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) {

Narzędzia

@@ -4111,9 +4286,9 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) {
- +
@@ -4174,7 +4349,7 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) {

Ostatnie ogłoszenia

- + Zobacz wszystkie
{recentListings.length === 0 ? (

Brak ogłoszeń do wyświetlenia.

@@ -4204,7 +4379,7 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) {

Aktywność użytkowników

- + Zobacz wszystkich
{recentUsers.length === 0 ? (

Brak użytkowników do wyświetlenia.

@@ -4230,10 +4405,10 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) {

Szybkie akcje

- - - - + Dodaj nowe ogłoszenie + Zarządzaj kategoriami + Zarządzaj lokalizacjami + Zarządzaj banerami
@@ -4943,13 +5118,17 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { {!loading && tab === 'categories' && } {!loading && tab === 'locations' && } {!loading && tab === 'promotions' && } + {!loading && tab === 'leads' && } + {!loading && tab === 'campaigns' && } + {!loading && tab === 'mailConfig' && } ); } -function DistrictRankingPage({ onNavigate }: { onNavigate: (view: View) => void }) { +function DistrictRankingPage() { + const navigate = useNavigate(); const [rankingMetric, setRankingMetric] = useState('overall'); const [propertyType, setPropertyType] = useState('flat'); const [marketType, setMarketType] = useState('all'); @@ -5000,9 +5179,9 @@ function DistrictRankingPage({ onNavigate }: { onNavigate: (view: View) => void
- + Panel główny - + Ranking dzielnic
@@ -5114,16 +5293,15 @@ function DistrictRankingPage({ onNavigate }: { onNavigate: (view: View) => void } function NegotiationPage({ - onNavigate, offers, selectedOffer, onSelectOffer, }: { - onNavigate: (view: View) => void; offers: BuyOffer[]; selectedOffer: BuyOffer | null; onSelectOffer: (offer: BuyOffer) => void; }) { + const navigate = useNavigate(); const fallbackOffer = offers[0] ?? null; const activeOffer = selectedOffer ?? fallbackOffer; @@ -5132,9 +5310,9 @@ function NegotiationPage({
- + Panel główny - + Ile możesz utargować?
@@ -5169,9 +5347,9 @@ function NegotiationPage({
- + Panel główny - + Ile możesz utargować?
@@ -5274,7 +5452,7 @@ function NegotiationPage({

Nasi eksperci pomogą Ci przejść przez proces zakupu i wynegocjować najlepszą cenę.

- + Skontaktuj się z ekspertem Pierwsza konsultacja gratis
@@ -5328,22 +5506,13 @@ function NegotiationPage({ ); } -function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { +function AccountSidebar() { const { user } = useAuth(); - const isSettingsView = activeView === 'accountSettings' || activeView === 'accountProfileEdit' || activeView === 'accountPublicProfile'; - const isSecurityView = activeView === 'accountSecurity'; - const isHelpContactView = activeView === 'accountHelpContact'; - const isDashboardView = activeView === 'account'; - const isSearchesView = activeView === 'accountSearches'; - const isFavoritesView = activeView === 'favorites'; - const isPriceAlertsView = activeView === 'accountPriceAlerts'; - const isMeetingsView = activeView === 'accountMeetings'; - const isListingsView = activeView === 'accountListings'; - const isPriceHistoryView = activeView === 'priceHistory'; - const isNegotiationView = activeView === 'negotiation'; - const isValuationView = activeView === 'valuation'; - const isDistrictRankingView = activeView === 'districtRanking'; - const isComparisonView = activeView === 'comparison'; + const location = useLocation(); + // Ustawienia obejmuja takze podstrony profilu - podswietlamy je wspolna pozycja menu. + const isSettingsSection = location.pathname === ROUTES.accountSettings + || location.pathname === ROUTES.accountProfileEdit + || location.pathname === ROUTES.accountPublicProfile; const [messagesUnreadCount, setMessagesUnreadCount] = useState(0); const allListingsCount = (() => { if (typeof window === 'undefined') { @@ -5418,34 +5587,35 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) =

Moje aktywności

- - - - - + Panel główny + Zapisane wyszukiwania {savedSearchCount} + Ulubione ogłoszenia {favoritesCount} + Alerty cenowe {priceAlertsCount} + Porównanie mieszkań

Moje transakcje

- - - + Zapytania i wiadomości {messagesUnreadCount} + Umówione spotkania + Moje oferty {allListingsCount}

Moje narzędzia

- - - - + Zdolność kredytowa + Historia cen + Ile możesz utargować? + Wyceny mieszkań + Ranking dzielnic

Ustawienia

- - - - + Dane i ustawienia + Powiadomienia + Bezpieczeństwo + Pomoc i kontakt
@@ -5460,16 +5630,13 @@ function AccountSidebar({ onNavigate, activeView }: { onNavigate: (view: View) = } function AccountDashboardPage({ - onNavigate, - activeView, savedSearches, onOpenSavedSearch, }: { - onNavigate: (view: View) => void; - activeView: View; savedSearches: SavedSearchRecord[]; onOpenSavedSearch: (savedSearch: SavedSearchRecord) => void; }) { + const navigate = useNavigate(); const { user } = useAuth(); type DashboardWidgetId = 'recent' | 'alerts' | 'searches' | 'tools'; type DashboardWidgetConfig = { @@ -5496,11 +5663,12 @@ function AccountDashboardPage({ const [activeAlerts, setActiveAlerts] = useState(() => alerts.map(() => true)); const tools = [ - { icon: 'chart', title: 'Historia cen', text: 'Sprawdź jak zmieniały się ceny mieszkań w czasie', view: 'priceHistory' as View }, - { icon: 'coin', title: 'Ile możesz utargować?', text: 'AI podpowie realistyczną cenę transakcyjną', view: 'negotiation' as View }, - { icon: 'calculator', title: 'Wycena mieszkania', text: 'Darmowa wycena Twojego mieszkania online', view: 'valuation' as View }, - { icon: 'house', title: 'Ranking dzielnic', text: 'Zobacz najlepsze dzielnice w Polsce', view: 'districtRanking' as View }, - { icon: 'shuffle', title: 'Porównanie mieszkań', text: 'Porównuj oferty i wybieraj najlepsze', view: 'comparison' as View }, + { icon: 'money', title: 'Zdolność kredytowa', text: 'Sprawdź, na jaki kredyt Cię stać i jaka będzie rata', path: ROUTES.creditCalculator }, + { icon: 'chart', title: 'Historia cen', text: 'Sprawdź jak zmieniały się ceny mieszkań w czasie', path: ROUTES.priceHistory }, + { icon: 'coin', title: 'Ile możesz utargować?', text: 'AI podpowie realistyczną cenę transakcyjną', path: ROUTES.negotiation }, + { icon: 'calculator', title: 'Wycena mieszkania', text: 'Darmowa wycena Twojego mieszkania online', path: ROUTES.valuation }, + { icon: 'house', title: 'Ranking dzielnic', text: 'Zobacz najlepsze dzielnice w Polsce', path: ROUTES.districtRanking }, + { icon: 'shuffle', title: 'Porównanie mieszkań', text: 'Porównuj oferty i wybieraj najlepsze', path: ROUTES.comparison }, ]; const recentListings = [ @@ -5695,7 +5863,7 @@ function AccountDashboardPage({

Moje wyszukiwania

- + Zobacz wszystkie
{savedSearches.slice(0, 4).map((row) => ( @@ -5722,7 +5890,7 @@ function AccountDashboardPage({ ))} {savedSearches.length === 0 &&

Nie masz jeszcze zapisanych wyszukiwań. Wejdź w Kup lub Wynajem i kliknij dzwonek „Zapisz wyszukiwanie”.

} - + Utwórz nowe wyszukiwanie
), @@ -5733,24 +5901,14 @@ function AccountDashboardPage({
{tools.map((tool) => ( -
onNavigate(tool.view)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onNavigate(tool.view); - } - }} - > + // Kafelek prowadzi na inna strone, wiec jest linkiem - bez protezy role="button". +
{tool.title} {tool.text}
-
+ ))}
@@ -5761,7 +5919,7 @@ function AccountDashboardPage({ return (
- +
@@ -5920,18 +6078,15 @@ function AccountDashboardPage({ } function AccountSavedSearchesPage({ - onNavigate, - activeView, savedSearches, onOpenSavedSearch, onRemoveSavedSearch, }: { - onNavigate: (view: View) => void; - activeView: View; savedSearches: SavedSearchRecord[]; onOpenSavedSearch: (savedSearch: SavedSearchRecord) => void; onRemoveSavedSearch: (savedSearchId: string) => void; }) { + const navigate = useNavigate(); const [query, setQuery] = useState(''); const [modeFilter, setModeFilter] = useState<'all' | SavedSearchMode>('all'); const [sortMode, setSortMode] = useState<'newest' | 'oldest' | 'city'>('newest'); @@ -5967,7 +6122,7 @@ function AccountSavedSearchesPage({ return (
- +
@@ -5976,7 +6131,7 @@ function AccountSavedSearchesPage({

Zapisane wyszukiwania

Wróć do swoich filtrów jednym kliknięciem i przejdź od razu do odpowiedniej listy ofert.

- + Dodaj nowe
@@ -6072,9 +6227,9 @@ function AccountSavedSearchesPage({
Nie masz jeszcze zapisanych wyszukiwań

Ustaw filtry w Kup lub Wynajem i kliknij dzwonek „Zapisz wyszukiwanie”, a pojawią się tutaj.

- +
)} @@ -6103,12 +6258,8 @@ function AccountSavedSearchesPage({ } function AccountHelpContactPage({ - onNavigate, - activeView, user, }: { - onNavigate: (view: View) => void; - activeView: View; user: AuthUser | null; }) { type HelpContactFormValues = { @@ -6326,7 +6477,7 @@ function AccountHelpContactPage({ return (
- +
@@ -6455,16 +6606,13 @@ function AccountHelpContactPage({ } function AccountPriceAlertsPage({ - onNavigate, - activeView, user, onOpenListing, }: { - onNavigate: (view: View) => void; - activeView: View; user: AuthUser | null; onOpenListing: (id: number) => void; }) { + const navigate = useNavigate(); const [cityAlerts, setCityAlerts] = useState([]); const [priceEvents, setPriceEvents] = useState([]); const [cityPriceEvents, setCityPriceEvents] = useState([]); @@ -6563,7 +6711,7 @@ function AccountPriceAlertsPage({ return (
- +
@@ -6659,7 +6807,7 @@ function AccountPriceAlertsPage({ onOpenListing(activeListingEvent.listingId); return; } - onNavigate('priceHistory'); + navigate(ROUTES.priceHistory); }} disabled={!latestEventType} > @@ -6714,7 +6862,7 @@ function AccountPriceAlertsPage({
)}
- + Dodaj kolejne lokalizacje
@@ -6731,16 +6879,16 @@ function AccountPriceAlertsPage({ Usuń )) : ( - + )}
- + Zarządzaj alertami
@@ -6749,7 +6897,8 @@ function AccountPriceAlertsPage({ ); } -function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNavigate: (view: View) => void; activeView: View; onOpenListing: (id: number) => void }) { +function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number) => void }) { + const navigate = useNavigate(); type ComparisonPropertyFilter = 'ALL' | ApiPropertyType | 'LAND' | 'COMMERCIAL' | 'ROOM'; const comparisonPropertyOptions: Array<{ value: ComparisonPropertyFilter; label: string }> = [ @@ -6785,7 +6934,9 @@ function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNa useEffect(() => { if (selectedIds.length === 0) { - setSelectedDetails({}); + // Zachowujemy referencje pustego obiektu - podstawienie nowego {} zmienialoby + // zaleznosc selectedDetails i efekt wpadalby w nieskonczona petle renderow. + setSelectedDetails((current) => (Object.keys(current).length === 0 ? current : {})); return; } @@ -7160,13 +7311,13 @@ function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNa return (
- +
- + Strona główna - + Porównywarka mieszkań
@@ -7350,7 +7501,8 @@ function AccountComparisonPage({ onNavigate, activeView, onOpenListing }: { onNa ); } -function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { +function AccountMeetingsPage() { + const { notify } = useNotifications(); type MeetingType = 'buy' | 'rent' | 'own'; type MeetingStatus = 'planned' | 'completed' | 'cancelled'; type MeetingItem = { @@ -7612,7 +7764,13 @@ function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi if (selectedDateIsPast) { return; } + const target = meetings.find((meeting) => meeting.id === id); setMeetings((current) => current.map((meeting) => (meeting.id === id ? { ...meeting, status } : meeting))); + if (target && status === 'cancelled') { + notify({ category: 'system', icon: 'calendar', title: 'Spotkanie odwołane', body: `Spotkanie „${target.title}” zostało odwołane.`, link: 'meetings', dedupeKey: `meeting-${id}-cancelled` }); + } else if (target && status === 'completed') { + notify({ category: 'system', icon: 'check', title: 'Spotkanie potwierdzone', body: `Spotkanie „${target.title}” zostało oznaczone jako zrealizowane.`, link: 'meetings', dedupeKey: `meeting-${id}-completed` }); + } }; const removeMeeting = (id: string) => { @@ -7695,6 +7853,10 @@ function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi ]); } + if (!editingMeetingId) { + notify({ category: 'system', icon: 'calendar', title: 'Spotkanie zaplanowane', body: `„${newMeeting.title.trim()}” — ${newMeeting.dateKey}, godz. ${newMeeting.start}.`, link: 'meetings' }); + } + setFormError(''); setSelectedDateKey(newMeeting.dateKey); const selectedDate = new Date(newMeeting.dateKey); @@ -7736,7 +7898,7 @@ function AccountMeetingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi return (
- +
@@ -7945,7 +8107,8 @@ function MyApiListingsBand({ onOpenListing }: { onOpenListing: (id: number) => v ); } -function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavigate: (view: View) => void; activeView: View; onOpenListing: (id: number) => void }) { +function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => void }) { + const navigate = useNavigate(); type ListingCategory = 'sale' | 'rent'; type ListingStatus = 'active' | 'paused' | 'archived'; type ListingItem = { @@ -7966,6 +8129,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi status: ListingStatus; pausedUntil?: string | null; promoted: boolean; + isApi?: boolean; + apiId?: number; + apiStatus?: ApiListingStatus; }; type ListingTab = 'all' | 'sale' | 'rent' | 'archived'; @@ -8225,6 +8391,73 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi window.localStorage.setItem(listingsStorageKey, JSON.stringify(listings)); }, [listings]); + // Prawdziwe ogloszenia uzytkownika z backendu (/listings/mine) - scalane z makietami ponizej. + const [apiListings, setApiListings] = useState([]); + useEffect(() => { + let cancelled = false; + apiFetch('/listings/mine') + .then((data) => { if (!cancelled) setApiListings(data); }) + .catch(() => { if (!cancelled) setApiListings([]); }); + return () => { cancelled = true; }; + }, []); + + const isApiId = (id: string) => id.startsWith('api-'); + const apiIdNum = (id: string) => Number(id.slice(4)); + + // Ulubione uzytkownika (localStorage) - do statystyki "zapisanych" dla realnych ogloszen. + const { user } = useAuth(); + const favoriteListingIds = useMemo( + () => new Set(readUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).map((fav) => fav.listingId)), + [user], + ); + + const apiRows: ListingItem[] = apiListings.map((summary) => ({ + id: `api-${summary.id}`, + title: summary.title, + address: [summary.address, summary.district, summary.city].filter(Boolean).join(', ') || summary.city, + area: summary.area, + rooms: summary.rooms, + floor: summary.floor ? (parseInt(summary.floor, 10) || 0) : 0, + price: summary.price, + monthlyFees: 0, + views: summary.viewsCount ?? 0, + saves: favoriteListingIds.has(summary.id) ? 1 : 0, + addedDate: (summary.createdAt || '').slice(0, 10) || '2025-01-01', + image: summary.coverPhoto || heroImage, + imagePosition: 'center', + category: summary.offerType === 'RENT' ? 'rent' : 'sale', + status: summary.status === 'PAUSED' ? 'paused' : 'active', + pausedUntil: null, + promoted: false, + isApi: true, + apiId: summary.id, + apiStatus: summary.status, + })); + + // Prawdziwe ogloszenia na gorze listy, pod nimi makiety. + const allRows: ListingItem[] = [...apiRows, ...listings]; + + const apiSetStatus = (id: string, status: ApiListingStatus) => { + const numericId = apiIdNum(id); + apiFetch(`/listings/${numericId}/status?status=${status}`, { method: 'PATCH' }) + .then((updated) => { + setApiListings((current) => current.map((item) => (item.id === numericId ? { ...item, status: updated.status } : item))); + setFeedback(status === 'PAUSED' ? 'Ogłoszenie zostało wstrzymane. Wznowisz je ręcznie.' : 'Ogłoszenie zostało wznowione i jest znów widoczne.'); + }) + .catch((error) => setFeedback(error instanceof Error ? error.message : 'Nie udało się zmienić statusu ogłoszenia.')); + }; + + const apiDeleteListing = (id: string) => { + const numericId = apiIdNum(id); + apiFetch(`/listings/${numericId}`, { method: 'DELETE' }) + .then(() => { + setApiListings((current) => current.filter((item) => item.id !== numericId)); + setFeedback('Ogłoszenie zostało trwale usunięte z serwisu.'); + }) + .catch((error) => setFeedback(error instanceof Error ? error.message : 'Nie udało się usunąć ogłoszenia.')) + .finally(() => closeDeleteModal()); + }; + useEffect(() => { const resumePausedListings = () => { const nowMs = Date.now(); @@ -8290,13 +8523,13 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const tabCounts = { - all: listings.filter((item) => item.status !== 'archived').length, - sale: listings.filter((item) => item.category === 'sale' && item.status === 'active').length, - rent: listings.filter((item) => item.category === 'rent' && item.status === 'active').length, - archived: listings.filter((item) => item.status === 'paused').length, + all: allRows.filter((item) => item.status !== 'archived').length, + sale: allRows.filter((item) => item.category === 'sale' && item.status === 'active').length, + rent: allRows.filter((item) => item.category === 'rent' && item.status === 'active').length, + archived: allRows.filter((item) => item.status === 'paused').length, }; - const visibleListings = listings + const visibleListings = allRows .filter((item) => { if (activeTab === 'archived') { return item.status === 'paused'; @@ -8358,10 +8591,18 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; window.localStorage.setItem(EDIT_LISTING_DRAFT_STORAGE_KEY, JSON.stringify(draft)); - onNavigate('add'); + navigate(ROUTES.add); }; const togglePause = (id: string) => { + if (isApiId(id)) { + const row = apiRows.find((item) => item.id === id); + if (!row) { + return; + } + apiSetStatus(id, row.status === 'paused' ? 'APPROVED' : 'PAUSED'); + return; + } const listing = listings.find((item) => item.id === id); if (!listing || listing.status === 'archived') { return; @@ -8432,6 +8673,10 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi }; const removeListing = (id: string) => { + if (isApiId(id)) { + apiDeleteListing(id); + return; + } setListings((current) => current.filter((item) => item.id !== id)); const selectedReason = deleteReasons.find((reason) => reason.id === deleteReason); setFeedback(selectedReason ? `Ogłoszenie zostało usunięte (${selectedReason.title.toLowerCase()}).` : 'Ogłoszenie zostało usunięte.'); @@ -8465,7 +8710,7 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi return (
- +
@@ -8473,8 +8718,6 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi

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

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

{item.title}

@@ -8517,7 +8766,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi
- {item.status === 'paused' ? ( + {item.isApi && (item.apiStatus === 'PENDING' || item.apiStatus === 'REJECTED') ? ( + LISTING_STATUS_LABELS[item.apiStatus] + ) : item.status === 'paused' ? ( <> Wstrzymane
@@ -8526,6 +8777,7 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi ) : statusLabel(item.status)}
Dodane: {formatDate(item.addedDate)} + {item.isApi && W serwisie} {item.promoted && Promowane}
@@ -8541,9 +8793,9 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi
{item.status !== 'archived' && } - {item.status !== 'archived' && } + {(item.isApi ? (item.apiStatus === 'APPROVED' || item.apiStatus === 'PAUSED') : item.status !== 'archived') && } - + {!item.isApi && }
))} @@ -8615,25 +8867,62 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi ); } -function AccountSettingsPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { +function AccountSettingsPage() { + const navigate = useNavigate(); const { user } = useAuth(); const [accountSwitches, setAccountSwitches] = useState([true, true, false, false]); + // Indeks 4 ("Oferty promowane i newsletter") to zgoda marketingowa e-mail zapisywana na serwerze. const [mailSwitches, setMailSwitches] = useState([true, true, true, false, false]); + const [smsConsent, setSmsConsent] = useState(true); const address = user?.address ?? ''; const birthDate = user?.birthDate ?? ''; + // Wczytaj aktualną zgodę marketingową użytkownika (steruje kwalifikacją do kampanii). + useEffect(() => { + if (!user) { + return; + } + let active = true; + apiFetch<{ emailConsent: boolean; smsConsent: boolean }>('/me/marketing') + .then((consent) => { + if (!active) { + return; + } + setSmsConsent(consent.smsConsent); + setMailSwitches((current) => current.map((value, idx) => (idx === 4 ? consent.emailConsent : value))); + }) + .catch(() => { + // Brak zgody/sesji - zostawiamy wartości domyślne. + }); + return () => { + active = false; + }; + }, [user]); + const toggleAccountSwitch = (index: number) => { setAccountSwitches((current) => current.map((value, idx) => (idx === index ? !value : value))); }; const toggleMailSwitch = (index: number) => { - setMailSwitches((current) => current.map((value, idx) => (idx === index ? !value : value))); + setMailSwitches((current) => { + const next = current.map((value, idx) => (idx === index ? !value : value)); + // Zgodę marketingową e-mail (indeks 4) utrwalamy na serwerze. + if (index === 4) { + apiFetch('/me/marketing', { + method: 'PUT', + body: JSON.stringify({ emailConsent: next[4], smsConsent }), + }).catch(() => { + // Błąd zapisu nie blokuje UI; wartość zostanie zsynchronizowana przy kolejnym wejściu. + }); + } + return next; + }); }; return (
- +
@@ -8652,7 +8941,7 @@ function AccountSettingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi {getDisplayName(user)} Konto Premium
- + Edytuj
Adres e-mail{user?.email}
@@ -8744,7 +9033,7 @@ function AccountSettingsPage({ onNavigate, activeView }: { onNavigate: (view: Vi ); } -function AccountSecurityPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { +function AccountSecurityPage() { const { user } = useAuth(); const recentSecurityActivity = [ { @@ -8851,7 +9140,7 @@ function AccountSecurityPage({ onNavigate, activeView }: { onNavigate: (view: Vi return (
- +
@@ -8931,8 +9220,10 @@ function AccountSecurityPage({ onNavigate, activeView }: { onNavigate: (view: Vi ); } -function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { +function AccountProfileEditPage() { + const navigate = useNavigate(); const { user, updateProfile } = useAuth(); + const { notify } = useNotifications(); type UploadTarget = 'avatar' | 'cover'; type MessageTone = 'success' | 'error' | 'info'; @@ -9384,6 +9675,7 @@ function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: setVerificationCodeError(''); setIsPhoneVerified(true); setPhoneVerificationStep(3); + notify({ category: 'system', icon: 'check', title: 'Numer telefonu zweryfikowany', body: 'Twój numer telefonu został pomyślnie zweryfikowany.', link: 'accountSecurity', dedupeKey: 'phone-verified' }); return; } setVerificationCodeError('Kod jest nieprawidłowy. Spróbuj ponownie.'); @@ -9393,7 +9685,7 @@ function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: <>
- +
Dane i ustawienia Edytuj profil
@@ -9587,7 +9879,7 @@ function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: {coverPhotoName && (
- + Podgląd profilu
)} @@ -9641,9 +9933,9 @@ function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: {profileVisibility === 'publiczna' && 'Zobacz jak inni widzą Twój profil.'} {profileVisibility === 'kontakty' && 'Profil widzą tylko zaakceptowane kontakty.'} - +
@@ -9844,7 +10136,8 @@ function AccountProfileEditPage({ onNavigate, activeView }: { onNavigate: (view: ); } -function AccountPublicProfilePage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { +function AccountPublicProfilePage() { + const navigate = useNavigate(); const { user } = useAuth(); const publicProfileCover = window.localStorage.getItem(accountStorageKey('CoverImage', user)) || heroImage; const publicReviews = [ @@ -9917,7 +10210,7 @@ function AccountPublicProfilePage({ onNavigate, activeView }: { onNavigate: (vie return (
- +
Dane i ustawienia Jak inni widzą Twój profil
@@ -9926,7 +10219,7 @@ function AccountPublicProfilePage({ onNavigate, activeView }: { onNavigate: (vie

Jak inni widzą Twój profil

Zobacz, jak Twój profil prezentuje się publicznie dla innych użytkowników Mieszko.

- + Edytuj profil
@@ -10618,7 +10911,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { ); } -function HomePage({ onNavigate, filters, onOpenMap }: { onNavigate: (view: View) => void; filters: FilterState; onOpenMap: (city: string, mode?: 'buy' | 'rent') => void }) { +function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (city: string, mode?: 'buy' | 'rent') => void }) { + const navigate = useNavigate(); const [favoriteListings, setFavoriteListings] = useState>(new Set()); const toggleFavoriteListing = (location: string) => { @@ -10689,14 +10983,14 @@ function HomePage({ onNavigate, filters, onOpenMap }: { onNavigate: (view: View)
- - + + Wynajem
- onNavigate('buy')} /> + navigate(ROUTES.buy)} />
@@ -10713,7 +11007,7 @@ function HomePage({ onNavigate, filters, onOpenMap }: { onNavigate: (view: View)

{card.title}

{card.text}

- + {card.action} ))}
@@ -10721,7 +11015,7 @@ function HomePage({ onNavigate, filters, onOpenMap }: { onNavigate: (view: View)

Polecane ogłoszenia

- Zobacz wszystkie + Zobacz wszystkie
@@ -11016,7 +11310,8 @@ function RentPage({ ); } -function AddListingPage({ onNavigate }: { onNavigate: (view: View) => void }) { +function AddListingPage() { + const navigate = useNavigate(); const [currentStep, setCurrentStep] = useState(0); const [listingDetails, setListingDetails] = useState(INITIAL_LISTING_DETAILS); const [location, setLocation] = useState(INITIAL_LISTING_LOCATION); @@ -12105,13 +12400,13 @@ function AddListingPage({ onNavigate }: { onNavigate: (view: View) => void }) { znajdziesz je w „Moich ogłoszeniach” ze statusem „Oczekuje”.

- - +
) : ( @@ -12292,7 +12587,670 @@ function AddListingPage({ onNavigate }: { onNavigate: (view: View) => void }) { ); } +const buyingProcessSteps = [ + { icon: 'search', title: 'Analiza potrzeb i możliwości', text: 'Określ, po co kupujesz i na co Cię stać.' }, + { icon: 'calculator', title: 'Określenie budżetu', text: 'Policz cenę plus wszystkie koszty dodatkowe.' }, + { icon: 'chart', title: 'Sprawdzenie zdolności', text: 'Zbadaj zdolność kredytową w kilku bankach.' }, + { icon: 'pin', title: 'Wybór i analiza lokalizacji', text: 'Sprawdź okolicę, dojazdy i plany zagospodarowania.' }, + { icon: 'eye', title: 'Oglądanie mieszkań', text: 'Odwiedź kilka ofert i porównaj je na spokojnie.' }, + { icon: 'group', title: 'Negocjacja ceny', text: 'Ustal realną wartość i wynegocjuj warunki.' }, + { icon: 'calendar', title: 'Rezerwacja mieszkania', text: 'Zabezpiecz ofertę umową rezerwacyjną.' }, + { icon: 'document', title: 'Umowa przedwstępna', text: 'Spisz warunki i terminy u notariusza lub cywilnie.' }, + { icon: 'building', title: 'Kredyt hipoteczny', text: 'Złóż wnioski i wybierz najlepszą ofertę.' }, + { icon: 'scale', title: 'Akt notarialny', text: 'Podpisz umowę przenoszącą własność.' }, + { icon: 'key', title: 'Odbiór mieszkania', text: 'Sprawdź stan techniczny i spisz protokół.' }, + { icon: 'house', title: 'Zameldowanie i formalności', text: 'Przepisz media, ubezpiecz i zamelduj się.' }, +]; + +const buyingToc = [ + { id: 'start', label: 'Od czego zacząć?' }, + { id: 'budzet', label: 'Jak określić budżet?' }, + { id: 'gotowka-kredyt', label: 'Gotówka czy kredyt?' }, + { id: 'zdolnosc', label: 'Zdolność kredytowa' }, + { id: 'rynek', label: 'Rynek pierwotny czy wtórny?' }, + { id: 'lokalizacja', label: 'Wybór lokalizacji' }, + { id: 'ogladanie', label: 'Jak oglądać mieszkanie?' }, + { id: 'stan-techniczny', label: 'Stan techniczny' }, + { id: 'ksiega-wieczysta', label: 'Księga wieczysta' }, + { id: 'negocjacje', label: 'Negocjacja ceny' }, + { id: 'umowa-przedwstepna', label: 'Umowa przedwstępna' }, + { id: 'akt-notarialny', label: 'Akt notarialny' }, + { id: 'odbior', label: 'Odbiór mieszkania' }, + { id: 'klucze', label: 'Przekazanie kluczy' }, + { id: 'bledy', label: 'Najczęstsze błędy' }, + { id: 'faq', label: 'FAQ' }, + { id: 'podsumowanie', label: 'Podsumowanie' }, +]; + +const buyingCostRows = [ + { name: 'Wkład własny', who: 'Kupujący', value: 'zwykle 10–20% ceny', note: 'Im wyższy, tym niższa rata i lepsze warunki kredytu.' }, + { name: 'Podatek PCC (rynek wtórny)', who: 'Kupujący', value: '2% wartości', note: 'Zakup pierwszego mieszkania bywa zwolniony z PCC.' }, + { name: 'Taksa notarialna', who: 'Kupujący', value: 'zależna od ceny', note: 'Stawki maksymalne są ustalone rozporządzeniem, do negocjacji.' }, + { name: 'Wpis do księgi wieczystej', who: 'Kupujący', value: '200 zł + 200 zł', note: 'Wpis własności oraz ewentualnej hipoteki.' }, + { name: 'Prowizja pośrednika', who: 'Zależnie od umowy', value: 'zwykle 1–3%', note: 'Tylko jeśli korzystasz z biura nieruchomości.' }, + { name: 'Koszty kredytu', who: 'Kredytobiorca', value: 'prowizja, wycena, ubezpieczenia', note: 'Doliczane do finansowania zakupu z banku.' }, +]; + +const buyingChecklist = [ + 'Przeczytaj aktualny odpis z księgi wieczystej i sprawdź dział III i IV.', + 'Poproś o zaświadczenie o braku zaległości w czynszu i mediach.', + 'Sprawdź, kto figuruje jako właściciel i czy zgadza się ze sprzedającym.', + 'Zapytaj o zaległości i plany remontowe wspólnoty lub spółdzielni.', + 'Obejrzyj mieszkanie za dnia i wieczorem, sprawdź nasłonecznienie.', + 'Skontroluj instalacje: prąd, wodę, ogrzewanie, wentylację i wilgoć.', + 'Ustal, co dokładnie wchodzi w cenę (meble, kuchnia, miejsce postojowe).', + 'Zweryfikuj wysokość czynszu administracyjnego i co obejmuje.', + 'Sprawdź plan zagospodarowania i co może powstać w okolicy.', + 'Policz pełny koszt zakupu z podatkami i kredytem, nie tylko cenę.', +]; + +const buyingMistakes = [ + { title: 'Liczenie tylko ceny mieszkania', text: 'Pomijanie podatków, taksy notarialnej, kosztów kredytu i wykończenia potrafi podnieść budżet o kilkanaście procent.' }, + { title: 'Pominięcie księgi wieczystej', text: 'Brak sprawdzenia działu III i IV grozi zakupem mieszkania z hipoteką, służebnością lub roszczeniem osoby trzeciej.' }, + { title: 'Zakup pod wpływem emocji', text: 'Decyzja po jednym oglądaniu i „bo ktoś inny też jest zainteresowany” to najczęstsza droga do przepłacenia.' }, + { title: 'Brak zapasu w budżecie', text: 'Kredyt na maksimum zdolności nie zostawia poduszki na wzrost rat, remont czy nagłe wydatki.' }, + { title: 'Zaliczka zamiast zadatku', text: 'Przy zaliczce, jeśli druga strona się wycofa, trudniej dochodzić rekompensaty niż przy zadatku.' }, + { title: 'Zignorowanie stanu technicznego', text: 'Ukryta wilgoć, zła instalacja elektryczna czy stare piony to koszty, które ujawniają się dopiero po zakupie.' }, +]; + +const buyingFaq = [ + { + q: 'Ile własnych pieniędzy potrzebuję na start?', + a: 'Do kredytu banki oczekują zwykle wkładu własnego na poziomie 10–20% ceny. Do tego dochodzą koszty transakcyjne: podatek, taksa notarialna, wpisy do księgi wieczystej i ewentualna prowizja pośrednika. Bezpiecznie jest założyć, że oprócz wkładu własnego potrzebujesz jeszcze kilku procent ceny na same formalności.', + }, + { + q: 'Czy lepiej kupić od dewelopera czy z rynku wtórnego?', + a: 'To zależy od Twoich priorytetów. Nowe mieszkanie od dewelopera bywa energooszczędne i objęte gwarancją, ale często wymaga wykończenia i czekania na odbiór. Rynek wtórny to gotowe do zamieszkania lokale w ukształtowanych dzielnicach, za to trzeba dokładniej sprawdzić stan techniczny i stan prawny.', + }, + { + q: 'Czy przy zakupie pierwszego mieszkania zapłacę podatek PCC?', + a: 'Przy zakupie z rynku wtórnego standardowo obowiązuje podatek 2% od wartości. Dla osób kupujących swoje pierwsze mieszkanie lub dom przewidziano jednak zwolnienie z tego podatku. Zawsze potwierdź aktualne warunki u notariusza, bo to on nalicza i odprowadza PCC.', + }, + { + q: 'Jak długo trwa cały proces zakupu?', + a: 'Zakup za gotówkę można sfinalizować nawet w kilka tygodni. Z kredytem hipotecznym realny czas to zwykle 1,5–3 miesiące, bo dochodzi analiza wniosku, wycena nieruchomości i decyzja banku. Warto ten czas uwzględnić w umowie przedwstępnej.', + }, + { + q: 'Co daje umowa przedwstępna?', + a: 'Zabezpiecza obie strony: ustala cenę, termin i warunki finalnej transakcji. Najczęściej towarzyszy jej zadatek. Jeśli od umowy odstąpi kupujący, traci zadatek; jeśli sprzedający, zwraca go w podwójnej wysokości. To silniejsze zabezpieczenie niż zwykła zaliczka.', + }, + { + q: 'Czy muszę korzystać z pośrednika?', + a: 'Nie ma takiego obowiązku. Dobry pośrednik oszczędza czas i pomaga w formalnościach, ale pobiera prowizję. Jeśli kupujesz samodzielnie, tym bardziej zadbaj o weryfikację stanu prawnego i technicznego oraz o dobrze napisane umowy.', + }, +]; + +async function downloadBuyingChecklistPdf() { + const unwrapModuleDefault = (module: T | { default: T }): T => + 'default' in Object(module) ? (module as { default: T }).default : (module as T); + const downloadBlob = (blob: Blob, fileName: string) => { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + link.rel = 'noopener'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + }; + + type PdfMakeRuntime = { + addFontContainer: (fontContainer: unknown) => void; + createPdf: (definition: unknown) => { getBlob: () => Promise }; + }; + + let pdfMake: PdfMakeRuntime; + try { + const pdfMakeModule = await import('pdfmake/build/pdfmake.js'); + const robotoFontModule = await import('pdfmake/build/fonts/Roboto.js'); + pdfMake = unwrapModuleDefault(pdfMakeModule as unknown as PdfMakeRuntime | { default: PdfMakeRuntime }); + pdfMake.addFontContainer(unwrapModuleDefault(robotoFontModule as unknown as { default: unknown })); + } catch (error) { + console.error('Nie udało się załadować generatora PDF', error); + window.alert('Nie udało się przygotować pliku PDF. Odśwież stronę i spróbuj ponownie.'); + return; + } + + const docDefinition = { + pageSize: 'A4', + pageMargins: [48, 48, 48, 48], + defaultStyle: { font: 'Roboto', color: '#14223a', fontSize: 11, lineHeight: 1.35 }, + content: [ + { text: 'PolskaLokalnie', color: '#0b9f5c', fontSize: 12, bold: true, margin: [0, 0, 0, 2] }, + { text: 'Lista kontrolna przy zakupie mieszkania', fontSize: 20, bold: true, margin: [0, 0, 0, 4] }, + { text: '10 rzeczy, które warto sprawdzić przed podpisaniem umowy', color: '#526171', margin: [0, 0, 0, 18] }, + { + ol: buyingChecklist.map((item) => ({ text: item, margin: [0, 0, 0, 6] })), + }, + { + text: 'Materiał informacyjny. Nie stanowi porady prawnej ani podatkowej – w razie wątpliwości skonsultuj się z notariuszem lub doradcą.', + color: '#8793a3', + fontSize: 9, + margin: [0, 22, 0, 0], + }, + ], + }; + + try { + const blob = await pdfMake.createPdf(docDefinition).getBlob(); + downloadBlob(blob, 'lista-kontrolna-zakup-mieszkania.pdf'); + } catch (error) { + console.error('Nie udało się wygenerować PDF', error); + window.alert('Nie udało się wygenerować pliku PDF. Spróbuj ponownie.'); + } +} + +function GuideBuyingPage() { + const navigate = useNavigate(); + const scrollToId = (id: string) => { + const target = document.getElementById(id); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + return ( +
+
+ +
+
+ + + Wróć do poradników + + + + Kupno mieszkania + +

Jak kupić mieszkanie krok po kroku

+

+ Kompletny, uczciwy przewodnik po całym procesie zakupu – od pierwszej decyzji, przez budżet, + kredyt i formalności, aż po odbiór kluczy. Prostym językiem, z listami kontrolnymi i praktycznymi + wskazówkami, które pomogą Ci uniknąć najczęstszych błędów. +

+
+ Aktualna wiedza na 2026 rok + Oparte na sprawdzonych zasadach + Listy kontrolne i PDF + Kalkulatory kosztów +
+
+
+ +
+ + +
+
+
+

Proces zakupu mieszkania w 12 krokach

+

Cała droga w skrócie. Poniżej rozwijamy każdy etap dokładnie.

+
+
+ {buyingProcessSteps.map((step, index) => ( +
+ {index + 1} + + {step.title} + {step.text} +
+ ))} +
+
+ +
+ 01 +

Od czego zacząć zakup mieszkania?

+

+ Zanim zaczniesz przeglądać oferty, odpowiedz sobie na kilka pytań. To one, a nie ładne zdjęcia, + zdecydują o trafności zakupu. Ustal, czy kupujesz dla siebie, dla rodziny, czy pod inwestycję, + jak długo planujesz tu mieszkać i co jest dla Ciebie nienegocjowalne – metraż, liczba pokoi, + piętro, winda, balkon czy miejsce postojowe. +

+

+ Rozdziel potrzeby od zachcianek. Potrzeba to np. druga sypialnia przy powiększającej się rodzinie. + Zachcianka to konkretny kolor kuchni, który i tak zmienisz. Im lepiej rozpoznasz swoje realne potrzeby, + tym mniej ofert będziesz musiał obejrzeć i tym rzadziej dasz się ponieść emocjom. +

+
+ +
+ Wskazówka +

Wypisz na kartce trzy kryteria, których nie odpuścisz, i trzy, które są mile widziane. Wracaj do tej listy przy każdym oglądaniu – to najprostsza ochrona przed impulsywną decyzją.

+
+
+
+ +
+ 02 +

Jak określić budżet?

+

+ Budżet to nie tylko cena mieszkania. To cena powiększona o wszystkie koszty transakcyjne oraz o to, + co wydasz zaraz po zakupie. Zsumuj cenę, podatki, taksę notarialną, wpisy do księgi wieczystej, + ewentualną prowizję pośrednika i koszty kredytu. Dopiero ta kwota pokazuje, na co naprawdę Cię stać. +

+
+ + + + + + + + + + + {buyingCostRows.map((row) => ( + + + + + + + ))} + +
KosztKto ponosiIle (orientacyjnie)Uwagi
{row.name}{row.who}{row.value}{row.note}
+
+
+ +
+ Zostaw zapas +

Nie planuj zakupu na granicy możliwości. Poduszka finansowa na kilka rat, drobny remont i nieprzewidziane wydatki to nie luksus, tylko element bezpiecznego budżetu.

+
+
+
+ +
+ 03 +

Gotówka czy kredyt hipoteczny?

+

+ Zakup za gotówkę jest szybszy, prostszy i pozwala mocniej negocjować, bo sprzedający nie czeka na + decyzję banku. Nie zawsze jednak warto zamrażać wszystkie oszczędności w jednej nieruchomości – + tracisz płynność i poduszkę bezpieczeństwa. +

+

+ Kredyt hipoteczny rozkłada koszt w czasie i pozwala kupić wcześniej, ale oznacza wieloletnie + zobowiązanie, odsetki i zależność od zmian stóp procentowych. Zanim zdecydujesz, porównaj oferty + kilku banków i policz całkowity koszt kredytu, a nie tylko wysokość raty. +

+
+
+

Gotówka

+
    +
  • Szybka i prosta transakcja
  • +
  • Silniejsza pozycja w negocjacjach
  • +
  • Brak odsetek i rat
  • +
  • Uwaga: mniejsza płynność finansowa
  • +
+
+
+

Kredyt hipoteczny

+
    +
  • Kupujesz, nie mając całej kwoty
  • +
  • Oszczędności zostają jako rezerwa
  • +
  • Uwaga: całkowity koszt rośnie o odsetki
  • +
  • Uwaga: wieloletnie zobowiązanie
  • +
+
+
+
+ +
+ 04 +

Jak obliczyć zdolność kredytową?

+

+ Zdolność kredytowa to maksymalna kwota, jaką bank jest gotów Ci pożyczyć. Wpływają na nią głównie: + wysokość i stabilność dochodów, forma zatrudnienia, liczba osób na utrzymaniu, inne zobowiązania + (raty, karty, limity) oraz historia w BIK. Banki liczą też bufor na wypadek wzrostu stóp, dlatego + maksymalna kwota bywa niższa, niż wynikałoby z samej raty. +

+

+ Każdy bank liczy zdolność inaczej, więc warto sprawdzić kilka ofert – różnice bywają znaczne. + Zanim złożysz wnioski, uporządkuj finanse: spłać drobne zobowiązania, zamknij nieużywane karty + i limity, zadbaj o pozytywną historię kredytową. To realnie podnosi zdolność. +

+
+ +
+ Policz orientacyjnie +

Skorzystaj z naszych kalkulatorów po prawej stronie, aby oszacować budżet i porównać koszty. Ostateczną zdolność zawsze potwierdza bank na podstawie dokumentów.

+
+
+
+ +
+ 05 +

Rynek pierwotny czy wtórny?

+

+ Rynek pierwotny to zakup od dewelopera – najczęściej nowe, energooszczędne mieszkanie, często jeszcze + w budowie i do wykończenia. Rynek wtórny to zakup od dotychczasowego właściciela – zwykle mieszkanie + gotowe do zamieszkania, w ukształtowanej okolicy. Oba mają swoje zalety; wybór zależy od tego, czy + wolisz nowe i „pod siebie”, czy gotowe i sprawdzone. +

+
+
+

Rynek pierwotny

+
    +
  • Nowy standard i niższe koszty eksploatacji
  • +
  • Możliwość aranżacji od podstaw
  • +
  • Gwarancja i rękojmia dewelopera
  • +
  • Uwaga: często konieczne wykończenie i czekanie na odbiór
  • +
+
+
+

Rynek wtórny

+
    +
  • Gotowe do zamieszkania od zaraz
  • +
  • Sprawdzona lokalizacja i sąsiedztwo
  • +
  • Możliwość zobaczenia realnego stanu
  • +
  • Uwaga: dokładniej sprawdź stan techniczny i prawny
  • +
+
+
+
+ +
+ 06 +

Jak wybrać lokalizację?

+

+ Lokalizacji nie zmienisz remontem, dlatego to jedno z najważniejszych kryteriów. Sprawdź dojazd do + pracy i szkoły, dostęp do komunikacji, sklepów, przychodni i terenów zielonych. Odwiedź okolicę o + różnych porach dnia – rano, po południu i wieczorem – żeby poznać hałas, ruch i realny klimat miejsca. +

+

+ Zajrzyj do miejscowego planu zagospodarowania przestrzennego, żeby wiedzieć, co może powstać w + sąsiedztwie – park czy ruchliwa droga to duża różnica dla komfortu i przyszłej wartości. Zwróć uwagę + na kierunki świata i nasłonecznienie oraz na to, czy okolica się rozwija, czy raczej podupada. +

+
+ +
+ 07 +

Jak oglądać mieszkanie?

+

+ Nie kupuj po jednym oglądaniu i nie oglądaj tylko przy pełnym słońcu. Przyjdź kilka razy, także + wieczorem, i nie bój się zadawać pytań: o wysokość czynszu i co obejmuje, o koszty ogrzewania, o wiek + instalacji, o plany remontowe budynku i o powód sprzedaży. Rób zdjęcia i notatki, bo po obejrzeniu + kilku ofert łatwo je pomylić. +

+

+ Zwróć uwagę na to, czego nie widać na pierwszy rzut oka: ślady wilgoci w narożnikach i pod oknami, + stan okien i drzwi, ciśnienie wody, działanie wentylacji, wygłuszenie między mieszkaniami. Sprawdź też + części wspólne – klatkę, windę, piwnicę i otoczenie budynku mówią wiele o tym, jak zarządzana jest + nieruchomość. +

+
+ +
+ 08 +

Jak sprawdzić stan techniczny?

+

+ Stan techniczny decyduje o tym, ile dołożysz po zakupie. Najważniejsze punkty to instalacja + elektryczna (czy jest bezpieczna i o odpowiedniej mocy), instalacja wodno-kanalizacyjna i piony, + ogrzewanie, wentylacja oraz izolacja i ślady wilgoci lub grzyba. W starszych budynkach zwróć uwagę na + wiek instalacji i okien – ich wymiana bywa kosztowna. +

+
+ +
+ Rozważ fachowca +

Przy droższym zakupie lub starszym mieszkaniu warto zabrać na oględziny osobę znającą się na budownictwie. Koszt takiej konsultacji jest niewielki wobec potencjalnych ukrytych wad.

+
+
+
+ +
+ 09 +

Jak sprawdzić księgę wieczystą?

+

+ Księga wieczysta to dokument, który pokazuje stan prawny nieruchomości. Odpis możesz sprawdzić online, + znając numer księgi. Zwróć uwagę na cztery działy: oznaczenie nieruchomości, właściciela, prawa i + ograniczenia oraz hipoteki. To podstawowa ochrona przed zakupem mieszkania z niespodziankami. +

+
    +
  • Dział I – co to za nieruchomość: adres, metraż, pomieszczenia przynależne.
  • +
  • Dział II – kto jest właścicielem. Musi zgadzać się z osobą sprzedającą.
  • +
  • Dział III – prawa, ograniczenia, służebności i roszczenia osób trzecich.
  • +
  • Dział IV – hipoteki. Jeśli jest, ustal z bankiem i notariuszem sposób jej wykreślenia.
  • +
+
+ +
+ Nie pomijaj tego kroku +

Wpis obciążający nieruchomość może przejść na Ciebie razem z zakupem. Sprawdzenie księgi to kilka minut, a chroni przed poważnymi problemami.

+
+
+
+ +
+ 10 +

Jak negocjować cenę?

+

+ Negocjacje zacznij od wiedzy. Sprawdź ceny podobnych mieszkań w okolicy, zobacz, jak długo oferta jest + wystawiona i poznaj sytuację sprzedającego. Argumentem nie są emocje, tylko fakty: konieczne remonty, + stan instalacji, wysoki czynsz czy niższe ceny porównywalnych lokali. +

+

+ Bądź konkretny i spokojny. Podaj kwotę, którą jesteś gotów zapłacić, wraz z uzasadnieniem, i miej + wyznaczoną granicę, powyżej której rezygnujesz. Gotowość do szybkiej, pewnej transakcji – zwłaszcza za + gotówkę – to często silniejszy argument niż sama presja na obniżkę. +

+
+ +
+ 11 +

Umowa przedwstępna

+

+ Umowa przedwstępna zabezpiecza ustalenia, zanim dojdzie do finalnej transakcji. Określa cenę, termin + zawarcia umowy właściwej, stan mieszkania i warunki, np. uzyskanie kredytu. Można ją zawrzeć zwykłą + formą pisemną lub u notariusza – forma notarialna daje silniejszą ochronę. +

+
+ +
+ Zadatek a zaliczka +

Zadatek działa dyscyplinująco: jeśli od umowy odstąpi kupujący, traci zadatek; jeśli sprzedający – zwraca go w podwójnej wysokości. Zaliczka jest po prostu zwracana. Dla bezpieczeństwa transakcji częściej stosuje się zadatek.

+
+
+
+ +
+ 12 +

Akt notarialny

+

+ Przeniesienie własności mieszkania musi mieć formę aktu notarialnego. To notariusz sprawdza tożsamość + stron, stan prawny nieruchomości i treść umowy, a następnie składa wniosek o wpis nowego właściciela do + księgi wieczystej. Przy akcie płacisz taksę notarialną, podatek oraz opłaty sądowe. +

+

+ Przed podpisaniem dokładnie przeczytaj projekt aktu – ceny, terminy, sposób i datę wydania mieszkania, + zapisy o zadatku i o wykreśleniu ewentualnej hipoteki. Jeśli czegoś nie rozumiesz, pytaj notariusza. + To jego rola, żeby wyjaśnić Ci każdy zapis. +

+
+ +
+ 13 +

Odbiór mieszkania

+

+ Odbiór to moment sprawdzenia, czy dostajesz mieszkanie w umówionym stanie. Spisz protokół zdawczo-odbiorczy: + stany liczników (prąd, woda, gaz, ciepło), liczbę kluczy i pilotów oraz to, co zostaje w mieszkaniu. + Przy nowym lokalu od dewelopera dokładnie sprawdź jakość wykończenia i zgłoś wszystkie usterki w protokole. +

+
+ +
+ Wszystko na piśmie +

Każdą usterkę i każdy stan licznika zapisz w protokole podpisanym przez obie strony. To Twój dowód na wypadek późniejszych rozliczeń i reklamacji.

+
+
+
+ +
+ 14 +

Przekazanie kluczy i formalności po zakupie

+

+ Po odbiorze zostają formalności, o których łatwo zapomnieć w euforii. Przepisz na siebie umowy na prąd, + gaz, wodę i internet, zgłoś zmianę właściciela do wspólnoty lub spółdzielni, rozważ ubezpieczenie + mieszkania i zamelduj się pod nowym adresem. Zachowaj wszystkie dokumenty zakupu w jednym miejscu. +

+
    +
  • Przepisanie liczników i umów z dostawcami mediów.
  • +
  • Zgłoszenie nowego właściciela do zarządcy budynku.
  • +
  • Ubezpieczenie nieruchomości (a przy kredycie – często wymagane).
  • +
  • Zameldowanie oraz aktualizacja adresu w ważnych instytucjach.
  • +
+
+ +
+
+

Checklista – 10 rzeczy do sprawdzenia przed zakupem

+

Wydrukuj lub pobierz w PDF i miej przy sobie podczas oglądania.

+
+
+ {buyingChecklist.map((item, index) => ( + + ))} +
+ +
+ +
+ 15 +

Najczęstsze błędy kupujących

+

Większości kosztownych pomyłek da się uniknąć, jeśli wie się, gdzie najczęściej się pojawiają.

+
+ {buyingMistakes.map((mistake) => ( +
+ +
+ {mistake.title} +

{mistake.text}

+
+
+ ))} +
+
+ +
+ 16 +

Najczęściej zadawane pytania (FAQ)

+
+ {buyingFaq.map((item) => ( +
+ + {item.q} + + +

{item.a}

+
+ ))} +
+
+ +
+ 17 +

Podsumowanie

+

+ Zakup mieszkania to jedna z ważniejszych decyzji finansowych w życiu, ale rozłożona na etapy przestaje + być przytłaczająca. Zacznij od potrzeb i budżetu, świadomie wybierz sposób finansowania, dokładnie + sprawdź stan techniczny i prawny, a formalności potraktuj poważnie. Najlepszą ochroną są wiedza, + spokój i pisemne ustalenia – emocje zostaw na moment odbioru kluczy. +

+
+ +

Ten poradnik ma charakter informacyjny i nie stanowi porady prawnej ani podatkowej. Szczegóły dotyczące podatków, opłat i umów potwierdzaj u notariusza, doradcy kredytowego lub prawnika.

+
+
+ +
+
+

Gotowy, by znaleźć swoje mieszkanie?

+

Przeglądaj aktualne oferty dopasowane do Twoich potrzeb i budżetu.

+
+ + Zobacz oferty mieszkań + + +
+
+ + +
+
+ ); +} + function GuidesPage() { + const navigate = useNavigate(); return (
@@ -12309,10 +13267,10 @@ function GuidesPage() {
Popularne: - Kredyt hipoteczny - Umowa najmu - Podatek od sprzedaży - Księga wieczysta + Kredyt hipoteczny + Umowa najmu + Podatek od sprzedaży + Księga wieczysta
@@ -12329,23 +13287,37 @@ function GuidesPage() {
- {guideTopics.map((topic) => ( -
- - - -

{topic.title}

-

{topic.text}

- {topic.count} -
- ))} + {guideTopics.map((topic) => { + const isBuying = topic.title === 'Kupno mieszkania'; + // Klikalny kafelek to link do poradnika; pozostale nie maja jeszcze tresci. + const content = ( + <> + + + +

{topic.title}

+

{topic.text}

+ {topic.count} + + ); + + return isBuying ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })}

Polecane artykuły

- Zobacz wszystkie + Zobacz wszystkie
{guideArticles.map((article) => ( @@ -12378,7 +13350,7 @@ function GuidesPage() {

{offer.title}

{offer.count} - Zobacz oferty + Zobacz oferty
void }) { +function SellPage() { + const navigate = useNavigate(); return (
@@ -12468,7 +13441,7 @@ function SellPage({ onNavigate }: { onNavigate: (view: View) => void }) { Pełne wsparcie
- + Dodaj ogłoszenie za darmo Zajmie Ci to tylko 2 minuty
@@ -12512,7 +13485,7 @@ function SellPage({ onNavigate }: { onNavigate: (view: View) => void }) {

Sprawdź wartość swojego mieszkania

Bezpłatna wycena online w 30 sekund

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

Kalkulator zdolności kredytowej

+

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

+
+
+ +
+
+
+

1 Twoje dane

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

2 Dochody miesięczne (netto)

+
+ + + + +
+
+ +
+

3 Zobowiązania miesięczne

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

4 Informacje o kredycie

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

{rating.note}

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

Szczegóły symulacji

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

Jak zwiększyć zdolność?

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

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

+
+
+ + ); + + if (user) { + return ( +
+
+ +
+ {pageBody} +
+
+
+ ); + } + + return ( +
+
+
+ Poradnik kupna + + Kalkulator zdolności kredytowej +
+ {pageBody} +
+
+ ); +} + +function ValuationPage() { + const navigate = useNavigate(); const [step, setStep] = useState(0); const [valuationForm, setValuationForm] = useState(INITIAL_VALUATION_FORM); const [valuationLocationSuggestOpen, setValuationLocationSuggestOpen] = useState(false); @@ -13584,9 +15081,9 @@ function ValuationPage({ onNavigate }: { onNavigate: (view: View) => void }) {
- + Panel główny - + Wycena nieruchomości
@@ -13649,7 +15146,7 @@ function ValuationPage({ onNavigate }: { onNavigate: (view: View) => void }) {
- + Rozpocznij sprzedaż
@@ -13980,6 +15477,121 @@ function ValuationPage({ onNavigate }: { onNavigate: (view: View) => void }) { ); } +// Lista firm i usług pod /firmy. Kategoria pochodzi z query stringa (?kategoria=remonty), +// więc widok da się skopiować, odświeżyć i otworzyć w nowej karcie. +function CompaniesPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const categoryParam = searchParams.get('kategoria'); + const activeCategory = serviceCategories.find((item) => item.slug === categoryParam) ?? null; + + const companies = activeCategory + ? companyDirectory.filter((company) => company.category === activeCategory.slug) + : companyDirectory; + + // Zmiana filtra to zmiana adresu - replace, zeby nie zasmiecac historii przy przelaczaniu. + const selectCategory = (slug: string | null) => { + setSearchParams(slug ? { kategoria: slug } : {}, { replace: true }); + }; + + return ( +
+
+
+
+ Strona główna + + Firmy i usługi + + {activeCategory ? activeCategory.title : 'Wszystkie firmy'} +
+ +
+

{activeCategory ? activeCategory.title : 'Wszystkie firmy i usługi'}

+

+ {activeCategory + ? activeCategory.text + : 'Sprawdzone firmy i specjaliści z całej Polski. Porównaj oferty, oceny i opinie klientów.'} +

+ + {companies.length === 1 ? '1 firma' : `${companies.length} firm`} + {activeCategory ? ` w kategorii „${activeCategory.title}”` : ' w bazie'} + +
+ + + +
+
+ + + + +
+ +
+ {companies.map((company) => ( +
+
+
+

{company.title}

+

{company.rating}

+ {company.text} + {company.city} + Zweryfikowana firma +
+ + +
+ ))} +
+
+ +
+

Jak to działa?

+
+ {serviceSteps.map((step) => ( +
+ + + +
+

{step.title}

+

{step.text}

+
+
+ ))} +
+
+
+
+
+ ); +} + function ServicesPage() { return (
@@ -14017,7 +15629,7 @@ function ServicesPage() {

{category.title}

{category.text}

- Zobacz firmy + Zobacz firmy ))}
@@ -14026,7 +15638,7 @@ function ServicesPage() {

Polecane firmy

- Zobacz wszystkie + Zobacz wszystkie
{serviceCompanies.map((company) => ( @@ -15223,7 +16835,8 @@ function historyShortMarketLabel(market: PriceHistoryMarket) { return tail; } -function PriceHistoryPage({ onNavigate, user }: { onNavigate: (view: View) => void; user: AuthUser | null }) { +function PriceHistoryPage({ user }: { user: AuthUser | null }) { + const navigate = useNavigate(); const [locationInput, setLocationInput] = useState(''); const [selectedMarket, setSelectedMarket] = useState(null); const [propertyType, setPropertyType] = useState('Mieszkanie'); @@ -15413,7 +17026,7 @@ function PriceHistoryPage({ onNavigate, user }: { onNavigate: (view: View) => vo const createCityAlert = () => { if (!user) { setCreateAlertFeedback('Zaloguj się, aby dodać alert cenowy.'); - onNavigate('login'); + navigate(ROUTES.login); return; } if (!selectedMarket) { @@ -15447,9 +17060,9 @@ function PriceHistoryPage({ onNavigate, user }: { onNavigate: (view: View) => vo
- + Panel główny - + Historia cen
@@ -15662,14 +17275,10 @@ function RealListingsBand({ item.yearBuilt ? `${item.yearBuilt} r.` : null, ].filter(Boolean).join(' • '); return ( -
onOpenListing(item.id)} - onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onOpenListing(item.id); } }} - > + // Karta prowadzi do stron oferty. Tytul jest linkiem rozciagnietym na cala + // karte (::after), dzieki czemu mamy prawdziwy - do skopiowania i + // otwarcia w nowej karcie - bez zagniezdzania przyciskow w . +
{offerType === 'SALE' && (
@@ -15691,7 +17300,7 @@ function RealListingsBand({
-

{item.title}

+

{item.title}

{[item.city, item.district].filter(Boolean).join(', ')}

{meta}
@@ -15699,9 +17308,9 @@ function RealListingsBand({
{formatPln(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''} {listingRelativeDate(item.createdAt)} - +
); @@ -16628,7 +18237,8 @@ function LocationPickerMap({ ); } -function MapSearchPage({ initialCity, offerType, onNavigate }: { initialCity: string; offerType: ApiOfferType; onNavigate: (view: View) => void }) { +function MapSearchPage({ initialCity, offerType }: { initialCity: string; offerType: ApiOfferType }) { + const navigate = useNavigate(); const knownInitialCity = findKnownCityKey(initialCity); const startCity = knownInitialCity ?? ''; const initialCityValue = initialCity.trim(); @@ -16877,10 +18487,10 @@ function MapSearchPage({ initialCity, offerType, onNavigate }: { initialCity: st return (
- +
@@ -17004,65 +18614,27 @@ function MapSearchPage({ initialCity, offerType, onNavigate }: { initialCity: st } function Header({ - activeView, - onNavigate, onLogout, hasUnreadFavorites, + onOpenNotification, }: { - activeView: View; - onNavigate: (view: View) => void; onLogout: () => void; hasUnreadFavorites: boolean; + onOpenNotification: (notification: DisplayNotification) => void; }) { + const navigate = useNavigate(); const { user } = useAuth(); + const { notifications: serverNotifications, unreadCount } = useNotifications(); const isLoggedIn = Boolean(user); const isAdmin = user?.role === 'ADMIN'; const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); const notificationPopoverRef = useRef(null); const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); const accountMenuRef = useRef(null); - const [adminUnreadCount, setAdminUnreadCount] = useState(0); - useEffect(() => { - if (!user || user.role === 'ADMIN') { - setAdminUnreadCount(0); - return; - } - - let cancelled = false; - apiFetch<{ count: number }>('/messages/unread-count') - .then((data) => { - if (!cancelled) { - setAdminUnreadCount(data.count); - } - }) - .catch(() => {}); - - return () => { - cancelled = true; - }; - }, [user]); - - const allNotifications = useMemo(() => { - const base = getAllNotifications(); - if (adminUnreadCount <= 0) { - return base; - } - const adminNotification: UserNotification = { - id: 'admin-message-live', - title: 'Nowa wiadomość od administracji', - description: `Masz ${adminUnreadCount} ${adminUnreadCount === 1 ? 'nieprzeczytaną wiadomość' : 'nieprzeczytane wiadomości'} od zespołu Mieszko.`, - timeLabel: 'Teraz', - dayLabel: 'Dzisiaj', - category: 'messages', - icon: 'message', - unread: true, - action: 'arrow', - }; - return [adminNotification, ...base]; - }, [adminUnreadCount]); - const unreadCount = allNotifications.filter((item) => item.unread).length; - const previewNotifications = allNotifications.slice(0, 3); + // Dane powiadomień z centralnego providera. W dzwonku pokazujemy nieprzeczytane (znikają po kliknięciu). + const displayNotifications = toDisplayNotifications(serverNotifications); + const previewNotifications = displayNotifications.filter((item) => item.unread).slice(0, 6); useEffect(() => { if (!isNotificationsOpen) { @@ -17120,15 +18692,13 @@ function Header({ }; }, [isAccountMenuOpen]); - const goToAccountSection = (targetView: View) => { - setIsAccountMenuOpen(false); - onNavigate(targetView); - }; + // Pozycje menu sa linkami; zamkniecie popovera to efekt uboczny kliknięcia, nie nawigacja. + const closeAccountMenu = () => setIsAccountMenuOpen(false); return (
- + {isAdmin && ADMIN}
-