firmy i usługi plus ustawiaenia

This commit is contained in:
2026-08-19 17:02:55 +02:00
parent c52bbabec0
commit f9f030ba14
21 changed files with 3438 additions and 426 deletions
@@ -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
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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;
+194
View File
@@ -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;
}
+4 -1
View File
@@ -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>
+8
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+30 -25
View File
@@ -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 }) => {