diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index c9245dc..2ce04cd 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -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 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 deleteAdvertisingEnquiry(@PathVariable Long id) { + advertisingEnquiryService.delete(id); + return ResponseEntity.noContent().build(); + } + // --- Slowa zabronione --- @GetMapping("/forbidden-words") diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 67250c0..4f7e3b7 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -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() diff --git a/backend/src/main/java/pl/polskalokalnie/fx/FxController.java b/backend/src/main/java/pl/polskalokalnie/fx/FxController.java new file mode 100644 index 0000000..a0b8bf1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/fx/FxController.java @@ -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(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/fx/FxRateService.java b/backend/src/main/java/pl/polskalokalnie/fx/FxRateService.java new file mode 100644 index 0000000..6366700 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/fx/FxRateService.java @@ -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 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 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 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 rates = baseRates(); + rates.putAll(FALLBACK_RATES); + return new FxRatesResponse("PLN", rates, "", "FALLBACK"); + } + + private static Map baseRates() { + Map rates = new LinkedHashMap<>(); + rates.put("PLN", BigDecimal.ONE); + return rates; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/fx/FxRatesResponse.java b/backend/src/main/java/pl/polskalokalnie/fx/FxRatesResponse.java new file mode 100644 index 0000000..bb06afd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/fx/FxRatesResponse.java @@ -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 rates, + String effectiveDate, + String source +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiry.java b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiry.java new file mode 100644 index 0000000..1c26a66 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiry.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryController.java b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryController.java new file mode 100644 index 0000000..53bf4c7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryController.java @@ -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); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryRepository.java b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryRepository.java new file mode 100644 index 0000000..6adf47c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryRepository.java @@ -0,0 +1,11 @@ +package pl.polskalokalnie.lead; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdvertisingEnquiryRepository extends JpaRepository { + + List findAllByOrderByCreatedAtDesc(); + + long countByHandledFalse(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryRequest.java new file mode 100644 index 0000000..2547a25 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryResponse.java new file mode 100644 index 0000000..7f0503d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryService.java b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryService.java new file mode 100644 index 0000000..6a11ae9 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/AdvertisingEnquiryService.java @@ -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 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); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java index c43add4..9e8cee4 100644 --- a/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java @@ -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.empty() : leadRepository.findFirstByEmailIgnoreCase(email)) + .or(() -> phone == null ? Optional.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 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 cleanTags(List tags) { List cleaned = new ArrayList<>(); if (tags != null) { diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java index 0c3ea96..0e7b177 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -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 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") diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index 20e54ea..55f48e9 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -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 search(String city, OfferType offerType, PropertyType propertyType) { + public List 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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 09052aa..07a799f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,12 @@ import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ROUTES, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { ProtectedRoute } from './ProtectedRoute'; import { Avatar, initialsFrom } from './Avatar'; import { apiFetch, useAuth } from './auth'; import type { AccountType, AreaUnit, AuthUser, ContactPreference, Currency, PreferredLanguage, ProfileVisibility, RegisterPending, SearchPreferences, UserSettings, UserSettingsPatch } from './auth'; +import { useDisplay } from './display'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; @@ -420,6 +421,10 @@ type FavoriteListing = { mode: FavoriteTab; image: string; price: string; + /** Wartosci zrodlowe (PLN, m²) - po nich sortujemy i z nich budujemy etykiety w wybranej walucie. */ + priceValue: number; + areaValue: number; + isRent: boolean; title: string; location: string; area: string; @@ -687,6 +692,9 @@ function toFavoriteListingFromSummary(listing: ApiListingSummary): FavoriteListi listingId: listing.id, mode, image: listing.coverPhoto ?? heroImage, + priceValue: listing.price, + areaValue: listing.area, + isRent: mode === 'rent', price, title: listing.title, location: location || listing.city, @@ -711,6 +719,9 @@ function toFavoriteListing(listing: ApiListingDetail): FavoriteListing { listingId: listing.id, mode, image: listing.photos[0] ?? heroImage, + priceValue: listing.price, + areaValue: listing.area, + isRent: mode === 'rent', price, title: listing.title, location: location || listing.city, @@ -858,35 +869,12 @@ const RANKING_METRIC_LABELS: Record = { trend: 'Największy wzrost cen', }; +// Cena i metraz liczbowo (PLN, m²) - etykiety powstaja przy renderowaniu, wiec ida za ustawieniami. const listings = [ - { - location: 'Kraków, Zabłocie', - meta: 'Mieszkanie · 3 pokoje · 62 m²', - price: '749 000 zł', - perMeter: '12 081 zł/m²', - badge: 'Nowe', - }, - { - location: 'Warszawa, Mokotów', - meta: 'Mieszkanie · 2 pokoje · 45 m²', - price: '679 000 zł', - perMeter: '15 089 zł/m²', - badge: 'Premium', - }, - { - location: 'Wrocław, Kępa Mieszczańska', - meta: 'Mieszkanie · 4 pokoje · 78 m²', - price: '899 000 zł', - perMeter: '11 526 zł/m²', - badge: 'Nowe', - }, - { - location: 'Gdańsk, Wrzeszcz', - meta: 'Mieszkanie · 3 pokoje · 56 m²', - price: '619 000 zł', - perMeter: '11 054 zł/m²', - badge: '', - }, + { location: 'Kraków, Zabłocie', rooms: 3, area: 62, price: 749000, badge: 'Nowe' }, + { location: 'Warszawa, Mokotów', rooms: 2, area: 45, price: 679000, badge: 'Premium' }, + { location: 'Wrocław, Kępa Mieszczańska', rooms: 4, area: 78, price: 899000, badge: 'Nowe' }, + { location: 'Gdańsk, Wrzeszcz', rooms: 3, area: 56, price: 619000, badge: '' }, ]; const cities = [ @@ -1252,10 +1240,32 @@ function roomsLabel(rooms: number): string { return `${rooms} ${ROOM_WORDS[rooms] ?? 'pokoi'}`; } +/** + * Cena w wartosci bazowej, czyli w zlotowkach. + * + * Uzywaj tam, gdzie kwota ma NIE isc za ustawieniem waluty uzytkownika: platnosci za promowanie + * (AutoPay pobiera zlotowki), panel administratora oraz pola, w ktorych wlasciciel wpisuje cene + * oferty. W pozostalych miejscach formatuj przez useDisplay(). + */ function formatPln(value: number): string { return `${value.toLocaleString('pl-PL').replace(/\u00a0/g, ' ')} zł`; } +/** Cena za metr w wartosci bazowej - odpowiednik formatPln dla stawek za powierzchnie. */ +function basePricePerMeter(plnPerSquareMeter: number): string { + return `${formatPln(Math.round(plnPerSquareMeter))}/m²`; +} + +/** + * Formatery przekazywane funkcjom spoza komponentow - one same nie moga siegnac po useDisplay(), + * wiec dostaja gotowe funkcje od komponentu, ktory je wywoluje. + */ +type PriceFormatters = { + formatPrice: (pln: number) => string; + formatArea: (squareMeters: number) => string; + formatPricePerArea: (plnPerSquareMeter: number) => string; +}; + /** * Konwertuje ulotny blob/URL zdjecia na skompresowany data URL (JPEG), przeskalowany do maks. * `maxDim` px po dluzszym boku - dzieki temu zdjecia da sie utrwalic w bazie bez rozdmuchiwania jej. @@ -1294,10 +1304,10 @@ function safeRandomId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } -function offerMeta(offer: BuyOffer): string { +function offerMeta(offer: BuyOffer, fmt: PriceFormatters): string { const perMeter = Math.round(offer.priceValue / offer.area); const roomPart = offer.propertyType === 'Działka' ? offer.propertyType : roomsLabel(offer.rooms); - return `${roomPart} • ${offer.area} m² • ${formatPln(perMeter).replace(' zł', '')} zł/m² • ${offer.year} r.`; + return `${roomPart} • ${fmt.formatArea(offer.area)} • ${fmt.formatPricePerArea(perMeter)} • ${offer.year} r.`; } type NegotiationChance = { @@ -1401,7 +1411,7 @@ function buildNegotiationModel(offer: BuyOffer): NegotiationModel { }; } -function buildComparableTransactions(activeOffer: BuyOffer, source: BuyOffer[]): NegotiationTransaction[] { +function buildComparableTransactions(activeOffer: BuyOffer, source: BuyOffer[], fmt: PriceFormatters): NegotiationTransaction[] { const sameCityAndType = source .filter((offer) => offer !== activeOffer && offer.city === activeOffer.city && offer.propertyType === activeOffer.propertyType) .sort((left, right) => Math.abs(left.area - activeOffer.area) - Math.abs(right.area - activeOffer.area)); @@ -1429,9 +1439,9 @@ function buildComparableTransactions(activeOffer: BuyOffer, source: BuyOffer[]): const perMeter = Math.round(offer.priceValue / offer.area); return { street: offer.district, - meta: `${offer.area} m² · ${roomsLabel(offer.rooms)} · ${offer.year} r.`, - price: formatPln(offer.priceValue), - sqm: `${formatPln(perMeter).replace(' zł', '')} zł/m²`, + meta: `${fmt.formatArea(offer.area)} · ${roomsLabel(offer.rooms)} · ${offer.year} r.`, + price: fmt.formatPrice(offer.priceValue), + sqm: fmt.formatPricePerArea(perMeter), date: offer.date, image: offer.image, }; @@ -1596,66 +1606,31 @@ const serviceCategories = [ type ServiceCategorySlug = (typeof serviceCategories)[number]['slug']; -const serviceCompanies = [ - { - icon: 'house', - title: 'Finanse Expert', - rating: '4.9 (128 opinii)', - text: 'Kredyty hipoteczne', - city: 'Warszawa, cała Polska', - }, - { - icon: 'hammer', - title: 'Remont Masters', - rating: '4.8 (96 opinii)', - text: 'Remonty i wykończenia', - city: 'Kraków, małopolskie', - }, - { - icon: 'letter', - title: 'Notariusz Nowak', - rating: '4.9 (74 opinie)', - text: 'Kancelarie notarialne', - city: 'Wrocław, dolnośląskie', - }, - { - icon: 'truck', - title: 'SuperPrzeprowadzki', - rating: '4.7 (111 opinii)', - text: 'Przeprowadzki', - city: 'Poznań, wielkopolskie', - }, -]; +/** + * Polecane firmy na /firmy-i-uslugi. + * + * Pusto, bo w serwisie nie ma jeszcze ani jednej firmy - zamiast wymyslonych wizytowek z ocenami + * i liczba opinii pokazujemy zaproszenie "Szukamy specjalistow". Gdy pojawi sie zrodlo prawdziwych + * firm, wystarczy je tu podstawic - karty renderuja sie same, bez zmian w widoku. + */ +const serviceCompanies: Array<{ + icon: string; + title: string; + rating: string; + text: string; + city: string; +}> = []; -const serviceDirectory = [ - { - title: 'Remont Masters', - rating: '4.8 (96 opinii)', - text: 'Kompleksowe remonty mieszkań i domów', - city: 'Kraków, małopolskie', - image: heroImage, - position: '72% 62%', - }, - { - title: 'Perfect House', - rating: '4.7 (64 opinie)', - text: 'Wykończenia wnętrz pod klucz', - city: 'Warszawa, mazowieckie', - image: heroImage, - position: '92% 50%', - }, - { - title: 'Home Finish', - rating: '4.6 (52 opinie)', - text: 'Remonty, wykończenia, projekty wnętrz', - city: 'Wrocław, dolnośląskie', - image: heroImage, - position: '48% 58%', - }, -]; +/** Tyle wolnych miejsc pokazujemy, gdy nie ma firm - jedno na kazda kolumne siatki. */ +const EMPTY_COMPANY_SLOTS = [1, 2, 3, 4]; -// Pelna baza firm dla listy /firmy. Kazda pozycja ma kategorie, wiec filtrowanie -// po ?kategoria= dziala na realnych danych, a nie tylko podswietla zakladke. +/** + * Baza firm dla listy /firmy. + * + * Pusta - do serwisu nie dolaczyla jeszcze zadna firma. Zamiast wymyslonych wizytowek z ocenami + * i opiniami lista pokazuje wtedy zaproszenie (patrz CompaniesEmptyState). Po podpieciu prawdziwego + * zrodla wystarczy je tu podstawic, a filtrowanie po ?kategoria= zadziala bez zmian w widoku. + */ const companyDirectory: Array<{ title: string; category: ServiceCategorySlug; @@ -1664,25 +1639,7 @@ const companyDirectory: Array<{ city: string; image: string; position: string; -}> = [ - { title: 'Finanse Expert', category: 'finanse', rating: '4.9 (128 opinii)', text: 'Kredyty hipoteczne i doradztwo finansowe', city: 'Warszawa, cała Polska', image: cityImage, position: '50% 40%' }, - { title: 'Kredyt Partner', category: 'finanse', rating: '4.6 (57 opinii)', text: 'Porównanie ofert kredytowych, ubezpieczenia', city: 'Gdańsk, pomorskie', image: heroImage, position: '30% 55%' }, - { title: 'Notariusz Nowak', category: 'prawo', rating: '4.9 (74 opinie)', text: 'Kancelaria notarialna, akty i umowy', city: 'Wrocław, dolnośląskie', image: heroImage, position: '62% 45%' }, - { title: 'Lex Nieruchomości', category: 'prawo', rating: '4.7 (41 opinii)', text: 'Obsługa prawna transakcji, księgi wieczyste', city: 'Warszawa, mazowieckie', image: cityImage, position: '70% 60%' }, - { title: 'Remont Masters', category: 'remonty', rating: '4.8 (96 opinii)', text: 'Kompleksowe remonty mieszkań i domów', city: 'Kraków, małopolskie', image: heroImage, position: '72% 62%' }, - { title: 'Perfect House', category: 'remonty', rating: '4.7 (64 opinie)', text: 'Wykończenia wnętrz pod klucz', city: 'Warszawa, mazowieckie', image: heroImage, position: '92% 50%' }, - { title: 'Home Finish', category: 'remonty', rating: '4.6 (52 opinie)', text: 'Remonty, wykończenia, projekty wnętrz', city: 'Wrocław, dolnośląskie', image: heroImage, position: '48% 58%' }, - { title: 'SuperPrzeprowadzki', category: 'przeprowadzka', rating: '4.7 (111 opinii)', text: 'Przeprowadzki lokalne i krajowe', city: 'Poznań, wielkopolskie', image: cityImage, position: '40% 50%' }, - { title: 'Transport 24', category: 'przeprowadzka', rating: '4.5 (38 opinii)', text: 'Transport mebli, magazynowanie, pakowanie', city: 'Łódź, łódzkie', image: heroImage, position: '20% 70%' }, - { title: 'Kuchnie na Wymiar', category: 'wyposazenie', rating: '4.8 (83 opinie)', text: 'Meble kuchenne i zabudowy na wymiar', city: 'Katowice, śląskie', image: heroImage, position: '85% 40%' }, - { title: 'Salon Oświetlenia Lumo', category: 'wyposazenie', rating: '4.4 (29 opinii)', text: 'Oświetlenie, AGD, wyposażenie wnętrz', city: 'Szczecin, zachodniopomorskie', image: cityImage, position: '60% 35%' }, - { title: 'FotoNieruchomości', category: 'sprzedaz', rating: '4.9 (67 opinii)', text: 'Fotografia ofert, home staging, spacery 3D', city: 'Warszawa, cała Polska', image: cityImage, position: '35% 65%' }, - { title: 'Studio Wirtualnych Spacerów', category: 'sprzedaz', rating: '4.7 (45 opinii)', text: 'Wirtualne spacery 3D i plany mieszkań', city: 'Kraków, małopolskie', image: heroImage, position: '55% 48%' }, - { title: 'Nowa Inwestycja Development', category: 'deweloperzy', rating: '4.6 (58 opinii)', text: 'Nowe inwestycje mieszkaniowe, biuro sprzedaży', city: 'Gdynia, pomorskie', image: cityImage, position: '80% 55%' }, - { title: 'Osiedle Park Deweloper', category: 'deweloperzy', rating: '4.5 (34 opinie)', text: 'Mieszkania z rynku pierwotnego', city: 'Wrocław, dolnośląskie', image: heroImage, position: '45% 42%' }, - { title: 'Serwis Domowy Fix', category: 'inne', rating: '4.6 (72 opinie)', text: 'Drobne naprawy, złota rączka, przeglądy', city: 'Cała Polska', image: heroImage, position: '68% 68%' }, - { title: 'Czysto i Gotowe', category: 'inne', rating: '4.8 (91 opinii)', text: 'Sprzątanie po remoncie i przed wprowadzeniem', city: 'Warszawa, mazowieckie', image: cityImage, position: '25% 45%' }, -]; +}> = []; const serviceSteps = [ { @@ -2823,6 +2780,7 @@ function preferredLanguageLabel(language: PreferredLanguage | null | undefined): function App() { const { user, logout, loadSearchPreferences } = useAuth(); + const { formatPrice, formatPricePerArea, areaSuffix } = useDisplay(); const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); @@ -3024,7 +2982,7 @@ function App() { category: 'listing', icon: 'coin', title: 'Spadek cen w lokalizacji', - body: `Średnia cena za m² w „${event.label}” spadła o ${diff.toLocaleString('pl-PL')} zł.`, + body: `Średnia cena za ${areaSuffix} w „${event.label}” spadła o ${formatPricePerArea(diff)}.`, link: 'priceAlerts', dedupeKey: `city-price-${event.subscriptionKey}-${event.currentPerM2}`, }); @@ -3626,7 +3584,7 @@ function App() { category: 'listing', icon: 'coin', title: 'Zmiana ceny', - body: `Cena „${event.title}” spadła o ${diff.toLocaleString('pl-PL')} zł.`, + body: `Cena „${event.title}” spadła o ${formatPrice(diff)}.`, link: `listing:${event.listingId}`, dedupeKey: `price-${event.listingId}-${event.currentPrice}`, }); @@ -3741,6 +3699,7 @@ function App() { } /> } /> + } /> } /> } /> @@ -3993,6 +3952,21 @@ type AdminSupportMessage = { createdAt: string; }; +/** Zgloszenie z formularza reklamowego (/reklama) w widoku administratora. */ +type AdminAdvertisingEnquiry = { + id: number; + fullName: string; + companyName: string; + email: string; + phone: string; + serviceCategory: string; + message: string | null; + handled: boolean; + handledAt: string | null; + handledByEmail: string | null; + createdAt: string; +}; + type AdminUserReportMessage = { fromReporter: boolean; content: string | null; @@ -4141,6 +4115,7 @@ const ADMIN_TAB_META: Record = { users: { title: 'Użytkownicy', subtitle: 'Zarządzaj kontami zarejestrowanych użytkowników.' }, messages: { title: 'Wiadomości', subtitle: 'Podgląd wiadomości wymienianych między użytkownikami.' }, support: { title: 'Pomoc i kontakt', subtitle: 'Wiadomości wysłane przez formularz „Napisz do nas” na stronie pomocy.' }, + advertisingEnquiries: { title: 'Zapytania o reklamę', subtitle: 'Zgłoszenia firm z formularza na stronie „Jeszcze nie mamy tej usługi” (/reklama).' }, reports: { title: 'Zgłoszenia', subtitle: 'Zgłoszenia dotyczące ogłoszeń i użytkowników wymagające uwagi.' }, payments: { title: 'Płatności i promowanie', subtitle: 'Plany i pakiety promowania ofert, konfiguracja AutoPay oraz historia płatności.' }, stats: { title: 'Statystyki', subtitle: 'Statystyki aktywności i ruchu w serwisie.' }, @@ -4162,6 +4137,7 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'users', icon: 'user', label: 'Użytkownicy' }, { id: 'messages', icon: 'message', label: 'Wiadomości' }, { id: 'support', icon: 'mail', label: 'Pomoc i kontakt' }, + { id: 'advertisingEnquiries', icon: 'megaphone', label: 'Zapytania o reklamę' }, { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, { id: 'leads', icon: 'user', label: 'Leady' }, { id: 'campaigns', icon: 'mail', label: 'Kampanie marketingowe' }, @@ -4343,6 +4319,7 @@ function AdminPage() { const [messageThreadsError, setMessageThreadsError] = useState(null); // Wiadomosci z formularza "Napisz do nas" (/konto/pomoc) - osobna lista od czatu z uzytkownikiem. const [supportMessages, setSupportMessages] = useState([]); + const [advertisingEnquiries, setAdvertisingEnquiries] = useState([]); const [statsPeriodDays, setStatsPeriodDays] = useState(1); const [statsPeriodMode, setStatsPeriodMode] = useState('preset'); const [statsCustomFrom, setStatsCustomFrom] = useState(''); @@ -4437,6 +4414,18 @@ function AdminPage() { void loadSupportMessages(); }, [loadSupportMessages]); + const loadAdvertisingEnquiries = useCallback(async () => { + try { + setAdvertisingEnquiries(await apiFetch('/admin/advertising-enquiries')); + } catch { + setAdvertisingEnquiries([]); + } + }, []); + + useEffect(() => { + void loadAdvertisingEnquiries(); + }, [loadAdvertisingEnquiries]); + const loadMessageThreads = useCallback(async (silent = false) => { const regularUsers = users.filter((item) => item.role !== 'ADMIN'); if (regularUsers.length === 0) { @@ -4564,6 +4553,7 @@ function AdminPage() { const pendingReportsCount = reports.filter((item) => item.status === 'OPEN').length + userReports.filter((item) => item.status === 'OPEN').length; const newSupportMessagesCount = supportMessages.filter((item) => !item.handled).length; + const newAdvertisingEnquiriesCount = advertisingEnquiries.filter((item) => !item.handled).length; // Konto, z ktorego wyslano formularz - po nim otwieramy czat z uzytkownikiem. const supportMessageAuthor = (message: AdminSupportMessage) => users.find( (item) => item.email.toLowerCase() === message.accountEmail.toLowerCase(), @@ -4735,6 +4725,7 @@ function AdminPage() { {item.id === 'users' && pendingVerificationCount > 0 && {pendingVerificationCount}} {item.id === 'reports' && pendingReportsCount > 0 && {pendingReportsCount}} {item.id === 'support' && newSupportMessagesCount > 0 && {newSupportMessagesCount}} + {item.id === 'advertisingEnquiries' && newAdvertisingEnquiriesCount > 0 && {newAdvertisingEnquiriesCount}} ))} @@ -5520,6 +5511,80 @@ function AdminPage() { )} + {!loading && tab === 'advertisingEnquiries' && ( +
+ {advertisingEnquiries.length === 0 ? ( +

Brak zapytań o reklamę. Pojawią się tu zgłoszenia z formularza na stronie /reklama.

+ ) : ( +
+
+ Firma + Kontakt + Usługa + Wiadomość + Status + Akcje +
+ {advertisingEnquiries.map((item) => ( +
+ + {item.companyName} + {item.fullName} + + + {item.email} + {item.phone} • #{item.id} • {formatAdminDate(item.createdAt)} + + {item.serviceCategory} + {item.message ?? Bez wiadomości} + + + {item.handled ? 'Obsłużone' : 'Nowe'} + + {item.handled && item.handledAt && ( + + {formatAdminDate(item.handledAt)}{item.handledByEmail ? ` • ${item.handledByEmail}` : ''} + + )} + + + + Odpowiedz e-mailem + + + + +
+ ))} +
+ )} +
+ )} {!loading && tab === 'payments' && } {!loading && tab === 'stats' && (
@@ -5768,6 +5833,7 @@ function AdminPage() { function DistrictRankingPage() { const navigate = useNavigate(); + const { formatPricePerArea, formatPricePerAreaNumber, formatArea, areaSuffix } = useDisplay(); const [ranking, setRanking] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -5826,7 +5892,7 @@ function DistrictRankingPage() { const podium = sorted.slice(0, 3); const areaName = (item: AreaStats) => (item.district ? `${item.district}, ${item.city}` : item.city); - const perM2 = (value: number) => `${Math.round(value).toLocaleString('pl-PL')} zł/m²${offerType === 'RENT' ? ' / mies.' : ''}`; + const perM2 = (value: number) => `${formatPricePerArea(value)}${offerType === 'RENT' ? ' / mies.' : ''}`; const generatedLabel = ranking ? new Date(ranking.generatedAt).toLocaleString('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; @@ -5945,7 +6011,7 @@ function DistrictRankingPage() { # {level === 'districts' ? 'Dzielnica' : 'Miasto'} Oferty - Śr. cena za m² + Śr. cena za {areaSuffix} Rozpiętość Śr. metraż Zainteresowanie @@ -5973,10 +6039,10 @@ function DistrictRankingPage() { {item.listingsCount > 1 - ? `${Math.round(item.minPricePerM2).toLocaleString('pl-PL')} - ${Math.round(item.maxPricePerM2).toLocaleString('pl-PL')}` + ? `${formatPricePerAreaNumber(item.minPricePerM2)} - ${formatPricePerAreaNumber(item.maxPricePerM2)}` : '-'} - {`${item.avgArea}`.replace('.', ',')} m² + {formatArea(item.avgArea)} {item.totalViews} @@ -6006,7 +6072,7 @@ function DistrictRankingPage() {

Jak liczymy ranking - Podstawą są wyłącznie opublikowane ogłoszenia z podaną ceną i metrażem. Cena za m² to średnia + Podstawą są wyłącznie opublikowane ogłoszenia z podaną ceną i metrażem. Cena za {areaSuffix} to średnia z ofert w danym obszarze, zainteresowanie sumuje wyświetlenia i zapisania do ulubionych, a trend porównuje oferty z ostatnich 90 dni ze starszymi - pokazujemy go dopiero wtedy, gdy po obu stronach są co najmniej 2 oferty. Obszary z jedną ofertą oznaczamy jako „próbka 1”, bo @@ -6032,6 +6098,8 @@ function NegotiationPage({ onSelectOffer: (offer: BuyOffer) => void; }) { const navigate = useNavigate(); + const display = useDisplay(); + const { formatPrice, formatPriceNumber, formatPricePerArea, formatPricePerAreaNumber } = display; const fallbackOffer = offers[0] ?? null; const activeOffer = selectedOffer ?? fallbackOffer; @@ -6059,7 +6127,7 @@ function NegotiationPage({ } const model = buildNegotiationModel(activeOffer); - const transactions = buildComparableTransactions(activeOffer, offers); + const transactions = buildComparableTransactions(activeOffer, offers, display); const selectedIndex = Math.max(0, offers.findIndex((offer) => offer === activeOffer)); const chanceTone = model.chance.label === 'Niska' ? 'low' : model.chance.label === 'Średnia' ? 'medium' : 'high'; const negotiationPercent = ((model.negotiationSpan / activeOffer.priceValue) * 100).toFixed(1).replace('.', ','); @@ -6101,7 +6169,7 @@ function NegotiationPage({ > {offers.map((offer, index) => ( ))} @@ -6114,12 +6182,12 @@ function NegotiationPage({

{activeOffer.title}

{activeOffer.district}

- {offerMeta(activeOffer)} + {offerMeta(activeOffer, display)}
Cena ofertowa - {formatPln(activeOffer.priceValue)} - {formatPln(model.offerPerMeter).replace(' zł', '')} zł/m² + {formatPrice(activeOffer.priceValue)} + {formatPricePerArea(model.offerPerMeter)}
@@ -6127,8 +6195,8 @@ function NegotiationPage({

Rekomendowana cena transakcyjna

-

{formatPln(model.minPrice)} - {formatPln(model.maxPrice)}

- {formatPln(model.minPerMeter).replace(' zł', '')} - {formatPln(model.maxPerMeter).replace(' zł', '')} zł/m² +

{formatPrice(model.minPrice)} - {formatPrice(model.maxPrice)}

+ {formatPricePerAreaNumber(model.minPerMeter)} - {formatPricePerArea(model.maxPerMeter)} Realistyczna cena, którą możesz zaproponować sprzedającemu.
- {formatPln(model.minPrice)} + {formatPrice(model.minPrice)} Cena minimalna
- {formatPln(model.negotiationSpan)} + {formatPrice(model.negotiationSpan)} Zakres negocjacji ({negotiationPercent}%)
- {formatPln(activeOffer.priceValue)} + {formatPrice(activeOffer.priceValue)} Cena ofertowa
@@ -6164,7 +6232,7 @@ function NegotiationPage({

Dlaczego właśnie taki zakres?

-
Analiza rynkuŚrednia cena transakcyjna podobnych ofert: {formatPln(model.marketAvgPerMeter).replace(' zł', '')} zł/m² ({model.marketDeltaPct.toString().replace('.', ',')}% vs cena ofertowa).
+
Analiza rynkuŚrednia cena transakcyjna podobnych ofert: {formatPricePerArea(model.marketAvgPerMeter)} ({model.marketDeltaPct.toString().replace('.', ',')}% vs cena ofertowa).
Stan mieszkaniaModel wskazuje potencjał korekty o ok. {model.conditionDiscountPct.toString().replace('.', ',')}% zależnie od wieku budynku i standardu rynku.
Czas na rynkuSzacowany czas sprzedaży podobnych lokali: około {model.marketDays} dni. Dłuższa ekspozycja zwiększa przestrzeń negocjacji.
SezonowośćAktualna dynamika popytu daje dodatkowo około {model.seasonNegotiationBonusPct.toString().replace('.', ',')}% pola do rozmowy o cenie.
@@ -6173,7 +6241,7 @@ function NegotiationPage({
Pamiętaj -

Najlepszą skuteczność daje oferta startowa blisko {formatPln(model.minPrice)} i kontrpropozycja końcowa w okolicy {formatPln(rangeMidPrice)} ({formatPln(rangeMidPerMeter).replace(' zł', '')} zł/m²).

+

Najlepszą skuteczność daje oferta startowa blisko {formatPrice(model.minPrice)} i kontrpropozycja końcowa w okolicy {formatPrice(rangeMidPrice)} ({formatPricePerArea(rangeMidPerMeter)}).

@@ -6227,7 +6295,7 @@ function NegotiationPage({
Wskazówka negocjacyjna -

{model.chance.label === 'Wysoka' ? 'Zacznij od dolnej granicy i zwiększaj ofertę małymi krokami (5-10 tys. zł).' : model.chance.label === 'Średnia' ? 'Zacznij od środka zakresu i przygotuj argumenty: porównywalne transakcje, czas na rynku, koszty remontu.' : 'Rozpocznij bliżej górnej granicy i negocjuj warunki dodatkowe, np. termin wydania lub wyposażenie.'}

+

{model.chance.label === 'Wysoka' ? `Zacznij od dolnej granicy i zwiększaj ofertę małymi krokami (${formatPriceNumber(5000)}-${formatPrice(10000)}).` : model.chance.label === 'Średnia' ? 'Zacznij od środka zakresu i przygotuj argumenty: porównywalne transakcje, czas na rynku, koszty remontu.' : 'Rozpocznij bliżej górnej granicy i negocjuj warunki dodatkowe, np. termin wydania lub wyposażenie.'}

@@ -6422,6 +6490,7 @@ function AccountDashboardPage({ }) { const navigate = useNavigate(); const { user } = useAuth(); + const { formatPrice, formatPricePerArea, formatArea, formatAreaNumber } = useDisplay(); type DashboardWidgetId = 'recent' | 'alerts' | 'searches' | 'tools'; type DashboardWidgetConfig = { id: DashboardWidgetId; @@ -6439,10 +6508,11 @@ function AccountDashboardPage({ { icon: 'message', label: 'Nieprzeczytane wiadomości', count: 4, action: 'Zobacz wiadomości' }, ]; + // Wartosci trzymamy liczbowo (PLN, m²), zeby szly za ustawieniem waluty i jednostki z /konto/ustawienia. const alerts = [ - { place: 'Mokotów, Stegny', meta: 'Mieszkania 3-pokojowe, 50-70 m²', price: 'Do 700 000 zł' }, - { place: 'Praga-Południe, Grochów', meta: 'Mieszkania 2-pokojowe, 35-55 m²', price: 'Do 600 000 zł' }, - { place: 'Wola, Odolany', meta: 'Mieszkania 3-4 pokojowe, 60-80 m²', price: 'Do 900 000 zł' }, + { place: 'Mokotów, Stegny', rooms: 'Mieszkania 3-pokojowe', areaFrom: 50, areaTo: 70, budget: 700000 }, + { place: 'Praga-Południe, Grochów', rooms: 'Mieszkania 2-pokojowe', areaFrom: 35, areaTo: 55, budget: 600000 }, + { place: 'Wola, Odolany', rooms: 'Mieszkania 3-4 pokojowe', areaFrom: 60, areaTo: 80, budget: 900000 }, ]; const [activeAlerts, setActiveAlerts] = useState(() => alerts.map(() => true)); @@ -6455,37 +6525,35 @@ function AccountDashboardPage({ { icon: 'shuffle', title: 'Porównanie mieszkań', text: 'Porównuj oferty i wybieraj najlepsze', path: ROUTES.comparison }, ]; + // Cena i powierzchnia liczbowo - etykiety powstaja przy renderowaniu, wiec reaguja na ustawienia. const recentListings = [ { title: 'Mieszkanie 3-pokojowe', district: 'Warszawa, Mokotów, Stegny', - priceLabel: '749 000 zł', - metaLabel: '62 m²', + price: 749000, + area: 62, roomsLabel: '3 pokoje', floorLabel: '4/8 piętro', - perMeterLabel: '12 081 zł/m²', image: heroImage, position: '47% 58%', }, { title: 'Mieszkanie 2-pokojowe', district: 'Warszawa, Praga-Południe', - priceLabel: '699 000 zł', - metaLabel: '45 m²', + price: 699000, + area: 45, roomsLabel: '2 pokoje', floorLabel: '3/6 piętro', - perMeterLabel: '15 533 zł/m²', image: cityImage, position: '52% 55%', }, { title: 'Mieszkanie 4-pokojowe', district: 'Warszawa, Wola, Odolany', - priceLabel: '999 000 zł', - metaLabel: '45 m²', + price: 999000, + area: 45, roomsLabel: '2 pokoje', floorLabel: '5/7 piętro', - perMeterLabel: '13 320 zł/m²', image: heroImage, position: '72% 52%', }, @@ -6603,11 +6671,11 @@ function AccountDashboardPage({ {recentListings.map((listing) => (
- {listing.priceLabel} + {formatPrice(listing.price)}

{listing.title}

{listing.district}

- {listing.metaLabel} • {listing.roomsLabel} • {listing.floorLabel} - {listing.perMeterLabel} + {formatArea(listing.area)} • {listing.roomsLabel} • {listing.floorLabel} + {formatPricePerArea(listing.price / listing.area)}
))}
@@ -6625,8 +6693,8 @@ function AccountDashboardPage({
{alert.place} - {alert.meta} - {alert.price} + {`${alert.rooms}, ${formatAreaNumber(alert.areaFrom)}-${formatArea(alert.areaTo)}`} + {`Do ${formatPrice(alert.budget)}`}
))} @@ -9336,6 +9407,7 @@ function AccountMeetingsPage() { } function MyApiListingsBand({ onOpenListing }: { onOpenListing: (id: number) => void }) { + const { formatPrice, formatArea } = useDisplay(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -9373,8 +9445,8 @@ function MyApiListingsBand({ onOpenListing }: { onOpenListing: (id: number) => v
{item.title} {[item.city, item.district].filter(Boolean).join(', ')} - {formatPln(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''} - {`${item.area}`.replace('.', ',')} m² • {listingRoomsText(item.rooms)} + {formatPrice(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''} + {formatArea(item.area)} • {listingRoomsText(item.rooms)}
))} @@ -9575,6 +9647,7 @@ function PromoteModal({ listingId, listingTitle, onClose, onDone }: { function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) => void }) { const navigate = useNavigate(); + const { formatPrice, formatMonthlyPrice, formatPricePerArea, formatArea } = useDisplay(); type ListingCategory = 'sale' | 'rent'; type ListingStatus = 'active' | 'paused' | 'archived'; type ListingItem = { @@ -10194,13 +10267,13 @@ function AccountListingsPage({ onOpenListing }: { onOpenListing: (id: number) =>

{item.title}

{item.address}

- {item.area} m² • {item.rooms} pokoje • {item.floor} piętro + {formatArea(item.area)} • {item.rooms} pokoje • {item.floor} piętro
<> - {item.category === 'sale' ? formatPln(item.price) : `${item.price.toLocaleString('pl-PL')} zł / miesiąc`} - {item.monthlyFees > 0 ? `+ ${item.monthlyFees.toLocaleString('pl-PL')} zł opłaty` : `${Math.round(item.price / item.area).toLocaleString('pl-PL')} zł/m²`} + {item.category === 'sale' ? formatPrice(item.price) : formatMonthlyPrice(item.price)} + {item.monthlyFees > 0 ? `+ ${formatPrice(item.monthlyFees)} opłaty` : formatPricePerArea(item.price / item.area)}
@@ -10604,6 +10677,7 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences deleteAccount, loadSettings, saveSettings, refreshUser, verifyPhone, resendPhoneOtp, } = useAuth(); const { notify } = useNotifications(); + const { applySettings: applyDisplaySettings } = useDisplay(); const navigate = useNavigate(); type Tone = 'success' | 'error' | 'info'; @@ -10842,6 +10916,9 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences try { const saved = await saveSettings(patch); setSettings(saved); + // Waluta i jednostka powierzchni steruja kazda cena w aplikacji, wiec zapis od razu trafia do + // providera prezentacji - listy ofert, mapa czy kalkulatory przeliczaja sie bez przeladowania. + applyDisplaySettings(saved); // Zdjecie profilowe wisi przy zalogowanym uzytkowniku, wiec po jego zmianie odswiezamy profil - // awatar w naglowku i panelu konta zmienia sie od razu, bez przeladowania strony. if ('avatarImage' in patch) { @@ -11614,20 +11691,15 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences { void patchSettings({ directOffersOnly: next }); }} /> - { void patchSettings({ hideInactiveOffers: next }); }} - /> { void patchSettings({ saveSearchesOnHome: next }); }} @@ -11877,6 +11949,7 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences function AccountPublicProfilePage() { const navigate = useNavigate(); const { user, loadSettings } = useAuth(); + const { formatPrice, formatMonthlyPrice, formatArea } = useDisplay(); // Zdjecia profilu sa na koncie (a nie w pamieci przegladarki), wiec podglad pokazuje to samo, // co zobaczy kazdy inny uzytkownik - takze po zalogowaniu na innym urzadzeniu. const [profileSettings, setProfileSettings] = useState(null); @@ -11924,16 +11997,18 @@ function AccountPublicProfilePage() { .map((chunk) => chunk[0]?.toUpperCase() ?? '') .join(''); + // Cena i metraz liczbowo (PLN, m²) - etykiety powstaja przy renderowaniu, wiec ida za ustawieniami. const publicListings = [ { image: heroImage, imagePosition: 'left center', badge: 'Na sprzedaż', badgeClass: 'sale', - price: '748 000 zł', + price: 748000, + isRent: false, title: 'Mieszkanie 3-pokojowe', location: 'Warszawa, Mokotów, Stegny', - area: '62 m²', + area: 62, rooms: '3 pokoje', floor: '4/8 piętro', date: 'Dodane 2 dni temu', @@ -11943,10 +12018,11 @@ function AccountPublicProfilePage() { imagePosition: 'center center', badge: 'Na sprzedaż', badgeClass: 'sale', - price: '699 000 zł', + price: 699000, + isRent: false, title: 'Mieszkanie 2-pokojowe', location: 'Warszawa, Praga-Południe', - area: '45 m²', + area: 45, rooms: '2 pokoje', floor: '3/6 piętro', date: 'Dodane 5 dni temu', @@ -11956,10 +12032,11 @@ function AccountPublicProfilePage() { imagePosition: '70% center', badge: 'Na wynajem', badgeClass: 'rent', - price: '3 200 zł / mies.', + price: 3200, + isRent: true, title: 'Mieszkanie 2-pokojowe', location: 'Warszawa, Wola, Odolany', - area: '48 m²', + area: 48, rooms: '2 pokoje', floor: '2/7 piętro', date: 'Dodane 1 tydzień temu', @@ -12024,11 +12101,11 @@ function AccountPublicProfilePage() { {listing.badge}
- {listing.price} + {listing.isRent ? formatMonthlyPrice(listing.price) : formatPrice(listing.price)}

{listing.title}

{listing.location}
- {listing.area} + {formatArea(listing.area)} {listing.rooms} {listing.floor}
@@ -12884,6 +12961,7 @@ function CardSlider({ ariaLabel, children }: { ariaLabel: string; children: Reac function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (city: string, mode?: 'buy' | 'rent') => void }) { const navigate = useNavigate(); + const { formatPrice, formatPricePerArea, formatArea, showRecentlyViewedOnHome } = useDisplay(); const [favoriteListings, setFavoriteListings] = useState>(new Set()); const [searchMode, setSearchMode] = useState<'buy' | 'rent'>('buy'); const [recentlyViewed] = useState(() => readRecentlyViewed()); @@ -12918,7 +12996,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
{benefits.map((benefit) => (
- + @@ -12983,7 +13061,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
{insightCards.map((card) => (
- +
@@ -13010,10 +13088,10 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci

{[item.city, item.district].filter(Boolean).join(', ')}

- Mieszkanie · {listingRoomsText(item.rooms)} · {`${item.area}`.replace('.', ',')} m² + Mieszkanie · {listingRoomsText(item.rooms)} · {formatArea(item.area)}
- {formatPln(item.price)} - {Math.round(item.price / item.area).toLocaleString('pl-PL')} zł/m² + {formatPrice(item.price)} + {formatPricePerArea(item.price / item.area)}
@@ -13035,10 +13113,10 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci

{listing.location}

- {listing.meta} + {`Mieszkanie · ${listingRoomsText(listing.rooms)} · ${formatArea(listing.area)}`}
- {listing.price} - {listing.perMeter} + {formatPrice(listing.price)} + {formatPricePerArea(listing.price / listing.area)}
@@ -13049,7 +13127,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci - {recentlyViewed.length > 0 && ( + {showRecentlyViewedOnHome && recentlyViewed.length > 0 && (

Ostatnio przeglądane

@@ -13063,7 +13141,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci

{[item.city, item.district].filter(Boolean).join(', ')}

{item.title}
- {formatPln(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''} + {formatPrice(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''}
@@ -13143,6 +13221,7 @@ function RentPage({ onToggleSavedSearch: () => void; }) { const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, chips, resetAll } = filters; + const { formatPrice } = useDisplay(); const [, setSearchParams] = useSearchParams(); const [isMapOpen, setIsMapOpen] = useState(false); const [studentOnly, setStudentOnly] = useState(false); @@ -13173,7 +13252,7 @@ function RentPage({ return { id: `rent-${item.id}`, offerId: item.id, - label: `${item.price} zł`, + label: formatPrice(item.price), lat: point.lat, lng: point.lng, }; @@ -15453,7 +15532,7 @@ function GuidesPage() { // Klikalny kafelek to link do poradnika; pozostale nie maja jeszcze tresci. const content = ( <> - +

{topic.title}

@@ -15576,6 +15655,7 @@ function GuidesPage() { function SellPage() { const navigate = useNavigate(); + const { formatPrice, formatPriceNumber } = useDisplay(); return (
@@ -15650,8 +15730,8 @@ function SellPage() {
Przykładowa wycena Kraków, Zabłocie - 742 000 zł - Zakres: 720 000 - 765 000 zł + {formatPrice(742000)} + {`Zakres: ${formatPriceNumber(720000)} - ${formatPrice(765000)}`}