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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
@@ -28,7 +33,7 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
public ResponseEntity<RegisterResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
|
||||
}
|
||||
|
||||
@@ -42,6 +47,48 @@ public class AuthController {
|
||||
return authService.socialLogin(request);
|
||||
}
|
||||
|
||||
// --- Aktywacja konta ---
|
||||
|
||||
@PostMapping("/activate")
|
||||
public ResponseEntity<Void> activate(@Valid @RequestBody TokenRequest request) {
|
||||
authService.activate(request.token());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/resend-activation")
|
||||
public ResponseEntity<Void> resendActivation(@Valid @RequestBody EmailRequest request) {
|
||||
authService.resendActivation(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Reset hasla ---
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
public ResponseEntity<Void> forgotPassword(@Valid @RequestBody EmailRequest request) {
|
||||
authService.forgotPassword(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/reset-password")
|
||||
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
|
||||
authService.resetPassword(request.token(), request.password());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Weryfikacja telefonu (OTP SMS) ---
|
||||
|
||||
@PostMapping("/verify-phone")
|
||||
public ResponseEntity<Void> verifyPhone(@Valid @RequestBody VerifyPhoneRequest request) {
|
||||
authService.verifyPhone(request.email(), request.code());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/resend-phone-otp")
|
||||
public ResponseEntity<Void> resendPhoneOtp(@Valid @RequestBody EmailRequest request) {
|
||||
authService.resendPhoneOtp(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public UserResponse me(Authentication authentication) {
|
||||
return authService.currentUser(authentication.getName());
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.lead.LeadSyncService;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
@@ -27,12 +33,19 @@ import pl.polskalokalnie.user.UserRepository;
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
|
||||
private static final int ACTIVATION_TTL_MINUTES = 48 * 60;
|
||||
private static final int RESET_TTL_MINUTES = 60;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BlockedEmailRepository blockedEmailRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final TextModerationService textModerationService;
|
||||
private final LeadSyncService leadSyncService;
|
||||
private final AuthTokenRepository authTokenRepository;
|
||||
private final AccountEmailService accountEmailService;
|
||||
private final PhoneVerificationService phoneVerificationService;
|
||||
|
||||
public AuthService(
|
||||
UserRepository userRepository,
|
||||
@@ -40,7 +53,10 @@ public class AuthService {
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
TextModerationService textModerationService,
|
||||
LeadSyncService leadSyncService
|
||||
LeadSyncService leadSyncService,
|
||||
AuthTokenRepository authTokenRepository,
|
||||
AccountEmailService accountEmailService,
|
||||
PhoneVerificationService phoneVerificationService
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
@@ -48,9 +64,13 @@ public class AuthService {
|
||||
this.jwtService = jwtService;
|
||||
this.textModerationService = textModerationService;
|
||||
this.leadSyncService = leadSyncService;
|
||||
this.authTokenRepository = authTokenRepository;
|
||||
this.accountEmailService = accountEmailService;
|
||||
this.phoneVerificationService = phoneVerificationService;
|
||||
}
|
||||
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
@Transactional
|
||||
public RegisterResponse register(RegisterRequest request) {
|
||||
String email = normalizeEmail(request.email());
|
||||
if (blockedEmailRepository.existsByEmailIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji");
|
||||
@@ -59,7 +79,7 @@ public class AuthService {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Konto z tym adresem e-mail już istnieje");
|
||||
}
|
||||
|
||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip());
|
||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip(), request.address());
|
||||
|
||||
AppUser user = new AppUser();
|
||||
user.setEmail(email);
|
||||
@@ -68,14 +88,28 @@ public class AuthService {
|
||||
user.setRole(Role.USER);
|
||||
user.setProvider(AuthProvider.LOCAL);
|
||||
user.setAccountType(request.accountType() != null ? request.accountType() : AccountType.PERSONAL);
|
||||
user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null);
|
||||
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
|
||||
user.setPhone(phone);
|
||||
user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null);
|
||||
// Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora.
|
||||
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
||||
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
|
||||
user.setVerified(false);
|
||||
user.setPhoneVerified(false);
|
||||
|
||||
AppUser saved = userRepository.save(user);
|
||||
leadSyncService.syncUser(saved);
|
||||
return buildAuthResponse(saved);
|
||||
|
||||
// Wysylka linku aktywacyjnego na e-mail.
|
||||
String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
|
||||
accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token);
|
||||
|
||||
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru.
|
||||
boolean phoneVerificationRequired = phone != null;
|
||||
if (phoneVerificationRequired) {
|
||||
phoneVerificationService.sendOtp(saved.getEmail(), phone);
|
||||
}
|
||||
|
||||
return new RegisterResponse(saved.getEmail(), phoneVerificationRequired);
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
@@ -89,10 +123,112 @@ public class AuthService {
|
||||
if (user.isBlocked()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
|
||||
}
|
||||
// Logowanie mozliwe dopiero po aktywacji konta linkiem z e-maila.
|
||||
if (!user.isVerified()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
|
||||
}
|
||||
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
// --- Aktywacja konta ---
|
||||
|
||||
@Transactional
|
||||
public void activate(String token) {
|
||||
AuthToken authToken = requireToken(token, TokenPurpose.ACTIVATION);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setVerified(true);
|
||||
userRepository.save(user);
|
||||
authToken.setUsedAt(Instant.now());
|
||||
authTokenRepository.save(authToken);
|
||||
}
|
||||
|
||||
// Ponowne wyslanie linku aktywacyjnego. Nie ujawniamy, czy konto istnieje.
|
||||
@Transactional
|
||||
public void resendActivation(String email) {
|
||||
userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> {
|
||||
if (!user.isVerified()) {
|
||||
String token = issueToken(user.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
|
||||
accountEmailService.sendActivation(user.getEmail(), user.getFullName(), token);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Reset hasla ---
|
||||
|
||||
// Wyslanie linku do resetu hasla. Zawsze zwraca sukces (nie ujawniamy istnienia konta).
|
||||
@Transactional
|
||||
public void forgotPassword(String email) {
|
||||
userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> {
|
||||
String token = issueToken(user.getEmail(), TokenPurpose.PASSWORD_RESET, RESET_TTL_MINUTES);
|
||||
accountEmailService.sendPasswordReset(user.getEmail(), user.getFullName(), token);
|
||||
});
|
||||
}
|
||||
|
||||
// Ustawienie nowego hasla. Dotychczasowe haslo dziala az do tego momentu.
|
||||
@Transactional
|
||||
public void resetPassword(String token, String newPassword) {
|
||||
AuthToken authToken = requireToken(token, TokenPurpose.PASSWORD_RESET);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
|
||||
user.setVerified(true);
|
||||
userRepository.save(user);
|
||||
authToken.setUsedAt(Instant.now());
|
||||
authTokenRepository.save(authToken);
|
||||
}
|
||||
|
||||
// --- Weryfikacja telefonu (OTP) ---
|
||||
|
||||
public void verifyPhone(String email, String code) {
|
||||
phoneVerificationService.verifyOtp(normalizeEmail(email), code);
|
||||
}
|
||||
|
||||
public void resendPhoneOtp(String email) {
|
||||
String normalized = normalizeEmail(email);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||
}
|
||||
phoneVerificationService.sendOtp(normalized, user.getPhone());
|
||||
}
|
||||
|
||||
// --- Pomocnicze tokeny ---
|
||||
|
||||
private String issueToken(String email, TokenPurpose purpose, int ttlMinutes) {
|
||||
// Uniewazniamy poprzednie, nieuzyte tokeny tego samego przeznaczenia.
|
||||
authTokenRepository.findByUserEmailAndPurpose(email, purpose).forEach(old -> {
|
||||
if (old.getUsedAt() == null) {
|
||||
old.setUsedAt(Instant.now());
|
||||
authTokenRepository.save(old);
|
||||
}
|
||||
});
|
||||
byte[] bytes = new byte[32];
|
||||
TOKEN_RANDOM.nextBytes(bytes);
|
||||
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
|
||||
AuthToken authToken = new AuthToken();
|
||||
authToken.setToken(token);
|
||||
authToken.setUserEmail(email);
|
||||
authToken.setPurpose(purpose);
|
||||
authToken.setExpiresAt(Instant.now().plus(ttlMinutes, ChronoUnit.MINUTES));
|
||||
authTokenRepository.save(authToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
private AuthToken requireToken(String token, TokenPurpose purpose) {
|
||||
AuthToken authToken = authTokenRepository.findByToken(token == null ? "" : token.trim())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link"));
|
||||
if (authToken.getPurpose() != purpose || !authToken.isUsable()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link wygasł lub został już wykorzystany");
|
||||
}
|
||||
return authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca.
|
||||
* Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Jednorazowy token (aktywacja konta / reset hasla) wysylany w linku mailowym. */
|
||||
@Entity
|
||||
@Table(name = "auth_tokens")
|
||||
public class AuthToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
private String token;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private TokenPurpose purpose;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
private Instant usedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public boolean isUsable() {
|
||||
return usedAt == null && expiresAt != null && expiresAt.isAfter(Instant.now());
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public TokenPurpose getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public void setPurpose(TokenPurpose purpose) {
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public Instant getUsedAt() {
|
||||
return usedAt;
|
||||
}
|
||||
|
||||
public void setUsedAt(Instant usedAt) {
|
||||
this.usedAt = usedAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||
Optional<AuthToken> findByToken(String token);
|
||||
List<AuthToken> findByUserEmailAndPurpose(String userEmail, TokenPurpose purpose);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Jednorazowy kod SMS (OTP) do potwierdzenia numeru telefonu. Jeden aktywny kod na uzytkownika. */
|
||||
@Entity
|
||||
@Table(name = "phone_otps")
|
||||
public class PhoneOtp {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Column(nullable = false, length = 10)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String phone;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int attempts = 0;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public void setAttempts(int attempts) {
|
||||
this.attempts = attempts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PhoneOtpRepository extends JpaRepository<PhoneOtp, Long> {
|
||||
Optional<PhoneOtp> findByUserEmail(String userEmail);
|
||||
void deleteByUserEmail(String userEmail);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||
import pl.polskalokalnie.send.SmsSender;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
/**
|
||||
* Potwierdzanie numeru telefonu kodem SMS (OTP). Kod jednorazowy, wazny 10 minut, maks. 5 prob.
|
||||
* Gdy bramka SMS nie jest skonfigurowana - kod jest logowany (tryb deweloperski).
|
||||
*/
|
||||
@Service
|
||||
public class PhoneVerificationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PhoneVerificationService.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private static final int TTL_MINUTES = 10;
|
||||
|
||||
private final PhoneOtpRepository otpRepository;
|
||||
private final SmsSender smsSender;
|
||||
private final MailConfigService mailConfigService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public PhoneVerificationService(PhoneOtpRepository otpRepository,
|
||||
SmsSender smsSender,
|
||||
MailConfigService mailConfigService,
|
||||
UserRepository userRepository) {
|
||||
this.otpRepository = otpRepository;
|
||||
this.smsSender = smsSender;
|
||||
this.mailConfigService = mailConfigService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void sendOtp(String email, String phone) {
|
||||
if (phone == null || phone.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak numeru telefonu");
|
||||
}
|
||||
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||
|
||||
PhoneOtp otp = otpRepository.findByUserEmail(email).orElseGet(PhoneOtp::new);
|
||||
otp.setUserEmail(email);
|
||||
otp.setPhone(phone.trim());
|
||||
otp.setCode(code);
|
||||
otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES));
|
||||
otp.setAttempts(0);
|
||||
otpRepository.save(otp);
|
||||
|
||||
SmsGatewayConfig sms = mailConfigService.getSms();
|
||||
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank();
|
||||
String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut.";
|
||||
if (!configured) {
|
||||
log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code);
|
||||
return;
|
||||
}
|
||||
var result = smsSender.send(sms, phone.trim(), message);
|
||||
if (!result.ok()) {
|
||||
log.warn("[SMS-OTP] Nie udalo sie wyslac kodu do {} ({}). Kod: {}", email, phone, code);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void verifyOtp(String email, String code) {
|
||||
PhoneOtp otp = otpRepository.findByUserEmail(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Brak kodu do weryfikacji - wyślij kod ponownie"));
|
||||
if (otp.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ResponseStatusException(HttpStatus.GONE, "Kod wygasł - wyślij nowy kod");
|
||||
}
|
||||
if (otp.getAttempts() >= MAX_ATTEMPTS) {
|
||||
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Przekroczono liczbę prób - wyślij nowy kod");
|
||||
}
|
||||
if (code == null || !code.trim().equals(otp.getCode())) {
|
||||
otp.setAttempts(otp.getAttempts() + 1);
|
||||
otpRepository.save(otp);
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy kod");
|
||||
}
|
||||
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setPhoneVerified(true);
|
||||
userRepository.save(user);
|
||||
otpRepository.delete(otp);
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,19 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/error").permitAll()
|
||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll()
|
||||
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
|
||||
"/api/auth/forgot-password", "/api/auth/reset-password",
|
||||
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
|
||||
.requestMatchers("/api/i18n/translate").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||
.requestMatchers("/api/auth/me").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/banners/*/click").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/payments/autopay/itn").permitAll()
|
||||
.requestMatchers("/api/promotion/**").authenticated()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/listings").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
/** Przeznaczenie jednorazowego tokenu wysylanego mailem. */
|
||||
public enum TokenPurpose {
|
||||
ACTIVATION,
|
||||
PASSWORD_RESET
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** Zbior prostych rekordow zadan dla operacji na koncie (aktywacja, reset hasla, OTP). */
|
||||
public final class AccountRequests {
|
||||
|
||||
private AccountRequests() {
|
||||
}
|
||||
|
||||
public record TokenRequest(@NotBlank String token) {
|
||||
}
|
||||
|
||||
public record EmailRequest(@NotBlank @Email String email) {
|
||||
}
|
||||
|
||||
public record ResetPasswordRequest(@NotBlank String token, @NotBlank @Size(min = 8, max = 100) String password) {
|
||||
}
|
||||
|
||||
public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) {
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public record RegisterRequest(
|
||||
@NotBlank String fullName,
|
||||
AccountType accountType,
|
||||
String phone,
|
||||
String nip
|
||||
String nip,
|
||||
String address
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
/**
|
||||
* Odpowiedz po rejestracji. Konto wymaga aktywacji linkiem e-mail (brak od razu tokenu logowania).
|
||||
* phoneVerificationRequired = true, gdy podano numer telefonu i wyslano kod SMS do potwierdzenia.
|
||||
*/
|
||||
public record RegisterResponse(
|
||||
String email,
|
||||
boolean phoneVerificationRequired
|
||||
) {
|
||||
}
|
||||
@@ -23,7 +23,9 @@ public record UserResponse(
|
||||
String nip,
|
||||
LocalDate birthDate,
|
||||
boolean verified,
|
||||
boolean phoneVerified,
|
||||
boolean blocked,
|
||||
int promotionCredits,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static UserResponse from(AppUser user) {
|
||||
@@ -41,7 +43,9 @@ public record UserResponse(
|
||||
user.getNip(),
|
||||
user.getBirthDate(),
|
||||
user.isVerified(),
|
||||
user.isPhoneVerified(),
|
||||
user.isBlocked(),
|
||||
user.getPromotionCredits(),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Zarzadzanie banerami/promocjami w panelu admina (/admin/promocje). Wymaga roli ADMIN. */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/banners")
|
||||
public class AdminBannerController {
|
||||
|
||||
private final BannerService bannerService;
|
||||
|
||||
public AdminBannerController(BannerService bannerService) {
|
||||
this.bannerService = bannerService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<BannerResponse> list() {
|
||||
return bannerService.listAll();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public BannerResponse create(@Valid @RequestBody BannerRequest request) {
|
||||
return bannerService.create(request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public BannerResponse update(@PathVariable Long id, @Valid @RequestBody BannerRequest request) {
|
||||
return bannerService.update(id, request);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/toggle")
|
||||
public BannerResponse toggle(@PathVariable Long id) {
|
||||
return bannerService.toggle(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
bannerService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Baner reklamowy/promocyjny przypisany do slotu (miejsca) w serwisie.
|
||||
* Grafika przechowywana jako data URL (base64) w kolumnie TEXT - jak zdjecia ogloszen.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "banners")
|
||||
public class Banner {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 30)
|
||||
private BannerSlot slot;
|
||||
|
||||
@Column(nullable = false, length = 160)
|
||||
private String title;
|
||||
|
||||
@Column(length = 400)
|
||||
private String subtitle;
|
||||
|
||||
// Grafika jako data URL (base64). TEXT, bo przekracza limity VARCHAR.
|
||||
@Column(columnDefinition = "text")
|
||||
private String imageUrl;
|
||||
|
||||
@Column(length = 500)
|
||||
private String linkUrl;
|
||||
|
||||
@Column(length = 60)
|
||||
private String ctaLabel;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
// Kolejnosc w obrebie slotu (mniejsza = wyzej/pierwsza).
|
||||
@Column(nullable = false)
|
||||
private int position = 0;
|
||||
|
||||
// Opcjonalne okno emisji. null = bez ograniczenia.
|
||||
private Instant startsAt;
|
||||
private Instant endsAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private long impressions = 0;
|
||||
|
||||
@Column(nullable = false)
|
||||
private long clicks = 0;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public BannerSlot getSlot() {
|
||||
return slot;
|
||||
}
|
||||
|
||||
public void setSlot(BannerSlot slot) {
|
||||
this.slot = slot;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getSubtitle() {
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
public void setSubtitle(String subtitle) {
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public String getLinkUrl() {
|
||||
return linkUrl;
|
||||
}
|
||||
|
||||
public void setLinkUrl(String linkUrl) {
|
||||
this.linkUrl = linkUrl;
|
||||
}
|
||||
|
||||
public String getCtaLabel() {
|
||||
return ctaLabel;
|
||||
}
|
||||
|
||||
public void setCtaLabel(String ctaLabel) {
|
||||
this.ctaLabel = ctaLabel;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPosition(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Instant getStartsAt() {
|
||||
return startsAt;
|
||||
}
|
||||
|
||||
public void setStartsAt(Instant startsAt) {
|
||||
this.startsAt = startsAt;
|
||||
}
|
||||
|
||||
public Instant getEndsAt() {
|
||||
return endsAt;
|
||||
}
|
||||
|
||||
public void setEndsAt(Instant endsAt) {
|
||||
this.endsAt = endsAt;
|
||||
}
|
||||
|
||||
public long getImpressions() {
|
||||
return impressions;
|
||||
}
|
||||
|
||||
public void setImpressions(long impressions) {
|
||||
this.impressions = impressions;
|
||||
}
|
||||
|
||||
public long getClicks() {
|
||||
return clicks;
|
||||
}
|
||||
|
||||
public void setClicks(long clicks) {
|
||||
this.clicks = clicks;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Publiczny odczyt aktywnych banerow dla slotu oraz zliczanie klikniec. */
|
||||
@RestController
|
||||
@RequestMapping("/api/banners")
|
||||
public class BannerController {
|
||||
|
||||
private final BannerService bannerService;
|
||||
|
||||
public BannerController(BannerService bannerService) {
|
||||
this.bannerService = bannerService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PublicBannerResponse> bySlot(@RequestParam BannerSlot slot) {
|
||||
return bannerService.publicBySlot(slot);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/click")
|
||||
public ResponseEntity<Void> registerClick(@PathVariable Long id) {
|
||||
bannerService.registerClick(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface BannerRepository extends JpaRepository<Banner, Long> {
|
||||
|
||||
List<Banner> findByOrderBySlotAscPositionAscIdAsc();
|
||||
|
||||
List<Banner> findBySlotAndActiveTrueOrderByPositionAscIdAsc(BannerSlot slot);
|
||||
|
||||
@Modifying
|
||||
@Query("update Banner b set b.impressions = b.impressions + 1 where b.id in :ids")
|
||||
void incrementImpressions(@Param("ids") List<Long> ids);
|
||||
|
||||
@Modifying
|
||||
@Query("update Banner b set b.clicks = b.clicks + 1 where b.id = :id")
|
||||
void incrementClicks(@Param("id") Long id);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Dane wejsciowe tworzenia/edycji banera (panel admina). */
|
||||
public record BannerRequest(
|
||||
@NotNull BannerSlot slot,
|
||||
@NotBlank String title,
|
||||
String subtitle,
|
||||
String imageUrl,
|
||||
String linkUrl,
|
||||
String ctaLabel,
|
||||
Boolean active,
|
||||
Integer position,
|
||||
Instant startsAt,
|
||||
Instant endsAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Pelna reprezentacja banera dla panelu admina (ze statystykami). */
|
||||
public record BannerResponse(
|
||||
Long id,
|
||||
BannerSlot slot,
|
||||
String title,
|
||||
String subtitle,
|
||||
String imageUrl,
|
||||
String linkUrl,
|
||||
String ctaLabel,
|
||||
boolean active,
|
||||
int position,
|
||||
Instant startsAt,
|
||||
Instant endsAt,
|
||||
long impressions,
|
||||
long clicks,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static BannerResponse from(Banner b) {
|
||||
return new BannerResponse(
|
||||
b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(), b.getImageUrl(),
|
||||
b.getLinkUrl(), b.getCtaLabel(), b.isActive(), b.getPosition(),
|
||||
b.getStartsAt(), b.getEndsAt(), b.getImpressions(), b.getClicks(), b.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class BannerService {
|
||||
|
||||
private final BannerRepository bannerRepository;
|
||||
|
||||
public BannerService(BannerRepository bannerRepository) {
|
||||
this.bannerRepository = bannerRepository;
|
||||
}
|
||||
|
||||
// --- Panel admina ---
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<BannerResponse> listAll() {
|
||||
return bannerRepository.findByOrderBySlotAscPositionAscIdAsc().stream()
|
||||
.map(BannerResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerResponse create(BannerRequest request) {
|
||||
Banner banner = new Banner();
|
||||
apply(banner, request);
|
||||
return BannerResponse.from(bannerRepository.save(banner));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerResponse update(Long id, BannerRequest request) {
|
||||
Banner banner = find(id);
|
||||
apply(banner, request);
|
||||
return BannerResponse.from(bannerRepository.save(banner));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BannerResponse toggle(Long id) {
|
||||
Banner banner = find(id);
|
||||
banner.setActive(!banner.isActive());
|
||||
return BannerResponse.from(bannerRepository.save(banner));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
bannerRepository.delete(find(id));
|
||||
}
|
||||
|
||||
// --- Publiczne ---
|
||||
|
||||
// Aktywne banery danego slotu, w oknie emisji; przy okazji zliczamy wyswietlenia.
|
||||
@Transactional
|
||||
public List<PublicBannerResponse> publicBySlot(BannerSlot slot) {
|
||||
Instant now = Instant.now();
|
||||
List<Banner> visible = bannerRepository.findBySlotAndActiveTrueOrderByPositionAscIdAsc(slot).stream()
|
||||
.filter(b -> b.getStartsAt() == null || !b.getStartsAt().isAfter(now))
|
||||
.filter(b -> b.getEndsAt() == null || !b.getEndsAt().isBefore(now))
|
||||
.toList();
|
||||
if (!visible.isEmpty()) {
|
||||
bannerRepository.incrementImpressions(visible.stream().map(Banner::getId).toList());
|
||||
}
|
||||
return visible.stream().map(PublicBannerResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void registerClick(Long id) {
|
||||
// Cichy no-op dla nieistniejacego id - klikniecia nie moga wywrocic UX.
|
||||
if (bannerRepository.existsById(id)) {
|
||||
bannerRepository.incrementClicks(id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wspolne ---
|
||||
|
||||
private Banner find(Long id) {
|
||||
return bannerRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Baner nie istnieje"));
|
||||
}
|
||||
|
||||
private void apply(Banner banner, BannerRequest request) {
|
||||
banner.setSlot(request.slot());
|
||||
banner.setTitle(request.title().trim());
|
||||
banner.setSubtitle(trimToNull(request.subtitle()));
|
||||
banner.setImageUrl(trimToNull(request.imageUrl()));
|
||||
banner.setLinkUrl(trimToNull(request.linkUrl()));
|
||||
banner.setCtaLabel(trimToNull(request.ctaLabel()));
|
||||
banner.setActive(request.active() == null || request.active());
|
||||
banner.setPosition(request.position() == null ? 0 : request.position());
|
||||
banner.setStartsAt(request.startsAt());
|
||||
banner.setEndsAt(request.endsAt());
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
/**
|
||||
* Miejsca (sloty) reklamowe w serwisie. Front renderuje baner przypisany do danego slotu.
|
||||
* HERO - pasek/karta w sekcji hero na stronie glownej.
|
||||
* HOME_TOP - szeroki baner pod wyszukiwarka na stronie glownej.
|
||||
* HOME_MIDDLE - baner miedzy sekcjami ofert (polecane / ostatnio ogladane).
|
||||
* SIDEBAR - baner w bocznej kolumnie (np. karta oferty).
|
||||
*/
|
||||
public enum BannerSlot {
|
||||
HERO,
|
||||
HOME_TOP,
|
||||
HOME_MIDDLE,
|
||||
SIDEBAR
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package pl.polskalokalnie.banner;
|
||||
|
||||
/** Publiczna, minimalna reprezentacja banera (bez statystyk i harmonogramu). */
|
||||
public record PublicBannerResponse(
|
||||
Long id,
|
||||
BannerSlot slot,
|
||||
String title,
|
||||
String subtitle,
|
||||
String imageUrl,
|
||||
String linkUrl,
|
||||
String ctaLabel
|
||||
) {
|
||||
public static PublicBannerResponse from(Banner b) {
|
||||
return new PublicBannerResponse(
|
||||
b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(),
|
||||
b.getImageUrl(), b.getLinkUrl(), b.getCtaLabel());
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,18 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import pl.polskalokalnie.banner.Banner;
|
||||
import pl.polskalokalnie.banner.BannerRepository;
|
||||
import pl.polskalokalnie.banner.BannerSlot;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.OfferType;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.listing.PropertyType;
|
||||
import pl.polskalokalnie.promotion.PromotionPackage;
|
||||
import pl.polskalokalnie.promotion.PromotionPackageRepository;
|
||||
import pl.polskalokalnie.promotion.PromotionPlan;
|
||||
import pl.polskalokalnie.promotion.PromotionPlanRepository;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
@@ -20,6 +27,9 @@ public class DataSeeder implements CommandLineRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final BannerRepository bannerRepository;
|
||||
private final PromotionPlanRepository promotionPlanRepository;
|
||||
private final PromotionPackageRepository promotionPackageRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final String adminEmail;
|
||||
private final String adminPassword;
|
||||
@@ -28,6 +38,9 @@ public class DataSeeder implements CommandLineRunner {
|
||||
public DataSeeder(
|
||||
UserRepository userRepository,
|
||||
ListingRepository listingRepository,
|
||||
BannerRepository bannerRepository,
|
||||
PromotionPlanRepository promotionPlanRepository,
|
||||
PromotionPackageRepository promotionPackageRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
||||
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
||||
@@ -35,6 +48,9 @@ public class DataSeeder implements CommandLineRunner {
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.bannerRepository = bannerRepository;
|
||||
this.promotionPlanRepository = promotionPlanRepository;
|
||||
this.promotionPackageRepository = promotionPackageRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.adminEmail = adminEmail;
|
||||
this.adminPassword = adminPassword;
|
||||
@@ -45,6 +61,74 @@ public class DataSeeder implements CommandLineRunner {
|
||||
public void run(String... args) {
|
||||
seedAdmin();
|
||||
seedDemoListings();
|
||||
seedBanners();
|
||||
seedPromotion();
|
||||
}
|
||||
|
||||
private void seedPromotion() {
|
||||
if (promotionPlanRepository.count() == 0) {
|
||||
promotionPlanRepository.save(promotionPlan("Wyróżnienie 4 dni", 4, "19.99", 0));
|
||||
promotionPlanRepository.save(promotionPlan("Wyróżnienie 14 dni", 14, "49.99", 1));
|
||||
promotionPlanRepository.save(promotionPlan("Wyróżnienie 30 dni", 30, "89.99", 2));
|
||||
}
|
||||
if (promotionPackageRepository.count() == 0) {
|
||||
promotionPackageRepository.save(promotionPackage("Pakiet 5 promowań", 5, "199.99", 0));
|
||||
promotionPackageRepository.save(promotionPackage("Pakiet 10 promowań", 10, "349.99", 1));
|
||||
}
|
||||
}
|
||||
|
||||
private PromotionPlan promotionPlan(String name, int days, String price, int position) {
|
||||
PromotionPlan plan = new PromotionPlan();
|
||||
plan.setName(name);
|
||||
plan.setDurationDays(days);
|
||||
plan.setPrice(new BigDecimal(price));
|
||||
plan.setActive(true);
|
||||
plan.setPosition(position);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private PromotionPackage promotionPackage(String name, int quantity, String price, int position) {
|
||||
PromotionPackage pack = new PromotionPackage();
|
||||
pack.setName(name);
|
||||
pack.setQuantity(quantity);
|
||||
pack.setPrice(new BigDecimal(price));
|
||||
pack.setActive(true);
|
||||
pack.setPosition(position);
|
||||
return pack;
|
||||
}
|
||||
|
||||
private void seedBanners() {
|
||||
if (bannerRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
bannerRepository.save(banner(BannerSlot.HERO,
|
||||
"Wyróżnij swoje ogłoszenie",
|
||||
"Dotrzyj do tysięcy kupujących i najemców - bez prowizji.",
|
||||
"Dodaj ogłoszenie", "/dodaj-ogloszenie"));
|
||||
bannerRepository.save(banner(BannerSlot.HOME_TOP,
|
||||
"Sprawdź wartość swojej nieruchomości",
|
||||
"Poznaj realistyczną cenę mieszkania w kilka minut.",
|
||||
"Wyceń mieszkanie", "/wycena"));
|
||||
bannerRepository.save(banner(BannerSlot.HOME_MIDDLE,
|
||||
"Planujesz kredyt hipoteczny?",
|
||||
"Policz zdolność kredytową i sprawdź, na co Cię stać.",
|
||||
"Sprawdź zdolność", "/kalkulator-zdolnosci"));
|
||||
bannerRepository.save(banner(BannerSlot.SIDEBAR,
|
||||
"Zareklamuj się w Polska Lokalnie",
|
||||
"Zarezerwuj miejsce reklamowe dla swojej firmy.",
|
||||
"Napisz do nas", "/konto/pomoc"));
|
||||
}
|
||||
|
||||
private Banner banner(BannerSlot slot, String title, String subtitle, String ctaLabel, String linkUrl) {
|
||||
Banner banner = new Banner();
|
||||
banner.setSlot(slot);
|
||||
banner.setTitle(title);
|
||||
banner.setSubtitle(subtitle);
|
||||
banner.setCtaLabel(ctaLabel);
|
||||
banner.setLinkUrl(linkUrl);
|
||||
banner.setActive(true);
|
||||
banner.setPosition(0);
|
||||
return banner;
|
||||
}
|
||||
|
||||
private void seedAdmin() {
|
||||
@@ -93,6 +177,8 @@ public class DataSeeder implements CommandLineRunner {
|
||||
listing.setPrice(price);
|
||||
listing.setArea(area);
|
||||
listing.setRooms(rooms);
|
||||
listing.setContactName("Biuro Polska Lokalnie");
|
||||
listing.setContactPhone("+48 600 100 200");
|
||||
listing.setOwnerEmail("demo.user@polskalokalnie.pl");
|
||||
listing.setStatus(status);
|
||||
return listing;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Panel admina: konfiguracja integracji z GUS (BIR1.1). Wymaga roli ADMIN (/api/admin/**). */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/gus-config")
|
||||
public class AdminGusConfigController {
|
||||
|
||||
private final GusService gusService;
|
||||
|
||||
public AdminGusConfigController(GusService gusService) {
|
||||
this.gusService = gusService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public GusConfigResponse get() {
|
||||
return GusConfigResponse.from(gusService.getConfig());
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public GusConfigResponse update(@RequestBody GusConfigRequest request) {
|
||||
return GusConfigResponse.from(gusService.updateConfig(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
/** Dane firmy pobrane z rejestru REGON - tyle, ile uzupelnia formularz rejestracji. */
|
||||
public record GusCompanyResponse(
|
||||
String name,
|
||||
String nip,
|
||||
String regon,
|
||||
String address
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* Konfiguracja integracji z wyszukiwarka REGON (GUS BIR1.1). Pojedynczy rekord (id=1).
|
||||
* Gdy sandbox=true uzywamy srodowiska testowego i wbudowanego klucza testowego GUS.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "gus_config")
|
||||
public class GusConfig {
|
||||
|
||||
@Id
|
||||
private Long id = 1L;
|
||||
|
||||
// Klucz uzytkownika nadany przez GUS - nigdy nie zwracany w odpowiedzi API.
|
||||
@Column(length = 100)
|
||||
private String userKey;
|
||||
|
||||
// true = srodowisko testowe BIR1.1, false = produkcyjne.
|
||||
@Column(nullable = false)
|
||||
private boolean sandbox = true;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUserKey() {
|
||||
return userKey;
|
||||
}
|
||||
|
||||
public void setUserKey(String userKey) {
|
||||
this.userKey = userKey;
|
||||
}
|
||||
|
||||
public boolean isSandbox() {
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
public void setSandbox(boolean sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface GusConfigRepository extends JpaRepository<GusConfig, Long> {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
/** Zapis konfiguracji GUS. Puste userKey = zachowaj dotychczasowy klucz. */
|
||||
public record GusConfigRequest(
|
||||
String userKey,
|
||||
Boolean sandbox
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
/** Odpowiedz konfiguracji - nigdy nie zwraca klucza, tylko flage czy jest ustawiony. */
|
||||
public record GusConfigResponse(
|
||||
boolean userKeySet,
|
||||
boolean sandbox
|
||||
) {
|
||||
public static GusConfigResponse from(GusConfig config) {
|
||||
boolean keySet = config.getUserKey() != null && !config.getUserKey().isBlank();
|
||||
return new GusConfigResponse(keySet, config.isSandbox());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Pobranie danych firmy z rejestru REGON - dostepne publicznie (formularz rejestracji konta firmowego). */
|
||||
@RestController
|
||||
@RequestMapping("/api/gus")
|
||||
public class GusController {
|
||||
|
||||
private final GusService gusService;
|
||||
|
||||
public GusController(GusService gusService) {
|
||||
this.gusService = gusService;
|
||||
}
|
||||
|
||||
@GetMapping("/company")
|
||||
public GusCompanyResponse company(@RequestParam("nip") String nip) {
|
||||
return gusService.lookupByNip(nip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package pl.polskalokalnie.gus;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
/**
|
||||
* Integracja z wyszukiwarka REGON (GUS, usluga BIR1.1). Loguje sie kluczem uzytkownika i wyszukuje
|
||||
* podmiot po numerze NIP. Sesja (sid) jest wazna okolo godziny - trzymamy ja w pamieci i odnawiamy,
|
||||
* gdy GUS odpowie pustym wynikiem (tak sygnalizuje nieaktualna sesje).
|
||||
*/
|
||||
@Service
|
||||
public class GusService {
|
||||
|
||||
private static final Long SINGLETON_ID = 1L;
|
||||
private static final String PROD_URL = "https://wyszukiwarkaregon.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc";
|
||||
private static final String TEST_URL = "https://wyszukiwarkaregontest.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc";
|
||||
// Klucz testowy udostepniony publicznie przez GUS dla srodowiska testowego.
|
||||
private static final String TEST_KEY = "abcde12345abcde12345";
|
||||
private static final String ACTION_PREFIX = "http://CIS/BIR/PUBL/2014/07/IUslugaBIRzewnPubl/";
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(30);
|
||||
// Usluga odpowiada wiadomoscia MTOM (multipart) - koperte SOAP trzeba wyluskac z opakowania MIME.
|
||||
private static final Pattern ENVELOPE_START = Pattern.compile("<[A-Za-z0-9]*:?Envelope[\\s>]");
|
||||
private static final int[] NIP_WEIGHTS = {6, 5, 7, 2, 3, 4, 5, 6, 7};
|
||||
|
||||
private final GusConfigRepository repository;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
private String sessionId;
|
||||
private String sessionEndpoint;
|
||||
private Instant sessionCreatedAt;
|
||||
|
||||
public GusService(GusConfigRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GusConfig getConfig() {
|
||||
return repository.findById(SINGLETON_ID).orElseGet(() -> {
|
||||
GusConfig config = new GusConfig();
|
||||
config.setId(SINGLETON_ID);
|
||||
return repository.save(config);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GusConfig updateConfig(GusConfigRequest request) {
|
||||
GusConfig config = getConfig();
|
||||
if (request.sandbox() != null) {
|
||||
config.setSandbox(request.sandbox());
|
||||
}
|
||||
// Klucz nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni).
|
||||
if (request.userKey() != null && !request.userKey().isBlank()) {
|
||||
config.setUserKey(request.userKey().trim());
|
||||
}
|
||||
resetSession();
|
||||
return repository.save(config);
|
||||
}
|
||||
|
||||
/** Pobiera dane firmy z rejestru REGON po numerze NIP. */
|
||||
public GusCompanyResponse lookupByNip(String rawNip) {
|
||||
String nip = normalizeNip(rawNip);
|
||||
GusConfig config = getConfig();
|
||||
String endpoint = config.isSandbox() ? TEST_URL : PROD_URL;
|
||||
String key = config.isSandbox() ? TEST_KEY : config.getUserKey();
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Pobieranie danych z GUS nie jest skonfigurowane. Skontaktuj się z administratorem serwisu.");
|
||||
}
|
||||
|
||||
Map<String, String> dane = search(endpoint, key, nip, true);
|
||||
if (dane.containsKey("ErrorCode")) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON.");
|
||||
}
|
||||
return new GusCompanyResponse(dane.get("Nazwa"), nip, dane.get("Regon"), buildAddress(dane));
|
||||
}
|
||||
|
||||
// Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze.
|
||||
private Map<String, String> search(String endpoint, String key, String nip, boolean canRetry) {
|
||||
String body = """
|
||||
<ns:DaneSzukajPodmioty>
|
||||
<ns:pParametryWyszukiwania>
|
||||
<dat:Nip>%s</dat:Nip>
|
||||
</ns:pParametryWyszukiwania>
|
||||
</ns:DaneSzukajPodmioty>""".formatted(nip);
|
||||
String response = call(endpoint, "DaneSzukajPodmioty", body, session(endpoint, key));
|
||||
Map<String, String> dane = firstDane(resultOf(response, "DaneSzukajPodmiotyResult"));
|
||||
if (dane == null) {
|
||||
if (canRetry) {
|
||||
resetSession();
|
||||
return search(endpoint, key, nip, false);
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
|
||||
"Rejestr REGON nie zwrócił danych. Spróbuj ponownie za chwilę.");
|
||||
}
|
||||
return dane;
|
||||
}
|
||||
|
||||
private synchronized String session(String endpoint, String key) {
|
||||
boolean expired = sessionId == null
|
||||
|| !endpoint.equals(sessionEndpoint)
|
||||
|| sessionCreatedAt == null
|
||||
|| sessionCreatedAt.plus(SESSION_TTL).isBefore(Instant.now());
|
||||
if (expired) {
|
||||
String body = "<ns:Zaloguj><ns:pKluczUzytkownika>%s</ns:pKluczUzytkownika></ns:Zaloguj>".formatted(key);
|
||||
String sid = resultOf(call(endpoint, "Zaloguj", body, null), "ZalogujResult");
|
||||
if (sid == null || sid.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
|
||||
"Nie udało się połączyć z rejestrem REGON. Sprawdź klucz dostępowy GUS.");
|
||||
}
|
||||
sessionId = sid.trim();
|
||||
sessionEndpoint = endpoint;
|
||||
sessionCreatedAt = Instant.now();
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private synchronized void resetSession() {
|
||||
sessionId = null;
|
||||
sessionEndpoint = null;
|
||||
sessionCreatedAt = null;
|
||||
}
|
||||
|
||||
private String call(String endpoint, String action, String body, String sid) {
|
||||
String envelope = """
|
||||
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://www.w3.org/2005/08/addressing" xmlns:ns="http://CIS/BIR/PUBL/2014/07" xmlns:dat="http://CIS/BIR/PUBL/2014/07/DataContract">
|
||||
<soap:Header>
|
||||
<wsa:Action>%s%s</wsa:Action>
|
||||
<wsa:To>%s</wsa:To>
|
||||
</soap:Header>
|
||||
<soap:Body>%s</soap:Body>
|
||||
</soap:Envelope>""".formatted(ACTION_PREFIX, action, endpoint, body);
|
||||
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(endpoint))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(envelope, StandardCharsets.UTF_8));
|
||||
if (sid != null) {
|
||||
request.header("sid", sid);
|
||||
}
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() != 200) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił błąd (HTTP " + response.statusCode() + ").");
|
||||
}
|
||||
return unwrapSoap(response.body());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Połączenie z rejestrem REGON zostało przerwane.");
|
||||
} catch (java.io.IOException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się połączyć z rejestrem REGON. Spróbuj ponownie za chwilę.");
|
||||
}
|
||||
}
|
||||
|
||||
// Odcina naglowki i stopke MIME (MTOM), zostawiajac sama koperte SOAP.
|
||||
private static String unwrapSoap(String body) {
|
||||
Matcher matcher = ENVELOPE_START.matcher(body);
|
||||
if (!matcher.find()) {
|
||||
return body;
|
||||
}
|
||||
int end = body.lastIndexOf("Envelope>");
|
||||
return end < 0 ? body.substring(matcher.start()) : body.substring(matcher.start(), end + "Envelope>".length());
|
||||
}
|
||||
|
||||
// Wynik operacji BIR to XML zagniezdzony jako tekst w kopercie SOAP.
|
||||
private static String resultOf(String soap, String localName) {
|
||||
NodeList nodes = parse(soap).getElementsByTagNameNS("*", localName);
|
||||
return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null;
|
||||
}
|
||||
|
||||
// Zwraca pola pierwszego rekordu <dane> albo null, gdy odpowiedz jest pusta (wygasla sesja).
|
||||
private static Map<String, String> firstDane(String xml) {
|
||||
if (xml == null || xml.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
NodeList danes = parse(xml).getElementsByTagName("dane");
|
||||
if (danes.getLength() == 0) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> values = new HashMap<>();
|
||||
NodeList children = danes.item(0).getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
Node child = children.item(i);
|
||||
if (child instanceof Element element) {
|
||||
String value = element.getTextContent().trim();
|
||||
if (!value.isEmpty()) {
|
||||
values.put(element.getTagName(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static Document parse(String xml) {
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setNamespaceAware(true);
|
||||
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił nieczytelną odpowiedź.");
|
||||
}
|
||||
}
|
||||
|
||||
// Adres w jednej linii: "ul. Krucza 208/3, 00-925 Warszawa".
|
||||
private static String buildAddress(Map<String, String> dane) {
|
||||
StringBuilder line = new StringBuilder();
|
||||
String street = dane.get("Ulica");
|
||||
if (street != null) {
|
||||
line.append(street);
|
||||
}
|
||||
String building = dane.get("NrNieruchomosci");
|
||||
if (building != null) {
|
||||
if (line.length() > 0) {
|
||||
line.append(' ');
|
||||
}
|
||||
line.append(building);
|
||||
String flat = dane.get("NrLokalu");
|
||||
if (flat != null) {
|
||||
line.append('/').append(flat);
|
||||
}
|
||||
}
|
||||
String postalCode = formatPostalCode(dane.get("KodPocztowy"));
|
||||
String city = dane.get("Miejscowosc");
|
||||
String cityLine = postalCode != null && city != null ? postalCode + " " + city : (postalCode != null ? postalCode : city);
|
||||
if (cityLine != null) {
|
||||
if (line.length() > 0) {
|
||||
line.append(", ");
|
||||
}
|
||||
line.append(cityLine);
|
||||
}
|
||||
return line.length() > 0 ? line.toString() : null;
|
||||
}
|
||||
|
||||
// Czesc odpowiedzi GUS zawiera kod pocztowy bez myslnika.
|
||||
private static String formatPostalCode(String code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
String digits = code.replaceAll("\\D", "");
|
||||
return digits.length() == 5 ? digits.substring(0, 2) + "-" + digits.substring(2) : code;
|
||||
}
|
||||
|
||||
/** NIP bez separatorow, z kontrola sumy - bledny numer odrzucamy bez odpytywania GUS. */
|
||||
private static String normalizeNip(String rawNip) {
|
||||
String nip = rawNip == null ? "" : rawNip.replaceAll("\\D", "");
|
||||
boolean valid = nip.length() == 10;
|
||||
if (valid) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < NIP_WEIGHTS.length; i++) {
|
||||
sum += NIP_WEIGHTS[i] * (nip.charAt(i) - '0');
|
||||
}
|
||||
valid = sum % 11 == (nip.charAt(9) - '0');
|
||||
}
|
||||
if (!valid) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny numer NIP (10 cyfr).");
|
||||
}
|
||||
return nip;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,8 @@ public record ListingDetailResponse(
|
||||
String ownerEmail,
|
||||
ListingStatus status,
|
||||
Instant createdAt,
|
||||
Long viewsCount
|
||||
Long viewsCount,
|
||||
Instant promotedUntil
|
||||
) {
|
||||
public static ListingDetailResponse from(PropertyListing listing) {
|
||||
return new ListingDetailResponse(
|
||||
@@ -91,7 +92,8 @@ public record ListingDetailResponse(
|
||||
listing.getOwnerEmail(),
|
||||
listing.getStatus(),
|
||||
listing.getCreatedAt(),
|
||||
listing.getViewsCount()
|
||||
listing.getViewsCount(),
|
||||
listing.getPromotedUntil()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public record ListingResponse(
|
||||
String ownerEmail,
|
||||
ListingStatus status,
|
||||
Long viewsCount,
|
||||
Instant promotedUntil,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static ListingResponse from(PropertyListing listing) {
|
||||
@@ -50,6 +51,7 @@ public record ListingResponse(
|
||||
listing.getOwnerEmail(),
|
||||
listing.getStatus(),
|
||||
listing.getViewsCount(),
|
||||
listing.getPromotedUntil(),
|
||||
listing.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,13 +28,19 @@ public class ListingService {
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
// Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania.
|
||||
private static Comparator<PropertyListing> promotedFirst() {
|
||||
return Comparator.comparing(PropertyListing::isPromoted).reversed()
|
||||
.thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder());
|
||||
}
|
||||
|
||||
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
|
||||
return listingRepository.findAll().stream()
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim()))
|
||||
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.sorted(promotedFirst())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
@@ -55,7 +61,7 @@ public class ListingService {
|
||||
public List<ListingResponse> findMine(String ownerEmail) {
|
||||
return listingRepository.findAll().stream()
|
||||
.filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail()))
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.sorted(promotedFirst())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
@@ -173,7 +179,7 @@ public class ListingService {
|
||||
|
||||
public List<ListingResponse> findAllForModeration() {
|
||||
return listingRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.sorted(promotedFirst())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -155,6 +155,9 @@ public class PropertyListing {
|
||||
@Column(nullable = false)
|
||||
private Long viewsCount = 0L;
|
||||
|
||||
// Data wygasniecia promowania (wyroznienia). null = nie promowane.
|
||||
private Instant promotedUntil;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -481,6 +484,18 @@ public class PropertyListing {
|
||||
this.viewsCount = viewsCount == null ? 0L : Math.max(0L, viewsCount);
|
||||
}
|
||||
|
||||
public Instant getPromotedUntil() {
|
||||
return promotedUntil;
|
||||
}
|
||||
|
||||
public void setPromotedUntil(Instant promotedUntil) {
|
||||
this.promotedUntil = promotedUntil;
|
||||
}
|
||||
|
||||
public boolean isPromoted() {
|
||||
return promotedUntil != null && promotedUntil.isAfter(Instant.now());
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Panel admina: konfiguracja bramki AutoPay. Wymaga roli ADMIN (/api/admin/**). */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/payment-config")
|
||||
public class AdminPaymentConfigController {
|
||||
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
|
||||
public AdminPaymentConfigController(PaymentConfigService paymentConfigService) {
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PaymentConfigResponse get() {
|
||||
return PaymentConfigResponse.from(paymentConfigService.get());
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public PaymentConfigResponse update(@RequestBody PaymentConfigRequest request) {
|
||||
return PaymentConfigResponse.from(paymentConfigService.update(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media).
|
||||
*
|
||||
* Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych
|
||||
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
|
||||
*
|
||||
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
|
||||
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
|
||||
*/
|
||||
@Service
|
||||
public class AutoPayService {
|
||||
|
||||
private static final String PROD_URL = "https://pay.autopay.eu/payment";
|
||||
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
|
||||
|
||||
/** Adres paywalla z podpisanymi parametrami transakcji. */
|
||||
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount,
|
||||
String description, String customerEmail, String returnUrl) {
|
||||
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
|
||||
// Kolejnosc parametrow ma znaczenie dla podpisu.
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("ServiceID", cfg.getServiceId());
|
||||
params.put("OrderID", orderId);
|
||||
params.put("Amount", amountStr);
|
||||
params.put("Currency", "PLN");
|
||||
params.put("Description", description == null ? "" : description);
|
||||
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
|
||||
if (returnUrl != null && !returnUrl.isBlank()) {
|
||||
params.put("ReturnURL", returnUrl);
|
||||
}
|
||||
|
||||
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
params.put("Hash", hash);
|
||||
|
||||
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||
StringBuilder query = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (query.length() > 0) {
|
||||
query.append('&');
|
||||
}
|
||||
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
|
||||
}
|
||||
return base + "?" + query;
|
||||
}
|
||||
|
||||
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
|
||||
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
|
||||
if (params == null || cfg == null || cfg.getSecretKey() == null) {
|
||||
return false;
|
||||
}
|
||||
String provided = params.get("Hash");
|
||||
if (provided == null || provided.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (!"Hash".equalsIgnoreCase(e.getKey())) {
|
||||
values.add(e.getValue());
|
||||
}
|
||||
}
|
||||
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
return MessageDigest.isEqual(
|
||||
expected.getBytes(StandardCharsets.UTF_8),
|
||||
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
|
||||
public String computeHash(List<String> values, String separator, String key) {
|
||||
List<String> parts = new ArrayList<>(values);
|
||||
parts.add(key);
|
||||
String data = String.join(separator, parts);
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
hex.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Nie udalo sie policzyc podpisu platnosci", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* Konfiguracja bramki szybkich platnosci AutoPay (Autopay/Blue Media). Pojedynczy rekord (id=1).
|
||||
* Gdy wylaczona lub bez danych dostepowych - dziala tryb sandbox (platnosc auto-potwierdzana).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payment_config")
|
||||
public class PaymentConfig {
|
||||
|
||||
@Id
|
||||
private Long id = 1L;
|
||||
|
||||
// ServiceID nadawany przez AutoPay.
|
||||
@Column(length = 40)
|
||||
private String serviceId;
|
||||
|
||||
// Klucz wspolpracy (sekret) do liczenia hasha - nigdy nie zwracany w odpowiedzi API.
|
||||
@Column(length = 200)
|
||||
private String secretKey;
|
||||
|
||||
// Separator wartosci przy liczeniu hasha (AutoPay domyslnie "|").
|
||||
@Column(length = 5)
|
||||
private String hashSeparator = "|";
|
||||
|
||||
// true = srodowisko testowe (testpay.autopay.eu), false = produkcja (pay.autopay.eu).
|
||||
@Column(nullable = false)
|
||||
private boolean sandbox = true;
|
||||
|
||||
// Gdy false lub brak serviceId/secretKey - platnosci sa symulowane (auto-potwierdzenie).
|
||||
@Column(nullable = false)
|
||||
private boolean enabled = false;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public void setServiceId(String serviceId) {
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public String getHashSeparator() {
|
||||
return hashSeparator == null || hashSeparator.isEmpty() ? "|" : hashSeparator;
|
||||
}
|
||||
|
||||
public void setHashSeparator(String hashSeparator) {
|
||||
this.hashSeparator = hashSeparator;
|
||||
}
|
||||
|
||||
public boolean isSandbox() {
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
public void setSandbox(boolean sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/** Czy skonfigurowana na tyle, by wywolac realna bramke. W innym wypadku - tryb sandbox. */
|
||||
public boolean isLive() {
|
||||
return enabled
|
||||
&& serviceId != null && !serviceId.isBlank()
|
||||
&& secretKey != null && !secretKey.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PaymentConfigRepository extends JpaRepository<PaymentConfig, Long> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
public record PaymentConfigRequest(
|
||||
String serviceId,
|
||||
String secretKey,
|
||||
String hashSeparator,
|
||||
Boolean sandbox,
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
/** Odpowiedz konfiguracji - nigdy nie zwraca sekretu, tylko flage czy jest ustawiony. */
|
||||
public record PaymentConfigResponse(
|
||||
String serviceId,
|
||||
boolean secretKeySet,
|
||||
String hashSeparator,
|
||||
boolean sandbox,
|
||||
boolean enabled
|
||||
) {
|
||||
public static PaymentConfigResponse from(PaymentConfig c) {
|
||||
boolean secretSet = c.getSecretKey() != null && !c.getSecretKey().isBlank();
|
||||
return new PaymentConfigResponse(c.getServiceId(), secretSet, c.getHashSeparator(), c.isSandbox(), c.isEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class PaymentConfigService {
|
||||
|
||||
private static final Long SINGLETON_ID = 1L;
|
||||
|
||||
private final PaymentConfigRepository repository;
|
||||
|
||||
public PaymentConfigService(PaymentConfigRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PaymentConfig get() {
|
||||
return repository.findById(SINGLETON_ID).orElseGet(() -> {
|
||||
PaymentConfig config = new PaymentConfig();
|
||||
config.setId(SINGLETON_ID);
|
||||
return repository.save(config);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PaymentConfig update(PaymentConfigRequest request) {
|
||||
PaymentConfig config = get();
|
||||
config.setServiceId(trimToNull(request.serviceId()));
|
||||
if (request.hashSeparator() != null && !request.hashSeparator().isBlank()) {
|
||||
config.setHashSeparator(request.hashSeparator().trim());
|
||||
}
|
||||
if (request.sandbox() != null) {
|
||||
config.setSandbox(request.sandbox());
|
||||
}
|
||||
if (request.enabled() != null) {
|
||||
config.setEnabled(request.enabled());
|
||||
}
|
||||
// Sekret nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni).
|
||||
if (request.secretKey() != null && !request.secretKey().isBlank()) {
|
||||
config.setSecretKey(request.secretKey().trim());
|
||||
}
|
||||
return repository.save(config);
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Panel admina: definiowanie planow i pakietow promowania + historia platnosci. Wymaga roli ADMIN. */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/promotion")
|
||||
public class AdminPromotionController {
|
||||
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public AdminPromotionController(PromotionService promotionService) {
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
@GetMapping("/plans")
|
||||
public List<PlanResponse> plans() {
|
||||
return promotionService.allPlans();
|
||||
}
|
||||
|
||||
@PostMapping("/plans")
|
||||
public PlanResponse createPlan(@Valid @RequestBody PlanRequest request) {
|
||||
return promotionService.createPlan(request);
|
||||
}
|
||||
|
||||
@PutMapping("/plans/{id}")
|
||||
public PlanResponse updatePlan(@PathVariable Long id, @Valid @RequestBody PlanRequest request) {
|
||||
return promotionService.updatePlan(id, request);
|
||||
}
|
||||
|
||||
@PatchMapping("/plans/{id}/toggle")
|
||||
public PlanResponse togglePlan(@PathVariable Long id) {
|
||||
return promotionService.togglePlan(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/plans/{id}")
|
||||
public ResponseEntity<Void> deletePlan(@PathVariable Long id) {
|
||||
promotionService.deletePlan(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/packages")
|
||||
public List<PackageResponse> packages() {
|
||||
return promotionService.allPackages();
|
||||
}
|
||||
|
||||
@PostMapping("/packages")
|
||||
public PackageResponse createPackage(@Valid @RequestBody PackageRequest request) {
|
||||
return promotionService.createPackage(request);
|
||||
}
|
||||
|
||||
@PutMapping("/packages/{id}")
|
||||
public PackageResponse updatePackage(@PathVariable Long id, @Valid @RequestBody PackageRequest request) {
|
||||
return promotionService.updatePackage(id, request);
|
||||
}
|
||||
|
||||
@PatchMapping("/packages/{id}/toggle")
|
||||
public PackageResponse togglePackage(@PathVariable Long id) {
|
||||
return promotionService.togglePackage(id);
|
||||
}
|
||||
|
||||
@DeleteMapping("/packages/{id}")
|
||||
public ResponseEntity<Void> deletePackage(@PathVariable Long id) {
|
||||
promotionService.deletePackage(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/orders")
|
||||
public List<OrderResponse> orders() {
|
||||
return promotionService.allOrders();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
|
||||
/**
|
||||
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
|
||||
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
|
||||
*
|
||||
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
|
||||
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
|
||||
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/payments/autopay")
|
||||
public class AutoPayItnController {
|
||||
|
||||
private final AutoPayService autoPayService;
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public AutoPayItnController(AutoPayService autoPayService,
|
||||
PaymentConfigService paymentConfigService,
|
||||
PromotionService promotionService) {
|
||||
this.autoPayService = autoPayService;
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
@PostMapping("/itn")
|
||||
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!autoPayService.verifyItn(params, cfg)) {
|
||||
return ResponseEntity.badRequest().body("ERROR: invalid hash");
|
||||
}
|
||||
String orderId = params.get("OrderID");
|
||||
if (orderId != null && isSuccess(params)) {
|
||||
promotionService.markPaidByExternalId(orderId);
|
||||
}
|
||||
return ResponseEntity.ok("OK");
|
||||
}
|
||||
|
||||
private static boolean isSuccess(Map<String, String> params) {
|
||||
String status = params.getOrDefault("PaymentStatus",
|
||||
params.getOrDefault("Status", params.getOrDefault("paymentStatus", "SUCCESS")));
|
||||
String s = status == null ? "" : status.trim().toUpperCase();
|
||||
return s.equals("SUCCESS") || s.equals("CONFIRMED") || s.equals("TRUE") || s.equals("PENDING");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** Zamowienie promowania: plan (z listingId) albo pakiet. */
|
||||
public record CheckoutRequest(
|
||||
@NotNull PromotionKind kind,
|
||||
Long planId,
|
||||
Long packageId,
|
||||
Long listingId,
|
||||
String method
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
/**
|
||||
* Wynik rozpoczecia platnosci.
|
||||
* status = "PAID" (tryb sandbox - od razu oplacone) lub "REDIRECT" (przekierowanie na paywall AutoPay).
|
||||
*/
|
||||
public record CheckoutResponse(
|
||||
String status,
|
||||
Long orderId,
|
||||
String redirectUrl
|
||||
) {
|
||||
public static CheckoutResponse paid(Long orderId) {
|
||||
return new CheckoutResponse("PAID", orderId, null);
|
||||
}
|
||||
|
||||
public static CheckoutResponse redirect(Long orderId, String url) {
|
||||
return new CheckoutResponse("REDIRECT", orderId, url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** Promowanie oferty z wykorzystaniem kredytu (bez platnosci). */
|
||||
public record CreditPromoteRequest(
|
||||
@NotNull Long listingId,
|
||||
@NotNull Long planId
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
public record OrderResponse(
|
||||
Long id,
|
||||
String userEmail,
|
||||
PromotionKind kind,
|
||||
String itemName,
|
||||
BigDecimal amount,
|
||||
String method,
|
||||
Long listingId,
|
||||
OrderStatus status,
|
||||
Instant createdAt,
|
||||
Instant paidAt
|
||||
) {
|
||||
public static OrderResponse from(PromotionOrder o) {
|
||||
return new OrderResponse(o.getId(), o.getUserEmail(), o.getKind(), o.getItemName(), o.getAmount(),
|
||||
o.getMethod(), o.getListingId(), o.getStatus(), o.getCreatedAt(), o.getPaidAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
/** Status platnosci za promowanie. */
|
||||
public enum OrderStatus {
|
||||
PENDING,
|
||||
PAID,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PackageRequest(
|
||||
@NotBlank String name,
|
||||
@Min(1) int quantity,
|
||||
@NotNull @PositiveOrZero BigDecimal price,
|
||||
Boolean active,
|
||||
Integer position
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PackageResponse(
|
||||
Long id,
|
||||
String name,
|
||||
int quantity,
|
||||
BigDecimal price,
|
||||
boolean active,
|
||||
int position
|
||||
) {
|
||||
public static PackageResponse from(PromotionPackage p) {
|
||||
return new PackageResponse(p.getId(), p.getName(), p.getQuantity(), p.getPrice(), p.isActive(), p.getPosition());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PlanRequest(
|
||||
@NotBlank String name,
|
||||
@Min(1) int durationDays,
|
||||
@NotNull @PositiveOrZero BigDecimal price,
|
||||
Boolean active,
|
||||
Integer position
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PlanResponse(
|
||||
Long id,
|
||||
String name,
|
||||
int durationDays,
|
||||
BigDecimal price,
|
||||
boolean active,
|
||||
int position
|
||||
) {
|
||||
public static PlanResponse from(PromotionPlan p) {
|
||||
return new PlanResponse(p.getId(), p.getName(), p.getDurationDays(), p.getPrice(), p.isActive(), p.getPosition());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
|
||||
@RestController
|
||||
@RequestMapping("/api/promotion")
|
||||
public class PromotionController {
|
||||
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public PromotionController(PromotionService promotionService) {
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
@GetMapping("/plans")
|
||||
public List<PlanResponse> plans() {
|
||||
return promotionService.activePlans();
|
||||
}
|
||||
|
||||
@GetMapping("/packages")
|
||||
public List<PackageResponse> packages() {
|
||||
return promotionService.activePackages();
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public CreditsResponse me(Authentication authentication) {
|
||||
return new CreditsResponse(promotionService.credits(authentication.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/checkout")
|
||||
public CheckoutResponse checkout(@Valid @RequestBody CheckoutRequest request, Authentication authentication) {
|
||||
return promotionService.checkout(authentication.getName(), request);
|
||||
}
|
||||
|
||||
@PostMapping("/promote-with-credit")
|
||||
public CreditsResponse promoteWithCredit(@Valid @RequestBody CreditPromoteRequest request, Authentication authentication) {
|
||||
return new CreditsResponse(promotionService.promoteWithCredit(authentication.getName(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/order/{id}")
|
||||
public OrderStatusResponse orderStatus(@PathVariable Long id, Authentication authentication) {
|
||||
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
|
||||
}
|
||||
|
||||
public record CreditsResponse(int credits) {
|
||||
}
|
||||
|
||||
public record OrderStatusResponse(String status) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
/** Rodzaj zamowienia promowania: pojedynczy plan (wyroznienie oferty) albo pakiet kredytow. */
|
||||
public enum PromotionKind {
|
||||
PLAN,
|
||||
PACKAGE
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Zamowienie/platnosc za promowanie (plan lub pakiet). Sluzy tez jako historia platnosci. */
|
||||
@Entity
|
||||
@Table(name = "promotion_orders")
|
||||
public class PromotionOrder {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
// Unikalny identyfikator przekazywany do bramki (OrderID w AutoPay).
|
||||
@Column(nullable = false, unique = true, length = 40)
|
||||
private String externalId;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private PromotionKind kind;
|
||||
|
||||
// Id planu lub pakietu.
|
||||
private Long refId;
|
||||
|
||||
@Column(length = 160)
|
||||
private String itemName;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Column(length = 60)
|
||||
private String method;
|
||||
|
||||
// Dla planu: promowana oferta.
|
||||
private Long listingId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private OrderStatus status = OrderStatus.PENDING;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
private Instant paidAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getExternalId() {
|
||||
return externalId;
|
||||
}
|
||||
|
||||
public void setExternalId(String externalId) {
|
||||
this.externalId = externalId;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public PromotionKind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void setKind(PromotionKind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public Long getRefId() {
|
||||
return refId;
|
||||
}
|
||||
|
||||
public void setRefId(Long refId) {
|
||||
this.refId = refId;
|
||||
}
|
||||
|
||||
public String getItemName() {
|
||||
return itemName;
|
||||
}
|
||||
|
||||
public void setItemName(String itemName) {
|
||||
this.itemName = itemName;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public void setMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public OrderStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(OrderStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getPaidAt() {
|
||||
return paidAt;
|
||||
}
|
||||
|
||||
public void setPaidAt(Instant paidAt) {
|
||||
this.paidAt = paidAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
|
||||
Optional<PromotionOrder> findByExternalId(String externalId);
|
||||
List<PromotionOrder> findByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Pakiet promowan definiowany przez admina: liczba kredytow (sztuk) w cenie promocyjnej. */
|
||||
@Entity
|
||||
@Table(name = "promotion_packages")
|
||||
public class PromotionPackage {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int quantity;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal price;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int position = 0;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(int quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(BigDecimal price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPosition(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PromotionPackageRepository extends JpaRepository<PromotionPackage, Long> {
|
||||
List<PromotionPackage> findByOrderByPositionAscIdAsc();
|
||||
List<PromotionPackage> findByActiveTrueOrderByPositionAscIdAsc();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Plan promowania definiowany przez admina: czas trwania (dni) i cena. */
|
||||
@Entity
|
||||
@Table(name = "promotion_plans")
|
||||
public class PromotionPlan {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int durationDays;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal price;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int position = 0;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getDurationDays() {
|
||||
return durationDays;
|
||||
}
|
||||
|
||||
public void setDurationDays(int durationDays) {
|
||||
this.durationDays = durationDays;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(BigDecimal price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPosition(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PromotionPlanRepository extends JpaRepository<PromotionPlan, Long> {
|
||||
List<PromotionPlan> findByOrderByPositionAscIdAsc();
|
||||
List<PromotionPlan> findByActiveTrueOrderByPositionAscIdAsc();
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Service
|
||||
public class PromotionService {
|
||||
|
||||
private final PromotionPlanRepository planRepository;
|
||||
private final PromotionPackageRepository packageRepository;
|
||||
private final PromotionOrderRepository orderRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final AutoPayService autoPayService;
|
||||
private final String baseUrl;
|
||||
|
||||
public PromotionService(PromotionPlanRepository planRepository,
|
||||
PromotionPackageRepository packageRepository,
|
||||
PromotionOrderRepository orderRepository,
|
||||
UserRepository userRepository,
|
||||
ListingRepository listingRepository,
|
||||
PaymentConfigService paymentConfigService,
|
||||
AutoPayService autoPayService,
|
||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||
this.planRepository = planRepository;
|
||||
this.packageRepository = packageRepository;
|
||||
this.orderRepository = orderRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.autoPayService = autoPayService;
|
||||
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
// --- Publiczne (uzytkownik) ---
|
||||
|
||||
public List<PlanResponse> activePlans() {
|
||||
return planRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
|
||||
}
|
||||
|
||||
public List<PackageResponse> activePackages() {
|
||||
return packageRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList();
|
||||
}
|
||||
|
||||
public int credits(String email) {
|
||||
return userRepository.findByEmailIgnoreCase(email).map(AppUser::getPromotionCredits).orElse(0);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CheckoutResponse checkout(String email, CheckoutRequest request) {
|
||||
PromotionOrder order = new PromotionOrder();
|
||||
order.setUserEmail(email);
|
||||
order.setKind(request.kind());
|
||||
order.setExternalId(generateExternalId());
|
||||
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
|
||||
|
||||
if (request.kind() == PromotionKind.PLAN) {
|
||||
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||
order.setRefId(plan.getId());
|
||||
order.setItemName("Promowanie: " + plan.getName());
|
||||
order.setAmount(plan.getPrice());
|
||||
order.setListingId(listing.getId());
|
||||
} else {
|
||||
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
order.setRefId(pack.getId());
|
||||
order.setItemName("Pakiet promowan: " + pack.getName());
|
||||
order.setAmount(pack.getPrice());
|
||||
}
|
||||
order = orderRepository.save(order);
|
||||
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!cfg.isLive()) {
|
||||
// Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay).
|
||||
applyPaid(order);
|
||||
return CheckoutResponse.paid(order.getId());
|
||||
}
|
||||
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
|
||||
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
|
||||
order.getItemName(), email, returnUrl);
|
||||
return CheckoutResponse.redirect(order.getId(), url);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void applyPaid(PromotionOrder order) {
|
||||
if (order.getStatus() == OrderStatus.PAID) {
|
||||
return;
|
||||
}
|
||||
if (order.getKind() == PromotionKind.PLAN) {
|
||||
PromotionPlan plan = planRepository.findById(order.getRefId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PropertyListing listing = listingRepository.findById(order.getListingId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje"));
|
||||
extendPromotion(listing, plan.getDurationDays());
|
||||
listingRepository.save(listing);
|
||||
} else {
|
||||
PromotionPackage pack = packageRepository.findById(order.getRefId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
|
||||
userRepository.save(user);
|
||||
}
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(Instant.now());
|
||||
orderRepository.save(order);
|
||||
}
|
||||
|
||||
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu.
|
||||
@Transactional
|
||||
public void markPaidByExternalId(String externalId) {
|
||||
orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||
if (user.getPromotionCredits() <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
||||
}
|
||||
PromotionPlan plan = planRepository.findById(request.planId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||
|
||||
user.setPromotionCredits(user.getPromotionCredits() - 1);
|
||||
userRepository.save(user);
|
||||
extendPromotion(listing, plan.getDurationDays());
|
||||
listingRepository.save(listing);
|
||||
|
||||
PromotionOrder order = new PromotionOrder();
|
||||
order.setUserEmail(email);
|
||||
order.setKind(PromotionKind.PLAN);
|
||||
order.setExternalId(generateExternalId());
|
||||
order.setRefId(plan.getId());
|
||||
order.setItemName("Promowanie (kredyt): " + plan.getName());
|
||||
order.setAmount(BigDecimal.ZERO);
|
||||
order.setMethod("Kredyt promocji");
|
||||
order.setListingId(listing.getId());
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(Instant.now());
|
||||
orderRepository.save(order);
|
||||
|
||||
return user.getPromotionCredits();
|
||||
}
|
||||
|
||||
public String orderStatus(String email, Long id) {
|
||||
PromotionOrder order = orderRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
|
||||
if (!order.getUserEmail().equalsIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
|
||||
}
|
||||
return order.getStatus().name();
|
||||
}
|
||||
|
||||
// --- Admin ---
|
||||
|
||||
public List<PlanResponse> allPlans() {
|
||||
return planRepository.findByOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
|
||||
}
|
||||
|
||||
public List<PackageResponse> allPackages() {
|
||||
return packageRepository.findByOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList();
|
||||
}
|
||||
|
||||
public List<OrderResponse> allOrders() {
|
||||
return orderRepository.findByOrderByCreatedAtDesc().stream().map(OrderResponse::from).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlanResponse createPlan(PlanRequest request) {
|
||||
PromotionPlan plan = new PromotionPlan();
|
||||
applyPlan(plan, request);
|
||||
return PlanResponse.from(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlanResponse updatePlan(Long id, PlanRequest request) {
|
||||
PromotionPlan plan = planRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
applyPlan(plan, request);
|
||||
return PlanResponse.from(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlanResponse togglePlan(Long id) {
|
||||
PromotionPlan plan = planRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
plan.setActive(!plan.isActive());
|
||||
return PlanResponse.from(planRepository.save(plan));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deletePlan(Long id) {
|
||||
planRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PackageResponse createPackage(PackageRequest request) {
|
||||
PromotionPackage pack = new PromotionPackage();
|
||||
applyPackage(pack, request);
|
||||
return PackageResponse.from(packageRepository.save(pack));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PackageResponse updatePackage(Long id, PackageRequest request) {
|
||||
PromotionPackage pack = packageRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
applyPackage(pack, request);
|
||||
return PackageResponse.from(packageRepository.save(pack));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PackageResponse togglePackage(Long id) {
|
||||
PromotionPackage pack = packageRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
pack.setActive(!pack.isActive());
|
||||
return PackageResponse.from(packageRepository.save(pack));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deletePackage(Long id) {
|
||||
packageRepository.deleteById(id);
|
||||
}
|
||||
|
||||
// --- Pomocnicze ---
|
||||
|
||||
private void applyPlan(PromotionPlan plan, PlanRequest request) {
|
||||
plan.setName(request.name().trim());
|
||||
plan.setDurationDays(request.durationDays());
|
||||
plan.setPrice(request.price());
|
||||
plan.setActive(request.active() == null || request.active());
|
||||
plan.setPosition(request.position() == null ? 0 : request.position());
|
||||
}
|
||||
|
||||
private void applyPackage(PromotionPackage pack, PackageRequest request) {
|
||||
pack.setName(request.name().trim());
|
||||
pack.setQuantity(request.quantity());
|
||||
pack.setPrice(request.price());
|
||||
pack.setActive(request.active() == null || request.active());
|
||||
pack.setPosition(request.position() == null ? 0 : request.position());
|
||||
}
|
||||
|
||||
private void extendPromotion(PropertyListing listing, int days) {
|
||||
Instant now = Instant.now();
|
||||
Instant base = listing.getPromotedUntil() != null && listing.getPromotedUntil().isAfter(now)
|
||||
? listing.getPromotedUntil() : now;
|
||||
listing.setPromotedUntil(base.plus(days, ChronoUnit.DAYS));
|
||||
}
|
||||
|
||||
private PropertyListing requireOwnedListing(Long listingId, String email) {
|
||||
if (listingId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak listingId");
|
||||
}
|
||||
PropertyListing listing = listingRepository.findById(listingId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje"));
|
||||
if (listing.getOwnerEmail() == null || !listing.getOwnerEmail().equalsIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "To nie jest Twoja oferta");
|
||||
}
|
||||
return listing;
|
||||
}
|
||||
|
||||
private static Long requireId(Long id, String field) {
|
||||
if (id == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak " + field);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private static String generateExternalId() {
|
||||
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package pl.polskalokalnie.send;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
@@ -11,19 +13,21 @@ import org.springframework.stereotype.Component;
|
||||
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||
|
||||
/**
|
||||
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). Zadanie HTTP POST
|
||||
* (form-urlencoded) z kluczem API, polem creator, numerem i trescia; timeout z konfiguracji.
|
||||
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}).
|
||||
*
|
||||
* UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z
|
||||
* dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac.
|
||||
* Zgodnie z manualem bramki: POST {@code application/json} z polami {@code api_key}, {@code to},
|
||||
* {@code message} oraz opcjonalnym {@code creator}. Odpowiedz to JSON
|
||||
* {@code {"status":"success|error","message":"..."}}. Sukcesem jest HTTP 2xx i {@code status=success};
|
||||
* przy bledzie zwracamy komunikat z pola {@code message} bramki.
|
||||
*/
|
||||
@Component
|
||||
public class SmsSender {
|
||||
|
||||
private static final String PARAM_API_KEY = "apiKey";
|
||||
private static final String PARAM_CREATOR = "creator";
|
||||
private static final String PARAM_TO = "to";
|
||||
private static final String PARAM_MESSAGE = "message";
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SmsSender(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public SendResult send(SmsGatewayConfig config, String phone, String message) {
|
||||
if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank()
|
||||
@@ -33,10 +37,14 @@ public class SmsSender {
|
||||
try {
|
||||
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
|
||||
? config.getTimeoutSeconds() : 10;
|
||||
String form = PARAM_API_KEY + "=" + enc(config.getApiKey())
|
||||
+ "&" + PARAM_CREATOR + "=" + enc(config.getCreator())
|
||||
+ "&" + PARAM_TO + "=" + enc(phone)
|
||||
+ "&" + PARAM_MESSAGE + "=" + enc(message);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("api_key", config.getApiKey());
|
||||
payload.put("to", phone == null ? "" : phone.trim());
|
||||
payload.put("message", message == null ? "" : message);
|
||||
String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator();
|
||||
payload.put("creator", creator);
|
||||
String jsonBody = objectMapper.writeValueAsString(payload);
|
||||
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(timeout))
|
||||
@@ -44,24 +52,38 @@ public class SmsSender {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.getEndpointUrl()))
|
||||
.timeout(Duration.ofSeconds(timeout))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8))
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
String body = response.body() == null ? "" : response.body().trim();
|
||||
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
||||
return SendResult.ok(shorten(body));
|
||||
|
||||
JsonNode json = tryParse(body);
|
||||
String gatewayStatus = json != null && json.hasNonNull("status") ? json.get("status").asText() : null;
|
||||
String gatewayMessage = json != null && json.hasNonNull("message") ? json.get("message").asText() : null;
|
||||
boolean http2xx = response.statusCode() >= 200 && response.statusCode() < 300;
|
||||
|
||||
if (http2xx && (gatewayStatus == null || gatewayStatus.equalsIgnoreCase("success"))) {
|
||||
return SendResult.ok(gatewayMessage != null ? gatewayMessage : "SMS dodany do kolejki.");
|
||||
}
|
||||
return SendResult.failure("Bramka SMS zwróciła status " + response.statusCode()
|
||||
+ (body.isEmpty() ? "" : ": " + shorten(body)));
|
||||
String detail = gatewayMessage != null && !gatewayMessage.isBlank() ? gatewayMessage : shorten(body);
|
||||
return SendResult.failure("Bramka SMS: " + (detail.isBlank() ? "status " + response.statusCode() : detail));
|
||||
} catch (Exception ex) {
|
||||
return SendResult.failure(shorten(ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
private JsonNode tryParse(String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(body);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String shorten(String value) {
|
||||
|
||||
@@ -65,6 +65,11 @@ public class AppUser {
|
||||
|
||||
private LocalDate birthDate;
|
||||
|
||||
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("0")
|
||||
private int promotionCredits = 0;
|
||||
|
||||
// Konta zakladane przez rejestracje lokalna czekaja na zatwierdzenie przez administratora.
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
@@ -73,6 +78,11 @@ public class AppUser {
|
||||
@Column(nullable = false)
|
||||
private boolean blocked = false;
|
||||
|
||||
// Czy numer telefonu zostal potwierdzony kodem SMS (OTP).
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean phoneVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -183,6 +193,14 @@ public class AppUser {
|
||||
this.birthDate = birthDate;
|
||||
}
|
||||
|
||||
public int getPromotionCredits() {
|
||||
return promotionCredits;
|
||||
}
|
||||
|
||||
public void setPromotionCredits(int promotionCredits) {
|
||||
this.promotionCredits = promotionCredits;
|
||||
}
|
||||
|
||||
public boolean isVerified() {
|
||||
return verified;
|
||||
}
|
||||
@@ -191,6 +209,14 @@ public class AppUser {
|
||||
this.verified = verified;
|
||||
}
|
||||
|
||||
public boolean isPhoneVerified() {
|
||||
return phoneVerified;
|
||||
}
|
||||
|
||||
public void setPhoneVerified(boolean phoneVerified) {
|
||||
this.phoneVerified = phoneVerified;
|
||||
}
|
||||
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
+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 (
|
||||
<div className="mkt-grid-2">
|
||||
<div className="mkt-wrap">
|
||||
<div className="admin-card mkt-card">
|
||||
<div className="admin-card-head"><h2>Nowa kampania</h2></div>
|
||||
<div className="mkt-form">
|
||||
<label>Nazwa
|
||||
<div className="mkt-config-grid">
|
||||
<label className="mkt-col-span">Nazwa
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Newsletter lipiec" />
|
||||
</label>
|
||||
<div className="mkt-form-row">
|
||||
<label>Kanał
|
||||
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
|
||||
<option value="EMAIL">E-mail</option>
|
||||
@@ -631,20 +630,18 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{form.channel === 'EMAIL' && (
|
||||
<label>Temat
|
||||
<label className="mkt-col-span">Temat
|
||||
<input value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} placeholder="Temat wiadomości" />
|
||||
</label>
|
||||
)}
|
||||
<label>Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
|
||||
<label className="mkt-col-span">Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
|
||||
{form.channel === 'EMAIL' ? (
|
||||
<RichTextEditor value={form.body} onChange={(html) => setForm({ ...form, body: html })} ariaLabel="Treść kampanii e-mail" placeholder="Cześć {{name}}, ..." />
|
||||
) : (
|
||||
<textarea rows={5} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
|
||||
<textarea rows={6} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
|
||||
)}
|
||||
</label>
|
||||
<div className="mkt-form-row">
|
||||
<label>Dzienny limit
|
||||
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
|
||||
</label>
|
||||
@@ -653,6 +650,7 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="mkt-error">{error}</p>}
|
||||
<div className="mkt-config-save">
|
||||
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
|
||||
{saving ? 'Zapisywanie...' : 'Utwórz kampanię (robocza)'}
|
||||
</button>
|
||||
@@ -730,10 +728,18 @@ type SmsConfig = {
|
||||
apiKeySet: boolean;
|
||||
};
|
||||
|
||||
export function AdminMailConfigView({ Icon }: { Icon: IconComponent }) {
|
||||
// Rozdzielone obszary w panelu admina: osobno konfiguracja SMTP, osobno bramka SMS.
|
||||
export function AdminSmtpConfigView({ Icon }: { Icon: IconComponent }) {
|
||||
return (
|
||||
<div className="mkt-wrap">
|
||||
<SmtpCard Icon={Icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSmsConfigView({ Icon }: { Icon: IconComponent }) {
|
||||
return (
|
||||
<div className="mkt-wrap">
|
||||
<SmsCard Icon={Icon} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
birthDate: string | null;
|
||||
verified: boolean;
|
||||
phoneVerified: boolean;
|
||||
blocked: boolean;
|
||||
promotionCredits: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AuthResponse = { token: string; user: AuthUser };
|
||||
|
||||
// Po rejestracji konto wymaga aktywacji linkiem e-mail - nie logujemy od razu.
|
||||
export type RegisterPending = { email: string; phoneVerificationRequired: boolean };
|
||||
|
||||
export type RegisterDetails = {
|
||||
accountType: AccountType;
|
||||
phone?: string;
|
||||
nip?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
@@ -38,9 +44,16 @@ type AuthContextValue = {
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AuthUser>;
|
||||
register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise<AuthUser>;
|
||||
register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise<RegisterPending>;
|
||||
socialLogin: (provider: Exclude<AuthProviderName, 'LOCAL'>) => Promise<AuthUser>;
|
||||
activateAccount: (token: string) => Promise<void>;
|
||||
resendActivation: (email: string) => Promise<void>;
|
||||
forgotPassword: (email: string) => Promise<void>;
|
||||
resetPassword: (token: string, password: string) => Promise<void>;
|
||||
verifyPhone: (email: string, code: string) => Promise<void>;
|
||||
resendPhoneOtp: (email: string) => Promise<void>;
|
||||
updateProfile: (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => Promise<AuthUser>;
|
||||
refreshUser: () => Promise<AuthUser | null>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
@@ -161,13 +174,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
[applyAuth],
|
||||
);
|
||||
|
||||
// Rejestracja NIE loguje od razu - konto wymaga aktywacji linkiem z e-maila.
|
||||
const register = useCallback(
|
||||
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
|
||||
applyAuth(await apiFetch<AuthResponse>('/auth/register', {
|
||||
apiFetch<RegisterPending>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, fullName, ...details }),
|
||||
})),
|
||||
[applyAuth],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const socialLogin = useCallback(
|
||||
@@ -179,6 +193,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
[applyAuth],
|
||||
);
|
||||
|
||||
const activateAccount = useCallback(async (token: string) => {
|
||||
await apiFetch<void>('/auth/activate', { method: 'POST', body: JSON.stringify({ token }) });
|
||||
}, []);
|
||||
|
||||
const resendActivation = useCallback(async (email: string) => {
|
||||
await apiFetch<void>('/auth/resend-activation', { method: 'POST', body: JSON.stringify({ email }) });
|
||||
}, []);
|
||||
|
||||
const forgotPassword = useCallback(async (email: string) => {
|
||||
await apiFetch<void>('/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
|
||||
}, []);
|
||||
|
||||
const resetPassword = useCallback(async (token: string, password: string) => {
|
||||
await apiFetch<void>('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
|
||||
}, []);
|
||||
|
||||
const verifyPhone = useCallback(async (email: string, code: string) => {
|
||||
await apiFetch<void>('/auth/verify-phone', { method: 'POST', body: JSON.stringify({ email, code }) });
|
||||
}, []);
|
||||
|
||||
const resendPhoneOtp = useCallback(async (email: string) => {
|
||||
await apiFetch<void>('/auth/resend-phone-otp', { method: 'POST', body: JSON.stringify({ email }) });
|
||||
}, []);
|
||||
|
||||
const updateProfile = useCallback(
|
||||
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
|
||||
const updated = await apiFetch<AuthUser>('/auth/me', {
|
||||
@@ -191,6 +229,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
[],
|
||||
);
|
||||
|
||||
// Ponowne pobranie danych uzytkownika (np. po zakupie promowania - odswieza saldo kredytow).
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (!getToken()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fresh = await apiFetch<AuthUser>('/auth/me');
|
||||
setUser(fresh);
|
||||
return fresh;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
@@ -198,8 +250,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, token, loading, login, register, socialLogin, updateProfile, logout }),
|
||||
[user, token, loading, login, register, socialLogin, updateProfile, logout],
|
||||
() => ({
|
||||
user, token, loading, login, register, socialLogin,
|
||||
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
||||
updateProfile, refreshUser, logout,
|
||||
}),
|
||||
[user, token, loading, login, register, socialLogin,
|
||||
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
||||
updateProfile, refreshUser, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -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',
|
||||
accountPublicProfile: '/konto/profil',
|
||||
accountHelpContact: '/konto/pomoc',
|
||||
terms: '/regulamin',
|
||||
privacy: '/polityka-prywatnosci',
|
||||
activation: '/aktywacja/:token',
|
||||
passwordReset: '/reset-hasla/:token',
|
||||
notifications: '/powiadomienia',
|
||||
favorites: '/ulubione',
|
||||
messages: '/wiadomosci',
|
||||
@@ -89,7 +93,8 @@ export const ADMIN_TAB_PATHS = {
|
||||
forbiddenWords: 'zakazane-slowa',
|
||||
leads: 'leady',
|
||||
campaigns: 'kampanie',
|
||||
mailConfig: 'konfiguracja-wysylki',
|
||||
smtpConfig: 'konfiguracja-smtp',
|
||||
smsConfig: 'konfiguracja-sms',
|
||||
} as const;
|
||||
|
||||
export type AdminTabKey = keyof typeof ADMIN_TAB_PATHS;
|
||||
|
||||
+787
-36
@@ -3312,12 +3312,11 @@ svg {
|
||||
}
|
||||
|
||||
.satellite-tiles {
|
||||
height: 640px;
|
||||
left: 50%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 640px;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.satellite-tiles img {
|
||||
@@ -12610,45 +12609,48 @@ svg {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.social-login-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.social-login {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 6px;
|
||||
color: #142238;
|
||||
border: 1px solid #dce4ec;
|
||||
border-radius: 10px;
|
||||
color: #24344b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
gap: 10px;
|
||||
height: 42px;
|
||||
font-weight: 800;
|
||||
gap: 9px;
|
||||
height: 46px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.google-mark,
|
||||
.facebook-mark {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
flex: 0 0 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 950;
|
||||
.social-login:hover:not(:disabled) {
|
||||
background: #f7f9fc;
|
||||
border-color: #c4d2e2;
|
||||
box-shadow: 0 2px 10px rgba(20, 34, 58, 0.07);
|
||||
}
|
||||
|
||||
.social-login:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.social-login-icon {
|
||||
flex: 0 0 auto;
|
||||
height: 18px;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.google-mark {
|
||||
color: #1a73e8;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.facebook-mark {
|
||||
background: #1877f2;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
@media (max-width: 400px) {
|
||||
.social-login-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
.login-security-note {
|
||||
@@ -27542,13 +27544,18 @@ a.listing-detail-back {
|
||||
.ld-headline-price strong { color: #129357; font-size: 28px; font-weight: 900; }
|
||||
.ld-headline-price span { color: #6b7a8d; font-size: 14px; font-weight: 700; }
|
||||
|
||||
/* Zdjecie glowne bylo za duze po poszerzeniu strony - ograniczamy wysokosc.
|
||||
object-fit: contain, aby cale zdjecie bylo widoczne (bez przycinania); tlo wypelnia letterbox. */
|
||||
.listing-detail-gallery-main-image { background: #eef2f6; height: clamp(300px, 40vw, 470px); }
|
||||
/* Ramka galerii dopasowuje sie do proporcji aktualnego zdjecia (aspect-ratio nadawane inline z JS),
|
||||
dzieki czemu kadr przylega do zdjecia - brak pustych paskow po bokach. Wysokosc ograniczona. */
|
||||
.listing-detail-gallery-main-image {
|
||||
aspect-ratio: 1.6;
|
||||
background: #0f1b2d;
|
||||
max-height: 620px;
|
||||
width: 100%;
|
||||
}
|
||||
.listing-detail-gallery-main-image img {
|
||||
aspect-ratio: auto;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -27765,3 +27772,747 @@ a.listing-detail-back {
|
||||
/* Karta "ostatnio przegladane" jest linkiem do oferty. */
|
||||
.listing-card-link { color: inherit; text-decoration: none; }
|
||||
.listing-card-link:hover { box-shadow: 0 10px 26px rgba(20, 34, 58, 0.12); }
|
||||
|
||||
/* ===== BANERY / PROMOCJE (sloty reklamowe) =====
|
||||
Publiczne miejsca reklamowe na stronie glownej i karcie oferty + panel admina. */
|
||||
|
||||
/* Wrapper slotow pelnoszerokosciowych (hero/top) - trzyma szerokosc obszaru roboczego. */
|
||||
.home-banner-wrap {
|
||||
margin: 0 auto;
|
||||
max-width: var(--workspace-max);
|
||||
padding: 0 var(--workspace-pad);
|
||||
width: 100%;
|
||||
}
|
||||
.home-banner-wrap:not(:empty) { margin-top: 22px; }
|
||||
.banner-slot-home_middle { display: block; margin: 22px 0; }
|
||||
.banner-slot-sidebar { display: block; margin-top: 16px; }
|
||||
|
||||
.promo-banner {
|
||||
align-items: flex-end;
|
||||
background: linear-gradient(120deg, #0f2f5b 0%, #12a764 135%);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(15, 31, 58, 0.12);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
min-height: 140px;
|
||||
overflow: hidden;
|
||||
padding: 22px 26px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.promo-banner:hover { transform: translateY(-2px); box-shadow: 0 16px 38px rgba(15, 31, 58, 0.18); }
|
||||
.promo-banner.has-image { background-color: #0f1b2d; }
|
||||
|
||||
.promo-banner-tag {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 999px;
|
||||
color: #33475f;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 3px 9px;
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
text-transform: uppercase;
|
||||
top: 12px;
|
||||
}
|
||||
.promo-banner-body { display: flex; flex-direction: column; gap: 6px; max-width: 640px; position: relative; z-index: 1; }
|
||||
.promo-banner-title { font-size: 22px; font-weight: 950; line-height: 1.2; }
|
||||
.promo-banner-sub { font-size: 14px; font-weight: 600; opacity: 0.94; }
|
||||
.promo-banner-cta {
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
background: #ffffff;
|
||||
border-radius: 999px;
|
||||
color: #0f2f5b;
|
||||
display: inline-flex;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 9px 16px;
|
||||
}
|
||||
.promo-banner:hover .promo-banner-cta { background: #eafaf1; }
|
||||
|
||||
.banner-slot-sidebar .promo-banner { min-height: 210px; padding: 18px 20px; }
|
||||
.banner-slot-sidebar .promo-banner-title { font-size: 18px; }
|
||||
.banner-slot-sidebar .promo-banner-sub { font-size: 13px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.promo-banner { min-height: 120px; padding: 18px; }
|
||||
.promo-banner-title { font-size: 18px; }
|
||||
}
|
||||
|
||||
/* --- Panel admina: banery --- */
|
||||
.mkt-col-span { grid-column: 1 / -1; }
|
||||
.admin-btn.ghost { background: #ffffff; }
|
||||
|
||||
.banner-image-row {
|
||||
border-top: 1px solid #eef2f6;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
.banner-image-preview {
|
||||
align-items: center;
|
||||
background: #f4f7fa;
|
||||
border: 1px solid #dce4ec;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex: 0 0 260px;
|
||||
height: 130px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.banner-image-preview img { height: 100%; object-fit: cover; width: 100%; }
|
||||
.banner-image-empty { align-items: center; color: #8aa0b6; display: inline-flex; font-size: 13px; font-weight: 700; gap: 8px; }
|
||||
.banner-image-empty svg { width: 16px; height: 16px; }
|
||||
.banner-image-actions { align-items: flex-start; display: flex; flex-direction: column; gap: 8px; }
|
||||
.banner-upload-btn { align-items: center; cursor: pointer; display: inline-flex; gap: 7px; }
|
||||
.banner-upload-btn svg { height: 15px; width: 15px; }
|
||||
.banner-image-hint { color: #6b7f96; font-size: 12px; max-width: 420px; }
|
||||
.banner-slot-hint { align-items: center; color: #46617d; display: flex; font-size: 12px; gap: 7px; margin: 4px 0 0; }
|
||||
.banner-slot-hint svg { height: 14px; width: 14px; }
|
||||
|
||||
.banner-admin-list { display: grid; gap: 18px; }
|
||||
.banner-admin-group-title { color: #6b7f96; font-size: 12px; font-weight: 800; letter-spacing: 0.03em; margin: 0 0 8px; text-transform: uppercase; }
|
||||
.banner-admin-row {
|
||||
align-items: center;
|
||||
border: 1px solid #e6edf4;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.banner-admin-row.is-off { opacity: 0.62; }
|
||||
.banner-admin-thumb {
|
||||
align-items: center;
|
||||
background: linear-gradient(120deg, #0f2f5b, #12a764);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
flex: 0 0 92px;
|
||||
height: 60px;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.banner-admin-thumb img { height: 100%; object-fit: cover; width: 100%; }
|
||||
.banner-admin-thumb-text svg { height: 20px; width: 20px; }
|
||||
.banner-admin-info { display: flex; flex: 1 1 auto; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.banner-admin-info strong { color: #14223a; font-size: 14px; }
|
||||
.banner-admin-info > span { color: #5a6b80; font-size: 12px; }
|
||||
.banner-admin-meta { align-items: center; display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 3px; }
|
||||
.banner-admin-link { color: #12a764; font-size: 11px; overflow: hidden; text-overflow: ellipsis; max-width: 260px; white-space: nowrap; }
|
||||
.banner-admin-stats { color: #7a8aa0; font-size: 11px; font-weight: 700; }
|
||||
.banner-status { border-radius: 999px; font-size: 11px; font-weight: 800; padding: 2px 8px; }
|
||||
.banner-status.on { background: #e3f4ec; color: #12a764; }
|
||||
.banner-status.off { background: #f1f4f8; color: #8090a4; }
|
||||
.banner-admin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.banner-image-row { flex-direction: column; }
|
||||
.banner-image-preview { flex-basis: auto; width: 100%; }
|
||||
.banner-admin-row { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* ===== PROMOWANIE OFERT (modal, odznaki, panel admina) ===== */
|
||||
.listing-promote-modal {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e9f1;
|
||||
border-radius: 16px;
|
||||
max-width: 560px;
|
||||
padding: 22px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.listing-promote-icon {
|
||||
align-items: center;
|
||||
background: linear-gradient(120deg, #0f2f5b, #12a764);
|
||||
border-radius: 14px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
height: 46px;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
width: 46px;
|
||||
}
|
||||
.listing-promote-icon svg { height: 22px; width: 22px; }
|
||||
.listing-promote-modal h2 { color: #13243d; font-size: 20px; margin: 0 0 6px; }
|
||||
|
||||
.promote-plans { display: grid; gap: 8px; margin: 14px 0 6px; }
|
||||
.promote-plans > strong { color: #24374d; font-size: 13px; }
|
||||
.promote-plan {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1.5px solid #dce4ec;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.promote-plan:hover { border-color: #b7c6d8; }
|
||||
.promote-plan.active { background: #eafaf1; border-color: #12a764; }
|
||||
.promote-plan-name { color: #14223a; font-weight: 850; }
|
||||
.promote-plan-days { color: #6b7f96; font-size: 12px; font-weight: 700; }
|
||||
.promote-plan-price { color: #0f7a48; font-weight: 950; margin-left: auto; }
|
||||
|
||||
.promote-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0; }
|
||||
.promote-pay-btn {
|
||||
align-items: center;
|
||||
background: #12a764;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex: 1 1 220px;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.promote-pay-btn:hover { background: #0f8f55; }
|
||||
.promote-pay-btn:disabled { cursor: default; opacity: 0.6; }
|
||||
.promote-credit-btn {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1.5px solid #12a764;
|
||||
border-radius: 10px;
|
||||
color: #0f7a48;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex: 1 1 180px;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.promote-credit-btn:hover { background: #eafaf1; }
|
||||
.promote-credit-btn svg, .promote-pay-btn svg { height: 15px; width: 15px; }
|
||||
|
||||
.promote-packages { border-top: 1px solid #eef2f6; margin-top: 6px; padding-top: 14px; }
|
||||
.promote-packages > strong { color: #24374d; font-size: 13px; }
|
||||
.promote-packages-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
||||
.promote-package {
|
||||
background: #f6f9fc;
|
||||
border: 1.5px solid #dce4ec;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1 1 160px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.promote-package:hover { border-color: #12a764; }
|
||||
.promote-package:disabled { cursor: default; opacity: 0.6; }
|
||||
.promote-package-qty { color: #12a764; font-size: 16px; font-weight: 950; }
|
||||
.promote-package-name { color: #14223a; font-size: 12px; font-weight: 700; }
|
||||
.promote-package-price { color: #14223a; font-weight: 900; margin-top: 4px; }
|
||||
.promote-package-unit { color: #7a8aa0; font-size: 11px; }
|
||||
|
||||
.promote-note { align-items: center; color: #7a8798; display: flex; font-size: 12px; font-weight: 700; gap: 7px; margin: 12px 0 0; }
|
||||
.promote-note svg { height: 13px; width: 13px; }
|
||||
.promote-done { align-items: center; color: #0f7a48; display: flex; font-size: 15px; font-weight: 800; gap: 9px; margin: 16px 0; }
|
||||
.promote-done svg { color: #12a764; height: 20px; width: 20px; }
|
||||
|
||||
/* Przycisk "Promuj" na liscie moich ofert */
|
||||
.promote-action-btn {
|
||||
align-items: center;
|
||||
background: linear-gradient(120deg, #0f2f5b, #12a764);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #ffffff !important;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 850;
|
||||
gap: 6px;
|
||||
}
|
||||
.promote-action-btn svg { height: 14px; width: 14px; }
|
||||
|
||||
/* Odznaki "Wyroznione" */
|
||||
.listing-promoted-tag {
|
||||
align-items: center;
|
||||
background: #eafaf1;
|
||||
border-radius: 999px;
|
||||
color: #0f7a48;
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 850;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
}
|
||||
.listing-promoted-tag svg { height: 12px; width: 12px; }
|
||||
.ld-badge-promoted { align-items: center; background: #12a764; color: #ffffff; display: inline-flex; gap: 5px; }
|
||||
.ld-badge-promoted svg { height: 13px; width: 13px; }
|
||||
.result-promoted-badge {
|
||||
align-items: center;
|
||||
background: #12a764;
|
||||
border-radius: 999px;
|
||||
bottom: 12px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
gap: 5px;
|
||||
left: 12px;
|
||||
padding: 4px 10px;
|
||||
position: absolute;
|
||||
box-shadow: 0 3px 10px rgba(15, 31, 58, 0.22);
|
||||
}
|
||||
.result-promoted-badge svg { height: 12px; width: 12px; }
|
||||
|
||||
.publish-promote-cta {
|
||||
align-items: center;
|
||||
background: linear-gradient(120deg, #0f2f5b 0%, #12a764 130%);
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
gap: 9px;
|
||||
justify-content: center;
|
||||
margin: 4px auto 14px;
|
||||
max-width: 520px;
|
||||
padding: 13px 18px;
|
||||
width: 100%;
|
||||
}
|
||||
.publish-promote-cta:hover { filter: brightness(1.05); }
|
||||
.publish-promote-cta svg { height: 16px; width: 16px; }
|
||||
|
||||
/* Panel admina - listy planow/pakietow */
|
||||
.promo-admin-list { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.promo-admin-row {
|
||||
align-items: center;
|
||||
border: 1px solid #e6edf4;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
padding: 11px 13px;
|
||||
}
|
||||
.promo-admin-row.is-off { opacity: 0.62; }
|
||||
.promo-admin-info { display: flex; flex-direction: column; gap: 2px; margin-right: auto; min-width: 160px; }
|
||||
.promo-admin-info strong { color: #14223a; font-size: 14px; }
|
||||
.promo-admin-info span { color: #5a6b80; font-size: 12px; }
|
||||
.promo-admin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.promo-orders-table td, .promo-orders-table th { white-space: nowrap; }
|
||||
|
||||
/* ===== STRONY PRAWNE (Regulamin, Polityka prywatnosci) ===== */
|
||||
.legal-page { background: #f5f7fa; padding: 30px 0 50px; }
|
||||
.legal-shell {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e3e9f0;
|
||||
border-radius: 16px;
|
||||
margin: 0 auto;
|
||||
max-width: 900px;
|
||||
padding: 34px clamp(20px, 4vw, 44px);
|
||||
width: calc(100% - 2 * var(--workspace-pad));
|
||||
}
|
||||
.legal-head { border-bottom: 1px solid #eef2f6; margin-bottom: 18px; padding-bottom: 16px; }
|
||||
.legal-brand { color: #13243d; font-size: 14px; font-weight: 950; letter-spacing: -0.01em; margin: 0 0 10px; }
|
||||
.legal-brand span { color: #cdd6e2; font-weight: 400; margin: 0 4px; }
|
||||
.legal-head h1 { color: #13243d; font-size: clamp(24px, 3.4vw, 32px); margin: 0 0 8px; }
|
||||
.legal-head > p { color: #4f6277; font-size: 15px; margin: 0; }
|
||||
.legal-updated { color: #8a99ad !important; font-size: 13px !important; font-weight: 700; margin-top: 8px !important; }
|
||||
.legal-body { color: #2b3b52; font-size: 15px; line-height: 1.7; }
|
||||
.legal-body h2 { color: #13243d; font-size: 18px; font-weight: 850; margin: 26px 0 10px; }
|
||||
.legal-body p { margin: 0 0 12px; }
|
||||
.legal-body ul, .legal-body ol { margin: 0 0 14px; padding-left: 22px; }
|
||||
.legal-body li { margin: 6px 0; }
|
||||
.legal-body a { color: #12a764; font-weight: 700; text-decoration: underline; }
|
||||
.legal-note {
|
||||
background: #f6f9fc;
|
||||
border: 1px dashed #cdd9e6;
|
||||
border-radius: 10px;
|
||||
color: #6b7f96;
|
||||
font-size: 13px;
|
||||
margin-top: 24px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
/* ===== STOPKA SERWISU ===== */
|
||||
.site-footer { background: #0f2340; color: #c6d3e4; margin-top: 40px; }
|
||||
.site-footer-inner {
|
||||
display: grid;
|
||||
gap: 32px 40px;
|
||||
grid-template-columns: 1.4fr 3fr;
|
||||
margin: 0 auto;
|
||||
max-width: var(--workspace-max);
|
||||
padding: 42px var(--workspace-pad) 30px;
|
||||
width: 100%;
|
||||
}
|
||||
.footer-brand { max-width: 340px; }
|
||||
.footer-logo { color: #ffffff; font-size: 22px; font-weight: 950; letter-spacing: -0.02em; text-decoration: none; }
|
||||
.footer-logo span { color: #35d38a; }
|
||||
.footer-tagline { color: #a9bace; font-size: 14px; line-height: 1.6; margin: 12px 0 14px; }
|
||||
.footer-badge {
|
||||
background: rgba(53, 211, 138, 0.14);
|
||||
border-radius: 999px;
|
||||
color: #6ee7ad;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.footer-cols { display: grid; gap: 24px 28px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.footer-col { display: flex; flex-direction: column; gap: 9px; min-width: 0; }
|
||||
.footer-col h3 { color: #ffffff; font-size: 13px; font-weight: 850; letter-spacing: 0.04em; margin: 0 0 3px; text-transform: uppercase; }
|
||||
.footer-col a, .footer-link-btn {
|
||||
color: #b7c6d8;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.footer-col a:hover, .footer-link-btn:hover { color: #35d38a; }
|
||||
.footer-link-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.footer-muted { color: #7c90a8; font-size: 13px; }
|
||||
.site-footer-bottom {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
color: #8296ae;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12.5px;
|
||||
gap: 6px 20px;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
max-width: var(--workspace-max);
|
||||
padding: 16px var(--workspace-pad);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-footer-inner { grid-template-columns: 1fr; gap: 26px; }
|
||||
.footer-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.footer-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===== BANER ZGODY NA COOKIE ===== */
|
||||
.cookie-banner {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 14px;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 4000;
|
||||
}
|
||||
.cookie-banner-inner {
|
||||
align-items: center;
|
||||
background: #0f2340;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 40px rgba(9, 20, 38, 0.35);
|
||||
color: #e5edf7;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 22px;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
max-width: 1120px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.cookie-banner-text { flex: 1 1 340px; min-width: 0; }
|
||||
.cookie-banner-text strong { color: #ffffff; display: block; font-size: 15px; margin-bottom: 4px; }
|
||||
.cookie-banner-text p { color: #b7c6d8; font-size: 13px; line-height: 1.55; margin: 0; }
|
||||
.cookie-banner-text a { color: #35d38a; font-weight: 700; text-decoration: underline; }
|
||||
.cookie-banner-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.cookie-btn {
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
padding: 11px 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cookie-btn-ghost { background: transparent; border: 1.5px solid rgba(255, 255, 255, 0.28); color: #dbe6f3; }
|
||||
.cookie-btn-ghost:hover { border-color: rgba(255, 255, 255, 0.5); }
|
||||
.cookie-btn-primary { background: #12a764; border: 0; color: #ffffff; }
|
||||
.cookie-btn-primary:hover { background: #0f8f55; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.cookie-banner-actions { width: 100%; }
|
||||
.cookie-btn { flex: 1 1 auto; text-align: center; }
|
||||
}
|
||||
|
||||
/* --- Proces konta: strony statusu (aktywacja, reset hasla) + okno OTP telefonu --- */
|
||||
.auth-status-page {
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
}
|
||||
.auth-status-card {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e8f0;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 50px rgba(15, 35, 64, 0.10);
|
||||
padding: 40px 36px;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-status-logo {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #0f2340;
|
||||
letter-spacing: -0.01em;
|
||||
text-decoration: none;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.auth-status-logo span { color: #12a764; }
|
||||
.auth-status-card h1 {
|
||||
font-size: 24px;
|
||||
color: #0f2340;
|
||||
margin: 6px 0 12px;
|
||||
}
|
||||
.auth-status-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.auth-status-icon.ok { background: #e3f7ee; color: #12a764; }
|
||||
.auth-status-icon.err { background: #fdecec; color: #d64545; }
|
||||
.auth-status-lead {
|
||||
color: #4a5568;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.auth-status-hint {
|
||||
color: #7a8699;
|
||||
font-size: 13.5px;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
.auth-status-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
text-align: left;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.auth-status-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f2340;
|
||||
}
|
||||
.auth-status-form input {
|
||||
height: 44px;
|
||||
border: 1px solid #d5dce6;
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
color: #0f2340;
|
||||
}
|
||||
.auth-status-form input:focus {
|
||||
outline: none;
|
||||
border-color: #12a764;
|
||||
box-shadow: 0 0 0 3px rgba(18, 167, 100, 0.15);
|
||||
}
|
||||
.auth-status-error {
|
||||
color: #d64545;
|
||||
font-size: 13.5px;
|
||||
margin: 0;
|
||||
}
|
||||
.auth-status-btn {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #12a764;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.auth-status-btn:hover { background: #0f8f56; }
|
||||
.auth-status-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.auth-status-form .auth-status-btn { margin-top: 6px; }
|
||||
|
||||
/* Okno OTP telefonu (na wspolnym backdropie .listing-delete-overlay) */
|
||||
.otp-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 24px 60px rgba(15, 35, 64, 0.28);
|
||||
padding: 34px 30px 26px;
|
||||
text-align: center;
|
||||
}
|
||||
.otp-modal h2 {
|
||||
font-size: 21px;
|
||||
color: #0f2340;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.otp-modal-icon { font-size: 38px; line-height: 1; }
|
||||
.otp-modal-lead {
|
||||
color: #4a5568;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.otp-modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.otp-input {
|
||||
height: 58px;
|
||||
border: 1px solid #d5dce6;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
color: #0f2340;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.otp-input:focus {
|
||||
outline: none;
|
||||
border-color: #12a764;
|
||||
box-shadow: 0 0 0 3px rgba(18, 167, 100, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
.otp-modal-ok { color: #12a764; font-size: 13.5px; margin: 0; }
|
||||
.otp-modal-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.otp-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #12a764;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.otp-link:disabled { color: #9aa6b6; cursor: default; }
|
||||
.otp-link:hover:not(:disabled) { text-decoration: underline; }
|
||||
|
||||
/* Login: komunikat informacyjny, ponowne wyslanie linku, akcje dodatkowe */
|
||||
.login-info {
|
||||
background: #e3f7ee;
|
||||
color: #0f7a4d;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.login-resend-activation,
|
||||
.login-secondary-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.login-resend-activation {
|
||||
background: #fff;
|
||||
border: 1px solid #12a764;
|
||||
color: #12a764;
|
||||
}
|
||||
.login-resend-activation:hover { background: #f0fbf5; }
|
||||
.login-secondary-btn {
|
||||
background: #eef2f8;
|
||||
border: 1px solid #d5dce6;
|
||||
color: #0f2340;
|
||||
}
|
||||
.login-secondary-btn:hover { background: #e3e9f2; }
|
||||
.login-back-link {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #12a764;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-back-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Rejestracja firmy: pobranie danych z rejestru REGON (GUS) przyciskiem obok pola NIP */
|
||||
/* stretch: przycisk zawsze rowny wysokosci pola, takze w widoku mobilnym */
|
||||
.login-field-gus > div {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.login-field-gus input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-right: 14px;
|
||||
}
|
||||
.gus-fetch-btn {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #12a764;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #12a764;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.login-field-hint {
|
||||
color: #647487;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin: -6px 0 0;
|
||||
}
|
||||
.gus-fetch-btn:hover:not(:disabled) { background: #f0fbf5; }
|
||||
.gus-fetch-btn:disabled {
|
||||
border-color: #d8e0ea;
|
||||
color: #9aa6b6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user