diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java new file mode 100644 index 0000000..708d43d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java @@ -0,0 +1,113 @@ +package pl.polskalokalnie.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import pl.polskalokalnie.mailconfig.MailConfigService; +import pl.polskalokalnie.mailconfig.SmtpConfig; +import pl.polskalokalnie.send.EmailSender; +import pl.polskalokalnie.send.SendResult; + +/** + * Wysylka transakcyjnych wiadomosci e-mail (aktywacja konta, reset hasla) w firmowym szablonie HTML + * (logo + nazwa u gory, tresc, przycisk CTA). Korzysta z konfiguracji SMTP z panelu admina. + * + * Gdy SMTP nie jest skonfigurowany/wlaczony - link jest logowany (tryb deweloperski), aby proces + * dalo sie przetestowac bez realnej skrzynki. Po skonfigurowaniu SMTP wiadomosci ida realnie. + */ +@Service +public class AccountEmailService { + + private static final Logger log = LoggerFactory.getLogger(AccountEmailService.class); + + private final EmailSender emailSender; + private final MailConfigService mailConfigService; + private final String baseUrl; + + public AccountEmailService(EmailSender emailSender, + MailConfigService mailConfigService, + @Value("${app.public-base-url:http://localhost}") String baseUrl) { + this.emailSender = emailSender; + this.mailConfigService = mailConfigService; + this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", ""); + } + + public void sendActivation(String email, String name, String token) { + String link = baseUrl + "/aktywacja/" + token; + String html = template( + "Aktywuj swoje konto", + greeting(name), + "

Dziękujemy za rejestrację w serwisie Polska Lokalnie. Aby dokończyć zakładanie konta i móc" + + " korzystać ze wszystkich funkcji, potwierdź swój adres e-mail klikając poniższy przycisk.

" + + "

Link jest ważny przez 48 godzin. Jeśli to nie Ty" + + " zakładałeś konto, zignoruj tę wiadomość.

", + "Aktywuj konto", + link); + send(email, "Aktywuj konto w serwisie Polska Lokalnie", html, "AKTYWACJA", link); + } + + public void sendPasswordReset(String email, String name, String token) { + String link = baseUrl + "/reset-hasla/" + token; + String html = template( + "Ustaw nowe hasło", + greeting(name), + "

Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta. Kliknij poniższy przycisk, aby ustawić" + + " nowe hasło.

" + + "

Link jest ważny przez 1 godzinę. Dotychczasowe hasło" + + " pozostaje aktywne do momentu ustawienia nowego. Jeśli nie prosiłeś o zmianę hasła, zignoruj tę wiadomość.

", + "Ustaw nowe hasło", + link); + send(email, "Reset hasła w serwisie Polska Lokalnie", html, "RESET HASŁA", link); + } + + private void send(String email, String subject, String html, String label, String link) { + SmtpConfig smtp = mailConfigService.getSmtp(); + boolean configured = smtp.isEnabled() + && smtp.getHost() != null && !smtp.getHost().isBlank(); + if (!configured) { + // Tryb deweloperski - brak skonfigurowanej skrzynki. Logujemy link, by dalo sie przetestowac. + log.info("[E-MAIL:{}] SMTP nieskonfigurowany. Link dla {}: {}", label, email, link); + return; + } + SendResult result = emailSender.send(smtp, email, subject, html); + if (!result.ok()) { + log.warn("[E-MAIL:{}] Nie udalo sie wyslac do {} ({}). Link: {}", label, email, result.error(), link); + } + } + + private static String greeting(String name) { + String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0]; + return "

Cześć" + who + ",

"; + } + + /** Firmowy szablon HTML: naglowek z logo i nazwa, tresc, przycisk CTA, stopka. */ + private String template(String heading, String greetingHtml, String bodyHtml, String ctaLabel, String ctaUrl) { + return "" + + "
" + // Naglowek z logo + nazwa + + "
" + + "\"Polska" + + "" + + "Polska Lokalnie" + + "
" + // Tresc + + "
" + + "

" + heading + "

" + + "
" + greetingHtml + bodyHtml + "
" + + "
" + + "" + + ctaLabel + "
" + + "

Jeśli przycisk nie działa, skopiuj" + + " link do przeglądarki:
" + ctaUrl + "

" + + "
" + // Stopka + + "
" + + "

Polska Lokalnie | SoftSPM

" + + "

Ogłoszenia nieruchomości bez prowizji. Ta wiadomość została wysłana automatycznie - prosimy na nią nie odpowiadać.

" + + "
" + + "
"; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java index e7a321d..d56d384 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthController.java @@ -10,9 +10,14 @@ 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.auth.dto.AccountRequests.EmailRequest; +import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest; +import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest; +import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest; import pl.polskalokalnie.auth.dto.AuthResponse; import pl.polskalokalnie.auth.dto.LoginRequest; import pl.polskalokalnie.auth.dto.RegisterRequest; +import pl.polskalokalnie.auth.dto.RegisterResponse; import pl.polskalokalnie.auth.dto.SocialLoginRequest; import pl.polskalokalnie.auth.dto.UpdateProfileRequest; import pl.polskalokalnie.auth.dto.UserResponse; @@ -28,7 +33,7 @@ public class AuthController { } @PostMapping("/register") - public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request)); } @@ -42,6 +47,48 @@ public class AuthController { return authService.socialLogin(request); } + // --- Aktywacja konta --- + + @PostMapping("/activate") + public ResponseEntity activate(@Valid @RequestBody TokenRequest request) { + authService.activate(request.token()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/resend-activation") + public ResponseEntity resendActivation(@Valid @RequestBody EmailRequest request) { + authService.resendActivation(request.email()); + return ResponseEntity.noContent().build(); + } + + // --- Reset hasla --- + + @PostMapping("/forgot-password") + public ResponseEntity forgotPassword(@Valid @RequestBody EmailRequest request) { + authService.forgotPassword(request.email()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + authService.resetPassword(request.token(), request.password()); + return ResponseEntity.noContent().build(); + } + + // --- Weryfikacja telefonu (OTP SMS) --- + + @PostMapping("/verify-phone") + public ResponseEntity verifyPhone(@Valid @RequestBody VerifyPhoneRequest request) { + authService.verifyPhone(request.email(), request.code()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/resend-phone-otp") + public ResponseEntity resendPhoneOtp(@Valid @RequestBody EmailRequest request) { + authService.resendPhoneOtp(request.email()); + return ResponseEntity.noContent().build(); + } + @GetMapping("/me") public UserResponse me(Authentication authentication) { return authService.currentUser(authentication.getName()); diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 33a59e0..a5485cc 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -1,17 +1,23 @@ package pl.polskalokalnie.auth; +import java.security.SecureRandom; +import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Base64; import java.util.Locale; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; 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; import pl.polskalokalnie.auth.dto.RegisterRequest; +import pl.polskalokalnie.auth.dto.RegisterResponse; import pl.polskalokalnie.auth.dto.SocialLoginRequest; import pl.polskalokalnie.auth.dto.UpdateProfileRequest; import pl.polskalokalnie.auth.dto.UserResponse; @@ -27,12 +33,19 @@ import pl.polskalokalnie.user.UserRepository; @Service public class AuthService { + private static final SecureRandom TOKEN_RANDOM = new SecureRandom(); + private static final int ACTIVATION_TTL_MINUTES = 48 * 60; + private static final int RESET_TTL_MINUTES = 60; + private final UserRepository userRepository; private final BlockedEmailRepository blockedEmailRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final TextModerationService textModerationService; private final LeadSyncService leadSyncService; + private final AuthTokenRepository authTokenRepository; + private final AccountEmailService accountEmailService; + private final PhoneVerificationService phoneVerificationService; public AuthService( UserRepository userRepository, @@ -40,7 +53,10 @@ public class AuthService { PasswordEncoder passwordEncoder, JwtService jwtService, TextModerationService textModerationService, - LeadSyncService leadSyncService + LeadSyncService leadSyncService, + AuthTokenRepository authTokenRepository, + AccountEmailService accountEmailService, + PhoneVerificationService phoneVerificationService ) { this.userRepository = userRepository; this.blockedEmailRepository = blockedEmailRepository; @@ -48,9 +64,13 @@ public class AuthService { this.jwtService = jwtService; this.textModerationService = textModerationService; this.leadSyncService = leadSyncService; + this.authTokenRepository = authTokenRepository; + this.accountEmailService = accountEmailService; + this.phoneVerificationService = phoneVerificationService; } - public AuthResponse register(RegisterRequest request) { + @Transactional + public RegisterResponse register(RegisterRequest request) { String email = normalizeEmail(request.email()); if (blockedEmailRepository.existsByEmailIgnoreCase(email)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji"); @@ -59,7 +79,7 @@ public class AuthService { throw new ResponseStatusException(HttpStatus.CONFLICT, "Konto z tym adresem e-mail już istnieje"); } - textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip()); + textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip(), request.address()); AppUser user = new AppUser(); user.setEmail(email); @@ -68,14 +88,28 @@ public class AuthService { user.setRole(Role.USER); user.setProvider(AuthProvider.LOCAL); user.setAccountType(request.accountType() != null ? request.accountType() : AccountType.PERSONAL); - user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null); + String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null; + user.setPhone(phone); user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null); - // Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora. + user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + // Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane. user.setVerified(false); + user.setPhoneVerified(false); AppUser saved = userRepository.save(user); leadSyncService.syncUser(saved); - return buildAuthResponse(saved); + + // Wysylka linku aktywacyjnego na e-mail. + String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES); + accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token); + + // Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru. + boolean phoneVerificationRequired = phone != null; + if (phoneVerificationRequired) { + phoneVerificationService.sendOtp(saved.getEmail(), phone); + } + + return new RegisterResponse(saved.getEmail(), phoneVerificationRequired); } public AuthResponse login(LoginRequest request) { @@ -89,10 +123,112 @@ public class AuthService { if (user.isBlocked()) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane"); } + // Logowanie mozliwe dopiero po aktywacji konta linkiem z e-maila. + if (!user.isVerified()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny."); + } return buildAuthResponse(user); } + // --- Aktywacja konta --- + + @Transactional + public void activate(String token) { + AuthToken authToken = requireToken(token, TokenPurpose.ACTIVATION); + AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); + user.setVerified(true); + userRepository.save(user); + authToken.setUsedAt(Instant.now()); + authTokenRepository.save(authToken); + } + + // Ponowne wyslanie linku aktywacyjnego. Nie ujawniamy, czy konto istnieje. + @Transactional + public void resendActivation(String email) { + userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> { + if (!user.isVerified()) { + String token = issueToken(user.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES); + accountEmailService.sendActivation(user.getEmail(), user.getFullName(), token); + } + }); + } + + // --- Reset hasla --- + + // Wyslanie linku do resetu hasla. Zawsze zwraca sukces (nie ujawniamy istnienia konta). + @Transactional + public void forgotPassword(String email) { + userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> { + String token = issueToken(user.getEmail(), TokenPurpose.PASSWORD_RESET, RESET_TTL_MINUTES); + accountEmailService.sendPasswordReset(user.getEmail(), user.getFullName(), token); + }); + } + + // Ustawienie nowego hasla. Dotychczasowe haslo dziala az do tego momentu. + @Transactional + public void resetPassword(String token, String newPassword) { + AuthToken authToken = requireToken(token, TokenPurpose.PASSWORD_RESET); + AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); + user.setPasswordHash(passwordEncoder.encode(newPassword)); + // Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne. + user.setVerified(true); + userRepository.save(user); + authToken.setUsedAt(Instant.now()); + authTokenRepository.save(authToken); + } + + // --- Weryfikacja telefonu (OTP) --- + + public void verifyPhone(String email, String code) { + phoneVerificationService.verifyOtp(normalizeEmail(email), code); + } + + public void resendPhoneOtp(String email) { + String normalized = normalizeEmail(email); + AppUser user = userRepository.findByEmailIgnoreCase(normalized) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); + if (user.getPhone() == null || user.getPhone().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu"); + } + phoneVerificationService.sendOtp(normalized, user.getPhone()); + } + + // --- Pomocnicze tokeny --- + + private String issueToken(String email, TokenPurpose purpose, int ttlMinutes) { + // Uniewazniamy poprzednie, nieuzyte tokeny tego samego przeznaczenia. + authTokenRepository.findByUserEmailAndPurpose(email, purpose).forEach(old -> { + if (old.getUsedAt() == null) { + old.setUsedAt(Instant.now()); + authTokenRepository.save(old); + } + }); + byte[] bytes = new byte[32]; + TOKEN_RANDOM.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + + AuthToken authToken = new AuthToken(); + authToken.setToken(token); + authToken.setUserEmail(email); + authToken.setPurpose(purpose); + authToken.setExpiresAt(Instant.now().plus(ttlMinutes, ChronoUnit.MINUTES)); + authTokenRepository.save(authToken); + return token; + } + + private AuthToken requireToken(String token, TokenPurpose purpose) { + AuthToken authToken = authTokenRepository.findByToken(token == null ? "" : token.trim()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link")); + if (authToken.getPurpose() != purpose || !authToken.isUsable()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link wygasł lub został już wykorzystany"); + } + return authToken; + } + /** * Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca. * Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo. diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthToken.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthToken.java new file mode 100644 index 0000000..57ece67 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthToken.java @@ -0,0 +1,91 @@ +package pl.polskalokalnie.auth; + +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; + +/** Jednorazowy token (aktywacja konta / reset hasla) wysylany w linku mailowym. */ +@Entity +@Table(name = "auth_tokens") +public class AuthToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String token; + + @Column(nullable = false, length = 180) + private String userEmail; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TokenPurpose purpose; + + @Column(nullable = false) + private Instant expiresAt; + + private Instant usedAt; + + @Column(nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + public boolean isUsable() { + return usedAt == null && expiresAt != null && expiresAt.isAfter(Instant.now()); + } + + public Long getId() { + return id; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } + + public TokenPurpose getPurpose() { + return purpose; + } + + public void setPurpose(TokenPurpose purpose) { + this.purpose = purpose; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getUsedAt() { + return usedAt; + } + + public void setUsedAt(Instant usedAt) { + this.usedAt = usedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthTokenRepository.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthTokenRepository.java new file mode 100644 index 0000000..a811179 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthTokenRepository.java @@ -0,0 +1,10 @@ +package pl.polskalokalnie.auth; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuthTokenRepository extends JpaRepository { + Optional findByToken(String token); + List findByUserEmailAndPurpose(String userEmail, TokenPurpose purpose); +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtp.java b/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtp.java new file mode 100644 index 0000000..4d302ea --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtp.java @@ -0,0 +1,78 @@ +package pl.polskalokalnie.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +/** Jednorazowy kod SMS (OTP) do potwierdzenia numeru telefonu. Jeden aktywny kod na uzytkownika. */ +@Entity +@Table(name = "phone_otps") +public class PhoneOtp { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 180) + private String userEmail; + + @Column(nullable = false, length = 10) + private String code; + + @Column(nullable = false, length = 30) + private String phone; + + @Column(nullable = false) + private Instant expiresAt; + + @Column(nullable = false) + private int attempts = 0; + + public Long getId() { + return id; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtpRepository.java b/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtpRepository.java new file mode 100644 index 0000000..a892f0e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/PhoneOtpRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.auth; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PhoneOtpRepository extends JpaRepository { + Optional findByUserEmail(String userEmail); + void deleteByUserEmail(String userEmail); +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/PhoneVerificationService.java b/backend/src/main/java/pl/polskalokalnie/auth/PhoneVerificationService.java new file mode 100644 index 0000000..86dd4a4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/PhoneVerificationService.java @@ -0,0 +1,95 @@ +package pl.polskalokalnie.auth; + +import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.mailconfig.MailConfigService; +import pl.polskalokalnie.mailconfig.SmsGatewayConfig; +import pl.polskalokalnie.send.SmsSender; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; + +/** + * Potwierdzanie numeru telefonu kodem SMS (OTP). Kod jednorazowy, wazny 10 minut, maks. 5 prob. + * Gdy bramka SMS nie jest skonfigurowana - kod jest logowany (tryb deweloperski). + */ +@Service +public class PhoneVerificationService { + + private static final Logger log = LoggerFactory.getLogger(PhoneVerificationService.class); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int MAX_ATTEMPTS = 5; + private static final int TTL_MINUTES = 10; + + private final PhoneOtpRepository otpRepository; + private final SmsSender smsSender; + private final MailConfigService mailConfigService; + private final UserRepository userRepository; + + public PhoneVerificationService(PhoneOtpRepository otpRepository, + SmsSender smsSender, + MailConfigService mailConfigService, + UserRepository userRepository) { + this.otpRepository = otpRepository; + this.smsSender = smsSender; + this.mailConfigService = mailConfigService; + this.userRepository = userRepository; + } + + @Transactional + public void sendOtp(String email, String phone) { + if (phone == null || phone.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak numeru telefonu"); + } + String code = String.format("%06d", RANDOM.nextInt(1_000_000)); + + PhoneOtp otp = otpRepository.findByUserEmail(email).orElseGet(PhoneOtp::new); + otp.setUserEmail(email); + otp.setPhone(phone.trim()); + otp.setCode(code); + otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES)); + otp.setAttempts(0); + otpRepository.save(otp); + + SmsGatewayConfig sms = mailConfigService.getSms(); + boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank(); + String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut."; + if (!configured) { + log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code); + return; + } + var result = smsSender.send(sms, phone.trim(), message); + if (!result.ok()) { + log.warn("[SMS-OTP] Nie udalo sie wyslac kodu do {} ({}). Kod: {}", email, phone, code); + } + } + + @Transactional + public void verifyOtp(String email, String code) { + PhoneOtp otp = otpRepository.findByUserEmail(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Brak kodu do weryfikacji - wyślij kod ponownie")); + if (otp.getExpiresAt().isBefore(Instant.now())) { + throw new ResponseStatusException(HttpStatus.GONE, "Kod wygasł - wyślij nowy kod"); + } + if (otp.getAttempts() >= MAX_ATTEMPTS) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Przekroczono liczbę prób - wyślij nowy kod"); + } + if (code == null || !code.trim().equals(otp.getCode())) { + otp.setAttempts(otp.getAttempts() + 1); + otpRepository.save(otp); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy kod"); + } + + AppUser user = userRepository.findByEmailIgnoreCase(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); + user.setPhoneVerified(true); + userRepository.save(user); + otpRepository.delete(otp); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 6769d69..67250c0 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -36,11 +36,19 @@ public class SecurityConfig { .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/error").permitAll() .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll() + .requestMatchers("/api/auth/activate", "/api/auth/resend-activation", + "/api/auth/forgot-password", "/api/auth/reset-password", + "/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll() .requestMatchers("/api/i18n/translate").permitAll() + .requestMatchers(HttpMethod.GET, "/api/gus/company").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() + .requestMatchers(HttpMethod.GET, "/api/banners").permitAll() + .requestMatchers(HttpMethod.POST, "/api/banners/*/click").permitAll() + .requestMatchers(HttpMethod.POST, "/api/payments/autopay/itn").permitAll() + .requestMatchers("/api/promotion/**").authenticated() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/api/listings").authenticated() .anyRequest().authenticated() diff --git a/backend/src/main/java/pl/polskalokalnie/auth/TokenPurpose.java b/backend/src/main/java/pl/polskalokalnie/auth/TokenPurpose.java new file mode 100644 index 0000000..396bbfc --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/TokenPurpose.java @@ -0,0 +1,7 @@ +package pl.polskalokalnie.auth; + +/** Przeznaczenie jednorazowego tokenu wysylanego mailem. */ +public enum TokenPurpose { + ACTIVATION, + PASSWORD_RESET +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java new file mode 100644 index 0000000..8d10183 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/AccountRequests.java @@ -0,0 +1,24 @@ +package pl.polskalokalnie.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** Zbior prostych rekordow zadan dla operacji na koncie (aktywacja, reset hasla, OTP). */ +public final class AccountRequests { + + private AccountRequests() { + } + + public record TokenRequest(@NotBlank String token) { + } + + public record EmailRequest(@NotBlank @Email String email) { + } + + public record ResetPasswordRequest(@NotBlank String token, @NotBlank @Size(min = 8, max = 100) String password) { + } + + public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java index a99f7b1..695fe19 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java @@ -11,6 +11,7 @@ public record RegisterRequest( @NotBlank String fullName, AccountType accountType, String phone, - String nip + String nip, + String address ) { } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterResponse.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterResponse.java new file mode 100644 index 0000000..42896a5 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterResponse.java @@ -0,0 +1,11 @@ +package pl.polskalokalnie.auth.dto; + +/** + * Odpowiedz po rejestracji. Konto wymaga aktywacji linkiem e-mail (brak od razu tokenu logowania). + * phoneVerificationRequired = true, gdy podano numer telefonu i wyslano kod SMS do potwierdzenia. + */ +public record RegisterResponse( + String email, + boolean phoneVerificationRequired +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java index 8b2f2fa..699ef56 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java @@ -23,7 +23,9 @@ public record UserResponse( String nip, LocalDate birthDate, boolean verified, + boolean phoneVerified, boolean blocked, + int promotionCredits, Instant createdAt ) { public static UserResponse from(AppUser user) { @@ -41,7 +43,9 @@ public record UserResponse( user.getNip(), user.getBirthDate(), user.isVerified(), + user.isPhoneVerified(), user.isBlocked(), + user.getPromotionCredits(), user.getCreatedAt() ); } diff --git a/backend/src/main/java/pl/polskalokalnie/banner/AdminBannerController.java b/backend/src/main/java/pl/polskalokalnie/banner/AdminBannerController.java new file mode 100644 index 0000000..8b004f7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/AdminBannerController.java @@ -0,0 +1,52 @@ +package pl.polskalokalnie.banner; + +import jakarta.validation.Valid; +import java.util.List; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Zarzadzanie banerami/promocjami w panelu admina (/admin/promocje). Wymaga roli ADMIN. */ +@RestController +@RequestMapping("/api/admin/banners") +public class AdminBannerController { + + private final BannerService bannerService; + + public AdminBannerController(BannerService bannerService) { + this.bannerService = bannerService; + } + + @GetMapping + public List list() { + return bannerService.listAll(); + } + + @PostMapping + public BannerResponse create(@Valid @RequestBody BannerRequest request) { + return bannerService.create(request); + } + + @PutMapping("/{id}") + public BannerResponse update(@PathVariable Long id, @Valid @RequestBody BannerRequest request) { + return bannerService.update(id, request); + } + + @PatchMapping("/{id}/toggle") + public BannerResponse toggle(@PathVariable Long id) { + return bannerService.toggle(id); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + bannerService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/banner/Banner.java b/backend/src/main/java/pl/polskalokalnie/banner/Banner.java new file mode 100644 index 0000000..1756cc2 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/Banner.java @@ -0,0 +1,176 @@ +package pl.polskalokalnie.banner; + +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.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +/** + * Baner reklamowy/promocyjny przypisany do slotu (miejsca) w serwisie. + * Grafika przechowywana jako data URL (base64) w kolumnie TEXT - jak zdjecia ogloszen. + */ +@Entity +@Table(name = "banners") +public class Banner { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private BannerSlot slot; + + @Column(nullable = false, length = 160) + private String title; + + @Column(length = 400) + private String subtitle; + + // Grafika jako data URL (base64). TEXT, bo przekracza limity VARCHAR. + @Column(columnDefinition = "text") + private String imageUrl; + + @Column(length = 500) + private String linkUrl; + + @Column(length = 60) + private String ctaLabel; + + @Column(nullable = false) + private boolean active = true; + + // Kolejnosc w obrebie slotu (mniejsza = wyzej/pierwsza). + @Column(nullable = false) + private int position = 0; + + // Opcjonalne okno emisji. null = bez ograniczenia. + private Instant startsAt; + private Instant endsAt; + + @Column(nullable = false) + private long impressions = 0; + + @Column(nullable = false) + private long clicks = 0; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public BannerSlot getSlot() { + return slot; + } + + public void setSlot(BannerSlot slot) { + this.slot = slot; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSubtitle() { + return subtitle; + } + + public void setSubtitle(String subtitle) { + this.subtitle = subtitle; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getLinkUrl() { + return linkUrl; + } + + public void setLinkUrl(String linkUrl) { + this.linkUrl = linkUrl; + } + + public String getCtaLabel() { + return ctaLabel; + } + + public void setCtaLabel(String ctaLabel) { + this.ctaLabel = ctaLabel; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } + + public Instant getStartsAt() { + return startsAt; + } + + public void setStartsAt(Instant startsAt) { + this.startsAt = startsAt; + } + + public Instant getEndsAt() { + return endsAt; + } + + public void setEndsAt(Instant endsAt) { + this.endsAt = endsAt; + } + + public long getImpressions() { + return impressions; + } + + public void setImpressions(long impressions) { + this.impressions = impressions; + } + + public long getClicks() { + return clicks; + } + + public void setClicks(long clicks) { + this.clicks = clicks; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/banner/BannerController.java b/backend/src/main/java/pl/polskalokalnie/banner/BannerController.java new file mode 100644 index 0000000..cbee079 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/BannerController.java @@ -0,0 +1,33 @@ +package pl.polskalokalnie.banner; + +import java.util.List; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Publiczny odczyt aktywnych banerow dla slotu oraz zliczanie klikniec. */ +@RestController +@RequestMapping("/api/banners") +public class BannerController { + + private final BannerService bannerService; + + public BannerController(BannerService bannerService) { + this.bannerService = bannerService; + } + + @GetMapping + public List bySlot(@RequestParam BannerSlot slot) { + return bannerService.publicBySlot(slot); + } + + @PostMapping("/{id}/click") + public ResponseEntity registerClick(@PathVariable Long id) { + bannerService.registerClick(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/banner/BannerRepository.java b/backend/src/main/java/pl/polskalokalnie/banner/BannerRepository.java new file mode 100644 index 0000000..f21b1fd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/BannerRepository.java @@ -0,0 +1,22 @@ +package pl.polskalokalnie.banner; + +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 BannerRepository extends JpaRepository { + + List findByOrderBySlotAscPositionAscIdAsc(); + + List findBySlotAndActiveTrueOrderByPositionAscIdAsc(BannerSlot slot); + + @Modifying + @Query("update Banner b set b.impressions = b.impressions + 1 where b.id in :ids") + void incrementImpressions(@Param("ids") List ids); + + @Modifying + @Query("update Banner b set b.clicks = b.clicks + 1 where b.id = :id") + void incrementClicks(@Param("id") Long id); +} diff --git a/backend/src/main/java/pl/polskalokalnie/banner/BannerRequest.java b/backend/src/main/java/pl/polskalokalnie/banner/BannerRequest.java new file mode 100644 index 0000000..4d06d64 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/BannerRequest.java @@ -0,0 +1,20 @@ +package pl.polskalokalnie.banner; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; + +/** Dane wejsciowe tworzenia/edycji banera (panel admina). */ +public record BannerRequest( + @NotNull BannerSlot slot, + @NotBlank String title, + String subtitle, + String imageUrl, + String linkUrl, + String ctaLabel, + Boolean active, + Integer position, + Instant startsAt, + Instant endsAt +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/banner/BannerResponse.java b/backend/src/main/java/pl/polskalokalnie/banner/BannerResponse.java new file mode 100644 index 0000000..3289d0e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/BannerResponse.java @@ -0,0 +1,28 @@ +package pl.polskalokalnie.banner; + +import java.time.Instant; + +/** Pelna reprezentacja banera dla panelu admina (ze statystykami). */ +public record BannerResponse( + Long id, + BannerSlot slot, + String title, + String subtitle, + String imageUrl, + String linkUrl, + String ctaLabel, + boolean active, + int position, + Instant startsAt, + Instant endsAt, + long impressions, + long clicks, + Instant createdAt +) { + public static BannerResponse from(Banner b) { + return new BannerResponse( + b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(), b.getImageUrl(), + b.getLinkUrl(), b.getCtaLabel(), b.isActive(), b.getPosition(), + b.getStartsAt(), b.getEndsAt(), b.getImpressions(), b.getClicks(), b.getCreatedAt()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/banner/BannerService.java b/backend/src/main/java/pl/polskalokalnie/banner/BannerService.java new file mode 100644 index 0000000..1a00f5f --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/BannerService.java @@ -0,0 +1,105 @@ +package pl.polskalokalnie.banner; + +import java.time.Instant; +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 BannerService { + + private final BannerRepository bannerRepository; + + public BannerService(BannerRepository bannerRepository) { + this.bannerRepository = bannerRepository; + } + + // --- Panel admina --- + + @Transactional(readOnly = true) + public List listAll() { + return bannerRepository.findByOrderBySlotAscPositionAscIdAsc().stream() + .map(BannerResponse::from) + .toList(); + } + + @Transactional + public BannerResponse create(BannerRequest request) { + Banner banner = new Banner(); + apply(banner, request); + return BannerResponse.from(bannerRepository.save(banner)); + } + + @Transactional + public BannerResponse update(Long id, BannerRequest request) { + Banner banner = find(id); + apply(banner, request); + return BannerResponse.from(bannerRepository.save(banner)); + } + + @Transactional + public BannerResponse toggle(Long id) { + Banner banner = find(id); + banner.setActive(!banner.isActive()); + return BannerResponse.from(bannerRepository.save(banner)); + } + + @Transactional + public void delete(Long id) { + bannerRepository.delete(find(id)); + } + + // --- Publiczne --- + + // Aktywne banery danego slotu, w oknie emisji; przy okazji zliczamy wyswietlenia. + @Transactional + public List publicBySlot(BannerSlot slot) { + Instant now = Instant.now(); + List visible = bannerRepository.findBySlotAndActiveTrueOrderByPositionAscIdAsc(slot).stream() + .filter(b -> b.getStartsAt() == null || !b.getStartsAt().isAfter(now)) + .filter(b -> b.getEndsAt() == null || !b.getEndsAt().isBefore(now)) + .toList(); + if (!visible.isEmpty()) { + bannerRepository.incrementImpressions(visible.stream().map(Banner::getId).toList()); + } + return visible.stream().map(PublicBannerResponse::from).toList(); + } + + @Transactional + public void registerClick(Long id) { + // Cichy no-op dla nieistniejacego id - klikniecia nie moga wywrocic UX. + if (bannerRepository.existsById(id)) { + bannerRepository.incrementClicks(id); + } + } + + // --- Wspolne --- + + private Banner find(Long id) { + return bannerRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Baner nie istnieje")); + } + + private void apply(Banner banner, BannerRequest request) { + banner.setSlot(request.slot()); + banner.setTitle(request.title().trim()); + banner.setSubtitle(trimToNull(request.subtitle())); + banner.setImageUrl(trimToNull(request.imageUrl())); + banner.setLinkUrl(trimToNull(request.linkUrl())); + banner.setCtaLabel(trimToNull(request.ctaLabel())); + banner.setActive(request.active() == null || request.active()); + banner.setPosition(request.position() == null ? 0 : request.position()); + banner.setStartsAt(request.startsAt()); + banner.setEndsAt(request.endsAt()); + } + + 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/banner/BannerSlot.java b/backend/src/main/java/pl/polskalokalnie/banner/BannerSlot.java new file mode 100644 index 0000000..e24a062 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/BannerSlot.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.banner; + +/** + * Miejsca (sloty) reklamowe w serwisie. Front renderuje baner przypisany do danego slotu. + * HERO - pasek/karta w sekcji hero na stronie glownej. + * HOME_TOP - szeroki baner pod wyszukiwarka na stronie glownej. + * HOME_MIDDLE - baner miedzy sekcjami ofert (polecane / ostatnio ogladane). + * SIDEBAR - baner w bocznej kolumnie (np. karta oferty). + */ +public enum BannerSlot { + HERO, + HOME_TOP, + HOME_MIDDLE, + SIDEBAR +} diff --git a/backend/src/main/java/pl/polskalokalnie/banner/PublicBannerResponse.java b/backend/src/main/java/pl/polskalokalnie/banner/PublicBannerResponse.java new file mode 100644 index 0000000..1c494d9 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/banner/PublicBannerResponse.java @@ -0,0 +1,18 @@ +package pl.polskalokalnie.banner; + +/** Publiczna, minimalna reprezentacja banera (bez statystyk i harmonogramu). */ +public record PublicBannerResponse( + Long id, + BannerSlot slot, + String title, + String subtitle, + String imageUrl, + String linkUrl, + String ctaLabel +) { + public static PublicBannerResponse from(Banner b) { + return new PublicBannerResponse( + b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(), + b.getImageUrl(), b.getLinkUrl(), b.getCtaLabel()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java index 5eabbf3..0ccafcb 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java +++ b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java @@ -5,11 +5,18 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import pl.polskalokalnie.banner.Banner; +import pl.polskalokalnie.banner.BannerRepository; +import pl.polskalokalnie.banner.BannerSlot; import pl.polskalokalnie.listing.ListingRepository; import pl.polskalokalnie.listing.ListingStatus; import pl.polskalokalnie.listing.OfferType; import pl.polskalokalnie.listing.PropertyListing; import pl.polskalokalnie.listing.PropertyType; +import pl.polskalokalnie.promotion.PromotionPackage; +import pl.polskalokalnie.promotion.PromotionPackageRepository; +import pl.polskalokalnie.promotion.PromotionPlan; +import pl.polskalokalnie.promotion.PromotionPlanRepository; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AuthProvider; import pl.polskalokalnie.user.Role; @@ -20,6 +27,9 @@ public class DataSeeder implements CommandLineRunner { private final UserRepository userRepository; private final ListingRepository listingRepository; + private final BannerRepository bannerRepository; + private final PromotionPlanRepository promotionPlanRepository; + private final PromotionPackageRepository promotionPackageRepository; private final PasswordEncoder passwordEncoder; private final String adminEmail; private final String adminPassword; @@ -28,6 +38,9 @@ public class DataSeeder implements CommandLineRunner { public DataSeeder( UserRepository userRepository, ListingRepository listingRepository, + BannerRepository bannerRepository, + PromotionPlanRepository promotionPlanRepository, + PromotionPackageRepository promotionPackageRepository, PasswordEncoder passwordEncoder, @Value("${app.admin.email:admin@mieszko.pl}") String adminEmail, @Value("${app.admin.password:Admin123!}") String adminPassword, @@ -35,6 +48,9 @@ public class DataSeeder implements CommandLineRunner { ) { this.userRepository = userRepository; this.listingRepository = listingRepository; + this.bannerRepository = bannerRepository; + this.promotionPlanRepository = promotionPlanRepository; + this.promotionPackageRepository = promotionPackageRepository; this.passwordEncoder = passwordEncoder; this.adminEmail = adminEmail; this.adminPassword = adminPassword; @@ -45,6 +61,74 @@ public class DataSeeder implements CommandLineRunner { public void run(String... args) { seedAdmin(); seedDemoListings(); + seedBanners(); + seedPromotion(); + } + + private void seedPromotion() { + if (promotionPlanRepository.count() == 0) { + promotionPlanRepository.save(promotionPlan("Wyróżnienie 4 dni", 4, "19.99", 0)); + promotionPlanRepository.save(promotionPlan("Wyróżnienie 14 dni", 14, "49.99", 1)); + promotionPlanRepository.save(promotionPlan("Wyróżnienie 30 dni", 30, "89.99", 2)); + } + if (promotionPackageRepository.count() == 0) { + promotionPackageRepository.save(promotionPackage("Pakiet 5 promowań", 5, "199.99", 0)); + promotionPackageRepository.save(promotionPackage("Pakiet 10 promowań", 10, "349.99", 1)); + } + } + + private PromotionPlan promotionPlan(String name, int days, String price, int position) { + PromotionPlan plan = new PromotionPlan(); + plan.setName(name); + plan.setDurationDays(days); + plan.setPrice(new BigDecimal(price)); + plan.setActive(true); + plan.setPosition(position); + return plan; + } + + private PromotionPackage promotionPackage(String name, int quantity, String price, int position) { + PromotionPackage pack = new PromotionPackage(); + pack.setName(name); + pack.setQuantity(quantity); + pack.setPrice(new BigDecimal(price)); + pack.setActive(true); + pack.setPosition(position); + return pack; + } + + private void seedBanners() { + if (bannerRepository.count() > 0) { + return; + } + bannerRepository.save(banner(BannerSlot.HERO, + "Wyróżnij swoje ogłoszenie", + "Dotrzyj do tysięcy kupujących i najemców - bez prowizji.", + "Dodaj ogłoszenie", "/dodaj-ogloszenie")); + bannerRepository.save(banner(BannerSlot.HOME_TOP, + "Sprawdź wartość swojej nieruchomości", + "Poznaj realistyczną cenę mieszkania w kilka minut.", + "Wyceń mieszkanie", "/wycena")); + bannerRepository.save(banner(BannerSlot.HOME_MIDDLE, + "Planujesz kredyt hipoteczny?", + "Policz zdolność kredytową i sprawdź, na co Cię stać.", + "Sprawdź zdolność", "/kalkulator-zdolnosci")); + bannerRepository.save(banner(BannerSlot.SIDEBAR, + "Zareklamuj się w Polska Lokalnie", + "Zarezerwuj miejsce reklamowe dla swojej firmy.", + "Napisz do nas", "/konto/pomoc")); + } + + private Banner banner(BannerSlot slot, String title, String subtitle, String ctaLabel, String linkUrl) { + Banner banner = new Banner(); + banner.setSlot(slot); + banner.setTitle(title); + banner.setSubtitle(subtitle); + banner.setCtaLabel(ctaLabel); + banner.setLinkUrl(linkUrl); + banner.setActive(true); + banner.setPosition(0); + return banner; } private void seedAdmin() { @@ -93,6 +177,8 @@ public class DataSeeder implements CommandLineRunner { listing.setPrice(price); listing.setArea(area); listing.setRooms(rooms); + listing.setContactName("Biuro Polska Lokalnie"); + listing.setContactPhone("+48 600 100 200"); listing.setOwnerEmail("demo.user@polskalokalnie.pl"); listing.setStatus(status); return listing; diff --git a/backend/src/main/java/pl/polskalokalnie/gus/AdminGusConfigController.java b/backend/src/main/java/pl/polskalokalnie/gus/AdminGusConfigController.java new file mode 100644 index 0000000..490346e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/AdminGusConfigController.java @@ -0,0 +1,29 @@ +package pl.polskalokalnie.gus; + +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; + +/** Panel admina: konfiguracja integracji z GUS (BIR1.1). Wymaga roli ADMIN (/api/admin/**). */ +@RestController +@RequestMapping("/api/admin/gus-config") +public class AdminGusConfigController { + + private final GusService gusService; + + public AdminGusConfigController(GusService gusService) { + this.gusService = gusService; + } + + @GetMapping + public GusConfigResponse get() { + return GusConfigResponse.from(gusService.getConfig()); + } + + @PutMapping + public GusConfigResponse update(@RequestBody GusConfigRequest request) { + return GusConfigResponse.from(gusService.updateConfig(request)); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusCompanyResponse.java b/backend/src/main/java/pl/polskalokalnie/gus/GusCompanyResponse.java new file mode 100644 index 0000000..0888e5c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusCompanyResponse.java @@ -0,0 +1,10 @@ +package pl.polskalokalnie.gus; + +/** Dane firmy pobrane z rejestru REGON - tyle, ile uzupelnia formularz rejestracji. */ +public record GusCompanyResponse( + String name, + String nip, + String regon, + String address +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusConfig.java b/backend/src/main/java/pl/polskalokalnie/gus/GusConfig.java new file mode 100644 index 0000000..17756ff --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusConfig.java @@ -0,0 +1,50 @@ +package pl.polskalokalnie.gus; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Konfiguracja integracji z wyszukiwarka REGON (GUS BIR1.1). Pojedynczy rekord (id=1). + * Gdy sandbox=true uzywamy srodowiska testowego i wbudowanego klucza testowego GUS. + */ +@Entity +@Table(name = "gus_config") +public class GusConfig { + + @Id + private Long id = 1L; + + // Klucz uzytkownika nadany przez GUS - nigdy nie zwracany w odpowiedzi API. + @Column(length = 100) + private String userKey; + + // true = srodowisko testowe BIR1.1, false = produkcyjne. + @Column(nullable = false) + private boolean sandbox = true; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUserKey() { + return userKey; + } + + public void setUserKey(String userKey) { + this.userKey = userKey; + } + + public boolean isSandbox() { + return sandbox; + } + + public void setSandbox(boolean sandbox) { + this.sandbox = sandbox; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/gus/GusConfigRepository.java new file mode 100644 index 0000000..e9bf1ea --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.gus; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GusConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/gus/GusConfigRequest.java new file mode 100644 index 0000000..3834235 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusConfigRequest.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.gus; + +/** Zapis konfiguracji GUS. Puste userKey = zachowaj dotychczasowy klucz. */ +public record GusConfigRequest( + String userKey, + Boolean sandbox +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/gus/GusConfigResponse.java new file mode 100644 index 0000000..bd436e9 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusConfigResponse.java @@ -0,0 +1,12 @@ +package pl.polskalokalnie.gus; + +/** Odpowiedz konfiguracji - nigdy nie zwraca klucza, tylko flage czy jest ustawiony. */ +public record GusConfigResponse( + boolean userKeySet, + boolean sandbox +) { + public static GusConfigResponse from(GusConfig config) { + boolean keySet = config.getUserKey() != null && !config.getUserKey().isBlank(); + return new GusConfigResponse(keySet, config.isSandbox()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusController.java b/backend/src/main/java/pl/polskalokalnie/gus/GusController.java new file mode 100644 index 0000000..acb418a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusController.java @@ -0,0 +1,23 @@ +package pl.polskalokalnie.gus; + +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; + +/** Pobranie danych firmy z rejestru REGON - dostepne publicznie (formularz rejestracji konta firmowego). */ +@RestController +@RequestMapping("/api/gus") +public class GusController { + + private final GusService gusService; + + public GusController(GusService gusService) { + this.gusService = gusService; + } + + @GetMapping("/company") + public GusCompanyResponse company(@RequestParam("nip") String nip) { + return gusService.lookupByNip(nip); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/gus/GusService.java b/backend/src/main/java/pl/polskalokalnie/gus/GusService.java new file mode 100644 index 0000000..5028eb0 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/gus/GusService.java @@ -0,0 +1,283 @@ +package pl.polskalokalnie.gus; + +import java.io.ByteArrayInputStream; +import java.net.URI; +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 java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * Integracja z wyszukiwarka REGON (GUS, usluga BIR1.1). Loguje sie kluczem uzytkownika i wyszukuje + * podmiot po numerze NIP. Sesja (sid) jest wazna okolo godziny - trzymamy ja w pamieci i odnawiamy, + * gdy GUS odpowie pustym wynikiem (tak sygnalizuje nieaktualna sesje). + */ +@Service +public class GusService { + + private static final Long SINGLETON_ID = 1L; + private static final String PROD_URL = "https://wyszukiwarkaregon.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc"; + private static final String TEST_URL = "https://wyszukiwarkaregontest.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc"; + // Klucz testowy udostepniony publicznie przez GUS dla srodowiska testowego. + private static final String TEST_KEY = "abcde12345abcde12345"; + private static final String ACTION_PREFIX = "http://CIS/BIR/PUBL/2014/07/IUslugaBIRzewnPubl/"; + private static final Duration SESSION_TTL = Duration.ofMinutes(30); + // Usluga odpowiada wiadomoscia MTOM (multipart) - koperte SOAP trzeba wyluskac z opakowania MIME. + private static final Pattern ENVELOPE_START = Pattern.compile("<[A-Za-z0-9]*:?Envelope[\\s>]"); + private static final int[] NIP_WEIGHTS = {6, 5, 7, 2, 3, 4, 5, 6, 7}; + + private final GusConfigRepository repository; + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private String sessionId; + private String sessionEndpoint; + private Instant sessionCreatedAt; + + public GusService(GusConfigRepository repository) { + this.repository = repository; + } + + @Transactional + public GusConfig getConfig() { + return repository.findById(SINGLETON_ID).orElseGet(() -> { + GusConfig config = new GusConfig(); + config.setId(SINGLETON_ID); + return repository.save(config); + }); + } + + @Transactional + public GusConfig updateConfig(GusConfigRequest request) { + GusConfig config = getConfig(); + if (request.sandbox() != null) { + config.setSandbox(request.sandbox()); + } + // Klucz nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni). + if (request.userKey() != null && !request.userKey().isBlank()) { + config.setUserKey(request.userKey().trim()); + } + resetSession(); + return repository.save(config); + } + + /** Pobiera dane firmy z rejestru REGON po numerze NIP. */ + public GusCompanyResponse lookupByNip(String rawNip) { + String nip = normalizeNip(rawNip); + GusConfig config = getConfig(); + String endpoint = config.isSandbox() ? TEST_URL : PROD_URL; + String key = config.isSandbox() ? TEST_KEY : config.getUserKey(); + if (key == null || key.isBlank()) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Pobieranie danych z GUS nie jest skonfigurowane. Skontaktuj się z administratorem serwisu."); + } + + Map dane = search(endpoint, key, nip, true); + if (dane.containsKey("ErrorCode")) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON."); + } + return new GusCompanyResponse(dane.get("Nazwa"), nip, dane.get("Regon"), buildAddress(dane)); + } + + // Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze. + private Map search(String endpoint, String key, String nip, boolean canRetry) { + String body = """ + + + %s + + """.formatted(nip); + String response = call(endpoint, "DaneSzukajPodmioty", body, session(endpoint, key)); + Map dane = firstDane(resultOf(response, "DaneSzukajPodmiotyResult")); + if (dane == null) { + if (canRetry) { + resetSession(); + return search(endpoint, key, nip, false); + } + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, + "Rejestr REGON nie zwrócił danych. Spróbuj ponownie za chwilę."); + } + return dane; + } + + private synchronized String session(String endpoint, String key) { + boolean expired = sessionId == null + || !endpoint.equals(sessionEndpoint) + || sessionCreatedAt == null + || sessionCreatedAt.plus(SESSION_TTL).isBefore(Instant.now()); + if (expired) { + String body = "%s".formatted(key); + String sid = resultOf(call(endpoint, "Zaloguj", body, null), "ZalogujResult"); + if (sid == null || sid.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, + "Nie udało się połączyć z rejestrem REGON. Sprawdź klucz dostępowy GUS."); + } + sessionId = sid.trim(); + sessionEndpoint = endpoint; + sessionCreatedAt = Instant.now(); + } + return sessionId; + } + + private synchronized void resetSession() { + sessionId = null; + sessionEndpoint = null; + sessionCreatedAt = null; + } + + private String call(String endpoint, String action, String body, String sid) { + String envelope = """ + + + %s%s + %s + + %s + """.formatted(ACTION_PREFIX, action, endpoint, body); + + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(endpoint)) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/soap+xml; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(envelope, StandardCharsets.UTF_8)); + if (sid != null) { + request.header("sid", sid); + } + try { + HttpResponse response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił błąd (HTTP " + response.statusCode() + ")."); + } + return unwrapSoap(response.body()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Połączenie z rejestrem REGON zostało przerwane."); + } catch (java.io.IOException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się połączyć z rejestrem REGON. Spróbuj ponownie za chwilę."); + } + } + + // Odcina naglowki i stopke MIME (MTOM), zostawiajac sama koperte SOAP. + private static String unwrapSoap(String body) { + Matcher matcher = ENVELOPE_START.matcher(body); + if (!matcher.find()) { + return body; + } + int end = body.lastIndexOf("Envelope>"); + return end < 0 ? body.substring(matcher.start()) : body.substring(matcher.start(), end + "Envelope>".length()); + } + + // Wynik operacji BIR to XML zagniezdzony jako tekst w kopercie SOAP. + private static String resultOf(String soap, String localName) { + NodeList nodes = parse(soap).getElementsByTagNameNS("*", localName); + return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null; + } + + // Zwraca pola pierwszego rekordu albo null, gdy odpowiedz jest pusta (wygasla sesja). + private static Map firstDane(String xml) { + if (xml == null || xml.isBlank()) { + return null; + } + NodeList danes = parse(xml).getElementsByTagName("dane"); + if (danes.getLength() == 0) { + return null; + } + Map values = new HashMap<>(); + NodeList children = danes.item(0).getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child instanceof Element element) { + String value = element.getTextContent().trim(); + if (!value.isEmpty()) { + values.put(element.getTagName(), value); + } + } + } + return values; + } + + private static Document parse(String xml) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił nieczytelną odpowiedź."); + } + } + + // Adres w jednej linii: "ul. Krucza 208/3, 00-925 Warszawa". + private static String buildAddress(Map dane) { + StringBuilder line = new StringBuilder(); + String street = dane.get("Ulica"); + if (street != null) { + line.append(street); + } + String building = dane.get("NrNieruchomosci"); + if (building != null) { + if (line.length() > 0) { + line.append(' '); + } + line.append(building); + String flat = dane.get("NrLokalu"); + if (flat != null) { + line.append('/').append(flat); + } + } + String postalCode = formatPostalCode(dane.get("KodPocztowy")); + String city = dane.get("Miejscowosc"); + String cityLine = postalCode != null && city != null ? postalCode + " " + city : (postalCode != null ? postalCode : city); + if (cityLine != null) { + if (line.length() > 0) { + line.append(", "); + } + line.append(cityLine); + } + return line.length() > 0 ? line.toString() : null; + } + + // Czesc odpowiedzi GUS zawiera kod pocztowy bez myslnika. + private static String formatPostalCode(String code) { + if (code == null) { + return null; + } + String digits = code.replaceAll("\\D", ""); + return digits.length() == 5 ? digits.substring(0, 2) + "-" + digits.substring(2) : code; + } + + /** NIP bez separatorow, z kontrola sumy - bledny numer odrzucamy bez odpytywania GUS. */ + private static String normalizeNip(String rawNip) { + String nip = rawNip == null ? "" : rawNip.replaceAll("\\D", ""); + boolean valid = nip.length() == 10; + if (valid) { + int sum = 0; + for (int i = 0; i < NIP_WEIGHTS.length; i++) { + sum += NIP_WEIGHTS[i] * (nip.charAt(i) - '0'); + } + valid = sum % 11 == (nip.charAt(9) - '0'); + } + if (!valid) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny numer NIP (10 cyfr)."); + } + return nip; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java index 1f16009..036d76e 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingDetailResponse.java @@ -48,7 +48,8 @@ public record ListingDetailResponse( String ownerEmail, ListingStatus status, Instant createdAt, - Long viewsCount + Long viewsCount, + Instant promotedUntil ) { public static ListingDetailResponse from(PropertyListing listing) { return new ListingDetailResponse( @@ -91,7 +92,8 @@ public record ListingDetailResponse( listing.getOwnerEmail(), listing.getStatus(), listing.getCreatedAt(), - listing.getViewsCount() + listing.getViewsCount(), + listing.getPromotedUntil() ); } } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java index 5da8292..31e8f1e 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingResponse.java @@ -27,6 +27,7 @@ public record ListingResponse( String ownerEmail, ListingStatus status, Long viewsCount, + Instant promotedUntil, Instant createdAt ) { public static ListingResponse from(PropertyListing listing) { @@ -50,6 +51,7 @@ public record ListingResponse( listing.getOwnerEmail(), listing.getStatus(), listing.getViewsCount(), + listing.getPromotedUntil(), 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 84b094f..1cd0762 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -28,13 +28,19 @@ public class ListingService { this.notificationService = notificationService; } + // Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania. + private static Comparator promotedFirst() { + return Comparator.comparing(PropertyListing::isPromoted).reversed() + .thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder()); + } + public List search(String city, OfferType offerType, PropertyType propertyType) { return listingRepository.findAll().stream() .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) .filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim())) .filter(listing -> offerType == null || listing.getOfferType() == offerType) .filter(listing -> propertyType == null || listing.getPropertyType() == propertyType) - .sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed()) + .sorted(promotedFirst()) .map(ListingResponse::from) .toList(); } @@ -55,7 +61,7 @@ public class ListingService { public List findMine(String ownerEmail) { return listingRepository.findAll().stream() .filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail())) - .sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed()) + .sorted(promotedFirst()) .map(ListingResponse::from) .toList(); } @@ -173,7 +179,7 @@ public class ListingService { public List findAllForModeration() { return listingRepository.findAll().stream() - .sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed()) + .sorted(promotedFirst()) .map(ListingResponse::from) .toList(); } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java index 317d843..38a473e 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/PropertyListing.java @@ -155,6 +155,9 @@ public class PropertyListing { @Column(nullable = false) private Long viewsCount = 0L; + // Data wygasniecia promowania (wyroznienia). null = nie promowane. + private Instant promotedUntil; + @Column(nullable = false, updatable = false) private Instant createdAt; @@ -481,6 +484,18 @@ public class PropertyListing { this.viewsCount = viewsCount == null ? 0L : Math.max(0L, viewsCount); } + public Instant getPromotedUntil() { + return promotedUntil; + } + + public void setPromotedUntil(Instant promotedUntil) { + this.promotedUntil = promotedUntil; + } + + public boolean isPromoted() { + return promotedUntil != null && promotedUntil.isAfter(Instant.now()); + } + public Instant getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/pl/polskalokalnie/payment/AdminPaymentConfigController.java b/backend/src/main/java/pl/polskalokalnie/payment/AdminPaymentConfigController.java new file mode 100644 index 0000000..b5797f6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/AdminPaymentConfigController.java @@ -0,0 +1,29 @@ +package pl.polskalokalnie.payment; + +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; + +/** Panel admina: konfiguracja bramki AutoPay. Wymaga roli ADMIN (/api/admin/**). */ +@RestController +@RequestMapping("/api/admin/payment-config") +public class AdminPaymentConfigController { + + private final PaymentConfigService paymentConfigService; + + public AdminPaymentConfigController(PaymentConfigService paymentConfigService) { + this.paymentConfigService = paymentConfigService; + } + + @GetMapping + public PaymentConfigResponse get() { + return PaymentConfigResponse.from(paymentConfigService.get()); + } + + @PutMapping + public PaymentConfigResponse update(@RequestBody PaymentConfigRequest request) { + return PaymentConfigResponse.from(paymentConfigService.update(request)); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/payment/AutoPayService.java b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayService.java new file mode 100644 index 0000000..6312f8f --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/AutoPayService.java @@ -0,0 +1,103 @@ +package pl.polskalokalnie.payment; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +/** + * Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media). + * + * Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych + * separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN. + * + * UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja + * AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac. + */ +@Service +public class AutoPayService { + + private static final String PROD_URL = "https://pay.autopay.eu/payment"; + private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment"; + + /** Adres paywalla z podpisanymi parametrami transakcji. */ + public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount, + String description, String customerEmail, String returnUrl) { + String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + + // Kolejnosc parametrow ma znaczenie dla podpisu. + Map params = new LinkedHashMap<>(); + params.put("ServiceID", cfg.getServiceId()); + params.put("OrderID", orderId); + params.put("Amount", amountStr); + params.put("Currency", "PLN"); + params.put("Description", description == null ? "" : description); + params.put("CustomerEmail", customerEmail == null ? "" : customerEmail); + if (returnUrl != null && !returnUrl.isBlank()) { + params.put("ReturnURL", returnUrl); + } + + String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey()); + params.put("Hash", hash); + + String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL; + StringBuilder query = new StringBuilder(); + for (Map.Entry e : params.entrySet()) { + if (query.length() > 0) { + query.append('&'); + } + query.append(enc(e.getKey())).append('=').append(enc(e.getValue())); + } + return base + "?" + query; + } + + /** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */ + public boolean verifyItn(Map params, PaymentConfig cfg) { + if (params == null || cfg == null || cfg.getSecretKey() == null) { + return false; + } + String provided = params.get("Hash"); + if (provided == null || provided.isBlank()) { + return false; + } + List values = new ArrayList<>(); + for (Map.Entry e : params.entrySet()) { + if (!"Hash".equalsIgnoreCase(e.getKey())) { + values.add(e.getValue()); + } + } + String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey()); + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); + } + + /** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */ + public String computeHash(List values, String separator, String key) { + List parts = new ArrayList<>(values); + parts.add(key); + String data = String.join(separator, parts); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (Exception ex) { + throw new IllegalStateException("Nie udalo sie policzyc podpisu platnosci", ex); + } + } + + private static String enc(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfig.java b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfig.java new file mode 100644 index 0000000..fb9d3f3 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfig.java @@ -0,0 +1,93 @@ +package pl.polskalokalnie.payment; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Konfiguracja bramki szybkich platnosci AutoPay (Autopay/Blue Media). Pojedynczy rekord (id=1). + * Gdy wylaczona lub bez danych dostepowych - dziala tryb sandbox (platnosc auto-potwierdzana). + */ +@Entity +@Table(name = "payment_config") +public class PaymentConfig { + + @Id + private Long id = 1L; + + // ServiceID nadawany przez AutoPay. + @Column(length = 40) + private String serviceId; + + // Klucz wspolpracy (sekret) do liczenia hasha - nigdy nie zwracany w odpowiedzi API. + @Column(length = 200) + private String secretKey; + + // Separator wartosci przy liczeniu hasha (AutoPay domyslnie "|"). + @Column(length = 5) + private String hashSeparator = "|"; + + // true = srodowisko testowe (testpay.autopay.eu), false = produkcja (pay.autopay.eu). + @Column(nullable = false) + private boolean sandbox = true; + + // Gdy false lub brak serviceId/secretKey - platnosci sa symulowane (auto-potwierdzenie). + @Column(nullable = false) + private boolean enabled = false; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getServiceId() { + return serviceId; + } + + public void setServiceId(String serviceId) { + this.serviceId = serviceId; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getHashSeparator() { + return hashSeparator == null || hashSeparator.isEmpty() ? "|" : hashSeparator; + } + + public void setHashSeparator(String hashSeparator) { + this.hashSeparator = hashSeparator; + } + + public boolean isSandbox() { + return sandbox; + } + + public void setSandbox(boolean sandbox) { + this.sandbox = sandbox; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** Czy skonfigurowana na tyle, by wywolac realna bramke. W innym wypadku - tryb sandbox. */ + public boolean isLive() { + return enabled + && serviceId != null && !serviceId.isBlank() + && secretKey != null && !secretKey.isBlank(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigRepository.java new file mode 100644 index 0000000..474dc0c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.payment; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PaymentConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigRequest.java new file mode 100644 index 0000000..6a15775 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigRequest.java @@ -0,0 +1,10 @@ +package pl.polskalokalnie.payment; + +public record PaymentConfigRequest( + String serviceId, + String secretKey, + String hashSeparator, + Boolean sandbox, + Boolean enabled +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigResponse.java new file mode 100644 index 0000000..c70695e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigResponse.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.payment; + +/** Odpowiedz konfiguracji - nigdy nie zwraca sekretu, tylko flage czy jest ustawiony. */ +public record PaymentConfigResponse( + String serviceId, + boolean secretKeySet, + String hashSeparator, + boolean sandbox, + boolean enabled +) { + public static PaymentConfigResponse from(PaymentConfig c) { + boolean secretSet = c.getSecretKey() != null && !c.getSecretKey().isBlank(); + return new PaymentConfigResponse(c.getServiceId(), secretSet, c.getHashSeparator(), c.isSandbox(), c.isEnabled()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigService.java b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigService.java new file mode 100644 index 0000000..de860ce --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/payment/PaymentConfigService.java @@ -0,0 +1,53 @@ +package pl.polskalokalnie.payment; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PaymentConfigService { + + private static final Long SINGLETON_ID = 1L; + + private final PaymentConfigRepository repository; + + public PaymentConfigService(PaymentConfigRepository repository) { + this.repository = repository; + } + + @Transactional + public PaymentConfig get() { + return repository.findById(SINGLETON_ID).orElseGet(() -> { + PaymentConfig config = new PaymentConfig(); + config.setId(SINGLETON_ID); + return repository.save(config); + }); + } + + @Transactional + public PaymentConfig update(PaymentConfigRequest request) { + PaymentConfig config = get(); + config.setServiceId(trimToNull(request.serviceId())); + if (request.hashSeparator() != null && !request.hashSeparator().isBlank()) { + config.setHashSeparator(request.hashSeparator().trim()); + } + if (request.sandbox() != null) { + config.setSandbox(request.sandbox()); + } + if (request.enabled() != null) { + config.setEnabled(request.enabled()); + } + // Sekret nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni). + if (request.secretKey() != null && !request.secretKey().isBlank()) { + config.setSecretKey(request.secretKey().trim()); + } + return repository.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/promotion/AdminPromotionController.java b/backend/src/main/java/pl/polskalokalnie/promotion/AdminPromotionController.java new file mode 100644 index 0000000..7ad72a1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/AdminPromotionController.java @@ -0,0 +1,83 @@ +package pl.polskalokalnie.promotion; + +import jakarta.validation.Valid; +import java.util.List; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Panel admina: definiowanie planow i pakietow promowania + historia platnosci. Wymaga roli ADMIN. */ +@RestController +@RequestMapping("/api/admin/promotion") +public class AdminPromotionController { + + private final PromotionService promotionService; + + public AdminPromotionController(PromotionService promotionService) { + this.promotionService = promotionService; + } + + @GetMapping("/plans") + public List plans() { + return promotionService.allPlans(); + } + + @PostMapping("/plans") + public PlanResponse createPlan(@Valid @RequestBody PlanRequest request) { + return promotionService.createPlan(request); + } + + @PutMapping("/plans/{id}") + public PlanResponse updatePlan(@PathVariable Long id, @Valid @RequestBody PlanRequest request) { + return promotionService.updatePlan(id, request); + } + + @PatchMapping("/plans/{id}/toggle") + public PlanResponse togglePlan(@PathVariable Long id) { + return promotionService.togglePlan(id); + } + + @DeleteMapping("/plans/{id}") + public ResponseEntity deletePlan(@PathVariable Long id) { + promotionService.deletePlan(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/packages") + public List packages() { + return promotionService.allPackages(); + } + + @PostMapping("/packages") + public PackageResponse createPackage(@Valid @RequestBody PackageRequest request) { + return promotionService.createPackage(request); + } + + @PutMapping("/packages/{id}") + public PackageResponse updatePackage(@PathVariable Long id, @Valid @RequestBody PackageRequest request) { + return promotionService.updatePackage(id, request); + } + + @PatchMapping("/packages/{id}/toggle") + public PackageResponse togglePackage(@PathVariable Long id) { + return promotionService.togglePackage(id); + } + + @DeleteMapping("/packages/{id}") + public ResponseEntity deletePackage(@PathVariable Long id) { + promotionService.deletePackage(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/orders") + public List orders() { + return promotionService.allOrders(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnController.java b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnController.java new file mode 100644 index 0000000..63ec628 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/AutoPayItnController.java @@ -0,0 +1,56 @@ +package pl.polskalokalnie.promotion; + +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import pl.polskalokalnie.payment.AutoPayService; +import pl.polskalokalnie.payment.PaymentConfig; +import pl.polskalokalnie.payment.PaymentConfigService; + +/** + * Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification). + * Weryfikuje podpis i oznacza zamowienie jako oplacone. + * + * UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje + * parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie + * i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay. + */ +@RestController +@RequestMapping("/api/payments/autopay") +public class AutoPayItnController { + + private final AutoPayService autoPayService; + private final PaymentConfigService paymentConfigService; + private final PromotionService promotionService; + + public AutoPayItnController(AutoPayService autoPayService, + PaymentConfigService paymentConfigService, + PromotionService promotionService) { + this.autoPayService = autoPayService; + this.paymentConfigService = paymentConfigService; + this.promotionService = promotionService; + } + + @PostMapping("/itn") + public ResponseEntity itn(@RequestParam Map params) { + PaymentConfig cfg = paymentConfigService.get(); + if (!autoPayService.verifyItn(params, cfg)) { + return ResponseEntity.badRequest().body("ERROR: invalid hash"); + } + String orderId = params.get("OrderID"); + if (orderId != null && isSuccess(params)) { + promotionService.markPaidByExternalId(orderId); + } + return ResponseEntity.ok("OK"); + } + + private static boolean isSuccess(Map params) { + String status = params.getOrDefault("PaymentStatus", + params.getOrDefault("Status", params.getOrDefault("paymentStatus", "SUCCESS"))); + String s = status == null ? "" : status.trim().toUpperCase(); + return s.equals("SUCCESS") || s.equals("CONFIRMED") || s.equals("TRUE") || s.equals("PENDING"); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/CheckoutRequest.java b/backend/src/main/java/pl/polskalokalnie/promotion/CheckoutRequest.java new file mode 100644 index 0000000..9390146 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/CheckoutRequest.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.promotion; + +import jakarta.validation.constraints.NotNull; + +/** Zamowienie promowania: plan (z listingId) albo pakiet. */ +public record CheckoutRequest( + @NotNull PromotionKind kind, + Long planId, + Long packageId, + Long listingId, + String method +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/CheckoutResponse.java b/backend/src/main/java/pl/polskalokalnie/promotion/CheckoutResponse.java new file mode 100644 index 0000000..1325386 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/CheckoutResponse.java @@ -0,0 +1,19 @@ +package pl.polskalokalnie.promotion; + +/** + * Wynik rozpoczecia platnosci. + * status = "PAID" (tryb sandbox - od razu oplacone) lub "REDIRECT" (przekierowanie na paywall AutoPay). + */ +public record CheckoutResponse( + String status, + Long orderId, + String redirectUrl +) { + public static CheckoutResponse paid(Long orderId) { + return new CheckoutResponse("PAID", orderId, null); + } + + public static CheckoutResponse redirect(Long orderId, String url) { + return new CheckoutResponse("REDIRECT", orderId, url); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/CreditPromoteRequest.java b/backend/src/main/java/pl/polskalokalnie/promotion/CreditPromoteRequest.java new file mode 100644 index 0000000..50339fe --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/CreditPromoteRequest.java @@ -0,0 +1,10 @@ +package pl.polskalokalnie.promotion; + +import jakarta.validation.constraints.NotNull; + +/** Promowanie oferty z wykorzystaniem kredytu (bez platnosci). */ +public record CreditPromoteRequest( + @NotNull Long listingId, + @NotNull Long planId +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/OrderResponse.java b/backend/src/main/java/pl/polskalokalnie/promotion/OrderResponse.java new file mode 100644 index 0000000..25a26f9 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/OrderResponse.java @@ -0,0 +1,22 @@ +package pl.polskalokalnie.promotion; + +import java.math.BigDecimal; +import java.time.Instant; + +public record OrderResponse( + Long id, + String userEmail, + PromotionKind kind, + String itemName, + BigDecimal amount, + String method, + Long listingId, + OrderStatus status, + Instant createdAt, + Instant paidAt +) { + public static OrderResponse from(PromotionOrder o) { + return new OrderResponse(o.getId(), o.getUserEmail(), o.getKind(), o.getItemName(), o.getAmount(), + o.getMethod(), o.getListingId(), o.getStatus(), o.getCreatedAt(), o.getPaidAt()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/OrderStatus.java b/backend/src/main/java/pl/polskalokalnie/promotion/OrderStatus.java new file mode 100644 index 0000000..833b16a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/OrderStatus.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.promotion; + +/** Status platnosci za promowanie. */ +public enum OrderStatus { + PENDING, + PAID, + FAILED +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PackageRequest.java b/backend/src/main/java/pl/polskalokalnie/promotion/PackageRequest.java new file mode 100644 index 0000000..e93aae0 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PackageRequest.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.promotion; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import java.math.BigDecimal; + +public record PackageRequest( + @NotBlank String name, + @Min(1) int quantity, + @NotNull @PositiveOrZero BigDecimal price, + Boolean active, + Integer position +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PackageResponse.java b/backend/src/main/java/pl/polskalokalnie/promotion/PackageResponse.java new file mode 100644 index 0000000..9fdbb34 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PackageResponse.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.promotion; + +import java.math.BigDecimal; + +public record PackageResponse( + Long id, + String name, + int quantity, + BigDecimal price, + boolean active, + int position +) { + public static PackageResponse from(PromotionPackage p) { + return new PackageResponse(p.getId(), p.getName(), p.getQuantity(), p.getPrice(), p.isActive(), p.getPosition()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PlanRequest.java b/backend/src/main/java/pl/polskalokalnie/promotion/PlanRequest.java new file mode 100644 index 0000000..d79e29f --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PlanRequest.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.promotion; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import java.math.BigDecimal; + +public record PlanRequest( + @NotBlank String name, + @Min(1) int durationDays, + @NotNull @PositiveOrZero BigDecimal price, + Boolean active, + Integer position +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PlanResponse.java b/backend/src/main/java/pl/polskalokalnie/promotion/PlanResponse.java new file mode 100644 index 0000000..7cefed1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PlanResponse.java @@ -0,0 +1,16 @@ +package pl.polskalokalnie.promotion; + +import java.math.BigDecimal; + +public record PlanResponse( + Long id, + String name, + int durationDays, + BigDecimal price, + boolean active, + int position +) { + public static PlanResponse from(PromotionPlan p) { + return new PlanResponse(p.getId(), p.getName(), p.getDurationDays(), p.getPrice(), p.isActive(), p.getPosition()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionController.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionController.java new file mode 100644 index 0000000..57f0419 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionController.java @@ -0,0 +1,59 @@ +package pl.polskalokalnie.promotion; + +import jakarta.validation.Valid; +import java.util.List; +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; + +/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */ +@RestController +@RequestMapping("/api/promotion") +public class PromotionController { + + private final PromotionService promotionService; + + public PromotionController(PromotionService promotionService) { + this.promotionService = promotionService; + } + + @GetMapping("/plans") + public List plans() { + return promotionService.activePlans(); + } + + @GetMapping("/packages") + public List packages() { + return promotionService.activePackages(); + } + + @GetMapping("/me") + public CreditsResponse me(Authentication authentication) { + return new CreditsResponse(promotionService.credits(authentication.getName())); + } + + @PostMapping("/checkout") + public CheckoutResponse checkout(@Valid @RequestBody CheckoutRequest request, Authentication authentication) { + return promotionService.checkout(authentication.getName(), request); + } + + @PostMapping("/promote-with-credit") + public CreditsResponse promoteWithCredit(@Valid @RequestBody CreditPromoteRequest request, Authentication authentication) { + return new CreditsResponse(promotionService.promoteWithCredit(authentication.getName(), request)); + } + + @GetMapping("/order/{id}") + public OrderStatusResponse orderStatus(@PathVariable Long id, Authentication authentication) { + return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id)); + } + + public record CreditsResponse(int credits) { + } + + public record OrderStatusResponse(String status) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionKind.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionKind.java new file mode 100644 index 0000000..52d6cd6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionKind.java @@ -0,0 +1,7 @@ +package pl.polskalokalnie.promotion; + +/** Rodzaj zamowienia promowania: pojedynczy plan (wyroznienie oferty) albo pakiet kredytow. */ +public enum PromotionKind { + PLAN, + PACKAGE +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrder.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrder.java new file mode 100644 index 0000000..cb62bc7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrder.java @@ -0,0 +1,153 @@ +package pl.polskalokalnie.promotion; + +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.PrePersist; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.Instant; + +/** Zamowienie/platnosc za promowanie (plan lub pakiet). Sluzy tez jako historia platnosci. */ +@Entity +@Table(name = "promotion_orders") +public class PromotionOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Unikalny identyfikator przekazywany do bramki (OrderID w AutoPay). + @Column(nullable = false, unique = true, length = 40) + private String externalId; + + @Column(nullable = false, length = 180) + private String userEmail; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PromotionKind kind; + + // Id planu lub pakietu. + private Long refId; + + @Column(length = 160) + private String itemName; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal amount; + + @Column(length = 60) + private String method; + + // Dla planu: promowana oferta. + private Long listingId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OrderStatus status = OrderStatus.PENDING; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + private Instant paidAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public Long getId() { + return id; + } + + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } + + public String getUserEmail() { + return userEmail; + } + + public void setUserEmail(String userEmail) { + this.userEmail = userEmail; + } + + public PromotionKind getKind() { + return kind; + } + + public void setKind(PromotionKind kind) { + this.kind = kind; + } + + public Long getRefId() { + return refId; + } + + public void setRefId(Long refId) { + this.refId = refId; + } + + public String getItemName() { + return itemName; + } + + public void setItemName(String itemName) { + this.itemName = itemName; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public Long getListingId() { + return listingId; + } + + public void setListingId(Long listingId) { + this.listingId = listingId; + } + + public OrderStatus getStatus() { + return status; + } + + public void setStatus(OrderStatus status) { + this.status = status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getPaidAt() { + return paidAt; + } + + public void setPaidAt(Instant paidAt) { + this.paidAt = paidAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrderRepository.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrderRepository.java new file mode 100644 index 0000000..df5ef09 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionOrderRepository.java @@ -0,0 +1,10 @@ +package pl.polskalokalnie.promotion; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PromotionOrderRepository extends JpaRepository { + Optional findByExternalId(String externalId); + List findByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPackage.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPackage.java new file mode 100644 index 0000000..d30158c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPackage.java @@ -0,0 +1,94 @@ +package pl.polskalokalnie.promotion; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.Instant; + +/** Pakiet promowan definiowany przez admina: liczba kredytow (sztuk) w cenie promocyjnej. */ +@Entity +@Table(name = "promotion_packages") +public class PromotionPackage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 120) + private String name; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal price; + + @Column(nullable = false) + private boolean active = true; + + @Column(nullable = false) + private int position = 0; + + @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 int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPackageRepository.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPackageRepository.java new file mode 100644 index 0000000..0e958c9 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPackageRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.promotion; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PromotionPackageRepository extends JpaRepository { + List findByOrderByPositionAscIdAsc(); + List findByActiveTrueOrderByPositionAscIdAsc(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPlan.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPlan.java new file mode 100644 index 0000000..5a42aaf --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPlan.java @@ -0,0 +1,94 @@ +package pl.polskalokalnie.promotion; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.Instant; + +/** Plan promowania definiowany przez admina: czas trwania (dni) i cena. */ +@Entity +@Table(name = "promotion_plans") +public class PromotionPlan { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 120) + private String name; + + @Column(nullable = false) + private int durationDays; + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal price; + + @Column(nullable = false) + private boolean active = true; + + @Column(nullable = false) + private int position = 0; + + @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 int getDurationDays() { + return durationDays; + } + + public void setDurationDays(int durationDays) { + this.durationDays = durationDays; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPlanRepository.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPlanRepository.java new file mode 100644 index 0000000..7a468bd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionPlanRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.promotion; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PromotionPlanRepository extends JpaRepository { + List findByOrderByPositionAscIdAsc(); + List findByActiveTrueOrderByPositionAscIdAsc(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java new file mode 100644 index 0000000..749487c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/promotion/PromotionService.java @@ -0,0 +1,290 @@ +package pl.polskalokalnie.promotion; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +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.listing.ListingRepository; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.payment.AutoPayService; +import pl.polskalokalnie.payment.PaymentConfig; +import pl.polskalokalnie.payment.PaymentConfigService; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; + +@Service +public class PromotionService { + + private final PromotionPlanRepository planRepository; + private final PromotionPackageRepository packageRepository; + private final PromotionOrderRepository orderRepository; + private final UserRepository userRepository; + private final ListingRepository listingRepository; + private final PaymentConfigService paymentConfigService; + private final AutoPayService autoPayService; + private final String baseUrl; + + public PromotionService(PromotionPlanRepository planRepository, + PromotionPackageRepository packageRepository, + PromotionOrderRepository orderRepository, + UserRepository userRepository, + ListingRepository listingRepository, + PaymentConfigService paymentConfigService, + AutoPayService autoPayService, + @Value("${app.public-base-url:http://localhost}") String baseUrl) { + this.planRepository = planRepository; + this.packageRepository = packageRepository; + this.orderRepository = orderRepository; + this.userRepository = userRepository; + this.listingRepository = listingRepository; + this.paymentConfigService = paymentConfigService; + this.autoPayService = autoPayService; + this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", ""); + } + + // --- Publiczne (uzytkownik) --- + + public List activePlans() { + return planRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList(); + } + + public List activePackages() { + return packageRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList(); + } + + public int credits(String email) { + return userRepository.findByEmailIgnoreCase(email).map(AppUser::getPromotionCredits).orElse(0); + } + + @Transactional + public CheckoutResponse checkout(String email, CheckoutRequest request) { + PromotionOrder order = new PromotionOrder(); + order.setUserEmail(email); + order.setKind(request.kind()); + order.setExternalId(generateExternalId()); + order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim()); + + if (request.kind() == PromotionKind.PLAN) { + PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId")) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje")); + PropertyListing listing = requireOwnedListing(request.listingId(), email); + order.setRefId(plan.getId()); + order.setItemName("Promowanie: " + plan.getName()); + order.setAmount(plan.getPrice()); + order.setListingId(listing.getId()); + } else { + PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId")) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje")); + order.setRefId(pack.getId()); + order.setItemName("Pakiet promowan: " + pack.getName()); + order.setAmount(pack.getPrice()); + } + order = orderRepository.save(order); + + PaymentConfig cfg = paymentConfigService.get(); + if (!cfg.isLive()) { + // Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay). + applyPaid(order); + return CheckoutResponse.paid(order.getId()); + } + String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId(); + String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(), + order.getItemName(), email, returnUrl); + return CheckoutResponse.redirect(order.getId(), url); + } + + @Transactional + public void applyPaid(PromotionOrder order) { + if (order.getStatus() == OrderStatus.PAID) { + return; + } + if (order.getKind() == PromotionKind.PLAN) { + PromotionPlan plan = planRepository.findById(order.getRefId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje")); + PropertyListing listing = listingRepository.findById(order.getListingId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje")); + extendPromotion(listing, plan.getDurationDays()); + listingRepository.save(listing); + } else { + PromotionPackage pack = packageRepository.findById(order.getRefId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje")); + AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje")); + user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity()); + userRepository.save(user); + } + order.setStatus(OrderStatus.PAID); + order.setPaidAt(Instant.now()); + orderRepository.save(order); + } + + // Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu. + @Transactional + public void markPaidByExternalId(String externalId) { + orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid); + } + + @Transactional + public int promoteWithCredit(String email, CreditPromoteRequest request) { + AppUser user = userRepository.findByEmailIgnoreCase(email) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje")); + if (user.getPromotionCredits() <= 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania"); + } + PromotionPlan plan = planRepository.findById(request.planId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje")); + PropertyListing listing = requireOwnedListing(request.listingId(), email); + + user.setPromotionCredits(user.getPromotionCredits() - 1); + userRepository.save(user); + extendPromotion(listing, plan.getDurationDays()); + listingRepository.save(listing); + + PromotionOrder order = new PromotionOrder(); + order.setUserEmail(email); + order.setKind(PromotionKind.PLAN); + order.setExternalId(generateExternalId()); + order.setRefId(plan.getId()); + order.setItemName("Promowanie (kredyt): " + plan.getName()); + order.setAmount(BigDecimal.ZERO); + order.setMethod("Kredyt promocji"); + order.setListingId(listing.getId()); + order.setStatus(OrderStatus.PAID); + order.setPaidAt(Instant.now()); + orderRepository.save(order); + + return user.getPromotionCredits(); + } + + public String orderStatus(String email, Long id) { + PromotionOrder order = orderRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje")); + if (!order.getUserEmail().equalsIgnoreCase(email)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia"); + } + return order.getStatus().name(); + } + + // --- Admin --- + + public List allPlans() { + return planRepository.findByOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList(); + } + + public List allPackages() { + return packageRepository.findByOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList(); + } + + public List allOrders() { + return orderRepository.findByOrderByCreatedAtDesc().stream().map(OrderResponse::from).toList(); + } + + @Transactional + public PlanResponse createPlan(PlanRequest request) { + PromotionPlan plan = new PromotionPlan(); + applyPlan(plan, request); + return PlanResponse.from(planRepository.save(plan)); + } + + @Transactional + public PlanResponse updatePlan(Long id, PlanRequest request) { + PromotionPlan plan = planRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje")); + applyPlan(plan, request); + return PlanResponse.from(planRepository.save(plan)); + } + + @Transactional + public PlanResponse togglePlan(Long id) { + PromotionPlan plan = planRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje")); + plan.setActive(!plan.isActive()); + return PlanResponse.from(planRepository.save(plan)); + } + + @Transactional + public void deletePlan(Long id) { + planRepository.deleteById(id); + } + + @Transactional + public PackageResponse createPackage(PackageRequest request) { + PromotionPackage pack = new PromotionPackage(); + applyPackage(pack, request); + return PackageResponse.from(packageRepository.save(pack)); + } + + @Transactional + public PackageResponse updatePackage(Long id, PackageRequest request) { + PromotionPackage pack = packageRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje")); + applyPackage(pack, request); + return PackageResponse.from(packageRepository.save(pack)); + } + + @Transactional + public PackageResponse togglePackage(Long id) { + PromotionPackage pack = packageRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje")); + pack.setActive(!pack.isActive()); + return PackageResponse.from(packageRepository.save(pack)); + } + + @Transactional + public void deletePackage(Long id) { + packageRepository.deleteById(id); + } + + // --- Pomocnicze --- + + private void applyPlan(PromotionPlan plan, PlanRequest request) { + plan.setName(request.name().trim()); + plan.setDurationDays(request.durationDays()); + plan.setPrice(request.price()); + plan.setActive(request.active() == null || request.active()); + plan.setPosition(request.position() == null ? 0 : request.position()); + } + + private void applyPackage(PromotionPackage pack, PackageRequest request) { + pack.setName(request.name().trim()); + pack.setQuantity(request.quantity()); + pack.setPrice(request.price()); + pack.setActive(request.active() == null || request.active()); + pack.setPosition(request.position() == null ? 0 : request.position()); + } + + private void extendPromotion(PropertyListing listing, int days) { + Instant now = Instant.now(); + Instant base = listing.getPromotedUntil() != null && listing.getPromotedUntil().isAfter(now) + ? listing.getPromotedUntil() : now; + listing.setPromotedUntil(base.plus(days, ChronoUnit.DAYS)); + } + + private PropertyListing requireOwnedListing(Long listingId, String email) { + if (listingId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak listingId"); + } + PropertyListing listing = listingRepository.findById(listingId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje")); + if (listing.getOwnerEmail() == null || !listing.getOwnerEmail().equalsIgnoreCase(email)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "To nie jest Twoja oferta"); + } + return listing; + } + + private static Long requireId(Long id, String field) { + if (id == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak " + field); + } + return id; + } + + private static String generateExternalId() { + return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000)); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java index 506f8d1..808be69 100644 --- a/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java +++ b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java @@ -1,7 +1,9 @@ package pl.polskalokalnie.send; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.net.URI; -import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -11,19 +13,21 @@ 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. + * Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). * - * UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z - * dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac. + * Zgodnie z manualem bramki: POST {@code application/json} z polami {@code api_key}, {@code to}, + * {@code message} oraz opcjonalnym {@code creator}. Odpowiedz to JSON + * {@code {"status":"success|error","message":"..."}}. Sukcesem jest HTTP 2xx i {@code status=success}; + * przy bledzie zwracamy komunikat z pola {@code message} bramki. */ @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"; + private final ObjectMapper objectMapper; + + public SmsSender(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } public SendResult send(SmsGatewayConfig config, String phone, String message) { if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank() @@ -33,10 +37,14 @@ public class SmsSender { 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); + + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("api_key", config.getApiKey()); + payload.put("to", phone == null ? "" : phone.trim()); + payload.put("message", message == null ? "" : message); + String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator(); + payload.put("creator", creator); + String jsonBody = objectMapper.writeValueAsString(payload); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(timeout)) @@ -44,24 +52,38 @@ public class SmsSender { 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)) + .header("Content-Type", "application/json; charset=UTF-8") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, 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)); + + JsonNode json = tryParse(body); + String gatewayStatus = json != null && json.hasNonNull("status") ? json.get("status").asText() : null; + String gatewayMessage = json != null && json.hasNonNull("message") ? json.get("message").asText() : null; + boolean http2xx = response.statusCode() >= 200 && response.statusCode() < 300; + + if (http2xx && (gatewayStatus == null || gatewayStatus.equalsIgnoreCase("success"))) { + return SendResult.ok(gatewayMessage != null ? gatewayMessage : "SMS dodany do kolejki."); } - return SendResult.failure("Bramka SMS zwróciła status " + response.statusCode() - + (body.isEmpty() ? "" : ": " + shorten(body))); + String detail = gatewayMessage != null && !gatewayMessage.isBlank() ? gatewayMessage : shorten(body); + return SendResult.failure("Bramka SMS: " + (detail.isBlank() ? "status " + response.statusCode() : detail)); } 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 JsonNode tryParse(String body) { + if (body == null || body.isBlank()) { + return null; + } + try { + return objectMapper.readTree(body); + } catch (Exception ignored) { + return null; + } } private static String shorten(String value) { diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java index fb24b89..cfbfd41 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -65,6 +65,11 @@ public class AppUser { private LocalDate birthDate; + // Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu). + @Column(nullable = false) + @ColumnDefault("0") + private int promotionCredits = 0; + // Konta zakladane przez rejestracje lokalna czekaja na zatwierdzenie przez administratora. @Column(nullable = false) @ColumnDefault("false") @@ -73,6 +78,11 @@ public class AppUser { @Column(nullable = false) private boolean blocked = false; + // Czy numer telefonu zostal potwierdzony kodem SMS (OTP). + @Column(nullable = false) + @ColumnDefault("false") + private boolean phoneVerified = false; + @Column(nullable = false, updatable = false) private Instant createdAt; @@ -183,6 +193,14 @@ public class AppUser { this.birthDate = birthDate; } + public int getPromotionCredits() { + return promotionCredits; + } + + public void setPromotionCredits(int promotionCredits) { + this.promotionCredits = promotionCredits; + } + public boolean isVerified() { return verified; } @@ -191,6 +209,14 @@ public class AppUser { this.verified = verified; } + public boolean isPhoneVerified() { + return phoneVerified; + } + + public void setPhoneVerified(boolean phoneVerified) { + this.phoneVerified = phoneVerified; + } + public boolean isBlocked() { return blocked; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 10e6555..ac9dba0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,20 @@ import { Link, NavLink, Outlet, Route, Routes, useLocation, useNavigate, useNavi import { ROUTES, listingPath, listingEditPath, 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 type { AuthUser, ContactPreference, PreferredLanguage, RegisterPending } from './auth'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; -import { AdminLeadsView, AdminCampaignsView, AdminMailConfigView } from './admin/marketing'; +import { AdminLeadsView, AdminCampaignsView, AdminSmtpConfigView, AdminSmsConfigView } from './admin/marketing'; import { RichTextEditor, SafeHtml, stripHtml, isRichTextEmpty } from './RichText'; +import { compressImageFile } from './media'; +import { BannerSlot } from './Banners'; +import { AdminBannersView } from './admin/banners'; +import { AdminPromotionView } from './admin/promotion'; +import { AdminSettingsView } from './admin/settings'; +import { TermsPage, PrivacyPolicyPage, CookieBanner } from './legal'; +import { SiteFooter } from './Footer'; +import { ActivationPage, PasswordResetPage, PhoneOtpModal } from './AuthPages'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; @@ -66,6 +74,7 @@ export type ApiListingSummary = { ownerEmail: string | null; status: ApiListingStatus; viewsCount: number; + promotedUntil: string | null; createdAt: string; }; @@ -111,6 +120,7 @@ export type ApiListingDetail = { status: ApiListingStatus; createdAt: string; viewsCount?: number; + promotedUntil?: string | null; }; // Ostatnio przegladane oferty - trzymane lokalnie (per urzadzenie) do slajdera na stronie glownej. @@ -1294,32 +1304,6 @@ async function imageUrlToDataUrl(url: string, maxDim = 1280, quality = 0.72): Pr } } -/** - * Kompresja zdjecia przy wgrywaniu: jesli szerokosc przekracza maxWidth, skalujemy do maxWidth - * (proporcjonalnie), inaczej zostawiamy oryginalny rozmiar. Zwraca dataURL JPEG. Gdy cos zawiedzie, - * wraca obiektowy URL oryginalu, zeby dodawanie zdjec zawsze dzialalo. - */ -async function compressImageFile(file: File, maxWidth = 1024, quality = 0.82): Promise { - try { - const bitmap = await createImageBitmap(file); - const scale = Math.min(1, maxWidth / bitmap.width); - const width = Math.max(1, Math.round(bitmap.width * scale)); - const height = Math.max(1, Math.round(bitmap.height * scale)); - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) { - return URL.createObjectURL(file); - } - ctx.drawImage(bitmap, 0, 0, width, height); - bitmap.close(); - return canvas.toDataURL('image/jpeg', quality); - } catch { - return URL.createObjectURL(file); - } -} - /** * Bezpieczny generator ID. crypto.randomUUID istnieje tylko w bezpiecznym kontekscie (HTTPS/localhost); * przy dostepie po IP w sieci lokalnej jest undefined i rzucalby wyjatek, wywalajac apke do bialego ekranu. @@ -3491,6 +3475,10 @@ function App() { {/* Trasy publiczne */} } /> } /> + } /> + } /> + } /> + } /> } /> + + ); } @@ -3821,7 +3811,7 @@ const ADMIN_TAB_META: Record = { users: { title: 'Użytkownicy', subtitle: 'Zarządzaj kontami zarejestrowanych użytkowników.' }, messages: { title: 'Wiadomości', subtitle: 'Podgląd wiadomości wymienianych między użytkownikami.' }, reports: { title: 'Zgłoszenia', subtitle: 'Zgłoszenia dotyczące ogłoszeń i użytkowników wymagające uwagi.' }, - payments: { title: 'Płatności', subtitle: 'Historia płatności i promocji ogłoszeń.' }, + payments: { title: 'Płatności i promowanie', subtitle: 'Plany i pakiety promowania ofert, konfiguracja AutoPay oraz historia płatności.' }, stats: { title: 'Statystyki', subtitle: 'Statystyki aktywności i ruchu w serwisie.' }, settings: { title: 'Ustawienia serwisu', subtitle: 'Globalna konfiguracja serwisu Polska Lokalnie.' }, moderation: { title: 'Weryfikacja ogłoszeń', subtitle: 'Kolejka ogłoszeń oczekujących na moderację.' }, @@ -3831,7 +3821,8 @@ const ADMIN_TAB_META: Record = { 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ń.' }, + smtpConfig: { title: 'Konfiguracja SMTP', subtitle: 'Ustawienia skrzynki e-mail (SMTP) dla kampanii i powiadomień.' }, + smsConfig: { title: 'Bramka SMS', subtitle: 'Ustawienia bramki SMS dla kampanii i powiadomień.' }, }; const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ @@ -3849,7 +3840,8 @@ 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: 'smtpConfig', icon: 'mail', label: 'Konfiguracja SMTP' }, + { id: 'smsConfig', icon: 'message', label: 'Bramka SMS' }, { id: 'forbiddenWords', icon: 'shield', label: 'Zakazane słowa' }, { id: 'categories', icon: 'list', label: 'Kategorie' }, { id: 'locations', icon: 'pin', label: 'Lokalizacje' }, @@ -4978,7 +4970,7 @@ function AdminPage() { )} )} - {!loading && tab === 'payments' && } + {!loading && tab === 'payments' && } {!loading && tab === 'stats' && (
@@ -5128,7 +5120,7 @@ function AdminPage() {
)} - {!loading && tab === 'settings' && } + {!loading && tab === 'settings' && } {!loading && tab === 'moderation' && } {!loading && tab === 'forbiddenWords' && (
@@ -5213,10 +5205,11 @@ function AdminPage() { )} {!loading && tab === 'categories' && } {!loading && tab === 'locations' && } - {!loading && tab === 'promotions' && } + {!loading && tab === 'promotions' && } {!loading && tab === 'leads' && } {!loading && tab === 'campaigns' && } - {!loading && tab === 'mailConfig' && } + {!loading && tab === 'smtpConfig' && } + {!loading && tab === 'smsConfig' && }
@@ -6715,7 +6708,11 @@ function AccountHelpContactPage({

{card.title}

{card.description}

- + {card.id === 'rules' ? ( + {card.action} + ) : ( + + )}
))} @@ -8224,6 +8221,196 @@ function MyApiListingsBand({ onOpenListing }: { onOpenListing: (id: number) => v ); } +type PromoPlan = { id: number; name: string; durationDays: number; price: number; active: boolean; position: number }; +type PromoPackage = { id: number; name: string; quantity: number; price: number; active: boolean; position: number }; + +const formatZl = (n: number) => `${new Intl.NumberFormat('pl-PL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n)} zł`; +const promoErr = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd.'); + +// Modal promowania oferty: wybór planu (4/14/30 dni), płatność AutoPay lub użycie kredytu z pakietu. +function PromoteModal({ listingId, listingTitle, onClose, onDone }: { + listingId: number; + listingTitle?: string; + onClose: () => void; + onDone: () => void; +}) { + const { refreshUser } = useAuth(); + const [plans, setPlans] = useState([]); + const [packages, setPackages] = useState([]); + const [credits, setCredits] = useState(0); + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [pl, pk, me] = await Promise.all([ + apiFetch('/promotion/plans'), + apiFetch('/promotion/packages'), + apiFetch<{ credits: number }>('/promotion/me'), + ]); + if (cancelled) { + return; + } + setPlans(pl); + setPackages(pk); + setCredits(me.credits); + setSelectedPlanId(pl.find((p) => p.durationDays === 14)?.id ?? pl[0]?.id ?? null); + } catch (e) { + if (!cancelled) { + setError(promoErr(e)); + } + } + })(); + return () => { cancelled = true; }; + }, []); + + const payPlan = async () => { + if (!selectedPlanId) { + return; + } + setBusy(true); + setError(null); + try { + const res = await apiFetch<{ status: string; redirectUrl?: string }>('/promotion/checkout', { + method: 'POST', + body: JSON.stringify({ kind: 'PLAN', planId: selectedPlanId, listingId, method: 'AutoPay' }), + }); + if (res.status === 'REDIRECT' && res.redirectUrl) { + window.location.href = res.redirectUrl; + return; + } + await refreshUser(); + setDone('Oferta została wyróżniona. Dziękujemy za płatność!'); + } catch (e) { + setError(promoErr(e)); + } finally { + setBusy(false); + } + }; + + const useCredit = async () => { + if (!selectedPlanId) { + return; + } + setBusy(true); + setError(null); + try { + const res = await apiFetch<{ credits: number }>('/promotion/promote-with-credit', { + method: 'POST', + body: JSON.stringify({ listingId, planId: selectedPlanId }), + }); + setCredits(res.credits); + await refreshUser(); + setDone(`Oferta wyróżniona z pakietu. Pozostało kredytów: ${res.credits}.`); + } catch (e) { + setError(promoErr(e)); + } finally { + setBusy(false); + } + }; + + const buyPackage = async (packageId: number) => { + setBusy(true); + setError(null); + try { + const res = await apiFetch<{ status: string; redirectUrl?: string }>('/promotion/checkout', { + method: 'POST', + body: JSON.stringify({ kind: 'PACKAGE', packageId, method: 'AutoPay' }), + }); + if (res.status === 'REDIRECT' && res.redirectUrl) { + window.location.href = res.redirectUrl; + return; + } + const me = await apiFetch<{ credits: number }>('/promotion/me'); + setCredits(me.credits); + await refreshUser(); + setDone(`Pakiet kupiony. Masz teraz ${me.credits} kredytów promowania.`); + } catch (e) { + setError(promoErr(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+
event.stopPropagation()}> + +
+

