Negocjacje na prawdziwych danych zamiast zmyslonego modelu
Deploy Polska Lokalnie / deploy (push) Successful in 11s

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:56:47 +02:00
parent 556dd6cf6a
commit 017ff4b2c6
4 changed files with 441 additions and 419 deletions
+358 -416
View File
@@ -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'; type PropertySearchType = 'Mieszkanie' | 'Dom' | 'Działka' | 'Lokal użytkowy' | 'Pokój';
const POLISH_CITIES = [ const POLISH_CITIES = [
@@ -960,150 +941,6 @@ function safeRandomId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; 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<string>();
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 // slug trafia do adresu listy firm: /firmy?kategoria=remonty
const serviceCategories = [ const serviceCategories = [
{ {
@@ -3226,12 +3063,6 @@ function App() {
navigate(mapPath(name, source === 'rent' ? 'RENT' : 'SALE')); 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) => { const openListing = (id: number) => {
navigate(listingPath(id)); navigate(listingPath(id));
}; };
@@ -3668,7 +3499,6 @@ function App() {
<BuyPage <BuyPage
filters={filters} filters={filters}
onOpenMap={openMap} onOpenMap={openMap}
onOpenNegotiation={openNegotiation}
onOpenListing={openListing} onOpenListing={openListing}
isFavorite={isListingFavorite} isFavorite={isListingFavorite}
onToggleFavorite={toggleSummaryFavorite} onToggleFavorite={toggleSummaryFavorite}
@@ -3719,7 +3549,8 @@ function App() {
<Route element={<AccountShell />}> <Route element={<AccountShell />}>
<Route path={ROUTES.priceHistory} element={<PriceHistoryPage user={user} />} /> <Route path={ROUTES.priceHistory} element={<PriceHistoryPage user={user} />} />
<Route path={ROUTES.districtRanking} element={<DistrictRankingPage />} /> <Route path={ROUTES.districtRanking} element={<DistrictRankingPage />} />
<Route path={ROUTES.negotiation} element={<NegotiationRoute />} /> <Route path={ROUTES.negotiation} element={<NegotiationPage />} />
<Route path={ROUTES.negotiationListing} element={<NegotiationPage />} />
</Route> </Route>
<Route path={ROUTES.services} element={<ServicesPage />} /> <Route path={ROUTES.services} element={<ServicesPage />} />
@@ -3852,23 +3683,6 @@ function MapSearchRoute() {
return <MapSearchPage key={`${city}-${offerType}`} initialCity={city} offerType={offerType} />; return <MapSearchPage key={`${city}-${offerType}`} initialCity={city} offerType={offerType} />;
} }
// 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<BuyOffer | null>(offerFromState);
useEffect(() => {
if (offerFromState) {
setSelectedOffer(offerFromState);
}
}, [offerFromState]);
// Analiza negocjacji nie ma jeszcze zrodla danych - bez oferty strona pokazuje pusty stan.
return <NegotiationPage offers={[]} selectedOffer={selectedOffer} onSelectOffer={setSelectedOffer} />;
}
// Nieznany adres: czytelny komunikat zamiast pustego ekranu. // Nieznany adres: czytelny komunikat zamiast pustego ekranu.
function NotFoundPage() { function NotFoundPage() {
return ( return (
@@ -6143,125 +5957,303 @@ function DistrictRankingPage() {
); );
} }
function NegotiationPage({ // Punkt odniesienia dla ceny ogloszenia - wylacznie prawdziwe dane (NBP, GUS, ogloszenia serwisu).
offers, type NegotiationReference = {
selectedOffer, label: string;
onSelectOffer, // Ta sama nazwa do zdania "wartosc wg ...".
}: { byLabel: string;
offers: BuyOffer[]; detail: string;
selectedOffer: BuyOffer | null; perMeter: number;
onSelectOffer: (offer: BuyOffer) => void; transactional: boolean;
}) { };
const navigate = useNavigate();
const display = useDisplay();
const { formatPrice, formatPriceNumber, formatPricePerArea, formatPricePerAreaNumber } = display;
const fallbackOffer = offers[0] ?? null;
const activeOffer = selectedOffer ?? fallbackOffer;
if (!activeOffer) { type NegotiationAnalysis = {
return ( offerPerMeter: number;
<section className="negotiation-page" aria-label="Ile możesz utargować"> references: NegotiationReference[];
<div className="negotiation-shell"> // Podstawa przedzialu: cena transakcyjna (NBP, potem GUS), a bez niej srednia ogloszen.
<div className="negotiation-breadcrumb"> basis: NegotiationReference | null;
<Link to={ROUTES.account}>Panel główny</Link> basisValue: number | null;
<Icon name="chevron" /> // O ile procent cena ofertowa przewyzsza wartosc wg podstawy (ujemne = taniej).
<Link className="active" to={ROUTES.negotiation}>Ile możesz utargować?</Link> overpricePct: number | null;
</div> // Od wartosci wg podstawy do polowy nadwyzki ponad nia; null, gdy ogloszenie nie jest drozsze.
range: { from: number; to: number } | null;
};
<div className="negotiation-title"> const NEGOTIATION_MIN_LISTINGS = 3;
<h1>Ile możesz utargować?</h1>
<p>Brak danych do analizy.</p> // Srednia ogloszen bez analizowanego ogloszenia - inaczej porownywaloby sie samo ze soba.
</div> 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<ApiListingDetail | null>(null);
const [listingError, setListingError] = useState(false);
const [cityHistory, setCityHistory] = useState<ApiPriceHistory | null>(null);
const [districtHistory, setDistrictHistory] = useState<ApiPriceHistory | null>(null);
const [marketLoading, setMarketLoading] = useState(false);
const [similar, setSimilar] = useState<ApiListingSummary[]>([]);
useEffect(() => {
setListing(null);
setListingError(false);
if (!listingId) {
return;
}
let cancelled = false;
apiFetch<ApiListingDetail>(`/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<ApiPriceHistory>(`/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<ApiListingSummary[]>(`/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 = (
<div className="negotiation-breadcrumb">
<Link to={ROUTES.account}>Panel główny</Link>
<Icon name="chevron" />
<Link className={listingId ? '' : 'active'} to={ROUTES.negotiation}>Ile możesz utargować?</Link>
{listing && (
<>
<Icon name="chevron" />
<Link className="active" to={negotiationPath(listing.id)}>{listing.title}</Link>
</>
)}
</div>
);
const title = (
<div className="negotiation-title">
<h1>Ile możesz utargować?</h1>
<p>Porównujemy cenę ogłoszenia z cenami transakcyjnymi NBP i GUS oraz z ogłoszeniami w serwisie.</p>
</div>
);
const shell = (content: ReactNode) => (
<section className="negotiation-page" aria-label="Ile możesz utargować">
<div className="negotiation-shell">
{breadcrumb}
{title}
{content}
</div>
</section>
);
if (!listingId) {
return shell(
<div className="negotiation-layout">
<div className="negotiation-main">
<section className="negotiation-card negotiation-note"> <section className="negotiation-card negotiation-note">
<strong>Brak danych</strong> <strong>Wybierz ogłoszenie sprzedaży</strong>
<p>Analiza rekomendowanej ceny transakcyjnej będzie dostępna wkrótce.</p> <p>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.</p>
<Link className="negotiation-primary-link" to={ROUTES.buy}>Przeglądaj oferty sprzedaży <Icon name="arrow" /></Link>
</section> </section>
</div> </div>
</section> <aside className="negotiation-side">
<section className="negotiation-card">
<h3>Jak to działa?</h3>
<ol className="negotiation-steps">
<li>Liczymy cenę ogłoszenia za m²</li>
<li>Porównujemy ją z cenami transakcyjnymi NBP i GUS w mieście</li>
<li>Dokładamy średnią ogłoszeń z dzielnicy i miasta</li>
<li>Wyznaczamy przedział negocjacji z opisanego wzoru</li>
</ol>
</section>
</aside>
</div>,
); );
} }
const model = buildNegotiationModel(activeOffer); if (listingError) {
const transactions = buildComparableTransactions(activeOffer, offers, display); return shell(
const selectedIndex = Math.max(0, offers.findIndex((offer) => offer === activeOffer)); <section className="negotiation-card negotiation-note">
const chanceTone = model.chance.label === 'Niska' ? 'low' : model.chance.label === 'Średnia' ? 'medium' : 'high'; <strong>Nie znaleźliśmy tego ogłoszenia</strong>
const negotiationPercent = ((model.negotiationSpan / activeOffer.priceValue) * 100).toFixed(1).replace('.', ','); <p>Ogłoszenie mogło zostać usunięte albo zakończone.</p>
const rangeMidPrice = Math.round((model.minPrice + model.maxPrice) / 2); <Link className="negotiation-primary-link" to={ROUTES.buy}>Przeglądaj oferty sprzedaży <Icon name="arrow" /></Link>
const rangeMidPerMeter = Math.round(rangeMidPrice / activeOffer.area); </section>,
const updatedAt = new Date().toLocaleDateString('pl-PL'); );
const steps = [ }
'Analizujemy ofertę',
'Badamy rynek',
'Wyznaczamy zakres negocjacji',
'Pomagamy w negocjacjach',
];
return ( if (!listing) {
<section className="negotiation-page" aria-label="Ile możesz utargować"> return shell(<section className="negotiation-card negotiation-note"><p>Wczytujemy ogłoszenie…</p></section>);
<div className="negotiation-shell"> }
<div className="negotiation-breadcrumb">
<Link to={ROUTES.account}>Panel główny</Link>
<Icon name="chevron" />
<Link className="active" to={ROUTES.negotiation}>Ile możesz utargować?</Link>
</div>
<div className="negotiation-title"> if (listing.offerType !== 'SALE') {
<h1>Ile możesz utargować?</h1> return shell(
<p>AI podpowie realistyczną cenę transakcyjną na podstawie danych rynkowych.</p> <section className="negotiation-card negotiation-note">
</div> <strong>Analiza dotyczy tylko ogłoszeń sprzedaży</strong>
<p>Dla najmu nie mamy cen transakcyjnych, z którymi można by porównać czynsz.</p>
<Link className="negotiation-primary-link" to={listingPath(listing.id)}>Wróć do ogłoszenia <Icon name="arrow" /></Link>
</section>,
);
}
<section className="negotiation-card negotiation-picker" aria-label="Wybór ogłoszenia"> const analysis = buildNegotiationAnalysis(listing, cityHistory, districtHistory);
<label htmlFor="negotiation-offer-select">Wybierz ogłoszenie do analizy:</label> const place = [listing.city, listing.district].filter(Boolean).join(', ');
<select const isOwner = Boolean(user && listing.ownerId != null && listing.ownerId === user.id);
id="negotiation-offer-select" const photo = listing.photos[0] ?? heroImage;
value={String(selectedIndex)}
onChange={(event) => { return shell(
const next = offers[Number(event.target.value)]; <div className="negotiation-layout">
if (next) { <div className="negotiation-main">
onSelectOffer(next); <section className="negotiation-card negotiation-offer">
} <div className="negotiation-offer-photo" style={{ backgroundImage: `url(${photo})` }} />
}} <div className="negotiation-offer-body">
> <h2><Link to={listingPath(listing.id)}>{listing.title}</Link></h2>
{offers.map((offer, index) => ( <p>{place}</p>
<option value={index} key={`${offer.city}-${offer.district}-${offer.title}-${offer.priceValue}-${index}`}> <small>{roomsLabel(listing.rooms)} • {formatArea(listing.area)}{listing.market ? ` • rynek ${listing.market.toLowerCase()}` : ''}{listing.yearBuilt ? ` • ${listing.yearBuilt} r.` : ''}</small>
{offer.title} | {offer.district} | {formatPrice(offer.priceValue)} </div>
</option> <div className="negotiation-price">
))} <span>Cena ofertowa</span>
</select> <strong>{formatPrice(listing.price)}</strong>
<em>{formatPricePerArea(Math.round(analysis.offerPerMeter))}</em>
</div>
</section> </section>
<div className="negotiation-layout"> <section className="negotiation-card negotiation-range">
<div className="negotiation-main"> {marketLoading ? (
<section className="negotiation-card negotiation-offer"> <p>Pobieramy dane rynkowe dla lokalizacji {place}…</p>
<div className="negotiation-offer-photo" style={{ backgroundImage: `url(${activeOffer.image === 'city' ? cityImage : heroImage})`, backgroundPosition: activeOffer.position }} /> ) : !analysis.basis || analysis.basisValue == null || analysis.overpricePct == null ? (
<div className="negotiation-offer-body"> <div className="negotiation-range-head">
<h2>{activeOffer.title}</h2> <div>
<p>{activeOffer.district}</p> <p>Brak danych rynkowych</p>
<small>{offerMeta(activeOffer, display)}</small> <h3 className="negotiation-range-text">Nie mamy z czym porównać tej ceny</h3>
<small>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}).</small>
</div> </div>
<div className="negotiation-price"> </div>
<span>Cena ofertowa</span> ) : analysis.range ? (
<strong>{formatPrice(activeOffer.priceValue)}</strong> <>
<em>{formatPricePerArea(model.offerPerMeter)}</em>
</div>
</section>
<section className="negotiation-card negotiation-range">
<div className="negotiation-range-head"> <div className="negotiation-range-head">
<div> <div>
<p>Rekomendowana cena transakcyjna</p> <p>Proponowany przedział negocjacji</p>
<h3>{formatPrice(model.minPrice)} - {formatPrice(model.maxPrice)}</h3> <h3>{formatPrice(analysis.range.from)} - {formatPrice(analysis.range.to)}</h3>
<small>{formatPricePerAreaNumber(model.minPerMeter)} - {formatPricePerArea(model.maxPerMeter)}</small> <small>{formatPricePerAreaNumber(Math.round(analysis.range.from / listing.area))} - {formatPricePerArea(Math.round(analysis.range.to / listing.area))}</small>
<em className="negotiation-range-badge">Realistyczna cena, którą możesz zaproponować sprzedającemu.</em> <em className="negotiation-range-badge">Ogłoszenie jest o {analysis.overpricePct.toFixed(1).replace('.', ',')}% droższe niż wartość wg {analysis.basis.byLabel}.</em>
</div> </div>
<aside className={`negotiation-chance chance-${chanceTone}`}>
<span>Szansa na zakup</span>
<strong>{model.chance.label}</strong>
<small>{model.chance.text}</small>
<div className="negotiation-gauge" aria-hidden="true">
<i />
</div>
</aside>
</div> </div>
<div className="negotiation-line" aria-hidden="true"> <div className="negotiation-line" aria-hidden="true">
<span className="dot left" /> <span className="dot left" />
@@ -6270,92 +6262,87 @@ function NegotiationPage({
</div> </div>
<div className="negotiation-line-meta"> <div className="negotiation-line-meta">
<div> <div>
<strong>{formatPrice(model.minPrice)}</strong> <strong>{formatPrice(analysis.range.from)}</strong>
<small>Cena minimalna</small> <small>Wartość wg ceny rynkowej</small>
</div> </div>
<div> <div>
<strong>{formatPrice(model.negotiationSpan)}</strong> <strong>{formatPrice(listing.price - analysis.range.to)} - {formatPrice(listing.price - analysis.range.from)}</strong>
<small>Zakres negocjacji ({negotiationPercent}%)</small> <small>Możliwa różnica względem ceny ofertowej</small>
</div> </div>
<div> <div>
<strong>{formatPrice(activeOffer.priceValue)}</strong> <strong>{formatPrice(listing.price)}</strong>
<small>Cena ofertowa</small> <small>Cena ofertowa</small>
</div> </div>
</div> </div>
</section> </>
) : (
<section className="negotiation-card negotiation-why"> <div className="negotiation-range-head">
<h3>Dlaczego właśnie taki zakres?</h3>
<div className="negotiation-why-grid">
<article><span><Icon name="chart" /></span><div><strong>Analiza rynku</strong><small>Średnia cena transakcyjna podobnych ofert: {formatPricePerArea(model.marketAvgPerMeter)} ({model.marketDeltaPct.toString().replace('.', ',')}% vs cena ofertowa).</small></div></article>
<article><span><Icon name="spark" /></span><div><strong>Stan mieszkania</strong><small>Model wskazuje potencjał korekty o ok. {model.conditionDiscountPct.toString().replace('.', ',')}% zależnie od wieku budynku i standardu rynku.</small></div></article>
<article><span><Icon name="target" /></span><div><strong>Czas na rynku</strong><small>Szacowany czas sprzedaży podobnych lokali: około {model.marketDays} dni. Dłuższa ekspozycja zwiększa przestrzeń negocjacji.</small></div></article>
<article><span><Icon name="trend" /></span><div><strong>Sezonowość</strong><small>Aktualna dynamika popytu daje dodatkowo około {model.seasonNegotiationBonusPct.toString().replace('.', ',')}% pola do rozmowy o cenie.</small></div></article>
</div>
</section>
<section className="negotiation-card negotiation-note">
<strong>Pamiętaj</strong>
<p>Najlepszą skuteczność daje oferta startowa blisko {formatPrice(model.minPrice)} i kontrpropozycja końcowa w okolicy {formatPrice(rangeMidPrice)} ({formatPricePerArea(rangeMidPerMeter)}).</p>
</section>
<section className="negotiation-card negotiation-cta">
<div> <div>
<strong>Potrzebujesz pomocy w negocjacjach?</strong> <p>Cena na poziomie rynku lub niżej</p>
<p>Nasi eksperci pomogą Ci przejść przez proces zakupu i wynegocjować najlepszą cenę.</p> <h3 className="negotiation-range-text">Pole do negocjacji jest małe</h3>
<small>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.</small>
</div> </div>
<div className="negotiation-cta-action"> </div>
<Link to={ROUTES.services}>Skontaktuj się z ekspertem</Link> )}
<small>Pierwsza konsultacja gratis</small> </section>
</div>
</section>
<section className="negotiation-card negotiation-footer"> {analysis.references.length > 0 && (
<span><Icon name="shield" /></span> <section className="negotiation-card negotiation-why">
<p>Dane aktualizowane codziennie na podstawie tysięcy transakcji z całej Polski.</p> <h3>Cena ogłoszenia na tle rynku</h3>
<small>Ostatnia aktualizacja: {updatedAt}</small> <div className="negotiation-why-grid">
</section> {analysis.references.map((reference) => (
</div> <article key={reference.label}>
<span><Icon name={reference.transactional ? 'chart' : 'house'} /></span>
<div>
<strong>{reference.label}: {formatPricePerArea(reference.perMeter)}</strong>
<small>{reference.detail}. Ogłoszenie: {formatSignedPercent(((analysis.offerPerMeter - reference.perMeter) / reference.perMeter) * 100)}.</small>
</div>
</article>
))}
</div>
</section>
)}
<aside className="negotiation-side"> {analysis.basis && (
<section className="negotiation-card"> <section className="negotiation-card negotiation-note">
<h3>Jak to działa?</h3> <strong>Jak liczymy przedział?</strong>
<ol className="negotiation-steps"> <p>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.'}</p>
{steps.map((step) => ( </section>
<li key={step}>{step}</li> )}
))}
</ol>
</section>
<section className="negotiation-card">
<h3>Podobne transakcje w okolicy</h3>
<div className="negotiation-transactions">
{transactions.map((tx) => (
<article key={tx.street}>
<div className="photo" style={{ backgroundImage: `url(${tx.image === 'city' ? cityImage : heroImage})` }} />
<div>
<strong>{tx.street}</strong>
<small>{tx.meta}</small>
<em>{tx.date}</em>
</div>
<div className="price">
<strong>{tx.price}</strong>
<small>{tx.sqm}</small>
</div>
</article>
))}
</div>
<button type="button" className="ghost">Zobacz więcej transakcji</button>
</section>
<section className="negotiation-card tip">
<strong>Wskazówka negocjacyjna</strong>
<p>{model.chance.label === 'Wysoka' ? `Zacznij od dolnej granicy i zwiększaj ofertę małymi krokami (${formatPriceNumber(5000)}-${formatPrice(10000)}).` : model.chance.label === 'Średnia' ? 'Zacznij od środka zakresu i przygotuj argumenty: porównywalne transakcje, czas na rynku, koszty remontu.' : 'Rozpocznij bliżej górnej granicy i negocjuj warunki dodatkowe, np. termin wydania lub wyposażenie.'}</p>
</section>
</aside>
</div>
</div> </div>
</section>
<aside className="negotiation-side">
<section className="negotiation-card">
<h3>Podobne ogłoszenia: {listing.city}</h3>
{similar.length > 0 ? (
<div className="negotiation-transactions">
{similar.map((other) => (
<Link key={other.id} to={listingPath(other.id)}>
<div className="photo" style={{ backgroundImage: `url(${other.coverPhoto ?? heroImage})` }} />
<div>
<strong>{other.district || other.city}</strong>
<small>{formatArea(other.area)} · {roomsLabel(other.rooms)}</small>
</div>
<div className="price">
<strong>{formatPrice(other.price)}</strong>
<small>{formatPricePerArea(Math.round(other.price / other.area))}</small>
</div>
</Link>
))}
</div>
) : (
<p className="negotiation-empty">{marketLoading ? 'Szukamy podobnych ogłoszeń…' : 'Nie ma jeszcze innych ogłoszeń tego typu w tym mieście.'}</p>
)}
<Link className="ghost" to={buyCityPath(listing.city)}>Wszystkie oferty: {listing.city}</Link>
</section>
<section className="negotiation-card tip">
<strong>Co dalej?</strong>
{!isOwner && <p><Link to={newConversationPath(listing.id)}>Napisz do sprzedającego</Link> - zaproponuj cenę i powołaj się na dane z tej strony.</p>}
<p><Link to={priceHistoryPath(place, listing.propertyType === 'HOUSE' ? 'Dom' : 'Mieszkanie')}>Historia cen: {place}</Link></p>
</section>
</aside>
</div>,
); );
} }
@@ -6596,7 +6583,7 @@ function AccountDashboardPage({
const tools = [ const tools = [
{ icon: 'money', title: 'Zdolność kredytowa', text: 'Sprawdź, na jaki kredyt Cię stać i jaka będzie rata', path: ROUTES.creditCalculator }, { 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: '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: '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: '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 }, { 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; return null;
} }
const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = {
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 = [ 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 },
@@ -22422,7 +22360,6 @@ function OffersMapModal({
function BuyPage({ function BuyPage({
filters, filters,
onOpenMap, onOpenMap,
onOpenNegotiation,
onOpenListing, onOpenListing,
isFavorite, isFavorite,
onToggleFavorite, onToggleFavorite,
@@ -22431,7 +22368,6 @@ function BuyPage({
}: { }: {
filters: FilterState; filters: FilterState;
onOpenMap: (city: string, mode?: 'buy' | 'rent') => void; onOpenMap: (city: string, mode?: 'buy' | 'rent') => void;
onOpenNegotiation: (offer: BuyOffer) => void;
onOpenListing: (id: number) => void; onOpenListing: (id: number) => void;
isFavorite: (listingId: number) => boolean; isFavorite: (listingId: number) => boolean;
onToggleFavorite: (listing: ApiListingSummary) => void; onToggleFavorite: (listing: ApiListingSummary) => void;
@@ -27421,6 +27357,12 @@ function ListingDetailPage({
<Icon name="message" /> Napisz wiadomość <Icon name="message" /> Napisz wiadomość
</button> </button>
{listing.offerType === 'SALE' && !isOwner && (
<Link className="listing-detail-secondary" to={negotiationPath(listing.id)}>
<Icon name="coin" /> Ile mogę utargować?
</Link>
)}
<div className="listing-detail-seller-card"> <div className="listing-detail-seller-card">
<Avatar className="listing-detail-seller-avatar" name={sellerName} image={listing.ownerAvatar} singleLetter /> <Avatar className="listing-detail-seller-avatar" name={sellerName} image={listing.ownerAvatar} singleLetter />
<div> <div>
+3 -1
View File
@@ -13,6 +13,8 @@ export const ROUTES = {
priceHistory: '/historia-cen', priceHistory: '/historia-cen',
districtRanking: '/ranking-dzielnic', districtRanking: '/ranking-dzielnic',
negotiation: '/negocjacje', negotiation: '/negocjacje',
// Analiza konkretnego ogloszenia sprzedazy - numer ogloszenia w adresie.
negotiationListing: '/negocjacje/:listingId',
comparison: '/konto/porownanie', comparison: '/konto/porownanie',
add: '/dodaj-ogloszenie', add: '/dodaj-ogloszenie',
edit: '/edytuj-ogloszenie/:id', edit: '/edytuj-ogloszenie/:id',
@@ -132,7 +134,7 @@ export function publicProfileNickPath(nick: string): string {
return `/u/${encodeURIComponent(nick)}`; 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 { export function negotiationPath(offerId?: number | null): string {
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation; return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
} }
+59 -1
View File
@@ -11984,7 +11984,7 @@ a.city-card:hover {
gap: 10px; gap: 10px;
} }
.negotiation-transactions article { .negotiation-transactions > :is(article, a) {
align-items: center; align-items: center;
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -35135,3 +35135,61 @@ a.listing-detail-back {
font-weight: 950; font-weight: 950;
padding-top: 18px; 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;
}
+21 -1
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test'; 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, // Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router,
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady. // a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
@@ -438,3 +438,23 @@ test.describe('Wycena', () => {
await page.waitForFunction(() => window.location.pathname === '/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();
});
});