SMPT, SMS, Rejestracja + GUS
This commit is contained in:
@@ -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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.AuthResponse;
|
||||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||||
|
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||||
@@ -28,7 +33,7 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@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));
|
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +47,48 @@ public class AuthController {
|
|||||||
return authService.socialLogin(request);
|
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")
|
@GetMapping("/me")
|
||||||
public UserResponse me(Authentication authentication) {
|
public UserResponse me(Authentication authentication) {
|
||||||
return authService.currentUser(authentication.getName());
|
return authService.currentUser(authentication.getName());
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
package pl.polskalokalnie.auth;
|
package pl.polskalokalnie.auth;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.lead.LeadSyncService;
|
import pl.polskalokalnie.lead.LeadSyncService;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||||
|
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||||
@@ -27,12 +33,19 @@ import pl.polskalokalnie.user.UserRepository;
|
|||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
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 UserRepository userRepository;
|
||||||
private final BlockedEmailRepository blockedEmailRepository;
|
private final BlockedEmailRepository blockedEmailRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final TextModerationService textModerationService;
|
private final TextModerationService textModerationService;
|
||||||
private final LeadSyncService leadSyncService;
|
private final LeadSyncService leadSyncService;
|
||||||
|
private final AuthTokenRepository authTokenRepository;
|
||||||
|
private final AccountEmailService accountEmailService;
|
||||||
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
|
|
||||||
public AuthService(
|
public AuthService(
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
@@ -40,7 +53,10 @@ public class AuthService {
|
|||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
TextModerationService textModerationService,
|
TextModerationService textModerationService,
|
||||||
LeadSyncService leadSyncService
|
LeadSyncService leadSyncService,
|
||||||
|
AuthTokenRepository authTokenRepository,
|
||||||
|
AccountEmailService accountEmailService,
|
||||||
|
PhoneVerificationService phoneVerificationService
|
||||||
) {
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.blockedEmailRepository = blockedEmailRepository;
|
this.blockedEmailRepository = blockedEmailRepository;
|
||||||
@@ -48,9 +64,13 @@ public class AuthService {
|
|||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.textModerationService = textModerationService;
|
this.textModerationService = textModerationService;
|
||||||
this.leadSyncService = leadSyncService;
|
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());
|
String email = normalizeEmail(request.email());
|
||||||
if (blockedEmailRepository.existsByEmailIgnoreCase(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");
|
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");
|
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();
|
AppUser user = new AppUser();
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
@@ -68,14 +88,28 @@ public class AuthService {
|
|||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setProvider(AuthProvider.LOCAL);
|
user.setProvider(AuthProvider.LOCAL);
|
||||||
user.setAccountType(request.accountType() != null ? request.accountType() : AccountType.PERSONAL);
|
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);
|
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.setVerified(false);
|
||||||
|
user.setPhoneVerified(false);
|
||||||
|
|
||||||
AppUser saved = userRepository.save(user);
|
AppUser saved = userRepository.save(user);
|
||||||
leadSyncService.syncUser(saved);
|
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) {
|
public AuthResponse login(LoginRequest request) {
|
||||||
@@ -89,10 +123,112 @@ public class AuthService {
|
|||||||
if (user.isBlocked()) {
|
if (user.isBlocked()) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
|
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);
|
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.
|
* Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca.
|
||||||
* Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo.
|
* 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(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/error").permitAll()
|
.requestMatchers("/error").permitAll()
|
||||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").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("/api/i18n/translate").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||||
.requestMatchers("/api/auth/me").authenticated()
|
.requestMatchers("/api/auth/me").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
.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("/api/admin/**").hasRole("ADMIN")
|
||||||
.requestMatchers(HttpMethod.POST, "/api/listings").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/listings").authenticated()
|
||||||
.anyRequest().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,
|
@NotBlank String fullName,
|
||||||
AccountType accountType,
|
AccountType accountType,
|
||||||
String phone,
|
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,
|
String nip,
|
||||||
LocalDate birthDate,
|
LocalDate birthDate,
|
||||||
boolean verified,
|
boolean verified,
|
||||||
|
boolean phoneVerified,
|
||||||
boolean blocked,
|
boolean blocked,
|
||||||
|
int promotionCredits,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
public static UserResponse from(AppUser user) {
|
public static UserResponse from(AppUser user) {
|
||||||
@@ -41,7 +43,9 @@ public record UserResponse(
|
|||||||
user.getNip(),
|
user.getNip(),
|
||||||
user.getBirthDate(),
|
user.getBirthDate(),
|
||||||
user.isVerified(),
|
user.isVerified(),
|
||||||
|
user.isPhoneVerified(),
|
||||||
user.isBlocked(),
|
user.isBlocked(),
|
||||||
|
user.getPromotionCredits(),
|
||||||
user.getCreatedAt()
|
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.boot.CommandLineRunner;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Component;
|
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.ListingRepository;
|
||||||
import pl.polskalokalnie.listing.ListingStatus;
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
import pl.polskalokalnie.listing.OfferType;
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
import pl.polskalokalnie.listing.PropertyListing;
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
import pl.polskalokalnie.listing.PropertyType;
|
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.AppUser;
|
||||||
import pl.polskalokalnie.user.AuthProvider;
|
import pl.polskalokalnie.user.AuthProvider;
|
||||||
import pl.polskalokalnie.user.Role;
|
import pl.polskalokalnie.user.Role;
|
||||||
@@ -20,6 +27,9 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ListingRepository listingRepository;
|
private final ListingRepository listingRepository;
|
||||||
|
private final BannerRepository bannerRepository;
|
||||||
|
private final PromotionPlanRepository promotionPlanRepository;
|
||||||
|
private final PromotionPackageRepository promotionPackageRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final String adminEmail;
|
private final String adminEmail;
|
||||||
private final String adminPassword;
|
private final String adminPassword;
|
||||||
@@ -28,6 +38,9 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
public DataSeeder(
|
public DataSeeder(
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
ListingRepository listingRepository,
|
ListingRepository listingRepository,
|
||||||
|
BannerRepository bannerRepository,
|
||||||
|
PromotionPlanRepository promotionPlanRepository,
|
||||||
|
PromotionPackageRepository promotionPackageRepository,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
||||||
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
||||||
@@ -35,6 +48,9 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
) {
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.listingRepository = listingRepository;
|
this.listingRepository = listingRepository;
|
||||||
|
this.bannerRepository = bannerRepository;
|
||||||
|
this.promotionPlanRepository = promotionPlanRepository;
|
||||||
|
this.promotionPackageRepository = promotionPackageRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.adminEmail = adminEmail;
|
this.adminEmail = adminEmail;
|
||||||
this.adminPassword = adminPassword;
|
this.adminPassword = adminPassword;
|
||||||
@@ -45,6 +61,74 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
public void run(String... args) {
|
public void run(String... args) {
|
||||||
seedAdmin();
|
seedAdmin();
|
||||||
seedDemoListings();
|
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() {
|
private void seedAdmin() {
|
||||||
@@ -93,6 +177,8 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
listing.setPrice(price);
|
listing.setPrice(price);
|
||||||
listing.setArea(area);
|
listing.setArea(area);
|
||||||
listing.setRooms(rooms);
|
listing.setRooms(rooms);
|
||||||
|
listing.setContactName("Biuro Polska Lokalnie");
|
||||||
|
listing.setContactPhone("+48 600 100 200");
|
||||||
listing.setOwnerEmail("demo.user@polskalokalnie.pl");
|
listing.setOwnerEmail("demo.user@polskalokalnie.pl");
|
||||||
listing.setStatus(status);
|
listing.setStatus(status);
|
||||||
return listing;
|
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,
|
String ownerEmail,
|
||||||
ListingStatus status,
|
ListingStatus status,
|
||||||
Instant createdAt,
|
Instant createdAt,
|
||||||
Long viewsCount
|
Long viewsCount,
|
||||||
|
Instant promotedUntil
|
||||||
) {
|
) {
|
||||||
public static ListingDetailResponse from(PropertyListing listing) {
|
public static ListingDetailResponse from(PropertyListing listing) {
|
||||||
return new ListingDetailResponse(
|
return new ListingDetailResponse(
|
||||||
@@ -91,7 +92,8 @@ public record ListingDetailResponse(
|
|||||||
listing.getOwnerEmail(),
|
listing.getOwnerEmail(),
|
||||||
listing.getStatus(),
|
listing.getStatus(),
|
||||||
listing.getCreatedAt(),
|
listing.getCreatedAt(),
|
||||||
listing.getViewsCount()
|
listing.getViewsCount(),
|
||||||
|
listing.getPromotedUntil()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public record ListingResponse(
|
|||||||
String ownerEmail,
|
String ownerEmail,
|
||||||
ListingStatus status,
|
ListingStatus status,
|
||||||
Long viewsCount,
|
Long viewsCount,
|
||||||
|
Instant promotedUntil,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
public static ListingResponse from(PropertyListing listing) {
|
public static ListingResponse from(PropertyListing listing) {
|
||||||
@@ -50,6 +51,7 @@ public record ListingResponse(
|
|||||||
listing.getOwnerEmail(),
|
listing.getOwnerEmail(),
|
||||||
listing.getStatus(),
|
listing.getStatus(),
|
||||||
listing.getViewsCount(),
|
listing.getViewsCount(),
|
||||||
|
listing.getPromotedUntil(),
|
||||||
listing.getCreatedAt()
|
listing.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,19 @@ public class ListingService {
|
|||||||
this.notificationService = notificationService;
|
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) {
|
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
|
||||||
return listingRepository.findAll().stream()
|
return listingRepository.findAll().stream()
|
||||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||||
.filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim()))
|
.filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim()))
|
||||||
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
||||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
.sorted(promotedFirst())
|
||||||
.map(ListingResponse::from)
|
.map(ListingResponse::from)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -55,7 +61,7 @@ public class ListingService {
|
|||||||
public List<ListingResponse> findMine(String ownerEmail) {
|
public List<ListingResponse> findMine(String ownerEmail) {
|
||||||
return listingRepository.findAll().stream()
|
return listingRepository.findAll().stream()
|
||||||
.filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail()))
|
.filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail()))
|
||||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
.sorted(promotedFirst())
|
||||||
.map(ListingResponse::from)
|
.map(ListingResponse::from)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -173,7 +179,7 @@ public class ListingService {
|
|||||||
|
|
||||||
public List<ListingResponse> findAllForModeration() {
|
public List<ListingResponse> findAllForModeration() {
|
||||||
return listingRepository.findAll().stream()
|
return listingRepository.findAll().stream()
|
||||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
.sorted(promotedFirst())
|
||||||
.map(ListingResponse::from)
|
.map(ListingResponse::from)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ public class PropertyListing {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Long viewsCount = 0L;
|
private Long viewsCount = 0L;
|
||||||
|
|
||||||
|
// Data wygasniecia promowania (wyroznienia). null = nie promowane.
|
||||||
|
private Instant promotedUntil;
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false)
|
@Column(nullable = false, updatable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -481,6 +484,18 @@ public class PropertyListing {
|
|||||||
this.viewsCount = viewsCount == null ? 0L : Math.max(0L, viewsCount);
|
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() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
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;
|
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.URI;
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
@@ -11,19 +13,21 @@ import org.springframework.stereotype.Component;
|
|||||||
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). Zadanie HTTP POST
|
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}).
|
||||||
* (form-urlencoded) z kluczem API, polem creator, numerem i trescia; timeout z konfiguracji.
|
|
||||||
*
|
*
|
||||||
* UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z
|
* Zgodnie z manualem bramki: POST {@code application/json} z polami {@code api_key}, {@code to},
|
||||||
* dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac.
|
* {@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
|
@Component
|
||||||
public class SmsSender {
|
public class SmsSender {
|
||||||
|
|
||||||
private static final String PARAM_API_KEY = "apiKey";
|
private final ObjectMapper objectMapper;
|
||||||
private static final String PARAM_CREATOR = "creator";
|
|
||||||
private static final String PARAM_TO = "to";
|
public SmsSender(ObjectMapper objectMapper) {
|
||||||
private static final String PARAM_MESSAGE = "message";
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
public SendResult send(SmsGatewayConfig config, String phone, String message) {
|
public SendResult send(SmsGatewayConfig config, String phone, String message) {
|
||||||
if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank()
|
if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank()
|
||||||
@@ -33,10 +37,14 @@ public class SmsSender {
|
|||||||
try {
|
try {
|
||||||
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
|
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
|
||||||
? config.getTimeoutSeconds() : 10;
|
? config.getTimeoutSeconds() : 10;
|
||||||
String form = PARAM_API_KEY + "=" + enc(config.getApiKey())
|
|
||||||
+ "&" + PARAM_CREATOR + "=" + enc(config.getCreator())
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
+ "&" + PARAM_TO + "=" + enc(phone)
|
payload.put("api_key", config.getApiKey());
|
||||||
+ "&" + PARAM_MESSAGE + "=" + enc(message);
|
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()
|
HttpClient client = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(timeout))
|
.connectTimeout(Duration.ofSeconds(timeout))
|
||||||
@@ -44,24 +52,38 @@ public class SmsSender {
|
|||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(config.getEndpointUrl()))
|
.uri(URI.create(config.getEndpointUrl()))
|
||||||
.timeout(Duration.ofSeconds(timeout))
|
.timeout(Duration.ofSeconds(timeout))
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8))
|
.header("Accept", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
String body = response.body() == null ? "" : response.body().trim();
|
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()
|
String detail = gatewayMessage != null && !gatewayMessage.isBlank() ? gatewayMessage : shorten(body);
|
||||||
+ (body.isEmpty() ? "" : ": " + shorten(body)));
|
return SendResult.failure("Bramka SMS: " + (detail.isBlank() ? "status " + response.statusCode() : detail));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
return SendResult.failure(shorten(ex.getMessage()));
|
return SendResult.failure(shorten(ex.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String enc(String value) {
|
private JsonNode tryParse(String body) {
|
||||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
if (body == null || body.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(body);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String shorten(String value) {
|
private static String shorten(String value) {
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ public class AppUser {
|
|||||||
|
|
||||||
private LocalDate birthDate;
|
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.
|
// Konta zakladane przez rejestracje lokalna czekaja na zatwierdzenie przez administratora.
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@ColumnDefault("false")
|
@ColumnDefault("false")
|
||||||
@@ -73,6 +78,11 @@ public class AppUser {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private boolean blocked = 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)
|
@Column(nullable = false, updatable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -183,6 +193,14 @@ public class AppUser {
|
|||||||
this.birthDate = birthDate;
|
this.birthDate = birthDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getPromotionCredits() {
|
||||||
|
return promotionCredits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPromotionCredits(int promotionCredits) {
|
||||||
|
this.promotionCredits = promotionCredits;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isVerified() {
|
public boolean isVerified() {
|
||||||
return verified;
|
return verified;
|
||||||
}
|
}
|
||||||
@@ -191,6 +209,14 @@ public class AppUser {
|
|||||||
this.verified = verified;
|
this.verified = verified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isPhoneVerified() {
|
||||||
|
return phoneVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneVerified(boolean phoneVerified) {
|
||||||
|
this.phoneVerified = phoneVerified;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isBlocked() {
|
public boolean isBlocked() {
|
||||||
return blocked;
|
return blocked;
|
||||||
}
|
}
|
||||||
|
|||||||
+527
-107
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 zł 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -609,14 +609,13 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mkt-grid-2">
|
<div className="mkt-wrap">
|
||||||
<div className="admin-card mkt-card">
|
<div className="admin-card mkt-card">
|
||||||
<div className="admin-card-head"><h2>Nowa kampania</h2></div>
|
<div className="admin-card-head"><h2>Nowa kampania</h2></div>
|
||||||
<div className="mkt-form">
|
<div className="mkt-config-grid">
|
||||||
<label>Nazwa
|
<label className="mkt-col-span">Nazwa
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Newsletter lipiec" />
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Newsletter lipiec" />
|
||||||
</label>
|
</label>
|
||||||
<div className="mkt-form-row">
|
|
||||||
<label>Kanał
|
<label>Kanał
|
||||||
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
|
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
|
||||||
<option value="EMAIL">E-mail</option>
|
<option value="EMAIL">E-mail</option>
|
||||||
@@ -631,20 +630,18 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
{form.channel === 'EMAIL' && (
|
{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" />
|
<input value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} placeholder="Temat wiadomości" />
|
||||||
</label>
|
</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' ? (
|
{form.channel === 'EMAIL' ? (
|
||||||
<RichTextEditor value={form.body} onChange={(html) => setForm({ ...form, body: html })} ariaLabel="Treść kampanii e-mail" placeholder="Cześć {{name}}, ..." />
|
<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>
|
</label>
|
||||||
<div className="mkt-form-row">
|
|
||||||
<label>Dzienny limit
|
<label>Dzienny limit
|
||||||
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
|
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
|
||||||
</label>
|
</label>
|
||||||
@@ -653,6 +650,7 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="mkt-error">{error}</p>}
|
{error && <p className="mkt-error">{error}</p>}
|
||||||
|
<div className="mkt-config-save">
|
||||||
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
|
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
|
||||||
{saving ? 'Zapisywanie...' : 'Utwórz kampanię (robocza)'}
|
{saving ? 'Zapisywanie...' : 'Utwórz kampanię (robocza)'}
|
||||||
</button>
|
</button>
|
||||||
@@ -730,10 +728,18 @@ type SmsConfig = {
|
|||||||
apiKeySet: boolean;
|
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 (
|
return (
|
||||||
<div className="mkt-wrap">
|
<div className="mkt-wrap">
|
||||||
<SmtpCard Icon={Icon} />
|
<SmtpCard Icon={Icon} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSmsConfigView({ Icon }: { Icon: IconComponent }) {
|
||||||
|
return (
|
||||||
|
<div className="mkt-wrap">
|
||||||
<SmsCard Icon={Icon} />
|
<SmsCard Icon={Icon} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (zł)
|
||||||
|
<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 (zł)
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -21,16 +21,22 @@ export type AuthUser = {
|
|||||||
nip: string | null;
|
nip: string | null;
|
||||||
birthDate: string | null;
|
birthDate: string | null;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
phoneVerified: boolean;
|
||||||
blocked: boolean;
|
blocked: boolean;
|
||||||
|
promotionCredits: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthResponse = { token: string; user: AuthUser };
|
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 = {
|
export type RegisterDetails = {
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
nip?: string;
|
nip?: string;
|
||||||
|
address?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
@@ -38,9 +44,16 @@ type AuthContextValue = {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (email: string, password: string) => Promise<AuthUser>;
|
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>;
|
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>;
|
updateProfile: (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => Promise<AuthUser>;
|
||||||
|
refreshUser: () => Promise<AuthUser | null>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,13 +174,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
[applyAuth],
|
[applyAuth],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Rejestracja NIE loguje od razu - konto wymaga aktywacji linkiem z e-maila.
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
|
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
|
||||||
applyAuth(await apiFetch<AuthResponse>('/auth/register', {
|
apiFetch<RegisterPending>('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password, fullName, ...details }),
|
body: JSON.stringify({ email, password, fullName, ...details }),
|
||||||
})),
|
}),
|
||||||
[applyAuth],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const socialLogin = useCallback(
|
const socialLogin = useCallback(
|
||||||
@@ -179,6 +193,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
[applyAuth],
|
[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(
|
const updateProfile = useCallback(
|
||||||
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
|
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
|
||||||
const updated = await apiFetch<AuthUser>('/auth/me', {
|
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(() => {
|
const logout = useCallback(() => {
|
||||||
window.localStorage.removeItem(TOKEN_KEY);
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
@@ -198,8 +250,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
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>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ export const ROUTES = {
|
|||||||
accountProfileEdit: '/konto/profil/edycja',
|
accountProfileEdit: '/konto/profil/edycja',
|
||||||
accountPublicProfile: '/konto/profil',
|
accountPublicProfile: '/konto/profil',
|
||||||
accountHelpContact: '/konto/pomoc',
|
accountHelpContact: '/konto/pomoc',
|
||||||
|
terms: '/regulamin',
|
||||||
|
privacy: '/polityka-prywatnosci',
|
||||||
|
activation: '/aktywacja/:token',
|
||||||
|
passwordReset: '/reset-hasla/:token',
|
||||||
notifications: '/powiadomienia',
|
notifications: '/powiadomienia',
|
||||||
favorites: '/ulubione',
|
favorites: '/ulubione',
|
||||||
messages: '/wiadomosci',
|
messages: '/wiadomosci',
|
||||||
@@ -89,7 +93,8 @@ export const ADMIN_TAB_PATHS = {
|
|||||||
forbiddenWords: 'zakazane-slowa',
|
forbiddenWords: 'zakazane-slowa',
|
||||||
leads: 'leady',
|
leads: 'leady',
|
||||||
campaigns: 'kampanie',
|
campaigns: 'kampanie',
|
||||||
mailConfig: 'konfiguracja-wysylki',
|
smtpConfig: 'konfiguracja-smtp',
|
||||||
|
smsConfig: 'konfiguracja-sms',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AdminTabKey = keyof typeof ADMIN_TAB_PATHS;
|
export type AdminTabKey = keyof typeof ADMIN_TAB_PATHS;
|
||||||
|
|||||||
+787
-36
@@ -3312,12 +3312,11 @@ svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.satellite-tiles {
|
.satellite-tiles {
|
||||||
height: 640px;
|
height: 100%;
|
||||||
left: 50%;
|
left: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 0;
|
||||||
transform: translate(-50%, -50%);
|
width: 100%;
|
||||||
width: 640px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.satellite-tiles img {
|
.satellite-tiles img {
|
||||||
@@ -12610,45 +12609,48 @@ svg {
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.social-login-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.social-login {
|
.social-login {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #dfe5ed;
|
border: 1px solid #dce4ec;
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
color: #142238;
|
color: #24344b;
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 900;
|
font-weight: 800;
|
||||||
gap: 10px;
|
gap: 9px;
|
||||||
height: 42px;
|
height: 46px;
|
||||||
justify-content: center;
|
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,
|
.social-login:hover:not(:disabled) {
|
||||||
.facebook-mark {
|
background: #f7f9fc;
|
||||||
align-items: center;
|
border-color: #c4d2e2;
|
||||||
display: grid;
|
box-shadow: 0 2px 10px rgba(20, 34, 58, 0.07);
|
||||||
flex: 0 0 18px;
|
}
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 950;
|
.social-login:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-login-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
place-items: center;
|
|
||||||
width: 18px;
|
width: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-mark {
|
@media (max-width: 400px) {
|
||||||
color: #1a73e8;
|
.social-login-row { flex-direction: column; }
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-security-note {
|
.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 strong { color: #129357; font-size: 28px; font-weight: 900; }
|
||||||
.ld-headline-price span { color: #6b7a8d; font-size: 14px; font-weight: 700; }
|
.ld-headline-price span { color: #6b7a8d; font-size: 14px; font-weight: 700; }
|
||||||
|
|
||||||
/* Zdjecie glowne bylo za duze po poszerzeniu strony - ograniczamy wysokosc.
|
/* Ramka galerii dopasowuje sie do proporcji aktualnego zdjecia (aspect-ratio nadawane inline z JS),
|
||||||
object-fit: contain, aby cale zdjecie bylo widoczne (bez przycinania); tlo wypelnia letterbox. */
|
dzieki czemu kadr przylega do zdjecia - brak pustych paskow po bokach. Wysokosc ograniczona. */
|
||||||
.listing-detail-gallery-main-image { background: #eef2f6; height: clamp(300px, 40vw, 470px); }
|
.listing-detail-gallery-main-image {
|
||||||
|
aspect-ratio: 1.6;
|
||||||
|
background: #0f1b2d;
|
||||||
|
max-height: 620px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.listing-detail-gallery-main-image img {
|
.listing-detail-gallery-main-image img {
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27765,3 +27772,747 @@ a.listing-detail-back {
|
|||||||
/* Karta "ostatnio przegladane" jest linkiem do oferty. */
|
/* Karta "ostatnio przegladane" jest linkiem do oferty. */
|
||||||
.listing-card-link { color: inherit; text-decoration: none; }
|
.listing-card-link { color: inherit; text-decoration: none; }
|
||||||
.listing-card-link:hover { box-shadow: 0 10px 26px rgba(20, 34, 58, 0.12); }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user