Wyróżnij ogłoszenie

+

+ Wyróżnione oferty wyświetlają się wyżej na listach i przyciągają więcej zainteresowanych{listingTitle ? ` - „${listingTitle}"` : ''}. +

+ + {done ? ( + <> +
{done}
+
+ +
+ + ) : ( + <> +
+ Wybierz czas wyróżnienia + {plans.map((p) => ( + + ))} + {plans.length === 0 &&

Ładowanie planów...

} +
+ + {error &&

{error}

} + +
+ {credits > 0 && ( + + )} + +
+ + {packages.length > 0 && ( +
+ Kup pakiet promowań w niższej cenie za sztukę +
+ {packages.map((pk) => ( + + ))} +
+
+ )} + +

Szybka płatność (BLIK, karta, przelew) przez AutoPay.

+ + )} +
+
+ ); +} + function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => void }) { const navigate = useNavigate(); type ListingCategory = 'sale' | 'rent'; @@ -8246,6 +8433,7 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => status: ListingStatus; pausedUntil?: string | null; promoted: boolean; + promotedUntil?: string | null; isApi?: boolean; apiId?: number; apiStatus?: ApiListingStatus; @@ -8491,6 +8679,7 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => const [deleteReason, setDeleteReason] = useState(''); const [pauseModalListingId, setPauseModalListingId] = useState(null); const [pauseDuration, setPauseDuration] = useState<'7' | '14' | '30' | 'manual'>('7'); + const [promoteModalListingId, setPromoteModalListingId] = useState(null); const [listings, setListings] = useState(() => { try { const raw = window.localStorage.getItem(listingsStorageKey); @@ -8508,15 +8697,16 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => window.localStorage.setItem(listingsStorageKey, JSON.stringify(listings)); }, [listings]); - // Prawdziwe ogloszenia uzytkownika z backendu (/listings/mine) - scalane z makietami ponizej. + // Prawdziwe ogloszenia uzytkownika z backendu (/listings/mine). 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 loadMine = useCallback(async () => { + try { + setApiListings(await apiFetch('/listings/mine')); + } catch { + setApiListings([]); + } }, []); + useEffect(() => { void loadMine(); }, [loadMine]); const isApiId = (id: string) => id.startsWith('api-'); const apiIdNum = (id: string) => Number(id.slice(4)); @@ -8545,14 +8735,15 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => category: summary.offerType === 'RENT' ? 'rent' : 'sale', status: summary.status === 'PAUSED' ? 'paused' : 'active', pausedUntil: null, - promoted: false, + promoted: !!summary.promotedUntil && new Date(summary.promotedUntil).getTime() > Date.now(), + promotedUntil: summary.promotedUntil, isApi: true, apiId: summary.id, apiStatus: summary.status, })); - // Prawdziwe ogloszenia na gorze listy, pod nimi makiety. - const allRows: ListingItem[] = [...apiRows, ...listings]; + // Pokazujemy wylacznie prawdziwe ogloszenia z API - kazde z pelnymi opcjami (edycja, promowanie). + const allRows: ListingItem[] = apiRows; const apiSetStatus = (id: string, status: ApiListingStatus) => { const numericId = apiIdNum(id); @@ -8858,7 +9049,7 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => Dodane: {formatDate(item.addedDate)} {item.isApi && W serwisie} - {item.promoted && Promowane} + {item.promoted && Wyróżnione{item.promotedUntil ? ` do ${formatDate(item.promotedUntil.slice(0, 10))}` : ''}}
@@ -8873,6 +9064,7 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) =>
{item.isApi && item.apiId != null && item.status !== 'archived' && } + {item.isApi && item.apiId != null && item.apiStatus === 'APPROVED' && } {(item.isApi ? (item.apiStatus === 'APPROVED' || item.apiStatus === 'PAUSED') : item.status !== 'archived') && } {!item.isApi && } @@ -8910,6 +9102,15 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) =>
)} + {promoteModalListingId != null && ( + row.apiId === promoteModalListingId)?.title} + onClose={() => setPromoteModalListingId(null)} + onDone={() => { setPromoteModalListingId(null); void loadMine(); }} + /> + )} + {pauseModalListingId && (
@@ -10399,10 +10600,33 @@ function AccountPublicProfilePage() { ); } -type LoginStep = 'login' | 'register-choice' | 'register-private' | 'register-company' | 'register-pending'; +type LoginStep = 'login' | 'register-choice' | 'register-private' | 'register-company' | 'register-pending' | 'forgot'; + +// Dane firmy z rejestru REGON (GUS) - odpowiedz /api/gus/company. +type GusCompany = { name: string | null; nip: string; regon: string | null; address: string | null }; + +// Oficjalne logotypy dostawcow logowania spolecznosciowego (SVG w barwach marki). +function GoogleIcon() { + return ( + + ); +} + +function FacebookIcon() { + return ( + + ); +} function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { - const { login, register, socialLogin } = useAuth(); + const { login, register, socialLogin, forgotPassword, resendActivation } = useAuth(); const [step, setStep] = useState('login'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -10412,39 +10636,57 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { const [phone, setPhone] = useState(''); const [companyName, setCompanyName] = useState(''); const [nip, setNip] = useState(''); + const [companyAddress, setCompanyAddress] = useState(''); + const [gusLoading, setGusLoading] = useState(false); const [companyPhone, setCompanyPhone] = useState(''); const [agreeTerms, setAgreeTerms] = useState(false); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); + const [info, setInfo] = useState(null); const [submitting, setSubmitting] = useState(false); - const [pendingUser, setPendingUser] = useState(null); + // Rejestracja: dane oczekujace na aktywacje e-mail i (opcjonalnie) weryfikacje telefonu. + const [pendingEmail, setPendingEmail] = useState(''); + const [pendingPhone, setPendingPhone] = useState(''); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [notActivated, setNotActivated] = useState(false); const runAuth = async (action: () => Promise) => { if (submitting) { return; } setError(null); + setInfo(null); + setNotActivated(false); setSubmitting(true); try { const authedUser = await action(); onLogin(authedUser); } catch (err) { - setError(err instanceof Error ? err.message : 'Nie udało się zalogować. Spróbuj ponownie.'); + const message = err instanceof Error ? err.message : 'Nie udało się zalogować. Spróbuj ponownie.'; + setError(message); + if (/aktywowan/i.test(message)) { + setNotActivated(true); + } } finally { setSubmitting(false); } }; - const runRegister = async (action: () => Promise) => { + // Rejestracja NIE loguje od razu - pokazuje ekran "aktywuj konto" i ewentualnie okno OTP telefonu. + const handleRegister = async (action: () => Promise) => { if (submitting) { return; } setError(null); + setInfo(null); setSubmitting(true); try { - const registeredUser = await action(); - setPendingUser(registeredUser); + const pending = await action(); + setPendingEmail(pending.email); setStep('register-pending'); + if (pending.phoneVerificationRequired) { + setOtpModalOpen(true); + } } catch (err) { setError(err instanceof Error ? err.message : 'Nie udało się utworzyć konta. Spróbuj ponownie.'); } finally { @@ -10455,6 +10697,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { const goToStep = (nextStep: LoginStep) => { setStep(nextStep); setError(null); + setInfo(null); + setNotActivated(false); }; const handleLoginSubmit = (event: ReactFormEvent) => { @@ -10462,6 +10706,34 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { runAuth(() => login(email.trim(), password)); }; + const handleResendActivation = async () => { + try { + await resendActivation((pendingEmail || email).trim()); + setInfo('Wysłaliśmy nowy link aktywacyjny. Sprawdź skrzynkę e-mail.'); + setNotActivated(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Nie udało się wysłać linku.'); + } + }; + + const handleForgotSubmit = async (event: ReactFormEvent) => { + event.preventDefault(); + if (submitting) { + return; + } + setError(null); + setInfo(null); + setSubmitting(true); + try { + await forgotPassword(email.trim()); + setInfo('Jeśli konto o podanym adresie istnieje, wysłaliśmy na nie link do zresetowania hasła. Sprawdź skrzynkę e-mail.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Nie udało się wysłać linku.'); + } finally { + setSubmitting(false); + } + }; + const validateRegisterPasswords = () => { if (password !== confirmPassword) { setError('Hasła nie są identyczne.'); @@ -10480,7 +10752,8 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { return; } const fullName = `${firstName.trim()} ${lastName.trim()}`.trim(); - runRegister(() => register(email.trim(), password, fullName, { + setPendingPhone(phone.trim()); + handleRegister(() => register(email.trim(), password, fullName, { accountType: 'PERSONAL', phone: phone.trim() || undefined, })); @@ -10491,36 +10764,68 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { if (!validateRegisterPasswords()) { return; } - runRegister(() => register(email.trim(), password, companyName.trim(), { + setPendingPhone(companyPhone.trim()); + handleRegister(() => register(email.trim(), password, companyName.trim(), { accountType: 'COMPANY', nip: nip.trim() || undefined, + phone: companyPhone.trim() || undefined, + address: companyAddress.trim() || undefined, })); }; + // Rejestracja firmy: pobranie nazwy i adresu z rejestru REGON (GUS) na podstawie numeru NIP. + const handleGusLookup = async () => { + if (gusLoading) { + return; + } + setError(null); + setInfo(null); + setGusLoading(true); + try { + const company = await apiFetch(`/gus/company?nip=${encodeURIComponent(nip.trim())}`); + setNip(company.nip); + if (company.name) { + setCompanyName(company.name); + } + if (company.address) { + setCompanyAddress(company.address); + } + setInfo(`Pobrano dane z rejestru REGON${company.regon ? ` (REGON ${company.regon})` : ''}. Sprawdź je przed rejestracją.`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Nie udało się pobrać danych z GUS.'); + } finally { + setGusLoading(false); + } + }; + const socialButtons = ( <>
- lub + lub kontynuuj z
- - +
+ + +

Twoje dane są u nas bezpieczne. Stosujemy szyfrowanie SSL.

@@ -10619,13 +10924,19 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { {error &&

{error}

} + {notActivated && ( + + )} + {info &&

{info}

}
- +
-
- - -
+ + + +

Wpisz 10 cyfr numeru NIP, aby pobrać nazwę i adres firmy z rejestru REGON.

+ +
)} + + {step === 'forgot' && ( +
+
+

Nie pamiętasz hasła?

+

Podaj adres e-mail konta - wyślemy link do ustawienia nowego hasła.

+
+ + {error &&

{error}

} + {info &&

{info}

} + + +
+ )}
+ {otpModalOpen && ( + setOtpModalOpen(false)} + onVerified={() => { setOtpModalOpen(false); setInfo('Numer telefonu został potwierdzony.'); }} + /> + )} +
@@ -11109,6 +11486,8 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
+
+
@@ -11179,6 +11558,8 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
+ + {recentlyViewed.length > 0 && (
@@ -11246,6 +11627,8 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
+ + @@ -11543,6 +11926,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { const [publishState, setPublishState] = useState<'idle' | 'saving' | 'success' | 'error'>('idle'); const [publishError, setPublishError] = useState(''); const [virtualTourError, setVirtualTourError] = useState(''); + const [createdListingId, setCreatedListingId] = useState(null); + const [showPromoteModal, setShowPromoteModal] = useState(false); const handlePreviewVirtualTour = () => { try { @@ -11638,7 +12023,8 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { setPublishState('success'); navigate(ROUTES.accountListings); } else { - await apiFetch('/listings', { method: 'POST', body: JSON.stringify(payload) }); + const created = await apiFetch<{ id: number }>('/listings', { method: 'POST', body: JSON.stringify(payload) }); + setCreatedListingId(created?.id ?? null); setPublishState('success'); } } catch (err) { @@ -12613,6 +12999,11 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { {listingDetails.transaction === 'Wynajem' ? 'Wynajmuję' : 'Kupuję'}. Do tego czasu znajdziesz je w „Moich ogłoszeniach” ze statusem „Oczekuje”.

+ {createdListingId != null && ( + + )}
Przejdź do moich ogłoszeń @@ -12622,6 +13013,14 @@ function AddListingPage({ editListingId }: { editListingId?: number }) { Wróć do strony głównej
+ {showPromoteModal && createdListingId != null && ( + setShowPromoteModal(false)} + onDone={() => setShowPromoteModal(false)} + /> + )} ) : ( <> @@ -17608,6 +18007,9 @@ function RealListingsBand({ {listingMarketBadge(item.market)} )} + {!!item.promotedUntil && new Date(item.promotedUntil).getTime() > Date.now() && ( + Wyróżnione + )} -
- - -
{OFFER_TYPE_LABELS[listing.offerType]} {PROPERTY_TYPE_LABELS[listing.propertyType]} + {!!listing.promotedUntil && new Date(listing.promotedUntil).getTime() > Date.now() && ( + Wyróżnione + )}

{listing.title}

{locationLine || listing.city}

-
- {formatPln(listing.price)}{priceSuffix} - {perMeter && {perMeter}} -
@@ -21732,8 +22137,20 @@ function ListingDetailPage({ className="listing-detail-gallery-main-image" onClick={() => openLightbox(activePhoto)} aria-label="Powiększ zdjęcie" + style={{ aspectRatio: String(mainRatio) }} > - {listing.title} + {listing.title} { + const el = event.currentTarget; + if (el.naturalWidth > 0 && el.naturalHeight > 0) { + const ratio = el.naturalWidth / el.naturalHeight; + // Ograniczenie do rozsadnego zakresu (od ~4:5 do ~2:1), by uniknac skrajnosci. + setMainRatio(Math.min(Math.max(ratio, 0.8), 2.0)); + } + }} + /> {gallery.length > 1 && ( <> @@ -21875,9 +22292,11 @@ function ListingDetailPage({ + diff --git a/frontend/src/AuthPages.tsx b/frontend/src/AuthPages.tsx new file mode 100644 index 0000000..dcd2d28 --- /dev/null +++ b/frontend/src/AuthPages.tsx @@ -0,0 +1,211 @@ +// Strony i komponenty procesu konta: aktywacja przez link, ustawienie nowego hasla, okno OTP telefonu. +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { ROUTES } from './routes'; +import { useAuth } from './auth'; + +const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd. Spróbuj ponownie.'); + +function AuthStatusCard({ children }: { children: ReactNode }) { + return ( +
+
+ Polska Lokalnie + {children} +
+
+ ); +} + +// /aktywacja/:token - potwierdzenie adresu e-mail z linku. +export function ActivationPage() { + const { token } = useParams<{ token: string }>(); + const { activateAccount } = useAuth(); + const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading'); + const [message, setMessage] = useState(''); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) { + return; + } + ran.current = true; + (async () => { + try { + await activateAccount(token ?? ''); + setState('ok'); + } catch (e) { + setState('error'); + setMessage(errMsg(e)); + } + })(); + }, [token, activateAccount]); + + return ( + + {state === 'loading' &&

Aktywujemy Twoje konto...

} + {state === 'ok' && ( + <> +
+

Konto zostało aktywowane!

+

Możesz się teraz zalogować i korzystać ze wszystkich funkcji serwisu.

+ Przejdź do logowania + + )} + {state === 'error' && ( + <> +
!
+

Nie udało się aktywować konta

+

{message || 'Link aktywacyjny jest nieprawidłowy lub wygasł.'}

+

Zaloguj się i poproś o ponowne wysłanie linku aktywacyjnego.

+ Przejdź do logowania + + )} +
+ ); +} + +// /reset-hasla/:token - ustawienie nowego hasla. +export function PasswordResetPage() { + const { token } = useParams<{ token: string }>(); + const { resetPassword } = useAuth(); + const navigate = useNavigate(); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + const [busy, setBusy] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + if (password.length < 8) { + setError('Hasło musi mieć co najmniej 8 znaków.'); + return; + } + if (password !== confirm) { + setError('Hasła nie są identyczne.'); + return; + } + setBusy(true); + try { + await resetPassword(token ?? '', password); + setDone(true); + setTimeout(() => navigate(ROUTES.login), 2500); + } catch (e) { + setError(errMsg(e)); + } finally { + setBusy(false); + } + }; + + return ( + + {done ? ( + <> +
+

Hasło zostało zmienione

+

Za chwilę przekierujemy Cię do logowania.

+ Przejdź do logowania + + ) : ( + <> +

Ustaw nowe hasło

+

Wpisz nowe hasło do swojego konta. Dotychczasowe hasło działa do momentu jego zmiany.

+
+ + + {error &&

{error}

} + +
+ + )} +
+ ); +} + +// Okno potwierdzenia numeru telefonu kodem SMS (OTP), pokazywane po rejestracji z podanym telefonem. +export function PhoneOtpModal({ email, phone, onClose, onVerified }: { + email: string; + phone: string; + onClose: () => void; + onVerified: () => void; +}) { + const { verifyPhone, resendPhoneOtp } = useAuth(); + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + const [busy, setBusy] = useState(false); + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + if (cooldown <= 0) { + return; + } + const t = window.setTimeout(() => setCooldown((c) => c - 1), 1000); + return () => window.clearTimeout(t); + }, [cooldown]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setInfo(null); + setBusy(true); + try { + await verifyPhone(email, code.trim()); + onVerified(); + } catch (e) { + setError(errMsg(e)); + } finally { + setBusy(false); + } + }; + + const resend = async () => { + setError(null); + try { + await resendPhoneOtp(email); + setInfo('Wysłaliśmy nowy kod SMS.'); + setCooldown(30); + } catch (e) { + setError(errMsg(e)); + } + }; + + return ( +
+
+ +
📱
+

Potwierdź numer telefonu

+

Wysłaliśmy kod SMS na numer {phone}. Wpisz go poniżej, aby potwierdzić poprawność numeru.

+
+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="______" + aria-label="Kod z SMS" + autoFocus + /> + {error &&

{error}

} + {info &&

{info}

} + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/Banners.tsx b/frontend/src/Banners.tsx new file mode 100644 index 0000000..40749b4 --- /dev/null +++ b/frontend/src/Banners.tsx @@ -0,0 +1,97 @@ +// Publiczny komponent slotu reklamowego. Pobiera aktywne banery dla danego miejsca i renderuje +// pierwszy (najwyzszy priorytetem). Zlicza klikniecia. Gdy brak banera - nie renderuje nic. +import { useEffect, useState, type MouseEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { apiFetch } from './auth'; + +export type BannerSlotName = 'HERO' | 'HOME_TOP' | 'HOME_MIDDLE' | 'SIDEBAR'; + +export type PublicBanner = { + id: number; + slot: BannerSlotName; + title: string; + subtitle: string | null; + imageUrl: string | null; + linkUrl: string | null; + ctaLabel: string | null; +}; + +function ArrowIcon() { + return ( + + ); +} + +export function BannerSlot({ slot, className }: { slot: BannerSlotName; className?: string }) { + const [banners, setBanners] = useState([]); + const navigate = useNavigate(); + + useEffect(() => { + let cancelled = false; + apiFetch(`/banners?slot=${slot}`) + .then((data) => { + if (!cancelled) { + setBanners(Array.isArray(data) ? data : []); + } + }) + .catch(() => { + if (!cancelled) { + setBanners([]); + } + }); + return () => { + cancelled = true; + }; + }, [slot]); + + const banner = banners[0]; + if (!banner) { + return null; + } + + const isInternal = !!banner.linkUrl && banner.linkUrl.startsWith('/'); + + const handleClick = (event: MouseEvent) => { + // Zliczenie klikniecia - bez blokowania nawigacji. + apiFetch(`/banners/${banner.id}/click`, { method: 'POST' }).catch(() => {}); + if (!banner.linkUrl) { + event.preventDefault(); + return; + } + if (isInternal) { + event.preventDefault(); + navigate(banner.linkUrl); + } + }; + + const slotClass = `banner-slot-${slot.toLowerCase()}`; + const withImage = banner.imageUrl ? 'has-image' : 'no-image'; + const style = banner.imageUrl + ? { backgroundImage: `linear-gradient(90deg, rgba(9,20,38,0.78) 0%, rgba(9,20,38,0.45) 45%, rgba(9,20,38,0.15) 100%), url(${banner.imageUrl})` } + : undefined; + + return ( + + ); +} diff --git a/frontend/src/Footer.tsx b/frontend/src/Footer.tsx new file mode 100644 index 0000000..8299095 --- /dev/null +++ b/frontend/src/Footer.tsx @@ -0,0 +1,56 @@ +// Globalna stopka serwisu - widoczna na wszystkich stronach i podstronach. +import { Link } from 'react-router-dom'; +import { ROUTES } from './routes'; +import { openCookieSettings } from './legal'; + +export function SiteFooter() { + const year = new Date().getFullYear(); + return ( +
+
+
+ Polska Lokalnie +

Ogłoszenia nieruchomości bez prowizji. Kupuj, wynajmuj i sprzedawaj bezpośrednio, bez pośredników.

+

0 zł za ogłoszenie - bez ukrytych opłat

+
+ + +
+ +
+ © {year} Polska Lokalnie | SoftSPM. Wszelkie prawa zastrzeżone. +
+
+ ); +} diff --git a/frontend/src/admin/banners.tsx b/frontend/src/admin/banners.tsx new file mode 100644 index 0000000..43d1eb2 --- /dev/null +++ b/frontend/src/admin/banners.tsx @@ -0,0 +1,329 @@ +// Panel admina: zarzadzanie banerami i promocjami (/admin/promocje). +// CRUD banerow przypisanych do slotow serwisu, z uploadem grafiki (kompresja) i harmonogramem. +import { useCallback, useEffect, useState, type ChangeEvent } from 'react'; +import { apiFetch } from '../auth'; +import { compressImageFile } from '../media'; + +type IconComponent = (props: { name: string }) => JSX.Element | null; + +type BannerSlotName = 'HERO' | 'HOME_TOP' | 'HOME_MIDDLE' | 'SIDEBAR'; + +type AdminBanner = { + id: number; + slot: BannerSlotName; + title: string; + subtitle: string | null; + imageUrl: string | null; + linkUrl: string | null; + ctaLabel: string | null; + active: boolean; + position: number; + startsAt: string | null; + endsAt: string | null; + impressions: number; + clicks: number; + createdAt: string; +}; + +const SLOT_OPTIONS: { value: BannerSlotName; label: string; hint: string }[] = [ + { value: 'HERO', label: 'Strona główna - hero (góra)', hint: 'Szeroki baner nad wyszukiwarką.' }, + { value: 'HOME_TOP', label: 'Strona główna - pod wyszukiwarką', hint: 'Pasek tuż pod wyszukiwarką.' }, + { value: 'HOME_MIDDLE', label: 'Strona główna - między ofertami', hint: 'Między sekcjami ofert.' }, + { value: 'SIDEBAR', label: 'Karta oferty - panel boczny', hint: 'Kolumna boczna na stronie oferty.' }, +]; + +const slotLabel = (slot: BannerSlotName) => SLOT_OPTIONS.find((o) => o.value === slot)?.label ?? slot; + +type FormState = { + id: number | null; + slot: BannerSlotName; + title: string; + subtitle: string; + imageUrl: string; + linkUrl: string; + ctaLabel: string; + active: boolean; + position: number; + startsAt: string; + endsAt: string; +}; + +const emptyForm = (): FormState => ({ + id: null, + slot: 'HERO', + title: '', + subtitle: '', + imageUrl: '', + linkUrl: '', + ctaLabel: '', + active: true, + position: 0, + startsAt: '', + endsAt: '', +}); + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : 'Wystąpił błąd.'; +} + +// ISO (UTC) -> wartosc dla w czasie lokalnym. +function isoToLocalInput(iso: string | null): string { + if (!iso) { + return ''; + } + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + return ''; + } + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +// datetime-local (czas lokalny) -> ISO (UTC) albo null. +function localInputToIso(value: string): string | null { + if (!value.trim()) { + return null; + } + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} + +export function AdminBannersView({ Icon }: { Icon: IconComponent }) { + const [banners, setBanners] = useState([]); + const [loading, setLoading] = useState(true); + const [form, setForm] = useState(emptyForm()); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [note, setNote] = useState(null); + + const load = useCallback(async () => { + try { + setBanners(await apiFetch('/admin/banners')); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, []); + useEffect(() => { load(); }, [load]); + + const set = (patch: Partial) => setForm((current) => ({ ...current, ...patch })); + + const resetForm = () => { + setForm(emptyForm()); + setError(null); + }; + + const startEdit = (banner: AdminBanner) => { + setForm({ + id: banner.id, + slot: banner.slot, + title: banner.title, + subtitle: banner.subtitle ?? '', + imageUrl: banner.imageUrl ?? '', + linkUrl: banner.linkUrl ?? '', + ctaLabel: banner.ctaLabel ?? '', + active: banner.active, + position: banner.position, + startsAt: isoToLocalInput(banner.startsAt), + endsAt: isoToLocalInput(banner.endsAt), + }); + setNote(null); + setError(null); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const onPickImage = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) { + return; + } + if (!['image/jpeg', 'image/png'].includes(file.type) || file.size > 10 * 1024 * 1024) { + setError('Dozwolone JPG/PNG do 10 MB.'); + return; + } + setUploading(true); + setError(null); + try { + // Banery sa szerokie - kompresujemy do 1600 px. + set({ imageUrl: await compressImageFile(file, 1600) }); + } finally { + setUploading(false); + } + }; + + const save = async () => { + if (!form.title.trim()) { + setError('Podaj tytuł banera.'); + return; + } + setSaving(true); + setError(null); + setNote(null); + try { + const payload = { + slot: form.slot, + title: form.title.trim(), + subtitle: form.subtitle.trim() || null, + imageUrl: form.imageUrl || null, + linkUrl: form.linkUrl.trim() || null, + ctaLabel: form.ctaLabel.trim() || null, + active: form.active, + position: Number.isFinite(form.position) ? form.position : 0, + startsAt: localInputToIso(form.startsAt), + endsAt: localInputToIso(form.endsAt), + }; + if (form.id) { + await apiFetch(`/admin/banners/${form.id}`, { method: 'PUT', body: JSON.stringify(payload) }); + setNote('Zapisano zmiany banera.'); + } else { + await apiFetch('/admin/banners', { method: 'POST', body: JSON.stringify(payload) }); + setNote('Dodano nowy baner.'); + } + resetForm(); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + }; + + const toggle = async (banner: AdminBanner) => { + try { + await apiFetch(`/admin/banners/${banner.id}/toggle`, { method: 'PATCH' }); + await load(); + } catch (err) { + setError(errorMessage(err)); + } + }; + + const remove = async (banner: AdminBanner) => { + if (!window.confirm(`Usunąć baner "${banner.title}"?`)) { + return; + } + try { + await apiFetch(`/admin/banners/${banner.id}`, { method: 'DELETE' }); + if (form.id === banner.id) { + resetForm(); + } + await load(); + } catch (err) { + setError(errorMessage(err)); + } + }; + + const activeSlot = SLOT_OPTIONS.find((o) => o.value === form.slot); + + return ( +
+
+
+

