Pomoc z rysunkami ekranow, kafelki na cala szerokosc, kalkulator na danych GUS i Eurostatu
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Pomoc i kontakt: - kanaly kontaktu wypelniaja szerokosc panelu (auto-fit zamiast czterech kolumn) - FAQ podzielone na cztery moduly, 21 pytan, ze schematycznymi rysunkami ekranow (inline SVG w helpIllustrations.tsx - nie zrzuty, wiec nie dezaktualizuja sie) - menu konta nie przykleja sie juz nad trescia na telefonie i tablecie Kafelki wyboru: - kolumna nie schodzi ponizej czytelnej szerokosci, podpisy lamia sie miedzy slowami zamiast po literach (bylo widoczne na monitorach ok. 1440 px) Kalkulator zdolnosci kredytowej: - rodzaj oprocentowania decyduje o buforze: 2,5 p.p. przy stalym, 5 p.p. przy zmiennym (wczesniej zawsze 2,5 p.p., co zawyzalo zdolnosc przy zmiennej stopie o ok. 100 tys. zl) - nowy modul market: przecietne wynagrodzenie z GUS BDL i stopa rynku pienieznego 3M z Eurostatu, odswiezane co tydzien, publiczny GET /api/market-indicators - prog wyzszego DStI i podpowiadane oprocentowanie licza sie z tych wskaznikow, z podpisem zrodla i okresu; brak danych = wartosci zapasowe Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<String> 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<String, JsonNode> latest = null;
|
||||
for (var it = periods.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> entry = it.next();
|
||||
if (values.has(String.valueOf(entry.getValue().asInt())) && (latest == null
|
||||
|| Comparator.<String>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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<IndicatorResponse> list() {
|
||||
return service.all().stream()
|
||||
.map(indicator -> new IndicatorResponse(indicator.getKey(), indicator.getValue(),
|
||||
indicator.getAsOf(), indicator.getSource(), indicator.getFetchedAt()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.market;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface MarketIndicatorRepository extends JpaRepository<MarketIndicator, Long> {
|
||||
|
||||
Optional<MarketIndicator> findByKey(String key);
|
||||
}
|
||||
@@ -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<MarketIndicator> 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<MarketIndicator> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Variable> variables() throws IOException, InterruptedException {
|
||||
return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+364
-40
@@ -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 <Link to={ROUTES.add}>Dodaj ogłoszenie</Link>. 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 <Link to={ROUTES.register}>stronę rejestracji</Link> 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.
|
||||
<RegisterIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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.
|
||||
<ActivationIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.accountSettings}>ustawieniach konta</Link>.
|
||||
<LoginIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'Nie pamiętam hasła',
|
||||
answer: (
|
||||
<>
|
||||
Na stronie logowania kliknij <Link to={ROUTES.loginForgot}>Nie pamiętam hasła</Link>. 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 <Link to={ROUTES.privacy}>Polityce prywatności</Link>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'Jak usunąć konto?',
|
||||
answer: (
|
||||
<>
|
||||
W <Link to={ROUTES.accountSettings}>ustawieniach konta</Link>, 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.
|
||||
<AccountDeletionIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.add}>Dodaj ogłoszenie</Link>: 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.
|
||||
<AddListingIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.accountListings}>Moje oferty</Link>, 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ę.
|
||||
<MyListingsIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.accountListings}>Moje oferty</Link> 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.
|
||||
<PromotionIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.accountListings}>Moje oferty</Link> 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 <Link to={ROUTES.buy}>Kupuję</Link> albo <Link to={ROUTES.rent}>Wynajmuję</Link>.
|
||||
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.
|
||||
<SearchIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.priceHistory}>Historii cen</Link>. 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 <Link to={ROUTES.accountPriceAlerts}>Alerty cenowe</Link>.
|
||||
<PriceAlertIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.comparison}>Porównaniu</Link> 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: (
|
||||
<>
|
||||
<Link to={ROUTES.negotiation}>Analiza negocjacji</Link> 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 <Link to={ROUTES.accountListings}>Moje oferty</Link>, 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 <Link to={ROUTES.comparison}>Porównaniu</Link> 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 <Link to={ROUTES.priceHistory}>Historii cen</Link>. Wszystkie zobaczysz w panelu <Link to={ROUTES.accountPriceAlerts}>Alerty cenowe</Link>. 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 <Link to={ROUTES.messages}>Wiadomości</Link>. Numer telefonu widzisz, jeśli ogłaszający zgodził się
|
||||
go pokazać.
|
||||
<MessagesIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 <Link to={ROUTES.accountMeetings}>Umówione spotkania</Link>. Każda ze
|
||||
stron może termin odwołać.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: 'Czym sterują przełączniki powiadomień?',
|
||||
answer: (
|
||||
<>
|
||||
Tym, co trafia na <Link to={ROUTES.notifications}>listę powiadomień</Link> i pod dzwonek w nagłówku: nowymi
|
||||
ofertami z zapisanych wyszukiwań, alertami cenowymi oraz wiadomościami i terminami oglądania. Znajdziesz je
|
||||
w <Link to={ROUTES.accountSettings}>ustawieniach konta</Link>. Zgody na newsletter i SMS to osobna sekcja -
|
||||
dotyczą wiadomości, które wysyłamy my.
|
||||
<NotificationSettingsIllustration />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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({
|
||||
<h2>Najczęściej zadawane pytania</h2>
|
||||
<Link to={ROUTES.guides}>Poradniki <Icon name="arrow" /></Link>
|
||||
</header>
|
||||
<div className="help-contact-faq-list">
|
||||
{faqItems.map((item) => (
|
||||
<details key={item.question}>
|
||||
<summary>
|
||||
<span>{item.question}</span>
|
||||
<Icon name="chevron" />
|
||||
</summary>
|
||||
<p>{item.answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
{faqGroups.map((group) => (
|
||||
<section className="help-faq-group" key={group.id}>
|
||||
<h3><Icon name={group.icon} /> {group.title}</h3>
|
||||
<div className="help-contact-faq-list">
|
||||
{group.items.map((item) => (
|
||||
<details key={item.question}>
|
||||
<summary>
|
||||
<span>{item.question}</span>
|
||||
<Icon name="chevron" />
|
||||
</summary>
|
||||
<div className="help-faq-answer">{item.answer}</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</article>
|
||||
|
||||
<article ref={contactFormCardRef} className={`help-contact-form-card ${isContactFormHighlighted ? 'is-highlighted' : ''}`}>
|
||||
@@ -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<CreditIncomeSource, number> = {
|
||||
'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<typeof calculateCreditworthiness>;
|
||||
|
||||
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<CreditForm>(INITIAL_CREDIT_FORM);
|
||||
const [highlight, setHighlight] = useState(false);
|
||||
const resultRef = useRef<HTMLDivElement | null>(null);
|
||||
const result = calculateCreditworthiness(form);
|
||||
// Wskazniki rynkowe z GUS i Eurostatu. Gdy nie dotra, kalkulator liczy na wartosciach zapasowych.
|
||||
const [market, setMarket] = useState<CreditMarket>(EMPTY_CREDIT_MARKET);
|
||||
const result = calculateCreditworthiness(form, market);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiFetch<ApiMarketIndicator[]>('/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
|
||||
<button type="button" className={form.repayment === 'malejące' ? 'active' : ''} onClick={() => update('repayment', 'malejące')}>Malejące</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="credit-field">
|
||||
<span>Rodzaj oprocentowania</span>
|
||||
<div className="credit-segmented">
|
||||
<button type="button" className={form.rateType === 'stałe' ? 'active' : ''} onClick={() => update('rateType', 'stałe')}>Stałe</button>
|
||||
<button type="button" className={form.rateType === 'zmienne' ? 'active' : ''} onClick={() => update('rateType', 'zmienne')}>Zmienne</button>
|
||||
</div>
|
||||
<small>
|
||||
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.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -18215,10 +18517,32 @@ function CreditCalculatorPage({ onSearchWithBudget }: { onSearchWithBudget: (max
|
||||
|
||||
<p className="credit-method-note">
|
||||
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ę.
|
||||
</p>
|
||||
|
||||
{(market.averageWage || market.moneyMarketRate) && (
|
||||
<p className="credit-method-note">
|
||||
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.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="credit-cta-band">
|
||||
|
||||
@@ -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 (
|
||||
<figure className="help-figure">
|
||||
<div className="help-figure-frame">{children}</div>
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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 (
|
||||
<Figure caption="Rejestracja - najpierw wybierasz rodzaj konta, potem wypełniasz dane.">
|
||||
<svg viewBox="0 0 520 170" role="img" aria-label="Szkic rejestracji: dwa kafelki wyboru konta - Osoba prywatna i Firma, poniżej pola e-mail i hasło oraz zgoda na regulamin.">
|
||||
<text x="12" y="20" className={TITLE}>Załóż konto</text>
|
||||
<text x="12" y="40" className={LABEL}>Rodzaj konta</text>
|
||||
|
||||
<rect x="12" y="50" width="240" height="40" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<circle cx="32" cy="70" r="7" className="hf-dot-active" />
|
||||
<text x="48" y="74" className="hf-title hf-text-active">Osoba prywatna</text>
|
||||
|
||||
<rect x="268" y="50" width="240" height="40" rx="8" className={BOX} strokeWidth="1" />
|
||||
<circle cx="288" cy="70" r="7" className="hf-dot" />
|
||||
<text x="304" y="74" className={LABEL}>Firma (NIP z rejestru GUS)</text>
|
||||
|
||||
<rect x="12" y="102" width="240" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="119" className={SMALL}>Adres e-mail</text>
|
||||
<rect x="268" y="102" width="240" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="278" y="119" className={SMALL}>Hasło (min. 8 znaków)</text>
|
||||
|
||||
<rect x="12" y="138" width="14" height="14" rx="3" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="34" y="150" className={SMALL}>Akceptuję Regulamin i Politykę prywatności</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aktywacja konta: link z e-maila. */
|
||||
export function ActivationIllustration() {
|
||||
return (
|
||||
<Figure caption="Po rejestracji konto czeka na aktywację - klikasz link z wiadomości e-mail.">
|
||||
<svg viewBox="0 0 520 120" role="img" aria-label="Szkic przebiegu aktywacji: rejestracja, wiadomość e-mail z linkiem, aktywne konto.">
|
||||
{[
|
||||
{ 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) => (
|
||||
<g key={step.title}>
|
||||
<rect x={step.x} y="28" width="144" height="60" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x={step.x + 14} y="54" className={TITLE}>{step.title}</text>
|
||||
<text x={step.x + 14} y="72" className={SMALL}>{step.sub}</text>
|
||||
</g>
|
||||
))}
|
||||
<path d="M162 58 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
<path d="M338 58 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Logowanie wylacznie adresem e-mail. */
|
||||
export function LoginIllustration() {
|
||||
return (
|
||||
<Figure caption="Logujesz się adresem e-mail - nazwa użytkownika (nick) służy tylko do adresu profilu.">
|
||||
<svg viewBox="0 0 520 140" role="img" aria-label="Szkic logowania: pole e-mail, pole hasła, odnośnik do odzyskiwania hasła i przycisk Zaloguj się.">
|
||||
<text x="12" y="22" className={TITLE}>Zaloguj się</text>
|
||||
<rect x="12" y="34" width="330" height="28" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="52" className={SMALL}>jan.kowalski@example.com</text>
|
||||
<rect x="12" y="70" width="330" height="28" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="88" className={SMALL}>••••••••</text>
|
||||
<text x="12" y="118" className="hf-label-sm hf-text-active">Nie pamiętam hasła</text>
|
||||
<rect x="358" y="34" width="150" height="64" rx="8" className="hf-fill-active" />
|
||||
<text x="398" y="72" className="hf-title hf-text-white">Zaloguj się</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formularz dodawania ogloszenia i droga przez moderacje. */
|
||||
export function AddListingIllustration() {
|
||||
return (
|
||||
<Figure caption="Dodawanie ogłoszenia - wypełniasz formularz, a ogłoszenie czeka na akceptację obsługi.">
|
||||
<svg viewBox="0 0 520 180" role="img" aria-label="Szkic formularza ogłoszenia: rodzaj oferty, tytuł, cena, metraż i zdjęcia, obok ścieżka od wysłania do publikacji.">
|
||||
<text x="12" y="20" className={TITLE}>Dodaj ogłoszenie</text>
|
||||
|
||||
<rect x="12" y="32" width="120" height="30" rx="6" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="34" y="51" className="hf-title hf-text-active">Sprzedaż</text>
|
||||
<rect x="140" y="32" width="120" height="30" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="170" y="51" className={LABEL}>Wynajem</text>
|
||||
|
||||
<rect x="12" y="72" width="248" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="89" className={SMALL}>Tytuł ogłoszenia</text>
|
||||
<rect x="12" y="106" width="120" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="123" className={SMALL}>Cena</text>
|
||||
<rect x="140" y="106" width="120" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="150" y="123" className={SMALL}>Metraż</text>
|
||||
<rect x="12" y="140" width="248" height="28" rx="6" className={BOX} strokeWidth="1" strokeDasharray="4 3" />
|
||||
<text x="22" y="158" className={SMALL}>Zdjęcia - przeciągnij albo wybierz z dysku</text>
|
||||
|
||||
{[
|
||||
{ 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) => (
|
||||
<g key={step.title}>
|
||||
<rect x="290" y={step.y - 20} width="218" height="40" rx="8" className={BOX} strokeWidth="1" />
|
||||
<circle cx="312" cy={step.y} r="8" className="hf-dot-active" />
|
||||
<text x="330" y={step.y - 2} className={TITLE}>{step.title}</text>
|
||||
<text x="330" y={step.y + 12} className={SMALL}>{step.sub}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Panel "Moje oferty" z akcjami przy ogloszeniu. */
|
||||
export function MyListingsIllustration() {
|
||||
return (
|
||||
<Figure caption="Moje oferty - przy każdym ogłoszeniu masz edycję, wstrzymanie, wyróżnienie i usunięcie.">
|
||||
<svg viewBox="0 0 520 170" role="img" aria-label="Szkic listy Moje oferty: dwa wiersze ogłoszeń ze statusem oraz przyciskami Edytuj, Wstrzymaj i Wyróżnij.">
|
||||
<text x="12" y="20" className={TITLE}>Moje oferty</text>
|
||||
{[
|
||||
{ y: 34, status: 'Opublikowane', tone: 'hf-fill-ok' },
|
||||
{ y: 104, status: 'Oczekuje', tone: 'hf-fill-wait' },
|
||||
].map((row) => (
|
||||
<g key={row.status}>
|
||||
<rect x="12" y={row.y} width="496" height="56" rx="8" className={BOX} strokeWidth="1" />
|
||||
<rect x="22" y={row.y + 10} width="50" height="36" rx="4" className="hf-photo" />
|
||||
<rect x="84" y={row.y + 12} width="150" height="9" rx="3" className="hf-line" />
|
||||
<rect x="84" y={row.y + 28} width="78" height="14" rx="4" className={row.tone} />
|
||||
<text x="92" y={row.y + 39} className="hf-label-sm hf-text-white">{row.status}</text>
|
||||
<text x="250" y={row.y + 25} className="hf-title hf-text-active">690 000 zł</text>
|
||||
<text x="250" y={row.y + 41} className={SMALL}>Gdańsk, 58 m²</text>
|
||||
{['Edytuj', 'Wstrzymaj', 'Wyróżnij'].map((action, index) => (
|
||||
<g key={action}>
|
||||
<rect x={358 + index * 52} y={row.y + 18} width="48" height="20" rx="4" className={BOX} strokeWidth="1" />
|
||||
<text x={364 + index * 52} y={row.y + 32} className={SMALL}>{action}</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wyroznienie ogloszenia i platnosc. */
|
||||
export function PromotionIllustration() {
|
||||
return (
|
||||
<Figure caption="Wyróżnienie - wybierasz pakiet, płacisz przez AutoPay albo zużywasz kredyt z pakietu.">
|
||||
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic wyróżniania: trzy pakiety do wyboru, poniżej ścieżka płatności AutoPay i powrót do serwisu.">
|
||||
<text x="12" y="20" className={TITLE}>Wyróżnij ogłoszenie</text>
|
||||
{/* 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) => (
|
||||
<g key={plan.name}>
|
||||
<rect
|
||||
x={plan.x}
|
||||
y="32"
|
||||
width="152"
|
||||
height="52"
|
||||
rx="8"
|
||||
className={plan.active ? 'hf-box hf-active' : BOX}
|
||||
strokeWidth={plan.active ? 2 : 1}
|
||||
/>
|
||||
<text x={plan.x + 16} y="54" className={TITLE}>{plan.name} wyróżnienie</text>
|
||||
<text x={plan.x + 16} y="72" className={plan.active ? 'hf-label-sm hf-text-active' : SMALL}>liczba dni i cena z cennika</text>
|
||||
</g>
|
||||
))}
|
||||
{[
|
||||
{ x: 12, label: 'Wybór pakietu' },
|
||||
{ x: 188, label: 'AutoPay - płatność' },
|
||||
{ x: 364, label: 'Ogłoszenie na górze listy' },
|
||||
].map((step) => (
|
||||
<g key={step.label}>
|
||||
<rect x={step.x} y="102" width="144" height="34" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x={step.x + 12} y="124" className={SMALL}>{step.label}</text>
|
||||
</g>
|
||||
))}
|
||||
<path d="M162 119 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
<path d="M338 119 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wyszukiwanie z filtrami i zapisem wyszukiwania. */
|
||||
export function SearchIllustration() {
|
||||
return (
|
||||
<Figure caption="Wyszukiwanie - filtry siedzą w adresie strony, więc wynik da się odświeżyć i wysłać znajomemu.">
|
||||
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic wyszukiwarki: pola lokalizacja, rodzaj, cena i metraż, przycisk Szukaj oraz przycisk Zapisz wyszukiwanie.">
|
||||
{[
|
||||
{ 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) => (
|
||||
<g key={field.label}>
|
||||
<rect x={field.x} y="20" width={field.w} height="44" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x={field.x + 12} y="38" className={SMALL}>{field.label}</text>
|
||||
<text x={field.x + 12} y="55" className={TITLE}>{field.value}</text>
|
||||
</g>
|
||||
))}
|
||||
<rect x="406" y="20" width="102" height="44" rx="8" className="hf-fill-active" />
|
||||
<text x="437" y="48" className="hf-title hf-text-white">Szukaj</text>
|
||||
|
||||
<rect x="12" y="80" width="190" height="30" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="30" y="100" className="hf-title hf-text-active">Zapisz wyszukiwanie</text>
|
||||
<text x="216" y="100" className={SMALL}>Nowe pasujące oferty trafią na listę powiadomień.</text>
|
||||
|
||||
<text x="12" y="132" className={SMALL}>Adres: /kupno?miasto=Gdańsk&typ=Mieszkanie&cena_do=800000</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Alerty cenowe - gdzie sie je zaklada i gdzie ladują. */
|
||||
export function PriceAlertIllustration() {
|
||||
return (
|
||||
<Figure caption="Alerty cenowe - zakładasz je przy ofercie albo w Historii cen, a zmiany trafiają na listę powiadomień.">
|
||||
<svg viewBox="0 0 520 140" role="img" aria-label="Szkic alertów: przycisk przy ofercie, przycisk w historii cen oraz powiadomienie o zmianie ceny.">
|
||||
<rect x="12" y="22" width="220" height="44" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y="42" className={SMALL}>Karta oferty</text>
|
||||
<text x="26" y="58" className="hf-title hf-text-active">Utwórz alert ceny</text>
|
||||
|
||||
<rect x="12" y="78" width="220" height="44" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y="98" className={SMALL}>Historia cen</text>
|
||||
<text x="26" y="114" className="hf-title hf-text-active">Utwórz alert cenowy</text>
|
||||
|
||||
<rect x="268" y="40" width="240" height="64" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<circle cx="292" cy="62" r="9" className="hf-dot-active" />
|
||||
<text x="310" y="60" className={TITLE}>Cena spadła: Gdańsk, Wrzeszcz</text>
|
||||
<text x="310" y="78" className={SMALL}>690 000 zł → 659 000 zł (-4,5%)</text>
|
||||
<text x="310" y="94" className={SMALL}>Lista powiadomień w serwisie</text>
|
||||
|
||||
<path d="M240 62 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wiadomosci i umawianie ogladania. */
|
||||
export function MessagesIllustration() {
|
||||
return (
|
||||
<Figure caption="Wiadomości - rozmowa dotyczy konkretnego ogłoszenia, a termin oglądania proponujesz w tym samym oknie.">
|
||||
<svg viewBox="0 0 520 160" role="img" aria-label="Szkic wiadomości: lista rozmów po lewej, rozmowa po prawej z przyciskiem proponowania terminu oglądania.">
|
||||
<rect x="12" y="16" width="150" height="130" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="24" y="36" className={TITLE}>Rozmowy</text>
|
||||
{[52, 84, 116].map((y, index) => (
|
||||
<g key={y}>
|
||||
<rect x="22" y={y} width="130" height="24" rx="6" className={index === 0 ? 'hf-box hf-active' : 'hf-photo'} strokeWidth={index === 0 ? 2 : 0} />
|
||||
<text x="32" y={y + 16} className={SMALL}>Mieszkanie {index + 1}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<rect x="174" y="16" width="334" height="130" rx="8" className={BOX} strokeWidth="1" />
|
||||
<rect x="186" y="30" width="180" height="22" rx="8" className="hf-photo" />
|
||||
<text x="196" y="45" className={SMALL}>Czy mieszkanie jest dostępne?</text>
|
||||
<rect x="316" y="60" width="180" height="22" rx="8" className="hf-fill-active" />
|
||||
<text x="326" y="75" className="hf-label-sm hf-text-white">Tak, zapraszam na oglądanie</text>
|
||||
|
||||
<rect x="186" y="98" width="200" height="30" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="202" y="117" className="hf-title hf-text-active">Zaproponuj termin oglądania</text>
|
||||
<text x="396" y="117" className={SMALL}>Trafi do kalendarza</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ustawienia powiadomien. */
|
||||
export function NotificationSettingsIllustration() {
|
||||
return (
|
||||
<Figure caption="Ustawienia konta - trzy przełączniki decydują, co trafia na listę powiadomień.">
|
||||
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic ustawień powiadomień: przełączniki dla zapisanych wyszukiwań, alertów cenowych i wiadomości oraz osobna sekcja zgód marketingowych.">
|
||||
<text x="12" y="20" className={TITLE}>Powiadomienia</text>
|
||||
{[
|
||||
{ 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) => (
|
||||
<g key={row.label}>
|
||||
<rect x="12" y={row.y} width="496" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y={row.y + 17} className={LABEL}>{row.label}</text>
|
||||
<rect x="452" y={row.y + 6} width="34" height="16" rx="8" className={row.on ? 'hf-fill-active' : 'hf-photo'} />
|
||||
<circle cx={row.on ? 478 : 460} cy={row.y + 14} r="6" className="hf-knob" />
|
||||
</g>
|
||||
))}
|
||||
<text x="12" y="138" className={SMALL}>Niżej, pod kreską: zgody marketingowe (newsletter i SMS) - osobna sprawa.</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Usuniecie konta i zakres kasowanych danych. */
|
||||
export function AccountDeletionIllustration() {
|
||||
return (
|
||||
<Figure caption="Usunięcie konta - kasujemy dane i treści, zostają tylko dokumenty wymagane przepisami.">
|
||||
<svg viewBox="0 0 520 120" role="img" aria-label="Szkic usuwania konta: lista kasowanych danych po lewej i lista danych zachowywanych po prawej.">
|
||||
<rect x="12" y="16" width="240" height="90" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y="36" className={TITLE}>Kasujemy</text>
|
||||
<text x="26" y="56" className={SMALL}>ogłoszenia, wiadomości, ulubione,</text>
|
||||
<text x="26" y="72" className={SMALL}>zapisane wyszukiwania, alerty,</text>
|
||||
<text x="26" y="88" className={SMALL}>terminy oglądania, dane profilu</text>
|
||||
|
||||
<rect x="268" y="16" width="240" height="90" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="282" y="36" className={TITLE}>Zostaje</text>
|
||||
<text x="282" y="56" className={SMALL}>rozliczenia płatności (5 lat),</text>
|
||||
<text x="282" y="72" className={SMALL}>zgłoszenia i moderacja (do 3 lat)</text>
|
||||
<text x="282" y="88" className={SMALL}>- wymagają tego przepisy</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
+172
-20
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user