Martwe przyciski: podepnij pod prawdziwe strony albo oznacz "Wkrotce"

- AI Dopasowanie, "Zobacz kupujacych", kafelek 360 i Konto Premium:
  nieaktywne z plakietka "Wkrotce" zamiast przyciskow bez akcji.
- Pomoc: jeden adres info@polskalokalnie.pl i prawdziwy numer
  +48 453 193 605 jako linki mailto/tel, czat na zywo usuniety,
  FAQ z prawdziwymi odpowiedziami zamiast jednego zdania.
- Poradniki: dzialajaca wyszukiwarka (?szukaj= w adresie), szybkie
  poradniki linkuja do rozdzialow, martwe strzalki karuzeli usuniete.
- Mapa: "Szukaj na biezacej mapie" zaweza liste do widocznych ofert
  (?obszar= w adresie).
- Porownanie: wybrane oferty w adresie (?oferty=), "Udostepnij
  porownanie" kopiuje link.
- Kafelek miast prowadzi na mape, karuzela opinii bez atrap strzalek.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 08:08:45 +02:00
parent 017ff4b2c6
commit e3cc999343
3 changed files with 426 additions and 154 deletions
+281 -89
View File
@@ -2129,31 +2129,37 @@ function articleCountLabel(count: number): string {
return `${count} artykułów`; return `${count} artykułów`;
} }
// Skroty do wbudowanych poradnikow - kazdy prowadzi do rozdzialu, ktory opisuje dany temat.
const quickGuides = [ const quickGuides = [
{ {
icon: 'scale', icon: 'scale',
title: 'Jak sprawdzić księgę wieczystą?', title: 'Jak sprawdzić księgę wieczystą?',
text: 'Poradnik krok po kroku', text: 'Poradnik krok po kroku',
to: ROUTES.guideLaw,
}, },
{ {
icon: 'percent', icon: 'percent',
title: 'Podatek od sprzedaży mieszkania', title: 'Podatek od sprzedaży mieszkania',
text: 'Kiedy trzeba zapłacić?', text: 'Kiedy trzeba zapłacić?',
to: ROUTES.guideLaw,
}, },
{ {
icon: 'paint', icon: 'paint',
title: 'Ile kosztuje remont mieszkania?', title: 'Ile kosztuje remont mieszkania?',
text: 'Aktualne koszty robocizny i materiałów', text: 'Aktualne koszty robocizny i materiałów',
to: ROUTES.guideRenovation,
}, },
{ {
icon: 'shield', icon: 'shield',
title: 'Jak bezpiecznie wynająć mieszkanie?', title: 'Jak bezpiecznie wynająć mieszkanie?',
text: 'Porady dla wynajmujących', text: 'Porady dla wynajmujących',
to: ROUTES.guideRent,
}, },
{ {
icon: 'chart', icon: 'chart',
title: 'PCC przy zakupie mieszkania', title: 'PCC przy zakupie mieszkania',
text: 'Stawki i zwolnienia w 2026 roku', text: 'Stawki i zwolnienia w 2026 roku',
to: ROUTES.guideLaw,
}, },
]; ];
@@ -6497,10 +6503,10 @@ function AccountSidebar() {
<div className="account-premium-card"> <div className="account-premium-card">
<div> <div>
<strong><Icon name="coin" /> Konto Premium</strong> <strong><Icon name="coin" /> Konto Premium <em className="soon-tag">Wkrótce</em></strong>
<p>Zyskaj dostęp do dodatkowych narzędzi i raportów.</p> <p>Zyskaj dostęp do dodatkowych narzędzi i raportów.</p>
</div> </div>
<button type="button">Sprawdź korzyści</button> <button type="button" disabled title={SOON_HINT}>Sprawdź korzyści</button>
</div> </div>
</aside> </aside>
); );
@@ -6816,7 +6822,6 @@ function AccountDashboardPage({
</Link> </Link>
))} ))}
</div> </div>
<button type="button" className="account-tools-more">Zobacz wszystkie narzędzia <Icon name="arrow" /></button>
</section> </section>
), ),
}; };
@@ -7180,23 +7185,16 @@ function AccountHelpContactPage({
text: string; text: string;
}; };
// Kanaly kontaktu: e-mail i telefon otwieraja program pocztowy albo dzwonia, formularz jest nizej na stronie.
const supportChannels = [ const supportChannels = [
{
id: 'live-chat',
icon: 'message',
iconTone: 'green',
title: 'Czat na żywo',
badge: 'Najszybsza pomoc',
description: 'Porozmawiaj z naszym konsultantem w czasie rzeczywistym.',
action: 'Dostępny 8:00 - 20:00',
},
{ {
id: 'email', id: 'email',
icon: 'mail', icon: 'mail',
iconTone: 'blue', iconTone: 'blue',
title: 'E-mail', title: 'E-mail',
description: 'Napisz do nas, odpowiemy najszybciej jak to możliwe.', description: 'Napisz do nas, odpowiemy najszybciej jak to możliwe.',
action: 'bok@polskalokalnie.pl', action: SUPPORT_EMAIL,
href: `mailto:${SUPPORT_EMAIL}`,
}, },
{ {
id: 'phone', id: 'phone',
@@ -7204,7 +7202,8 @@ function AccountHelpContactPage({
iconTone: 'violet', iconTone: 'violet',
title: 'Telefon', title: 'Telefon',
description: 'Zadzwoń do nas, chętnie odpowiemy na Twoje pytania.', description: 'Zadzwoń do nas, chętnie odpowiemy na Twoje pytania.',
action: '+48 22 123 45 67', action: SUPPORT_PHONE,
href: SUPPORT_PHONE_HREF,
}, },
{ {
id: 'form', id: 'form',
@@ -7216,14 +7215,33 @@ function AccountHelpContactPage({
}, },
] as const; ] as const;
// Odpowiedzi opisuja to, co serwis naprawde robi - kazda prowadzi do wlasciwej strony.
const faqItems = [ const faqItems = [
'Jak dodać ogłoszenie?', {
'Ile kosztuje dodanie ogłoszenia?', question: 'Jak dodać ogłoszenie?',
'Jak wyróżnić moje ogłoszenie?', answer: <>Wypełnij formularz na stronie <Link to={ROUTES.add}>Dodaj ogłoszenie</Link>. Po wysłaniu ogłoszenie czeka na akceptację obsługi i dopiero potem pojawia się na liście ofert.</>,
'Jak edytować lub usunąć ogłoszenie?', },
'Jak działa porównywarka mieszkań?', {
'Jak ustawić alerty cenowe?', question: 'Ile kosztuje dodanie ogłoszenia?',
] as const; answer: <>Nic. Dodanie i publikacja ogłoszenia są bezpłatne, nie pobieramy prowizji od sprzedaży. Płatne jest tylko dobrowolne wyróżnienie oferty.</>,
},
{
question: 'Jak wyróżnić moje ogłoszenie?',
answer: <>W panelu <Link to={ROUTES.accountListings}>Moje oferty</Link> kliknij „Wyróżnij” przy zaakceptowanym ogłoszeniu. Wybierzesz czas wyróżnienia i zapłacisz przez AutoPay albo użyjesz kredytów z wcześniej kupionego pakietu.</>,
},
{
question: 'Jak edytować lub usunąć ogłoszenie?',
answer: <>Obie opcje są w panelu <Link to={ROUTES.accountListings}>Moje oferty</Link>, przy każdym ogłoszeniu. Po edycji ogłoszenie ponownie trafia do akceptacji.</>,
},
{
question: 'Jak działa porównywarka mieszkań?',
answer: <>Na liście ofert dodajesz mieszkania do porównania, a w <Link to={ROUTES.comparison}>Porównaniu</Link> widzisz je obok siebie: cenę, metraż, cenę za m² i pozostałe parametry.</>,
},
{
question: 'Jak ustawić alerty cenowe?',
answer: <>Alert ceny ogłoszenia założysz przyciskiem na karcie oferty, a alert lokalizacji w <Link to={ROUTES.priceHistory}>Historii cen</Link>. Wszystkie zobaczysz w panelu <Link to={ROUTES.accountPriceAlerts}>Alerty cenowe</Link>. Nie wysyłamy jeszcze powiadomień e-mail.</>,
},
];
const moreHelpCards = [ const moreHelpCards = [
{ {
@@ -7421,18 +7439,24 @@ function AccountHelpContactPage({
<span className={`help-contact-channel-icon ${channel.iconTone}`}><Icon name={channel.icon} /></span> <span className={`help-contact-channel-icon ${channel.iconTone}`}><Icon name={channel.icon} /></span>
<div> <div>
<h3>{channel.title}</h3> <h3>{channel.title}</h3>
{'badge' in channel && channel.badge ? <em>{channel.badge}</em> : null}
</div> </div>
</div> </div>
<p>{channel.description}</p> <p>{channel.description}</p>
{'href' in channel && channel.href ? (
<a className="help-contact-channel-action" href={channel.href}>
{channel.action}
<Icon name="chevron" />
</a>
) : (
<button <button
type="button" type="button"
className="help-contact-channel-action" className="help-contact-channel-action"
onClick={channel.id === 'form' ? highlightContactFormForThreeSeconds : undefined} onClick={highlightContactFormForThreeSeconds}
> >
{channel.action} {channel.action}
<Icon name="chevron" /> <Icon name="chevron" />
</button> </button>
)}
</article> </article>
))} ))}
</div> </div>
@@ -7442,16 +7466,16 @@ function AccountHelpContactPage({
<article className="help-contact-faq-card"> <article className="help-contact-faq-card">
<header> <header>
<h2>Najczęściej zadawane pytania</h2> <h2>Najczęściej zadawane pytania</h2>
<button type="button">Zobacz wszystkie <Icon name="arrow" /></button> <Link to={ROUTES.guides}>Poradniki <Icon name="arrow" /></Link>
</header> </header>
<div className="help-contact-faq-list"> <div className="help-contact-faq-list">
{faqItems.map((question) => ( {faqItems.map((item) => (
<details key={question}> <details key={item.question}>
<summary> <summary>
<span>{question}</span> <span>{item.question}</span>
<Icon name="chevron" /> <Icon name="chevron" />
</summary> </summary>
<p>Znajdziesz odpowiedź w naszym centrum pomocy. W razie potrzeby skorzystaj z formularza kontaktowego obok.</p> <p>{item.answer}</p>
</details> </details>
))} ))}
</div> </div>
@@ -8213,7 +8237,7 @@ function AccountPriceAlertsPage({
<header> <header>
<div> <div>
<h2>Twoje alerty</h2> <h2>Twoje alerty</h2>
<p>Obserwowane ogłoszenia — powiadomimy Cię, gdy zmieni się ich cena</p> <p>Obserwowane ogłoszenia — zmiany ich cen zobaczysz tutaj</p>
</div> </div>
<Link className="price-alerts-create" to={ROUTES.buy}> <Link className="price-alerts-create" to={ROUTES.buy}>
<Icon name="plus" /> Dodaj z ogłoszenia <Icon name="plus" /> Dodaj z ogłoszenia
@@ -8367,14 +8391,40 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
const [propertyTypeFilter, setPropertyTypeFilter] = useState<ComparisonPropertyFilter>('ALL'); const [propertyTypeFilter, setPropertyTypeFilter] = useState<ComparisonPropertyFilter>('ALL');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [pickerValue, setPickerValue] = useState<number | ''>(''); const [pickerValue, setPickerValue] = useState<number | ''>('');
const [selectedIds, setSelectedIds] = useState<number[]>([]); // Wybrane oferty siedza w adresie (?oferty=1,4), wiec porownanie przezywa odswiezenie
// i da sie je wyslac linkiem ("Udostepnij porownanie").
const [comparisonParams, setComparisonParams] = useSearchParams();
const selectedIds = (comparisonParams.get('oferty') ?? '')
.split(',')
.map(Number)
.filter((id) => Number.isInteger(id) && id > 0);
const setSelectedIds = (update: number[] | ((current: number[]) => number[])) => {
const next = typeof update === 'function' ? update(selectedIds) : update;
setComparisonParams((prev) => {
const params = new URLSearchParams(prev);
if (next.length > 0) {
params.set('oferty', next.join(','));
} else {
params.delete('oferty');
}
return params;
}, { replace: true });
};
const [selectedDetails, setSelectedDetails] = useState<Record<number, ApiListingDetail>>({}); const [selectedDetails, setSelectedDetails] = useState<Record<number, ApiListingDetail>>({});
const [shareInfo, setShareInfo] = useState('');
const [valuesGridOffset, setValuesGridOffset] = useState(0); const [valuesGridOffset, setValuesGridOffset] = useState(0);
const summaryFirstMetricRef = useRef<HTMLElement | null>(null); const summaryFirstMetricRef = useRef<HTMLElement | null>(null);
const firstValueCellRef = useRef<HTMLDivElement | null>(null); const firstValueCellRef = useRef<HTMLDivElement | null>(null);
const { items, loading } = useLiveListings(offerType); const { items, loading } = useLiveListings(offerType);
// Przelaczenie sprzedaz/najem czysci wybor, ale nie przy pierwszym wejsciu - inaczej
// skasowaloby oferty podane w adresie (udostepniony link).
const comparisonMountedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!comparisonMountedRef.current) {
comparisonMountedRef.current = true;
return;
}
setSelectedIds([]); setSelectedIds([]);
setPickerValue(''); setPickerValue('');
setSearch(''); setSearch('');
@@ -8382,8 +8432,12 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
}, [offerType]); }, [offerType]);
useEffect(() => { useEffect(() => {
// Dopiero gdy lista ofert dotarla - na pustej liscie skasowalibysmy wybor z adresu.
if (loading || items.length === 0) {
return;
}
setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id))); setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id)));
}, [items]); }, [items, loading]);
useEffect(() => { useEffect(() => {
if (selectedIds.length === 0) { if (selectedIds.length === 0) {
@@ -8773,10 +8827,23 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
<p>Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.</p> <p>Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.</p>
</div> </div>
<div className="comparison-head-actions"> <div className="comparison-head-actions">
<button type="button" className="ghost">Udostępnij porównanie</button> <button
type="button"
className="ghost"
disabled={selectedIds.length === 0}
onClick={() => {
const link = window.location.href;
navigator.clipboard?.writeText(link)
.then(() => setShareInfo('Skopiowano link do porównania.'))
.catch(() => setShareInfo(link));
}}
>
Udostępnij porównanie
</button>
<button type="button" onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0}>Wyczyść wszystko</button> <button type="button" onClick={() => setSelectedIds([])} disabled={selectedIds.length === 0}>Wyczyść wszystko</button>
</div> </div>
</div> </div>
{shareInfo && <p className="comparison-share-info" role="status">{shareInfo}</p>}
<div className="comparison-workspace"> <div className="comparison-workspace">
<aside className="comparison-summary-column"> <aside className="comparison-summary-column">
@@ -13045,6 +13112,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<div className="ai-label"> <div className="ai-label">
<Icon name="spark" /> <Icon name="spark" />
<span>Zainteresowani kupujący</span> <span>Zainteresowani kupujący</span>
<em className="soon-tag">Wkrótce</em>
</div> </div>
<strong>57 osób szuka podobnego mieszkania</strong> <strong>57 osób szuka podobnego mieszkania</strong>
<div className="avatar-row"> <div className="avatar-row">
@@ -13053,7 +13121,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
))} ))}
<em>+53</em> <em>+53</em>
</div> </div>
<button>Zobacz kupujących</button> <button type="button" disabled title={SOON_HINT}>Zobacz kupujących</button>
</aside> </aside>
<aside className="tour-card" aria-label="Wirtualny spacer"> <aside className="tour-card" aria-label="Wirtualny spacer">
@@ -13062,6 +13130,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<strong>Wirtualny spacer</strong> <strong>Wirtualny spacer</strong>
<small>Zobacz mieszkanie w 3D</small> <small>Zobacz mieszkanie w 3D</small>
</span> </span>
<em className="soon-tag">Wkrótce</em>
</aside> </aside>
</div> </div>
</section> </section>
@@ -13181,13 +13250,11 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
</span> </span>
</Link> </Link>
))} ))}
<article className="city-card all-cities"> <Link className="city-card all-cities" to={ROUTES.map} aria-label="Zobacz oferty na mapie całej Polski">
<span>Zobacz wszystkie miasta</span> <span>Zobacz wszystkie miasta</span>
<strong>500+ lokalizacji</strong> <strong>Cała Polska na mapie</strong>
<button aria-label="Zobacz wszystkie miasta"> <em><Icon name="arrow" /></em>
<Icon name="arrow" /> </Link>
</button>
</article>
</div> </div>
</section> </section>
@@ -13248,6 +13315,16 @@ function RentPage({
const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, chips, resetAll } = filters; const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, chips, resetAll } = filters;
const { formatPrice } = useDisplay(); const { formatPrice } = useDisplay();
const [, setSearchParams] = useSearchParams(); const [, setSearchParams] = useSearchParams();
// "Szukaj na bieżącej mapie": numery ofert widocznych na mapie trafiaja do adresu (?obszar=...),
// wiec zawezony wynik przezywa odswiezenie. Myslnik = na mapie nie bylo zadnej oferty.
const searchVisibleOnMap = (offerIds: number[]) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('obszar', offerIds.length > 0 ? offerIds.join(',') : '-');
next.delete('strona');
return next;
});
};
const [isMapOpen, setIsMapOpen] = useState(false); const [isMapOpen, setIsMapOpen] = useState(false);
const [studentOnly, setStudentOnly] = useState(false); const [studentOnly, setStudentOnly] = useState(false);
const [petsOnly, setPetsOnly] = useState(false); const [petsOnly, setPetsOnly] = useState(false);
@@ -13387,6 +13464,7 @@ function RentPage({
cityLabel={city || 'Warszawa'} cityLabel={city || 'Warszawa'}
geoMarkers={rentGeoMarkers} geoMarkers={rentGeoMarkers}
onMapClick={() => setIsMapOpen(true)} onMapClick={() => setIsMapOpen(true)}
onSearchVisible={searchVisibleOnMap}
/> />
</div> </div>
@@ -13394,9 +13472,10 @@ function RentPage({
<div className="ai-label"> <div className="ai-label">
<Icon name="spark" /> <Icon name="spark" />
<span>AI Dopasowanie</span> <span>AI Dopasowanie</span>
<em className="soon-tag">Wkrótce</em>
</div> </div>
<p>Powiedz nam, czego szukasz, a AI znajdzie dla Ciebie idealne oferty wynajmu.</p> <p>Powiedz nam, czego szukasz, a AI znajdzie dla Ciebie idealne oferty wynajmu.</p>
<button> <button type="button" disabled title={SOON_HINT}>
<Icon name="spark" /> <Icon name="spark" />
Dopasuj mi mieszkanie Dopasuj mi mieszkanie
</button> </button>
@@ -14990,18 +15069,18 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<div> <div>
<span><Icon name="phone" /></span> <span><Icon name="phone" /></span>
<p> <p>
<strong>+48 123 456 789</strong> <a href={SUPPORT_PHONE_HREF}>{SUPPORT_PHONE}</a>
<small>Pon. - Pt. 9:00 - 17:00</small> <small>Pon. - Pt. 9:00 - 17:00</small>
</p> </p>
</div> </div>
<div> <div>
<span><Icon name="mail" /></span> <span><Icon name="mail" /></span>
<p> <p>
<strong>pomoc@polskalokalnie.pl</strong> <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
<small>Odpowiadamy w ciągu 24h</small> <small>Odpowiadamy w ciągu 24h</small>
</p> </p>
</div> </div>
<button type="button">Najczęściej zadawane pytania</button> <Link className="help-card-faq" to={ROUTES.accountHelpContact}>Najczęściej zadawane pytania <Icon name="arrow" /></Link>
</section> </section>
</> </>
)} )}
@@ -16081,6 +16160,25 @@ function GuideArticleTemplate({ content }: { content: GuideContent }) {
); );
} }
// Kafelek artykulu uzytkownika - ten sam w polecanych i w wynikach wyszukiwania.
function ArticleTile({ article }: { article: ArticleCard }) {
return (
<Link className="article-card is-clickable" to={articlePath(article.id)}>
<div className="article-image" style={{ backgroundImage: `url(${heroImage})` }}>
<span>{guideTopicBySlug(article.topicSlug)?.title.toUpperCase() ?? 'PORADNIK'}</span>
</div>
<div className="article-body">
<h3>{article.title}</h3>
<p>{article.excerpt}</p>
<div>
<span>{article.readMinutes} min czytania</span>
<span><Icon name="eye" /> {article.views}</span>
</div>
</div>
</Link>
);
}
function GuidesPage() { function GuidesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
// Liczba artykułów użytkowników w każdym temacie. Do tego doliczamy 1 wbudowany poradnik. // Liczba artykułów użytkowników w każdym temacie. Do tego doliczamy 1 wbudowany poradnik.
@@ -16092,9 +16190,9 @@ function GuidesPage() {
.catch(() => { /* brak sieci - licznik pokaże sam wbudowany poradnik */ }); .catch(() => { /* brak sieci - licznik pokaże sam wbudowany poradnik */ });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
// Polecane artykuly: najczesciej czytane artykuly uzytkownikow ze wszystkich tematow. // Wszystkie artykuly uzytkownikow - API zwraca je per temat, wiec laczymy odpowiedzi.
// API zwraca artykuly per temat, wiec laczymy odpowiedzi dla kazdego tematu. // Z tej listy bierzemy polecane (najczesciej czytane) i wyniki wyszukiwania.
const [featuredArticles, setFeaturedArticles] = useState<ArticleCard[] | null>(null); const [allArticles, setAllArticles] = useState<ArticleCard[] | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
Promise.all(GUIDE_TOPICS.map((topic) => ( Promise.all(GUIDE_TOPICS.map((topic) => (
@@ -16102,11 +16200,32 @@ function GuidesPage() {
))) )))
.then((perTopic) => { .then((perTopic) => {
if (!cancelled) { if (!cancelled) {
setFeaturedArticles(perTopic.flat().sort((left, right) => right.views - left.views).slice(0, 4)); setAllArticles(perTopic.flat());
} }
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
const featuredArticles = allArticles && [...allArticles].sort((left, right) => right.views - left.views).slice(0, 4);
// Szukana fraza siedzi w adresie (/poradniki?szukaj=...), wiec wynik da sie odswiezyc i udostepnic.
const [guideParams, setGuideParams] = useSearchParams();
const guideQuery = guideParams.get('szukaj') ?? '';
const [guideSearchDraft, setGuideSearchDraft] = useState(guideQuery);
useEffect(() => {
setGuideSearchDraft(guideQuery);
}, [guideQuery]);
const submitGuideSearch = (event: ReactFormEvent<HTMLFormElement>) => {
event.preventDefault();
const phrase = guideSearchDraft.trim();
setGuideParams(phrase ? { szukaj: phrase } : {});
};
const normalizedQuery = guideQuery.trim().toLowerCase();
const matchingArticles = normalizedQuery.length > 0 && allArticles
? allArticles.filter((article) => `${article.title} ${article.excerpt}`.toLowerCase().includes(normalizedQuery))
: [];
const matchingTopics = normalizedQuery.length > 0
? GUIDE_TOPICS.filter((topic) => topic.title.toLowerCase().includes(normalizedQuery))
: [];
return ( return (
<section className="guides-page" aria-label="Poradniki"> <section className="guides-page" aria-label="Poradniki">
<section className="guides-hero"> <section className="guides-hero">
@@ -16116,11 +16235,16 @@ function GuidesPage() {
<div> <div>
<h1>Centrum wiedzy <span>o nieruchomościach</span></h1> <h1>Centrum wiedzy <span>o nieruchomościach</span></h1>
<p>Praktyczne poradniki, analizy rynku i eksperckie wskazówki, które pomogą Ci podjąć najlepsze decyzje.</p> <p>Praktyczne poradniki, analizy rynku i eksperckie wskazówki, które pomogą Ci podjąć najlepsze decyzje.</p>
<div className="guides-search"> <form className="guides-search" onSubmit={submitGuideSearch}>
<Icon name="search" /> <Icon name="search" />
<input placeholder="O czym chcesz przeczytać?" /> <input
<button>Szukaj</button> placeholder="O czym chcesz przeczytać?"
</div> value={guideSearchDraft}
onChange={(event) => setGuideSearchDraft(event.target.value)}
aria-label="Szukaj w poradnikach"
/>
<button type="submit">Szukaj</button>
</form>
<div className="popular-links"> <div className="popular-links">
<span>Popularne:</span> <span>Popularne:</span>
<Link to={ROUTES.creditCalculator}>Kredyt hipoteczny</Link> <Link to={ROUTES.creditCalculator}>Kredyt hipoteczny</Link>
@@ -16134,13 +16258,43 @@ function GuidesPage() {
<div className="guides-container guides-layout"> <div className="guides-container guides-layout">
<div className="guides-main"> <div className="guides-main">
<section className="guides-section"> {normalizedQuery.length > 0 && (
<section className="guides-section" aria-label="Wyniki wyszukiwania">
<div className="guide-topic-head">
<h2>Wyniki dla „{guideQuery.trim()}”</h2>
<Link to={ROUTES.guides}>Wyczyść wyszukiwanie</Link>
</div>
{matchingTopics.length > 0 && (
<div className="guide-topic-grid">
{matchingTopics.map((topic) => (
<Link className="guide-topic-card is-clickable" key={topic.slug} to={guideTopicListPath(topic.slug)}>
<span className={`guide-topic-icon ico-${topic.icon}`}>
<Icon name={topic.icon} />
</span>
<strong>{topic.title}</strong>
</Link>
))}
</div>
)}
{matchingArticles.length > 0 ? (
<div className="article-grid">
{matchingArticles.map((article) => (
<ArticleTile key={article.id} article={article} />
))}
</div>
) : (
matchingTopics.length === 0 && (
<p className="guides-search-empty">
{allArticles == null ? 'Szukamy…' : 'Nie znaleźliśmy artykułu ani tematu z tą frazą. Sprawdź tematy poniżej.'}
</p>
)
)}
</section>
)}
<section className="guides-section" id="tematy-poradnikow">
<div className="guide-topic-head"> <div className="guide-topic-head">
<h2>Wybierz temat</h2> <h2>Wybierz temat</h2>
<div>
<button aria-label="Poprzednie"><Icon name="chevron" /></button>
<button aria-label="Następne"><Icon name="chevron" /></button>
</div>
</div> </div>
<div className="guide-topic-grid"> <div className="guide-topic-grid">
{guideTopics.map((topic) => { {guideTopics.map((topic) => {
@@ -16174,23 +16328,7 @@ function GuidesPage() {
) : ( ) : (
<div className="article-grid"> <div className="article-grid">
{(featuredArticles ?? []).map((article) => ( {(featuredArticles ?? []).map((article) => (
<Link <ArticleTile key={article.id} article={article} />
className="article-card is-clickable"
key={article.id}
to={articlePath(article.id)}
>
<div className="article-image" style={{ backgroundImage: `url(${heroImage})` }}>
<span>{guideTopicBySlug(article.topicSlug)?.title.toUpperCase() ?? 'PORADNIK'}</span>
</div>
<div className="article-body">
<h3>{article.title}</h3>
<p>{article.excerpt}</p>
<div>
<span>{article.readMinutes} min czytania</span>
<span><Icon name="eye" /> {article.views}</span>
</div>
</div>
</Link>
))} ))}
</div> </div>
)} )}
@@ -16236,7 +16374,7 @@ function GuidesPage() {
<h2>Szybkie poradniki</h2> <h2>Szybkie poradniki</h2>
<div> <div>
{quickGuides.map((guide) => ( {quickGuides.map((guide) => (
<article key={guide.title}> <Link key={guide.title} to={guide.to}>
<span> <span>
<Icon name={guide.icon} /> <Icon name={guide.icon} />
</span> </span>
@@ -16245,13 +16383,13 @@ function GuidesPage() {
<small>{guide.text}</small> <small>{guide.text}</small>
</p> </p>
<Icon name="chevron" /> <Icon name="chevron" />
</article> </Link>
))} ))}
</div> </div>
<button> <a href="#tematy-poradnikow">
Zobacz wszystkie poradniki Zobacz wszystkie tematy
<Icon name="arrow" /> <Icon name="arrow" />
</button> </a>
</section> </section>
</aside> </aside>
</div> </div>
@@ -17034,7 +17172,7 @@ function SellPage() {
<section className="sell-section"> <section className="sell-section">
<h2>Zobacz opinie naszych użytkowników</h2> <h2>Zobacz opinie naszych użytkowników</h2>
<div className="testimonial-grid"> <div className="testimonial-grid">
{sellTestimonials.map((testimonial, index) => ( {sellTestimonials.map((testimonial) => (
<article className="testimonial-card" key={testimonial.name}> <article className="testimonial-card" key={testimonial.name}>
<div className="testimonial-head"> <div className="testimonial-head">
<span>{testimonial.name.charAt(0)}</span> <span>{testimonial.name.charAt(0)}</span>
@@ -17050,15 +17188,9 @@ function SellPage() {
))} ))}
<span>5.0</span> <span>5.0</span>
</div> </div>
{index === 0 && <button className="testimonial-nav prev">‹</button>}
{index === 2 && <button className="testimonial-nav next">›</button>}
</article> </article>
))} ))}
</div> </div>
<div className="testimonial-dots">
<span />
<span />
</div>
</section> </section>
<section className="sell-bottom-cta"> <section className="sell-bottom-cta">
@@ -22086,7 +22218,17 @@ function RealListingsBand({
}, [sortOpen]); }, [sortOpen]);
const cityFilter = (city ?? '').trim(); const cityFilter = (city ?? '').trim();
const visible = cityFilter ? items.filter((item) => cityStartsWith(item.city, cityFilter)) : items; const byCity = cityFilter ? items.filter((item) => cityStartsWith(item.city, cityFilter)) : items;
// "Szukaj na bieżącej mapie" wpisuje numery widocznych ofert do adresu (?obszar=1,4,9),
// dzieki czemu zawezony wynik przezywa odswiezenie i da sie go wyslac linkiem.
const areaParam = searchParams.get('obszar');
const areaIds = areaParam ? new Set(areaParam.split(',').map(Number).filter((id) => !Number.isNaN(id))) : null;
const visible = areaIds ? byCity.filter((item) => areaIds.has(item.id)) : byCity;
const clearArea = () => {
const next = new URLSearchParams(searchParams);
next.delete('obszar');
setSearchParams(next);
};
// W trakcie ladowania pokazujemy animacje zamiast pustego miejsca; gdy dane przyjda i lista jest // W trakcie ladowania pokazujemy animacje zamiast pustego miejsca; gdy dane przyjda i lista jest
// pusta - nie pokazujemy nic (jak dotad). // pusta - nie pokazujemy nic (jak dotad).
@@ -22098,7 +22240,16 @@ function RealListingsBand({
); );
} }
if (visible.length === 0) { if (visible.length === 0) {
return null; // Pusto po zawezeniu do obszaru mapy to co innego niz brak ofert - trzeba dac droge powrotna.
return areaIds ? (
<div className="real-listings-section" aria-label="Ogłoszenia dodane w serwisie">
<p className="map-area-note" role="status">
<Icon name="pin" />
<span>Na widocznym fragmencie mapy nie ma żadnej oferty.</span>
<button type="button" onClick={clearArea}>Pokaż wszystkie</button>
</p>
</div>
) : null;
} }
// Widok prezentacji (lista/kompaktowa/kafelki) oraz numer strony trzymamy w query // Widok prezentacji (lista/kompaktowa/kafelki) oraz numer strony trzymamy w query
@@ -22155,6 +22306,13 @@ function RealListingsBand({
return ( return (
<div className="real-listings-section" aria-label="Ogłoszenia dodane w serwisie"> <div className="real-listings-section" aria-label="Ogłoszenia dodane w serwisie">
{areaIds && (
<p className="map-area-note" role="status">
<Icon name="pin" />
<span>Pokazujemy oferty widoczne na mapie.</span>
<button type="button" onClick={clearArea}>Pokaż wszystkie</button>
</p>
)}
<div className="real-listings-heading"> <div className="real-listings-heading">
<h3>Ogłoszenia dodane w serwisie</h3> <h3>Ogłoszenia dodane w serwisie</h3>
<span>{visible.length}</span> <span>{visible.length}</span>
@@ -22376,6 +22534,16 @@ function BuyPage({
}) { }) {
const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, noFeeOnly, chips, resetAll, fromPreferences } = filters; const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, noFeeOnly, chips, resetAll, fromPreferences } = filters;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// "Szukaj na bieżącej mapie": numery ofert widocznych na mapie trafiaja do adresu (?obszar=...),
// wiec zawezony wynik przezywa odswiezenie. Myslnik = na mapie nie bylo zadnej oferty.
const searchVisibleOnMap = (offerIds: number[]) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('obszar', offerIds.length > 0 ? offerIds.join(',') : '-');
next.delete('strona');
return next;
});
};
const [isMapOpen, setIsMapOpen] = useState(false); const [isMapOpen, setIsMapOpen] = useState(false);
// Miasto przekazane w adresie (np. z kafelka miasta na stronie glownej: /kupno?miasto=Warszawa) // Miasto przekazane w adresie (np. z kafelka miasta na stronie glownej: /kupno?miasto=Warszawa)
@@ -22510,6 +22678,7 @@ function BuyPage({
cityLabel={city || 'Warszawa'} cityLabel={city || 'Warszawa'}
geoMarkers={saleGeoMarkers} geoMarkers={saleGeoMarkers}
onMapClick={() => setIsMapOpen(true)} onMapClick={() => setIsMapOpen(true)}
onSearchVisible={searchVisibleOnMap}
/> />
</div> </div>
@@ -22517,9 +22686,10 @@ function BuyPage({
<div className="ai-label"> <div className="ai-label">
<Icon name="spark" /> <Icon name="spark" />
<span>AI Dopasowanie</span> <span>AI Dopasowanie</span>
<em className="soon-tag">Wkrótce</em>
</div> </div>
<p>Powiedz nam czego szukasz, a AI znajdzie oferty idealnie dopasowane do Ciebie.</p> <p>Powiedz nam czego szukasz, a AI znajdzie oferty idealnie dopasowane do Ciebie.</p>
<button type="button"> <button type="button" disabled title={SOON_HINT}>
<Icon name="spark" /> <Icon name="spark" />
Dopasuj mi mieszkanie Dopasuj mi mieszkanie
</button> </button>
@@ -22591,6 +22761,7 @@ function SatelliteMap({
geoMarkers, geoMarkers,
onMapClick, onMapClick,
onMarkerClick, onMarkerClick,
onSearchVisible,
size, size,
areaRadiusMeters, areaRadiusMeters,
}: { }: {
@@ -22603,6 +22774,8 @@ function SatelliteMap({
geoMarkers?: { id: string; lat: number; lng: number; label: string; offerId?: number }[]; geoMarkers?: { id: string; lat: number; lng: number; label: string; offerId?: number }[];
onMapClick?: () => void; onMapClick?: () => void;
onMarkerClick?: (offerId: number) => void; onMarkerClick?: (offerId: number) => void;
// Zawezenie listy ofert do tych, ktore widac na mapie. Bez tej funkcji przycisku nie pokazujemy.
onSearchVisible?: (offerIds: number[]) => void;
size?: number; size?: number;
// Podany promien (w metrach) zastepuje pinezke kolem - uzywany dla przyblizonej lokalizacji. // Podany promien (w metrach) zastepuje pinezke kolem - uzywany dla przyblizonej lokalizacji.
areaRadiusMeters?: number; areaRadiusMeters?: number;
@@ -22790,10 +22963,21 @@ function SatelliteMap({
))} ))}
</div> </div>
<div className="satellite-map-shade" /> <div className="satellite-map-shade" />
<button type="button" className="map-current"> {onSearchVisible && (
<button
type="button"
className="map-current"
onClick={(event) => {
event.stopPropagation();
onSearchVisible(positionedGeoMarkers
.map((marker) => marker.offerId)
.filter((offerId): offerId is number => offerId != null));
}}
>
<span /> <span />
Szukaj na bieżącej mapie Szukaj na bieżącej mapie
</button> </button>
)}
{areaCircle && ( {areaCircle && (
<span <span
className="map-area-circle" className="map-area-circle"
@@ -26602,6 +26786,14 @@ function useLiveListings(offerType: ApiOfferType, refreshMs = LIVE_LISTINGS_REFR
return { items, loading }; return { items, loading };
} }
// Funkcje zapowiedziane, ale jeszcze niezbudowane - przycisk jest nieaktywny i ma plakietke "Wkrotce".
const SOON_HINT = 'Ta funkcja jest w przygotowaniu.';
// Jeden adres i jeden numer kontaktowy w calym serwisie - te same co w stopce i na stronach prawnych.
const SUPPORT_EMAIL = 'info@polskalokalnie.pl';
const SUPPORT_PHONE = '+48 453 193 605';
const SUPPORT_PHONE_HREF = 'tel:+48453193605';
// Podpowiedz przy narzedziach wylaczonych we wlasnym ogloszeniu (kontakt, ulubione, alert ceny, zgloszenie). // Podpowiedz przy narzedziach wylaczonych we wlasnym ogloszeniu (kontakt, ulubione, alert ceny, zgloszenie).
const OWNER_TOOL_HINT ='To Twoje ogłoszenie - ta opcja jest dostępna tylko dla innych użytkowników.'; const OWNER_TOOL_HINT ='To Twoje ogłoszenie - ta opcja jest dostępna tylko dla innych użytkowników.';
+94 -54
View File
@@ -1302,7 +1302,7 @@ a.city-card:hover {
opacity: 0.9; opacity: 0.9;
} }
.all-cities button { .all-cities em {
align-items: center; align-items: center;
background: transparent; background: transparent;
border: 2px solid rgba(255, 255, 255, 0.8); border: 2px solid rgba(255, 255, 255, 0.8);
@@ -4153,7 +4153,7 @@ a.city-card:hover {
margin-top: 15px; margin-top: 15px;
} }
.quick-guides-card article { .quick-guides-card :is(article, a) {
align-items: center; align-items: center;
border-bottom: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5;
display: grid; display: grid;
@@ -4162,7 +4162,7 @@ a.city-card:hover {
min-height: 64px; min-height: 64px;
} }
.quick-guides-card article > span { .quick-guides-card :is(article, a) > span {
align-items: center; align-items: center;
background: #edf3ff; background: #edf3ff;
border-radius: 8px; border-radius: 8px;
@@ -4174,17 +4174,17 @@ a.city-card:hover {
width: 34px; width: 34px;
} }
.quick-guides-card article:nth-child(2) > span { .quick-guides-card :is(article, a):nth-child(2) > span {
background: #f1edff; background: #f1edff;
color: #9b5cff; color: #9b5cff;
} }
.quick-guides-card article:nth-child(3) > span { .quick-guides-card :is(article, a):nth-child(3) > span {
background: #fff4df; background: #fff4df;
color: #f49a31; color: #f49a31;
} }
.quick-guides-card article:nth-child(4) > span { .quick-guides-card :is(article, a):nth-child(4) > span {
background: #e9f8ef; background: #e9f8ef;
color: #10a15e; color: #10a15e;
} }
@@ -4208,7 +4208,7 @@ a.city-card:hover {
margin-top: 3px; margin-top: 3px;
} }
.quick-guides-card article > svg { .quick-guides-card :is(article, a) > svg {
color: #9aa5b4; color: #9aa5b4;
transform: rotate(-90deg); transform: rotate(-90deg);
} }
@@ -9475,50 +9475,6 @@ a.city-card:hover {
margin-left: 6px; margin-left: 6px;
} }
.testimonial-nav {
align-items: center;
background: #ffffff;
border: 1px solid #dce4ec;
border-radius: 50%;
color: #526171;
display: grid;
font-size: 24px;
height: 34px;
place-items: center;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 34px;
}
.testimonial-nav.prev {
left: -18px;
}
.testimonial-nav.next {
right: -18px;
}
.testimonial-dots {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
}
.testimonial-dots span {
background: #14223a;
border-radius: 999px;
height: 6px;
opacity: 0.35;
width: 6px;
}
.testimonial-dots span:first-child {
opacity: 1;
width: 18px;
}
.sell-bottom-cta { .sell-bottom-cta {
align-items: center; align-items: center;
background: linear-gradient(90deg, #f8fffb 0%, #effaf4 100%); background: linear-gradient(90deg, #f8fffb 0%, #effaf4 100%);
@@ -12705,9 +12661,6 @@ a.city-card:hover {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.testimonial-nav {
display: none;
}
.services-search, .services-search,
.service-mini-search { .service-mini-search {
@@ -35193,3 +35146,90 @@ a.listing-detail-secondary {
color: #0b9f5c; color: #0b9f5c;
font-weight: 900; font-weight: 900;
} }
/* Zapowiedziane funkcje: plakietka "Wkrotce" i nieaktywny przycisk obok niej. */
.soon-tag {
background: #eef2f7;
border-radius: 999px;
color: #56657a;
font-size: 10px;
font-style: normal;
font-weight: 900;
letter-spacing: 0.02em;
padding: 3px 8px;
text-transform: uppercase;
white-space: nowrap;
}
.ai-card button:disabled,
.map-ai-card button:disabled,
.account-premium-card button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.tour-card .soon-tag {
margin-left: auto;
}
/* Szybkie poradniki i wyszukiwarka poradnikow prowadza do prawdziwych stron. */
.quick-guides-card :is(a, article) {
color: inherit;
text-decoration: none;
}
.quick-guides-card > a:last-child {
align-items: center;
color: #0b9f5c;
display: inline-flex;
font-size: 13px;
font-weight: 900;
gap: 8px;
padding-top: 14px;
text-decoration: none;
}
.guide-topic-head a {
color: #0b9f5c;
font-size: 13px;
font-weight: 900;
text-decoration: none;
}
.guides-search-empty {
color: #526171;
font-size: 14px;
}
/* Zawezenie listy do ofert widocznych na mapie. */
.map-area-note {
align-items: center;
background: #f1f8f4;
border: 1px solid #cfe8db;
border-radius: 10px;
color: #27364d;
display: flex;
font-size: 13px;
font-weight: 800;
gap: 8px;
margin: 0 0 14px;
padding: 10px 14px;
}
.map-area-note button {
background: transparent;
border: 0;
color: #0b9f5c;
cursor: pointer;
font-size: 13px;
font-weight: 900;
margin-left: auto;
padding: 0;
}
.comparison-share-info {
color: #0b9f5c;
font-size: 13px;
font-weight: 900;
margin: 0 0 12px;
}
+40
View File
@@ -458,3 +458,43 @@ test.describe('Negocjacje', () => {
await expect(page.getByText('Nie znaleźliśmy tego ogłoszenia')).toBeVisible(); await expect(page.getByText('Nie znaleźliśmy tego ogłoszenia')).toBeVisible();
}); });
}); });
test.describe('Standard: przyciski robia to, co obiecuja', () => {
test('wyszukiwarka poradnikow trzyma fraze w adresie i przezywa F5', async ({ page }) => {
await goto(page, ROUTES.guides);
await page.locator('.guides-search input').fill('kredyt');
await page.locator('.guides-search button').click();
await page.waitForFunction(() => window.location.search.includes('szukaj=kredyt'));
await page.reload();
await expect(page.locator('section[aria-label="Wyniki wyszukiwania"]')).toContainText('kredyt');
});
test('"Szukaj na biezacej mapie" zaweza liste do ofert z mapy', async ({ page }) => {
await goto(page, ROUTES.buy);
await page.locator('.map-card').first().waitFor();
await page.getByRole('button', { name: /Szukaj na bieżącej mapie/ }).first().click();
await page.waitForFunction(() => window.location.search.includes('obszar='));
await expect(page.locator('.map-area-note')).toBeVisible();
await page.locator('.map-area-note button').click();
await page.waitForFunction(() => !window.location.search.includes('obszar='));
});
test('porownanie trzyma wybrane oferty w adresie', async ({ page }) => {
await login(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');
const listingId = Number(href!.split('/').pop());
await goto(page, `${ROUTES.comparison}?oferty=${listingId}`);
await expect(page.locator('.comparison-main')).toContainText('Zobacz ogłoszenie');
await page.reload();
await expect(page.locator('.comparison-main')).toContainText('Zobacz ogłoszenie');
});
test('kanaly kontaktu w pomocy to prawdziwe linki mailto i tel', async ({ page }) => {
await login(page);
await goto(page, ROUTES.accountHelpContact);
await expect(page.locator('a.help-contact-channel-action[href^="mailto:"]')).toHaveCount(1);
await expect(page.locator('a.help-contact-channel-action[href^="tel:"]')).toHaveCount(1);
});
});