Negocjacje na prawdziwych danych zamiast zmyslonego modelu
Deploy Polska Lokalnie / deploy (push) Successful in 11s
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:
+358
-416
@@ -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<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
|
||||
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() {
|
||||
<BuyPage
|
||||
filters={filters}
|
||||
onOpenMap={openMap}
|
||||
onOpenNegotiation={openNegotiation}
|
||||
onOpenListing={openListing}
|
||||
isFavorite={isListingFavorite}
|
||||
onToggleFavorite={toggleSummaryFavorite}
|
||||
@@ -3719,7 +3549,8 @@ function App() {
|
||||
<Route element={<AccountShell />}>
|
||||
<Route path={ROUTES.priceHistory} element={<PriceHistoryPage user={user} />} />
|
||||
<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 path={ROUTES.services} element={<ServicesPage />} />
|
||||
@@ -3852,23 +3683,6 @@ function MapSearchRoute() {
|
||||
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.
|
||||
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 (
|
||||
<section className="negotiation-page" aria-label="Ile możesz utargować">
|
||||
<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>
|
||||
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;
|
||||
};
|
||||
|
||||
<div className="negotiation-title">
|
||||
<h1>Ile możesz utargować?</h1>
|
||||
<p>Brak danych do analizy.</p>
|
||||
</div>
|
||||
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<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">
|
||||
<strong>Brak danych</strong>
|
||||
<p>Analiza rekomendowanej ceny transakcyjnej będzie dostępna wkrótce.</p>
|
||||
<strong>Wybierz ogłoszenie sprzedaży</strong>
|
||||
<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>
|
||||
</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);
|
||||
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(
|
||||
<section className="negotiation-card negotiation-note">
|
||||
<strong>Nie znaleźliśmy tego ogłoszenia</strong>
|
||||
<p>Ogłoszenie mogło zostać usunięte albo zakończone.</p>
|
||||
<Link className="negotiation-primary-link" to={ROUTES.buy}>Przeglądaj oferty sprzedaży <Icon name="arrow" /></Link>
|
||||
</section>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="negotiation-page" aria-label="Ile możesz utargować">
|
||||
<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>
|
||||
if (!listing) {
|
||||
return shell(<section className="negotiation-card negotiation-note"><p>Wczytujemy ogłoszenie…</p></section>);
|
||||
}
|
||||
|
||||
<div className="negotiation-title">
|
||||
<h1>Ile możesz utargować?</h1>
|
||||
<p>AI podpowie realistyczną cenę transakcyjną na podstawie danych rynkowych.</p>
|
||||
</div>
|
||||
if (listing.offerType !== 'SALE') {
|
||||
return shell(
|
||||
<section className="negotiation-card negotiation-note">
|
||||
<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">
|
||||
<label htmlFor="negotiation-offer-select">Wybierz ogłoszenie do analizy:</label>
|
||||
<select
|
||||
id="negotiation-offer-select"
|
||||
value={String(selectedIndex)}
|
||||
onChange={(event) => {
|
||||
const next = offers[Number(event.target.value)];
|
||||
if (next) {
|
||||
onSelectOffer(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{offers.map((offer, index) => (
|
||||
<option value={index} key={`${offer.city}-${offer.district}-${offer.title}-${offer.priceValue}-${index}`}>
|
||||
{offer.title} | {offer.district} | {formatPrice(offer.priceValue)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
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(
|
||||
<div className="negotiation-layout">
|
||||
<div className="negotiation-main">
|
||||
<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>
|
||||
<p>{place}</p>
|
||||
<small>{roomsLabel(listing.rooms)} • {formatArea(listing.area)}{listing.market ? ` • rynek ${listing.market.toLowerCase()}` : ''}{listing.yearBuilt ? ` • ${listing.yearBuilt} r.` : ''}</small>
|
||||
</div>
|
||||
<div className="negotiation-price">
|
||||
<span>Cena ofertowa</span>
|
||||
<strong>{formatPrice(listing.price)}</strong>
|
||||
<em>{formatPricePerArea(Math.round(analysis.offerPerMeter))}</em>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="negotiation-layout">
|
||||
<div className="negotiation-main">
|
||||
<section className="negotiation-card negotiation-offer">
|
||||
<div className="negotiation-offer-photo" style={{ backgroundImage: `url(${activeOffer.image === 'city' ? cityImage : heroImage})`, backgroundPosition: activeOffer.position }} />
|
||||
<div className="negotiation-offer-body">
|
||||
<h2>{activeOffer.title}</h2>
|
||||
<p>{activeOffer.district}</p>
|
||||
<small>{offerMeta(activeOffer, display)}</small>
|
||||
<section className="negotiation-card negotiation-range">
|
||||
{marketLoading ? (
|
||||
<p>Pobieramy dane rynkowe dla lokalizacji {place}…</p>
|
||||
) : !analysis.basis || analysis.basisValue == null || analysis.overpricePct == null ? (
|
||||
<div className="negotiation-range-head">
|
||||
<div>
|
||||
<p>Brak danych rynkowych</p>
|
||||
<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 className="negotiation-price">
|
||||
<span>Cena ofertowa</span>
|
||||
<strong>{formatPrice(activeOffer.priceValue)}</strong>
|
||||
<em>{formatPricePerArea(model.offerPerMeter)}</em>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="negotiation-card negotiation-range">
|
||||
</div>
|
||||
) : analysis.range ? (
|
||||
<>
|
||||
<div className="negotiation-range-head">
|
||||
<div>
|
||||
<p>Rekomendowana cena transakcyjna</p>
|
||||
<h3>{formatPrice(model.minPrice)} - {formatPrice(model.maxPrice)}</h3>
|
||||
<small>{formatPricePerAreaNumber(model.minPerMeter)} - {formatPricePerArea(model.maxPerMeter)}</small>
|
||||
<em className="negotiation-range-badge">Realistyczna cena, którą możesz zaproponować sprzedającemu.</em>
|
||||
<p>Proponowany przedział negocjacji</p>
|
||||
<h3>{formatPrice(analysis.range.from)} - {formatPrice(analysis.range.to)}</h3>
|
||||
<small>{formatPricePerAreaNumber(Math.round(analysis.range.from / listing.area))} - {formatPricePerArea(Math.round(analysis.range.to / listing.area))}</small>
|
||||
<em className="negotiation-range-badge">Ogłoszenie jest o {analysis.overpricePct.toFixed(1).replace('.', ',')}% droższe niż wartość wg {analysis.basis.byLabel}.</em>
|
||||
</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 className="negotiation-line" aria-hidden="true">
|
||||
<span className="dot left" />
|
||||
@@ -6270,92 +6262,87 @@ function NegotiationPage({
|
||||
</div>
|
||||
<div className="negotiation-line-meta">
|
||||
<div>
|
||||
<strong>{formatPrice(model.minPrice)}</strong>
|
||||
<small>Cena minimalna</small>
|
||||
<strong>{formatPrice(analysis.range.from)}</strong>
|
||||
<small>Wartość wg ceny rynkowej</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatPrice(model.negotiationSpan)}</strong>
|
||||
<small>Zakres negocjacji ({negotiationPercent}%)</small>
|
||||
<strong>{formatPrice(listing.price - analysis.range.to)} - {formatPrice(listing.price - analysis.range.from)}</strong>
|
||||
<small>Możliwa różnica względem ceny ofertowej</small>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatPrice(activeOffer.priceValue)}</strong>
|
||||
<strong>{formatPrice(listing.price)}</strong>
|
||||
<small>Cena ofertowa</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="negotiation-card negotiation-why">
|
||||
<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 className="negotiation-range-head">
|
||||
<div>
|
||||
<strong>Potrzebujesz pomocy w negocjacjach?</strong>
|
||||
<p>Nasi eksperci pomogą Ci przejść przez proces zakupu i wynegocjować najlepszą cenę.</p>
|
||||
<p>Cena na poziomie rynku lub niżej</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 className="negotiation-cta-action">
|
||||
<Link to={ROUTES.services}>Skontaktuj się z ekspertem</Link>
|
||||
<small>Pierwsza konsultacja gratis</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="negotiation-card negotiation-footer">
|
||||
<span><Icon name="shield" /></span>
|
||||
<p>Dane aktualizowane codziennie na podstawie tysięcy transakcji z całej Polski.</p>
|
||||
<small>Ostatnia aktualizacja: {updatedAt}</small>
|
||||
</section>
|
||||
</div>
|
||||
{analysis.references.length > 0 && (
|
||||
<section className="negotiation-card negotiation-why">
|
||||
<h3>Cena ogłoszenia na tle rynku</h3>
|
||||
<div className="negotiation-why-grid">
|
||||
{analysis.references.map((reference) => (
|
||||
<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">
|
||||
<section className="negotiation-card">
|
||||
<h3>Jak to działa?</h3>
|
||||
<ol className="negotiation-steps">
|
||||
{steps.map((step) => (
|
||||
<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>
|
||||
{analysis.basis && (
|
||||
<section className="negotiation-card negotiation-note">
|
||||
<strong>Jak liczymy przedział?</strong>
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
</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 = [
|
||||
{ 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<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 = [
|
||||
{ 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({
|
||||
<Icon name="message" /> Napisz wiadomość
|
||||
</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">
|
||||
<Avatar className="listing-detail-seller-avatar" name={sellerName} image={listing.ownerAvatar} singleLetter />
|
||||
<div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+59
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <Link>/<NavLink>. 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user