Wycena na prawdziwych danych zamiast zmyslonej tabeli cen
Deploy Polska Lokalnie / deploy (push) Successful in 11s
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:
+190
-143
@@ -17130,6 +17130,10 @@ type ValuationResult = {
|
||||
validUntil: string;
|
||||
details: { icon: string; label: string; value: 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 = {
|
||||
@@ -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_PURPOSES = ['Handel', 'Biuro', 'Usługi', 'Gastronomia', 'Medyczny', 'Magazyn'];
|
||||
|
||||
type ValuationMarketReference = {
|
||||
label: string;
|
||||
// Stawka bazowa wyceny z prawdziwych danych: cena transakcyjna NBP/GUS dla miasta albo srednia
|
||||
// cen ofertowych z ogloszen serwisu. sourceLabel trafia do wyniku i raportu PDF.
|
||||
type ValuationBase = {
|
||||
basePerMeter: number;
|
||||
precision: 'city' | 'region' | 'national';
|
||||
locationType: string;
|
||||
sourceLabel: 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> = {
|
||||
Warszawa: 15700,
|
||||
@@ -17230,53 +17341,6 @@ const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = {
|
||||
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 = [
|
||||
{ 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 },
|
||||
@@ -17353,78 +17417,6 @@ function parsePositiveNumber(value: string): number | 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 {
|
||||
if (area < 35) {
|
||||
return 1.08;
|
||||
@@ -17586,15 +17578,16 @@ type ValuationDisplay = {
|
||||
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² -
|
||||
// dlatego normalizujemy go tutaj, zanim trafi do progow i mnoznikow.
|
||||
const areaInput = parsePositiveNumber(form.area);
|
||||
const area = areaInput ? display.toSquareMeters(areaInput) : null;
|
||||
const plotAreaInput = parsePositiveNumber(form.plotArea);
|
||||
const plotArea = plotAreaInput ? display.toSquareMeters(plotAreaInput) : null;
|
||||
const marketReference = resolveValuationMarket(form.location);
|
||||
if (!area || !marketReference) {
|
||||
if (!area || !base) {
|
||||
return {
|
||||
ready: false,
|
||||
minValue: 0,
|
||||
@@ -17606,13 +17599,20 @@ function calculateApartmentValuation(form: ValuationForm, display: ValuationDisp
|
||||
validUntil: '',
|
||||
details: [],
|
||||
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 marketMultiplier = form.market === 'Pierwotny' ? 1.07 : 1;
|
||||
const propertyMultiplier = form.propertyType === 'Dom' ? 0.86 : form.propertyType === 'Lokal użytkowy' ? 0.78 : 1;
|
||||
// NBP podaje rynek wprost - korekta rynku tylko, gdy stawka nie dotyczy juz wybranego rynku.
|
||||
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 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;
|
||||
@@ -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 adjustedPerMeter = basePerMeter
|
||||
* getDistrictMultiplier(form.location)
|
||||
* marketMultiplier
|
||||
* propertyMultiplier
|
||||
* 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' },
|
||||
],
|
||||
mainFactors: [
|
||||
`${marketReference.label}: baza ${display.formatPricePerAreaNumber(basePerMeter)} za ${display.areaSuffix}`,
|
||||
marketReference.precision === 'city' ? 'Dokładność: miasto' : marketReference.precision === 'region' ? 'Dokładność: region i typ lokalizacji' : 'Dokładność: średnia krajowa',
|
||||
`Lokalizacja: x${getDistrictMultiplier(form.location).toFixed(2)}`,
|
||||
`Stawka bazowa: ${display.formatPricePerAreaNumber(basePerMeter)} za ${display.areaSuffix} - ${base.sourceLabel}`,
|
||||
base.precisionLabel,
|
||||
`Stan: x${conditionMultiplier.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: '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',
|
||||
margin: [0, 0, 0, 18],
|
||||
},
|
||||
@@ -18539,8 +18538,55 @@ function ValuationPage() {
|
||||
const [valuationLocationSuggestOpen, setValuationLocationSuggestOpen] = useState(false);
|
||||
const [valuationLocationSuggestions, setValuationLocationSuggestions] = useState<PlaceSuggestion[]>([]);
|
||||
const [valuationLocationSuggestLoading, setValuationLocationSuggestLoading] = useState(false);
|
||||
const valuation = calculateApartmentValuation(valuationForm, { toSquareMeters, areaSuffix, formatAreaNumber, formatPricePerAreaNumber });
|
||||
const valuationMarketReference = resolveValuationMarket(valuationForm.location);
|
||||
// Dane rynkowe dla lokalizacji z formularza - te same co w Historii cen (ogloszenia, NBP, GUS).
|
||||
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 isValuationConditionStep = step === 2;
|
||||
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 visibleValuationAmenityOptions = getValuationAmenityOptions(valuationForm.propertyType);
|
||||
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(
|
||||
parsePositiveNumber(valuationForm.area)
|
||||
&& valuationForm.rooms
|
||||
@@ -18593,8 +18639,8 @@ function ValuationPage() {
|
||||
{ icon: 'target', label: 'Powtórz wycenę' },
|
||||
];
|
||||
const valuationMissingItems = [
|
||||
!valuationMarketReference
|
||||
? { icon: 'pin', title: 'Podaj lokalizację', text: 'Wpisz miasto, dzielnicę, miejscowość albo wieś i wybierz podpowiedź z map.' }
|
||||
valuationPlace.city.length < 2
|
||||
? { icon: 'pin', title: 'Podaj lokalizację', text: 'Wpisz miasto, najlepiej z dzielnicą, np. „Kraków, Podgórze”.' }
|
||||
: null,
|
||||
!parsePositiveNumber(valuationForm.area)
|
||||
? { 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>Ś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>
|
||||
<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 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>}
|
||||
<small>Przedział cenowy</small>
|
||||
<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 className="valuation-house-graphic" aria-hidden="true">
|
||||
<span><Icon name="house" /></span>
|
||||
|
||||
@@ -35099,3 +35099,13 @@ a.listing-detail-back {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user