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`;
}
// Skroty do wbudowanych poradnikow - kazdy prowadzi do rozdzialu, ktory opisuje dany temat.
const quickGuides = [
{
icon: 'scale',
title: 'Jak sprawdzić księgę wieczystą?',
text: 'Poradnik krok po kroku',
to: ROUTES.guideLaw,
},
{
icon: 'percent',
title: 'Podatek od sprzedaży mieszkania',
text: 'Kiedy trzeba zapłacić?',
to: ROUTES.guideLaw,
},
{
icon: 'paint',
title: 'Ile kosztuje remont mieszkania?',
text: 'Aktualne koszty robocizny i materiałów',
to: ROUTES.guideRenovation,
},
{
icon: 'shield',
title: 'Jak bezpiecznie wynająć mieszkanie?',
text: 'Porady dla wynajmujących',
to: ROUTES.guideRent,
},
{
icon: 'chart',
title: 'PCC przy zakupie mieszkania',
text: 'Stawki i zwolnienia w 2026 roku',
to: ROUTES.guideLaw,
},
];
@@ -6497,10 +6503,10 @@ function AccountSidebar() {
<div className="account-premium-card">
<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>
</div>
<button type="button">Sprawdź korzyści</button>
<button type="button" disabled title={SOON_HINT}>Sprawdź korzyści</button>
</div>
</aside>
);
@@ -6816,7 +6822,6 @@ function AccountDashboardPage({
</Link>
))}
</div>
<button type="button" className="account-tools-more">Zobacz wszystkie narzędzia <Icon name="arrow" /></button>
</section>
),
};
@@ -7180,23 +7185,16 @@ function AccountHelpContactPage({
text: string;
};
// Kanaly kontaktu: e-mail i telefon otwieraja program pocztowy albo dzwonia, formularz jest nizej na stronie.
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',
icon: 'mail',
iconTone: 'blue',
title: 'E-mail',
description: 'Napisz do nas, odpowiemy najszybciej jak to możliwe.',
action: 'bok@polskalokalnie.pl',
action: SUPPORT_EMAIL,
href: `mailto:${SUPPORT_EMAIL}`,
},
{
id: 'phone',
@@ -7204,7 +7202,8 @@ function AccountHelpContactPage({
iconTone: 'violet',
title: 'Telefon',
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',
@@ -7216,14 +7215,33 @@ function AccountHelpContactPage({
},
] as const;
// Odpowiedzi opisuja to, co serwis naprawde robi - kazda prowadzi do wlasciwej strony.
const faqItems = [
'Jak dodać ogłoszenie?',
'Ile kosztuje dodanie ogłoszenia?',
'Jak wyróżnić moje ogłoszenie?',
'Jak edytować lub usunąć ogłoszenie?',
'Jak działa porównywarka mieszkań?',
'Jak ustawić alerty cenowe?',
] as const;
{
question: 'Jak dodać 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.</>,
},
{
question: 'Ile kosztuje dodanie ogłoszenia?',
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 = [
{
@@ -7421,18 +7439,24 @@ function AccountHelpContactPage({
<span className={`help-contact-channel-icon ${channel.iconTone}`}><Icon name={channel.icon} /></span>
<div>
<h3>{channel.title}</h3>
{'badge' in channel && channel.badge ? <em>{channel.badge}</em> : null}
</div>
</div>
<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
type="button"
className="help-contact-channel-action"
onClick={channel.id === 'form' ? highlightContactFormForThreeSeconds : undefined}
onClick={highlightContactFormForThreeSeconds}
>
{channel.action}
<Icon name="chevron" />
</button>
)}
</article>
))}
</div>
@@ -7442,16 +7466,16 @@ function AccountHelpContactPage({
<article className="help-contact-faq-card">
<header>
<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>
<div className="help-contact-faq-list">
{faqItems.map((question) => (
<details key={question}>
{faqItems.map((item) => (
<details key={item.question}>
<summary>
<span>{question}</span>
<span>{item.question}</span>
<Icon name="chevron" />
</summary>
<p>Znajdziesz odpowiedź w naszym centrum pomocy. W razie potrzeby skorzystaj z formularza kontaktowego obok.</p>
<p>{item.answer}</p>
</details>
))}
</div>
@@ -8213,7 +8237,7 @@ function AccountPriceAlertsPage({
<header>
<div>
<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>
<Link className="price-alerts-create" to={ROUTES.buy}>
<Icon name="plus" /> Dodaj z ogłoszenia
@@ -8367,14 +8391,40 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
const [propertyTypeFilter, setPropertyTypeFilter] = useState<ComparisonPropertyFilter>('ALL');
const [search, setSearch] = useState('');
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 [shareInfo, setShareInfo] = useState('');
const [valuesGridOffset, setValuesGridOffset] = useState(0);
const summaryFirstMetricRef = useRef<HTMLElement | null>(null);
const firstValueCellRef = useRef<HTMLDivElement | null>(null);
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(() => {
if (!comparisonMountedRef.current) {
comparisonMountedRef.current = true;
return;
}
setSelectedIds([]);
setPickerValue('');
setSearch('');
@@ -8382,8 +8432,12 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
}, [offerType]);
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)));
}, [items]);
}, [items, loading]);
useEffect(() => {
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>
</div>
<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>
</div>
</div>
{shareInfo && <p className="comparison-share-info" role="status">{shareInfo}</p>}
<div className="comparison-workspace">
<aside className="comparison-summary-column">
@@ -13045,6 +13112,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<div className="ai-label">
<Icon name="spark" />
<span>Zainteresowani kupujący</span>
<em className="soon-tag">Wkrótce</em>
</div>
<strong>57 osób szuka podobnego mieszkania</strong>
<div className="avatar-row">
@@ -13053,7 +13121,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
))}
<em>+53</em>
</div>
<button>Zobacz kupujących</button>
<button type="button" disabled title={SOON_HINT}>Zobacz kupujących</button>
</aside>
<aside className="tour-card" aria-label="Wirtualny spacer">
@@ -13062,6 +13130,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<strong>Wirtualny spacer</strong>
<small>Zobacz mieszkanie w 3D</small>
</span>
<em className="soon-tag">Wkrótce</em>
</aside>
</div>
</section>
@@ -13181,13 +13250,11 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
</span>
</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>
<strong>500+ lokalizacji</strong>
<button aria-label="Zobacz wszystkie miasta">
<Icon name="arrow" />
</button>
</article>
<strong>Cała Polska na mapie</strong>
<em><Icon name="arrow" /></em>
</Link>
</div>
</section>
@@ -13248,6 +13315,16 @@ function RentPage({
const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, chips, resetAll } = filters;
const { formatPrice } = useDisplay();
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 [studentOnly, setStudentOnly] = useState(false);
const [petsOnly, setPetsOnly] = useState(false);
@@ -13387,6 +13464,7 @@ function RentPage({
cityLabel={city || 'Warszawa'}
geoMarkers={rentGeoMarkers}
onMapClick={() => setIsMapOpen(true)}
onSearchVisible={searchVisibleOnMap}
/>
</div>
@@ -13394,9 +13472,10 @@ function RentPage({
<div className="ai-label">
<Icon name="spark" />
<span>AI Dopasowanie</span>
<em className="soon-tag">Wkrótce</em>
</div>
<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" />
Dopasuj mi mieszkanie
</button>
@@ -14990,18 +15069,18 @@ function AddListingPage({ editListingId }: { editListingId?: number }) {
<div>
<span><Icon name="phone" /></span>
<p>
<strong>+48 123 456 789</strong>
<a href={SUPPORT_PHONE_HREF}>{SUPPORT_PHONE}</a>
<small>Pon. - Pt. 9:00 - 17:00</small>
</p>
</div>
<div>
<span><Icon name="mail" /></span>
<p>
<strong>pomoc@polskalokalnie.pl</strong>
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
<small>Odpowiadamy w ciągu 24h</small>
</p>
</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>
</>
)}
@@ -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() {
const navigate = useNavigate();
// 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 */ });
return () => { cancelled = true; };
}, []);
// Polecane artykuly: najczesciej czytane artykuly uzytkownikow ze wszystkich tematow.
// API zwraca artykuly per temat, wiec laczymy odpowiedzi dla kazdego tematu.
const [featuredArticles, setFeaturedArticles] = useState<ArticleCard[] | null>(null);
// Wszystkie artykuly uzytkownikow - API zwraca je per temat, wiec laczymy odpowiedzi.
// Z tej listy bierzemy polecane (najczesciej czytane) i wyniki wyszukiwania.
const [allArticles, setAllArticles] = useState<ArticleCard[] | null>(null);
useEffect(() => {
let cancelled = false;
Promise.all(GUIDE_TOPICS.map((topic) => (
@@ -16102,11 +16200,32 @@ function GuidesPage() {
)))
.then((perTopic) => {
if (!cancelled) {
setFeaturedArticles(perTopic.flat().sort((left, right) => right.views - left.views).slice(0, 4));
setAllArticles(perTopic.flat());
}
});
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 (
<section className="guides-page" aria-label="Poradniki">
<section className="guides-hero">
@@ -16116,11 +16235,16 @@ function GuidesPage() {
<div>
<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>
<div className="guides-search">
<form className="guides-search" onSubmit={submitGuideSearch}>
<Icon name="search" />
<input placeholder="O czym chcesz przeczytać?" />
<button>Szukaj</button>
</div>
<input
placeholder="O czym chcesz przeczytać?"
value={guideSearchDraft}
onChange={(event) => setGuideSearchDraft(event.target.value)}
aria-label="Szukaj w poradnikach"
/>
<button type="submit">Szukaj</button>
</form>
<div className="popular-links">
<span>Popularne:</span>
<Link to={ROUTES.creditCalculator}>Kredyt hipoteczny</Link>
@@ -16134,13 +16258,43 @@ function GuidesPage() {
<div className="guides-container guides-layout">
<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">
<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 className="guide-topic-grid">
{guideTopics.map((topic) => {
@@ -16174,23 +16328,7 @@ function GuidesPage() {
) : (
<div className="article-grid">
{(featuredArticles ?? []).map((article) => (
<Link
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>
<ArticleTile key={article.id} article={article} />
))}
</div>
)}
@@ -16236,7 +16374,7 @@ function GuidesPage() {
<h2>Szybkie poradniki</h2>
<div>
{quickGuides.map((guide) => (
<article key={guide.title}>
<Link key={guide.title} to={guide.to}>
<span>
<Icon name={guide.icon} />
</span>
@@ -16245,13 +16383,13 @@ function GuidesPage() {
<small>{guide.text}</small>
</p>
<Icon name="chevron" />
</article>
</Link>
))}
</div>
<button>
Zobacz wszystkie poradniki
<a href="#tematy-poradnikow">
Zobacz wszystkie tematy
<Icon name="arrow" />
</button>
</a>
</section>
</aside>
</div>
@@ -17034,7 +17172,7 @@ function SellPage() {
<section className="sell-section">
<h2>Zobacz opinie naszych użytkowników</h2>
<div className="testimonial-grid">
{sellTestimonials.map((testimonial, index) => (
{sellTestimonials.map((testimonial) => (
<article className="testimonial-card" key={testimonial.name}>
<div className="testimonial-head">
<span>{testimonial.name.charAt(0)}</span>
@@ -17050,15 +17188,9 @@ function SellPage() {
))}
<span>5.0</span>
</div>
{index === 0 && <button className="testimonial-nav prev">‹</button>}
{index === 2 && <button className="testimonial-nav next">›</button>}
</article>
))}
</div>
<div className="testimonial-dots">
<span />
<span />
</div>
</section>
<section className="sell-bottom-cta">
@@ -22086,7 +22218,17 @@ function RealListingsBand({
}, [sortOpen]);
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
// pusta - nie pokazujemy nic (jak dotad).
@@ -22098,7 +22240,16 @@ function RealListingsBand({
);
}
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
@@ -22155,6 +22306,13 @@ function RealListingsBand({
return (
<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">
<h3>Ogłoszenia dodane w serwisie</h3>
<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 [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);
// Miasto przekazane w adresie (np. z kafelka miasta na stronie glownej: /kupno?miasto=Warszawa)
@@ -22510,6 +22678,7 @@ function BuyPage({
cityLabel={city || 'Warszawa'}
geoMarkers={saleGeoMarkers}
onMapClick={() => setIsMapOpen(true)}
onSearchVisible={searchVisibleOnMap}
/>
</div>
@@ -22517,9 +22686,10 @@ function BuyPage({
<div className="ai-label">
<Icon name="spark" />
<span>AI Dopasowanie</span>
<em className="soon-tag">Wkrótce</em>
</div>
<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" />
Dopasuj mi mieszkanie
</button>
@@ -22591,6 +22761,7 @@ function SatelliteMap({
geoMarkers,
onMapClick,
onMarkerClick,
onSearchVisible,
size,
areaRadiusMeters,
}: {
@@ -22603,6 +22774,8 @@ function SatelliteMap({
geoMarkers?: { id: string; lat: number; lng: number; label: string; offerId?: number }[];
onMapClick?: () => 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;
// Podany promien (w metrach) zastepuje pinezke kolem - uzywany dla przyblizonej lokalizacji.
areaRadiusMeters?: number;
@@ -22790,10 +22963,21 @@ function SatelliteMap({
))}
</div>
<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 />
Szukaj na bieżącej mapie
</button>
)}
{areaCircle && (
<span
className="map-area-circle"
@@ -26602,6 +26786,14 @@ function useLiveListings(offerType: ApiOfferType, refreshMs = LIVE_LISTINGS_REFR
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).
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;
}
.all-cities button {
.all-cities em {
align-items: center;
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.8);
@@ -4153,7 +4153,7 @@ a.city-card:hover {
margin-top: 15px;
}
.quick-guides-card article {
.quick-guides-card :is(article, a) {
align-items: center;
border-bottom: 1px solid #edf1f5;
display: grid;
@@ -4162,7 +4162,7 @@ a.city-card:hover {
min-height: 64px;
}
.quick-guides-card article > span {
.quick-guides-card :is(article, a) > span {
align-items: center;
background: #edf3ff;
border-radius: 8px;
@@ -4174,17 +4174,17 @@ a.city-card:hover {
width: 34px;
}
.quick-guides-card article:nth-child(2) > span {
.quick-guides-card :is(article, a):nth-child(2) > span {
background: #f1edff;
color: #9b5cff;
}
.quick-guides-card article:nth-child(3) > span {
.quick-guides-card :is(article, a):nth-child(3) > span {
background: #fff4df;
color: #f49a31;
}
.quick-guides-card article:nth-child(4) > span {
.quick-guides-card :is(article, a):nth-child(4) > span {
background: #e9f8ef;
color: #10a15e;
}
@@ -4208,7 +4208,7 @@ a.city-card:hover {
margin-top: 3px;
}
.quick-guides-card article > svg {
.quick-guides-card :is(article, a) > svg {
color: #9aa5b4;
transform: rotate(-90deg);
}
@@ -9475,50 +9475,6 @@ a.city-card:hover {
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 {
align-items: center;
background: linear-gradient(90deg, #f8fffb 0%, #effaf4 100%);
@@ -12705,9 +12661,6 @@ a.city-card:hover {
grid-template-columns: 1fr;
}
.testimonial-nav {
display: none;
}
.services-search,
.service-mini-search {
@@ -35193,3 +35146,90 @@ a.listing-detail-secondary {
color: #0b9f5c;
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();
});
});
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);
});
});