firmy i usługi plus ustawiaenia
This commit is contained in:
@@ -5,6 +5,7 @@ import java.util.List;
|
|||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -33,6 +34,8 @@ import pl.polskalokalnie.report.ListingReportService;
|
|||||||
import pl.polskalokalnie.report.ResolveListingReportRequest;
|
import pl.polskalokalnie.report.ResolveListingReportRequest;
|
||||||
import pl.polskalokalnie.report.UserReportResponse;
|
import pl.polskalokalnie.report.UserReportResponse;
|
||||||
import pl.polskalokalnie.report.UserReportService;
|
import pl.polskalokalnie.report.UserReportService;
|
||||||
|
import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
|
||||||
|
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
|
||||||
import pl.polskalokalnie.support.SupportMessageResponse;
|
import pl.polskalokalnie.support.SupportMessageResponse;
|
||||||
import pl.polskalokalnie.support.SupportMessageService;
|
import pl.polskalokalnie.support.SupportMessageService;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
@@ -55,6 +58,7 @@ public class AdminController {
|
|||||||
private final ListingReportService listingReportService;
|
private final ListingReportService listingReportService;
|
||||||
private final UserReportService userReportService;
|
private final UserReportService userReportService;
|
||||||
private final SupportMessageService supportMessageService;
|
private final SupportMessageService supportMessageService;
|
||||||
|
private final AdvertisingEnquiryService advertisingEnquiryService;
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final TextModerationService textModerationService;
|
private final TextModerationService textModerationService;
|
||||||
private final AdminStatsService adminStatsService;
|
private final AdminStatsService adminStatsService;
|
||||||
@@ -65,6 +69,7 @@ public class AdminController {
|
|||||||
ListingService listingService, ListingReportService listingReportService,
|
ListingService listingService, ListingReportService listingReportService,
|
||||||
UserReportService userReportService,
|
UserReportService userReportService,
|
||||||
SupportMessageService supportMessageService,
|
SupportMessageService supportMessageService,
|
||||||
|
AdvertisingEnquiryService advertisingEnquiryService,
|
||||||
MessageService messageService,
|
MessageService messageService,
|
||||||
TextModerationService textModerationService,
|
TextModerationService textModerationService,
|
||||||
AdminStatsService adminStatsService,
|
AdminStatsService adminStatsService,
|
||||||
@@ -76,6 +81,7 @@ public class AdminController {
|
|||||||
this.listingReportService = listingReportService;
|
this.listingReportService = listingReportService;
|
||||||
this.userReportService = userReportService;
|
this.userReportService = userReportService;
|
||||||
this.supportMessageService = supportMessageService;
|
this.supportMessageService = supportMessageService;
|
||||||
|
this.advertisingEnquiryService = advertisingEnquiryService;
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.textModerationService = textModerationService;
|
this.textModerationService = textModerationService;
|
||||||
this.adminStatsService = adminStatsService;
|
this.adminStatsService = adminStatsService;
|
||||||
@@ -310,6 +316,28 @@ public class AdminController {
|
|||||||
return supportMessageService.setHandled(id, handled, authentication.getName());
|
return supportMessageService.setHandled(id, handled, authentication.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Zgloszenia z formularza reklamowego (/reklama) ---
|
||||||
|
|
||||||
|
@GetMapping("/advertising-enquiries")
|
||||||
|
public List<AdvertisingEnquiryResponse> advertisingEnquiries() {
|
||||||
|
return advertisingEnquiryService.findAllForAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/advertising-enquiries/{id}/handled")
|
||||||
|
public AdvertisingEnquiryResponse setAdvertisingEnquiryHandled(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "true") boolean handled,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
return advertisingEnquiryService.setHandled(id, handled, authentication.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/advertising-enquiries/{id}")
|
||||||
|
public ResponseEntity<Void> deleteAdvertisingEnquiry(@PathVariable Long id) {
|
||||||
|
advertisingEnquiryService.delete(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Slowa zabronione ---
|
// --- Slowa zabronione ---
|
||||||
|
|
||||||
@GetMapping("/forbidden-words")
|
@GetMapping("/forbidden-words")
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public class SecurityConfig {
|
|||||||
"/api/auth/forgot-password", "/api/auth/reset-password",
|
"/api/auth/forgot-password", "/api/auth/reset-password",
|
||||||
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
|
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
|
||||||
.requestMatchers("/api/i18n/translate").permitAll()
|
.requestMatchers("/api/i18n/translate").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||||
.requestMatchers("/api/auth/me").authenticated()
|
.requestMatchers("/api/auth/me").authenticated()
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package pl.polskalokalnie.fx;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kursy walut dla frontendu. Endpoint jest publiczny, bo ceny widza takze niezalogowani -
|
||||||
|
* odpowiedz nie zawiera zadnych danych uzytkownika.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/fx")
|
||||||
|
public class FxController {
|
||||||
|
|
||||||
|
private final FxRateService fxRateService;
|
||||||
|
|
||||||
|
public FxController(FxRateService fxRateService) {
|
||||||
|
this.fxRateService = fxRateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rates")
|
||||||
|
public FxRatesResponse rates() {
|
||||||
|
return fxRateService.currentRates();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package pl.polskalokalnie.fx;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import pl.polskalokalnie.settings.AppSetting;
|
||||||
|
import pl.polskalokalnie.settings.AppSettingRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kursy walut dla ustawienia "Waluta" z /konto/ustawienia.
|
||||||
|
*
|
||||||
|
* Zrodlem jest tabela A NBP (kursy srednie), odpytywana najwyzej raz na REFRESH_INTERVAL.
|
||||||
|
* Ostatnia udana odpowiedz laduje w app_settings, wiec po restarcie aplikacji nadal mamy realny
|
||||||
|
* kurs, nawet jesli NBP akurat nie odpowiada. Dopiero gdy i tego nie ma, wchodza wartosci zapasowe -
|
||||||
|
* sa przyblizone i sluza wylacznie temu, zeby interfejs nie pokazywal pustych cen.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class FxRateService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FxRateService.class);
|
||||||
|
|
||||||
|
private static final String NBP_TABLE_A_URL = "https://api.nbp.pl/api/exchangerates/tables/A?format=json";
|
||||||
|
private static final Duration REFRESH_INTERVAL = Duration.ofHours(6);
|
||||||
|
private static final String CACHE_KEY = "fx.nbp.table-a";
|
||||||
|
|
||||||
|
/** Waluty, ktore uzytkownik moze wybrac (zgodnie z enumem Currency). */
|
||||||
|
private static final String[] SUPPORTED = {"EUR", "USD"};
|
||||||
|
|
||||||
|
/** Awaryjne kursy uzywane tylko wtedy, gdy NBP nie odpowiedzialo i nie mamy nic w bazie. */
|
||||||
|
private static final Map<String, BigDecimal> FALLBACK_RATES = Map.of(
|
||||||
|
"EUR", new BigDecimal("4.30"),
|
||||||
|
"USD", new BigDecimal("3.95"));
|
||||||
|
|
||||||
|
private final AppSettingRepository appSettingRepository;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private volatile FxRatesResponse cached;
|
||||||
|
private volatile Instant fetchedAt;
|
||||||
|
|
||||||
|
public FxRateService(AppSettingRepository appSettingRepository) {
|
||||||
|
this.appSettingRepository = appSettingRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FxRatesResponse currentRates() {
|
||||||
|
FxRatesResponse snapshot = cached;
|
||||||
|
if (snapshot != null && fetchedAt != null
|
||||||
|
&& Duration.between(fetchedAt, Instant.now()).compareTo(REFRESH_INTERVAL) < 0) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
return refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized FxRatesResponse refresh() {
|
||||||
|
try {
|
||||||
|
FxRatesResponse fresh = fetchFromNbp();
|
||||||
|
cached = fresh;
|
||||||
|
fetchedAt = Instant.now();
|
||||||
|
store(fresh);
|
||||||
|
return fresh;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Nie udalo sie pobrac kursow z NBP: {}", ex.getMessage());
|
||||||
|
// Kolejna probe podejmiemy dopiero po REFRESH_INTERVAL, zeby nie dobijac sie do NBP przy kazdym zadaniu.
|
||||||
|
FxRatesResponse fallback = cached != null ? cached : readStored();
|
||||||
|
cached = fallback;
|
||||||
|
fetchedAt = Instant.now();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FxRatesResponse fetchFromNbp() throws Exception {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(NBP_TABLE_A_URL))
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IllegalStateException("NBP odpowiedzialo kodem " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode table = objectMapper.readTree(response.body());
|
||||||
|
if (!table.isArray() || table.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Nieoczekiwany format odpowiedzi NBP");
|
||||||
|
}
|
||||||
|
JsonNode first = table.get(0);
|
||||||
|
String effectiveDate = first.path("effectiveDate").asText("");
|
||||||
|
|
||||||
|
Map<String, BigDecimal> rates = baseRates();
|
||||||
|
for (JsonNode rate : first.path("rates")) {
|
||||||
|
String code = rate.path("code").asText("");
|
||||||
|
for (String supported : SUPPORTED) {
|
||||||
|
if (supported.equals(code)) {
|
||||||
|
rates.put(code, rate.path("mid").decimalValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rates.size() != SUPPORTED.length + 1) {
|
||||||
|
throw new IllegalStateException("Brak kompletu kursow w tabeli NBP");
|
||||||
|
}
|
||||||
|
return new FxRatesResponse("PLN", rates, effectiveDate, "NBP");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zapis ostatnich udanych kursow, zeby przetrwaly restart aplikacji. */
|
||||||
|
private void store(FxRatesResponse rates) {
|
||||||
|
try {
|
||||||
|
String payload = objectMapper.writeValueAsString(rates);
|
||||||
|
AppSetting setting = appSettingRepository.findById(CACHE_KEY).orElse(null);
|
||||||
|
if (setting == null) {
|
||||||
|
appSettingRepository.save(new AppSetting(CACHE_KEY, payload));
|
||||||
|
} else {
|
||||||
|
setting.setSettingValue(payload);
|
||||||
|
appSettingRepository.save(setting);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Nie udalo sie zapisac kursow walut: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FxRatesResponse readStored() {
|
||||||
|
try {
|
||||||
|
AppSetting setting = appSettingRepository.findById(CACHE_KEY).orElse(null);
|
||||||
|
if (setting != null) {
|
||||||
|
FxRatesResponse stored = objectMapper.readValue(setting.getSettingValue(), FxRatesResponse.class);
|
||||||
|
if (stored.rates() != null && !stored.rates().isEmpty()) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Nie udalo sie odczytac zapisanych kursow: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FxRatesResponse fallback() {
|
||||||
|
Map<String, BigDecimal> rates = baseRates();
|
||||||
|
rates.putAll(FALLBACK_RATES);
|
||||||
|
return new FxRatesResponse("PLN", rates, "", "FALLBACK");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, BigDecimal> baseRates() {
|
||||||
|
Map<String, BigDecimal> rates = new LinkedHashMap<>();
|
||||||
|
rates.put("PLN", BigDecimal.ONE);
|
||||||
|
return rates;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pl.polskalokalnie.fx;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kursy przeliczeniowe dla cen prezentowanych uzytkownikowi.
|
||||||
|
*
|
||||||
|
* Ceny w bazie sa trzymane w zlotowkach, wiec PLN jest waluta bazowa. Kazdy wpis w `rates` mowi,
|
||||||
|
* ile zlotowek kosztuje jedna jednostka danej waluty (tak samo jak kurs sredni NBP z tabeli A),
|
||||||
|
* czyli przeliczenie PLN -> waluta to dzielenie przez ten kurs.
|
||||||
|
*
|
||||||
|
* @param source `NBP` gdy kursy pochodza z api.nbp.pl, `FALLBACK` gdy uzyto wartosci zapasowych
|
||||||
|
*/
|
||||||
|
public record FxRatesResponse(
|
||||||
|
String base,
|
||||||
|
Map<String, BigDecimal> rates,
|
||||||
|
String effectiveDate,
|
||||||
|
String source
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package pl.polskalokalnie.lead;
|
||||||
|
|
||||||
|
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.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pojedyncze zgloszenie z formularza "Zainteresowany reklama?" na stronie /reklama.
|
||||||
|
*
|
||||||
|
* Trzymamy je osobno od leada, bo to dwie rozne rzeczy: lead to KONTAKT (jeden na osobe, zasila
|
||||||
|
* kampanie), a zgloszenie to ZDARZENIE - ta sama firma moze napisac kilka razy o rozne kategorie i
|
||||||
|
* kazda wiadomosc ma zostac czytelna w panelu. Upychanie tresci w notatce leada gubiloby daty,
|
||||||
|
* kolejnosc i mozliwosc oznaczenia pojedynczej wiadomosci jako obsluzonej.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "advertising_enquiries")
|
||||||
|
public class AdvertisingEnquiry {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String fullName;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 160)
|
||||||
|
private String companyName;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 160)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 40)
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 80)
|
||||||
|
private String serviceCategory;
|
||||||
|
|
||||||
|
@Column(length = 2000)
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean handled = false;
|
||||||
|
|
||||||
|
private Instant handledAt;
|
||||||
|
|
||||||
|
@Column(length = 180)
|
||||||
|
private String handledByEmail;
|
||||||
|
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFullName() {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFullName(String fullName) {
|
||||||
|
this.fullName = fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCompanyName() {
|
||||||
|
return companyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCompanyName(String companyName) {
|
||||||
|
this.companyName = companyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmail(String email) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhone() {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhone(String phone) {
|
||||||
|
this.phone = phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServiceCategory() {
|
||||||
|
return serviceCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServiceCategory(String serviceCategory) {
|
||||||
|
this.serviceCategory = serviceCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHandled() {
|
||||||
|
return handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHandled(boolean handled) {
|
||||||
|
this.handled = handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getHandledAt() {
|
||||||
|
return handledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHandledAt(Instant handledAt) {
|
||||||
|
this.handledAt = handledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHandledByEmail() {
|
||||||
|
return handledByEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHandledByEmail(String handledByEmail) {
|
||||||
|
this.handledByEmail = handledByEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pl.polskalokalnie.lead;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
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.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiczny formularz zainteresowania reklama (strona /reklama).
|
||||||
|
*
|
||||||
|
* Endpoint nie zwraca tresci (204, jak pozostale akcje bez wyniku w tym API) - front pokazuje
|
||||||
|
* wlasne potwierdzenie. Nie odpowiadamy tez, czy dany
|
||||||
|
* kontakt byl juz w bazie, zeby formularz nie stal sie sposobem na sprawdzanie, kto sie zglaszal.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/advertising-enquiries")
|
||||||
|
public class AdvertisingEnquiryController {
|
||||||
|
|
||||||
|
private final AdvertisingEnquiryService advertisingEnquiryService;
|
||||||
|
|
||||||
|
public AdvertisingEnquiryController(AdvertisingEnquiryService advertisingEnquiryService) {
|
||||||
|
this.advertisingEnquiryService = advertisingEnquiryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void submit(@Valid @RequestBody AdvertisingEnquiryRequest request) {
|
||||||
|
advertisingEnquiryService.register(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pl.polskalokalnie.lead;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AdvertisingEnquiryRepository extends JpaRepository<AdvertisingEnquiry, Long> {
|
||||||
|
|
||||||
|
List<AdvertisingEnquiry> findAllByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
long countByHandledFalse();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package pl.polskalokalnie.lead;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zgloszenie z formularza "Zainteresowany reklama?" na stronie /reklama.
|
||||||
|
*
|
||||||
|
* Formularz jest publiczny - pokazujemy go, gdy uzytkownik wejdzie w kategorie uslug, ktorej
|
||||||
|
* jeszcze nie obslugujemy. Zgloszenie laduje w bazie leadow ze zrodlem FORM, wiec obsluguje je
|
||||||
|
* ten sam panel marketingowy co pozostale kontakty.
|
||||||
|
*/
|
||||||
|
public record AdvertisingEnquiryRequest(
|
||||||
|
@NotBlank(message = "Podaj imię i nazwisko") @Size(max = 120) String fullName,
|
||||||
|
@NotBlank(message = "Podaj nazwę firmy") @Size(max = 160) String companyName,
|
||||||
|
@NotBlank(message = "Podaj adres e-mail") @Email(message = "Podaj poprawny adres e-mail") @Size(max = 160) String email,
|
||||||
|
@NotBlank(message = "Podaj numer telefonu") @Size(max = 40) String phone,
|
||||||
|
@NotBlank(message = "Wybierz rodzaj usługi") @Size(max = 80) String serviceCategory,
|
||||||
|
@Size(max = 2000) String message,
|
||||||
|
// Bez zgody nie mamy podstawy, zeby oddzwonic z oferta - dlatego to pole wymagane, a nie opcjonalne.
|
||||||
|
@AssertTrue(message = "Potrzebujemy zgody na kontakt") Boolean contactConsent
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pl.polskalokalnie.lead;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Zgloszenie reklamowe w postaci pokazywanej w panelu administratora. */
|
||||||
|
public record AdvertisingEnquiryResponse(
|
||||||
|
Long id,
|
||||||
|
String fullName,
|
||||||
|
String companyName,
|
||||||
|
String email,
|
||||||
|
String phone,
|
||||||
|
String serviceCategory,
|
||||||
|
String message,
|
||||||
|
boolean handled,
|
||||||
|
Instant handledAt,
|
||||||
|
String handledByEmail,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
public static AdvertisingEnquiryResponse from(AdvertisingEnquiry enquiry) {
|
||||||
|
return new AdvertisingEnquiryResponse(
|
||||||
|
enquiry.getId(),
|
||||||
|
enquiry.getFullName(),
|
||||||
|
enquiry.getCompanyName(),
|
||||||
|
enquiry.getEmail(),
|
||||||
|
enquiry.getPhone(),
|
||||||
|
enquiry.getServiceCategory(),
|
||||||
|
enquiry.getMessage(),
|
||||||
|
enquiry.isHandled(),
|
||||||
|
enquiry.getHandledAt(),
|
||||||
|
enquiry.getHandledByEmail(),
|
||||||
|
enquiry.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package pl.polskalokalnie.lead;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zgloszenia z publicznego formularza reklamowego (/reklama).
|
||||||
|
*
|
||||||
|
* Jedno zgloszenie trafia w dwa miejsca i kazde ma inne zadanie:
|
||||||
|
* - `advertising_enquiries` to lista wiadomosci dla administratora (osobna zakladka w panelu),
|
||||||
|
* - lead to kontakt w bazie marketingowej, ktory zasila kampanie.
|
||||||
|
* Zapis leada jest celowo "miekki" - blad synchronizacji nie moze skasowac wiadomosci, ktora
|
||||||
|
* uzytkownik wlasnie wyslal.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AdvertisingEnquiryService {
|
||||||
|
|
||||||
|
private final AdvertisingEnquiryRepository repository;
|
||||||
|
private final LeadService leadService;
|
||||||
|
|
||||||
|
public AdvertisingEnquiryService(AdvertisingEnquiryRepository repository, LeadService leadService) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.leadService = leadService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void register(AdvertisingEnquiryRequest request) {
|
||||||
|
AdvertisingEnquiry enquiry = new AdvertisingEnquiry();
|
||||||
|
enquiry.setFullName(request.fullName().trim());
|
||||||
|
enquiry.setCompanyName(request.companyName().trim());
|
||||||
|
enquiry.setEmail(request.email().trim());
|
||||||
|
enquiry.setPhone(request.phone().trim());
|
||||||
|
enquiry.setServiceCategory(request.serviceCategory().trim());
|
||||||
|
enquiry.setMessage(request.message() == null || request.message().isBlank() ? null : request.message().trim());
|
||||||
|
repository.save(enquiry);
|
||||||
|
|
||||||
|
leadService.registerAdvertisingEnquiry(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AdvertisingEnquiryResponse> findAllForAdmin() {
|
||||||
|
return repository.findAllByOrderByCreatedAtDesc().stream().map(AdvertisingEnquiryResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oznaczenie zgloszenia jako obsluzonego (lub cofniecie tego oznaczenia). */
|
||||||
|
@Transactional
|
||||||
|
public AdvertisingEnquiryResponse setHandled(Long id, boolean handled, String adminEmail) {
|
||||||
|
AdvertisingEnquiry enquiry = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zgłoszenie nie istnieje"));
|
||||||
|
enquiry.setHandled(handled);
|
||||||
|
enquiry.setHandledAt(handled ? Instant.now() : null);
|
||||||
|
enquiry.setHandledByEmail(handled ? adminEmail : null);
|
||||||
|
return AdvertisingEnquiryResponse.from(repository.save(enquiry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
AdvertisingEnquiry enquiry = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zgłoszenie nie istnieje"));
|
||||||
|
repository.delete(enquiry);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -19,6 +20,12 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
@Service
|
@Service
|
||||||
public class LeadService {
|
public class LeadService {
|
||||||
|
|
||||||
|
/** Tag, po ktorym w panelu marketingowym poznajemy zgloszenia z formularza reklamowego. */
|
||||||
|
private static final String ADVERTISING_TAG = "reklama";
|
||||||
|
|
||||||
|
/** Limit kolumny `notes` w bazie - dluzszy tekst nie zapisze sie i wywolalby blad 500. */
|
||||||
|
private static final int NOTES_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
private final LeadRepository leadRepository;
|
private final LeadRepository leadRepository;
|
||||||
|
|
||||||
public LeadService(LeadRepository leadRepository) {
|
public LeadService(LeadRepository leadRepository) {
|
||||||
@@ -191,6 +198,87 @@ public class LeadService {
|
|||||||
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
|
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zgloszenie z publicznego formularza reklamowego (/reklama).
|
||||||
|
*
|
||||||
|
* W odroznieniu od recznego dodawania leada powtorzony kontakt NIE jest bledem - ktos moze
|
||||||
|
* zapytac o kilka kategorii uslug. Dopisujemy wtedy nowe zgloszenie do notatki i dokladamy
|
||||||
|
* tag kategorii, zamiast odrzucac formularz komunikatem, ze taki lead juz istnieje.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void registerAdvertisingEnquiry(AdvertisingEnquiryRequest request) {
|
||||||
|
String email = normalizeEmail(request.email());
|
||||||
|
String phone = normalizePhone(request.phone());
|
||||||
|
boolean consent = Boolean.TRUE.equals(request.contactConsent());
|
||||||
|
|
||||||
|
Lead lead = (email == null ? Optional.<Lead>empty() : leadRepository.findFirstByEmailIgnoreCase(email))
|
||||||
|
.or(() -> phone == null ? Optional.<Lead>empty() : leadRepository.findFirstByPhone(phone))
|
||||||
|
.orElseGet(Lead::new);
|
||||||
|
|
||||||
|
if (lead.getId() == null) {
|
||||||
|
lead.setSource(LeadSource.FORM);
|
||||||
|
lead.setEmail(email);
|
||||||
|
lead.setPhone(phone);
|
||||||
|
} else {
|
||||||
|
// Trafilismy w istniejacy kontakt - uzupelniamy tylko brakujace pola, zeby zgloszenie
|
||||||
|
// nie nadpisalo danych zebranych wczesniej. Rozbieznosci ladują w notatce (nizej),
|
||||||
|
// wiec podany w formularzu adres czy numer nigdy nie przepada.
|
||||||
|
if (lead.getEmail() == null) {
|
||||||
|
lead.setEmail(email);
|
||||||
|
}
|
||||||
|
if (lead.getPhone() == null) {
|
||||||
|
lead.setPhone(phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lead.setName(trimToNull(request.fullName()));
|
||||||
|
|
||||||
|
List<String> tags = new ArrayList<>(lead.getTags());
|
||||||
|
tags.add(ADVERTISING_TAG);
|
||||||
|
tags.add(trimToNull(request.serviceCategory()));
|
||||||
|
lead.setTags(cleanTags(tags));
|
||||||
|
|
||||||
|
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
|
||||||
|
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
|
||||||
|
lead.setEmailConsent(consent);
|
||||||
|
lead.setSmsConsent(consent);
|
||||||
|
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
|
||||||
|
leadRepository.save(lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dokleja nowe zgloszenie na koncu notatki leada.
|
||||||
|
*
|
||||||
|
* Kolumna `notes` ma 2000 znakow, a ten sam kontakt moze zglosic sie wielokrotnie - dlatego przy
|
||||||
|
* przekroczeniu limitu obcinamy najstarszy fragment. Publiczny formularz nie moze wywrocic sie
|
||||||
|
* na bledzie bazy tylko dlatego, ze ktos pisze po raz dziesiaty.
|
||||||
|
*/
|
||||||
|
private static String appendEnquiryNote(String existingNotes, AdvertisingEnquiryRequest request,
|
||||||
|
Lead lead, String email, String phone) {
|
||||||
|
StringBuilder note = new StringBuilder("Zgłoszenie reklamowe");
|
||||||
|
note.append("\nFirma: ").append(trimToNull(request.companyName()));
|
||||||
|
note.append("\nRodzaj usługi: ").append(trimToNull(request.serviceCategory()));
|
||||||
|
// Kontakt zapisujemy tylko wtedy, gdy rozni sie od tego na leadzie - inaczej dublowalby
|
||||||
|
// kolumny, a przy rozbieznosci to jedyne miejsce, w ktorym dane z formularza przetrwaja.
|
||||||
|
if (email != null && !email.equalsIgnoreCase(lead.getEmail())) {
|
||||||
|
note.append("\nE-mail z formularza: ").append(email);
|
||||||
|
}
|
||||||
|
if (phone != null && !phone.equals(lead.getPhone())) {
|
||||||
|
note.append("\nTelefon z formularza: ").append(phone);
|
||||||
|
}
|
||||||
|
String message = trimToNull(request.message());
|
||||||
|
if (message != null) {
|
||||||
|
note.append("\nWiadomość: ").append(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
String previous = trimToNull(existingNotes);
|
||||||
|
String combined = previous == null ? note.toString() : previous + "\n\n" + note;
|
||||||
|
if (combined.length() <= NOTES_MAX_LENGTH) {
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
return combined.substring(combined.length() - NOTES_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<String> cleanTags(List<String> tags) {
|
private static List<String> cleanTags(List<String> tags) {
|
||||||
List<String> cleaned = new ArrayList<>();
|
List<String> cleaned = new ArrayList<>();
|
||||||
if (tags != null) {
|
if (tags != null) {
|
||||||
|
|||||||
@@ -26,13 +26,18 @@ public class ListingController {
|
|||||||
this.listingService = listingService;
|
this.listingService = listingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param directOnly zostaw wylacznie oferty bezposrednie, czyli wystawione z kont osob
|
||||||
|
* prywatnych (ustawienie "Pokaz tylko oferty bezposrednie" w /konto/ustawienia)
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<ListingResponse> search(
|
public List<ListingResponse> search(
|
||||||
@RequestParam(required = false) String city,
|
@RequestParam(required = false) String city,
|
||||||
@RequestParam(required = false) OfferType offerType,
|
@RequestParam(required = false) OfferType offerType,
|
||||||
@RequestParam(required = false) PropertyType propertyType
|
@RequestParam(required = false) PropertyType propertyType,
|
||||||
|
@RequestParam(defaultValue = "false") boolean directOnly
|
||||||
) {
|
) {
|
||||||
return listingService.search(city, offerType, propertyType);
|
return listingService.search(city, offerType, propertyType, directOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/mine")
|
@GetMapping("/mine")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
import pl.polskalokalnie.notification.NotificationService;
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
|
import pl.polskalokalnie.user.AccountType;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
import pl.polskalokalnie.user.UserSettingsService;
|
import pl.polskalokalnie.user.UserSettingsService;
|
||||||
@@ -86,17 +87,30 @@ public class ListingService {
|
|||||||
.thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder());
|
.thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder());
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
|
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType, boolean directOnly) {
|
||||||
return listingRepository.findAll().stream()
|
return listingRepository.findAll().stream()
|
||||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||||
.filter(listing -> city == null || normalizeCity(listing.getCity()).equals(normalizeCity(city)))
|
.filter(listing -> city == null || normalizeCity(listing.getCity()).equals(normalizeCity(city)))
|
||||||
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
||||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||||
|
.filter(listing -> !directOnly || isDirectOffer(listing))
|
||||||
.sorted(promotedFirst())
|
.sorted(promotedFirst())
|
||||||
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oferta bezposrednia to taka, ktora wystawilo konto osoby prywatnej.
|
||||||
|
*
|
||||||
|
* Ogloszenia bez konta w serwisie (np. wpisy demonstracyjne z samym adresem e-mail) NIE sa
|
||||||
|
* uznawane za bezposrednie - skoro nie da sie potwierdzic, ze stoi za nimi osoba prywatna, to
|
||||||
|
* pokazanie ich pod filtrem obiecujacym "tylko od osob prywatnych" wprowadzaloby w blad.
|
||||||
|
*/
|
||||||
|
private boolean isDirectOffer(PropertyListing listing) {
|
||||||
|
AppUser owner = ownerOf(listing);
|
||||||
|
return owner != null && owner.getAccountType() == AccountType.PERSONAL;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Szczegoly ogloszenia. Publicznie dostepne sa wylacznie ogloszenia opublikowane - wstrzymane,
|
* Szczegoly ogloszenia. Publicznie dostepne sa wylacznie ogloszenia opublikowane - wstrzymane,
|
||||||
* oczekujace i odrzucone widzi tylko wlasciciel i administracja, takze przy wejsciu z linku.
|
* oczekujace i odrzucone widzi tylko wlasciciel i administracja, takze przy wejsciu z linku.
|
||||||
|
|||||||
+1223
-355
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ export type UserSettings = {
|
|||||||
currency: Currency;
|
currency: Currency;
|
||||||
areaUnit: AreaUnit;
|
areaUnit: AreaUnit;
|
||||||
directOffersOnly: boolean;
|
directOffersOnly: boolean;
|
||||||
hideInactiveOffers: boolean;
|
|
||||||
saveSearchesOnHome: boolean;
|
saveSearchesOnHome: boolean;
|
||||||
notifySavedSearches: boolean;
|
notifySavedSearches: boolean;
|
||||||
notifyPriceAlerts: boolean;
|
notifyPriceAlerts: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { apiFetch, useAuth } from './auth';
|
||||||
|
import type { AreaUnit, Currency, UserSettings } from './auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jednostki prezentacji cen i powierzchni.
|
||||||
|
*
|
||||||
|
* Baza trzyma ceny w zlotowkach, a powierzchnie w metrach kwadratowych - to sa wartosci zrodlowe i
|
||||||
|
* nigdy ich nie zmieniamy. Uzytkownik wybiera w /konto/ustawienia jedynie sposob WYSWIETLANIA, a
|
||||||
|
* cala konwersja dzieje sie tutaj, tuz przed pokazaniem liczby. Dzieki temu zmiana waluty nie
|
||||||
|
* dotyka danych i da sie ja cofnac bez zadnych skutkow ubocznych.
|
||||||
|
*
|
||||||
|
* Swiadomie NIE przeliczamy: platnosci za promowanie ofert (AutoPay realnie pobiera zlotowki),
|
||||||
|
* panelu administratora (raportowanie wewnetrzne) oraz pol, w ktorych uzytkownik wpisuje cene przy
|
||||||
|
* dodawaniu oferty - wpis zostaje w PLN/m2, zeby nie zapisac w bazie kwoty po kursie z jednego dnia.
|
||||||
|
*
|
||||||
|
* Obok jednostek trzymamy tu takze pozostale preferencje z tego samego zapytania o ustawienia konta
|
||||||
|
* (`/me/settings`), ktore decyduja o tym, CO uzytkownik widzi: filtr ofert bezposrednich i sekcje
|
||||||
|
* "Ostatnio przegladane". Dzieki temu ustawienia sa pobierane raz, a nie osobno w kazdym widoku.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FxRates = {
|
||||||
|
base: string;
|
||||||
|
rates: Record<string, number>;
|
||||||
|
effectiveDate: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 1 m2 = 10.763910417 ft2 (definicja stopy miedzynarodowej). */
|
||||||
|
const FT2_PER_M2 = 10.763910417;
|
||||||
|
|
||||||
|
const CURRENCY_FALLBACK: Record<Currency, number> = { PLN: 1, EUR: 4.3, USD: 3.95 };
|
||||||
|
|
||||||
|
export const AREA_SUFFIX: Record<AreaUnit, string> = { M2: 'm²', FT2: 'ft²' };
|
||||||
|
|
||||||
|
type DisplayContextValue = {
|
||||||
|
currency: Currency;
|
||||||
|
areaUnit: AreaUnit;
|
||||||
|
/** Skrot jednostki powierzchni do budowania wlasnych etykiet, np. "cena za m²". */
|
||||||
|
areaSuffix: string;
|
||||||
|
fx: FxRates | null;
|
||||||
|
/** Kwota w PLN przeliczona na wybrana walute (bez formatowania). */
|
||||||
|
convertPrice: (pln: number) => number;
|
||||||
|
/** Powierzchnia w m2 przeliczona na wybrana jednostke (bez formatowania). */
|
||||||
|
convertArea: (squareMeters: number) => number;
|
||||||
|
/**
|
||||||
|
* Konwersja odwrotna: liczba wpisana przez uzytkownika w jego jednostce -> metry kwadratowe.
|
||||||
|
* Potrzebna w kalkulatorach, ktore licza na bazach cen za m2, a pole formularza pokazuje ft².
|
||||||
|
*/
|
||||||
|
toSquareMeters: (valueInUserUnit: number) => number;
|
||||||
|
/** Cena z jednostka, np. "749 000 zł" albo "173 111 €". */
|
||||||
|
formatPrice: (pln: number) => string;
|
||||||
|
/** Cena miesieczna, np. "3 200 zł / miesiąc". */
|
||||||
|
formatMonthlyPrice: (pln: number) => string;
|
||||||
|
/** Cena za jednostke powierzchni, np. "12 081 zł/m²" albo "259 $/ft²". */
|
||||||
|
formatPricePerArea: (plnPerSquareMeter: number) => string;
|
||||||
|
/** Sama powierzchnia, np. "62 m²" albo "667 ft²". */
|
||||||
|
formatArea: (squareMeters: number) => string;
|
||||||
|
/** Liczba bez jednostki, w wybranej walucie - do miejsc, ktore dokladaja wlasny sufiks. */
|
||||||
|
formatPriceNumber: (pln: number) => string;
|
||||||
|
/** Sama liczba stawki za jednostke powierzchni - do zakresow typu "12 081 - 15 533". */
|
||||||
|
formatPricePerAreaNumber: (plnPerSquareMeter: number) => string;
|
||||||
|
/** Liczba bez jednostki, w wybranej jednostce powierzchni. */
|
||||||
|
formatAreaNumber: (squareMeters: number) => string;
|
||||||
|
/** Pokazuj wylacznie oferty od osob prywatnych (konta PERSONAL), z pominieciem firm. */
|
||||||
|
directOffersOnly: boolean;
|
||||||
|
/** Czy strona glowna ma pokazywac sekcje "Ostatnio przegladane". */
|
||||||
|
showRecentlyViewedOnHome: boolean;
|
||||||
|
/** Przyjmuje ustawienia zapisane w /konto/ustawienia, zeby widok zmienil sie od razu. */
|
||||||
|
applySettings: (settings: DisplaySettings) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fragment ustawien konta, ktory realnie steruje prezentacja i doborem ofert. */
|
||||||
|
type DisplaySettings = Pick<UserSettings, 'currency' | 'areaUnit' | 'directOffersOnly' | 'saveSearchesOnHome'>;
|
||||||
|
|
||||||
|
const DisplayContext = createContext<DisplayContextValue | null>(null);
|
||||||
|
|
||||||
|
function currencyFormatter(currency: Currency, fractionDigits: number): Intl.NumberFormat {
|
||||||
|
return new Intl.NumberFormat('pl-PL', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intl wstawia twarda spacje (U+00A0) i waskie spacje (U+202F); reszta aplikacji uzywa zwyklych.
|
||||||
|
function normalizeSpaces(value: string): string {
|
||||||
|
return value.replace(/[ ]/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DisplayProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { user, loadSettings } = useAuth();
|
||||||
|
const [currency, setCurrency] = useState<Currency>('PLN');
|
||||||
|
const [areaUnit, setAreaUnit] = useState<AreaUnit>('M2');
|
||||||
|
const [directOffersOnly, setDirectOffersOnly] = useState(false);
|
||||||
|
const [showRecentlyViewedOnHome, setShowRecentlyViewedOnHome] = useState(true);
|
||||||
|
const [fx, setFx] = useState<FxRates | null>(null);
|
||||||
|
|
||||||
|
// Kursy sa te same dla wszystkich, wiec pobieramy je raz na wejscie do aplikacji.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
apiFetch<FxRates>('/fx/rates')
|
||||||
|
.then((rates) => { if (active) setFx(rates); })
|
||||||
|
.catch(() => { if (active) setFx(null); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Preferencje sa wlasnoscia konta - goscie widza wartosci bazowe (PLN, m²).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setCurrency('PLN');
|
||||||
|
setAreaUnit('M2');
|
||||||
|
setDirectOffersOnly(false);
|
||||||
|
setShowRecentlyViewedOnHome(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
loadSettings()
|
||||||
|
.then((settings) => {
|
||||||
|
if (!active) return;
|
||||||
|
applyTo(settings);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => { active = false; };
|
||||||
|
// applyTo jest stabilne (same settery useState), wiec nie trzymamy go w zaleznosciach.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user, loadSettings]);
|
||||||
|
|
||||||
|
function applyTo(settings: DisplaySettings) {
|
||||||
|
setCurrency(settings.currency ?? 'PLN');
|
||||||
|
setAreaUnit(settings.areaUnit ?? 'M2');
|
||||||
|
setDirectOffersOnly(settings.directOffersOnly ?? false);
|
||||||
|
setShowRecentlyViewedOnHome(settings.saveSearchesOnHome ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySettings = useCallback((settings: DisplaySettings) => {
|
||||||
|
applyTo(settings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<DisplayContextValue>(() => {
|
||||||
|
const rate = fx?.rates?.[currency] ?? CURRENCY_FALLBACK[currency];
|
||||||
|
const safeRate = Number.isFinite(rate) && rate > 0 ? rate : 1;
|
||||||
|
|
||||||
|
const convertPrice = (pln: number) => (currency === 'PLN' ? pln : pln / safeRate);
|
||||||
|
const convertArea = (squareMeters: number) => (areaUnit === 'M2' ? squareMeters : squareMeters * FT2_PER_M2);
|
||||||
|
|
||||||
|
// Ceny ofert sa duze, wiec grosze tylko zaszumialyby karte - zaokraglamy do pelnej jednostki.
|
||||||
|
const price = currencyFormatter(currency, 0);
|
||||||
|
const plain = new Intl.NumberFormat('pl-PL', { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const formatPrice = (pln: number) => normalizeSpaces(price.format(Math.round(convertPrice(pln))));
|
||||||
|
const formatPriceNumber = (pln: number) => normalizeSpaces(plain.format(Math.round(convertPrice(pln))));
|
||||||
|
const formatAreaNumber = (squareMeters: number) => normalizeSpaces(plain.format(Math.round(convertArea(squareMeters))));
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
areaUnit,
|
||||||
|
areaSuffix: AREA_SUFFIX[areaUnit],
|
||||||
|
fx,
|
||||||
|
convertPrice,
|
||||||
|
convertArea,
|
||||||
|
toSquareMeters: (valueInUserUnit: number) => (areaUnit === 'M2' ? valueInUserUnit : valueInUserUnit / FT2_PER_M2),
|
||||||
|
formatPrice,
|
||||||
|
formatMonthlyPrice: (pln: number) => `${formatPrice(pln)} / miesiąc`,
|
||||||
|
// Cena za jednostke to iloraz dwoch przeliczonych wielkosci, wiec dzielimy takze przez wspolczynnik powierzchni.
|
||||||
|
formatPricePerArea: (plnPerSquareMeter: number) => {
|
||||||
|
const perUnit = convertPrice(plnPerSquareMeter) / (areaUnit === 'M2' ? 1 : FT2_PER_M2);
|
||||||
|
return `${normalizeSpaces(price.format(Math.round(perUnit)))}/${AREA_SUFFIX[areaUnit]}`;
|
||||||
|
},
|
||||||
|
formatArea: (squareMeters: number) => `${formatAreaNumber(squareMeters)} ${AREA_SUFFIX[areaUnit]}`,
|
||||||
|
directOffersOnly,
|
||||||
|
showRecentlyViewedOnHome,
|
||||||
|
formatPriceNumber,
|
||||||
|
formatPricePerAreaNumber: (plnPerSquareMeter: number) => {
|
||||||
|
const perUnit = convertPrice(plnPerSquareMeter) / (areaUnit === 'M2' ? 1 : FT2_PER_M2);
|
||||||
|
return normalizeSpaces(plain.format(Math.round(perUnit)));
|
||||||
|
},
|
||||||
|
formatAreaNumber,
|
||||||
|
applySettings,
|
||||||
|
};
|
||||||
|
}, [currency, areaUnit, directOffersOnly, showRecentlyViewedOnHome, fx, applySettings]);
|
||||||
|
|
||||||
|
return <DisplayContext.Provider value={value}>{children}</DisplayContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDisplay(): DisplayContextValue {
|
||||||
|
const context = useContext(DisplayContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDisplay musi być użyte wewnątrz DisplayProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { AuthProvider } from './auth';
|
import { AuthProvider } from './auth';
|
||||||
|
import { DisplayProvider } from './display';
|
||||||
import { NotificationsProvider } from './notifications';
|
import { NotificationsProvider } from './notifications';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
@@ -10,7 +11,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<App />
|
<DisplayProvider>
|
||||||
|
<App />
|
||||||
|
</DisplayProvider>
|
||||||
</NotificationsProvider>
|
</NotificationsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const ROUTES = {
|
|||||||
edit: '/edytuj-ogloszenie/:id',
|
edit: '/edytuj-ogloszenie/:id',
|
||||||
services: '/firmy-i-uslugi',
|
services: '/firmy-i-uslugi',
|
||||||
companies: '/firmy',
|
companies: '/firmy',
|
||||||
|
advertising: '/reklama',
|
||||||
guides: '/poradniki',
|
guides: '/poradniki',
|
||||||
guideBuying: '/poradniki/kupno-mieszkania',
|
guideBuying: '/poradniki/kupno-mieszkania',
|
||||||
creditCalculator: '/kalkulator-zdolnosci',
|
creditCalculator: '/kalkulator-zdolnosci',
|
||||||
@@ -107,6 +108,12 @@ export function companiesPath(categorySlug?: string): string {
|
|||||||
return categorySlug ? `${ROUTES.companies}?kategoria=${encodeURIComponent(categorySlug)}` : ROUTES.companies;
|
return categorySlug ? `${ROUTES.companies}?kategoria=${encodeURIComponent(categorySlug)}` : ROUTES.companies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strona "Jeszcze nie mamy tej usługi" z formularzem reklamowym. Kategoria trafia do query
|
||||||
|
// stringu, żeby po wejściu z kafelka pole "Rodzaj usługi" było już wypełnione.
|
||||||
|
export function advertisingPath(categorySlug?: string): string {
|
||||||
|
return categorySlug ? `${ROUTES.advertising}?kategoria=${encodeURIComponent(categorySlug)}` : ROUTES.advertising;
|
||||||
|
}
|
||||||
|
|
||||||
// Zakładki panelu administratora jako podścieżki /admin/*.
|
// Zakładki panelu administratora jako podścieżki /admin/*.
|
||||||
export const ADMIN_TAB_PATHS = {
|
export const ADMIN_TAB_PATHS = {
|
||||||
dashboard: '',
|
dashboard: '',
|
||||||
@@ -114,6 +121,7 @@ export const ADMIN_TAB_PATHS = {
|
|||||||
users: 'uzytkownicy',
|
users: 'uzytkownicy',
|
||||||
messages: 'wiadomosci',
|
messages: 'wiadomosci',
|
||||||
support: 'pomoc-i-kontakt',
|
support: 'pomoc-i-kontakt',
|
||||||
|
advertisingEnquiries: 'zapytania-o-reklame',
|
||||||
reports: 'zgloszenia',
|
reports: 'zgloszenia',
|
||||||
payments: 'platnosci',
|
payments: 'platnosci',
|
||||||
stats: 'statystyki',
|
stats: 'statystyki',
|
||||||
|
|||||||
+1323
-41
File diff suppressed because it is too large
Load Diff
@@ -73,52 +73,57 @@ test.describe('Trasy publiczne', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Lista firm i uslug (/firmy)', () => {
|
test.describe('Lista firm i uslug (/firmy)', () => {
|
||||||
test('wejscie na /firmy pokazuje pelna liste', async ({ page }) => {
|
// Do serwisu nie dolaczyla jeszcze zadna firma. Dopoki tak jest, /firmy pokazuje SAMO zaproszenie -
|
||||||
|
// bez naglowka, licznika, chipow kategorii i wyszukiwarki (pokazywalyby zera i filtrowaly pustke).
|
||||||
|
// Gdy pojawi sie pierwsza firma, gorna czesc wraca i te testy trzeba rozszerzyc o karty.
|
||||||
|
test('wejscie na /firmy pokazuje samo zaproszenie', async ({ page }) => {
|
||||||
await goto(page, ROUTES.companies);
|
await goto(page, ROUTES.companies);
|
||||||
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
|
await expect(page.locator('.companies-none')).toBeVisible();
|
||||||
const cards = await page.locator('.directory-card').count();
|
await expect(page.locator('.directory-card')).toHaveCount(0);
|
||||||
expect(cards).toBeGreaterThan(10);
|
await expect(page.locator('.companies-head')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.companies-filters')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.service-mini-search')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('filtr kategorii dziala przez query string', async ({ page }) => {
|
test('zaproszenie przejmuje role naglowka H1', async ({ page }) => {
|
||||||
|
await goto(page, ROUTES.companies);
|
||||||
|
const headings = page.locator('.companies-page h1');
|
||||||
|
await expect(headings).toHaveCount(1);
|
||||||
|
await expect(headings).toContainText('Nie mamy jeszcze żadnych firm');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kategoria z query stringu trafia do komunikatu', async ({ page }) => {
|
||||||
await goto(page, `${ROUTES.companies}?kategoria=remonty`);
|
await goto(page, `${ROUTES.companies}?kategoria=remonty`);
|
||||||
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
|
await expect(page.locator('.companies-none')).toContainText('Remont i wykończenie');
|
||||||
|
|
||||||
const filtered = await page.locator('.directory-card').count();
|
|
||||||
await goto(page, ROUTES.companies);
|
await goto(page, ROUTES.companies);
|
||||||
const all = await page.locator('.directory-card').count();
|
await expect(page.locator('.companies-none')).toContainText('w naszej bazie');
|
||||||
expect(filtered).toBeGreaterThan(0);
|
|
||||||
expect(filtered).toBeLessThan(all);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('nieznana kategoria nie wywraca widoku - pokazuje pelna liste', async ({ page }) => {
|
test('nieznana kategoria nie wywraca widoku', async ({ page }) => {
|
||||||
await goto(page, `${ROUTES.companies}?kategoria=nie-ma-takiej`);
|
await goto(page, `${ROUTES.companies}?kategoria=nie-ma-takiej`);
|
||||||
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
|
await expect(page.locator('.companies-none')).toBeVisible();
|
||||||
|
await expect(page.locator('.companies-none')).toContainText('w naszej bazie');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('klik w chip kategorii zmienia adres i wynik', async ({ page }) => {
|
// Kategorii usług jeszcze nie obsługujemy, więc kafelek prowadzi na stronę zgłoszenia
|
||||||
await goto(page, ROUTES.companies);
|
// reklamowego (/reklama), a nie do listy firm - z kategorią przeniesioną w query stringu.
|
||||||
await page.locator('.companies-chip', { hasText: 'Finanse' }).first().click();
|
test('"Zobacz firmy" prowadzi na strone reklamy z wybrana kategoria', async ({ page }) => {
|
||||||
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
|
|
||||||
await expect(page.locator('.companies-head h1')).toHaveText('Finanse');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('"Zobacz firmy" prowadzi do listy z wybrana kategoria', async ({ page }) => {
|
|
||||||
await goto(page, ROUTES.services);
|
await goto(page, ROUTES.services);
|
||||||
const link = page.locator('.service-category-card', { hasText: 'Remont i wykończenie' }).getByRole('link');
|
const link = page.locator('.service-category-card', { hasText: 'Remont i wykończenie' }).getByRole('link');
|
||||||
await expect(link).toHaveAttribute('href', '/firmy?kategoria=remonty');
|
await expect(link).toHaveAttribute('href', '/reklama?kategoria=remonty');
|
||||||
await link.click();
|
await link.click();
|
||||||
await page.waitForFunction(() => window.location.pathname === '/firmy');
|
await page.waitForFunction(() => window.location.pathname === '/reklama');
|
||||||
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
|
await expect(page.locator('.advertising-input.select select')).toHaveValue('Remont i wykończenie');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('"Zobacz wszystkie" prowadzi do pelnej listy', async ({ page }) => {
|
test('"Zobacz wszystkie" prowadzi na /firmy', async ({ page }) => {
|
||||||
await goto(page, ROUTES.services);
|
await goto(page, ROUTES.services);
|
||||||
const link = page.locator('.services-section .section-head', { hasText: 'Polecane firmy' }).getByRole('link');
|
const link = page.locator('.services-section .section-head', { hasText: 'Polecane firmy' }).getByRole('link');
|
||||||
await expect(link).toHaveAttribute('href', ROUTES.companies);
|
await expect(link).toHaveAttribute('href', ROUTES.companies);
|
||||||
await link.click();
|
await link.click();
|
||||||
await page.waitForFunction(() => window.location.pathname === '/firmy');
|
await page.waitForFunction(() => window.location.pathname === '/firmy');
|
||||||
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
|
await expect(page.locator('.companies-none')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Wstecz i Dalej dzialaja na filtrze kategorii', async ({ page }) => {
|
test('Wstecz i Dalej dzialaja na filtrze kategorii', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user