Strona /firmy, CTA jako linki i testy Playwright routingu

Nowa trasa:
- /firmy - lista firm i uslug na istniejacych komponentach (.directory-card)
  i stylistyce sekcji uslug; filtr kategorii w query stringu (/firmy?kategoria=remonty)
- serviceCategories dostaly slug, nowa baza companyDirectory pokrywa wszystkie
  8 kategorii, wiec filtrowanie dziala na realnych danych
- "Zobacz firmy" prowadzi do listy z wybrana kategoria, "Zobacz wszystkie"
  do pelnej listy - ostatnie dwie atrapy href="#" usuniete

CTA zamienione na <Link> (wylacznie czysta nawigacja, bez akcji):
- 35 przyciskow onClick={() => navigate(x)} zamienionych automatycznie
- 5 skrotow panelu admina (setTab) -> Link do /admin/*
- karty narzedzi konta i kafelek poradnika: zamiast role="button" prawdziwy link
- pozycje menu konta i popovera powiadomien -> Link + zamkniecie popovera
- karty ofert: link rozciagniety na cala karte (.card-stretched-link) zamiast
  role="button", bez zagniezdzania przyciskow w <a>
- bez zmian zostaly przyciski wykonujace akcje: publikacja, zapis draftu,
  wyszukiwanie, oznaczanie powiadomien, bramki logowania

Wyglad 1:1 - rozszerzone selektory kontenerow o warianty <a>, reset :where(a)
o zerowej specyficznosci oraz odtworzenie centrowania tam, gdzie <button>
robil to domyslnie. Zweryfikowane zrzutami ekranu i pomiarem geometrii.

Naprawa: kazdy blad /auth/me (takze chwilowy timeout albo 5xx) kasowal token
i wylogowywal uzytkownika. Teraz sesje konczy tylko odrzucenie tokenu (401/403),
pozostale bledy sa ponawiane raz.

Testy: frontend/tests/routing.spec.ts (41 testow) + playwright.config.ts,
skrypt npm run test:e2e. E2E_BASE_URL pozwala uruchomic je na buildzie
produkcyjnym za nginx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 00:37:04 +02:00
parent e1ab61ec7c
commit 3e394998a3
9 changed files with 796 additions and 148 deletions
+250 -124
View File
@@ -1,7 +1,7 @@
import { Fragment, 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, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom';
import { ROUTES, listingPath, mapPath, negotiationPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes';
import { ROUTES, listingPath, mapPath, negotiationPath, companiesPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes';
import { ProtectedRoute } from './ProtectedRoute';
import { apiFetch, useAuth } from './auth';
import type { AuthUser, ContactPreference, PreferredLanguage } from './auth';
@@ -1510,49 +1510,60 @@ const rentListings: RentOffer[] = [
},
];
// slug trafia do adresu listy firm: /firmy?kategoria=remonty
const serviceCategories = [
{
slug: 'finanse',
icon: 'credit-card',
title: 'Finanse',
text: 'Kredyty hipoteczne, doradcy, ubezpieczenia',
},
{
slug: 'prawo',
icon: 'scale',
title: 'Prawo i dokumenty',
text: 'Notariusze, prawnicy, rzeczoznawcy',
},
{
slug: 'remonty',
icon: 'paint',
title: 'Remont i wykończenie',
text: 'Firmy remontowe, fachowcy, projekty wnętrz',
},
{
slug: 'przeprowadzka',
icon: 'truck',
title: 'Przeprowadzka',
text: 'Firmy przeprowadzkowe, magazyny, transport',
},
{
slug: 'wyposazenie',
icon: 'sofa',
title: 'Wyposażenie wnętrz',
text: 'Meble, AGD, kuchnie na wymiar, oświetlenie',
},
{
slug: 'sprzedaz',
icon: 'camera',
title: 'Sprzedaż nieruchomości',
text: 'Fotografia, home staging, wirtualne spacery 3D',
},
{
slug: 'deweloperzy',
icon: 'city',
title: 'Deweloperzy i inwestycje',
text: 'Nowe inwestycje, deweloperzy, biura sprzedaży',
},
{
slug: 'inne',
icon: 'dots',
title: 'Inne usługi',
text: 'Usługi dodatkowe związane z nieruchomościami',
},
];
type ServiceCategorySlug = (typeof serviceCategories)[number]['slug'];
const serviceCompanies = [
{
icon: 'house',
@@ -1611,6 +1622,36 @@ const serviceDirectory = [
},
];
// Pelna baza firm dla listy /firmy. Kazda pozycja ma kategorie, wiec filtrowanie
// po ?kategoria= dziala na realnych danych, a nie tylko podswietla zakladke.
const companyDirectory: Array<{
title: string;
category: ServiceCategorySlug;
rating: string;
text: string;
city: string;
image: string;
position: string;
}> = [
{ title: 'Finanse Expert', category: 'finanse', rating: '4.9 (128 opinii)', text: 'Kredyty hipoteczne i doradztwo finansowe', city: 'Warszawa, cała Polska', image: cityImage, position: '50% 40%' },
{ title: 'Kredyt Partner', category: 'finanse', rating: '4.6 (57 opinii)', text: 'Porównanie ofert kredytowych, ubezpieczenia', city: 'Gdańsk, pomorskie', image: heroImage, position: '30% 55%' },
{ title: 'Notariusz Nowak', category: 'prawo', rating: '4.9 (74 opinie)', text: 'Kancelaria notarialna, akty i umowy', city: 'Wrocław, dolnośląskie', image: heroImage, position: '62% 45%' },
{ title: 'Lex Nieruchomości', category: 'prawo', rating: '4.7 (41 opinii)', text: 'Obsługa prawna transakcji, księgi wieczyste', city: 'Warszawa, mazowieckie', image: cityImage, position: '70% 60%' },
{ title: 'Remont Masters', category: 'remonty', rating: '4.8 (96 opinii)', text: 'Kompleksowe remonty mieszkań i domów', city: 'Kraków, małopolskie', image: heroImage, position: '72% 62%' },
{ title: 'Perfect House', category: 'remonty', rating: '4.7 (64 opinie)', text: 'Wykończenia wnętrz pod klucz', city: 'Warszawa, mazowieckie', image: heroImage, position: '92% 50%' },
{ title: 'Home Finish', category: 'remonty', rating: '4.6 (52 opinie)', text: 'Remonty, wykończenia, projekty wnętrz', city: 'Wrocław, dolnośląskie', image: heroImage, position: '48% 58%' },
{ title: 'SuperPrzeprowadzki', category: 'przeprowadzka', rating: '4.7 (111 opinii)', text: 'Przeprowadzki lokalne i krajowe', city: 'Poznań, wielkopolskie', image: cityImage, position: '40% 50%' },
{ title: 'Transport 24', category: 'przeprowadzka', rating: '4.5 (38 opinii)', text: 'Transport mebli, magazynowanie, pakowanie', city: 'Łódź, łódzkie', image: heroImage, position: '20% 70%' },
{ title: 'Kuchnie na Wymiar', category: 'wyposazenie', rating: '4.8 (83 opinie)', text: 'Meble kuchenne i zabudowy na wymiar', city: 'Katowice, śląskie', image: heroImage, position: '85% 40%' },
{ title: 'Salon Oświetlenia Lumo', category: 'wyposazenie', rating: '4.4 (29 opinii)', text: 'Oświetlenie, AGD, wyposażenie wnętrz', city: 'Szczecin, zachodniopomorskie', image: cityImage, position: '60% 35%' },
{ title: 'FotoNieruchomości', category: 'sprzedaz', rating: '4.9 (67 opinii)', text: 'Fotografia ofert, home staging, spacery 3D', city: 'Warszawa, cała Polska', image: cityImage, position: '35% 65%' },
{ title: 'Studio Wirtualnych Spacerów', category: 'sprzedaz', rating: '4.7 (45 opinii)', text: 'Wirtualne spacery 3D i plany mieszkań', city: 'Kraków, małopolskie', image: heroImage, position: '55% 48%' },
{ title: 'Nowa Inwestycja Development', category: 'deweloperzy', rating: '4.6 (58 opinii)', text: 'Nowe inwestycje mieszkaniowe, biuro sprzedaży', city: 'Gdynia, pomorskie', image: cityImage, position: '80% 55%' },
{ title: 'Osiedle Park Deweloper', category: 'deweloperzy', rating: '4.5 (34 opinie)', text: 'Mieszkania z rynku pierwotnego', city: 'Wrocław, dolnośląskie', image: heroImage, position: '45% 42%' },
{ title: 'Serwis Domowy Fix', category: 'inne', rating: '4.6 (72 opinie)', text: 'Drobne naprawy, złota rączka, przeglądy', city: 'Cała Polska', image: heroImage, position: '68% 68%' },
{ title: 'Czysto i Gotowe', category: 'inne', rating: '4.8 (91 opinii)', text: 'Sprzątanie po remoncie i przed wprowadzeniem', city: 'Warszawa, mazowieckie', image: cityImage, position: '25% 45%' },
];
const serviceSteps = [
{
icon: 'group',
@@ -3431,6 +3472,7 @@ function App() {
<Route path={ROUTES.districtRanking} element={<DistrictRankingPage />} />
<Route path={ROUTES.negotiation} element={<NegotiationRoute />} />
<Route path={ROUTES.services} element={<ServicesPage />} />
<Route path={ROUTES.companies} element={<CompaniesPage />} />
<Route path={ROUTES.guides} element={<GuidesPage />} />
<Route path={ROUTES.guideBuying} element={<GuideBuyingPage />} />
@@ -3853,16 +3895,12 @@ function AdminMessageModal({ user, onClose }: { user: AuthUser; onClose: () => v
}
function AdminPage() {
const navigate = useNavigate();
const { user } = useAuth();
// Aktywna zakladka wynika z adresu (/admin/leady), nie ze stanu komponentu -
// dzieki temu kazda sekcja panelu ma wlasny URL, dziala F5 i przycisk Wstecz.
const adminSegment = useParams()['*'] ?? '';
const tab: AdminTab = ADMIN_PATH_TO_TAB[adminSegment] ?? 'dashboard';
const isUnknownAdminSection = adminSegment !== '' && !(adminSegment in ADMIN_PATH_TO_TAB);
const setTab = useCallback((next: AdminTab) => {
navigate(adminTabPath(next));
}, [navigate]);
const [users, setUsers] = useState<AuthUser[]>([]);
const [listings, setListings] = useState<AdminListing[]>([]);
const [reports, setReports] = useState<AdminListingReport[]>([]);
@@ -4248,9 +4286,9 @@ function AdminPage() {
<button type="button" onClick={loadData}>Odśwież status <Icon name="arrow" /></button>
</div>
<button type="button" className="admin-back" onClick={() => navigate(ROUTES.home)}>
<Link className="admin-back" to={ROUTES.home}>
<Icon name="arrow" /> Wróć do strony głównej
</button>
</Link>
</aside>
<div className="admin-main">
@@ -4311,7 +4349,7 @@ function AdminPage() {
<section className="admin-card admin-recent-listings">
<div className="admin-card-head">
<h2>Ostatnie ogłoszenia</h2>
<button type="button" onClick={() => setTab('listings')}>Zobacz wszystkie <Icon name="arrow" /></button>
<Link to={adminTabPath('listings')}>Zobacz wszystkie <Icon name="arrow" /></Link>
</div>
{recentListings.length === 0 ? (
<p className="admin-empty">Brak ogłoszeń do wyświetlenia.</p>
@@ -4341,7 +4379,7 @@ function AdminPage() {
<section className="admin-card admin-recent-users">
<div className="admin-card-head">
<h2>Aktywność użytkowników</h2>
<button type="button" onClick={() => setTab('users')}>Zobacz wszystkich <Icon name="arrow" /></button>
<Link to={adminTabPath('users')}>Zobacz wszystkich <Icon name="arrow" /></Link>
</div>
{recentUsers.length === 0 ? (
<p className="admin-empty">Brak użytkowników do wyświetlenia.</p>
@@ -4367,10 +4405,10 @@ function AdminPage() {
<h2>Szybkie akcje</h2>
</div>
<div className="admin-quick-actions-grid">
<button type="button" onClick={() => navigate(ROUTES.add)}><Icon name="document" /> Dodaj nowe ogłoszenie <Icon name="arrow" /></button>
<button type="button" onClick={() => setTab('categories')}><Icon name="list" /> Zarządzaj kategoriami <Icon name="arrow" /></button>
<button type="button" onClick={() => setTab('locations')}><Icon name="pin" /> Zarządzaj lokalizacjami <Icon name="arrow" /></button>
<button type="button" onClick={() => setTab('promotions')}><Icon name="spark" /> Zarządzaj banerami <Icon name="arrow" /></button>
<Link to={ROUTES.add}><Icon name="document" /> Dodaj nowe ogłoszenie <Icon name="arrow" /></Link>
<Link to={adminTabPath('categories')}><Icon name="list" /> Zarządzaj kategoriami <Icon name="arrow" /></Link>
<Link to={adminTabPath('locations')}><Icon name="pin" /> Zarządzaj lokalizacjami <Icon name="arrow" /></Link>
<Link to={adminTabPath('promotions')}><Icon name="spark" /> Zarządzaj banerami <Icon name="arrow" /></Link>
</div>
</section>
</div>
@@ -5414,7 +5452,7 @@ function NegotiationPage({
<p>Nasi eksperci pomogą Ci przejść przez proces zakupu i wynegocjować najlepszą cenę.</p>
</div>
<div className="negotiation-cta-action">
<button type="button" onClick={() => navigate(ROUTES.services)}>Skontaktuj się z ekspertem</button>
<Link to={ROUTES.services}>Skontaktuj się z ekspertem</Link>
<small>Pierwsza konsultacja gratis</small>
</div>
</section>
@@ -5825,7 +5863,7 @@ function AccountDashboardPage({
<section className="account-panel" key="searches">
<div className="account-panel-head">
<h2>Moje wyszukiwania</h2>
<button type="button" onClick={() => navigate(ROUTES.accountSearches)}>Zobacz wszystkie <Icon name="arrow" /></button>
<Link to={ROUTES.accountSearches}>Zobacz wszystkie <Icon name="arrow" /></Link>
</div>
<div className="account-searches">
{savedSearches.slice(0, 4).map((row) => (
@@ -5852,7 +5890,7 @@ function AccountDashboardPage({
</article>
))}
{savedSearches.length === 0 && <p className="account-searches-empty">Nie masz jeszcze zapisanych wyszukiwań. Wejdź w Kup lub Wynajem i kliknij dzwonek „Zapisz wyszukiwanie”.</p>}
<button type="button" className="account-new-search" onClick={() => navigate(ROUTES.buy)}>Utwórz nowe wyszukiwanie <Icon name="plus" /></button>
<Link className="account-new-search" to={ROUTES.buy}>Utwórz nowe wyszukiwanie <Icon name="plus" /></Link>
</div>
</section>
),
@@ -5863,24 +5901,14 @@ function AccountDashboardPage({
</div>
<div className="account-tools">
{tools.map((tool) => (
<article
key={tool.title}
role="button"
tabIndex={0}
onClick={() => navigate(tool.path)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(tool.path);
}
}}
>
// Kafelek prowadzi na inna strone, wiec jest linkiem - bez protezy role="button".
<Link key={tool.title} to={tool.path}>
<span><Icon name={tool.icon} /></span>
<div>
<strong>{tool.title}</strong>
<small>{tool.text}</small>
</div>
</article>
</Link>
))}
</div>
<button type="button" className="account-tools-more">Zobacz wszystkie narzędzia <Icon name="arrow" /></button>
@@ -6103,7 +6131,7 @@ function AccountSavedSearchesPage({
<h1>Zapisane wyszukiwania</h1>
<p>Wróć do swoich filtrów jednym kliknięciem i przejdź od razu do odpowiedniej listy ofert.</p>
</div>
<button type="button" onClick={() => navigate(ROUTES.buy)}>Dodaj nowe <Icon name="plus" /></button>
<Link to={ROUTES.buy}>Dodaj nowe <Icon name="plus" /></Link>
</div>
<div className="account-searches-stats" aria-label="Statystyki zapisanych wyszukiwań">
@@ -6199,9 +6227,9 @@ function AccountSavedSearchesPage({
<div className="account-searches-empty-state">
<strong>Nie masz jeszcze zapisanych wyszukiwań</strong>
<p>Ustaw filtry w Kup lub Wynajem i kliknij dzwonek „Zapisz wyszukiwanie”, a pojawią się tutaj.</p>
<button type="button" className="account-empty-add-button" onClick={() => navigate(ROUTES.buy)}>
<Link className="account-empty-add-button" to={ROUTES.buy}>
<Icon name="search" /> Przejdź do wyszukiwania
</button>
</Link>
</div>
)}
@@ -6834,7 +6862,7 @@ function AccountPriceAlertsPage({
</div>
)}
</div>
<button type="button" className="price-alerts-link" onClick={() => navigate(ROUTES.priceHistory)}>Dodaj kolejne lokalizacje</button>
<Link className="price-alerts-link" to={ROUTES.priceHistory}>Dodaj kolejne lokalizacje</Link>
</article>
<article className="price-alerts-card price-alerts-active-list">
@@ -6851,16 +6879,16 @@ function AccountPriceAlertsPage({
<span>Usuń</span>
</button>
)) : (
<button type="button" onClick={() => navigate(ROUTES.priceHistory)}>
<Link to={ROUTES.priceHistory}>
<div>
<strong>Brak aktywnych alertów</strong>
<small>Przejdź do historii cen i dodaj pierwszą lokalizację.</small>
</div>
<span>Dodaj</span>
</button>
</Link>
)}
</div>
<button type="button" className="price-alerts-manage" onClick={() => navigate(ROUTES.priceHistory)}>Zarządzaj alertami</button>
<Link className="price-alerts-manage" to={ROUTES.priceHistory}>Zarządzaj alertami</Link>
</article>
</section>
</div>
@@ -8913,7 +8941,7 @@ function AccountSettingsPage() {
<strong>{getDisplayName(user)}</strong>
<small>Konto Premium</small>
</div>
<button type="button" onClick={() => navigate(ROUTES.accountProfileEdit)}>Edytuj</button>
<Link to={ROUTES.accountProfileEdit}>Edytuj</Link>
</div>
<div className="settings-list">
<article><span><Icon name="mail" /></span><em>Adres e-mail</em><strong>{user?.email}</strong><Icon name="chevron" /></article>
@@ -9851,7 +9879,7 @@ function AccountProfileEditPage() {
</article>
{coverPhotoName && (
<div className="cover-photo-actions-row">
<button type="button" className="cover-preview-button" onClick={() => navigate(ROUTES.accountPublicProfile)}>Podgląd profilu</button>
<Link className="cover-preview-button" to={ROUTES.accountPublicProfile}>Podgląd profilu</Link>
<button type="button" className="cover-remove-button" onClick={clearCoverPhoto}>Usuń zdjęcie</button>
</div>
)}
@@ -9905,9 +9933,9 @@ function AccountProfileEditPage() {
{profileVisibility === 'publiczna' && 'Zobacz jak inni widzą Twój profil.'}
{profileVisibility === 'kontakty' && 'Profil widzą tylko zaakceptowane kontakty.'}
</strong>
<button type="button" className="mini-tag profile-preview-cta" onClick={() => navigate(ROUTES.accountPublicProfile)}>
<Link className="mini-tag profile-preview-cta" to={ROUTES.accountPublicProfile}>
Zobacz profil <Icon name="arrow" />
</button>
</Link>
</article>
</div>
</section>
@@ -10191,7 +10219,7 @@ function AccountPublicProfilePage() {
<h1>Jak inni widzą Twój profil</h1>
<p>Zobacz, jak Twój profil prezentuje się publicznie dla innych użytkowników Mieszko.</p>
</div>
<button type="button" onClick={() => navigate(ROUTES.accountProfileEdit)}><Icon name="gear" /> Edytuj profil</button>
<Link to={ROUTES.accountProfileEdit}><Icon name="gear" /> Edytuj profil</Link>
</header>
<section className="public-profile-cover-card">
@@ -10955,10 +10983,10 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<section className="search-wrap" aria-label="Wyszukiwarka">
<div className="search-tabs">
<button className="active" onClick={() => navigate(ROUTES.buy)}>
<Link className="active" to={ROUTES.buy}>
Kup
</button>
<button onClick={() => navigate(ROUTES.rent)}>Wynajem</button>
</Link>
<Link to={ROUTES.rent}>Wynajem</Link>
</div>
<div className="search-panel search-panel-mega">
@@ -10979,7 +11007,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<h2>{card.title}</h2>
<p>{card.text}</p>
</div>
<button type="button" onClick={() => navigate(card.path)}>{card.action}</button>
<Link to={card.path}>{card.action}</Link>
</article>
))}
</div>
@@ -12372,13 +12400,13 @@ function AddListingPage() {
znajdziesz je w „Moich ogłoszeniach” ze statusem „Oczekuje”.
</p>
<div className="publish-success-actions">
<button className="next-step-button" type="button" onClick={() => navigate(ROUTES.accountListings)}>
<Link className="next-step-button" to={ROUTES.accountListings}>
Przejdź do moich ogłoszeń
<Icon name="arrow" />
</button>
<button className="back-step-button" type="button" onClick={() => navigate(ROUTES.home)}>
</Link>
<Link className="back-step-button" to={ROUTES.home}>
Wróć do strony głównej
</button>
</Link>
</div>
</div>
) : (
@@ -12728,10 +12756,10 @@ function GuideBuyingPage() {
<img src={heroImage} alt="" />
<div className="guide-hero-shade" />
<div className="guides-container guide-hero-inner">
<button className="guide-back" onClick={() => navigate(ROUTES.guides)}>
<Link className="guide-back" to={ROUTES.guides}>
<Icon name="arrow" />
<span>Wróć do poradników</span>
</button>
</Link>
<span className="guide-hero-badge">
<Icon name="house" />
Kupno mieszkania
@@ -13163,10 +13191,10 @@ function GuideBuyingPage() {
<h2>Gotowy, by znaleźć swoje mieszkanie?</h2>
<p>Przeglądaj aktualne oferty dopasowane do Twoich potrzeb i budżetu.</p>
</div>
<button onClick={() => navigate(ROUTES.buy)}>
<Link to={ROUTES.buy}>
Zobacz oferty mieszkań
<Icon name="arrow" />
</button>
</Link>
</section>
</div>
@@ -13174,22 +13202,22 @@ function GuideBuyingPage() {
<section className="guide-aside-card">
<h3><Icon name="calculator" /> Kalkulatory</h3>
<div className="guide-aside-list">
<button onClick={() => navigate(ROUTES.creditCalculator)}>
<Link to={ROUTES.creditCalculator}>
<span><strong>Kalkulator zdolności kredytowej</strong><small>Sprawdź, na jaki kredyt Cię stać</small></span>
<Icon name="chevron" />
</button>
<button onClick={() => navigate(ROUTES.valuation)}>
</Link>
<Link to={ROUTES.valuation}>
<span><strong>Kalkulator wyceny mieszkania</strong><small>Oszacuj wartość nieruchomości</small></span>
<Icon name="chevron" />
</button>
<button onClick={() => navigate(ROUTES.priceHistory)}>
</Link>
<Link to={ROUTES.priceHistory}>
<span><strong>Historia cen</strong><small>Zobacz, jak zmieniały się ceny</small></span>
<Icon name="chevron" />
</button>
<button onClick={() => navigate(ROUTES.districtRanking)}>
</Link>
<Link to={ROUTES.districtRanking}>
<span><strong>Ranking dzielnic</strong><small>Porównaj lokalizacje</small></span>
<Icon name="chevron" />
</button>
</Link>
</div>
</section>
@@ -13261,30 +13289,25 @@ function GuidesPage() {
<div className="guide-topic-grid">
{guideTopics.map((topic) => {
const isBuying = topic.title === 'Kupno mieszkania';
return (
<article
className={`guide-topic-card${isBuying ? ' is-clickable' : ''}`}
key={topic.title}
role={isBuying ? 'button' : undefined}
tabIndex={isBuying ? 0 : undefined}
onClick={isBuying ? () => navigate(ROUTES.guideBuying) : undefined}
onKeyDown={
isBuying
? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
navigate(ROUTES.guideBuying);
}
}
: undefined
}
>
// Klikalny kafelek to link do poradnika; pozostale nie maja jeszcze tresci.
const content = (
<>
<span className={`guide-topic-icon ${topic.icon}`}>
<Icon name={topic.icon} />
</span>
<h3>{topic.title}</h3>
<p>{topic.text}</p>
<small>{topic.count}</small>
</>
);
return isBuying ? (
<Link className="guide-topic-card is-clickable" key={topic.title} to={ROUTES.guideBuying}>
{content}
</Link>
) : (
<article className="guide-topic-card" key={topic.title}>
{content}
</article>
);
})}
@@ -13418,7 +13441,7 @@ function SellPage() {
<span><Icon name="spark" /> Pełne wsparcie</span>
</div>
<button className="sell-primary" onClick={() => navigate(ROUTES.add)}>Dodaj ogłoszenie za darmo</button>
<Link className="sell-primary" to={ROUTES.add}>Dodaj ogłoszenie za darmo</Link>
<small>Zajmie Ci to tylko 2 minuty</small>
</div>
</div>
@@ -13462,7 +13485,7 @@ function SellPage() {
<h2>Sprawdź wartość swojego mieszkania</h2>
<p>Bezpłatna wycena online w 30 sekund</p>
<div className="valuation-input">Podaj adres mieszkania</div>
<button onClick={() => navigate(ROUTES.valuation)}>Wyceń mieszkanie</button>
<Link to={ROUTES.valuation}>Wyceń mieszkanie</Link>
<div className="valuation-result">
<span>Przykładowa wycena</span>
<small>Kraków, Zabłocie</small>
@@ -13545,7 +13568,7 @@ function SellPage() {
<strong>Gotowy na szybką i bezpieczną sprzedaż?</strong>
<span>Dołącz do tysięcy osób, które już sprzedały z nami swoje mieszkanie.</span>
</div>
<button onClick={() => navigate(ROUTES.add)}>Dodaj ogłoszenie za darmo</button>
<Link to={ROUTES.add}>Dodaj ogłoszenie za darmo</Link>
<small>Zajmie Ci to tylko 2 minuty</small>
</section>
</div>
@@ -15123,7 +15146,7 @@ function ValuationPage() {
<div className="valuation-result-actions">
<button type="button" className="valuation-back" onClick={() => setStep(2)}><Icon name="arrow" /> Wróć</button>
<button type="button" className="valuation-back" disabled={!valuation.ready} onClick={() => downloadValuationReportPdf(valuationForm, valuation, valuationConfidenceLabel)}><Icon name="document" /> Pobierz raport (PDF)</button>
<button type="button" className="valuation-next" onClick={() => navigate(ROUTES.add)}>Rozpocznij sprzedaż <Icon name="arrow" /></button>
<Link className="valuation-next" to={ROUTES.add}>Rozpocznij sprzedaż <Icon name="arrow" /></Link>
</div>
</section>
</div>
@@ -15454,6 +15477,121 @@ function ValuationPage() {
);
}
// Lista firm i usług pod /firmy. Kategoria pochodzi z query stringa (?kategoria=remonty),
// więc widok da się skopiować, odświeżyć i otworzyć w nowej karcie.
function CompaniesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const categoryParam = searchParams.get('kategoria');
const activeCategory = serviceCategories.find((item) => item.slug === categoryParam) ?? null;
const companies = activeCategory
? companyDirectory.filter((company) => company.category === activeCategory.slug)
: companyDirectory;
// Zmiana filtra to zmiana adresu - replace, zeby nie zasmiecac historii przy przelaczaniu.
const selectCategory = (slug: string | null) => {
setSearchParams(slug ? { kategoria: slug } : {}, { replace: true });
};
return (
<section className="services-page companies-page" aria-label="Firmy i usługi">
<div className="services-frame companies-frame">
<div className="services-main">
<div className="breadcrumb">
<Link to={ROUTES.home}>Strona główna</Link>
<Icon name="chevron" />
<Link to={ROUTES.services}>Firmy i usługi</Link>
<Icon name="chevron" />
<strong>{activeCategory ? activeCategory.title : 'Wszystkie firmy'}</strong>
</div>
<section className="companies-head">
<h1>{activeCategory ? activeCategory.title : 'Wszystkie firmy i usługi'}</h1>
<p>
{activeCategory
? activeCategory.text
: 'Sprawdzone firmy i specjaliści z całej Polski. Porównaj oferty, oceny i opinie klientów.'}
</p>
<span className="companies-count">
{companies.length === 1 ? '1 firma' : `${companies.length} firm`}
{activeCategory ? ` w kategorii „${activeCategory.title}”` : ' w bazie'}
</span>
</section>
<nav className="companies-filters" aria-label="Filtr kategorii">
<button
type="button"
className={activeCategory ? 'companies-chip' : 'companies-chip active'}
onClick={() => selectCategory(null)}
>
Wszystkie <span>{companyDirectory.length}</span>
</button>
{serviceCategories.map((category) => {
const count = companyDirectory.filter((company) => company.category === category.slug).length;
return (
<button
key={category.slug}
type="button"
className={activeCategory?.slug === category.slug ? 'companies-chip active' : 'companies-chip'}
onClick={() => selectCategory(category.slug)}
>
<Icon name={category.icon} /> {category.title} <span>{count}</span>
</button>
);
})}
</nav>
<section className="services-section">
<div className="service-mini-search">
<SearchField icon="pin" label="Lokalizacja" value="Cała Polska" />
<SearchField icon="chevron" label="Usługa" value={activeCategory ? activeCategory.title : 'Wszystkie'} />
<SearchField icon="chevron" label="Sortuj według" value="Polecane" />
<button><Icon name="list" /> Filtry</button>
</div>
<div className="directory-list companies-list">
{companies.map((company) => (
<article className="directory-card" key={company.title}>
<div
className="directory-photo"
style={{ backgroundImage: `url(${company.image})`, backgroundPosition: company.position }}
/>
<div>
<h3>{company.title}</h3>
<p><Icon name="star" /> {company.rating}</p>
<span>{company.text}</span>
<small><Icon name="pin" /> {company.city}</small>
<em><Icon name="check" /> Zweryfikowana firma</em>
</div>
<button>Zobacz profil</button>
<Icon name="heart" />
</article>
))}
</div>
</section>
<section className="services-section service-steps-section">
<h2>Jak to działa?</h2>
<div className="service-steps">
{serviceSteps.map((step) => (
<article className="service-step" key={step.title}>
<span>
<Icon name={step.icon} />
</span>
<div>
<h3>{step.title}</h3>
<p>{step.text}</p>
</div>
</article>
))}
</div>
</section>
</div>
</div>
</section>
);
}
function ServicesPage() {
return (
<section className="services-page" aria-label="Firmy i usługi">
@@ -15491,7 +15629,7 @@ function ServicesPage() {
</span>
<h3>{category.title}</h3>
<p>{category.text}</p>
<a href="#">Zobacz firmy <Icon name="arrow" /></a>
<Link to={companiesPath(category.slug)}>Zobacz firmy <Icon name="arrow" /></Link>
</article>
))}
</div>
@@ -15500,7 +15638,7 @@ function ServicesPage() {
<section className="services-section">
<div className="section-head">
<h2>Polecane firmy</h2>
<a href="#">Zobacz wszystkie <Icon name="arrow" /></a>
<Link to={ROUTES.companies}>Zobacz wszystkie <Icon name="arrow" /></Link>
</div>
<div className="recommended-company-grid">
{serviceCompanies.map((company) => (
@@ -17137,14 +17275,10 @@ function RealListingsBand({
item.yearBuilt ? `${item.yearBuilt} r.` : null,
].filter(Boolean).join(' • ');
return (
<article
key={item.id}
className="result-card real-result-card"
role="button"
tabIndex={0}
onClick={() => onOpenListing(item.id)}
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onOpenListing(item.id); } }}
>
// Karta prowadzi do stron oferty. Tytul jest linkiem rozciagnietym na cala
// karte (::after), dzieki czemu mamy prawdziwy <a href> - do skopiowania i
// otwarcia w nowej karcie - bez zagniezdzania przyciskow w <a>.
<article key={item.id} className="result-card real-result-card">
<div className="result-photo" style={{ backgroundImage: `url(${item.coverPhoto ?? heroImage})` }}>
{offerType === 'SALE' && (
<div className="real-photo-badges">
@@ -17166,7 +17300,7 @@ function RealListingsBand({
</div>
<div className="result-info">
<h3>{item.title}</h3>
<h3><Link className="card-stretched-link" to={listingPath(item.id)}>{item.title}</Link></h3>
<p>{[item.city, item.district].filter(Boolean).join(', ')}</p>
<small>{meta}</small>
</div>
@@ -17174,9 +17308,9 @@ function RealListingsBand({
<div className="result-side">
<strong>{formatPln(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''}</strong>
<span>{listingRelativeDate(item.createdAt)}</span>
<button type="button" className="real-card-cta" onClick={(event) => { event.stopPropagation(); onOpenListing(item.id); }}>
<Link className="real-card-cta" to={listingPath(item.id)}>
Zobacz ogłoszenie
</button>
</Link>
</div>
</article>
);
@@ -18353,10 +18487,10 @@ function MapSearchPage({ initialCity, offerType }: { initialCity: string; offerT
return (
<section className="map-search-page" aria-label="Mapa ofert">
<div className="map-search-bar">
<button type="button" className="map-back" onClick={() => navigate(isRentMode ? ROUTES.rent : ROUTES.buy)}>
<Link className="map-back" to={isRentMode ? ROUTES.rent : ROUTES.buy}>
<Icon name="chevron" />
<span>Wróć do listy</span>
</button>
</Link>
<div className="map-field map-field-city">
<Icon name="search" />
@@ -18558,10 +18692,8 @@ function Header({
};
}, [isAccountMenuOpen]);
const goToAccountSection = (targetPath: string) => {
setIsAccountMenuOpen(false);
navigate(targetPath);
};
// Pozycje menu sa linkami; zamkniecie popovera to efekt uboczny kliknięcia, nie nawigacja.
const closeAccountMenu = () => setIsAccountMenuOpen(false);
return (
<header className={isAdmin ? 'header header-admin' : 'header'}>
@@ -18627,15 +18759,9 @@ function Header({
<section className="notification-popover" aria-label="Powiadomienia">
<div className="notification-popover-head">
<h3>Powiadomienia</h3>
<button
type="button"
onClick={() => {
setIsNotificationsOpen(false);
navigate(ROUTES.notifications);
}}
>
<Link to={ROUTES.notifications} onClick={() => setIsNotificationsOpen(false)}>
Zobacz wszystkie
</button>
</Link>
</div>
<div className="notification-popover-list">
@@ -18697,15 +18823,15 @@ function Header({
{isAccountMenuOpen && (
<div className="account-popover" role="menu" aria-label="Menu konta">
<button type="button" role="menuitem" onClick={() => goToAccountSection(ROUTES.account)}>
<Link role="menuitem" to={ROUTES.account} onClick={closeAccountMenu}>
<Icon name="house" /> Panel główny
</button>
<button type="button" role="menuitem" onClick={() => goToAccountSection(ROUTES.accountListings)}>
</Link>
<Link role="menuitem" to={ROUTES.accountListings} onClick={closeAccountMenu}>
<Icon name="document" /> Moje oferty
</button>
<button type="button" role="menuitem" onClick={() => goToAccountSection(ROUTES.accountSettings)}>
</Link>
<Link role="menuitem" to={ROUTES.accountSettings} onClick={closeAccountMenu}>
<Icon name="gear" /> Dane i ustawienia
</button>
</Link>
<button
type="button"
role="menuitem"
@@ -20303,7 +20429,7 @@ function FavoritesPage({
<div className="favorite-content">
<h3>Brak ogłoszeń w tej kategorii</h3>
<p>{searchQuery || activeFiltersCount > 0 ? 'Zmień filtry lub frazę wyszukiwania, aby zobaczyć więcej ofert.' : 'Dodaj nowe oferty do ulubionych, aby pojawiły się na tej liście.'}</p>
<button type="button" className="favorite-empty-cta" onClick={() => navigate(activeTab === 'buy' ? ROUTES.buy : ROUTES.rent)}><Icon name="plus" /> {activeTab === 'buy' ? 'Przejdź do ofert Kupuję' : 'Przejdź do ofert Wynajmuję'}</button>
<Link className="favorite-empty-cta" to={activeTab === 'buy' ? ROUTES.buy : ROUTES.rent}><Icon name="plus" /> {activeTab === 'buy' ? 'Przejdź do ofert Kupuję' : 'Przejdź do ofert Wynajmuję'}</Link>
</div>
</article>
)}
@@ -20885,9 +21011,9 @@ function ListingDetailPage({
<section className="listing-detail-page">
<div className="listing-detail-state">
<p>{error ?? 'Nie znaleziono ogłoszenia.'}</p>
<button type="button" className="listing-detail-back" onClick={() => navigate(ROUTES.buy)}>
<Link className="listing-detail-back" to={ROUTES.buy}>
<Icon name="arrow" /> Wróć do ofert
</button>
</Link>
</div>
</section>
);
@@ -21158,11 +21284,11 @@ function ListingDetailPage({
return (
<section className="listing-detail-page">
<div className="listing-detail-crumbs">
<button type="button" onClick={() => navigate(ROUTES.home)}>Panel główny</button>
<Link to={ROUTES.home}>Panel główny</Link>
<span>›</span>
<button type="button" onClick={() => navigate(listing.offerType === 'RENT' ? ROUTES.rent : ROUTES.buy)}>
<Link to={listing.offerType === 'RENT' ? ROUTES.rent : ROUTES.buy}>
{listing.offerType === 'RENT' ? 'Wynajmuję' : 'Kupuję'}
</button>
</Link>
<span>›</span>
<strong>{listing.title}</strong>
</div>
@@ -21349,9 +21475,9 @@ function ListingDetailPage({
</div>
</div>
<button type="button" className="listing-detail-profile-btn" onClick={() => navigate(ROUTES.accountPublicProfile)}>
<Link className="listing-detail-profile-btn" to={ROUTES.accountPublicProfile}>
Wyświetl profil
</button>
</Link>
<div className="listing-detail-share">
<strong>Udostępnij ofertę</strong>