diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 51fa2ab..d48ffe7 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -55,6 +55,8 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/og/**").permitAll() // Historia srednich cen z ogloszen - publiczna, jak same ogloszenia. .requestMatchers(HttpMethod.GET, "/api/price-history").permitAll() + // Wskazniki rynkowe kalkulatora zdolnosci - dane publiczne z GUS i Eurostatu. + .requestMatchers(HttpMethod.GET, "/api/market-indicators").permitAll() .requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/articles").authenticated() .requestMatchers(HttpMethod.GET, "/api/banners").permitAll() diff --git a/backend/src/main/java/pl/polskalokalnie/market/EurostatRateClient.java b/backend/src/main/java/pl/polskalokalnie/market/EurostatRateClient.java new file mode 100644 index 0000000..a37baad --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/market/EurostatRateClient.java @@ -0,0 +1,85 @@ +package pl.polskalokalnie.market; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +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.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Comparator; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Stopa rynku pienieznego dla Polski z Eurostatu (zbior irt_st_m, seria IRT_M3 - stawka 3-miesieczna, + * czyli odpowiednik WIBOR 3M). Dane miesieczne, bez klucza dostepu. + * + * DLACZEGO NIE NBP: NBP publikuje srednie oprocentowanie nowych kredytow mieszkaniowych, ale pliki + * lezą pod adresami zmieniajacymi sie co miesiac, a strony nbp.pl sa chronione przed automatycznym + * pobieraniem (odpowiadaja strona bota zamiast trescia). Dziala tylko static.nbp.pl z plikiem cen + * mieszkan, ktory juz pobieramy w module historii cen. + */ +@Component +public class EurostatRateClient { + + static final String URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/irt_st_m" + + "?format=JSON&geo=PL&int_rt=IRT_M3&lastTimePeriod=6"; + + /** Wartosc stopy w procentach wraz z miesiacem, ktorego dotyczy ("2026-08"). */ + public record Rate(BigDecimal percent, String period) { + } + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + private final ObjectMapper mapper = new ObjectMapper(); + + public Rate latestThreeMonthRate() throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(URL)) + .timeout(Duration.ofSeconds(30)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new IOException("Eurostat odpowiedzial " + response.statusCode()); + } + return parseLatest(mapper.readTree(response.body())); + } + + /** + * Odpowiedz w formacie JSON-stat: wartosci leza w mapie "value" pod indeksem plaskim, a wymiar + * czasu ma wlasne indeksy. Przy jednej serii (geo=PL, int_rt=IRT_M3) indeks czasu jest zarazem + * indeksem wartosci, ale zamiast na tym polegac dobieramy wartosc po indeksie z wymiaru "time". + */ + static Rate parseLatest(JsonNode root) throws IOException { + JsonNode values = root.path("value"); + JsonNode periods = root.path("dimension").path("time").path("category").path("index"); + if (values.isMissingNode() || periods.isMissingNode() || periods.isEmpty()) { + throw new IOException("Eurostat: odpowiedz bez danych"); + } + + // Najpozniejszy miesiac z dostepna wartoscia - ostatnie okresy bywaja jeszcze puste. + Map.Entry latest = null; + for (var it = periods.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if (values.has(String.valueOf(entry.getValue().asInt())) && (latest == null + || Comparator.naturalOrder().compare(entry.getKey(), latest.getKey()) > 0)) { + latest = entry; + } + } + if (latest == null) { + throw new IOException("Eurostat: brak wartosci dla ktoregokolwiek miesiaca"); + } + BigDecimal percent = values.path(String.valueOf(latest.getValue().asInt())).decimalValue(); + if (percent.signum() <= 0) { + throw new IOException("Eurostat: nieprawidlowa wartosc stopy " + percent); + } + return new Rate(percent, latest.getKey()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/market/MarketIndicator.java b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicator.java new file mode 100644 index 0000000..e4c17bd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicator.java @@ -0,0 +1,92 @@ +package pl.polskalokalnie.market; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.Instant; + +/** + * Pojedynczy wskaznik rynkowy pobierany z zewnatrz (przecietne wynagrodzenie, stopa rynku + * pieniężnego). Trzymamy ostatnia znana wartosc razem z okresem, ktorego dotyczy, i momentem + * pobrania - dzieki temu kalkulator moze pokazac zrodlo i date, a nieudany import niczego nie psuje. + */ +@Entity +@Table(name = "market_indicators") +public class MarketIndicator { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "indicator_key", nullable = false, unique = true, length = 40) + private String key; + + @Column(nullable = false, precision = 12, scale = 4) + private BigDecimal value; + + /** Okres, ktorego dotyczy wartosc, w formie podanej przez zrodlo: "2025" albo "2026-08". */ + @Column(name = "as_of", nullable = false, length = 20) + private String asOf; + + /** Nazwa zrodla pokazywana uzytkownikowi, np. "GUS, Bank Danych Lokalnych". */ + @Column(nullable = false, length = 120) + private String source; + + @Column(name = "fetched_at", nullable = false) + private Instant fetchedAt; + + protected MarketIndicator() { + } + + public MarketIndicator(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) { + this.key = key; + this.value = value; + this.asOf = asOf; + this.source = source; + this.fetchedAt = fetchedAt; + } + + public Long getId() { + return id; + } + + public String getKey() { + return key; + } + + public BigDecimal getValue() { + return value; + } + + public void setValue(BigDecimal value) { + this.value = value; + } + + public String getAsOf() { + return asOf; + } + + public void setAsOf(String asOf) { + this.asOf = asOf; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Instant getFetchedAt() { + return fetchedAt; + } + + public void setFetchedAt(Instant fetchedAt) { + this.fetchedAt = fetchedAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorController.java b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorController.java new file mode 100644 index 0000000..266625d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorController.java @@ -0,0 +1,34 @@ +package pl.polskalokalnie.market; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Wskazniki rynkowe dla kalkulatora zdolnosci - publiczne, jak sam kalkulator. + * Odpowiedz niesie tez zrodlo i okres, zeby interfejs mogl je podpisac przy liczbach. + */ +@RestController +@RequestMapping("/api/market-indicators") +public class MarketIndicatorController { + + public record IndicatorResponse(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) { + } + + private final MarketIndicatorService service; + + public MarketIndicatorController(MarketIndicatorService service) { + this.service = service; + } + + @GetMapping + public List list() { + return service.all().stream() + .map(indicator -> new IndicatorResponse(indicator.getKey(), indicator.getValue(), + indicator.getAsOf(), indicator.getSource(), indicator.getFetchedAt())) + .toList(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorRepository.java b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorRepository.java new file mode 100644 index 0000000..b167409 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.market; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MarketIndicatorRepository extends JpaRepository { + + Optional findByKey(String key); +} diff --git a/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorService.java b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorService.java new file mode 100644 index 0000000..37adca9 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/market/MarketIndicatorService.java @@ -0,0 +1,119 @@ +package pl.polskalokalnie.market; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.polskalokalnie.pricehistory.GusBdlClient; + +/** + * Wskazniki rynkowe dla kalkulatora zdolnosci kredytowej - pobierane raz w tygodniu, tak jak ceny + * transakcyjne w module historii cen. Nieudane pobranie zostawia poprzednia wartosc; gdy zadnej + * jeszcze nie ma, kalkulator korzysta z wartosci zapasowych wpisanych w interfejsie. + */ +@Service +public class MarketIndicatorService { + + /** Przecietne miesieczne wynagrodzenie brutto w Polsce (GUS). */ + public static final String AVERAGE_WAGE = "average-wage-gross"; + /** Stopa rynku pienieznego 3M dla Polski - odpowiednik WIBOR 3M (Eurostat). */ + public static final String MONEY_MARKET_3M = "money-market-3m"; + + private static final Logger log = LoggerFactory.getLogger(MarketIndicatorService.class); + + private final MarketIndicatorRepository repository; + private final GusBdlClient gusClient; + private final EurostatRateClient eurostatClient; + + public MarketIndicatorService(MarketIndicatorRepository repository, GusBdlClient gusClient, + EurostatRateClient eurostatClient) { + this.repository = repository; + this.gusClient = gusClient; + this.eurostatClient = eurostatClient; + } + + @Transactional(readOnly = true) + public List all() { + return repository.findAll(); + } + + @Async + @EventListener(ApplicationReadyEvent.class) + public void refreshOnStartupIfStale() { + Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS); + if (isStale(AVERAGE_WAGE, weekAgo)) { + refreshAverageWage(); + } + if (isStale(MONEY_MARKET_3M, weekAgo)) { + refreshMoneyMarketRate(); + } + } + + @Scheduled(cron = "0 25 4 * * MON", zone = "Europe/Warsaw") + public void refreshWeekly() { + refreshAverageWage(); + refreshMoneyMarketRate(); + } + + public boolean refreshAverageWage() { + try { + GusBdlClient.AnnualValue wage = gusClient.latestAverageWage(); + save(AVERAGE_WAGE, wage.value(), String.valueOf(wage.year()), "GUS, Bank Danych Lokalnych"); + log.info("Wskazniki: przecietne wynagrodzenie {} zl za {}", wage.value(), wage.year()); + return true; + } catch (IOException | RuntimeException ex) { + log.warn("Wskazniki: nie udalo sie pobrac wynagrodzenia z GUS ({}) - zostaje poprzednia wartosc", + ex.getMessage()); + return false; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return false; + } + } + + public boolean refreshMoneyMarketRate() { + try { + EurostatRateClient.Rate rate = eurostatClient.latestThreeMonthRate(); + save(MONEY_MARKET_3M, rate.percent(), rate.period(), "Eurostat, stopy rynku pieniężnego (irt_st_m)"); + log.info("Wskazniki: stopa rynku pienieznego 3M {}% za {}", rate.percent(), rate.period()); + return true; + } catch (IOException | RuntimeException ex) { + log.warn("Wskazniki: nie udalo sie pobrac stopy z Eurostatu ({}) - zostaje poprzednia wartosc", + ex.getMessage()); + return false; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return false; + } + } + + private boolean isStale(String key, Instant threshold) { + return repository.findByKey(key).map(indicator -> indicator.getFetchedAt().isBefore(threshold)).orElse(true); + } + + @Transactional + protected void save(String key, BigDecimal value, String asOf, String source) { + Optional existing = repository.findByKey(key); + Instant now = Instant.now(); + if (existing.isPresent()) { + MarketIndicator indicator = existing.get(); + indicator.setValue(value); + indicator.setAsOf(asOf); + indicator.setSource(source); + indicator.setFetchedAt(now); + repository.save(indicator); + } else { + repository.save(new MarketIndicator(key, value, asOf, source, now)); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/GusBdlClient.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/GusBdlClient.java index f769260..de8994a 100644 --- a/backend/src/main/java/pl/polskalokalnie/pricehistory/GusBdlClient.java +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/GusBdlClient.java @@ -49,6 +49,35 @@ public class GusBdlClient { return matchCityUnit(results, city); } + /** Przecietne miesieczne wynagrodzenie brutto w Polsce (temat P2497, zmienna 64428, dane roczne). */ + static final long WAGE_VARIABLE_ID = 64428; + + public record AnnualValue(int year, java.math.BigDecimal value) { + } + + /** Najnowszy rok z wartoscia przecietnego wynagrodzenia brutto dla calej Polski. */ + public AnnualValue latestAverageWage() throws IOException, InterruptedException { + JsonNode response = get("/data/by-variable/" + WAGE_VARIABLE_ID + "?format=json&unit-level=0&page-size=100"); + return parseLatestAnnualValue(response); + } + + static AnnualValue parseLatestAnnualValue(JsonNode response) throws IOException { + AnnualValue latest = null; + for (JsonNode result : response.path("results")) { + for (JsonNode value : result.path("values")) { + int year = value.path("year").asInt(0); + java.math.BigDecimal amount = value.path("val").decimalValue(); + if (year > 0 && amount.signum() > 0 && (latest == null || year > latest.year())) { + latest = new AnnualValue(year, amount); + } + } + } + if (latest == null) { + throw new IOException("GUS BDL: brak wartosci przecietnego wynagrodzenia"); + } + return latest; + } + public List variables() throws IOException, InterruptedException { return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results")); } diff --git a/backend/src/test/java/pl/polskalokalnie/market/EurostatRateClientTest.java b/backend/src/test/java/pl/polskalokalnie/market/EurostatRateClientTest.java new file mode 100644 index 0000000..2737788 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/market/EurostatRateClientTest.java @@ -0,0 +1,53 @@ +package pl.polskalokalnie.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +/** Parsowanie odpowiedzi JSON-stat z Eurostatu - ksztalt jak w zbiorze irt_st_m. */ +class EurostatRateClientTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private String response(String values, String periods) { + return "{\"value\":" + values + ",\"dimension\":{\"time\":{\"category\":{\"index\":" + periods + "}}}}"; + } + + @Test + void bierzeNajpozniejszyMiesiacZWartoscia() throws Exception { + String json = response("{\"0\":3.85,\"1\":3.82,\"2\":3.83}", + "{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}"); + EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json)); + assertThat(rate.period()).isEqualTo("2026-08"); + assertThat(rate.percent()).isEqualByComparingTo("3.83"); + } + + @Test + void pomijaMiesiaceBezWartosci() throws Exception { + // Ostatni okres bywa jeszcze pusty - wtedy bierzemy poprzedni, a nie zglaszamy bledu. + String json = response("{\"0\":3.85,\"1\":3.82}", + "{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}"); + EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json)); + assertThat(rate.period()).isEqualTo("2026-07"); + assertThat(rate.percent()).isEqualByComparingTo("3.82"); + } + + @Test + void odrzucaOdpowiedzBezDanych() throws Exception { + assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree("{}"))) + .isInstanceOf(IOException.class); + assertThatThrownBy(() -> EurostatRateClient.parseLatest( + mapper.readTree(response("{}", "{\"2026-08\":0}")))) + .isInstanceOf(IOException.class); + } + + @Test + void odrzucaNieprawidlowaStope() throws Exception { + String json = response("{\"0\":0}", "{\"2026-08\":0}"); + assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree(json))) + .isInstanceOf(IOException.class); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/pricehistory/GusWageParsingTest.java b/backend/src/test/java/pl/polskalokalnie/pricehistory/GusWageParsingTest.java new file mode 100644 index 0000000..a317460 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/pricehistory/GusWageParsingTest.java @@ -0,0 +1,37 @@ +package pl.polskalokalnie.pricehistory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +/** Parsowanie rocznych wartosci z BDL - przecietne wynagrodzenie brutto dla calej Polski. */ +class GusWageParsingTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void bierzeNajnowszyRok() throws Exception { + String json = "{\"results\":[{\"id\":\"000000000000\",\"name\":\"POLSKA\",\"values\":[" + + "{\"year\":\"2023\",\"val\":7595.30},{\"year\":\"2025\",\"val\":9371.11}," + + "{\"year\":\"2024\",\"val\":8630.27}]}]}"; + GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json)); + assertThat(wage.year()).isEqualTo(2025); + assertThat(wage.value()).isEqualByComparingTo("9371.11"); + } + + @Test + void pomijaLataBezWartosci() throws Exception { + String json = "{\"results\":[{\"values\":[{\"year\":\"2024\",\"val\":8630.27},{\"year\":\"2025\",\"val\":0}]}]}"; + GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json)); + assertThat(wage.year()).isEqualTo(2024); + } + + @Test + void odrzucaPustaOdpowiedz() { + assertThatThrownBy(() -> GusBdlClient.parseLatestAnnualValue(mapper.readTree("{\"results\":[]}"))) + .isInstanceOf(IOException.class); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 170865c..8859c45 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { AdminSettingsView } from './admin/settings'; import { TermsPage, PrivacyPolicyPage, CookieBanner, hasExternalEmbedConsent } from './legal'; import { SiteFooter } from './Footer'; import { ActivationPage, PasswordResetPage, PaymentRedirectPage, PhoneOtpModal } from './AuthPages'; +import { AccountDeletionIllustration, ActivationIllustration, AddListingIllustration, LoginIllustration, MessagesIllustration, MyListingsIllustration, NotificationSettingsIllustration, PriceAlertIllustration, PromotionIllustration, RegisterIllustration, SearchIllustration } from './helpIllustrations'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; @@ -6957,30 +6958,252 @@ function AccountHelpContactPage({ ] as const; // Odpowiedzi opisuja to, co serwis naprawde robi - kazda prowadzi do wlasciwej strony. - const faqItems = [ + // FAQ podzielone na moduly serwisu. Schematyczne rysunki ekranow (helpIllustrations.tsx) + // pokazuja, gdzie co kliknac - sa inline SVG, wiec nie dezaktualizuja sie przy zmianie stylu. + const faqGroups: { id: string; title: string; icon: string; items: { question: string; answer: ReactNode }[] }[] = [ { - question: 'Jak dodać ogłoszenie?', - answer: <>Wypełnij formularz na stronie Dodaj ogłoszenie. Po wysłaniu ogłoszenie czeka na akceptację obsługi i dopiero potem pojawia się na liście ofert., + id: 'konto', + title: 'Konto i logowanie', + icon: 'user', + items: [ + { + question: 'Jak założyć konto?', + answer: ( + <> + Wejdź na stronę rejestracji i wybierz rodzaj konta: osoba prywatna albo firma. + Osoba prywatna podaje imię, nazwisko, adres e-mail i hasło; firma dodatkowo NIP, po którym pobieramy nazwę + i adres siedziby z rejestru REGON. Jedno konto służy i do kupowania, i do wystawiania ofert. + + + ), + }, + { + question: 'Zarejestrowałem się, ale nie mogę się zalogować', + answer: ( + <> + Konto trzeba najpierw aktywować linkiem, który wysyłamy na podany adres e-mail. Do tego czasu logowanie jest + zablokowane. Jeśli wiadomość nie dotarła, sprawdź folder ze spamem, a potem napisz do nas przez formularz niżej. + + + ), + }, + { + question: 'Loguję się nazwą użytkownika czy e-mailem?', + answer: ( + <> + Wyłącznie adresem e-mail. Nazwa użytkownika (nick) służy tylko do adresu Twojego profilu publicznego + (polskalokalnie.pl/u/twoj-nick) i możesz ją zmienić w ustawieniach konta. + + + ), + }, + { + question: 'Nie pamiętam hasła', + answer: ( + <> + Na stronie logowania kliknij Nie pamiętam hasła. Wyślemy link do ustawienia + nowego hasła. Po zmianie hasła wszystkie wcześniejsze sesje przestają działać - jeśli ktoś obcy miał dostęp + do konta, traci go od razu. + + ), + }, + { + question: 'Jakie dane muszę podać?', + answer: ( + <> + Tylko te, których wymaga konkretna funkcja: adres e-mail, hasło oraz imię i nazwisko albo nazwę firmy. Numer + telefonu jest dobrowolny i przydaje się do kontaktu z ogłoszenia. Nie pytamy o datę urodzenia, a adresu + zamieszkania nie zbieramy od osób prywatnych - adres siedziby dotyczy wyłącznie kont firmowych. Szczegóły + w Polityce prywatności. + + ), + }, + { + question: 'Jak usunąć konto?', + answer: ( + <> + W ustawieniach konta, w sekcji Bezpieczeństwo. Usunięcie kasuje ogłoszenia, + wiadomości, ulubione, zapisane wyszukiwania, alerty i dane profilu. Zostają tylko rozliczenia płatności i zgłoszenia + moderacyjne, których wymagają od nas przepisy. + + + ), + }, + ], }, { - question: 'Ile kosztuje dodanie ogłoszenia?', - answer: <>Nic. Dodanie i publikacja ogłoszenia są bezpłatne, nie pobieramy prowizji od sprzedaży. Płatne jest tylko dobrowolne wyróżnienie oferty., + id: 'ogloszenia', + title: 'Ogłoszenia', + icon: 'document', + items: [ + { + question: 'Jak dodać ogłoszenie?', + answer: ( + <> + Wypełnij formularz na stronie Dodaj ogłoszenie: rodzaj oferty (sprzedaż albo wynajem), + tytuł, cenę, metraż, lokalizację i zdjęcia. Po wysłaniu ogłoszenie ma status „oczekuje" i czeka na sprawdzenie + przez obsługę - dopiero potem pojawia się na liście ofert. + + + ), + }, + { + question: 'Ile kosztuje dodanie ogłoszenia?', + answer: <>Nic. Dodanie i publikacja są bezpłatne, nie pobieramy też prowizji od sprzedaży. Płatne jest wyłącznie dobrowolne wyróżnienie oferty., + }, + { + question: 'Jak edytować, wstrzymać albo usunąć ogłoszenie?', + answer: ( + <> + Wszystkie akcje są w panelu Moje oferty, przy każdym ogłoszeniu. + Wstrzymane ogłoszenie znika z listy ofert, ale zostaje na Twoim koncie i możesz je wznowić. Po edycji ogłoszenie + wraca do sprawdzenia przez obsługę. + + + ), + }, + { + question: 'Dlaczego moje ogłoszenie czeka na akceptację?', + answer: ( + <> + Każde nowe i każde zmienione ogłoszenie sprawdzamy przed publikacją - chodzi o dane kontaktowe w treści, + wprowadzające w błąd opisy i oferty pośredników podszywających się pod właścicieli. O decyzji informujemy + powiadomieniem na Twoim koncie. + + ), + }, + { + question: 'Jak wyróżnić ogłoszenie?', + answer: ( + <> + W panelu Moje oferty kliknij „Wyróżnij" przy zaakceptowanym ogłoszeniu. + Wybierasz czas wyróżnienia i płacisz przez AutoPay albo zużywasz kredyt z wcześniej kupionego pakietu. + Wyróżnione ogłoszenia pokazują się wyżej na liście ofert. + + + ), + }, + { + question: 'Zapłaciłem, a ogłoszenie nie jest wyróżnione', + answer: ( + <> + Potwierdzenie z bramki płatniczej potrafi dotrzeć z opóźnieniem - odśwież panel po kilku minutach. Jeśli + wyróżnienie nadal nie działa, napisz do nas przez formularz niżej i podaj numer ogłoszenia; sprawdzimy + zamówienie po stronie operatora płatności. + + ), + }, + ], }, { - question: 'Jak wyróżnić moje ogłoszenie?', - answer: <>W panelu Moje oferty kliknij „Wyróżnij” przy zaakceptowanym ogłoszeniu. Wybierzesz czas wyróżnienia i zapłacisz przez AutoPay albo użyjesz kredytów z wcześniej kupionego pakietu., + id: 'szukanie', + title: 'Szukanie i narzędzia', + icon: 'search', + items: [ + { + question: 'Jak szukać ofert i zapisać wyszukiwanie?', + answer: ( + <> + Filtry ustawiasz na liście Kupuję albo Wynajmuję. + Wszystkie trafiają do adresu strony, więc wynik da się odświeżyć, wysłać znajomemu i otworzyć w nowej karcie. + Przycisk „Zapisz wyszukiwanie" zapamiętuje kryteria na koncie - gdy pojawi się pasujące ogłoszenie, dostaniesz + powiadomienie. + + + ), + }, + { + question: 'Jak działają alerty cenowe?', + answer: ( + <> + Alert ceny konkretnej oferty założysz przyciskiem na jej stronie, a alert dla całej lokalizacji + w Historii cen. Ceny sprawdzamy raz na dobę, a o zmianach informujemy + wyłącznie na liście powiadomień w serwisie - nie wysyłamy z nich maili ani SMS-ów. Wszystkie alerty zobaczysz + w panelu Alerty cenowe. + + + ), + }, + { + question: 'Skąd pochodzą dane w Historii cen i wycenie?', + answer: ( + <> + Z trzech źródeł: cen transakcyjnych Narodowego Banku Polskiego, danych Głównego Urzędu Statystycznego oraz + średnich cen ofertowych z ogłoszeń w naszym serwisie. Przy każdym wykresie podpisujemy, które źródło + pokazujemy - dane NBP i GUS dotyczą całego miasta, nie pojedynczej dzielnicy. + + ), + }, + { + question: 'Co robi porównywarka mieszkań?', + answer: ( + <> + Na liście ofert dodajesz mieszkania do porównania, a w Porównaniu widzisz je + obok siebie: cenę, metraż, cenę za m², liczbę pokoi i pozostałe parametry. Wybór trzymamy w adresie strony, + więc porównanie też da się wysłać. + + ), + }, + { + question: 'Do czego służy „Ile mogę utargować"?', + answer: ( + <> + Analiza negocjacji porównuje cenę ofertową z cenami transakcyjnymi NBP i GUS + dla danego miasta oraz z innymi ogłoszeniami w okolicy. Wynik to orientacyjny punkt wyjścia do rozmowy, + a nie wycena rzeczoznawcy. + + ), + }, + ], }, { - question: 'Jak edytować lub usunąć ogłoszenie?', - answer: <>Obie opcje są w panelu Moje oferty, przy każdym ogłoszeniu. Po edycji ogłoszenie ponownie trafia do akceptacji., - }, - { - question: 'Jak działa porównywarka mieszkań?', - answer: <>Na liście ofert dodajesz mieszkania do porównania, a w Porównaniu widzisz je obok siebie: cenę, metraż, cenę za m² i pozostałe parametry., - }, - { - question: 'Jak ustawić alerty cenowe?', - answer: <>Alert ceny ogłoszenia założysz przyciskiem na karcie oferty, a alert lokalizacji w Historii cen. Wszystkie zobaczysz w panelu Alerty cenowe. Nie wysyłamy jeszcze powiadomień e-mail., + id: 'kontakt', + title: 'Wiadomości i oglądania', + icon: 'message', + items: [ + { + question: 'Jak skontaktować się ze sprzedającym?', + answer: ( + <> + Przyciskiem „Napisz wiadomość" na stronie ogłoszenia. Rozmowa dotyczy konkretnej oferty i znajdziesz ją + w zakładce Wiadomości. Numer telefonu widzisz, jeśli ogłaszający zgodził się + go pokazać. + + + ), + }, + { + question: 'Jak umówić się na oglądanie?', + answer: ( + <> + W rozmowie o ogłoszeniu proponujesz termin, a druga strona go potwierdza albo odrzuca. Potwierdzone terminy + trafiają do kalendarza obu stron w panelu Umówione spotkania. Każda ze + stron może termin odwołać. + + ), + }, + { + question: 'Czym sterują przełączniki powiadomień?', + answer: ( + <> + Tym, co trafia na listę powiadomień i pod dzwonek w nagłówku: nowymi + ofertami z zapisanych wyszukiwań, alertami cenowymi oraz wiadomościami i terminami oglądania. Znajdziesz je + w ustawieniach konta. Zgody na newsletter i SMS to osobna sekcja - + dotyczą wiadomości, które wysyłamy my. + + + ), + }, + { + question: 'Ktoś zachowuje się nieuczciwie - co zrobić?', + answer: ( + <> + Przy ogłoszeniu i w rozmowie jest przycisk zgłoszenia. Możesz też zablokować rozmówcę w zakładce Wiadomości albo + napisać do nas przez formularz niżej. Każde zgłoszenie sprawdzamy i informujemy o decyzji. + + ), + }, + ], }, ]; @@ -7209,17 +7432,22 @@ function AccountHelpContactPage({

Najczęściej zadawane pytania

Poradniki -
- {faqItems.map((item) => ( -
- - {item.question} - - -

{item.answer}

-
- ))} -
+ {faqGroups.map((group) => ( +
+

{group.title}

+
+ {group.items.map((item) => ( +
+ + {item.question} + + +
{item.answer}
+
+ ))} +
+
+ ))}
@@ -17671,6 +17899,8 @@ type CreditMaritalStatus = 'Panna / kawaler' | 'W związku' | 'Małżeństwo' | type CreditIncomeSource = 'Umowa o pracę' | 'Umowa zlecenie' | 'Działalność gospodarcza' | 'B2B' | 'Emerytura' | 'Renta' | 'Dochód z najmu'; type CreditSeniority = 'do 3 miesięcy' | '3-12 miesięcy' | '1-3 lata' | 'powyżej 3 lat'; type CreditRepayment = 'równe' | 'malejące'; +// Rodzaj oprocentowania decyduje o wysokosci bufora przy badaniu zdolnosci (Rekomendacja S). +type CreditRateType = 'stałe' | 'zmienne'; type CreditForm = { age: string; @@ -17691,6 +17921,7 @@ type CreditForm = { ownContribution: string; loanYears: number; interestRate: string; + rateType: CreditRateType; repayment: CreditRepayment; }; @@ -17716,7 +17947,9 @@ const INITIAL_CREDIT_FORM: CreditForm = { propertyPrice: '', ownContribution: '', loanYears: 25, + // Wartosc startowa; po wczytaniu wskaznikow rynkowych podstawiamy stope z Eurostatu + marze. interestRate: '7,2', + rateType: 'zmienne', repayment: 'równe', }; @@ -17729,7 +17962,10 @@ const CREDIT_MAX_YEARS = 35; // Metodyka jak w bankach (Rekomendacja S KNF) - bez tych regul kalkulator zawyza zdolnosc: // 1. Zdolnosc bada sie przy stopie podwyzszonej o bufor, a nie przy stopie z umowy. -const CREDIT_STRESS_BUFFER_PP = 2.5; +// Bufor zalezy od rodzaju oprocentowania: przy stalej stopie ryzyko wzrostu raty w okresie +// stalosci jest mniejsze, przy zmiennej - pelne, wiec bufor jest wyzszy. +const CREDIT_STRESS_BUFFER_FIXED_PP = 2.5; +const CREDIT_STRESS_BUFFER_VARIABLE_PP = 5; // 2. Nawet dla kredytu na 35 lat zdolnosc liczy sie tak, jakby okres wynosil maksymalnie 25 lat. const CREDIT_CAPACITY_MAX_YEARS = 25; // 3. Kredyt powinien zostac splacony do 70. roku zycia kredytobiorcy. @@ -17737,7 +17973,13 @@ const CREDIT_MAX_AGE_AT_PAYOFF = 70; // 4. Laczna obsluga dlugu (DStI): do 40% dochodu, powyzej przecietnego wynagrodzenia do 50%. const CREDIT_DSTI_STANDARD = 0.4; const CREDIT_DSTI_HIGH_INCOME = 0.5; -const CREDIT_HIGH_INCOME_THRESHOLD = 8000; +// Prog uzywany, dopoki nie wczytamy przecietnego wynagrodzenia z GUS (np. gdy API nie odpowiada). +const CREDIT_HIGH_INCOME_FALLBACK = 8000; +// GUS podaje wynagrodzenie brutto, a kalkulator pyta o dochod netto. Przelicznik dla umowy +// o prace - przyblizenie, bo faktyczna kwota zalezy od ulg, progu podatkowego i PPK. +const CREDIT_NET_FROM_GROSS = 0.72; +// Stopa rynku pienieznego to tylko czesc oprocentowania kredytu - bank dolicza wlasna marze. +const CREDIT_BANK_MARGIN_PP = 2; // 5. Bank finansuje maksymalnie 90% wartosci nieruchomosci - reszta to wkład własny. const CREDIT_MAX_LTV = 0.9; @@ -17752,6 +17994,11 @@ const CREDIT_INCOME_FACTORS: Record = { 'Dochód z najmu': 0.7, }; +/** "2,5" zamiast "2.5", bez zbednego zera po przecinku ("5" zamiast "5,0"). */ +function formatBufferPp(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1).replace('.', ','); +} + function creditNum(value: string): number { const parsed = parseNumber(value); return parsed == null || parsed < 0 ? 0 : parsed; @@ -17780,9 +18027,20 @@ function principalForInstallment(installment: number, monthlyRate: number, month return (installment * (1 - Math.pow(1 + monthlyRate, -months))) / monthlyRate; } +/** Odpowiedz GET /api/market-indicators - wskazniki pobierane co tydzien z GUS i Eurostatu. */ +type ApiMarketIndicator = { key: string; value: number; asOf: string; source: string; fetchedAt: string }; + type CreditResult = ReturnType; -function calculateCreditworthiness(form: CreditForm) { +/** + * Wskazniki pobierane z /api/market-indicators (GUS i Eurostat). Null oznacza, ze jeszcze nie + * dotarly albo zrodlo nie odpowiedzialo - kalkulator dziala wtedy na wartosciach zapasowych. + */ +type MarketIndicator = { value: number; asOf: string; source: string }; +type CreditMarket = { averageWage: MarketIndicator | null; moneyMarketRate: MarketIndicator | null }; +const EMPTY_CREDIT_MARKET: CreditMarket = { averageWage: null, moneyMarketRate: null }; + +function calculateCreditworthiness(form: CreditForm, market: CreditMarket = EMPTY_CREDIT_MARKET) { const declaredIncome = creditNum(form.income); const declaredPartnerIncome = creditNum(form.partnerIncome); // Bank uznaje dochod w zaleznosci od formy zatrudnienia (umowa o prace w 100%, B2B mniej). @@ -17801,8 +18059,13 @@ function calculateCreditworthiness(form: CreditForm) { const totalObligations = declaredObligations + cardCharge + accountCharge; const disposable = totalIncome - livingCosts - totalObligations; + // Prog wyzszego DStI to przecietne wynagrodzenie z GUS (brutto) przeliczone na netto, bo + // kalkulator pyta o dochod netto. Bez danych z GUS zostaje wartosc zapasowa. + const highIncomeThreshold = market.averageWage + ? Math.round(market.averageWage.value * CREDIT_NET_FROM_GROSS) + : CREDIT_HIGH_INCOME_FALLBACK; // DStI obejmuje CALA obsluge dlugu, wiec od limitu odejmujemy raty juz splacane. - const dstiLimit = totalIncome > CREDIT_HIGH_INCOME_THRESHOLD ? CREDIT_DSTI_HIGH_INCOME : CREDIT_DSTI_STANDARD; + const dstiLimit = totalIncome > highIncomeThreshold ? CREDIT_DSTI_HIGH_INCOME : CREDIT_DSTI_STANDARD; const maxRateByDsti = totalIncome * dstiLimit - totalObligations; const maxInstallment = Math.max(0, Math.min(maxRateByDsti, disposable)); @@ -17818,7 +18081,8 @@ function calculateCreditworthiness(form: CreditForm) { const decreasing = form.repayment === 'malejące'; // Zdolnosc liczymy przy stopie z buforem i okresie nie dluzszym niz 25 lat (Rekomendacja S). - const stressRatePercent = creditNum(form.interestRate) + CREDIT_STRESS_BUFFER_PP; + const stressBufferPp = form.rateType === 'stałe' ? CREDIT_STRESS_BUFFER_FIXED_PP : CREDIT_STRESS_BUFFER_VARIABLE_PP; + const stressRatePercent = creditNum(form.interestRate) + stressBufferPp; const stressMonthlyRate = stressRatePercent / 100 / 12; const capacityYears = Math.min(years, CREDIT_CAPACITY_MAX_YEARS); const capacityMonths = capacityYears * 12; @@ -17894,8 +18158,8 @@ function calculateCreditworthiness(form: CreditForm) { income, partnerIncome, totalIncome, declaredIncome, declaredPartnerIncome, declaredTotalIncome, incomeFactor, adults, children, livingCosts, cardCharge, accountCharge, declaredObligations, totalObligations, - disposable, maxInstallment, dstiLimit, years, months, monthlyRate, decreasing, - stressRatePercent, capacityYears, maxLoan, ownContribution, maxPropertyPrice, + disposable, maxInstallment, dstiLimit, highIncomeThreshold, years, months, monthlyRate, decreasing, + stressRatePercent, stressBufferPp, capacityYears, maxLoan, ownContribution, maxPropertyPrice, recommendedOwnContribution, requiredOwnContribution, contributionBasePrice, propertyPrice, loanForPrice, ltv, ltvTooHigh, projectedInstallment, leftoverAfterInstallment, dsti, dti, hasIncome, ready, priceExceedsCapacity, ratingKey, warnings, @@ -17953,7 +18217,34 @@ function CreditCalculatorPage({ onSearchWithBudget }: { onSearchWithBudget: (max const [form, setForm] = useState(INITIAL_CREDIT_FORM); const [highlight, setHighlight] = useState(false); const resultRef = useRef(null); - const result = calculateCreditworthiness(form); + // Wskazniki rynkowe z GUS i Eurostatu. Gdy nie dotra, kalkulator liczy na wartosciach zapasowych. + const [market, setMarket] = useState(EMPTY_CREDIT_MARKET); + const result = calculateCreditworthiness(form, market); + + useEffect(() => { + let cancelled = false; + apiFetch('/market-indicators') + .then((indicators) => { + if (cancelled) { + return; + } + const pick = (key: string): MarketIndicator | null => { + const found = indicators.find((item) => item.key === key); + return found && found.value > 0 ? { value: found.value, asOf: found.asOf, source: found.source } : null; + }; + const rate = pick('money-market-3m'); + setMarket({ averageWage: pick('average-wage-gross'), moneyMarketRate: rate }); + // Domyslne oprocentowanie podstawiamy tylko wtedy, gdy uzytkownik go jeszcze nie ruszyl. + if (rate) { + const suggested = (rate.value + CREDIT_BANK_MARGIN_PP).toFixed(2).replace('.', ','); + setForm((current) => (current.interestRate === INITIAL_CREDIT_FORM.interestRate + ? { ...current, interestRate: suggested } + : current)); + } + }) + .catch(() => { /* brak wskaznikow - zostaja wartosci zapasowe */ }); + return () => { cancelled = true; }; + }, []); const rating = CREDIT_RATING_META[result.ratingKey]; const recommendations = buildCreditRecommendations(form, result.maxPropertyPrice); const fmtPln = (value: number) => formatPln(Math.round(value)); @@ -17984,8 +18275,8 @@ function CreditCalculatorPage({ onSearchWithBudget }: { onSearchWithBudget: (max { label: 'Koszty utrzymania gospodarstwa', value: fmtPln(result.livingCosts) }, { label: 'Łączne zobowiązania', value: fmtPln(result.totalObligations) }, { label: 'Dochód po odliczeniach', value: fmtPln(Math.max(0, result.disposable)) }, - { label: `Limit obsługi długu (DStI ${formatPercent(result.dstiLimit)})`, value: fmtPln(result.maxInstallment) }, - { label: 'Stopa do badania zdolności (z buforem 2,5 p.p.)', value: `${result.stressRatePercent.toFixed(2).replace('.', ',')} %` }, + { label: `Limit obsługi długu (DStI ${formatPercent(result.dstiLimit)}, próg ${fmtPln(result.highIncomeThreshold)} netto)`, value: fmtPln(result.maxInstallment) }, + { label: `Stopa do badania zdolności (bufor ${formatBufferPp(result.stressBufferPp)} p.p.)`, value: `${result.stressRatePercent.toFixed(2).replace('.', ',')} %` }, { label: 'Okres przyjęty do zdolności', value: `${result.capacityYears} lat` }, { label: 'Maksymalna kwota kredytu', value: fmtPln(result.maxLoan) }, { label: 'Maksymalna cena nieruchomości', value: fmtPln(result.maxPropertyPrice) }, @@ -18131,6 +18422,17 @@ function CreditCalculatorPage({ onSearchWithBudget }: { onSearchWithBudget: (max +
+ Rodzaj oprocentowania +
+ + +
+ + Przy zmiennej stopie bank bada zdolność z wyższym buforem + ({formatBufferPp(CREDIT_STRESS_BUFFER_VARIABLE_PP)} zamiast {formatBufferPp(CREDIT_STRESS_BUFFER_FIXED_PP)} p.p.), więc zdolność wychodzi niższa. + +
@@ -18215,10 +18517,32 @@ function CreditCalculatorPage({ onSearchWithBudget }: { onSearchWithBudget: (max

Wyliczenie stosuje zasady z Rekomendacji S KNF: zdolność badana przy stopie podwyższonej - o {CREDIT_STRESS_BUFFER_PP.toFixed(1).replace('.', ',')} p.p., okres kredytowania przyjęty do zdolności - maksymalnie {CREDIT_CAPACITY_MAX_YEARS} lat, kredyt do {formatPercent(CREDIT_MAX_LTV)} wartości nieruchomości. + o {formatBufferPp(result.stressBufferPp)} p.p. (oprocentowanie {form.rateType}), okres kredytowania + przyjęty do zdolności maksymalnie {CREDIT_CAPACITY_MAX_YEARS} lat, kredyt + do {formatPercent(CREDIT_MAX_LTV)} wartości nieruchomości. To symulacja orientacyjna - każdy bank stosuje własną metodykę.

+ + {(market.averageWage || market.moneyMarketRate) && ( +

+ Dane rynkowe odświeżamy co tydzień. + {market.averageWage && ( + <> + {' '}Próg wyższego limitu DStI to przeciętne wynagrodzenie {fmtPln(market.averageWage.value)} brutto + za {market.averageWage.asOf} r. ({market.averageWage.source}), przeliczone na netto + wskaźnikiem {formatPercent(CREDIT_NET_FROM_GROSS)}. + + )} + {market.moneyMarketRate && ( + <> + {' '}Podpowiadane oprocentowanie to stopa rynku pieniężnego 3M + {' '}{market.moneyMarketRate.value.toFixed(2).replace('.', ',')}% za {market.moneyMarketRate.asOf} + {' '}({market.moneyMarketRate.source}) powiększona o {formatBufferPp(CREDIT_BANK_MARGIN_PP)} p.p. + {' '}przykładowej marży banku - własną marżę wpisz w polu oprocentowania. + + )} +

+ )}
diff --git a/frontend/src/helpIllustrations.tsx b/frontend/src/helpIllustrations.tsx new file mode 100644 index 0000000..f9b1f4d --- /dev/null +++ b/frontend/src/helpIllustrations.tsx @@ -0,0 +1,338 @@ +/** + * Schematyczne ilustracje ekranow serwisu dla strony /konto/pomoc. + * + * SWIADOMY WYBOR: to inline SVG, a NIE zrzuty ekranu. Rysuja uklad i etykiety ("gdzie co kliknac"), + * nie piksel-perfect wyglad - dzieki temu nie dezaktualizuja sie przy zmianach stylu, sa lekkie + * (zadnych plikow binarnych w repozytorium) i edytuje sie je jak zwykly kod. To samo rozwiazanie + * co w Gieldzie Komunalnej (components/helpIllustrations.tsx). + * + * UTRZYMANIE: przy zmianie ukladu ekranu, nazw pol albo przebiegu poprawiaj ilustracje razem + * z odpowiedzia w FAQ. Drobne zmiany kolorow czy odstepow w aplikacji ilustracji nie uniewazniaja. + */ + +import type { ReactNode } from 'react'; + +/** Wspolna ramka: rysunek w ramce i podpis pod nim. */ +function Figure({ caption, children }: { caption: string; children: ReactNode }) { + return ( +
+
{children}
+
{caption}
+
+ ); +} + +/* Klasy powtarzane w rysunkach - kolory siedza w styles.css, nie w atrybutach. */ +const BOX = 'hf-box'; +const LABEL = 'hf-label'; +const SMALL = 'hf-label-sm'; +const TITLE = 'hf-title'; + +/** Rejestracja: wybor rodzaju konta na starcie. */ +export function RegisterIllustration() { + return ( +
+ + Załóż konto + Rodzaj konta + + + + Osoba prywatna + + + + Firma (NIP z rejestru GUS) + + + Adres e-mail + + Hasło (min. 8 znaków) + + + Akceptuję Regulamin i Politykę prywatności + +
+ ); +} + +/** Aktywacja konta: link z e-maila. */ +export function ActivationIllustration() { + return ( +
+ + {[ + { x: 12, title: '1. Rejestracja', sub: 'formularz na stronie' }, + { x: 188, title: '2. E-mail', sub: 'link aktywacyjny' }, + { x: 364, title: '3. Logowanie', sub: 'konto gotowe' }, + ].map((step) => ( + + + {step.title} + {step.sub} + + ))} + + + +
+ ); +} + +/** Logowanie wylacznie adresem e-mail. */ +export function LoginIllustration() { + return ( +
+ + Zaloguj się + + jan.kowalski@example.com + + •••••••• + Nie pamiętam hasła + + Zaloguj się + +
+ ); +} + +/** Formularz dodawania ogloszenia i droga przez moderacje. */ +export function AddListingIllustration() { + return ( +
+ + Dodaj ogłoszenie + + + Sprzedaż + + Wynajem + + + Tytuł ogłoszenia + + Cena + + Metraż + + Zdjęcia - przeciągnij albo wybierz z dysku + + {[ + { y: 36, title: 'Wysyłasz', sub: 'status: oczekuje' }, + { y: 90, title: 'Moderacja', sub: 'sprawdzamy treść' }, + { y: 144, title: 'Publikacja', sub: 'widoczne w ofertach' }, + ].map((step) => ( + + + + {step.title} + {step.sub} + + ))} + +
+ ); +} + +/** Panel "Moje oferty" z akcjami przy ogloszeniu. */ +export function MyListingsIllustration() { + return ( +
+ + Moje oferty + {[ + { y: 34, status: 'Opublikowane', tone: 'hf-fill-ok' }, + { y: 104, status: 'Oczekuje', tone: 'hf-fill-wait' }, + ].map((row) => ( + + + + + + {row.status} + 690 000 zł + Gdańsk, 58 m² + {['Edytuj', 'Wstrzymaj', 'Wyróżnij'].map((action, index) => ( + + + {action} + + ))} + + ))} + +
+ ); +} + +/** Wyroznienie ogloszenia i platnosc. */ +export function PromotionIllustration() { + return ( +
+ + Wyróżnij ogłoszenie + {/* Bez konkretnych kwot - cennik ustawia administrator i moze sie zmienic, + a rysunek ma pokazywac uklad ekranu, nie aktualna oferte. */} + {[ + { x: 12, name: 'Krótkie', active: true }, + { x: 184, name: 'Średnie', active: false }, + { x: 356, name: 'Długie', active: false }, + ].map((plan) => ( + + + {plan.name} wyróżnienie + liczba dni i cena z cennika + + ))} + {[ + { x: 12, label: 'Wybór pakietu' }, + { x: 188, label: 'AutoPay - płatność' }, + { x: 364, label: 'Ogłoszenie na górze listy' }, + ].map((step) => ( + + + {step.label} + + ))} + + + +
+ ); +} + +/** Wyszukiwanie z filtrami i zapisem wyszukiwania. */ +export function SearchIllustration() { + return ( +
+ + {[ + { x: 12, w: 150, label: 'Lokalizacja', value: 'Gdańsk' }, + { x: 170, w: 120, label: 'Rodzaj', value: 'Mieszkanie' }, + { x: 298, w: 100, label: 'Cena do', value: '800 000' }, + ].map((field) => ( + + + {field.label} + {field.value} + + ))} + + Szukaj + + + Zapisz wyszukiwanie + Nowe pasujące oferty trafią na listę powiadomień. + + Adres: /kupno?miasto=Gdańsk&typ=Mieszkanie&cena_do=800000 + +
+ ); +} + +/** Alerty cenowe - gdzie sie je zaklada i gdzie ladują. */ +export function PriceAlertIllustration() { + return ( +
+ + + Karta oferty + Utwórz alert ceny + + + Historia cen + Utwórz alert cenowy + + + + Cena spadła: Gdańsk, Wrzeszcz + 690 000 zł → 659 000 zł (-4,5%) + Lista powiadomień w serwisie + + + +
+ ); +} + +/** Wiadomosci i umawianie ogladania. */ +export function MessagesIllustration() { + return ( +
+ + + Rozmowy + {[52, 84, 116].map((y, index) => ( + + + Mieszkanie {index + 1} + + ))} + + + + Czy mieszkanie jest dostępne? + + Tak, zapraszam na oglądanie + + + Zaproponuj termin oglądania + Trafi do kalendarza + +
+ ); +} + +/** Ustawienia powiadomien. */ +export function NotificationSettingsIllustration() { + return ( +
+ + Powiadomienia + {[ + { y: 32, label: 'Nowe oferty z zapisanych wyszukiwań', on: true }, + { y: 62, label: 'Alerty cenowe', on: true }, + { y: 92, label: 'Wiadomości i terminy oglądania', on: true }, + ].map((row) => ( + + + {row.label} + + + + ))} + Niżej, pod kreską: zgody marketingowe (newsletter i SMS) - osobna sprawa. + +
+ ); +} + +/** Usuniecie konta i zakres kasowanych danych. */ +export function AccountDeletionIllustration() { + return ( +
+ + + Kasujemy + ogłoszenia, wiadomości, ulubione, + zapisane wyszukiwania, alerty, + terminy oglądania, dane profilu + + + Zostaje + rozliczenia płatności (5 lat), + zgłoszenia i moderacja (do 3 lat) + - wymagają tego przepisy + +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index eb165b0..9fdaaa8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -6214,12 +6214,14 @@ a.city-card:hover { gap: 12px 16px; } +/* Jak w kreatorze wyceny: kolumna nie schodzi ponizej czytelnej szerokosci, a nadmiar + kafelkow przechodzi do kolejnego wiersza. */ .media-option-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .amenities-option-grid { - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); } .extra-option-grid label { @@ -8106,10 +8108,13 @@ a.city-card:hover { grid-template-columns: minmax(0, 1fr) max-content; } +/* auto-fit z minimalna szerokoscia kolumny zamiast sztywnych pieciu: na wezszym monitorze + kafelki schodza do kolejnego wiersza, zamiast sciskac sie do kilkudziesieciu pikseli + i lamac podpisy po literach. */ .valuation-condition-grid { display: grid; gap: 18px; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .valuation-condition-grid button { @@ -8162,7 +8167,7 @@ a.city-card:hover { .valuation-amenity-grid { display: grid; gap: 16px 18px; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); } .valuation-amenity-grid button { @@ -8189,7 +8194,9 @@ a.city-card:hover { font-size: 12px; font-weight: 950; line-height: 1.25; - overflow-wrap: anywhere; + /* `break-word`, nie `anywhere`: podpisy to krotkie nazwy, wiec maja sie lamac miedzy + slowami. `anywhere` rozbijalo "Ogrzewanie podlogowe" na pojedyncze litery. */ + overflow-wrap: break-word; white-space: normal; } @@ -12568,11 +12575,6 @@ a.city-card:hover { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .valuation-condition-grid, - .valuation-amenity-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .valuation-benefit-grid { grid-template-columns: 1fr; } @@ -13257,9 +13259,7 @@ a.city-card:hover { gap: 10px; } - .extra-details-grid, - .media-option-grid, - .amenities-option-grid { + .extra-details-grid { grid-template-columns: 1fr; } @@ -13338,11 +13338,6 @@ a.city-card:hover { width: 100%; } - .valuation-condition-grid, - .valuation-amenity-grid { - grid-template-columns: 1fr; - } - .valuation-toggle-wide { max-width: none; } @@ -15807,10 +15802,12 @@ a.city-card:hover { margin: 0 0 14px; } +/* auto-fit zamiast sztywnych czterech kolumn: trzy kanaly kontaktu rozkladaja sie na cala + szerokosc panelu, zamiast zostawiac pusta kolumne po prawej. */ .help-contact-channel-grid { display: grid; gap: 12px; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } .help-contact-channel-card { @@ -16046,6 +16043,153 @@ a.city-card:hover { margin: 0 0 12px; } +/* --- FAQ po modulach serwisu --------------------------------------------------------- */ +.help-faq-group + .help-faq-group { + margin-top: 18px; +} + +.help-faq-group h3 { + align-items: center; + color: #1d2f49; + display: flex; + font-size: 15px; + gap: 8px; + margin: 0 0 8px; +} + +.help-faq-group h3 svg { + color: #10a15e; + font-size: 18px; +} + +.help-faq-answer { + color: #60748c; + font-size: 12.5px; + font-weight: 740; + line-height: 1.5; + padding-bottom: 12px; +} + +.help-faq-answer a { + color: #0f9c5b; + font-weight: 850; +} + +/* --- Schematyczne rysunki ekranow (helpIllustrations.tsx) ----------------------------- */ +.help-figure { + margin: 12px 0 0; +} + +.help-figure-frame { + background: #f7f9fc; + border: 1px solid #e3eaf2; + border-radius: 10px; + overflow-x: auto; + padding: 10px; +} + +.help-figure svg { + display: block; + height: auto; + min-width: 460px; + width: 100%; +} + +.help-figure figcaption { + color: #7d8da2; + font-size: 11px; + font-weight: 800; + line-height: 1.4; + margin-top: 6px; +} + +/* Kolory rysunkow - jeden zestaw dla wszystkich ilustracji. Globalna regula `svg` ustawia + fill:none i stroke:currentColor, wiec kazda klasa musi podac swoje wypelnienie. */ +.hf-box { + fill: #ffffff; + stroke: #d8e1ec; +} + +.hf-box.hf-active { + fill: rgba(16, 161, 94, 0.08); + stroke: #10a15e; +} + +.hf-fill-active { + fill: #10a15e; + stroke: none; +} + +.hf-fill-ok { + fill: #16a765; + stroke: none; +} + +.hf-fill-wait { + fill: #e0a32e; + stroke: none; +} + +.hf-photo { + fill: #e6ecf4; + stroke: none; +} + +.hf-line { + fill: #cfd9e5; + stroke: none; +} + +.hf-dot { + fill: #cfd9e5; + stroke: none; +} + +.hf-dot-active { + fill: #10a15e; + stroke: none; +} + +.hf-knob { + fill: #ffffff; + stroke: none; +} + +.hf-arrow { + fill: none; + stroke: #9fb0c4; + stroke-width: 1.6; +} + +.hf-title { + fill: #1d2f49; + font-size: 11px; + font-weight: 900; + stroke: none; +} + +.hf-label { + fill: #5b6d84; + font-size: 10.5px; + font-weight: 800; + stroke: none; +} + +.hf-label-sm { + fill: #7d8da2; + font-size: 9.5px; + font-weight: 800; + stroke: none; +} + +.hf-text-active { + fill: #0f8f55; +} + +.hf-text-white { + fill: #ffffff; +} + .help-contact-form-card form { display: grid; gap: 10px; @@ -16116,7 +16260,7 @@ a.city-card:hover { .help-contact-more-grid { display: grid; gap: 12px; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } .help-contact-more-card { @@ -19678,6 +19822,14 @@ a.city-card:hover { grid-template-columns: 1fr; } + /* W jednej kolumnie menu konta stoi nad trescia. Przyklejenie (sticky) trzymalo je + przy gornej krawedzi i zaslanialo to, co pod nim - z klikniecia w tresc robil sie + skok do innej zakladki. */ + .account-sidebar { + position: static; + top: auto; + } + .account-grid { grid-template-columns: 1fr; } diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index 26a63d3..bcb7313 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -853,3 +853,163 @@ test.describe('Ustawienia powiadomien', () => { await page.waitForTimeout(700); }); }); + +// Pomoc i kontakt: kanaly kontaktu wypelniaja szerokosc panelu, a FAQ jest podzielone +// na moduly serwisu i pokazuje schematyczne rysunki ekranow. +test.describe('Pomoc i kontakt', () => { + test('kanaly kontaktu zajmuja cala szerokosc panelu', async ({ page }) => { + await login(page); + await goto(page, ROUTES.accountHelpContact); + + const grid = await page.locator('.help-contact-channel-grid').boundingBox(); + const cards = await page.locator('.help-contact-channel-card').all(); + expect(cards.length).toBeGreaterThan(0); + const last = await cards[cards.length - 1].boundingBox(); + // Ostatnia karta konczy sie na prawej krawedzi siatki - zadnej pustej kolumny. + expect(grid!.x + grid!.width - (last!.x + last!.width)).toBeLessThanOrEqual(2); + }); + + test('FAQ ma grupy modulow i rysunek w odpowiedzi', async ({ page }) => { + await login(page); + await goto(page, ROUTES.accountHelpContact); + + const groups = page.locator('.help-faq-group'); + await expect(groups).toHaveCount(4); + await expect(page.locator('.help-contact-faq-list details').first()).toBeVisible(); + + const item = page.locator('.help-contact-faq-list details').filter({ hasText: 'Jak dodać ogłoszenie?' }).first(); + await expect(item.locator('.help-figure')).toBeHidden(); + await item.locator('summary').click(); + await expect(item.locator('.help-figure svg')).toBeVisible(); + await expect(item.locator('figcaption')).not.toBeEmpty(); + }); +}); + +// Kafelki wyboru nie moga sciskac sie do kilkudziesieciu pikseli - wtedy podpisy lamaly sie +// po literach ("Og rze wa nie po dlo go we"). Kolumna ma minimalna szerokosc, reszta idzie nizej. +test.describe('Kafelki wyboru w kreatorze wyceny', () => { + for (const width of [1920, 1600, 1440, 1366, 1280, 1100]) { + test(`podpisy udogodnien mieszcza sie przy szerokosci ${width}`, async ({ page }) => { + await page.setViewportSize({ width, height: 1000 }); + await goto(page, ROUTES.valuationCondition); + + const labels = page.locator('.valuation-amenity-grid button span'); + await expect(labels.first()).toBeVisible(); + const measured = await labels.evaluateAll((nodes) => nodes.map((node) => { + const rect = node.getBoundingClientRect(); + return { text: node.textContent ?? '', width: rect.width, height: rect.height }; + })); + + for (const item of measured) { + // Ponizej ~90 px nawet krotkie slowo zaczyna sie lamac w pionie. + expect(item.width, `podpis "${item.text}"`).toBeGreaterThan(90); + // Najdluzsza nazwa to dwa slowa - wiecej niz dwa wiersze znacza lamanie po literach. + expect(item.height, `podpis "${item.text}"`).toBeLessThanOrEqual(34); + } + }); + } +}); + +// Ten sam problem co w kreatorze wyceny dotyczyl kafelkow mediow i udogodnien w formularzu +// ogloszenia. Test przechodzi kreator do kroku "Dodatkowe informacje" i mierzy podpisy. +test.describe('Kafelki wyboru w formularzu ogloszenia', () => { + test('podpisy mediow i udogodnien lamia sie miedzy slowami', async ({ page }) => { + // Formularz edycji wchodzi z kompletem danych, wiec kroki przepuszczaja walidacje. + // Konto z gotowymi ogloszeniami: to samo, ktorego uzywaja testy ulubionych i wyszukiwan. + await goto(page, ROUTES.login); + await page.getByPlaceholder('Wpisz swój adres e-mail').fill('anna.kowalska@example.com'); + await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password); + await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click(); + await page.waitForURL(/\/konto$/); + + const token = await page.evaluate(() => window.localStorage.getItem('polskalokalnie-auth-token')); + const own = await page.request.get('/api/listings/mine', { headers: { Authorization: `Bearer ${token}` } }) + .then((response) => response.json()); + expect(own.length, 'konto testowe musi mieć własne ogłoszenie').toBeGreaterThan(0); + + await page.setViewportSize({ width: 1440, height: 1200 }); + await goto(page, `/edytuj-ogloszenie/${own[0].id}`); + + // Baner zgody stoi nad paskiem akcji na dole kroku - bez zamkniecia przejmuje klikniecie. + const consent = page.getByRole('button', { name: 'Tylko niezbędne' }); + if (await consent.count() > 0) { + await consent.click(); + } + // Formularz edycji dociaga dane ogloszenia - dopoki pola sa puste, walidacja nie puszcza dalej. + await expect(page.locator('.add-steps-item.active strong')).toContainText('Szczegóły oferty'); + await page.waitForTimeout(1500); + + // Trzy kroki dalej: Szczegoly -> Lokalizacja -> Zdjecia -> Dodatkowe informacje. + for (const next of ['Lokalizacja', 'Zdjęcia', 'Dodatkowe informacje']) { + await page.getByRole('button', { name: new RegExp('^Dalej') }).first().click(); + await expect(page.locator('.add-steps-item.active strong')).toContainText(next); + } + await expect(page.locator('.add-steps-item.active strong')).toContainText('Dodatkowe informacje'); + + const labels = page.locator('.extra-option-grid label span'); + await expect(labels.first()).toBeVisible(); + const measured = await labels.evaluateAll((nodes) => nodes.map((node) => ({ + text: node.textContent ?? '', + height: node.getBoundingClientRect().height, + }))); + expect(measured.length).toBeGreaterThan(5); + for (const item of measured) { + // Najdluzsze podpisy to trzy slowa - powyzej dwoch wierszy znaczy lamanie po literach. + expect(item.height, `podpis "${item.text}"`).toBeLessThanOrEqual(34); + } + }); +}); + +// Kalkulator zdolnosci: bufor zalezy od rodzaju oprocentowania (Rekomendacja S), a prog wyzszego +// DStI i podpowiadane oprocentowanie pochodza ze wskaznikow pobieranych z GUS i Eurostatu. +test.describe('Kalkulator zdolnosci kredytowej', () => { + test('wskazniki rynkowe sa dostepne publicznie', async ({ page }) => { + const indicators = await page.request.get('/api/market-indicators').then((response) => response.json()); + const keys = indicators.map((item: { key: string }) => item.key); + expect(keys).toContain('average-wage-gross'); + expect(keys).toContain('money-market-3m'); + for (const item of indicators) { + expect(item.value, `wskaznik ${item.key}`).toBeGreaterThan(0); + expect(item.asOf, `wskaznik ${item.key}`).toBeTruthy(); + expect(item.source, `wskaznik ${item.key}`).toBeTruthy(); + } + }); + + test('zmienne oprocentowanie daje nizsza zdolnosc niz stale', async ({ page }) => { + await goto(page, ROUTES.creditCalculator); + await page.locator('.credit-field').filter({ hasText: 'Twój dochód' }).locator('input').fill('9000'); + await page.waitForTimeout(400); + + const maxLoan = async () => { + const row = page.locator('.credit-details article, .credit-detail-row, .credit-detail') + .filter({ hasText: 'Maksymalna kwota kredytu' }).first(); + const text = await row.innerText(); + return Number(text.replace(/[^\d]/g, '')); + }; + + await page.getByRole('button', { name: 'Stałe', exact: true }).click(); + await page.waitForTimeout(400); + const withFixed = await maxLoan(); + + await page.getByRole('button', { name: 'Zmienne', exact: true }).click(); + await page.waitForTimeout(400); + const withVariable = await maxLoan(); + + expect(withFixed).toBeGreaterThan(0); + // Wyzszy bufor przy zmiennej stopie musi obnizyc maksymalna kwote kredytu. + expect(withVariable).toBeLessThan(withFixed); + }); + + test('podpowiadane oprocentowanie i prog DStI podaja zrodlo', async ({ page }) => { + await goto(page, ROUTES.creditCalculator); + await page.waitForTimeout(800); + + const rate = await page.locator('.credit-field').filter({ hasText: 'Oprocentowanie' }).locator('input').inputValue(); + expect(Number(rate.replace(',', '.'))).toBeGreaterThan(0); + + const notes = (await page.locator('.credit-method-note').allInnerTexts()).join(' '); + expect(notes).toContain('GUS'); + expect(notes).toContain('Eurostat'); + await expect(page.locator('.credit-details, .credit-detail-row, .credit-detail').first()).toContainText('próg'); + }); +});