From 017ff4b2c661adad0f665cc3e4def5b9699a3071 Mon Sep 17 00:00:00 2001 From: Przemek Zatorski Date: Tue, 22 Sep 2026 14:56:47 +0200 Subject: [PATCH] Negocjacje na prawdziwych danych zamiast zmyslonego modelu Nowa strona /negocjacje/:listingId porownuje cene ogloszenia sprzedazy z cenami transakcyjnymi NBP i GUS oraz srednia ogloszen w dzielnicy i miescie. Przedzial: od wartosci wg cen transakcyjnych do polowy nadwyzki ceny ofertowej. W ogloszeniu przycisk "Ile moge utargowac?". Usuniety fikcyjny model negocjacji i tabela cen za m2. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 774 +++++++++++++++------------------ frontend/src/routes.ts | 4 +- frontend/src/styles.css | 60 ++- frontend/tests/routing.spec.ts | 22 +- 4 files changed, 441 insertions(+), 419 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1058772..528c280 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -784,25 +784,6 @@ const rentQuickFilters = [ }, ]; -type BuyOffer = { - title: string; - propertyType: PropertySearchType; - city: string; - district: string; - rooms: number; - area: number; - priceValue: number; - market: 'wtorny' | 'pierwotny'; - year: number; - date: string; - dateOrder: number; - score: string; - saving: string; - tags: string[]; - position: string; - image: 'hero' | 'city'; -}; - type PropertySearchType = 'Mieszkanie' | 'Dom' | 'Działka' | 'Lokal użytkowy' | 'Pokój'; const POLISH_CITIES = [ @@ -960,150 +941,6 @@ function safeRandomId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } -function offerMeta(offer: BuyOffer, fmt: PriceFormatters): string { - const perMeter = Math.round(offer.priceValue / offer.area); - const roomPart = offer.propertyType === 'Działka' ? offer.propertyType : roomsLabel(offer.rooms); - return `${roomPart} • ${fmt.formatArea(offer.area)} • ${fmt.formatPricePerArea(perMeter)} • ${offer.year} r.`; -} - -type NegotiationChance = { - label: 'Wysoka' | 'Średnia' | 'Niska'; - text: string; -}; - -type NegotiationModel = { - offerPerMeter: number; - minPrice: number; - maxPrice: number; - minPerMeter: number; - maxPerMeter: number; - negotiationSpan: number; - expectedSaving: number; - chance: NegotiationChance; - marketAvgPerMeter: number; - marketDeltaPct: number; - conditionDiscountPct: number; - marketDays: number; - seasonNegotiationBonusPct: number; -}; - -type NegotiationTransaction = { - street: string; - meta: string; - price: string; - sqm: string; - date: string; - image: 'hero' | 'city'; -}; - -function roundPrice(value: number): number { - return Math.round(value / 100) * 100; -} - -function negotiationChanceFromScore(score: number): NegotiationChance { - if (score >= 76) { - return { - label: 'Wysoka', - text: 'Sprzedający prawdopodobnie zaakceptuje ofertę w tym zakresie cenowym.', - }; - } - if (score >= 52) { - return { - label: 'Średnia', - text: 'Oferta jest realna, ale sprzedający może oczekiwać mocniejszego uzasadnienia ceny.', - }; - } - return { - label: 'Niska', - text: 'Ten zakres może być zbyt agresywny. Rozważ podniesienie oferty startowej.', - }; -} - -function buildNegotiationModel(offer: BuyOffer): NegotiationModel { - const offerPerMeter = offer.priceValue / offer.area; - const cityBase = VALUATION_PRICE_PER_SQM_BY_CITY[offer.city] ?? offerPerMeter * 0.94; - const premiumToCity = cityBase > 0 ? (offerPerMeter - cityBase) / cityBase : 0; - const age = Math.max(0, 2026 - offer.year); - const marketFactor = offer.market === 'wtorny' ? 0.017 : 0.007; - const ageFactor = clampNumber(age / 145, 0, 0.07); - const opportunityFactor = offer.tags.includes('Okazja') ? 0.013 : 0; - const premiumFactor = Math.max(0, premiumToCity) * 0.5; - const baselineDiscount = clampNumber(0.026 + marketFactor + ageFactor + opportunityFactor + premiumFactor, 0.02, 0.165); - const spreadDiscount = clampNumber(0.014 + Math.max(0, premiumToCity) * 0.16 + age / 620, 0.012, 0.052); - const rawMax = roundPrice(offer.priceValue * (1 - baselineDiscount)); - const rawMin = roundPrice(offer.priceValue * (1 - baselineDiscount - spreadDiscount)); - const floorMin = roundPrice(offer.priceValue * 0.75); - const minPrice = Math.max(floorMin, Math.min(rawMin, rawMax - 5000)); - const maxPrice = Math.max(minPrice + 5000, Math.min(rawMax, offer.priceValue - 2000)); - const minPerMeter = Math.round(minPrice / offer.area); - const maxPerMeter = Math.round(maxPrice / offer.area); - const negotiationSpan = maxPrice - minPrice; - const expectedSaving = offer.priceValue - Math.round((minPrice + maxPrice) / 2); - const chanceScore = clampNumber( - 61 - + (offer.market === 'wtorny' ? 11 : -4) - + (offer.tags.includes('Okazja') ? 7 : 0) - + Math.max(0, premiumToCity) * 62 - + Math.min(0, premiumToCity) * 22 - + Math.min(age / 2.7, 14), - 24, - 96, - ); - - return { - offerPerMeter: Math.round(offerPerMeter), - minPrice, - maxPrice, - minPerMeter, - maxPerMeter, - negotiationSpan, - expectedSaving, - chance: negotiationChanceFromScore(chanceScore), - marketAvgPerMeter: Math.round(clampNumber((minPerMeter * 0.55 + maxPerMeter * 0.45), 1000, 40000)), - marketDeltaPct: Number((clampNumber((offerPerMeter - maxPerMeter) / offerPerMeter, -0.03, 0.18) * 100).toFixed(1)), - conditionDiscountPct: Number((clampNumber((age / 2.2) * 0.35 + (offer.market === 'wtorny' ? 2.2 : 1.1), 1.2, 8.4)).toFixed(1)), - marketDays: Math.round(clampNumber(38 + age * 1.05 + (offer.market === 'wtorny' ? 11 : 5), 28, 124)), - seasonNegotiationBonusPct: Number((clampNumber(1.2 + (offer.market === 'wtorny' ? 1 : 0.5) + Math.max(0, premiumToCity) * 3.5, 0.8, 5.6)).toFixed(1)), - }; -} - -function buildComparableTransactions(activeOffer: BuyOffer, source: BuyOffer[], fmt: PriceFormatters): NegotiationTransaction[] { - const sameCityAndType = source - .filter((offer) => offer !== activeOffer && offer.city === activeOffer.city && offer.propertyType === activeOffer.propertyType) - .sort((left, right) => Math.abs(left.area - activeOffer.area) - Math.abs(right.area - activeOffer.area)); - const sameCity = source - .filter((offer) => offer !== activeOffer && offer.city === activeOffer.city) - .sort((left, right) => Math.abs(left.area - activeOffer.area) - Math.abs(right.area - activeOffer.area)); - const fallback = source - .filter((offer) => offer !== activeOffer) - .sort((left, right) => Math.abs(left.area - activeOffer.area) - Math.abs(right.area - activeOffer.area)); - const merged = [...sameCityAndType, ...sameCity, ...fallback]; - const unique: BuyOffer[] = []; - const keys = new Set(); - for (const offer of merged) { - const key = `${offer.city}|${offer.district}|${offer.title}|${offer.priceValue}`; - if (keys.has(key)) { - continue; - } - keys.add(key); - unique.push(offer); - if (unique.length >= 3) { - break; - } - } - return unique.map((offer) => { - const perMeter = Math.round(offer.priceValue / offer.area); - return { - street: offer.district, - meta: `${fmt.formatArea(offer.area)} · ${roomsLabel(offer.rooms)} · ${offer.year} r.`, - price: fmt.formatPrice(offer.priceValue), - sqm: fmt.formatPricePerArea(perMeter), - date: offer.date, - image: offer.image, - }; - }); -} - // slug trafia do adresu listy firm: /firmy?kategoria=remonty const serviceCategories = [ { @@ -3226,12 +3063,6 @@ function App() { navigate(mapPath(name, source === 'rent' ? 'RENT' : 'SALE')); }; - // Wybrana oferta to dane przejsciowe miedzy trasami - przekazujemy je w location.state, - // dzieki czemu nie zostaja w globalnym stanie po opuszczeniu widoku. - const openNegotiation = (offer: BuyOffer) => { - navigate(negotiationPath(), { state: { offer } }); - }; - const openListing = (id: number) => { navigate(listingPath(id)); }; @@ -3668,7 +3499,6 @@ function App() { }> } /> } /> - } /> + } /> + } /> } /> @@ -3852,23 +3683,6 @@ function MapSearchRoute() { return ; } -// Negocjacje: oferta wskazana z listy ofert przychodzi w location.state (dane przejsciowe), -// wybor z rozwijanej listy to juz zwykly stan formularza. -function NegotiationRoute() { - const location = useLocation(); - const offerFromState = (location.state as { offer?: BuyOffer } | null)?.offer ?? null; - const [selectedOffer, setSelectedOffer] = useState(offerFromState); - - useEffect(() => { - if (offerFromState) { - setSelectedOffer(offerFromState); - } - }, [offerFromState]); - - // Analiza negocjacji nie ma jeszcze zrodla danych - bez oferty strona pokazuje pusty stan. - return ; -} - // Nieznany adres: czytelny komunikat zamiast pustego ekranu. function NotFoundPage() { return ( @@ -6143,125 +5957,303 @@ function DistrictRankingPage() { ); } -function NegotiationPage({ - offers, - selectedOffer, - onSelectOffer, -}: { - offers: BuyOffer[]; - selectedOffer: BuyOffer | null; - onSelectOffer: (offer: BuyOffer) => void; -}) { - const navigate = useNavigate(); - const display = useDisplay(); - const { formatPrice, formatPriceNumber, formatPricePerArea, formatPricePerAreaNumber } = display; - const fallbackOffer = offers[0] ?? null; - const activeOffer = selectedOffer ?? fallbackOffer; +// Punkt odniesienia dla ceny ogloszenia - wylacznie prawdziwe dane (NBP, GUS, ogloszenia serwisu). +type NegotiationReference = { + label: string; + // Ta sama nazwa do zdania "wartosc wg ...". + byLabel: string; + detail: string; + perMeter: number; + transactional: boolean; +}; - if (!activeOffer) { - return ( -
-
-
- Panel główny - - Ile możesz utargować? -
+type NegotiationAnalysis = { + offerPerMeter: number; + references: NegotiationReference[]; + // Podstawa przedzialu: cena transakcyjna (NBP, potem GUS), a bez niej srednia ogloszen. + basis: NegotiationReference | null; + basisValue: number | null; + // O ile procent cena ofertowa przewyzsza wartosc wg podstawy (ujemne = taniej). + overpricePct: number | null; + // Od wartosci wg podstawy do polowy nadwyzki ponad nia; null, gdy ogloszenie nie jest drozsze. + range: { from: number; to: number } | null; +}; -
-

Ile możesz utargować?

-

Brak danych do analizy.

-
+const NEGOTIATION_MIN_LISTINGS = 3; + +// Srednia ogloszen bez analizowanego ogloszenia - inaczej porownywaloby sie samo ze soba. +function listingsAverageWithout(current: ApiPricePoint | null | undefined, ownPerMeter: number, includesOwn: boolean): { perMeter: number; count: number } | null { + if (!current) { + return null; + } + if (!includesOwn) { + return { perMeter: current.avgPricePerM2, count: current.listingsCount }; + } + const count = current.listingsCount - 1; + return count > 0 + ? { perMeter: Math.round((current.avgPricePerM2 * current.listingsCount - ownPerMeter) / count), count } + : null; +} + +function buildNegotiationAnalysis(listing: ApiListingDetail, cityHistory: ApiPriceHistory | null, districtHistory: ApiPriceHistory | null): NegotiationAnalysis { + const offerPerMeter = listing.area > 0 ? listing.price / listing.area : 0; + const includesOwn = listing.status === 'APPROVED'; + const references: NegotiationReference[] = []; + + const nbp = cityHistory?.nbp[cityHistory.nbp.length - 1]; + if (nbp) { + references.push({ + label: 'Cena transakcyjna NBP', + byLabel: 'cen transakcyjnych NBP', + detail: `${listing.city}, rynek ${cityHistory!.nbpMarket.toLowerCase()}, ${HISTORY_ROMAN_QUARTERS[nbp.quarter - 1]} kw. ${nbp.year}`, + perMeter: nbp.pricePerM2, + transactional: true, + }); + } + const gus = cityHistory?.gus[cityHistory.gus.length - 1]; + if (gus && gus.year >= new Date().getFullYear() - VALUATION_MAX_GUS_AGE_YEARS) { + references.push({ + label: 'Cena transakcyjna GUS', + byLabel: 'cen transakcyjnych GUS', + detail: `${listing.city}, ${HISTORY_ROMAN_QUARTERS[gus.quarter - 1]} kw. ${gus.year}`, + perMeter: gus.pricePerM2, + transactional: true, + }); + } + const district = listing.district ? listingsAverageWithout(districtHistory?.current, offerPerMeter, includesOwn) : null; + if (district && district.count >= NEGOTIATION_MIN_LISTINGS) { + references.push({ + label: 'Średnia ogłoszeń w dzielnicy', + byLabel: 'średniej ogłoszeń w dzielnicy', + detail: `${listing.district}, ${district.count} ogłoszeń w serwisie`, + perMeter: district.perMeter, + transactional: false, + }); + } + const city = listingsAverageWithout(cityHistory?.current, offerPerMeter, includesOwn); + if (city && city.count >= NEGOTIATION_MIN_LISTINGS) { + references.push({ + label: 'Średnia ogłoszeń w mieście', + byLabel: 'średniej ogłoszeń w mieście', + detail: `${listing.city}, ${city.count} ogłoszeń w serwisie`, + perMeter: city.perMeter, + transactional: false, + }); + } + + const basis = references.find((reference) => reference.transactional) ?? references[0] ?? null; + if (!basis || listing.area <= 0) { + return { offerPerMeter, references, basis: null, basisValue: null, overpricePct: null, range: null }; + } + const basisValue = basis.perMeter * listing.area; + const overpricePct = ((listing.price - basisValue) / basisValue) * 100; + const range = listing.price > basisValue + ? { from: roundToThousand(basisValue), to: roundToThousand(basisValue + (listing.price - basisValue) / 2) } + : null; + return { offerPerMeter, references, basis, basisValue, overpricePct, range }; +} + +function formatSignedPercent(value: number): string { + return `${value > 0 ? '+' : ''}${value.toFixed(1).replace('.', ',')}%`; +} + +// /negocjacje - opis narzedzia; /negocjacje/:listingId - analiza konkretnego ogloszenia sprzedazy. +function NegotiationPage() { + const { listingId } = useParams(); + const { user } = useAuth(); + const { formatPrice, formatPricePerArea, formatPricePerAreaNumber, formatArea } = useDisplay(); + const [listing, setListing] = useState(null); + const [listingError, setListingError] = useState(false); + const [cityHistory, setCityHistory] = useState(null); + const [districtHistory, setDistrictHistory] = useState(null); + const [marketLoading, setMarketLoading] = useState(false); + const [similar, setSimilar] = useState([]); + + useEffect(() => { + setListing(null); + setListingError(false); + if (!listingId) { + return; + } + let cancelled = false; + apiFetch(`/listings/${encodeURIComponent(listingId)}`) + .then((data) => { + if (!cancelled) { + setListing(data); + } + }) + .catch(() => { + if (!cancelled) { + setListingError(true); + } + }); + return () => { + cancelled = true; + }; + }, [listingId]); + + useEffect(() => { + setCityHistory(null); + setDistrictHistory(null); + setSimilar([]); + if (!listing || listing.offerType !== 'SALE') { + return; + } + let cancelled = false; + const history = (district: string) => { + const params = new URLSearchParams({ + city: listing.city, + district, + offerType: 'SALE', + propertyType: listing.propertyType, + market: listing.market ?? '', + from: new Date().toISOString().slice(0, 10), + }); + return apiFetch(`/price-history?${params.toString()}`).catch(() => null); + }; + const similarParams = new URLSearchParams({ city: listing.city, offerType: 'SALE', propertyType: listing.propertyType }); + setMarketLoading(true); + Promise.all([ + history(''), + listing.district ? history(listing.district) : Promise.resolve(null), + apiFetch(`/listings?${similarParams.toString()}`).catch(() => []), + ]).then(([cityData, districtData, others]) => { + if (cancelled) { + return; + } + setCityHistory(cityData); + setDistrictHistory(districtData); + setSimilar(others + .filter((other) => other.id !== listing.id && other.area > 0) + .sort((left, right) => Math.abs(left.area - listing.area) - Math.abs(right.area - listing.area)) + .slice(0, 4)); + setMarketLoading(false); + }); + return () => { + cancelled = true; + }; + }, [listing]); + + const breadcrumb = ( +
+ Panel główny + + Ile możesz utargować? + {listing && ( + <> + + {listing.title} + + )} +
+ ); + const title = ( +
+

Ile możesz utargować?

+

Porównujemy cenę ogłoszenia z cenami transakcyjnymi NBP i GUS oraz z ogłoszeniami w serwisie.

+
+ ); + const shell = (content: ReactNode) => ( +
+
+ {breadcrumb} + {title} + {content} +
+
+ ); + + if (!listingId) { + return shell( +
+
- Brak danych -

Analiza rekomendowanej ceny transakcyjnej będzie dostępna wkrótce.

+ Wybierz ogłoszenie sprzedaży +

Analiza dotyczy konkretnego mieszkania lub domu. Otwórz ogłoszenie sprzedaży i kliknij „Ile mogę utargować?” - zobaczysz, jak jego cena wypada na tle rynku i jaki przedział negocjacji ma uzasadnienie w danych.

+ Przeglądaj oferty sprzedaży
-
+ + , ); } - const model = buildNegotiationModel(activeOffer); - const transactions = buildComparableTransactions(activeOffer, offers, display); - const selectedIndex = Math.max(0, offers.findIndex((offer) => offer === activeOffer)); - const chanceTone = model.chance.label === 'Niska' ? 'low' : model.chance.label === 'Średnia' ? 'medium' : 'high'; - const negotiationPercent = ((model.negotiationSpan / activeOffer.priceValue) * 100).toFixed(1).replace('.', ','); - const rangeMidPrice = Math.round((model.minPrice + model.maxPrice) / 2); - const rangeMidPerMeter = Math.round(rangeMidPrice / activeOffer.area); - const updatedAt = new Date().toLocaleDateString('pl-PL'); - const steps = [ - 'Analizujemy ofertę', - 'Badamy rynek', - 'Wyznaczamy zakres negocjacji', - 'Pomagamy w negocjacjach', - ]; + if (listingError) { + return shell( +
+ Nie znaleźliśmy tego ogłoszenia +

Ogłoszenie mogło zostać usunięte albo zakończone.

+ Przeglądaj oferty sprzedaży +
, + ); + } - return ( -
-
-
- Panel główny - - Ile możesz utargować? -
+ if (!listing) { + return shell(

Wczytujemy ogłoszenie…

); + } -
-

Ile możesz utargować?

-

AI podpowie realistyczną cenę transakcyjną na podstawie danych rynkowych.

-
+ if (listing.offerType !== 'SALE') { + return shell( +
+ Analiza dotyczy tylko ogłoszeń sprzedaży +

Dla najmu nie mamy cen transakcyjnych, z którymi można by porównać czynsz.

+ Wróć do ogłoszenia +
, + ); + } -
- - + const analysis = buildNegotiationAnalysis(listing, cityHistory, districtHistory); + const place = [listing.city, listing.district].filter(Boolean).join(', '); + const isOwner = Boolean(user && listing.ownerId != null && listing.ownerId === user.id); + const photo = listing.photos[0] ?? heroImage; + + return shell( +
+
+
+
+
+

{listing.title}

+

{place}

+ {roomsLabel(listing.rooms)} • {formatArea(listing.area)}{listing.market ? ` • rynek ${listing.market.toLowerCase()}` : ''}{listing.yearBuilt ? ` • ${listing.yearBuilt} r.` : ''} +
+
+ Cena ofertowa + {formatPrice(listing.price)} + {formatPricePerArea(Math.round(analysis.offerPerMeter))} +
-
-
-
-
-
-

{activeOffer.title}

-

{activeOffer.district}

- {offerMeta(activeOffer, display)} +
+ {marketLoading ? ( +

Pobieramy dane rynkowe dla lokalizacji {place}…

+ ) : !analysis.basis || analysis.basisValue == null || analysis.overpricePct == null ? ( +
+
+

Brak danych rynkowych

+

Nie mamy z czym porównać tej ceny

+ Dla lokalizacji {place} nie ma cen transakcyjnych NBP ani GUS, a w serwisie jest za mało podobnych ogłoszeń (potrzebujemy co najmniej {NEGOTIATION_MIN_LISTINGS}).
-
- Cena ofertowa - {formatPrice(activeOffer.priceValue)} - {formatPricePerArea(model.offerPerMeter)} -
-
- -
+
+ ) : analysis.range ? ( + <>
-

Rekomendowana cena transakcyjna

-

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

- {formatPricePerAreaNumber(model.minPerMeter)} - {formatPricePerArea(model.maxPerMeter)} - Realistyczna cena, którą możesz zaproponować sprzedającemu. +

Proponowany przedział negocjacji

+

{formatPrice(analysis.range.from)} - {formatPrice(analysis.range.to)}

+ {formatPricePerAreaNumber(Math.round(analysis.range.from / listing.area))} - {formatPricePerArea(Math.round(analysis.range.to / listing.area))} + Ogłoszenie jest o {analysis.overpricePct.toFixed(1).replace('.', ',')}% droższe niż wartość wg {analysis.basis.byLabel}.
-
- {formatPrice(model.minPrice)} - Cena minimalna + {formatPrice(analysis.range.from)} + Wartość wg ceny rynkowej
- {formatPrice(model.negotiationSpan)} - Zakres negocjacji ({negotiationPercent}%) + {formatPrice(listing.price - analysis.range.to)} - {formatPrice(listing.price - analysis.range.from)} + Możliwa różnica względem ceny ofertowej
- {formatPrice(activeOffer.priceValue)} + {formatPrice(listing.price)} Cena ofertowa
-
- -
-

Dlaczego właśnie taki zakres?

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

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

-
- -
+ + ) : ( +
- Potrzebujesz pomocy w negocjacjach? -

Nasi eksperci pomogą Ci przejść przez proces zakupu i wynegocjować najlepszą cenę.

+

Cena na poziomie rynku lub niżej

+

Pole do negocjacji jest małe

+ Cena ogłoszenia jest o {Math.abs(analysis.overpricePct).toFixed(1).replace('.', ',')}% niższa od wartości wg {analysis.basis.byLabel} ({formatPrice(roundToThousand(analysis.basisValue))}). Zamiast o cenę możesz rozmawiać o warunkach: terminie wydania, wyposażeniu, harmonogramie płatności.
-
- Skontaktuj się z ekspertem - Pierwsza konsultacja gratis -
-
+
+ )} +
-
- -

Dane aktualizowane codziennie na podstawie tysięcy transakcji z całej Polski.

- Ostatnia aktualizacja: {updatedAt} -
-
+ {analysis.references.length > 0 && ( +
+

Cena ogłoszenia na tle rynku

+
+ {analysis.references.map((reference) => ( +
+ +
+ {reference.label}: {formatPricePerArea(reference.perMeter)} + {reference.detail}. Ogłoszenie: {formatSignedPercent(((analysis.offerPerMeter - reference.perMeter) / reference.perMeter) * 100)}. +
+
+ ))} +
+
+ )} - - + {analysis.basis && ( +
+ Jak liczymy przedział? +

Dolna granica to wartość wg {analysis.basis.byLabel} ({formatPricePerArea(analysis.basis.perMeter)}) razy powierzchnia. Górna granica leży w połowie drogi między tą wartością a ceną ofertową. {analysis.basis.transactional ? 'Ceny NBP i GUS to średnie dla całego miasta - nie uwzględniają standardu, piętra ani dzielnicy tego mieszkania, więc traktuj wynik jako argument w rozmowie, a nie wycenę.' : 'Średnia ogłoszeń to ceny ofertowe, nie transakcyjne - realne ceny sprzedaży bywają niższe.'}

+
+ )} -
+ + + , ); } @@ -6596,7 +6583,7 @@ function AccountDashboardPage({ const tools = [ { icon: 'money', title: 'Zdolność kredytowa', text: 'Sprawdź, na jaki kredyt Cię stać i jaka będzie rata', path: ROUTES.creditCalculator }, { icon: 'chart', title: 'Historia cen', text: 'Sprawdź jak zmieniały się ceny mieszkań w czasie', path: ROUTES.priceHistory }, - { icon: 'coin', title: 'Ile możesz utargować?', text: 'AI podpowie realistyczną cenę transakcyjną', path: ROUTES.negotiation }, + { icon: 'coin', title: 'Ile możesz utargować?', text: 'Porównaj cenę ogłoszenia z cenami transakcyjnymi', path: ROUTES.negotiation }, { icon: 'calculator', title: 'Wycena mieszkania', text: 'Darmowa wycena Twojego mieszkania online', path: ROUTES.valuation }, { icon: 'house', title: 'Ranking dzielnic', text: 'Zobacz najlepsze dzielnice w Polsce', path: ROUTES.districtRanking }, { icon: 'shuffle', title: 'Porównanie mieszkań', text: 'Porównuj oferty i wybieraj najlepsze', path: ROUTES.comparison }, @@ -17315,55 +17302,6 @@ function buildValuationBaseFromData(form: ValuationForm, data: ValuationMarketDa return null; } - -const VALUATION_PRICE_PER_SQM_BY_CITY: Record = { - Warszawa: 15700, - Kraków: 14600, - Gdańsk: 13550, - Gdynia: 11900, - Wrocław: 11750, - Poznań: 10800, - Józefów: 11600, - 'Grodzisk Mazowiecki': 10600, - Rzeszów: 10200, - Lublin: 9300, - Katowice: 9200, - Szczecin: 9100, - Białystok: 9050, - Olsztyn: 9000, - Toruń: 8200, - Opole: 8200, - Bydgoszcz: 7950, - 'Bielsko-Biała': 7950, - Kielce: 7900, - 'Zielona Góra': 7600, - Łódź: 7550, - Radom: 7000, - Elbląg: 6950, - Gliwice: 6900, - Iława: 6850, - 'Gorzów Wielkopolski': 6800, - 'Jelenia Góra': 6600, - Częstochowa: 6100, - 'Dąbrowa Górnicza': 5900, - Chorzów: 5650, - Będzin: 5300, - Czeladź: 5000, - Bytom: 4450, - Sopot: 16300, - Piaseczno: 11800, - Pruszków: 11200, - Marki: 10800, - Ząbki: 10600, - Otwock: 9800, - Wieliczka: 10400, - Zakopane: 13500, - Kołobrzeg: 11800, - Świnoujście: 12100, - Puławy: 7400, - Ostrołęka: 7000, -}; - 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 }, @@ -22422,7 +22360,6 @@ function OffersMapModal({ function BuyPage({ filters, onOpenMap, - onOpenNegotiation, onOpenListing, isFavorite, onToggleFavorite, @@ -22431,7 +22368,6 @@ function BuyPage({ }: { filters: FilterState; onOpenMap: (city: string, mode?: 'buy' | 'rent') => void; - onOpenNegotiation: (offer: BuyOffer) => void; onOpenListing: (id: number) => void; isFavorite: (listingId: number) => boolean; onToggleFavorite: (listing: ApiListingSummary) => void; @@ -27421,6 +27357,12 @@ function ListingDetailPage({ Napisz wiadomość + {listing.offerType === 'SALE' && !isOwner && ( + + Ile mogę utargować? + + )} +
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index d324dbd..75d7309 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -13,6 +13,8 @@ export const ROUTES = { priceHistory: '/historia-cen', districtRanking: '/ranking-dzielnic', negotiation: '/negocjacje', + // Analiza konkretnego ogloszenia sprzedazy - numer ogloszenia w adresie. + negotiationListing: '/negocjacje/:listingId', comparison: '/konto/porownanie', add: '/dodaj-ogloszenie', edit: '/edytuj-ogloszenie/:id', @@ -132,7 +134,7 @@ export function publicProfileNickPath(nick: string): string { return `/u/${encodeURIComponent(nick)}`; } -// Negocjacje dotyczą konkretnej oferty - bez identyfikatora pokazujemy pierwszą z listy. +// Analiza negocjacji konkretnego ogłoszenia sprzedaży - bez numeru strona z opisem narzędzia. export function negotiationPath(offerId?: number | null): string { return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 22f24bd..f2ed2c1 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -11984,7 +11984,7 @@ a.city-card:hover { gap: 10px; } -.negotiation-transactions article { +.negotiation-transactions > :is(article, a) { align-items: center; display: grid; gap: 10px; @@ -35135,3 +35135,61 @@ a.listing-detail-back { font-weight: 950; padding-top: 18px; } + +/* Negocjacje na prawdziwych danych: linki do ogloszen i wezwania do dzialania. */ +.negotiation-transactions > a { + border-radius: 10px; + color: inherit; + text-decoration: none; +} + +.negotiation-transactions > a:hover { + background: #f1f8f4; +} + +.negotiation-side a.ghost { + align-items: center; + display: flex; + justify-content: center; + text-decoration: none; +} + +.negotiation-empty { + color: #657284; + font-size: 13px; + margin: 0; +} + +.negotiation-primary-link { + align-items: center; + background: #10a15e; + border-radius: 8px; + color: #ffffff; + display: inline-flex; + font-size: 13px; + font-weight: 900; + gap: 8px; + margin-top: 14px; + min-height: 44px; + padding: 0 16px; + text-decoration: none; +} + +.negotiation-offer-body h2 a { + color: inherit; + text-decoration: none; +} + +a.listing-detail-secondary { + text-decoration: none; +} + +.negotiation-range h3.negotiation-range-text { + font-size: 26px; + line-height: 1.2; +} + +.negotiation-card.tip a { + color: #0b9f5c; + font-weight: 900; +} diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index 51cffe3..364110a 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { ROUTES, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath } from '../src/routes'; +import { ROUTES, negotiationPath, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath } from '../src/routes'; // Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router, // a nawigacja odbywa sie przez /. Te testy pilnuja tej zasady. @@ -438,3 +438,23 @@ test.describe('Wycena', () => { await page.waitForFunction(() => window.location.pathname === '/wycena'); }); }); + +test.describe('Negocjacje', () => { + test('"Ile moge utargowac?" w ogloszeniu sprzedazy otwiera analize pod adresem z numerem', async ({ page }) => { + await goto(page, ROUTES.buy); + await page.locator('.real-result-card').first().waitFor(); + const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href'); + await goto(page, href!); + + await page.getByRole('link', { name: /Ile mogę utargować/ }).click(); + await page.waitForFunction(() => /^\/negocjacje\/\d+$/.test(window.location.pathname)); + await page.reload(); + await expect(page.locator('.negotiation-offer h2')).toBeVisible(); + await expect(page.locator('.negotiation-range')).not.toContainText('Pobieramy', { timeout: 15000 }); + }); + + test('nieistniejace ogloszenie pokazuje komunikat', async ({ page }) => { + await goto(page, negotiationPath(999999)); + await expect(page.getByText('Nie znaleźliśmy tego ogłoszenia')).toBeVisible(); + }); +});