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
+2
View File
@@ -10,6 +10,8 @@ backend/target/
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/test-results/
frontend/playwright-report/
# IDE
.idea/
+64
View File
@@ -15,6 +15,7 @@
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
@@ -109,6 +110,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -991,6 +1008,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
+3 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
"preview": "vite preview --host 0.0.0.0",
"test:e2e": "playwright test"
},
"dependencies": {
"@types/pdfmake": "^0.3.3",
@@ -16,6 +17,7 @@
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from '@playwright/test';
// Testy routingu uruchamiamy na systemowym Chrome (bez pobierania przegladarek Playwright).
// Domyslnie startujemy wlasny dev server; ustaw E2E_BASE_URL, zeby przetestowac
// build produkcyjny za nginx, np. E2E_BASE_URL=http://localhost npm run test:e2e
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: {
baseURL,
channel: 'chrome',
viewport: { width: 1680, height: 1050 },
actionTimeout: 15_000,
},
webServer: process.env.E2E_BASE_URL
? undefined
: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 120_000,
},
});
+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>
+27 -6
View File
@@ -104,21 +104,42 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
let cancelled = false;
setLoading(true);
apiFetch<AuthUser>('/auth/me')
.then((me) => {
// Sesje konczymy wylacznie wtedy, gdy backend odrzuci token (401/403).
// Chwilowy blad sieci albo 5xx nie moze wylogowywac uzytkownika - taki blad
// ponawiamy raz, a token zostaje, wiec kolejne wejscie na strone go odzyska.
const loadProfile = async (canRetry: boolean): Promise<void> => {
try {
const me = await apiFetch<AuthUser>('/auth/me');
if (!cancelled) {
setUser(me);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
} catch (error) {
if (cancelled) {
return;
}
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
setLoading(false);
return;
}
});
if (canRetry) {
await new Promise((resolve) => { window.setTimeout(resolve, 400); });
if (!cancelled) {
await loadProfile(false);
}
return;
}
setUser(null);
setLoading(false);
}
};
void loadProfile(true);
return () => {
cancelled = true;
};
+7
View File
@@ -12,6 +12,7 @@ export const ROUTES = {
comparison: '/konto/porownanie',
add: '/dodaj-ogloszenie',
services: '/firmy-i-uslugi',
companies: '/firmy',
guides: '/poradniki',
guideBuying: '/poradniki/kupno-mieszkania',
creditCalculator: '/kalkulator-zdolnosci',
@@ -59,6 +60,12 @@ export function mapPath(city?: string, offerType?: 'SALE' | 'RENT'): string {
return query ? `${ROUTES.map}?${query}` : ROUTES.map;
}
// Lista firm i usług: kategoria w query stringu, dzięki czemu widok da się
// skopiować, odświeżyć i otworzyć w nowej karcie.
export function companiesPath(categorySlug?: string): string {
return categorySlug ? `${ROUTES.companies}?kategoria=${encodeURIComponent(categorySlug)}` : ROUTES.companies;
}
// Zakładki panelu administratora jako podścieżki /admin/*.
export const ADMIN_TAB_PATHS = {
dashboard: '',
+165 -17
View File
@@ -589,7 +589,7 @@ svg {
width: max-content;
}
.search-tabs button {
.search-tabs button, .search-tabs a {
background: rgba(255, 255, 255, 0.88);
border: 0;
color: #657284;
@@ -600,11 +600,11 @@ svg {
padding: 10px 20px;
}
.search-tabs button:first-child {
.search-tabs button:first-child, .search-tabs a:first-child {
border-radius: 6px 0 0 0;
}
.search-tabs button:last-child {
.search-tabs button:last-child, .search-tabs a:last-child {
border-radius: 0 6px 0 0;
}
@@ -798,7 +798,7 @@ svg {
font-size: 12px;
}
.tool-card button {
.tool-card button, .tool-card a {
align-self: end;
background: #f8fafc;
border: 1px solid #e2e8ef;
@@ -3183,7 +3183,7 @@ svg {
margin-top: 6px;
}
.result-side button {
.result-side button, .result-side a {
align-self: end;
background: #0b9f5c;
border: 0;
@@ -5922,7 +5922,7 @@ svg {
padding: 15px 16px;
}
.sell-valuation-card button {
.sell-valuation-card button, .sell-valuation-card a {
background: #0b9f5c;
border: 0;
border-radius: 6px;
@@ -8331,7 +8331,7 @@ svg {
margin-top: 3px;
}
.sell-bottom-cta button {
.sell-bottom-cta button, .sell-bottom-cta a {
background: #0b9f5c;
border: 0;
border-radius: 6px;
@@ -11417,7 +11417,7 @@ svg {
width: 100%;
}
.search-tabs button {
.search-tabs button, .search-tabs a {
flex: 1;
min-width: 0;
padding-left: 10px;
@@ -18271,7 +18271,7 @@ svg {
gap: 8px;
}
.price-alerts-active-items button {
.price-alerts-active-items button, .price-alerts-active-items a {
align-items: center;
background: #fbfcfe;
border: 1px solid #e2eaf3;
@@ -18405,7 +18405,7 @@ svg {
}
.settings-card-head.split > button,
.settings-profile-row > button {
.settings-profile-row > button, .settings-profile-row > a {
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 6px;
@@ -20757,7 +20757,7 @@ svg {
gap: 12px;
}
.public-profile-head > button {
.public-profile-head > button, .public-profile-head > a {
align-items: center;
background: #ffffff;
border: 1px solid #d9e2ec;
@@ -20771,7 +20771,7 @@ svg {
padding: 0 14px;
}
.public-profile-head > button svg {
.public-profile-head > button svg, .public-profile-head > a svg {
height: 14px;
width: 14px;
}
@@ -21559,7 +21559,7 @@ svg {
margin: 0;
}
.admin-card-head button {
.admin-card-head button, .admin-card-head a {
background: transparent;
border: 0;
color: #159e5d;
@@ -21636,7 +21636,7 @@ svg {
padding: 12px 18px 18px;
}
.admin-quick-actions-grid button {
.admin-quick-actions-grid button, .admin-quick-actions-grid a {
align-items: center;
background: #f8fbff;
border: 1px solid #e0e8f2;
@@ -21650,11 +21650,11 @@ svg {
padding: 0 12px;
}
.admin-quick-actions-grid button svg:first-child {
.admin-quick-actions-grid button svg:first-child, .admin-quick-actions-grid a svg:first-child {
color: #109253;
}
.admin-quick-actions-grid button svg:last-child {
.admin-quick-actions-grid button svg:last-child, .admin-quick-actions-grid a svg:last-child {
margin-left: auto;
opacity: 0.6;
}
@@ -22853,7 +22853,7 @@ svg {
}
.listing-detail-back,
.listing-detail-crumbs button {
.listing-detail-crumbs button, .listing-detail-crumbs a {
align-items: center;
background: none;
border: none;
@@ -26789,3 +26789,151 @@ svg {
/* Routing: naglowkowe CTA jako <Link> - zachowanie wygladu przyciskow */
.post-button { align-content: center; text-decoration: none; }
/* Lista firm i uslug (/firmy) - korzysta z istniejacych kart .directory-card */
.companies-frame {
display: block;
margin: 0 auto;
max-width: 1420px;
padding: 0 96px;
}
.companies-frame .services-main { padding-right: 0; }
.companies-head { margin: 18px 0 20px; }
.companies-head h1 {
color: #111a2e;
font-size: 30px;
font-weight: 900;
line-height: 1.15;
margin: 0 0 8px;
}
.companies-head p {
color: #5b6472;
font-size: 15px;
margin: 0 0 10px;
max-width: 720px;
}
.companies-count {
color: #109253;
font-size: 13px;
font-weight: 800;
}
.companies-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.companies-chip {
align-items: center;
background: #ffffff;
border: 1px solid #dce4ec;
border-radius: 999px;
color: #27384f;
cursor: pointer;
display: inline-flex;
font-size: 13px;
font-weight: 800;
gap: 7px;
padding: 8px 14px;
}
.companies-chip svg { color: #7d8ca0; font-size: 14px; }
.companies-chip span {
background: #eef3f8;
border-radius: 999px;
color: #5b6472;
font-size: 11px;
font-weight: 900;
padding: 1px 7px;
}
.companies-chip:hover { border-color: #b9c6d4; }
.companies-chip.active {
background: #ebf6f0;
border-color: #13a360;
color: #109253;
}
.companies-chip.active svg { color: #109253; }
.companies-chip.active span { background: #d7efe2; color: #0d7a45; }
.companies-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 1280px) {
.companies-frame { padding: 0 24px; }
.companies-list { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 720px) {
.companies-head h1 { font-size: 24px; }
.companies-frame { padding: 0 14px; }
}
/* Routing: CTA nawigacyjne renderuja sie jako <a> (Link). :where() ma zerowa
specyficznosc, wiec kazda istniejaca regula klasy nadal wygrywa - to tylko
zdejmuje domyslne podkreslenie i niebieski kolor linku. */
:where(a) {
color: inherit;
text-decoration: none;
}
/* Routing: CTA o ukladzie blokowym odtwarzaja centrowanie tresci znane z <button> */
a.sell-primary,
a.account-new-search,
a.price-alerts-link,
a.price-alerts-manage,
a.cover-preview-button,
a.back-step-button,
a.listing-detail-back {
align-items: center;
display: flex;
justify-content: center;
text-align: center;
}
/* Kontenery, ktorych reguly nie ustawialy display - <button> centrowal tresc sam,
<a> wymaga tego jawnie. Pudelko (padding, min-height, tlo) bez zmian. */
.search-tabs a,
.tool-card a,
.sell-valuation-card a,
.sell-bottom-cta a,
.settings-profile-row > a {
align-items: center;
display: flex;
justify-content: center;
text-align: center;
}
/* Karty prowadzace do osobnej strony: link rozciagniety na cala karte.
Daje prawdziwy <a href> (kopiowanie, nowa karta, indeksowanie) bez
zagniezdzania przyciskow w <a>. Kontrolki karty zostaja klikalne nad nakladka. */
.real-result-card { position: relative; }
.card-stretched-link::after {
content: "";
inset: 0;
position: absolute;
z-index: 1;
}
.real-result-card .real-favorite-button,
.real-result-card .real-card-cta {
position: relative;
z-index: 2;
}
.real-card-cta {
align-items: center;
display: inline-flex;
justify-content: center;
text-align: center;
}
+249
View File
@@ -0,0 +1,249 @@
import { test, expect, type Page } from '@playwright/test';
import { ROUTES } from '../src/routes';
// Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router,
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
const ADMIN = { email: 'admin@mieszko.pl', password: 'Admin123!' };
const PUBLIC_ROUTES = [
ROUTES.home,
ROUTES.buy,
ROUTES.rent,
ROUTES.sell,
ROUTES.valuation,
ROUTES.priceHistory,
ROUTES.districtRanking,
ROUTES.negotiation,
ROUTES.creditCalculator,
ROUTES.map,
ROUTES.services,
ROUTES.companies,
ROUTES.guides,
ROUTES.guideBuying,
ROUTES.login,
];
const PROTECTED_ROUTES = [
ROUTES.account,
ROUTES.favorites,
ROUTES.messages,
ROUTES.add,
ROUTES.admin,
`${ROUTES.admin}/leady`,
];
async function goto(page: Page, path: string) {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.locator('main').first().waitFor({ state: 'attached' });
// ProtectedRoute dociaga profil z /auth/me - poczekaj, az skonczy stan ladowania
await page.locator('.route-loading').waitFor({ state: 'detached' }).catch(() => {});
await page.waitForTimeout(400);
}
async function login(page: Page) {
await goto(page, ROUTES.login);
await page.getByPlaceholder('Wpisz swój adres e-mail').fill(ADMIN.email);
await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password);
await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click();
// token w localStorage to pewny dowod zalogowania - adres moze sie jeszcze zmieniac
await page.waitForFunction(() => Boolean(window.localStorage.getItem('polskalokalnie-auth-token')));
await page.waitForFunction(() => window.location.pathname !== '/logowanie');
}
test.describe('Trasy publiczne', () => {
for (const path of PUBLIC_ROUTES) {
test(`bezposrednie wejscie na ${path} (F5 / nowa karta / wklejony link)`, async ({ page }) => {
await goto(page, path);
expect(new URL(page.url()).pathname).toBe(path);
await expect(page.locator('main')).not.toBeEmpty();
});
}
test('nieznany adres pokazuje strone 404', async ({ page }) => {
await goto(page, '/adres-ktory-nie-istnieje');
await expect(page.locator('.not-found-page')).toBeVisible();
});
test('odswiezenie strony zachowuje widok', async ({ page }) => {
await goto(page, ROUTES.districtRanking);
await page.reload({ waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe(ROUTES.districtRanking);
});
});
test.describe('Lista firm i uslug (/firmy)', () => {
test('wejscie na /firmy pokazuje pelna liste', async ({ page }) => {
await goto(page, ROUTES.companies);
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
const cards = await page.locator('.directory-card').count();
expect(cards).toBeGreaterThan(10);
});
test('filtr kategorii dziala przez query string', async ({ page }) => {
await goto(page, `${ROUTES.companies}?kategoria=remonty`);
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
const filtered = await page.locator('.directory-card').count();
await goto(page, ROUTES.companies);
const all = await page.locator('.directory-card').count();
expect(filtered).toBeGreaterThan(0);
expect(filtered).toBeLessThan(all);
});
test('nieznana kategoria nie wywraca widoku - pokazuje pelna liste', async ({ page }) => {
await goto(page, `${ROUTES.companies}?kategoria=nie-ma-takiej`);
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
});
test('klik w chip kategorii zmienia adres i wynik', async ({ page }) => {
await goto(page, ROUTES.companies);
await page.locator('.companies-chip', { hasText: 'Finanse' }).first().click();
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
await expect(page.locator('.companies-head h1')).toHaveText('Finanse');
});
test('"Zobacz firmy" prowadzi do listy z wybrana kategoria', async ({ page }) => {
await goto(page, ROUTES.services);
const link = page.locator('.service-category-card', { hasText: 'Remont i wykończenie' }).getByRole('link');
await expect(link).toHaveAttribute('href', '/firmy?kategoria=remonty');
await link.click();
await page.waitForFunction(() => window.location.pathname === '/firmy');
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
});
test('"Zobacz wszystkie" prowadzi do pelnej listy', async ({ page }) => {
await goto(page, ROUTES.services);
const link = page.locator('.services-section .section-head', { hasText: 'Polecane firmy' }).getByRole('link');
await expect(link).toHaveAttribute('href', ROUTES.companies);
await link.click();
await page.waitForFunction(() => window.location.pathname === '/firmy');
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
});
test('Wstecz i Dalej dzialaja na filtrze kategorii', async ({ page }) => {
await goto(page, ROUTES.services);
await page.locator('.service-category-card', { hasText: 'Finanse' }).getByRole('link').click();
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
await page.goBack({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.pathname === '/firmy-i-uslugi');
await page.goForward({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
});
});
test.describe('Nawigacja przez linki', () => {
test('menu glowne to <a href> - mozna otworzyc w nowej karcie', async ({ page }) => {
await goto(page, ROUTES.home);
for (const [label, expected] of [
['Kupuję', ROUTES.buy],
['Wynajmuję', ROUTES.rent],
['Poradniki', ROUTES.guides],
['Firmy i usługi', ROUTES.services],
] as const) {
const link = page.getByRole('link', { name: label, exact: true }).first();
await expect(link).toHaveAttribute('href', expected);
}
});
test('aktywna pozycja menu wynika z adresu', async ({ page }) => {
await goto(page, ROUTES.buy);
await expect(page.getByRole('link', { name: 'Kupuję', exact: true }).first()).toHaveClass(/active/);
});
test('CTA naglowka maja poprawne adresy', async ({ page }) => {
await goto(page, ROUTES.home);
await expect(page.locator('a.post-button')).toHaveAttribute('href', ROUTES.add);
await expect(page.getByRole('link', { name: 'Moje konto' })).toHaveAttribute('href', ROUTES.login);
});
test('CTA w tresci strony sa linkami z href', async ({ page }) => {
await goto(page, ROUTES.sell);
await expect(page.locator('a.sell-primary')).toHaveAttribute('href', ROUTES.add);
await expect(page.locator('.sell-valuation-card a')).toHaveAttribute('href', ROUTES.valuation);
});
test('karta oferty ma prawdziwy link do strony oferty', async ({ page }) => {
await goto(page, ROUTES.buy);
await page.locator('.real-result-card').first().waitFor();
const card = page.locator('.real-result-card').first();
const href = await card.locator('.card-stretched-link').getAttribute('href');
expect(href).toMatch(/^\/oferta\/\d+$/);
await expect(card.locator('a.real-card-cta')).toHaveAttribute('href', href!);
});
test('klik w serce na karcie nie nawiguje', async ({ page }) => {
await goto(page, ROUTES.buy);
await page.locator('.real-result-card').first().waitFor();
await page.locator('.real-result-card').first().locator('.real-favorite-button').click();
await page.waitForTimeout(700);
expect(new URL(page.url()).pathname).toBe(ROUTES.buy);
});
test('klik w menu zmienia adres, Wstecz i Dalej dzialaja', async ({ page }) => {
await goto(page, ROUTES.home);
await page.getByRole('link', { name: 'Kupuję', exact: true }).first().click();
await page.waitForFunction(() => window.location.pathname === '/kupno');
await page.getByRole('link', { name: 'Poradniki', exact: true }).first().click();
await page.waitForFunction(() => window.location.pathname === '/poradniki');
await page.goBack({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.pathname === '/kupno');
await page.goForward({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.pathname === '/poradniki');
});
});
test.describe('Standard: brak atrap linkow', () => {
const PAGES = [ROUTES.home, ROUTES.services, ROUTES.companies, ROUTES.guides, ROUTES.sell, ROUTES.buy];
for (const path of PAGES) {
test(`${path} nie zawiera href="#" ani javascript:void`, async ({ page }) => {
await goto(page, path);
const bad = await page.locator('a[href="#"], a[href^="javascript:"]').count();
expect(bad).toBe(0);
});
}
});
test.describe('Trasy chronione', () => {
test('bez logowania przekierowuja na /logowanie', async ({ page }) => {
for (const path of PROTECTED_ROUTES) {
await goto(page, path);
expect(new URL(page.url()).pathname).toBe(ROUTES.login);
}
});
test('po zalogowaniu wracamy na zamierzona strone', async ({ page }) => {
await goto(page, ROUTES.accountPriceAlerts);
expect(new URL(page.url()).pathname).toBe(ROUTES.login);
await page.getByPlaceholder('Wpisz swój adres e-mail').fill(ADMIN.email);
await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password);
await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click();
await page.waitForFunction(() => window.location.pathname === '/konto/alerty-cenowe');
});
test('zakladki panelu admina maja wlasne adresy i przezywaja F5', async ({ page }) => {
await login(page);
await goto(page, ROUTES.admin);
await page.locator('.admin-nav a', { hasText: 'Leady' }).first().click();
await page.waitForFunction(() => window.location.pathname === '/admin/leady');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.admin-nav').first().waitFor({ state: 'attached' });
expect(new URL(page.url()).pathname).toBe('/admin/leady');
});
test('nieznana sekcja panelu admina pokazuje 404', async ({ page }) => {
await login(page);
await goto(page, `${ROUTES.admin}/nie-ma-takiej-sekcji`);
await expect(page.locator('.not-found-page')).toBeVisible();
});
});