Wycena na prawdziwych danych zamiast zmyslonej tabeli cen
Deploy Polska Lokalnie / deploy (push) Successful in 11s

Stawka bazowa za m2 pochodzila z tabeli opisanej jako "SonarHome 2026"
i zmyslonych mnoznikow dzielnic. Teraz korzysta z /api/price-history:
- najnowsza cena transakcyjna mieszkan NBP dla miasta i rynku, potem GUS
  (dane najwyzej 3-letnie), a bez nich srednia cen ofertowych z ogloszen;
- korekta dzielnicy liczona z ogloszen (min. 3 w dzielnicy i 5 w miescie);
- domy: srednia z ogloszen domow, gdy sa co najmniej 3, inaczej ceny
  mieszkan z korekta modelu - opisane przy wyniku;
- zrodlo stawki w podgladzie, zalozeniach i raporcie PDF;
- brak danych dla lokalizacji to komunikat zamiast wymyslonej kwoty.

Korekty modelu (stan, pietro, rok budowy, udogodnienia) bez zmian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:21:22 +02:00
parent e92c68ec93
commit 94dd300c53
2 changed files with 200 additions and 143 deletions
+190 -143
View File
@@ -17130,6 +17130,10 @@ type ValuationResult = {
validUntil: string; validUntil: string;
details: { icon: string; label: string; value: string }[]; details: { icon: string; label: string; value: string }[];
mainFactors: string[]; mainFactors: string[];
// Dlaczego nie ma wyniku, gdy sa juz podstawowe dane (np. brak danych rynkowych dla lokalizacji).
unavailableReason?: string;
// Zrodlo stawki bazowej - do wyniku i raportu PDF.
sourceLabel?: string;
}; };
const INITIAL_VALUATION_FORM: ValuationForm = { const INITIAL_VALUATION_FORM: ValuationForm = {
@@ -17173,14 +17177,121 @@ const VALUATION_HOUSE_BUILDING_TYPES = ['Wolnostojący', 'Bliźniak', 'Szeregowy
const VALUATION_COMMERCIAL_BUILDING_TYPES = ['Lokal handlowy', 'Biuro', 'Usługi', 'Gastronomia', 'Magazyn']; const VALUATION_COMMERCIAL_BUILDING_TYPES = ['Lokal handlowy', 'Biuro', 'Usługi', 'Gastronomia', 'Magazyn'];
const VALUATION_COMMERCIAL_PURPOSES = ['Handel', 'Biuro', 'Usługi', 'Gastronomia', 'Medyczny', 'Magazyn']; const VALUATION_COMMERCIAL_PURPOSES = ['Handel', 'Biuro', 'Usługi', 'Gastronomia', 'Medyczny', 'Magazyn'];
type ValuationMarketReference = { // Stawka bazowa wyceny z prawdziwych danych: cena transakcyjna NBP/GUS dla miasta albo srednia
label: string; // cen ofertowych z ogloszen serwisu. sourceLabel trafia do wyniku i raportu PDF.
type ValuationBase = {
basePerMeter: number; basePerMeter: number;
precision: 'city' | 'region' | 'national'; sourceLabel: string;
locationType: string; precisionLabel: string;
// false, gdy stawka juz dotyczy wycenianego typu (np. srednia z ogloszen domow).
appliesPropertyMultiplier: boolean;
}; };
const VALUATION_2026_SOURCE_NOTE = 'model 2026: ceny transakcyjne SonarHome 2026-06-01 + korekty regionalne'; // Dane rynkowe dla wyceny - odpowiedzi /api/price-history dla miasta i dzielnicy.
type ValuationMarketData = {
city: string;
district: string;
apartmentCity: ApiPriceHistory | null;
apartmentDistrict: ApiPriceHistory | null;
houseCity: ApiPriceHistory | null;
houseDistrict: ApiPriceHistory | null;
};
const VALUATION_MIN_DISTRICT_LISTINGS = 3;
const VALUATION_MIN_CITY_LISTINGS_FOR_RATIO = 5;
// GUS publikuje z opoznieniem - starszych danych nie uzywamy jako stawki.
const VALUATION_MAX_GUS_AGE_YEARS = 3;
const VALUATION_PLACE_TYPE_WORDS = new Set(['miasto', 'wies', 'dzielnica', 'osiedle', 'miejscowosc', 'gmina', 'powiat', 'przysiolek', 'kolonia', 'osada']);
// "Kraków, Podgórze" -> miasto Kraków, dzielnica Podgórze. Podpowiedzi mapy maja postac
// "Kraków, miasto, Kraków, małopolskie" - opisy typu, powiatu i wojewodztwa pomijamy.
function parseValuationLocation(location: string): { city: string; district: string } {
const parts = location.split(',').map((part) => part.trim()).filter(Boolean);
const city = parts[0] ?? '';
const district = parts.slice(1).find((part) => {
const normalized = normalizeHistoryText(part.replace(/^powiat\s+/i, ''));
return !VALUATION_PLACE_TYPE_WORDS.has(normalized) && !isHistoryRegionName(part)
&& normalized !== normalizeHistoryText(city);
}) ?? '';
return { city, district };
}
// Dom albo lokal liczony od cen mieszkan - zaznaczamy to w opisie zrodla.
function buildValuationBase(form: ValuationForm, data: ValuationMarketData | null): ValuationBase | null {
const base = buildValuationBaseFromData(form, data);
if (base && base.appliesPropertyMultiplier && (form.propertyType === 'Dom' || form.propertyType === 'Lokal użytkowy')) {
const what = form.propertyType === 'Dom' ? 'domu' : 'lokalu użytkowego';
return { ...base, sourceLabel: `${base.sourceLabel}, przeliczona dla ${what} wg modelu` };
}
return base;
}
function buildValuationBaseFromData(form: ValuationForm, data: ValuationMarketData | null): ValuationBase | null {
if (!data || !data.city) {
return null;
}
const where = [data.city, data.district].filter(Boolean).join(', ');
const cityListings = data.apartmentCity?.current ?? null;
const districtListings = data.apartmentDistrict?.current ?? null;
// Korekta dzielnicy z naszych ogloszen - tylko przy wystarczajacej liczbie ogloszen w obu grupach.
const districtRatio = data.district && districtListings && cityListings
&& districtListings.listingsCount >= VALUATION_MIN_DISTRICT_LISTINGS
&& cityListings.listingsCount >= VALUATION_MIN_CITY_LISTINGS_FOR_RATIO
? clampNumber(districtListings.avgPricePerM2 / cityListings.avgPricePerM2, 0.7, 1.4)
: null;
const districtNote = districtRatio != null
? `, korekta dzielnicy wg ogłoszeń ${districtRatio >= 1 ? '+' : ''}${((districtRatio - 1) * 100).toFixed(1).replace('.', ',')}%`
: '';
if (form.propertyType === 'Dom') {
const houseListings = [data.houseDistrict?.current, data.houseCity?.current]
.find((current) => current && current.listingsCount >= VALUATION_MIN_DISTRICT_LISTINGS) ?? null;
if (houseListings) {
return {
basePerMeter: houseListings.avgPricePerM2,
sourceLabel: `średnia cen ofertowych domów z ${houseListings.listingsCount} ogłoszeń w serwisie (${where})`,
precisionLabel: 'Dane: ogłoszenia domów w tej lokalizacji',
appliesPropertyMultiplier: false,
};
}
}
const nbp = data.apartmentCity?.nbp?.[data.apartmentCity.nbp.length - 1] ?? null;
const gusAll = data.apartmentCity?.gus ?? [];
const gus = gusAll[gusAll.length - 1] ?? null;
const gusFresh = gus && gus.year >= new Date().getFullYear() - VALUATION_MAX_GUS_AGE_YEARS ? gus : null;
const transaction = nbp
? { price: nbp.pricePerM2, label: `cena transakcyjna mieszkań NBP, rynek ${(data.apartmentCity?.nbpMarket ?? 'Wtórny').toLowerCase()}, ${HISTORY_ROMAN_QUARTERS[nbp.quarter - 1]} kw. ${nbp.year} (${data.city})` }
: gusFresh
? { price: gusFresh.pricePerM2, label: `cena transakcyjna mieszkań GUS, ${HISTORY_ROMAN_QUARTERS[gusFresh.quarter - 1]} kw. ${gusFresh.year} (${data.city})` }
: null;
if (transaction) {
return {
basePerMeter: Math.round(transaction.price * (districtRatio ?? 1)),
sourceLabel: `${transaction.label}${districtNote}`,
precisionLabel: districtRatio != null ? 'Dokładność: dzielnica' : 'Dokładność: całe miasto',
appliesPropertyMultiplier: true,
};
}
// Bez cen transakcyjnych - srednia cen ofertowych mieszkan z naszych ogloszen.
const listings = districtListings && districtListings.listingsCount >= VALUATION_MIN_DISTRICT_LISTINGS
? { current: districtListings, place: where }
: cityListings ? { current: cityListings, place: data.city } : null;
if (listings) {
return {
basePerMeter: listings.current.avgPricePerM2,
sourceLabel: `średnia cen ofertowych mieszkań z ${listings.current.listingsCount} ogłoszeń w serwisie (${listings.place})`,
precisionLabel: listings.current.listingsCount < VALUATION_MIN_DISTRICT_LISTINGS
? 'Dokładność: orientacyjna (mało ogłoszeń)'
: 'Dokładność: ogłoszenia w tej lokalizacji',
appliesPropertyMultiplier: true,
};
}
return null;
}
const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = { const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = {
Warszawa: 15700, Warszawa: 15700,
@@ -17230,53 +17341,6 @@ const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = {
Ostrołęka: 7000, Ostrołęka: 7000,
}; };
const VALUATION_PRICE_PER_SQM_BY_REGION: Record<string, number> = {
mazowieckie: 8200,
małopolskie: 8300,
pomorskie: 8600,
dolnośląskie: 7900,
wielkopolskie: 7400,
łódzkie: 6500,
śląskie: 6700,
podkarpackie: 6600,
lubelskie: 6300,
zachodniopomorskie: 7000,
'kujawsko-pomorskie': 6100,
podlaskie: 6200,
'warmińsko-mazurskie': 6500,
świętokrzyskie: 5900,
opolskie: 6100,
lubuskie: 6100,
};
const VALUATION_NATIONAL_BASE_PER_SQM = 6900;
const VALUATION_DISTRICT_MULTIPLIERS: { keyword: string; multiplier: number }[] = [
{ keyword: 'śródmieście', multiplier: 1.18 },
{ keyword: 'stare miasto', multiplier: 1.16 },
{ keyword: 'mokotów', multiplier: 1.13 },
{ keyword: 'żoliborz', multiplier: 1.12 },
{ keyword: 'wola', multiplier: 1.1 },
{ keyword: 'wilanów', multiplier: 1.1 },
{ keyword: 'ursynów', multiplier: 1.05 },
{ keyword: 'zabłocie', multiplier: 1.08 },
{ keyword: 'podgórze', multiplier: 1.05 },
{ keyword: 'kazimierz', multiplier: 1.12 },
{ keyword: 'krowodrza', multiplier: 1.04 },
{ keyword: 'oliwa', multiplier: 1.09 },
{ keyword: 'wrzeszcz', multiplier: 1.06 },
{ keyword: 'przymorze', multiplier: 1.04 },
{ keyword: 'krzyki', multiplier: 1.05 },
{ keyword: 'nadodrze', multiplier: 1.03 },
{ keyword: 'jeżyce', multiplier: 1.04 },
{ keyword: 'łazarz', multiplier: 1.02 },
{ keyword: 'bemowo', multiplier: 1.02 },
{ keyword: 'praga', multiplier: 0.98 },
{ keyword: 'nowa huta', multiplier: 0.92 },
{ keyword: 'bielany', multiplier: 0.99 },
{ keyword: 'targówek', multiplier: 0.94 },
];
const VALUATION_CONDITION_OPTIONS = [ const VALUATION_CONDITION_OPTIONS = [
{ icon: 'paint', title: 'Do remontu', text: 'Nieruchomość wymaga generalnego remontu.', multiplier: 0.88 }, { icon: 'paint', title: 'Do remontu', text: 'Nieruchomość wymaga generalnego remontu.', multiplier: 0.88 },
{ icon: 'hammer', title: 'Do odświeżenia', text: 'Nieruchomość wymaga odświeżenia lub drobnych napraw.', multiplier: 0.95 }, { icon: 'hammer', title: 'Do odświeżenia', text: 'Nieruchomość wymaga odświeżenia lub drobnych napraw.', multiplier: 0.95 },
@@ -17353,78 +17417,6 @@ function parsePositiveNumber(value: string): number | null {
return parsed != null && parsed > 0 ? parsed : null; return parsed != null && parsed > 0 ? parsed : null;
} }
function findValuationCity(location: string): string | null {
const normalizedLocation = location.toLowerCase();
return Object.keys(VALUATION_PRICE_PER_SQM_BY_CITY).find((city) => normalizedLocation.includes(city.toLowerCase())) ?? null;
}
function getLocationTypeAdjustment(location: string): { multiplier: number; label: string } {
const normalizedLocation = location.toLowerCase();
if (normalizedLocation.includes('dzielnica') || normalizedLocation.includes('osiedle')) {
return { multiplier: 1, label: 'dzielnica' };
}
if (normalizedLocation.includes('wieś') || normalizedLocation.includes('hamlet') || normalizedLocation.includes('osada')) {
return { multiplier: 0.72, label: 'wieś' };
}
if (normalizedLocation.includes('powiat')) {
return { multiplier: 0.78, label: 'powiat' };
}
if (normalizedLocation.includes('miejscowość')) {
return { multiplier: 0.82, label: 'miejscowość' };
}
if (normalizedLocation.includes('miasto')) {
return { multiplier: 0.9, label: 'miasto' };
}
return { multiplier: 0.86, label: 'lokalizacja' };
}
function resolveValuationMarket(location: string): ValuationMarketReference | null {
const trimmedLocation = location.trim();
if (trimmedLocation.length < 2) {
return null;
}
const city = findValuationCity(trimmedLocation);
if (city) {
return {
label: city,
basePerMeter: VALUATION_PRICE_PER_SQM_BY_CITY[city],
precision: 'city',
locationType: 'miasto',
};
}
const normalizedLocation = trimmedLocation.toLowerCase();
const region = Object.keys(VALUATION_PRICE_PER_SQM_BY_REGION).find((regionName) => normalizedLocation.includes(regionName));
const locationType = getLocationTypeAdjustment(trimmedLocation);
if (region) {
return {
label: `${region}, ${locationType.label}`,
basePerMeter: Math.round(VALUATION_PRICE_PER_SQM_BY_REGION[region] * locationType.multiplier / 50) * 50,
precision: 'region',
locationType: locationType.label,
};
}
const knownCity = POLISH_CITIES.find((cityName) => normalizedLocation.includes(cityName.toLowerCase()));
if (knownCity) {
return {
label: knownCity,
basePerMeter: Math.round(VALUATION_NATIONAL_BASE_PER_SQM * 1.04 / 50) * 50,
precision: 'region',
locationType: 'miasto',
};
}
return {
label: `Polska, ${locationType.label}`,
basePerMeter: Math.round(VALUATION_NATIONAL_BASE_PER_SQM * locationType.multiplier / 50) * 50,
precision: 'national',
locationType: locationType.label,
};
}
function getDistrictMultiplier(location: string): number {
const normalizedLocation = location.toLowerCase();
return VALUATION_DISTRICT_MULTIPLIERS.find((item) => normalizedLocation.includes(item.keyword))?.multiplier ?? 1;
}
function getAreaMultiplier(area: number): number { function getAreaMultiplier(area: number): number {
if (area < 35) { if (area < 35) {
return 1.08; return 1.08;
@@ -17586,15 +17578,16 @@ type ValuationDisplay = {
formatPricePerAreaNumber: (plnPerSquareMeter: number) => string; formatPricePerAreaNumber: (plnPerSquareMeter: number) => string;
}; };
function calculateApartmentValuation(form: ValuationForm, display: ValuationDisplay): ValuationResult { // base - stawka z danych rynkowych (null: jeszcze sie wczytuje albo brak danych dla lokalizacji).
function calculateApartmentValuation(form: ValuationForm, display: ValuationDisplay, base: ValuationBase | null,
baseLoading: boolean): ValuationResult {
// Metraz przychodzi w jednostce wybranej przez uzytkownika, a caly model cen operuje na m² - // Metraz przychodzi w jednostce wybranej przez uzytkownika, a caly model cen operuje na m² -
// dlatego normalizujemy go tutaj, zanim trafi do progow i mnoznikow. // dlatego normalizujemy go tutaj, zanim trafi do progow i mnoznikow.
const areaInput = parsePositiveNumber(form.area); const areaInput = parsePositiveNumber(form.area);
const area = areaInput ? display.toSquareMeters(areaInput) : null; const area = areaInput ? display.toSquareMeters(areaInput) : null;
const plotAreaInput = parsePositiveNumber(form.plotArea); const plotAreaInput = parsePositiveNumber(form.plotArea);
const plotArea = plotAreaInput ? display.toSquareMeters(plotAreaInput) : null; const plotArea = plotAreaInput ? display.toSquareMeters(plotAreaInput) : null;
const marketReference = resolveValuationMarket(form.location); if (!area || !base) {
if (!area || !marketReference) {
return { return {
ready: false, ready: false,
minValue: 0, minValue: 0,
@@ -17606,13 +17599,20 @@ function calculateApartmentValuation(form: ValuationForm, display: ValuationDisp
validUntil: '', validUntil: '',
details: [], details: [],
mainFactors: [], mainFactors: [],
unavailableReason: area && form.location.trim().length >= 2
? baseLoading
? 'Pobieramy dane rynkowe dla tej lokalizacji…'
: 'Brak danych rynkowych dla tej lokalizacji - nie mamy cen transakcyjnych NBP/GUS ani ogłoszeń w serwisie. Spróbuj wpisać większe miasto w formacie „Miasto, dzielnica”.'
: undefined,
}; };
} }
const basePerMeter = marketReference.basePerMeter; const basePerMeter = base.basePerMeter;
const conditionMultiplier = VALUATION_CONDITION_OPTIONS.find((option) => option.title === form.condition)?.multiplier ?? 1; const conditionMultiplier = VALUATION_CONDITION_OPTIONS.find((option) => option.title === form.condition)?.multiplier ?? 1;
const marketMultiplier = form.market === 'Pierwotny' ? 1.07 : 1; // NBP podaje rynek wprost - korekta rynku tylko, gdy stawka nie dotyczy juz wybranego rynku.
const propertyMultiplier = form.propertyType === 'Dom' ? 0.86 : form.propertyType === 'Lokal użytkowy' ? 0.78 : 1; const marketMultiplier = form.market === 'Pierwotny' && !base.sourceLabel.includes('pierwotny') ? 1.07 : 1;
const propertyMultiplier = !base.appliesPropertyMultiplier ? 1
: form.propertyType === 'Dom' ? 0.86 : form.propertyType === 'Lokal użytkowy' ? 0.78 : 1;
const buildingMultiplier = getBuildingTypeMultiplier(form); const buildingMultiplier = getBuildingTypeMultiplier(form);
const materialMultiplier = form.buildingMaterial === 'Cegła' ? 1.025 : form.buildingMaterial === 'Żelbet' ? 1.015 : form.buildingMaterial === 'Wielka płyta' ? 0.975 : 1; const materialMultiplier = form.buildingMaterial === 'Cegła' ? 1.025 : form.buildingMaterial === 'Żelbet' ? 1.015 : form.buildingMaterial === 'Wielka płyta' ? 0.975 : 1;
const kitchenMultiplier = form.propertyType !== 'Lokal użytkowy' && form.kitchen === 'Oddzielna' && area >= 50 ? 1.01 : 1; const kitchenMultiplier = form.propertyType !== 'Lokal użytkowy' && form.kitchen === 'Oddzielna' && area >= 50 ? 1.01 : 1;
@@ -17621,7 +17621,6 @@ function calculateApartmentValuation(form: ValuationForm, display: ValuationDisp
const rentMultiplier = rent && area ? clampNumber(1 - Math.max(0, rent / area - 16) * 0.002, 0.97, 1.01) : 1; const rentMultiplier = rent && area ? clampNumber(1 - Math.max(0, rent / area - 16) * 0.002, 0.97, 1.01) : 1;
const adjustedPerMeter = basePerMeter const adjustedPerMeter = basePerMeter
* getDistrictMultiplier(form.location)
* marketMultiplier * marketMultiplier
* propertyMultiplier * propertyMultiplier
* getAreaMultiplier(area) * getAreaMultiplier(area)
@@ -17689,13 +17688,13 @@ function calculateApartmentValuation(form: ValuationForm, display: ValuationDisp
{ icon: 'money', label: form.propertyType === 'Dom' ? 'Koszty utrzymania' : form.propertyType === 'Lokal użytkowy' ? 'Czynsz / koszty' : 'Czynsz administracyjny', value: rent ? `${formatPln(rent)} / mies.` : 'Nie podano' }, { icon: 'money', label: form.propertyType === 'Dom' ? 'Koszty utrzymania' : form.propertyType === 'Lokal użytkowy' ? 'Czynsz / koszty' : 'Czynsz administracyjny', value: rent ? `${formatPln(rent)} / mies.` : 'Nie podano' },
], ],
mainFactors: [ mainFactors: [
`${marketReference.label}: baza ${display.formatPricePerAreaNumber(basePerMeter)} za ${display.areaSuffix}`, `Stawka bazowa: ${display.formatPricePerAreaNumber(basePerMeter)} za ${display.areaSuffix} - ${base.sourceLabel}`,
marketReference.precision === 'city' ? 'Dokładność: miasto' : marketReference.precision === 'region' ? 'Dokładność: region i typ lokalizacji' : 'Dokładność: średnia krajowa', base.precisionLabel,
`Lokalizacja: x${getDistrictMultiplier(form.location).toFixed(2)}`,
`Stan: x${conditionMultiplier.toFixed(2)}`, `Stan: x${conditionMultiplier.toFixed(2)}`,
`Rok, budynek i typ: x${(getYearMultiplier(form.yearBuilt) * buildingMultiplier * materialMultiplier * getPropertyTypeDetailMultiplier(form, plotArea)).toFixed(2)}`, `Rok, budynek i typ: x${(getYearMultiplier(form.yearBuilt) * buildingMultiplier * materialMultiplier * getPropertyTypeDetailMultiplier(form, plotArea)).toFixed(2)}`,
VALUATION_2026_SOURCE_NOTE, 'Korekty (stan, piętro, rok budowy, udogodnienia) to model orientacyjny, nie operat szacunkowy.',
], ],
sourceLabel: base.sourceLabel,
}; };
} }
@@ -17798,7 +17797,7 @@ async function downloadValuationReportPdf(form: ValuationForm, valuation: Valuat
}, },
{ text: 'Raport szacunkowej wyceny nieruchomości', style: 'title' }, { text: 'Raport szacunkowej wyceny nieruchomości', style: 'title' },
{ {
text: 'Dokument przygotowany automatycznie na podstawie danych podanych w formularzu oraz modelu cenowego 2026. Raport ma charakter informacyjny i nie jest operatem szacunkowym.', text: `Dokument przygotowany automatycznie na podstawie danych z formularza. Stawka bazowa: ${valuation.sourceLabel ?? 'dane rynkowe serwisu'}. Raport ma charakter informacyjny i nie jest operatem szacunkowym.`,
style: 'lead', style: 'lead',
margin: [0, 0, 0, 18], margin: [0, 0, 0, 18],
}, },
@@ -18539,8 +18538,55 @@ function ValuationPage() {
const [valuationLocationSuggestOpen, setValuationLocationSuggestOpen] = useState(false); const [valuationLocationSuggestOpen, setValuationLocationSuggestOpen] = useState(false);
const [valuationLocationSuggestions, setValuationLocationSuggestions] = useState<PlaceSuggestion[]>([]); const [valuationLocationSuggestions, setValuationLocationSuggestions] = useState<PlaceSuggestion[]>([]);
const [valuationLocationSuggestLoading, setValuationLocationSuggestLoading] = useState(false); const [valuationLocationSuggestLoading, setValuationLocationSuggestLoading] = useState(false);
const valuation = calculateApartmentValuation(valuationForm, { toSquareMeters, areaSuffix, formatAreaNumber, formatPricePerAreaNumber }); // Dane rynkowe dla lokalizacji z formularza - te same co w Historii cen (ogloszenia, NBP, GUS).
const valuationMarketReference = resolveValuationMarket(valuationForm.location); const [valuationMarketData, setValuationMarketData] = useState<ValuationMarketData | null>(null);
const [valuationMarketLoading, setValuationMarketLoading] = useState(false);
const valuationPlace = parseValuationLocation(valuationForm.location);
useEffect(() => {
const { city, district } = valuationPlace;
if (city.length < 2) {
setValuationMarketData(null);
setValuationMarketLoading(false);
return;
}
let cancelled = false;
setValuationMarketLoading(true);
const today = new Date().toISOString().slice(0, 10);
const load = (propertyType: 'APARTMENT' | 'HOUSE', withDistrict: boolean) => {
if (withDistrict && !district) {
return Promise.resolve(null);
}
const params = new URLSearchParams({
city,
district: withDistrict ? district : '',
propertyType,
market: valuationForm.market,
from: today,
});
return apiFetch<ApiPriceHistory>(`/price-history?${params.toString()}`).catch(() => null);
};
const timer = window.setTimeout(() => {
const wantsHouses = valuationForm.propertyType === 'Dom';
Promise.all([
load('APARTMENT', false),
load('APARTMENT', true),
wantsHouses ? load('HOUSE', false) : Promise.resolve(null),
wantsHouses ? load('HOUSE', true) : Promise.resolve(null),
]).then(([apartmentCity, apartmentDistrict, houseCity, houseDistrict]) => {
if (!cancelled) {
setValuationMarketData({ city, district, apartmentCity, apartmentDistrict, houseCity, houseDistrict });
setValuationMarketLoading(false);
}
});
}, 400);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [valuationPlace.city, valuationPlace.district, valuationForm.market, valuationForm.propertyType]);
const valuationBase = buildValuationBase(valuationForm, valuationMarketData);
const valuation = calculateApartmentValuation(valuationForm, { toSquareMeters, areaSuffix, formatAreaNumber, formatPricePerAreaNumber },
valuationBase, valuationMarketLoading);
const isValuationDetailsStep = step === 1; const isValuationDetailsStep = step === 1;
const isValuationConditionStep = step === 2; const isValuationConditionStep = step === 2;
const isValuationResultStep = step === 3; const isValuationResultStep = step === 3;
@@ -18550,7 +18596,7 @@ function ValuationPage() {
const valuationBuildingTypeOptions = isHouseValuation ? VALUATION_HOUSE_BUILDING_TYPES : isCommercialValuation ? VALUATION_COMMERCIAL_BUILDING_TYPES : VALUATION_APARTMENT_BUILDING_TYPES; const valuationBuildingTypeOptions = isHouseValuation ? VALUATION_HOUSE_BUILDING_TYPES : isCommercialValuation ? VALUATION_COMMERCIAL_BUILDING_TYPES : VALUATION_APARTMENT_BUILDING_TYPES;
const visibleValuationAmenityOptions = getValuationAmenityOptions(valuationForm.propertyType); const visibleValuationAmenityOptions = getValuationAmenityOptions(valuationForm.propertyType);
const nextStepLabel = isValuationConditionStep ? 'Dalej: Wynik wyceny' : isValuationDetailsStep ? 'Dalej: Stan i udogodnienia' : 'Dalej: Szczegóły nieruchomości'; const nextStepLabel = isValuationConditionStep ? 'Dalej: Wynik wyceny' : isValuationDetailsStep ? 'Dalej: Stan i udogodnienia' : 'Dalej: Szczegóły nieruchomości';
const isLocationComplete = Boolean(valuationMarketReference && valuationForm.market && valuationForm.propertyType); const isLocationComplete = Boolean(valuationPlace.city.length >= 2 && valuationForm.market && valuationForm.propertyType);
const isDetailsComplete = Boolean( const isDetailsComplete = Boolean(
parsePositiveNumber(valuationForm.area) parsePositiveNumber(valuationForm.area)
&& valuationForm.rooms && valuationForm.rooms
@@ -18593,8 +18639,8 @@ function ValuationPage() {
{ icon: 'target', label: 'Powtórz wycenę' }, { icon: 'target', label: 'Powtórz wycenę' },
]; ];
const valuationMissingItems = [ const valuationMissingItems = [
!valuationMarketReference valuationPlace.city.length < 2
? { icon: 'pin', title: 'Podaj lokalizację', text: 'Wpisz miasto, dzielnicę, miejscowość albo wieś i wybierz podpowiedź z map.' } ? { icon: 'pin', title: 'Podaj lokalizację', text: 'Wpisz miasto, najlepiej z dzielnicą, np. „Kraków, Podgórze”.' }
: null, : null,
!parsePositiveNumber(valuationForm.area) !parsePositiveNumber(valuationForm.area)
? { icon: 'house', title: `Podaj metraż ${valuationPropertyLabel}`, text: 'Wycena startuje dopiero po wpisaniu powierzchni w m².' } ? { icon: 'house', title: `Podaj metraż ${valuationPropertyLabel}`, text: 'Wycena startuje dopiero po wpisaniu powierzchni w m².' }
@@ -18764,7 +18810,7 @@ function ValuationPage() {
<p><span>Szacowana wartość maksymalna</span><strong>{valuation.ready ? formatPrice(valuation.maxValue) : '---'}</strong></p> <p><span>Szacowana wartość maksymalna</span><strong>{valuation.ready ? formatPrice(valuation.maxValue) : '---'}</strong></p>
<p><span>Średnia wartość</span><strong>{valuation.ready ? formatPrice(valuation.averageValue) : '---'}</strong></p> <p><span>Średnia wartość</span><strong>{valuation.ready ? formatPrice(valuation.averageValue) : '---'}</strong></p>
<p><span>Cena za {areaSuffix} (min - max)</span><strong>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : '---'}</strong></p> <p><span>Cena za {areaSuffix} (min - max)</span><strong>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : '---'}</strong></p>
<small>{valuation.ready ? `Wycena ważna do ${valuation.validUntil}` : 'Podaj dane, aby obliczyć wycenę'}</small> <small>{valuation.ready ? `Wycena ważna do ${valuation.validUntil}` : valuation.unavailableReason ?? 'Podaj dane, aby obliczyć wycenę'}</small>
</section> </section>
<section className="valuation-preview-card valuation-next-card"> <section className="valuation-preview-card valuation-next-card">
@@ -18978,7 +19024,8 @@ function ValuationPage() {
{valuation.ready ? <strong className="valuation-price-range">{formatPrice(valuation.minValue)} - {formatPrice(valuation.maxValue)}</strong> : <strong>0</strong>} {valuation.ready ? <strong className="valuation-price-range">{formatPrice(valuation.minValue)} - {formatPrice(valuation.maxValue)}</strong> : <strong>0</strong>}
<small>Przedział cenowy</small> <small>Przedział cenowy</small>
<em>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : formatPricePerArea(0)}</em> <em>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : formatPricePerArea(0)}</em>
<p>{valuation.ready ? `Średnio: ${formatPrice(valuation.averageValue)} (${formatPricePerArea(valuation.averagePerMeter)}).` : 'Podaj miasto oraz powierzchnię, aby zobaczyć wynik wyceny.'}</p> <p>{valuation.ready ? `Średnio: ${formatPrice(valuation.averageValue)} (${formatPricePerArea(valuation.averagePerMeter)}).` : valuation.unavailableReason ?? 'Podaj miasto oraz powierzchnię, aby zobaczyć wynik wyceny.'}</p>
{valuation.ready && valuation.sourceLabel && <small className="valuation-source-note">Na podstawie: {valuation.sourceLabel}.</small>}
</div> </div>
<div className="valuation-house-graphic" aria-hidden="true"> <div className="valuation-house-graphic" aria-hidden="true">
<span><Icon name="house" /></span> <span><Icon name="house" /></span>
+10
View File
@@ -35099,3 +35099,13 @@ a.listing-detail-back {
position: static; position: static;
} }
} }
/* Wycena: zrodlo stawki bazowej pod wynikiem (NBP / GUS / ogloszenia). */
.valuation-source-note {
color: #5f6f84;
display: block;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
margin-top: 6px;
}