SMPT, SMS, Rejestracja + GUS

This commit is contained in:
2026-08-09 21:12:16 +02:00
parent a0c9d7947b
commit c464c5f708
78 changed files with 5841 additions and 258 deletions
@@ -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),
"<p>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.</p>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Link jest ważny przez 48 godzin. Jeśli to nie Ty"
+ " zakładałeś konto, zignoruj tę wiadomość.</p>",
"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),
"<p>Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta. Kliknij poniższy przycisk, aby ustawić"
+ " nowe hasło.</p>"
+ "<p style=\"color:#6b7f96;font-size:13px\">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ść.</p>",
"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 "<p>Cześć" + who + ",</p>";
}
/** 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 "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa;\">"
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b;\">"
// Naglowek z logo + nazwa
+ "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">"
+ "<img src=\"" + baseUrl + "/favicon.png\" width=\"34\" height=\"34\" alt=\"Polska Lokalnie\""
+ " style=\"vertical-align:middle;border-radius:8px;\">"
+ "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">"
+ "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>"
+ "</div>"
// Tresc
+ "<div style=\"background:#ffffff;padding:28px 24px;\">"
+ "<h1 style=\"margin:0 0 16px;font-size:22px;color:#13243d;\">" + heading + "</h1>"
+ "<div style=\"font-size:15px;line-height:1.6;\">" + greetingHtml + bodyHtml + "</div>"
+ "<div style=\"text-align:center;margin:26px 0 8px;\">"
+ "<a href=\"" + ctaUrl + "\" style=\"display:inline-block;background:#12a764;color:#ffffff;"
+ "text-decoration:none;font-weight:bold;font-size:15px;padding:13px 26px;border-radius:10px;\">"
+ ctaLabel + "</a></div>"
+ "<p style=\"font-size:12px;color:#8a99ad;word-break:break-all;\">Jeśli przycisk nie działa, skopiuj"
+ " link do przeglądarki:<br><a href=\"" + ctaUrl + "\" style=\"color:#12a764;\">" + ctaUrl + "</a></p>"
+ "</div>"
// Stopka
+ "<div style=\"background:#eef2f6;border-radius:0 0 14px 14px;padding:16px 24px;font-size:12px;color:#6b7f96;text-align:center;\">"
+ "<p style=\"margin:0 0 4px;\"><strong>Polska Lokalnie | SoftSPM</strong></p>"
+ "<p style=\"margin:0;\">Ogłoszenia nieruchomości bez prowizji. Ta wiadomość została wysłana automatycznie - prosimy na nią nie odpowiadać.</p>"
+ "</div>"
+ "</div></body></html>";
}
}
@@ -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<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
public ResponseEntity<RegisterResponse> 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<Void> activate(@Valid @RequestBody TokenRequest request) {
authService.activate(request.token());
return ResponseEntity.noContent().build();
}
@PostMapping("/resend-activation")
public ResponseEntity<Void> resendActivation(@Valid @RequestBody EmailRequest request) {
authService.resendActivation(request.email());
return ResponseEntity.noContent().build();
}
// --- Reset hasla ---
@PostMapping("/forgot-password")
public ResponseEntity<Void> forgotPassword(@Valid @RequestBody EmailRequest request) {
authService.forgotPassword(request.email());
return ResponseEntity.noContent().build();
}
@PostMapping("/reset-password")
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
authService.resetPassword(request.token(), request.password());
return ResponseEntity.noContent().build();
}
// --- Weryfikacja telefonu (OTP SMS) ---
@PostMapping("/verify-phone")
public ResponseEntity<Void> verifyPhone(@Valid @RequestBody VerifyPhoneRequest request) {
authService.verifyPhone(request.email(), request.code());
return ResponseEntity.noContent().build();
}
@PostMapping("/resend-phone-otp")
public ResponseEntity<Void> 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());
@@ -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.
@@ -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;
}
}
@@ -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<AuthToken, Long> {
Optional<AuthToken> findByToken(String token);
List<AuthToken> findByUserEmailAndPurpose(String userEmail, TokenPurpose purpose);
}
@@ -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;
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.auth;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PhoneOtpRepository extends JpaRepository<PhoneOtp, Long> {
Optional<PhoneOtp> findByUserEmail(String userEmail);
void deleteByUserEmail(String userEmail);
}
@@ -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);
}
}
@@ -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()
@@ -0,0 +1,7 @@
package pl.polskalokalnie.auth;
/** Przeznaczenie jednorazowego tokenu wysylanego mailem. */
public enum TokenPurpose {
ACTIVATION,
PASSWORD_RESET
}
@@ -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) {
}
}
@@ -11,6 +11,7 @@ public record RegisterRequest(
@NotBlank String fullName,
AccountType accountType,
String phone,
String nip
String nip,
String address
) {
}
@@ -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
) {
}
@@ -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()
);
}
@@ -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<BannerResponse> 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<Void> delete(@PathVariable Long id) {
bannerService.delete(id);
return ResponseEntity.noContent().build();
}
}
@@ -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;
}
}
@@ -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<PublicBannerResponse> bySlot(@RequestParam BannerSlot slot) {
return bannerService.publicBySlot(slot);
}
@PostMapping("/{id}/click")
public ResponseEntity<Void> registerClick(@PathVariable Long id) {
bannerService.registerClick(id);
return ResponseEntity.noContent().build();
}
}
@@ -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<Banner, Long> {
List<Banner> findByOrderBySlotAscPositionAscIdAsc();
List<Banner> findBySlotAndActiveTrueOrderByPositionAscIdAsc(BannerSlot slot);
@Modifying
@Query("update Banner b set b.impressions = b.impressions + 1 where b.id in :ids")
void incrementImpressions(@Param("ids") List<Long> ids);
@Modifying
@Query("update Banner b set b.clicks = b.clicks + 1 where b.id = :id")
void incrementClicks(@Param("id") Long id);
}
@@ -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
) {
}
@@ -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());
}
}
@@ -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<BannerResponse> 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<PublicBannerResponse> publicBySlot(BannerSlot slot) {
Instant now = Instant.now();
List<Banner> 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;
}
}
@@ -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
}
@@ -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());
}
}
@@ -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;
@@ -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));
}
}
@@ -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
) {
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.gus;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GusConfigRepository extends JpaRepository<GusConfig, Long> {
}
@@ -0,0 +1,8 @@
package pl.polskalokalnie.gus;
/** Zapis konfiguracji GUS. Puste userKey = zachowaj dotychczasowy klucz. */
public record GusConfigRequest(
String userKey,
Boolean sandbox
) {
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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<String, String> 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<String, String> search(String endpoint, String key, String nip, boolean canRetry) {
String body = """
<ns:DaneSzukajPodmioty>
<ns:pParametryWyszukiwania>
<dat:Nip>%s</dat:Nip>
</ns:pParametryWyszukiwania>
</ns:DaneSzukajPodmioty>""".formatted(nip);
String response = call(endpoint, "DaneSzukajPodmioty", body, session(endpoint, key));
Map<String, String> 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 = "<ns:Zaloguj><ns:pKluczUzytkownika>%s</ns:pKluczUzytkownika></ns:Zaloguj>".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 = """
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://www.w3.org/2005/08/addressing" xmlns:ns="http://CIS/BIR/PUBL/2014/07" xmlns:dat="http://CIS/BIR/PUBL/2014/07/DataContract">
<soap:Header>
<wsa:Action>%s%s</wsa:Action>
<wsa:To>%s</wsa:To>
</soap:Header>
<soap:Body>%s</soap:Body>
</soap:Envelope>""".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<String> 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 <dane> albo null, gdy odpowiedz jest pusta (wygasla sesja).
private static Map<String, String> firstDane(String xml) {
if (xml == null || xml.isBlank()) {
return null;
}
NodeList danes = parse(xml).getElementsByTagName("dane");
if (danes.getLength() == 0) {
return null;
}
Map<String, String> 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<String, String> 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;
}
}
@@ -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()
);
}
}
@@ -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()
);
}
@@ -28,13 +28,19 @@ public class ListingService {
this.notificationService = notificationService;
}
// Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania.
private static Comparator<PropertyListing> promotedFirst() {
return Comparator.comparing(PropertyListing::isPromoted).reversed()
.thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder());
}
public List<ListingResponse> 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<ListingResponse> 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<ListingResponse> findAllForModeration() {
return listingRepository.findAll().stream()
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
.sorted(promotedFirst())
.map(ListingResponse::from)
.toList();
}
@@ -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;
}
@@ -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));
}
}
@@ -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<String, String> 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<String, String> 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<String, String> 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<String> values = new ArrayList<>();
for (Map.Entry<String, String> 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<String> values, String separator, String key) {
List<String> 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);
}
}
@@ -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();
}
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.payment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PaymentConfigRepository extends JpaRepository<PaymentConfig, Long> {
}
@@ -0,0 +1,10 @@
package pl.polskalokalnie.payment;
public record PaymentConfigRequest(
String serviceId,
String secretKey,
String hashSeparator,
Boolean sandbox,
Boolean enabled
) {
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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<PlanResponse> 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<Void> deletePlan(@PathVariable Long id) {
promotionService.deletePlan(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/packages")
public List<PackageResponse> 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<Void> deletePackage(@PathVariable Long id) {
promotionService.deletePackage(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/orders")
public List<OrderResponse> orders() {
return promotionService.allOrders();
}
}
@@ -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<String> itn(@RequestParam Map<String, String> 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<String, String> 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");
}
}
@@ -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
) {
}
@@ -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);
}
}
@@ -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
) {
}
@@ -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());
}
}
@@ -0,0 +1,8 @@
package pl.polskalokalnie.promotion;
/** Status platnosci za promowanie. */
public enum OrderStatus {
PENDING,
PAID,
FAILED
}
@@ -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
) {
}
@@ -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());
}
}
@@ -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
) {
}
@@ -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());
}
}
@@ -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<PlanResponse> plans() {
return promotionService.activePlans();
}
@GetMapping("/packages")
public List<PackageResponse> 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) {
}
}
@@ -0,0 +1,7 @@
package pl.polskalokalnie.promotion;
/** Rodzaj zamowienia promowania: pojedynczy plan (wyroznienie oferty) albo pakiet kredytow. */
public enum PromotionKind {
PLAN,
PACKAGE
}
@@ -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;
}
}
@@ -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<PromotionOrder, Long> {
Optional<PromotionOrder> findByExternalId(String externalId);
List<PromotionOrder> findByOrderByCreatedAtDesc();
}
@@ -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;
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.promotion;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PromotionPackageRepository extends JpaRepository<PromotionPackage, Long> {
List<PromotionPackage> findByOrderByPositionAscIdAsc();
List<PromotionPackage> findByActiveTrueOrderByPositionAscIdAsc();
}
@@ -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;
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.promotion;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PromotionPlanRepository extends JpaRepository<PromotionPlan, Long> {
List<PromotionPlan> findByOrderByPositionAscIdAsc();
List<PromotionPlan> findByActiveTrueOrderByPositionAscIdAsc();
}
@@ -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<PlanResponse> activePlans() {
return planRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
}
public List<PackageResponse> 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<PlanResponse> allPlans() {
return planRepository.findByOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
}
public List<PackageResponse> allPackages() {
return packageRepository.findByOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList();
}
public List<OrderResponse> 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));
}
}
@@ -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<String> 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) {
@@ -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;
}
+569 -149
View File
File diff suppressed because it is too large Load Diff
+211
View File
@@ -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 (
<section className="auth-status-page">
<div className="auth-status-card">
<Link to={ROUTES.home} className="auth-status-logo">Polska <span>Lokalnie</span></Link>
{children}
</div>
</section>
);
}
// /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 (
<AuthStatusCard>
{state === 'loading' && <p className="auth-status-lead">Aktywujemy Twoje konto...</p>}
{state === 'ok' && (
<>
<div className="auth-status-icon ok"></div>
<h1>Konto zostało aktywowane!</h1>
<p className="auth-status-lead">Możesz się teraz zalogować i korzystać ze wszystkich funkcji serwisu.</p>
<Link className="auth-status-btn" to={ROUTES.login}>Przejdź do logowania</Link>
</>
)}
{state === 'error' && (
<>
<div className="auth-status-icon err">!</div>
<h1>Nie udało się aktywować konta</h1>
<p className="auth-status-lead">{message || 'Link aktywacyjny jest nieprawidłowy lub wygasł.'}</p>
<p className="auth-status-hint">Zaloguj się i poproś o ponowne wysłanie linku aktywacyjnego.</p>
<Link className="auth-status-btn" to={ROUTES.login}>Przejdź do logowania</Link>
</>
)}
</AuthStatusCard>
);
}
// /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<string | null>(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 (
<AuthStatusCard>
{done ? (
<>
<div className="auth-status-icon ok"></div>
<h1>Hasło zostało zmienione</h1>
<p className="auth-status-lead">Za chwilę przekierujemy Cię do logowania.</p>
<Link className="auth-status-btn" to={ROUTES.login}>Przejdź do logowania</Link>
</>
) : (
<>
<h1>Ustaw nowe hasło</h1>
<p className="auth-status-lead">Wpisz nowe hasło do swojego konta. Dotychczasowe hasło działa do momentu jego zmiany.</p>
<form className="auth-status-form" onSubmit={submit}>
<label>Nowe hasło
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Min. 8 znaków" autoComplete="new-password" required />
</label>
<label>Powtórz hasło
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="Powtórz nowe hasło" autoComplete="new-password" required />
</label>
{error && <p className="auth-status-error" role="alert">{error}</p>}
<button className="auth-status-btn" type="submit" disabled={busy}>{busy ? 'Zapisywanie...' : 'Ustaw nowe hasło'}</button>
</form>
</>
)}
</AuthStatusCard>
);
}
// 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<string | null>(null);
const [info, setInfo] = useState<string | null>(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 (
<div className="listing-delete-overlay" role="dialog" aria-modal="true" aria-label="Potwierdź numer telefonu">
<div className="otp-modal">
<button type="button" className="listing-delete-close" aria-label="Zamknij" onClick={onClose}>×</button>
<div className="otp-modal-icon">📱</div>
<h2>Potwierdź numer telefonu</h2>
<p className="otp-modal-lead">Wysłaliśmy kod SMS na numer <strong>{phone}</strong>. Wpisz go poniżej, aby potwierdzić poprawność numeru.</p>
<form className="otp-modal-form" onSubmit={submit}>
<input
className="otp-input"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="______"
aria-label="Kod z SMS"
autoFocus
/>
{error && <p className="auth-status-error" role="alert">{error}</p>}
{info && <p className="otp-modal-ok">{info}</p>}
<button className="auth-status-btn" type="submit" disabled={busy || code.length < 6}>{busy ? 'Sprawdzanie...' : 'Potwierdź numer'}</button>
</form>
<div className="otp-modal-actions">
<button type="button" className="otp-link" disabled={cooldown > 0} onClick={resend}>
{cooldown > 0 ? `Wyślij ponownie (${cooldown}s)` : 'Wyślij kod ponownie'}
</button>
<button type="button" className="otp-link" onClick={onClose}>Zrobię to później</button>
</div>
</div>
</div>
);
}
+97
View File
@@ -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 (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12h14" />
<path d="M13 6l6 6-6 6" />
</svg>
);
}
export function BannerSlot({ slot, className }: { slot: BannerSlotName; className?: string }) {
const [banners, setBanners] = useState<PublicBanner[]>([]);
const navigate = useNavigate();
useEffect(() => {
let cancelled = false;
apiFetch<PublicBanner[]>(`/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<HTMLAnchorElement>) => {
// 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 (
<div className={`banner-slot ${slotClass} ${className ?? ''}`.trim()} aria-label="Reklama">
<a
className={`promo-banner ${withImage}`}
href={banner.linkUrl ?? undefined}
target={isInternal ? undefined : '_blank'}
rel={isInternal ? undefined : 'noopener noreferrer sponsored'}
onClick={handleClick}
style={style}
>
<span className="promo-banner-tag">Reklama</span>
<div className="promo-banner-body">
<strong className="promo-banner-title">{banner.title}</strong>
{banner.subtitle && <span className="promo-banner-sub">{banner.subtitle}</span>}
{banner.ctaLabel && (
<span className="promo-banner-cta">{banner.ctaLabel} <ArrowIcon /></span>
)}
</div>
</a>
</div>
);
}
+56
View File
@@ -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 (
<footer className="site-footer" aria-label="Stopka serwisu">
<div className="site-footer-inner">
<div className="footer-brand">
<Link to={ROUTES.home} className="footer-logo">Polska <span>Lokalnie</span></Link>
<p className="footer-tagline">Ogłoszenia nieruchomości bez prowizji. Kupuj, wynajmuj i sprzedawaj bezpośrednio, bez pośredników.</p>
<p className="footer-badge">0 za ogłoszenie - bez ukrytych opłat</p>
</div>
<nav className="footer-cols" aria-label="Nawigacja w stopce">
<div className="footer-col">
<h3>Serwis</h3>
<Link to={ROUTES.buy}>Kupuję</Link>
<Link to={ROUTES.rent}>Wynajmuję</Link>
<Link to={ROUTES.sell}>Sprzedaję</Link>
<Link to={ROUTES.add}>Dodaj ogłoszenie</Link>
<Link to={ROUTES.map}>Mapa ofert</Link>
</div>
<div className="footer-col">
<h3>Narzędzia</h3>
<Link to={ROUTES.valuation}>Wycena mieszkania</Link>
<Link to={ROUTES.priceHistory}>Historia cen</Link>
<Link to={ROUTES.districtRanking}>Ranking dzielnic</Link>
<Link to={ROUTES.creditCalculator}>Zdolność kredytowa</Link>
<Link to={ROUTES.guides}>Poradniki</Link>
</div>
<div className="footer-col">
<h3>Informacje</h3>
<Link to={ROUTES.services}>Firmy i usługi</Link>
<Link to={ROUTES.terms}>Regulamin</Link>
<Link to={ROUTES.privacy}>Polityka prywatności</Link>
<button type="button" className="footer-link-btn" onClick={openCookieSettings}>Ustawienia plików cookie</button>
</div>
<div className="footer-col">
<h3>Kontakt</h3>
<a href="mailto:info@polskalokalnie.pl">info@polskalokalnie.pl</a>
<span className="footer-muted">Dostawca: SoftSPM</span>
<span className="footer-muted">NIP: 7691956407</span>
<span className="footer-muted">Ławy 42A, 97-400 Bełchatów</span>
</div>
</nav>
</div>
<div className="site-footer-bottom">
<span>© {year} Polska Lokalnie | SoftSPM. Wszelkie prawa zastrzeżone.</span>
</div>
</footer>
);
}
+329
View File
@@ -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 <input type="datetime-local"> 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<AdminBanner[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<FormState>(emptyForm());
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setBanners(await apiFetch<AdminBanner[]>('/admin/banners'));
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const set = (patch: Partial<FormState>) => 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<HTMLInputElement>) => {
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 (
<div className="mkt-wrap">
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>{form.id ? 'Edytuj baner' : 'Nowy baner'}</h2>
{form.id && <button type="button" className="admin-btn" onClick={resetForm}>Anuluj edycję</button>}
</div>
<div className="mkt-config-grid">
<label>Miejsce (slot)
<select value={form.slot} onChange={(e) => set({ slot: e.target.value as BannerSlotName })}>
{SLOT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<label>Kolejność (mniejsza = wyżej)
<input type="number" value={form.position} onChange={(e) => set({ position: Number(e.target.value) })} />
</label>
<label>Tytuł
<input value={form.title} maxLength={160} onChange={(e) => set({ title: e.target.value })} placeholder="np. Wyróżnij swoje ogłoszenie" />
</label>
<label>Etykieta przycisku (CTA)
<input value={form.ctaLabel} maxLength={60} onChange={(e) => set({ ctaLabel: e.target.value })} placeholder="np. Dodaj ogłoszenie" />
</label>
<label className="mkt-col-span">Podtytuł / opis
<input value={form.subtitle} maxLength={400} onChange={(e) => set({ subtitle: e.target.value })} placeholder="Krótki tekst zachęcający do kliknięcia." />
</label>
<label className="mkt-col-span">Link po kliknięciu
<input value={form.linkUrl} onChange={(e) => set({ linkUrl: e.target.value })} placeholder="np. /dodaj-ogloszenie lub https://twojafirma.pl" />
</label>
<label>Emisja od (opcjonalnie)
<input type="datetime-local" value={form.startsAt} onChange={(e) => set({ startsAt: e.target.value })} />
</label>
<label>Emisja do (opcjonalnie)
<input type="datetime-local" value={form.endsAt} onChange={(e) => set({ endsAt: e.target.value })} />
</label>
<label>Status
<select value={form.active ? 'on' : 'off'} onChange={(e) => set({ active: e.target.value === 'on' })}>
<option value="on">Aktywny</option>
<option value="off">Wstrzymany</option>
</select>
</label>
</div>
<div className="banner-image-row">
<div className="banner-image-preview">
{form.imageUrl ? <img src={form.imageUrl} alt="Podgląd banera" /> : <span className="banner-image-empty"><Icon name="camera" /> Bez grafiki</span>}
</div>
<div className="banner-image-actions">
<label className="admin-btn banner-upload-btn">
<Icon name="upload" /> {uploading ? 'Przetwarzanie...' : (form.imageUrl ? 'Zmień grafikę' : 'Dodaj grafikę')}
<input type="file" accept="image/png,image/jpeg" onChange={onPickImage} hidden />
</label>
{form.imageUrl && <button type="button" className="admin-btn ghost" onClick={() => set({ imageUrl: '' })}>Usuń grafikę</button>}
<small className="banner-image-hint">JPG/PNG. Szersze niż 1600 px zmniejszymy automatycznie. Bez grafiki baner pokaże się jako kafelek z tekstem.</small>
</div>
</div>
{activeSlot && <p className="banner-slot-hint"><Icon name="pin" /> {activeSlot.hint}</p>}
{error && <p className="mkt-error">{error}</p>}
{note && <p className="mkt-ok">{note}</p>}
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={save} disabled={saving}>
{saving ? 'Zapisywanie...' : (form.id ? 'Zapisz zmiany' : 'Dodaj baner')}
</button>
</div>
</div>
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Banery w serwisie</h2>
<span className="mkt-count">{banners.length}</span>
</div>
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : banners.length === 0 ? (
<p className="admin-empty">Brak banerów. Dodaj pierwszy w formularzu powyżej.</p>
) : (
<div className="banner-admin-list">
{SLOT_OPTIONS.filter((o) => banners.some((b) => b.slot === o.value)).map((o) => (
<div key={o.value} className="banner-admin-group">
<h3 className="banner-admin-group-title">{o.label}</h3>
{banners.filter((b) => b.slot === o.value).map((b) => (
<div key={b.id} className={`banner-admin-row ${b.active ? '' : 'is-off'}`}>
<div className="banner-admin-thumb">
{b.imageUrl ? <img src={b.imageUrl} alt="" /> : <span className="banner-admin-thumb-text"><Icon name="spark" /></span>}
</div>
<div className="banner-admin-info">
<strong>{b.title}</strong>
{b.subtitle && <span>{b.subtitle}</span>}
<div className="banner-admin-meta">
<span className={`banner-status ${b.active ? 'on' : 'off'}`}>{b.active ? 'Aktywny' : 'Wstrzymany'}</span>
{b.linkUrl && <span className="banner-admin-link">{b.linkUrl}</span>}
<span className="banner-admin-stats">{b.impressions} wyświetleń · {b.clicks} kliknięć</span>
</div>
</div>
<div className="banner-admin-actions">
<button type="button" className="admin-btn" onClick={() => startEdit(b)}>Edytuj</button>
<button type="button" className="admin-btn ghost" onClick={() => toggle(b)}>{b.active ? 'Wstrzymaj' : 'Aktywuj'}</button>
<button type="button" className="admin-btn ghost danger" onClick={() => remove(b)}>Usuń</button>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</div>
);
}
+38 -32
View File
@@ -609,50 +609,48 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
};
return (
<div className="mkt-grid-2">
<div className="mkt-wrap">
<div className="admin-card mkt-card">
<div className="admin-card-head"><h2>Nowa kampania</h2></div>
<div className="mkt-form">
<label>Nazwa
<div className="mkt-config-grid">
<label className="mkt-col-span">Nazwa
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Newsletter lipiec" />
</label>
<div className="mkt-form-row">
<label>Kanał
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</select>
</label>
<label>Grupa docelowa
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
<option value="">- wybierz -</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
))}
</select>
</label>
</div>
<label>Kanał
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</select>
</label>
<label>Grupa docelowa
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
<option value="">- wybierz -</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
))}
</select>
</label>
{form.channel === 'EMAIL' && (
<label>Temat
<label className="mkt-col-span">Temat
<input value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} placeholder="Temat wiadomości" />
</label>
)}
<label>Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
<label className="mkt-col-span">Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
{form.channel === 'EMAIL' ? (
<RichTextEditor value={form.body} onChange={(html) => setForm({ ...form, body: html })} ariaLabel="Treść kampanii e-mail" placeholder="Cześć {{name}}, ..." />
) : (
<textarea rows={5} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
<textarea rows={6} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
)}
</label>
<div className="mkt-form-row">
<label>Dzienny limit
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
</label>
<label>Zaplanuj na (opcjonalnie)
<input type="datetime-local" value={form.scheduledAt} onChange={(e) => setForm({ ...form, scheduledAt: e.target.value })} />
</label>
</div>
{error && <p className="mkt-error">{error}</p>}
<label>Dzienny limit
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
</label>
<label>Zaplanuj na (opcjonalnie)
<input type="datetime-local" value={form.scheduledAt} onChange={(e) => setForm({ ...form, scheduledAt: e.target.value })} />
</label>
</div>
{error && <p className="mkt-error">{error}</p>}
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Utwórz kampanię (robocza)'}
</button>
@@ -730,10 +728,18 @@ type SmsConfig = {
apiKeySet: boolean;
};
export function AdminMailConfigView({ Icon }: { Icon: IconComponent }) {
// Rozdzielone obszary w panelu admina: osobno konfiguracja SMTP, osobno bramka SMS.
export function AdminSmtpConfigView({ Icon }: { Icon: IconComponent }) {
return (
<div className="mkt-wrap">
<SmtpCard Icon={Icon} />
</div>
);
}
export function AdminSmsConfigView({ Icon }: { Icon: IconComponent }) {
return (
<div className="mkt-wrap">
<SmsCard Icon={Icon} />
</div>
);
+283
View File
@@ -0,0 +1,283 @@
// Panel admina: promowanie ofert - plany, pakiety, konfiguracja AutoPay i historia platnosci.
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type Plan = { id: number; name: string; durationDays: number; price: number; active: boolean; position: number };
type Pack = { id: number; name: string; quantity: number; price: number; active: boolean; position: number };
type Order = {
id: number; userEmail: string; kind: string; itemName: string; amount: number;
method: string; listingId: number | null; status: string; createdAt: string; paidAt: string | null;
};
type PayCfg = { serviceId: string | null; secretKeySet: boolean; hashSeparator: string; sandbox: boolean; enabled: boolean };
const zl = (n: number) => `${new Intl.NumberFormat('pl-PL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n)} zł`;
const em = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd.');
const dt = (iso: string | null) => (iso ? new Date(iso).toLocaleString('pl-PL') : '-');
const emptyPlan = () => ({ id: null as number | null, name: '', durationDays: 14, price: '', active: true, position: 0 });
const emptyPack = () => ({ id: null as number | null, name: '', quantity: 5, price: '', active: true, position: 0 });
export function AdminPromotionView({ Icon }: { Icon: IconComponent }) {
const [plans, setPlans] = useState<Plan[]>([]);
const [packs, setPacks] = useState<Pack[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [planForm, setPlanForm] = useState(emptyPlan());
const [packForm, setPackForm] = useState(emptyPack());
const [pay, setPay] = useState({ serviceId: '', secretKey: '', hashSeparator: '|', sandbox: true, enabled: false });
const [secretSet, setSecretSet] = useState(false);
const load = useCallback(async () => {
try {
const [pl, pk, ord, cfg] = await Promise.all([
apiFetch<Plan[]>('/admin/promotion/plans'),
apiFetch<Pack[]>('/admin/promotion/packages'),
apiFetch<Order[]>('/admin/promotion/orders'),
apiFetch<PayCfg>('/admin/payment-config'),
]);
setPlans(pl);
setPacks(pk);
setOrders(ord);
setSecretSet(cfg.secretKeySet);
setPay((f) => ({ ...f, serviceId: cfg.serviceId ?? '', hashSeparator: cfg.hashSeparator || '|', sandbox: cfg.sandbox, enabled: cfg.enabled }));
} catch (e) {
setError(em(e));
}
}, []);
useEffect(() => { load(); }, [load]);
const flash = (msg: string) => { setNote(msg); setError(null); };
const savePlan = async () => {
try {
const body = JSON.stringify({
name: planForm.name, durationDays: Number(planForm.durationDays), price: Number(planForm.price),
active: planForm.active, position: Number(planForm.position),
});
if (planForm.id) {
await apiFetch(`/admin/promotion/plans/${planForm.id}`, { method: 'PUT', body });
} else {
await apiFetch('/admin/promotion/plans', { method: 'POST', body });
}
setPlanForm(emptyPlan());
flash('Zapisano plan.');
await load();
} catch (e) { setError(em(e)); }
};
const savePack = async () => {
try {
const body = JSON.stringify({
name: packForm.name, quantity: Number(packForm.quantity), price: Number(packForm.price),
active: packForm.active, position: Number(packForm.position),
});
if (packForm.id) {
await apiFetch(`/admin/promotion/packages/${packForm.id}`, { method: 'PUT', body });
} else {
await apiFetch('/admin/promotion/packages', { method: 'POST', body });
}
setPackForm(emptyPack());
flash('Zapisano pakiet.');
await load();
} catch (e) { setError(em(e)); }
};
const savePay = async () => {
try {
const body = JSON.stringify({
serviceId: pay.serviceId || null, secretKey: pay.secretKey || null,
hashSeparator: pay.hashSeparator, sandbox: pay.sandbox, enabled: pay.enabled,
});
const cfg = await apiFetch<PayCfg>('/admin/payment-config', { method: 'PUT', body });
setSecretSet(cfg.secretKeySet);
setPay((f) => ({ ...f, secretKey: '' }));
flash('Zapisano konfigurację AutoPay.');
} catch (e) { setError(em(e)); }
};
const toggle = async (kind: 'plans' | 'packages', id: number) => {
try { await apiFetch(`/admin/promotion/${kind}/${id}/toggle`, { method: 'PATCH' }); await load(); } catch (e) { setError(em(e)); }
};
const remove = async (kind: 'plans' | 'packages', id: number, label: string) => {
if (!window.confirm(`Usunąć „${label}"?`)) { return; }
try { await apiFetch(`/admin/promotion/${kind}/${id}`, { method: 'DELETE' }); await load(); } catch (e) { setError(em(e)); }
};
return (
<div className="mkt-wrap">
{(error || note) && (error ? <p className="mkt-error">{error}</p> : <p className="mkt-ok">{note}</p>)}
{/* Plany promowania */}
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>{planForm.id ? 'Edytuj plan promowania' : 'Plany promowania'}</h2>
{planForm.id && <button type="button" className="admin-btn" onClick={() => setPlanForm(emptyPlan())}>Anuluj</button>}
</div>
<div className="mkt-config-grid">
<label className="mkt-col-span">Nazwa
<input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} placeholder="np. Wyróżnienie 14 dni" />
</label>
<label>Czas trwania (dni)
<input type="number" min={1} value={planForm.durationDays} onChange={(e) => setPlanForm({ ...planForm, durationDays: Number(e.target.value) })} />
</label>
<label>Cena ()
<input type="number" min={0} step="0.01" value={planForm.price} onChange={(e) => setPlanForm({ ...planForm, price: e.target.value })} placeholder="49.99" />
</label>
<label>Kolejność
<input type="number" value={planForm.position} onChange={(e) => setPlanForm({ ...planForm, position: Number(e.target.value) })} />
</label>
<label>Status
<select value={planForm.active ? 'on' : 'off'} onChange={(e) => setPlanForm({ ...planForm, active: e.target.value === 'on' })}>
<option value="on">Aktywny</option>
<option value="off">Nieaktywny</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={savePlan} disabled={!planForm.name.trim()}>
{planForm.id ? 'Zapisz plan' : 'Dodaj plan'}
</button>
</div>
<div className="promo-admin-list">
{plans.map((p) => (
<div key={p.id} className={`promo-admin-row ${p.active ? '' : 'is-off'}`}>
<div className="promo-admin-info">
<strong>{p.name}</strong>
<span>{p.durationDays} dni · {zl(p.price)}</span>
</div>
<span className={`banner-status ${p.active ? 'on' : 'off'}`}>{p.active ? 'Aktywny' : 'Nieaktywny'}</span>
<div className="promo-admin-actions">
<button type="button" className="admin-btn" onClick={() => setPlanForm({ id: p.id, name: p.name, durationDays: p.durationDays, price: String(p.price), active: p.active, position: p.position })}>Edytuj</button>
<button type="button" className="admin-btn ghost" onClick={() => toggle('plans', p.id)}>{p.active ? 'Wyłącz' : 'Włącz'}</button>
<button type="button" className="admin-btn ghost danger" onClick={() => remove('plans', p.id, p.name)}>Usuń</button>
</div>
</div>
))}
{plans.length === 0 && <p className="admin-empty">Brak planów.</p>}
</div>
</div>
{/* Pakiety promowania */}
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>{packForm.id ? 'Edytuj pakiet' : 'Pakiety promowań'}</h2>
{packForm.id && <button type="button" className="admin-btn" onClick={() => setPackForm(emptyPack())}>Anuluj</button>}
</div>
<div className="mkt-config-grid">
<label className="mkt-col-span">Nazwa
<input value={packForm.name} onChange={(e) => setPackForm({ ...packForm, name: e.target.value })} placeholder="np. Pakiet 10 promowań" />
</label>
<label>Liczba promowań (kredytów)
<input type="number" min={1} value={packForm.quantity} onChange={(e) => setPackForm({ ...packForm, quantity: Number(e.target.value) })} />
</label>
<label>Cena pakietu ()
<input type="number" min={0} step="0.01" value={packForm.price} onChange={(e) => setPackForm({ ...packForm, price: e.target.value })} placeholder="349.99" />
</label>
<label>Kolejność
<input type="number" value={packForm.position} onChange={(e) => setPackForm({ ...packForm, position: Number(e.target.value) })} />
</label>
<label>Status
<select value={packForm.active ? 'on' : 'off'} onChange={(e) => setPackForm({ ...packForm, active: e.target.value === 'on' })}>
<option value="on">Aktywny</option>
<option value="off">Nieaktywny</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={savePack} disabled={!packForm.name.trim()}>
{packForm.id ? 'Zapisz pakiet' : 'Dodaj pakiet'}
</button>
</div>
<div className="promo-admin-list">
{packs.map((p) => (
<div key={p.id} className={`promo-admin-row ${p.active ? '' : 'is-off'}`}>
<div className="promo-admin-info">
<strong>{p.name}</strong>
<span>{p.quantity} szt. · {zl(p.price)} ({zl(p.price / p.quantity)} / szt.)</span>
</div>
<span className={`banner-status ${p.active ? 'on' : 'off'}`}>{p.active ? 'Aktywny' : 'Nieaktywny'}</span>
<div className="promo-admin-actions">
<button type="button" className="admin-btn" onClick={() => setPackForm({ id: p.id, name: p.name, quantity: p.quantity, price: String(p.price), active: p.active, position: p.position })}>Edytuj</button>
<button type="button" className="admin-btn ghost" onClick={() => toggle('packages', p.id)}>{p.active ? 'Wyłącz' : 'Włącz'}</button>
<button type="button" className="admin-btn ghost danger" onClick={() => remove('packages', p.id, p.name)}>Usuń</button>
</div>
</div>
))}
{packs.length === 0 && <p className="admin-empty">Brak pakietów.</p>}
</div>
</div>
{/* Konfiguracja AutoPay */}
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="credit-card" /></span>
<h2>Bramka szybkich płatności - AutoPay</h2>
</div>
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych działa tryb sandbox (płatność potwierdzana automatycznie).</p>
<div className="mkt-config-grid">
<label>ServiceID
<input value={pay.serviceId} onChange={(e) => setPay({ ...pay, serviceId: e.target.value })} placeholder="np. 123456" />
</label>
<label>Klucz współpracy (sekret) {secretSet && <span className="mkt-badge-set"><Icon name="lock" /> ustawiony</span>}
<input type="password" value={pay.secretKey} onChange={(e) => setPay({ ...pay, secretKey: e.target.value })} placeholder="wpisz, aby zmienić" />
</label>
<label>Separator hasha
<input value={pay.hashSeparator} maxLength={5} onChange={(e) => setPay({ ...pay, hashSeparator: e.target.value })} placeholder="|" />
</label>
<label>Środowisko
<select value={pay.sandbox ? 'sandbox' : 'prod'} onChange={(e) => setPay({ ...pay, sandbox: e.target.value === 'sandbox' })}>
<option value="sandbox">Testowe (sandbox)</option>
<option value="prod">Produkcyjne</option>
</select>
</label>
<label>Bramka aktywna
<select value={pay.enabled ? 'on' : 'off'} onChange={(e) => setPay({ ...pay, enabled: e.target.value === 'on' })}>
<option value="off">Wyłączona (symulacja)</option>
<option value="on">Włączona</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={savePay}>Zapisz konfigurację</button>
</div>
</div>
{/* Historia platnosci */}
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Historia płatności</h2>
<span className="mkt-count">{orders.length}</span>
</div>
{orders.length === 0 ? (
<p className="admin-empty">Brak płatności.</p>
) : (
<div className="mkt-table-scroll">
<table className="mkt-table promo-orders-table">
<thead>
<tr>
<th>Data</th><th>Użytkownik</th><th>Pozycja</th><th>Kwota</th><th>Metoda</th><th>Status</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id}>
<td>{dt(o.createdAt)}</td>
<td>{o.userEmail}</td>
<td>{o.itemName}</td>
<td>{zl(o.amount)}</td>
<td>{o.method}</td>
<td><span className={`banner-status ${o.status === 'PAID' ? 'on' : 'off'}`}>{o.status === 'PAID' ? 'Opłacone' : o.status === 'PENDING' ? 'Oczekuje' : 'Błąd'}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
// Panel admina: ustawienia serwisu - integracja z rejestrem REGON (GUS BIR1.1).
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type GusCfg = { userKeySet: boolean; sandbox: boolean };
const em = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd.');
export function AdminSettingsView({ Icon }: { Icon: IconComponent }) {
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [gus, setGus] = useState({ userKey: '', sandbox: true });
const [keySet, setKeySet] = useState(false);
const load = useCallback(async () => {
try {
const cfg = await apiFetch<GusCfg>('/admin/gus-config');
setKeySet(cfg.userKeySet);
setGus({ userKey: '', sandbox: cfg.sandbox });
} catch (e) {
setError(em(e));
}
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
try {
const body = JSON.stringify({ userKey: gus.userKey || null, sandbox: gus.sandbox });
const cfg = await apiFetch<GusCfg>('/admin/gus-config', { method: 'PUT', body });
setKeySet(cfg.userKeySet);
setGus({ userKey: '', sandbox: cfg.sandbox });
setError(null);
setNote('Zapisano konfigurację GUS.');
} catch (e) {
setError(em(e));
}
};
return (
<div className="mkt-wrap">
{(error || note) && (error ? <p className="mkt-error">{error}</p> : <p className="mkt-ok">{note}</p>)}
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="document" /></span>
<h2>Rejestr REGON (GUS) - pobieranie danych firmy po NIP</h2>
</div>
<p className="banner-slot-hint">
<Icon name="lock" /> Klucz użytkownika otrzymasz od GUS (usługa BIR1.1). W trybie testowym używamy środowiska
testowego GUS i klucza testowego - zwraca ono wyłącznie dane przykładowe.
</p>
<div className="mkt-config-grid">
<label>Klucz użytkownika {keySet && <span className="mkt-badge-set"><Icon name="lock" /> ustawiony</span>}
<input
type="password"
value={gus.userKey}
onChange={(e) => setGus({ ...gus, userKey: e.target.value })}
placeholder={keySet ? 'wpisz, aby zmienić' : 'klucz produkcyjny z GUS'}
/>
</label>
<label>Środowisko
<select value={gus.sandbox ? 'sandbox' : 'prod'} onChange={(e) => setGus({ ...gus, sandbox: e.target.value === 'sandbox' })}>
<option value="sandbox">Testowe (dane przykładowe)</option>
<option value="prod">Produkcyjne</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={save}>Zapisz konfigurację</button>
</div>
</div>
</div>
);
}
+64 -6
View File
@@ -21,16 +21,22 @@ export type AuthUser = {
nip: string | null;
birthDate: string | null;
verified: boolean;
phoneVerified: boolean;
blocked: boolean;
promotionCredits: number;
createdAt: string;
};
type AuthResponse = { token: string; user: AuthUser };
// Po rejestracji konto wymaga aktywacji linkiem e-mail - nie logujemy od razu.
export type RegisterPending = { email: string; phoneVerificationRequired: boolean };
export type RegisterDetails = {
accountType: AccountType;
phone?: string;
nip?: string;
address?: string;
};
type AuthContextValue = {
@@ -38,9 +44,16 @@ type AuthContextValue = {
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<AuthUser>;
register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise<AuthUser>;
register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise<RegisterPending>;
socialLogin: (provider: Exclude<AuthProviderName, 'LOCAL'>) => Promise<AuthUser>;
activateAccount: (token: string) => Promise<void>;
resendActivation: (email: string) => Promise<void>;
forgotPassword: (email: string) => Promise<void>;
resetPassword: (token: string, password: string) => Promise<void>;
verifyPhone: (email: string, code: string) => Promise<void>;
resendPhoneOtp: (email: string) => Promise<void>;
updateProfile: (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => Promise<AuthUser>;
refreshUser: () => Promise<AuthUser | null>;
logout: () => void;
};
@@ -161,13 +174,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[applyAuth],
);
// Rejestracja NIE loguje od razu - konto wymaga aktywacji linkiem z e-maila.
const register = useCallback(
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
applyAuth(await apiFetch<AuthResponse>('/auth/register', {
apiFetch<RegisterPending>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, fullName, ...details }),
})),
[applyAuth],
}),
[],
);
const socialLogin = useCallback(
@@ -179,6 +193,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[applyAuth],
);
const activateAccount = useCallback(async (token: string) => {
await apiFetch<void>('/auth/activate', { method: 'POST', body: JSON.stringify({ token }) });
}, []);
const resendActivation = useCallback(async (email: string) => {
await apiFetch<void>('/auth/resend-activation', { method: 'POST', body: JSON.stringify({ email }) });
}, []);
const forgotPassword = useCallback(async (email: string) => {
await apiFetch<void>('/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
}, []);
const resetPassword = useCallback(async (token: string, password: string) => {
await apiFetch<void>('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
}, []);
const verifyPhone = useCallback(async (email: string, code: string) => {
await apiFetch<void>('/auth/verify-phone', { method: 'POST', body: JSON.stringify({ email, code }) });
}, []);
const resendPhoneOtp = useCallback(async (email: string) => {
await apiFetch<void>('/auth/resend-phone-otp', { method: 'POST', body: JSON.stringify({ email }) });
}, []);
const updateProfile = useCallback(
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
const updated = await apiFetch<AuthUser>('/auth/me', {
@@ -191,6 +229,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[],
);
// Ponowne pobranie danych uzytkownika (np. po zakupie promowania - odswieza saldo kredytow).
const refreshUser = useCallback(async () => {
if (!getToken()) {
return null;
}
try {
const fresh = await apiFetch<AuthUser>('/auth/me');
setUser(fresh);
return fresh;
} catch {
return null;
}
}, []);
const logout = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
@@ -198,8 +250,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, token, loading, login, register, socialLogin, updateProfile, logout }),
[user, token, loading, login, register, socialLogin, updateProfile, logout],
() => ({
user, token, loading, login, register, socialLogin,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, refreshUser, logout,
}),
[user, token, loading, login, register, socialLogin,
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
updateProfile, refreshUser, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+272
View File
@@ -0,0 +1,272 @@
// Strony prawne serwisu: Regulamin, Polityka prywatności oraz baner zgody na pliki cookie.
// Treść dostosowana do usług portalu Polska Lokalnie (ogłoszenia nieruchomości bez prowizji,
// płatne promowanie ofert przez AutoPay). Dostawca: SoftSPM, hosting: OVHcloud.
import { useEffect, useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { ROUTES } from './routes';
const PROVIDER = {
name: 'SoftSPM',
legal: 'SoftSPM - działalność gospodarcza wpisana do Centralnej Ewidencji i Informacji o Działalności Gospodarczej (CEIDG)',
address: 'Ławy 42A, 97-400 Bełchatów, woj. łódzkie',
nip: '7691956407',
regon: '100629083',
email: 'info@polskalokalnie.pl',
hosting: 'OVHcloud (OVH Sp. z o.o., ul. Swobodna 1, 50-088 Wrocław)',
};
const SERVICE_NAME = 'Polska Lokalnie';
const LAST_UPDATED = '9 sierpnia 2026 r.';
function LegalPage({ title, subtitle, children }: { title: string; subtitle: string; children: ReactNode }) {
return (
<section className="legal-page" aria-label={title}>
<div className="legal-shell">
<header className="legal-head">
<p className="legal-brand">Polska Lokalnie <span>|</span> SoftSPM</p>
<h1>{title}</h1>
<p>{subtitle}</p>
<p className="legal-updated">Data ostatniej aktualizacji: {LAST_UPDATED}</p>
</header>
<div className="legal-body">{children}</div>
</div>
</section>
);
}
export function TermsPage() {
return (
<LegalPage
title="Regulamin platformy Polska Lokalnie"
subtitle="Zasady korzystania z platformy ogłoszeniowej oraz świadczenia usług drogą elektroniczną."
>
<h2>§ 1. Postanowienia ogólne</h2>
<ol>
<li>Niniejszy Regulamin określa zasady korzystania z platformy internetowej <strong>{SERVICE_NAME}</strong>, dostępnej pod adresem polskalokalnie.pl (dalej Platforma").</li>
<li>Właścicielem i dostawcą Platformy (Usługodawcą) jest <strong>{PROVIDER.legal}</strong>, {PROVIDER.address}, NIP: {PROVIDER.nip}, REGON: {PROVIDER.regon}, adres e-mail: {PROVIDER.email}.</li>
<li>Platforma jest utrzymywana na serwerach dostawcy hostingu {PROVIDER.hosting}, które są fizycznie zlokalizowane na terytorium Rzeczypospolitej Polskiej (w Unii Europejskiej).</li>
<li>Regulamin jest udostępniany nieodpłatnie w sposób umożliwiający jego pozyskanie, odtworzenie i utrwalenie.</li>
<li>Korzystanie z Platformy oznacza akceptację postanowień Regulaminu oraz Polityki prywatności.</li>
</ol>
<h2>§ 2. Definicje</h2>
<ul>
<li><strong>Usługodawca</strong> - {PROVIDER.name}, podmiot prowadzący Platformę.</li>
<li><strong>Użytkownik</strong> - osoba fizyczna, osoba prawna lub jednostka organizacyjna korzystająca z Platformy.</li>
<li><strong>Konsument</strong> - Użytkownik będący osobą fizyczną dokonujący czynności niezwiązanej bezpośrednio z jego działalnością gospodarczą lub zawodową.</li>
<li><strong>Konto</strong> - zbiór zasobów i uprawnień przypisanych Użytkownikowi po rejestracji.</li>
<li><strong>Ogłoszenie</strong> - zamieszczana przez Użytkownika oferta sprzedaży lub najmu nieruchomości.</li>
<li><strong>Usługa promowania (Wyróżnienie)</strong> - odpłatna usługa zwiększająca widoczność Ogłoszenia.</li>
<li><strong>Operator płatności</strong> - dostawca usług szybkich płatności obsługujący płatności w Platformie (AutoPay - Autopay S.A.).</li>
</ul>
<h2>§ 3. Rodzaje i zakres usług</h2>
<ol>
<li>Platforma umożliwia w szczególności: przeglądanie i wyszukiwanie ogłoszeń nieruchomości, <strong>bezpłatne dodawanie ogłoszeń</strong> sprzedaży i najmu, prowadzenie Konta, zapisywanie ofert do ulubionych, ustawianie alertów cenowych, korzystanie z narzędzi (m.in. orientacyjna wycena, kalkulator zdolności), kontakt między Użytkownikami oraz odpłatne <strong>promowanie ogłoszeń</strong>.</li>
<li>Dodawanie ogłoszeń jest bezpłatne i nie wiąże się z prowizją od transakcji.</li>
<li>Usługi płatne (promowanie, pakiety promowań) są świadczone zgodnie z § 8 i cennikiem prezentowanym w Platformie.</li>
<li>Narzędzia analityczne i wyceny mają charakter wyłącznie poglądowy i nie stanowią oferty, porady ani operatu szacunkowego w rozumieniu przepisów prawa.</li>
</ol>
<h2>§ 4. Wymagania techniczne</h2>
<ol>
<li>Do korzystania z Platformy niezbędne są: urządzenie z dostępem do Internetu, aktualna przeglądarka internetowa z obsługą JavaScript i plików cookie oraz aktywne konto poczty elektronicznej.</li>
<li>Usługodawca nie ponosi odpowiedzialności za problemy techniczne leżące po stronie Użytkownika lub dostawców usług telekomunikacyjnych.</li>
</ol>
<h2>§ 5. Rejestracja i Konto</h2>
<ol>
<li>Założenie Konta wymaga wypełnienia formularza rejestracji oraz akceptacji Regulaminu i Polityki prywatności. Możliwa jest również rejestracja i logowanie za pośrednictwem kont zewnętrznych (Google, Facebook).</li>
<li>Użytkownik zobowiązuje się podać dane prawdziwe i aktualne oraz zabezpieczyć dane logowania przed dostępem osób trzecich.</li>
<li>Konto jest przypisane do jednego Użytkownika; udostępnianie go osobom trzecim jest niedozwolone.</li>
<li>Użytkownik może w każdej chwili usunąć Konto, kontaktując się z Usługodawcą lub korzystając z odpowiedniej opcji w Platformie.</li>
</ol>
<h2>§ 6. Zasady publikacji ogłoszeń</h2>
<ol>
<li>Ogłoszenie może dotyczyć wyłącznie rzeczywistej oferty sprzedaży lub najmu nieruchomości, do której Użytkownik posiada tytuł prawny lub odpowiednie umocowanie.</li>
<li>Treść Ogłoszenia powinna być zgodna ze stanem faktycznym, kompletna i nie może wprowadzać w błąd.</li>
<li>Zabronione jest zamieszczanie treści bezprawnych, w szczególności: naruszających prawa osób trzecich, obraźliwych, wulgarnych, dyskryminujących, wprowadzających w błąd, o charakterze spamu lub niezwiązanych z przedmiotem Platformy.</li>
<li>Ogłoszenia podlegają moderacji. Usługodawca może wstrzymać publikację, edytować w niezbędnym zakresie lub usunąć Ogłoszenie naruszające Regulamin lub przepisy prawa, informując o tym Użytkownika.</li>
<li>Użytkownik udziela Usługodawcy niewyłącznej, nieodpłatnej licencji na korzystanie z treści i zdjęć zamieszczonych w Ogłoszeniu w zakresie niezbędnym do świadczenia usług (prezentacja i promocja Ogłoszenia w Platformie i kanałach powiązanych).</li>
</ol>
<h2>§ 7. Odpowiedzialność</h2>
<ol>
<li>Usługodawca udostępnia narzędzia teleinformatyczne i nie jest stroną transakcji zawieranych między Użytkownikami.</li>
<li>Za treść Ogłoszeń, ich zgodność z prawem oraz przebieg i skutki transakcji odpowiada Użytkownik zamieszczający Ogłoszenie.</li>
<li>Usługodawca dokłada starań, aby Platforma działała prawidłowo, jednak nie gwarantuje nieprzerwanej dostępności i zastrzega prawo do przerw technicznych.</li>
<li>Usługodawca działa zgodnie z ustawą o świadczeniu usług drogą elektroniczną i nie ma obowiązku uprzedniej kontroli treści; po uzyskaniu wiarygodnej wiadomości o bezprawnym charakterze treści niezwłocznie uniemożliwia do niej dostęp.</li>
</ol>
<h2>§ 8. Promowanie ogłoszeń (usługi płatne)</h2>
<ol>
<li>Użytkownik może odpłatnie wyróżnić Ogłoszenie na czas określony (np. 4, 14 lub 30 dni) zgodnie z planami dostępnymi w Platformie. Wyróżnione Ogłoszenia prezentowane są wyżej na listach wyników.</li>
<li>Dostępny jest również zakup pakietu promowań (np. 5 lub 10 sztuk) w cenie promocyjnej; zakupione kredyty pozwalają wyróżnić dowolne własne Ogłoszenie zgodnie z wybranym planem czasowym.</li>
<li>Ceny prezentowane w Platformie są cenami brutto wyrażonymi w złotych polskich (PLN).</li>
<li>Usługa promowania rozpoczyna się niezwłocznie po zaksięgowaniu płatności i trwa przez wybrany okres.</li>
</ol>
<h2>§ 9. Płatności</h2>
<ol>
<li>Płatności za usługi płatne obsługiwane są przez zewnętrznego Operatora płatności (AutoPay - Autopay S.A.), umożliwiającego m.in. płatność BLIK, kartą płatniczą oraz szybkim przelewem.</li>
<li>Rozliczenie następuje w momencie zakupu. Na żądanie Użytkownika Usługodawca wystawia dokument potwierdzający zakup zgodnie z obowiązującymi przepisami.</li>
<li>Usługodawca nie przechowuje pełnych danych kart płatniczych - są one przetwarzane wyłącznie przez Operatora płatności w bezpiecznym środowisku.</li>
</ol>
<h2>§ 10. Odstąpienie od umowy (Konsument)</h2>
<ol>
<li>Konsument, który zawarł umowę na odległość, może od niej odstąpić bez podania przyczyny w terminie 14 dni.</li>
<li>Rozpoczęcie świadczenia usługi promowania przed upływem terminu na odstąpienie następuje na wyraźne żądanie Konsumenta; Konsument przyjmuje do wiadomości, że po pełnym wykonaniu usługi traci prawo odstąpienia, a przy częściowym wykonaniu zwrot pomniejsza się proporcjonalnie do spełnionego świadczenia (art. 35 ustawy o prawach konsumenta).</li>
<li>Oświadczenie o odstąpieniu można złożyć na adres e-mail: {PROVIDER.email}.</li>
</ol>
<h2>§ 11. Reklamacje</h2>
<ol>
<li>Reklamacje dotyczące usług można składać na adres e-mail {PROVIDER.email}, wskazując dane Użytkownika i opis zastrzeżeń.</li>
<li>Usługodawca rozpatruje reklamację w terminie do 14 dni i informuje Użytkownika o wyniku.</li>
</ol>
<h2>§ 12. Dane osobowe</h2>
<p>Zasady przetwarzania danych osobowych określa <Link to={ROUTES.privacy}>Polityka prywatności</Link>. Administratorem danych jest Usługodawca.</p>
<h2>§ 13. Pliki cookie</h2>
<p>Platforma wykorzystuje pliki cookie zgodnie z Polityką prywatności. Podczas pierwszej wizyty Użytkownik wyraża zgodę na wykorzystanie plików cookie lub ogranicza ją do plików niezbędnych.</p>
<h2>§ 14. Prawa autorskie</h2>
<p>Platforma, jej układ, logotyp oraz oprogramowanie podlegają ochronie prawnej i stanowią własność Usługodawcy lub podmiotów, z którymi Usługodawca współpracuje. Kopiowanie lub wykorzystywanie bez zgody jest zabronione.</p>
<h2>§ 15. Pozasądowe rozwiązywanie sporów</h2>
<p>Konsument może skorzystać z pozasądowych sposobów rozpatrywania reklamacji i dochodzenia roszczeń, w tym z platformy ODR Komisji Europejskiej dostępnej pod adresem ec.europa.eu/consumers/odr oraz z pomocy powiatowego (miejskiego) rzecznika konsumentów.</p>
<h2>§ 16. Zmiany Regulaminu i postanowienia końcowe</h2>
<ol>
<li>Usługodawca może zmienić Regulamin z ważnych przyczyn (m.in. zmiana przepisów, zakresu usług), informując Użytkowników z odpowiednim wyprzedzeniem.</li>
<li>W sprawach nieuregulowanych stosuje się przepisy prawa polskiego, w szczególności Kodeksu cywilnego, ustawy o świadczeniu usług drogą elektroniczną oraz ustawy o prawach konsumenta.</li>
<li>Regulamin obowiązuje od dnia publikacji.</li>
</ol>
</LegalPage>
);
}
export function PrivacyPolicyPage() {
return (
<LegalPage
title="Polityka prywatności"
subtitle="Informacje o przetwarzaniu danych osobowych oraz wykorzystaniu plików cookie na platformie Polska Lokalnie."
>
<h2>1. Administrator danych</h2>
<p>Administratorem danych osobowych jest <strong>{PROVIDER.legal}</strong>, {PROVIDER.address}, NIP: {PROVIDER.nip}. Kontakt w sprawach danych osobowych: {PROVIDER.email}.</p>
<h2>2. Jakie dane przetwarzamy</h2>
<ul>
<li>dane podane przy rejestracji i w profilu (imię i nazwisko lub nazwa, adres e-mail, numer telefonu, dane firmowe),</li>
<li>dane zawarte w ogłoszeniach i wiadomościach,</li>
<li>dane dotyczące płatności za usługi płatne (bez pełnych danych kart płatniczych),</li>
<li>dane techniczne i o aktywności (adres IP, identyfikatory urządzenia, pliki cookie, logi).</li>
</ul>
<h2>3. Cele i podstawy prawne</h2>
<ul>
<li>świadczenie usług i prowadzenie Konta - art. 6 ust. 1 lit. b RODO (wykonanie umowy),</li>
<li>obsługa płatności i rozliczeń - art. 6 ust. 1 lit. b i c RODO,</li>
<li>obowiązki prawne (m.in. rachunkowe, reklamacje) - art. 6 ust. 1 lit. c RODO,</li>
<li>marketing własny, statystyki i bezpieczeństwo - art. 6 ust. 1 lit. f RODO (prawnie uzasadniony interes),</li>
<li>działania marketingowe realizowane na podstawie zgody (np. newsletter) - art. 6 ust. 1 lit. a RODO.</li>
</ul>
<h2>4. Odbiorcy danych</h2>
<p>Dane mogą być przekazywane podmiotom przetwarzającym je na zlecenie Administratora, w szczególności: dostawcy hostingu {PROVIDER.hosting}, Operatorowi płatności AutoPay (Autopay S.A.) w zakresie realizacji płatności, dostawcom usług e-mail i SMS oraz podmiotom świadczącym wsparcie techniczne - wyłącznie w zakresie niezbędnym i na podstawie odpowiednich umów powierzenia.</p>
<h2>5. Okres przechowywania</h2>
<p>Dane przechowujemy przez czas trwania umowy (posiadania Konta), a po jej zakończeniu przez okres wymagany przepisami (m.in. rozliczeniowymi) lub do przedawnienia roszczeń. Dane przetwarzane na podstawie zgody - do jej wycofania.</p>
<h2>6. Prawa osób, których dane dotyczą</h2>
<p>Przysługuje Państwu prawo dostępu do danych, ich sprostowania, usunięcia, ograniczenia przetwarzania, przenoszenia, wniesienia sprzeciwu oraz cofnięcia zgody w dowolnym momencie (bez wpływu na zgodność z prawem wcześniejszego przetwarzania). Mają Państwo także prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych (ul. Stawki 2, 00-193 Warszawa).</p>
<h2>7. Przekazywanie poza EOG</h2>
<p>Dane są przetwarzane na terenie Europejskiego Obszaru Gospodarczego, a serwery Platformy znajdują się fizycznie na terytorium Rzeczypospolitej Polskiej. W razie przekazania danych poza EOG stosujemy odpowiednie zabezpieczenia (m.in. standardowe klauzule umowne).</p>
<h2>8. Pliki cookie</h2>
<ul>
<li><strong>Niezbędne</strong> - konieczne do działania Platformy (logowanie, sesja, bezpieczeństwo). Nie wymagają zgody.</li>
<li><strong>Analityczne</strong> - pomagają zrozumieć sposób korzystania z Platformy i go ulepszać.</li>
<li><strong>Marketingowe/funkcjonalne</strong> - umożliwiają dopasowanie treści i zapamiętanie preferencji.</li>
</ul>
<p>Zgodę na pliki inne niż niezbędne wyrażają Państwo w banerze cookie podczas pierwszej wizyty; można ją w każdej chwili zmienić w ustawieniach przeglądarki. Ograniczenie plików cookie może wpłynąć na dostępność części funkcji.</p>
<h2>9. Bezpieczeństwo</h2>
<p>Stosujemy środki techniczne i organizacyjne odpowiadające ryzyku, w tym szyfrowanie transmisji (SSL/TLS) oraz kontrolę dostępu do danych.</p>
<h2>10. Zmiany polityki</h2>
<p>Polityka może być aktualizowana. Aktualna wersja jest zawsze dostępna w Platformie wraz z datą ostatniej aktualizacji.</p>
</LegalPage>
);
}
const COOKIE_CONSENT_KEY = 'polskalokalnie-cookie-consent';
const COOKIE_OPEN_EVENT = 'pl-cookie-open';
// Ponowne otwarcie ustawień plików cookie (np. z linku w stopce).
export function openCookieSettings() {
try {
window.localStorage.removeItem(COOKIE_CONSENT_KEY);
} catch {
/* ignorujemy brak dostępu do localStorage */
}
window.dispatchEvent(new Event(COOKIE_OPEN_EVENT));
}
// Baner zgody na pliki cookie - pojawia się przy pierwszej wizycie, dopóki Użytkownik nie dokona wyboru.
export function CookieBanner() {
const [visible, setVisible] = useState(false);
useEffect(() => {
try {
if (!window.localStorage.getItem(COOKIE_CONSENT_KEY)) {
setVisible(true);
}
} catch {
setVisible(true);
}
const open = () => setVisible(true);
window.addEventListener(COOKIE_OPEN_EVENT, open);
return () => window.removeEventListener(COOKIE_OPEN_EVENT, open);
}, []);
const decide = (choice: 'all' | 'essential') => {
try {
window.localStorage.setItem(COOKIE_CONSENT_KEY, choice);
} catch {
/* brak dostępu do localStorage - baner po prostu zniknie na tej sesji */
}
setVisible(false);
};
if (!visible) {
return null;
}
return (
<div className="cookie-banner" role="dialog" aria-live="polite" aria-label="Zgoda na pliki cookie">
<div className="cookie-banner-inner">
<div className="cookie-banner-text">
<strong>Dbamy o Twoją prywatność</strong>
<p>
Używamy plików cookie, aby platforma działała poprawnie, oraz - za Twoją zgodą - do celów analitycznych
i marketingowych. Szczegóły znajdziesz w <Link to={ROUTES.privacy}>Polityce prywatności</Link> i{' '}
<Link to={ROUTES.terms}>Regulaminie</Link>.
</p>
</div>
<div className="cookie-banner-actions">
<button type="button" className="cookie-btn cookie-btn-ghost" onClick={() => decide('essential')}>
Tylko niezbędne
</button>
<button type="button" className="cookie-btn cookie-btn-primary" onClick={() => decide('all')}>
Akceptuję wszystkie
</button>
</div>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
// Wspoldzielone narzedzia do obrazow (kompresja przy wgrywaniu).
/**
* Kompresja obrazu 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.
*/
export async function compressImageFile(file: File, maxWidth = 1024, quality = 0.82): Promise<string> {
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);
}
}
+6 -1
View File
@@ -29,6 +29,10 @@ export const ROUTES = {
accountProfileEdit: '/konto/profil/edycja',
accountPublicProfile: '/konto/profil',
accountHelpContact: '/konto/pomoc',
terms: '/regulamin',
privacy: '/polityka-prywatnosci',
activation: '/aktywacja/:token',
passwordReset: '/reset-hasla/:token',
notifications: '/powiadomienia',
favorites: '/ulubione',
messages: '/wiadomosci',
@@ -89,7 +93,8 @@ export const ADMIN_TAB_PATHS = {
forbiddenWords: 'zakazane-slowa',
leads: 'leady',
campaigns: 'kampanie',
mailConfig: 'konfiguracja-wysylki',
smtpConfig: 'konfiguracja-smtp',
smsConfig: 'konfiguracja-sms',
} as const;
export type AdminTabKey = keyof typeof ADMIN_TAB_PATHS;
+787 -36
View File
@@ -3312,12 +3312,11 @@ svg {
}
.satellite-tiles {
height: 640px;
left: 50%;
height: 100%;
left: 0;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 640px;
top: 0;
width: 100%;
}
.satellite-tiles img {
@@ -12610,45 +12609,48 @@ svg {
height: 1px;
}
.social-login-row {
display: flex;
gap: 10px;
}
.social-login {
align-items: center;
background: #ffffff;
border: 1px solid #dfe5ed;
border-radius: 6px;
color: #142238;
border: 1px solid #dce4ec;
border-radius: 10px;
color: #24344b;
cursor: pointer;
display: flex;
flex: 1 1 0;
font-size: 14px;
font-weight: 900;
gap: 10px;
height: 42px;
font-weight: 800;
gap: 9px;
height: 46px;
justify-content: center;
width: 100%;
min-width: 0;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.google-mark,
.facebook-mark {
align-items: center;
display: grid;
flex: 0 0 18px;
font-size: 14px;
font-weight: 950;
.social-login:hover:not(:disabled) {
background: #f7f9fc;
border-color: #c4d2e2;
box-shadow: 0 2px 10px rgba(20, 34, 58, 0.07);
}
.social-login:disabled {
cursor: default;
opacity: 0.6;
}
.social-login-icon {
flex: 0 0 auto;
height: 18px;
place-items: center;
width: 18px;
}
.google-mark {
color: #1a73e8;
font-family: Arial, sans-serif;
}
.facebook-mark {
background: #1877f2;
border-radius: 999px;
color: #ffffff;
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1;
@media (max-width: 400px) {
.social-login-row { flex-direction: column; }
}
.login-security-note {
@@ -27542,13 +27544,18 @@ a.listing-detail-back {
.ld-headline-price strong { color: #129357; font-size: 28px; font-weight: 900; }
.ld-headline-price span { color: #6b7a8d; font-size: 14px; font-weight: 700; }
/* Zdjecie glowne bylo za duze po poszerzeniu strony - ograniczamy wysokosc.
object-fit: contain, aby cale zdjecie bylo widoczne (bez przycinania); tlo wypelnia letterbox. */
.listing-detail-gallery-main-image { background: #eef2f6; height: clamp(300px, 40vw, 470px); }
/* Ramka galerii dopasowuje sie do proporcji aktualnego zdjecia (aspect-ratio nadawane inline z JS),
dzieki czemu kadr przylega do zdjecia - brak pustych paskow po bokach. Wysokosc ograniczona. */
.listing-detail-gallery-main-image {
aspect-ratio: 1.6;
background: #0f1b2d;
max-height: 620px;
width: 100%;
}
.listing-detail-gallery-main-image img {
aspect-ratio: auto;
height: 100%;
object-fit: contain;
object-fit: cover;
width: 100%;
}
@@ -27765,3 +27772,747 @@ a.listing-detail-back {
/* Karta "ostatnio przegladane" jest linkiem do oferty. */
.listing-card-link { color: inherit; text-decoration: none; }
.listing-card-link:hover { box-shadow: 0 10px 26px rgba(20, 34, 58, 0.12); }
/* ===== BANERY / PROMOCJE (sloty reklamowe) =====
Publiczne miejsca reklamowe na stronie glownej i karcie oferty + panel admina. */
/* Wrapper slotow pelnoszerokosciowych (hero/top) - trzyma szerokosc obszaru roboczego. */
.home-banner-wrap {
margin: 0 auto;
max-width: var(--workspace-max);
padding: 0 var(--workspace-pad);
width: 100%;
}
.home-banner-wrap:not(:empty) { margin-top: 22px; }
.banner-slot-home_middle { display: block; margin: 22px 0; }
.banner-slot-sidebar { display: block; margin-top: 16px; }
.promo-banner {
align-items: flex-end;
background: linear-gradient(120deg, #0f2f5b 0%, #12a764 135%);
background-size: cover;
background-position: center;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(15, 31, 58, 0.12);
color: #ffffff;
display: flex;
min-height: 140px;
overflow: hidden;
padding: 22px 26px;
position: relative;
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.promo-banner:hover { transform: translateY(-2px); box-shadow: 0 16px 38px rgba(15, 31, 58, 0.18); }
.promo-banner.has-image { background-color: #0f1b2d; }
.promo-banner-tag {
background: rgba(255, 255, 255, 0.85);
border-radius: 999px;
color: #33475f;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.03em;
padding: 3px 9px;
position: absolute;
right: 14px;
text-transform: uppercase;
top: 12px;
}
.promo-banner-body { display: flex; flex-direction: column; gap: 6px; max-width: 640px; position: relative; z-index: 1; }
.promo-banner-title { font-size: 22px; font-weight: 950; line-height: 1.2; }
.promo-banner-sub { font-size: 14px; font-weight: 600; opacity: 0.94; }
.promo-banner-cta {
align-items: center;
align-self: flex-start;
background: #ffffff;
border-radius: 999px;
color: #0f2f5b;
display: inline-flex;
font-size: 13px;
font-weight: 850;
gap: 6px;
margin-top: 8px;
padding: 9px 16px;
}
.promo-banner:hover .promo-banner-cta { background: #eafaf1; }
.banner-slot-sidebar .promo-banner { min-height: 210px; padding: 18px 20px; }
.banner-slot-sidebar .promo-banner-title { font-size: 18px; }
.banner-slot-sidebar .promo-banner-sub { font-size: 13px; }
@media (max-width: 640px) {
.promo-banner { min-height: 120px; padding: 18px; }
.promo-banner-title { font-size: 18px; }
}
/* --- Panel admina: banery --- */
.mkt-col-span { grid-column: 1 / -1; }
.admin-btn.ghost { background: #ffffff; }
.banner-image-row {
border-top: 1px solid #eef2f6;
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 14px;
}
.banner-image-preview {
align-items: center;
background: #f4f7fa;
border: 1px solid #dce4ec;
border-radius: 10px;
display: flex;
flex: 0 0 260px;
height: 130px;
justify-content: center;
overflow: hidden;
}
.banner-image-preview img { height: 100%; object-fit: cover; width: 100%; }
.banner-image-empty { align-items: center; color: #8aa0b6; display: inline-flex; font-size: 13px; font-weight: 700; gap: 8px; }
.banner-image-empty svg { width: 16px; height: 16px; }
.banner-image-actions { align-items: flex-start; display: flex; flex-direction: column; gap: 8px; }
.banner-upload-btn { align-items: center; cursor: pointer; display: inline-flex; gap: 7px; }
.banner-upload-btn svg { height: 15px; width: 15px; }
.banner-image-hint { color: #6b7f96; font-size: 12px; max-width: 420px; }
.banner-slot-hint { align-items: center; color: #46617d; display: flex; font-size: 12px; gap: 7px; margin: 4px 0 0; }
.banner-slot-hint svg { height: 14px; width: 14px; }
.banner-admin-list { display: grid; gap: 18px; }
.banner-admin-group-title { color: #6b7f96; font-size: 12px; font-weight: 800; letter-spacing: 0.03em; margin: 0 0 8px; text-transform: uppercase; }
.banner-admin-row {
align-items: center;
border: 1px solid #e6edf4;
border-radius: 12px;
display: flex;
gap: 14px;
margin-bottom: 10px;
padding: 12px;
}
.banner-admin-row.is-off { opacity: 0.62; }
.banner-admin-thumb {
align-items: center;
background: linear-gradient(120deg, #0f2f5b, #12a764);
border-radius: 8px;
color: #ffffff;
display: flex;
flex: 0 0 92px;
height: 60px;
justify-content: center;
overflow: hidden;
}
.banner-admin-thumb img { height: 100%; object-fit: cover; width: 100%; }
.banner-admin-thumb-text svg { height: 20px; width: 20px; }
.banner-admin-info { display: flex; flex: 1 1 auto; flex-direction: column; gap: 3px; min-width: 0; }
.banner-admin-info strong { color: #14223a; font-size: 14px; }
.banner-admin-info > span { color: #5a6b80; font-size: 12px; }
.banner-admin-meta { align-items: center; display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 3px; }
.banner-admin-link { color: #12a764; font-size: 11px; overflow: hidden; text-overflow: ellipsis; max-width: 260px; white-space: nowrap; }
.banner-admin-stats { color: #7a8aa0; font-size: 11px; font-weight: 700; }
.banner-status { border-radius: 999px; font-size: 11px; font-weight: 800; padding: 2px 8px; }
.banner-status.on { background: #e3f4ec; color: #12a764; }
.banner-status.off { background: #f1f4f8; color: #8090a4; }
.banner-admin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
@media (max-width: 720px) {
.banner-image-row { flex-direction: column; }
.banner-image-preview { flex-basis: auto; width: 100%; }
.banner-admin-row { flex-wrap: wrap; }
}
/* ===== PROMOWANIE OFERT (modal, odznaki, panel admina) ===== */
.listing-promote-modal {
background: #ffffff;
border: 1px solid #e4e9f1;
border-radius: 16px;
max-width: 560px;
padding: 22px;
position: relative;
width: 100%;
}
.listing-promote-icon {
align-items: center;
background: linear-gradient(120deg, #0f2f5b, #12a764);
border-radius: 14px;
color: #ffffff;
display: flex;
height: 46px;
justify-content: center;
margin-bottom: 10px;
width: 46px;
}
.listing-promote-icon svg { height: 22px; width: 22px; }
.listing-promote-modal h2 { color: #13243d; font-size: 20px; margin: 0 0 6px; }
.promote-plans { display: grid; gap: 8px; margin: 14px 0 6px; }
.promote-plans > strong { color: #24374d; font-size: 13px; }
.promote-plan {
align-items: center;
background: #ffffff;
border: 1.5px solid #dce4ec;
border-radius: 10px;
cursor: pointer;
display: flex;
gap: 10px;
padding: 12px 14px;
text-align: left;
transition: border-color 0.12s ease, background 0.12s ease;
}
.promote-plan:hover { border-color: #b7c6d8; }
.promote-plan.active { background: #eafaf1; border-color: #12a764; }
.promote-plan-name { color: #14223a; font-weight: 850; }
.promote-plan-days { color: #6b7f96; font-size: 12px; font-weight: 700; }
.promote-plan-price { color: #0f7a48; font-weight: 950; margin-left: auto; }
.promote-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0; }
.promote-pay-btn {
align-items: center;
background: #12a764;
border: 0;
border-radius: 10px;
color: #ffffff;
cursor: pointer;
display: inline-flex;
flex: 1 1 220px;
font-size: 14px;
font-weight: 850;
gap: 8px;
justify-content: center;
padding: 12px 16px;
}
.promote-pay-btn:hover { background: #0f8f55; }
.promote-pay-btn:disabled { cursor: default; opacity: 0.6; }
.promote-credit-btn {
align-items: center;
background: #ffffff;
border: 1.5px solid #12a764;
border-radius: 10px;
color: #0f7a48;
cursor: pointer;
display: inline-flex;
flex: 1 1 180px;
font-size: 14px;
font-weight: 850;
gap: 8px;
justify-content: center;
padding: 12px 16px;
}
.promote-credit-btn:hover { background: #eafaf1; }
.promote-credit-btn svg, .promote-pay-btn svg { height: 15px; width: 15px; }
.promote-packages { border-top: 1px solid #eef2f6; margin-top: 6px; padding-top: 14px; }
.promote-packages > strong { color: #24374d; font-size: 13px; }
.promote-packages-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.promote-package {
background: #f6f9fc;
border: 1.5px solid #dce4ec;
border-radius: 10px;
cursor: pointer;
display: flex;
flex: 1 1 160px;
flex-direction: column;
gap: 2px;
padding: 12px;
text-align: left;
}
.promote-package:hover { border-color: #12a764; }
.promote-package:disabled { cursor: default; opacity: 0.6; }
.promote-package-qty { color: #12a764; font-size: 16px; font-weight: 950; }
.promote-package-name { color: #14223a; font-size: 12px; font-weight: 700; }
.promote-package-price { color: #14223a; font-weight: 900; margin-top: 4px; }
.promote-package-unit { color: #7a8aa0; font-size: 11px; }
.promote-note { align-items: center; color: #7a8798; display: flex; font-size: 12px; font-weight: 700; gap: 7px; margin: 12px 0 0; }
.promote-note svg { height: 13px; width: 13px; }
.promote-done { align-items: center; color: #0f7a48; display: flex; font-size: 15px; font-weight: 800; gap: 9px; margin: 16px 0; }
.promote-done svg { color: #12a764; height: 20px; width: 20px; }
/* Przycisk "Promuj" na liscie moich ofert */
.promote-action-btn {
align-items: center;
background: linear-gradient(120deg, #0f2f5b, #12a764);
border: 0;
border-radius: 8px;
color: #ffffff !important;
cursor: pointer;
display: inline-flex;
font-weight: 850;
gap: 6px;
}
.promote-action-btn svg { height: 14px; width: 14px; }
/* Odznaki "Wyroznione" */
.listing-promoted-tag {
align-items: center;
background: #eafaf1;
border-radius: 999px;
color: #0f7a48;
display: inline-flex;
font-size: 11px;
font-style: normal;
font-weight: 850;
gap: 4px;
padding: 2px 9px;
}
.listing-promoted-tag svg { height: 12px; width: 12px; }
.ld-badge-promoted { align-items: center; background: #12a764; color: #ffffff; display: inline-flex; gap: 5px; }
.ld-badge-promoted svg { height: 13px; width: 13px; }
.result-promoted-badge {
align-items: center;
background: #12a764;
border-radius: 999px;
bottom: 12px;
color: #ffffff;
display: inline-flex;
font-size: 11px;
font-weight: 850;
gap: 5px;
left: 12px;
padding: 4px 10px;
position: absolute;
box-shadow: 0 3px 10px rgba(15, 31, 58, 0.22);
}
.result-promoted-badge svg { height: 12px; width: 12px; }
.publish-promote-cta {
align-items: center;
background: linear-gradient(120deg, #0f2f5b 0%, #12a764 130%);
border: 0;
border-radius: 12px;
color: #ffffff;
cursor: pointer;
display: flex;
font-size: 14px;
font-weight: 800;
gap: 9px;
justify-content: center;
margin: 4px auto 14px;
max-width: 520px;
padding: 13px 18px;
width: 100%;
}
.publish-promote-cta:hover { filter: brightness(1.05); }
.publish-promote-cta svg { height: 16px; width: 16px; }
/* Panel admina - listy planow/pakietow */
.promo-admin-list { display: grid; gap: 8px; margin-top: 14px; }
.promo-admin-row {
align-items: center;
border: 1px solid #e6edf4;
border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
padding: 11px 13px;
}
.promo-admin-row.is-off { opacity: 0.62; }
.promo-admin-info { display: flex; flex-direction: column; gap: 2px; margin-right: auto; min-width: 160px; }
.promo-admin-info strong { color: #14223a; font-size: 14px; }
.promo-admin-info span { color: #5a6b80; font-size: 12px; }
.promo-admin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.promo-orders-table td, .promo-orders-table th { white-space: nowrap; }
/* ===== STRONY PRAWNE (Regulamin, Polityka prywatnosci) ===== */
.legal-page { background: #f5f7fa; padding: 30px 0 50px; }
.legal-shell {
background: #ffffff;
border: 1px solid #e3e9f0;
border-radius: 16px;
margin: 0 auto;
max-width: 900px;
padding: 34px clamp(20px, 4vw, 44px);
width: calc(100% - 2 * var(--workspace-pad));
}
.legal-head { border-bottom: 1px solid #eef2f6; margin-bottom: 18px; padding-bottom: 16px; }
.legal-brand { color: #13243d; font-size: 14px; font-weight: 950; letter-spacing: -0.01em; margin: 0 0 10px; }
.legal-brand span { color: #cdd6e2; font-weight: 400; margin: 0 4px; }
.legal-head h1 { color: #13243d; font-size: clamp(24px, 3.4vw, 32px); margin: 0 0 8px; }
.legal-head > p { color: #4f6277; font-size: 15px; margin: 0; }
.legal-updated { color: #8a99ad !important; font-size: 13px !important; font-weight: 700; margin-top: 8px !important; }
.legal-body { color: #2b3b52; font-size: 15px; line-height: 1.7; }
.legal-body h2 { color: #13243d; font-size: 18px; font-weight: 850; margin: 26px 0 10px; }
.legal-body p { margin: 0 0 12px; }
.legal-body ul, .legal-body ol { margin: 0 0 14px; padding-left: 22px; }
.legal-body li { margin: 6px 0; }
.legal-body a { color: #12a764; font-weight: 700; text-decoration: underline; }
.legal-note {
background: #f6f9fc;
border: 1px dashed #cdd9e6;
border-radius: 10px;
color: #6b7f96;
font-size: 13px;
margin-top: 24px;
padding: 12px 14px;
}
/* ===== STOPKA SERWISU ===== */
.site-footer { background: #0f2340; color: #c6d3e4; margin-top: 40px; }
.site-footer-inner {
display: grid;
gap: 32px 40px;
grid-template-columns: 1.4fr 3fr;
margin: 0 auto;
max-width: var(--workspace-max);
padding: 42px var(--workspace-pad) 30px;
width: 100%;
}
.footer-brand { max-width: 340px; }
.footer-logo { color: #ffffff; font-size: 22px; font-weight: 950; letter-spacing: -0.02em; text-decoration: none; }
.footer-logo span { color: #35d38a; }
.footer-tagline { color: #a9bace; font-size: 14px; line-height: 1.6; margin: 12px 0 14px; }
.footer-badge {
background: rgba(53, 211, 138, 0.14);
border-radius: 999px;
color: #6ee7ad;
display: inline-block;
font-size: 12px;
font-weight: 800;
margin: 0;
padding: 6px 12px;
}
.footer-cols { display: grid; gap: 24px 28px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.footer-col { display: flex; flex-direction: column; gap: 9px; min-width: 0; }
.footer-col h3 { color: #ffffff; font-size: 13px; font-weight: 850; letter-spacing: 0.04em; margin: 0 0 3px; text-transform: uppercase; }
.footer-col a, .footer-link-btn {
color: #b7c6d8;
font-size: 14px;
text-decoration: none;
transition: color 0.12s ease;
}
.footer-col a:hover, .footer-link-btn:hover { color: #35d38a; }
.footer-link-btn {
background: none;
border: 0;
cursor: pointer;
font-family: inherit;
padding: 0;
text-align: left;
}
.footer-muted { color: #7c90a8; font-size: 13px; }
.site-footer-bottom {
align-items: center;
border-top: 1px solid rgba(255, 255, 255, 0.09);
color: #8296ae;
display: flex;
flex-wrap: wrap;
font-size: 12.5px;
gap: 6px 20px;
justify-content: center;
margin: 0 auto;
max-width: var(--workspace-max);
padding: 16px var(--workspace-pad);
text-align: center;
width: 100%;
}
@media (max-width: 900px) {
.site-footer-inner { grid-template-columns: 1fr; gap: 26px; }
.footer-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 520px) {
.footer-cols { grid-template-columns: 1fr; }
}
/* ===== BANER ZGODY NA COOKIE ===== */
.cookie-banner {
bottom: 0;
left: 0;
padding: 14px;
position: fixed;
right: 0;
z-index: 4000;
}
.cookie-banner-inner {
align-items: center;
background: #0f2340;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(9, 20, 38, 0.35);
color: #e5edf7;
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
justify-content: space-between;
margin: 0 auto;
max-width: 1120px;
padding: 16px 20px;
}
.cookie-banner-text { flex: 1 1 340px; min-width: 0; }
.cookie-banner-text strong { color: #ffffff; display: block; font-size: 15px; margin-bottom: 4px; }
.cookie-banner-text p { color: #b7c6d8; font-size: 13px; line-height: 1.55; margin: 0; }
.cookie-banner-text a { color: #35d38a; font-weight: 700; text-decoration: underline; }
.cookie-banner-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; }
.cookie-btn {
border-radius: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 850;
padding: 11px 18px;
white-space: nowrap;
}
.cookie-btn-ghost { background: transparent; border: 1.5px solid rgba(255, 255, 255, 0.28); color: #dbe6f3; }
.cookie-btn-ghost:hover { border-color: rgba(255, 255, 255, 0.5); }
.cookie-btn-primary { background: #12a764; border: 0; color: #ffffff; }
.cookie-btn-primary:hover { background: #0f8f55; }
@media (max-width: 620px) {
.cookie-banner-actions { width: 100%; }
.cookie-btn { flex: 1 1 auto; text-align: center; }
}
/* --- Proces konta: strony statusu (aktywacja, reset hasla) + okno OTP telefonu --- */
.auth-status-page {
min-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 20px;
}
.auth-status-card {
width: 100%;
max-width: 460px;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 18px;
box-shadow: 0 18px 50px rgba(15, 35, 64, 0.10);
padding: 40px 36px;
text-align: center;
}
.auth-status-logo {
display: inline-block;
font-size: 20px;
font-weight: 800;
color: #0f2340;
letter-spacing: -0.01em;
text-decoration: none;
margin-bottom: 22px;
}
.auth-status-logo span { color: #12a764; }
.auth-status-card h1 {
font-size: 24px;
color: #0f2340;
margin: 6px 0 12px;
}
.auth-status-icon {
width: 60px;
height: 60px;
margin: 0 auto 8px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 800;
}
.auth-status-icon.ok { background: #e3f7ee; color: #12a764; }
.auth-status-icon.err { background: #fdecec; color: #d64545; }
.auth-status-lead {
color: #4a5568;
font-size: 15px;
line-height: 1.6;
margin: 0 0 8px;
}
.auth-status-hint {
color: #7a8699;
font-size: 13.5px;
margin: 4px 0 12px;
}
.auth-status-form {
display: flex;
flex-direction: column;
gap: 14px;
text-align: left;
margin-top: 20px;
}
.auth-status-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #0f2340;
}
.auth-status-form input {
height: 44px;
border: 1px solid #d5dce6;
border-radius: 10px;
padding: 0 14px;
font-size: 15px;
background: #fff;
color: #0f2340;
}
.auth-status-form input:focus {
outline: none;
border-color: #12a764;
box-shadow: 0 0 0 3px rgba(18, 167, 100, 0.15);
}
.auth-status-error {
color: #d64545;
font-size: 13.5px;
margin: 0;
}
.auth-status-btn {
display: inline-block;
width: 100%;
margin-top: 20px;
height: 46px;
line-height: 46px;
border: none;
border-radius: 10px;
background: #12a764;
color: #fff;
font-size: 15px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: background 0.15s ease;
}
.auth-status-btn:hover { background: #0f8f56; }
.auth-status-btn:disabled { opacity: 0.6; cursor: default; }
.auth-status-form .auth-status-btn { margin-top: 6px; }
/* Okno OTP telefonu (na wspolnym backdropie .listing-delete-overlay) */
.otp-modal {
position: relative;
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 18px;
box-shadow: 0 24px 60px rgba(15, 35, 64, 0.28);
padding: 34px 30px 26px;
text-align: center;
}
.otp-modal h2 {
font-size: 21px;
color: #0f2340;
margin: 6px 0 10px;
}
.otp-modal-icon { font-size: 38px; line-height: 1; }
.otp-modal-lead {
color: #4a5568;
font-size: 14.5px;
line-height: 1.6;
margin: 0 0 18px;
}
.otp-modal-form {
display: flex;
flex-direction: column;
gap: 14px;
align-items: stretch;
}
.otp-input {
height: 58px;
border: 1px solid #d5dce6;
border-radius: 12px;
text-align: center;
font-size: 30px;
font-weight: 700;
letter-spacing: 0.5em;
padding-left: 0.5em;
color: #0f2340;
background: #f8fafc;
}
.otp-input:focus {
outline: none;
border-color: #12a764;
box-shadow: 0 0 0 3px rgba(18, 167, 100, 0.15);
background: #fff;
}
.otp-modal-ok { color: #12a764; font-size: 13.5px; margin: 0; }
.otp-modal-actions {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
}
.otp-link {
background: none;
border: none;
color: #12a764;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
padding: 4px 2px;
}
.otp-link:disabled { color: #9aa6b6; cursor: default; }
.otp-link:hover:not(:disabled) { text-decoration: underline; }
/* Login: komunikat informacyjny, ponowne wyslanie linku, akcje dodatkowe */
.login-info {
background: #e3f7ee;
color: #0f7a4d;
border-radius: 10px;
padding: 10px 14px;
font-size: 13.5px;
margin: 4px 0;
}
.login-resend-activation,
.login-secondary-btn {
width: 100%;
height: 44px;
border-radius: 10px;
font-size: 14.5px;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
}
.login-resend-activation {
background: #fff;
border: 1px solid #12a764;
color: #12a764;
}
.login-resend-activation:hover { background: #f0fbf5; }
.login-secondary-btn {
background: #eef2f8;
border: 1px solid #d5dce6;
color: #0f2340;
}
.login-secondary-btn:hover { background: #e3e9f2; }
.login-back-link {
display: inline-block;
margin-top: 16px;
background: none;
border: none;
color: #12a764;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.login-back-link:hover { text-decoration: underline; }
/* Rejestracja firmy: pobranie danych z rejestru REGON (GUS) przyciskiem obok pola NIP */
/* stretch: przycisk zawsze rowny wysokosci pola, takze w widoku mobilnym */
.login-field-gus > div {
align-items: stretch;
display: flex;
gap: 8px;
}
.login-field-gus input {
flex: 1 1 auto;
min-width: 0;
padding-right: 14px;
}
.gus-fetch-btn {
flex: 0 0 auto;
padding: 0 16px;
border: 1px solid #12a764;
border-radius: 6px;
background: #ffffff;
color: #12a764;
font-size: 13px;
font-weight: 800;
cursor: pointer;
white-space: nowrap;
}
.login-field-hint {
color: #647487;
font-size: 12px;
font-weight: 600;
margin: -6px 0 0;
}
.gus-fetch-btn:hover:not(:disabled) { background: #f0fbf5; }
.gus-fetch-btn:disabled {
border-color: #d8e0ea;
color: #9aa6b6;
cursor: default;
}