From bad91dc205fd94f43b7f1903da4855426170cab2 Mon Sep 17 00:00:00 2001 From: Przemek Zatorski Date: Tue, 22 Sep 2026 14:35:34 +0200 Subject: [PATCH] Nadaj adresy krokom wyceny i popraw pusty wynik Kroki kreatora maja wlasne trasy (/wycena/szczegoly, /wycena/stan, /wycena/wynik), a szkic formularza przetrwa F5. Pusty wynik wskazuje brakujace dane linkami do kroku, "Co dalej?" prowadzi do prawdziwych stron. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 105 +++++++++++++++++++++++---------- frontend/src/routes.ts | 7 +++ frontend/src/styles.css | 54 ++++++++++++----- frontend/tests/routing.spec.ts | 20 +++++++ 4 files changed, 142 insertions(+), 44 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61b3a69..d82ca38 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Fragment, type CSSProperties, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, conversationPath, newConversationPath, ADMIN_CONVERSATION_SLUG, publicProfileNickPath, listingEditPath, mapPath, buyCityPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, conversationPath, newConversationPath, ADMIN_CONVERSATION_SLUG, publicProfileNickPath, listingEditPath, mapPath, buyCityPath, negotiationPath, priceHistoryPath, VALUATION_STEP_PATHS, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { guideContents, type GuideContent, type GuideBlock } from './guides-data'; import { ProtectedRoute } from './ProtectedRoute'; import { Avatar, initialsFrom } from './Avatar'; @@ -3709,7 +3709,9 @@ function App() { {/* /wycena to pozycja gornego menu - dziala niezaleznie jak /kupno czy /wynajem, bez lewego menu konta. */} - } /> + {VALUATION_STEP_PATHS.map((path) => ( + } /> + ))} {/* Narzedzia dostepne publicznie (jak /kupno), ale otwierane takze z lewego menu konta. AccountShell pokazuje menu tylko zalogowanemu, a goscia @@ -17170,6 +17172,27 @@ const INITIAL_VALUATION_FORM: ValuationForm = { amenities: [], }; +// Szkic formularza wyceny w sessionStorage - kroki maja wlasne adresy, wiec dane +// musza przetrwac F5 i Wstecz/Dalej. Znika po zamknieciu karty. +const VALUATION_DRAFT_KEY = 'polskalokalnie.valuationDraft'; + +function readValuationDraft(): ValuationForm { + try { + const raw = window.sessionStorage.getItem(VALUATION_DRAFT_KEY); + return raw ? { ...INITIAL_VALUATION_FORM, ...(JSON.parse(raw) as Partial) } : INITIAL_VALUATION_FORM; + } catch { + return INITIAL_VALUATION_FORM; + } +} + +function saveValuationDraft(form: ValuationForm) { + try { + window.sessionStorage.setItem(VALUATION_DRAFT_KEY, JSON.stringify(form)); + } catch { + /* brak dostepu do sessionStorage - szkic nie przetrwa odswiezenia */ + } +} + const VALUATION_STEPS = ['Lokalizacja', 'Szczegóły nieruchomości', 'Stan i udogodnienia', 'Wynik wyceny']; const VALUATION_APARTMENT_BUILDING_TYPES = ['Blok', 'Kamienica', 'Apartamentowiec', 'Dom wielorodzinny']; @@ -18533,8 +18556,16 @@ function CreditCalculatorPage({ onSearchWithBudget }: { onSearchWithBudget: (max function ValuationPage() { const navigate = useNavigate(); const { formatPrice, formatPricePerArea, formatPricePerAreaNumber, formatAreaNumber, formatArea, toSquareMeters, areaSuffix } = useDisplay(); - const [step, setStep] = useState(0); - const [valuationForm, setValuationForm] = useState(INITIAL_VALUATION_FORM); + const { pathname } = useLocation(); + // Krok wynika z adresu (/wycena, /wycena/szczegoly, /wycena/stan, /wycena/wynik). + const step = Math.max(VALUATION_STEP_PATHS.indexOf(pathname), 0); + const [valuationForm, setValuationForm] = useState(readValuationDraft); + useEffect(() => { + saveValuationDraft(valuationForm); + }, [valuationForm]); + useEffect(() => { + window.scrollTo(0, 0); + }, [step]); const [valuationLocationSuggestOpen, setValuationLocationSuggestOpen] = useState(false); const [valuationLocationSuggestions, setValuationLocationSuggestions] = useState([]); const [valuationLocationSuggestLoading, setValuationLocationSuggestLoading] = useState(false); @@ -18633,28 +18664,29 @@ function ValuationPage() { { icon: 'shield', title: 'Bez zobowiązań', text: 'Wycena jest darmowa i niezobowiązująca' }, ]; const valuationNextActions = [ - { icon: 'house', label: 'Dodaj ogłoszenie i znajdź kupującego' }, - { icon: 'check', label: 'Uzupełnij dane i zwiększ dokładność' }, - { icon: 'chart', label: 'Obserwuj zmiany cen w Twojej okolicy' }, - { icon: 'target', label: 'Powtórz wycenę' }, + { icon: 'house', label: 'Dodaj ogłoszenie i znajdź kupującego', to: ROUTES.add }, + { icon: 'check', label: 'Uzupełnij dane i zwiększ dokładność', to: ROUTES.valuationDetails }, + { icon: 'chart', label: 'Obserwuj zmiany cen w Twojej okolicy', to: priceHistoryPath(valuationForm.location.trim() || undefined) }, ]; - const valuationMissingItems = [ + // Kazda brakujaca pozycja prowadzi do kroku, w ktorym mozna ja uzupelnic. + const valuationMissingItems = ([ valuationPlace.city.length < 2 - ? { icon: 'pin', title: 'Podaj lokalizację', text: 'Wpisz miasto, najlepiej z dzielnicą, np. „Kraków, Podgórze”.' } + ? { icon: 'pin', title: 'Podaj lokalizację (wymagane)', text: 'Wpisz miasto, najlepiej z dzielnicą, np. „Kraków, Podgórze”.', to: ROUTES.valuation } : null, !parsePositiveNumber(valuationForm.area) - ? { icon: 'house', title: `Podaj metraż ${valuationPropertyLabel}`, text: 'Wycena startuje dopiero po wpisaniu powierzchni w m².' } + ? { icon: 'house', title: `Podaj metraż ${valuationPropertyLabel} (wymagane)`, text: 'Wycena startuje dopiero po wpisaniu powierzchni w m².', to: ROUTES.valuationDetails } : null, !valuationForm.rooms - ? { icon: 'building', title: isCommercialValuation ? 'Dodaj liczbę pomieszczeń' : 'Dodaj liczbę pokoi', text: 'To poprawi ocenę funkcjonalności układu nieruchomości.' } + ? { icon: 'building', title: isCommercialValuation ? 'Dodaj liczbę pomieszczeń' : 'Dodaj liczbę pokoi', text: 'To poprawi ocenę funkcjonalności układu nieruchomości.', to: ROUTES.valuationDetails } : null, !valuationForm.yearBuilt && !valuationForm.buildingType - ? { icon: 'calendar', title: 'Uzupełnij budownictwo', text: 'Rok budowy, typ budynku i technologia mocno wpływają na wynik.' } + ? { icon: 'calendar', title: 'Uzupełnij budownictwo', text: 'Rok budowy, typ budynku i technologia mocno wpływają na wynik.', to: ROUTES.valuationDetails } : null, !valuationForm.condition - ? { icon: 'hammer', title: 'Wskaż stan techniczny', text: 'Standard wykończenia pomaga zawęzić przedział ceny.' } + ? { icon: 'hammer', title: 'Wskaż stan techniczny', text: 'Standard wykończenia pomaga zawęzić przedział ceny.', to: ROUTES.valuationCondition } : null, - ].filter((item): item is { icon: string; title: string; text: string } => item != null); + ] as ({ icon: string; title: string; text: string; to: string } | null)[]).filter((item): item is { icon: string; title: string; text: string; to: string } => item != null); + const firstMissingStep = valuationMissingItems[0]?.to ?? ROUTES.valuation; const valuationLocationQuery = valuationForm.location.trim(); const localValuationLocationSuggestions: PlaceSuggestion[] = (valuationLocationQuery.length >= 2 ? POLISH_CITIES @@ -18724,10 +18756,10 @@ function ValuationPage() { }; const resetValuation = () => { setValuationForm(INITIAL_VALUATION_FORM); - setStep(0); + navigate(ROUTES.valuation); }; - const goNext = () => setStep((current) => Math.min(current + 1, VALUATION_STEPS.length - 1)); - const goBack = () => setStep((current) => Math.max(current - 1, 0)); + const goNext = () => navigate(VALUATION_STEP_PATHS[Math.min(step + 1, VALUATION_STEP_PATHS.length - 1)]); + const goBack = () => navigate(VALUATION_STEP_PATHS[Math.max(step - 1, 0)]); return (
@@ -18740,15 +18772,17 @@ function ValuationPage() {

{isValuationResultStep ? 'Wynik wyceny' : 'Wycena nieruchomości'} {isValuationResultStep && }

-

{isValuationResultStep ? 'Dziękujemy! Oto szacowana wartość Twojej nieruchomości.' : 'Sprawdź, ile może być warta Twoja nieruchomość.'}

+

{isValuationResultStep + ? valuation.ready ? 'Dziękujemy! Oto szacowana wartość Twojej nieruchomości.' : 'Wynik pojawi się tutaj, gdy uzupełnisz wymagane dane.' + : 'Sprawdź, ile może być warta Twoja nieruchomość.'}

{VALUATION_STEPS.map((label, index) => ( - + ))}
@@ -18758,9 +18792,12 @@ function ValuationPage() {
Szacowana wartość rynkowa Twojej nieruchomości - {valuation.ready ? `${formatPrice(valuation.minValue)} - ${formatPrice(valuation.maxValue)}` : 'Uzupełnij lokalizację i metraż'} - {valuation.ready ? `Przedział cenowy: ${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : 'Wynik pojawi się po podaniu miasta oraz powierzchni.'} -

{valuation.ready ? 'Wycena została przygotowana na podstawie informacji, które podałeś, oraz aktualnych danych rynkowych z Twojej okolicy. Pamiętaj, że ostateczna cena może się różnić w zależności od sytuacji rynkowej i indywidualnych cech nieruchomości.' : 'Po lewej stronie widzisz, czego jeszcze brakuje. Najpierw podaj miasto lub dzielnicę oraz metraż, a dodatkowe informacje zawężą przedział wyceny.'}

+ {valuation.ready ? `${formatPrice(valuation.minValue)} - ${formatPrice(valuation.maxValue)}` : valuationMarketLoading ? 'Liczymy wycenę…' : valuation.unavailableReason ? 'Nie możemy jeszcze policzyć wyceny' : 'Brakuje danych do wyceny'} + {valuation.ready ? `Przedział cenowy: ${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : valuation.unavailableReason ?? 'Do wyceny potrzebujemy co najmniej miasta i powierzchni.'} +

{valuation.ready ? 'Wycena została przygotowana na podstawie informacji, które podałeś, oraz aktualnych danych rynkowych z Twojej okolicy. Pamiętaj, że ostateczna cena może się różnić w zależności od sytuacji rynkowej i indywidualnych cech nieruchomości.' : 'Poniżej znajdziesz listę brakujących informacji. Kliknij pozycję, aby przejść do kroku, w którym ją uzupełnisz.'}

+ {!valuation.ready && !valuation.unavailableReason && ( + Uzupełnij dane + )}
- + Wróć Rozpocznij sprzedaż
@@ -18816,18 +18853,26 @@ function ValuationPage() {

Co dalej?

{valuationNextActions.map((action) => ( - + ))} +

Informacje o wycenie

Wycena ma charakter orientacyjny i nie stanowi oferty handlowej.

- +
+ Jak liczymy wycenę? +

Stawkę za m² bierzemy z cen transakcyjnych NBP dla miasta, a gdy ich brak - z danych GUS (nie starszych niż 3 lata) albo ze średniej ogłoszeń w serwisie. Dzielnicę uwzględniamy, gdy mamy w niej co najmniej 3 ogłoszenia. Stan, piętro, rok budowy i udogodnienia korygują stawkę w górę lub w dół.

+
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 278c2de..d324dbd 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -6,6 +6,10 @@ export const ROUTES = { rent: '/wynajem', sell: '/jak-sprzedawac', valuation: '/wycena', + // Kolejne kroki kreatora wyceny - kazdy pod wlasnym adresem. + valuationDetails: '/wycena/szczegoly', + valuationCondition: '/wycena/stan', + valuationResult: '/wycena/wynik', priceHistory: '/historia-cen', districtRanking: '/ranking-dzielnic', negotiation: '/negocjacje', @@ -154,6 +158,9 @@ export function mapPath(city?: string, offerType?: 'SALE' | 'RENT'): string { return query ? `${ROUTES.map}?${query}` : ROUTES.map; } +// Kroki kreatora wyceny w kolejności - indeks = numer kroku. +export const VALUATION_STEP_PATHS: string[] = [ROUTES.valuation, ROUTES.valuationDetails, ROUTES.valuationCondition, ROUTES.valuationResult]; + // Historia cen z wybraną lokalizacją w adresie - wejście z alertu cenowego otwiera // od razu to miasto, a link da się skopiować, odświeżyć i otworzyć w nowej karcie. export function priceHistoryPath(label?: string, propertyType?: string, marketType?: string): string { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a852ff8..22f24bd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -7165,7 +7165,7 @@ a.city-card:hover { margin: 30px 0 38px; } -.valuation-progress button { +.valuation-progress a { align-items: start; background: transparent; border: 0; @@ -7180,7 +7180,7 @@ a.city-card:hover { text-align: left; } -.valuation-progress button:not(:last-child)::after { +.valuation-progress a:not(:last-child)::after { background: #dfe7ef; content: ""; height: 1px; @@ -7190,11 +7190,11 @@ a.city-card:hover { top: 16px; } -.valuation-progress button.active:not(:last-child)::after { +.valuation-progress a.active:not(:last-child)::after { background: #b8e8ce; } -.valuation-progress.complete button:not(:last-child)::after { +.valuation-progress.complete a:not(:last-child)::after { background: #0b9f5c; height: 2px; } @@ -7226,11 +7226,11 @@ a.city-card:hover { z-index: 1; } -.valuation-progress button.active { +.valuation-progress a.active { color: #0b9f5c; } -.valuation-progress button.active span { +.valuation-progress a.active span { background: #0b9f5c; color: #ffffff; } @@ -7340,15 +7340,15 @@ a.city-card:hover { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.valuation-result-details article { +.valuation-result-details :is(article, .valuation-missing-item) { align-items: start; display: grid; gap: 12px; grid-template-columns: 34px minmax(0, 1fr); } -.valuation-result-details article > span, -.valuation-next-card button > span { +.valuation-result-details :is(article, .valuation-missing-item) > span, +.valuation-next-card :is(a, button) > span { align-items: center; background: #e7f8ef; border-radius: 50%; @@ -7527,7 +7527,7 @@ a.city-card:hover { margin-bottom: 4px; } -.valuation-next-card button { +.valuation-next-card :is(a, button) { align-items: center; background: transparent; border: 0; @@ -7538,13 +7538,13 @@ a.city-card:hover { text-align: left; } -.valuation-next-card button strong { +.valuation-next-card :is(a, button) strong { color: #27364d; font-size: 12px; font-weight: 950; } -.valuation-next-card button > svg { +.valuation-next-card :is(a, button) > svg { color: #526171; transform: rotate(-90deg); } @@ -12677,7 +12677,7 @@ a.city-card:hover { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .valuation-progress button:not(:last-child)::after { + .valuation-progress a:not(:last-child)::after { display: none; } @@ -13079,7 +13079,7 @@ a.city-card:hover { margin: 28px 0; } - .valuation-progress button { + .valuation-progress a { min-height: 34px; } @@ -35109,3 +35109,29 @@ a.listing-detail-back { line-height: 1.4; margin-top: 6px; } + +/* Kroki wyceny, brakujace dane i "Co dalej?" sa linkami do konkretnych adresow. */ +.valuation-progress a, +.valuation-next-card a, +.valuation-missing-item { + text-decoration: none; +} + +.valuation-missing-item { + border-radius: 10px; + color: inherit; + margin: -6px; + padding: 6px; +} + +.valuation-missing-item:hover { + background: #f1f8f4; +} + +.valuation-info-card summary { + color: #0b9f5c; + cursor: pointer; + font-size: 12px; + font-weight: 950; + padding-top: 18px; +} diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index 79b3bf6..51cffe3 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -418,3 +418,23 @@ test.describe('Logowanie i rejestracja', () => { await page.waitForFunction(() => window.location.pathname === '/rejestracja'); }); }); + +test.describe('Wycena', () => { + test('kroki maja wlasne adresy, a dane przetrwaja odswiezenie', async ({ page }) => { + await goto(page, ROUTES.valuation); + await page.locator('.valuation-form-section input').first().fill('Warszawa'); + await page.getByRole('button', { name: /Dalej/ }).click(); + await page.waitForFunction(() => window.location.pathname === '/wycena/szczegoly'); + await page.reload(); + await page.goBack(); + await page.waitForFunction(() => window.location.pathname === '/wycena'); + await expect(page.locator('.valuation-form-section input').first()).toHaveValue('Warszawa'); + }); + + test('pusty wynik prowadzi do kroku z brakujacymi danymi', async ({ page }) => { + await goto(page, ROUTES.valuationResult); + await expect(page.locator('.valuation-result-hero strong').first()).toHaveText('Brakuje danych do wyceny'); + await page.locator('.valuation-missing-item').first().click(); + await page.waitForFunction(() => window.location.pathname === '/wycena'); + }); +});