firmy i usługi plus ustawiaenia
This commit is contained in:
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.security.SecureRandom;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.core.Authentication;
|
||||
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.UserReportResponse;
|
||||
import pl.polskalokalnie.report.UserReportService;
|
||||
import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
|
||||
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
|
||||
import pl.polskalokalnie.support.SupportMessageResponse;
|
||||
import pl.polskalokalnie.support.SupportMessageService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
@@ -55,6 +58,7 @@ public class AdminController {
|
||||
private final ListingReportService listingReportService;
|
||||
private final UserReportService userReportService;
|
||||
private final SupportMessageService supportMessageService;
|
||||
private final AdvertisingEnquiryService advertisingEnquiryService;
|
||||
private final MessageService messageService;
|
||||
private final TextModerationService textModerationService;
|
||||
private final AdminStatsService adminStatsService;
|
||||
@@ -65,6 +69,7 @@ public class AdminController {
|
||||
ListingService listingService, ListingReportService listingReportService,
|
||||
UserReportService userReportService,
|
||||
SupportMessageService supportMessageService,
|
||||
AdvertisingEnquiryService advertisingEnquiryService,
|
||||
MessageService messageService,
|
||||
TextModerationService textModerationService,
|
||||
AdminStatsService adminStatsService,
|
||||
@@ -76,6 +81,7 @@ public class AdminController {
|
||||
this.listingReportService = listingReportService;
|
||||
this.userReportService = userReportService;
|
||||
this.supportMessageService = supportMessageService;
|
||||
this.advertisingEnquiryService = advertisingEnquiryService;
|
||||
this.messageService = messageService;
|
||||
this.textModerationService = textModerationService;
|
||||
this.adminStatsService = adminStatsService;
|
||||
@@ -310,6 +316,28 @@ public class AdminController {
|
||||
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 ---
|
||||
|
||||
@GetMapping("/forbidden-words")
|
||||
|
||||
@@ -40,6 +40,8 @@ public class SecurityConfig {
|
||||
"/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/fx/rates").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||
.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.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -19,6 +20,12 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Service
|
||||
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;
|
||||
|
||||
public LeadService(LeadRepository leadRepository) {
|
||||
@@ -191,6 +198,87 @@ public class LeadService {
|
||||
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) {
|
||||
List<String> cleaned = new ArrayList<>();
|
||||
if (tags != null) {
|
||||
|
||||
@@ -26,13 +26,18 @@ public class ListingController {
|
||||
this.listingService = listingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param directOnly zostaw wylacznie oferty bezposrednie, czyli wystawione z kont osob
|
||||
* prywatnych (ustawienie "Pokaz tylko oferty bezposrednie" w /konto/ustawienia)
|
||||
*/
|
||||
@GetMapping
|
||||
public List<ListingResponse> search(
|
||||
@RequestParam(required = false) String city,
|
||||
@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")
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.notification.NotificationService;
|
||||
import pl.polskalokalnie.user.AccountType;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
import pl.polskalokalnie.user.UserSettingsService;
|
||||
@@ -86,17 +87,30 @@ public class ListingService {
|
||||
.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()
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.filter(listing -> city == null || normalizeCity(listing.getCity()).equals(normalizeCity(city)))
|
||||
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||
.filter(listing -> !directOnly || isDirectOffer(listing))
|
||||
.sorted(promotedFirst())
|
||||
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
||||
.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,
|
||||
* oczekujace i odrzucone widzi tylko wlasciciel i administracja, takze przy wejsciu z linku.
|
||||
|
||||
+1206
-338
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ export type UserSettings = {
|
||||
currency: Currency;
|
||||
areaUnit: AreaUnit;
|
||||
directOffersOnly: boolean;
|
||||
hideInactiveOffers: boolean;
|
||||
saveSearchesOnHome: boolean;
|
||||
notifySavedSearches: 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 App from './App';
|
||||
import { AuthProvider } from './auth';
|
||||
import { DisplayProvider } from './display';
|
||||
import { NotificationsProvider } from './notifications';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
@@ -10,7 +11,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<NotificationsProvider>
|
||||
<DisplayProvider>
|
||||
<App />
|
||||
</DisplayProvider>
|
||||
</NotificationsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -14,6 +14,7 @@ export const ROUTES = {
|
||||
edit: '/edytuj-ogloszenie/:id',
|
||||
services: '/firmy-i-uslugi',
|
||||
companies: '/firmy',
|
||||
advertising: '/reklama',
|
||||
guides: '/poradniki',
|
||||
guideBuying: '/poradniki/kupno-mieszkania',
|
||||
creditCalculator: '/kalkulator-zdolnosci',
|
||||
@@ -107,6 +108,12 @@ export function companiesPath(categorySlug?: string): string {
|
||||
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/*.
|
||||
export const ADMIN_TAB_PATHS = {
|
||||
dashboard: '',
|
||||
@@ -114,6 +121,7 @@ export const ADMIN_TAB_PATHS = {
|
||||
users: 'uzytkownicy',
|
||||
messages: 'wiadomosci',
|
||||
support: 'pomoc-i-kontakt',
|
||||
advertisingEnquiries: 'zapytania-o-reklame',
|
||||
reports: 'zgloszenia',
|
||||
payments: 'platnosci',
|
||||
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('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 expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
|
||||
const cards = await page.locator('.directory-card').count();
|
||||
expect(cards).toBeGreaterThan(10);
|
||||
await expect(page.locator('.companies-none')).toBeVisible();
|
||||
await expect(page.locator('.directory-card')).toHaveCount(0);
|
||||
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 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);
|
||||
const all = await page.locator('.directory-card').count();
|
||||
expect(filtered).toBeGreaterThan(0);
|
||||
expect(filtered).toBeLessThan(all);
|
||||
await expect(page.locator('.companies-none')).toContainText('w naszej bazie');
|
||||
});
|
||||
|
||||
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 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 }) => {
|
||||
await goto(page, ROUTES.companies);
|
||||
await page.locator('.companies-chip', { hasText: 'Finanse' }).first().click();
|
||||
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 }) => {
|
||||
// Kategorii usług jeszcze nie obsługujemy, więc kafelek prowadzi na stronę zgłoszenia
|
||||
// reklamowego (/reklama), a nie do listy firm - z kategorią przeniesioną w query stringu.
|
||||
test('"Zobacz firmy" prowadzi na strone reklamy z wybrana kategoria', async ({ page }) => {
|
||||
await goto(page, ROUTES.services);
|
||||
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 page.waitForFunction(() => window.location.pathname === '/firmy');
|
||||
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
|
||||
await page.waitForFunction(() => window.location.pathname === '/reklama');
|
||||
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);
|
||||
const link = page.locator('.services-section .section-head', { hasText: 'Polecane firmy' }).getByRole('link');
|
||||
await expect(link).toHaveAttribute('href', ROUTES.companies);
|
||||
await link.click();
|
||||
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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user