{form.id ? 'Edytuj baner' : 'Nowy baner'}

+ {form.id && } +
+ +
+ + + + + + + + + +
+ +
+
+ {form.imageUrl ? Podgląd banera : Bez grafiki} +
+
+ + {form.imageUrl && } + JPG/PNG. Szersze niż 1600 px zmniejszymy automatycznie. Bez grafiki baner pokaże się jako kafelek z tekstem. +
+
+ + {activeSlot &&

{activeSlot.hint}

} + {error &&

{error}

} + {note &&

{note}

} +
+ +
+
+ +
+
+

Banery w serwisie

+ {banners.length} +
+ {loading ? ( +

Ładowanie...

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

Brak banerów. Dodaj pierwszy w formularzu powyżej.

+ ) : ( +
+ {SLOT_OPTIONS.filter((o) => banners.some((b) => b.slot === o.value)).map((o) => ( +
+

{o.label}

+ {banners.filter((b) => b.slot === o.value).map((b) => ( +
+
+ {b.imageUrl ? : } +
+
+ {b.title} + {b.subtitle && {b.subtitle}} +
+ {b.active ? 'Aktywny' : 'Wstrzymany'} + {b.linkUrl && {b.linkUrl}} + {b.impressions} wyświetleń · {b.clicks} kliknięć +
+
+
+ + + +
+
+ ))} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/admin/marketing.tsx b/frontend/src/admin/marketing.tsx index 362fe98..5122104 100644 --- a/frontend/src/admin/marketing.tsx +++ b/frontend/src/admin/marketing.tsx @@ -609,50 +609,48 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) { }; return ( -
+

Nowa kampania

-
-