Compare commits

...

9 Commits

Author SHA1 Message Date
pascal 0ec73959a7 Merge branch 'pascal-zmiany' 2026-07-24 00:42:09 +02:00
pascal 3e394998a3 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>
2026-07-24 00:37:04 +02:00
pascal e1ab61ec7c Standard routingu w AGENTS.md i usuniecie atrap href="#"
- AGENTS.md: obowiazkowy standard architektoniczny - kazdy widok ma wlasny
  URL w React Routerze, zakaz nawigacji na useState/setView/activeView,
  identyfikatory w parametrach URL, filtry w query string, ProtectedRoute
- 7 z 9 atrap href="#" zamienionych na <Link> do istniejacych tras
- pozostaly 2 w ServicesPage ("Zobacz firmy", "Zobacz wszystkie") - brak
  docelowej strony listy firm, wymagaja decyzji produktowej

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:58:22 +02:00
pascal f966b4539a Okruszki nawigacyjne i CTA naglowka jako <Link>
- 16 przyciskow w breadcrumbach zamienionych na <Link> (semantyka linku,
  otwieranie w nowej karcie, podglad celu na hover)
- naglowek: "Moje konto" i "Dodaj ogloszenie za darmo" jako <Link>
- CSS breadcrumbow rozszerzony o warianty <a>, wyglad bez zmian
  (zweryfikowane zrzutami ekranu)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:52:37 +02:00
pascal 5f5bec7232 Przewijanie przy zmianie trasy i naprawa petli renderow w porownaniu ofert
- nowa trasa startuje od gory strony, przy cofaniu (POP) pozycja zostaje
  przy przegladarce, wiec powrot do listy ofert wraca w to samo miejsce
- AccountComparisonPage: setSelectedDetails({}) tworzylo nowy obiekt przy
  kazdym przebiegu efektu zaleznego od selectedDetails, co dawalo
  nieskonczona petle ("Maximum update depth exceeded"). Blad byl obecny
  przed migracja - ujawnily go testy E2E nowej trasy /konto/porownanie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:48:43 +02:00
pascal af21991d9f Panel admina: 16 zakladek jako podsciezki /admin/*
- aktywna zakladka wyznaczana z adresu (useParams), nie z useState
- setTab() nawiguje zamiast zmieniac stan lokalny
- sidebar admina: <button> -> <NavLink>
- typ AdminTab zunifikowany z AdminTabKey z routes.ts
- nieznana sekcja /admin/cokolwiek -> strona 404
- CSS .admin-nav rozszerzony o warianty <a>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:36:30 +02:00
pascal ec5a7289f6 Zastap useState(view) routingiem opartym o react-router-dom
Rdzen migracji - kazdy widok ma wlasny adres URL:
- render {view === '...'} zastapiony przez <Routes> z 31 trasami
- usuniete: type View, PROTECTED_VIEWS, proteza #login, stan view/mapCity/
  mapOfferType/selectedListingId/messageThreadTarget
- prop onNavigate (166 wystapien) zastapiony hookiem useNavigate w komponentach
- /oferta/:id czyta identyfikator z adresu (ListingDetailRoute)
- /mapa?miasto=&typ= czyta parametry z query stringa (MapSearchRoute)
- dane przejsciowe miedzy trasami (watek wiadomosci, oferta do negocjacji)
  przekazywane w location.state zamiast globalnego stanu
- ProtectedRoute na trasach konta, requireAdmin na /admin
- powrot do zamierzonego celu po zalogowaniu (location.state.from)
- Header i AccountSidebar: <button> -> <NavLink>, aktywnosc z adresu
- strona 404 dla nieznanych adresow
- CSS: selektory menu rozszerzone o warianty <a> (wyglad bez zmian)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:34:35 +02:00
pascal f072ed0115 Dodaj react-router-dom, mape tras i ProtectedRoute
Fundament migracji z useState(view) na prawdziwy routing:
- react-router-dom 7 w zaleznosciach
- BrowserRouter w main.tsx (nginx ma juz try_files, wiec czyste sciezki dzialaja)
- routes.ts: centralna mapa adresow + helpery listingPath/mapPath/adminTabPath
- ProtectedRoute z obsluga stanu loading z AuthProvider

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:24:22 +02:00
pascal 498ea07b9e Merge pull request 'Pascal zmiany Alerty cenowe + Pomoc i kontakt' (#1) from pascal-zmiany into main
Reviewed-on: #1
2026-07-13 01:22:20 +02:00
12 changed files with 1486 additions and 523 deletions
+2
View File
@@ -10,6 +10,8 @@ backend/target/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
frontend/test-results/
frontend/playwright-report/
# IDE # IDE
.idea/ .idea/
+25
View File
@@ -44,6 +44,31 @@ Pull requests should include a short summary, testing performed, linked issues w
Do not commit secrets, local credentials, generated build output, or machine-specific configuration. Store required environment variables in an ignored `.env` file and document safe example values in `.env.example`. Do not commit secrets, local credentials, generated build output, or machine-specific configuration. Store required environment variables in an ignored `.env` file and document safe example values in `.env.example`.
## Standard architektoniczny: routing (obowiązkowy)
Każdy nowy widok, ekran, zakładka lub podstrona **musi mieć własny, jednoznaczny adres URL** i być obsługiwana przez React Router. To twardy standard całego projektu, nie sugestia.
Zasady:
- **Nie buduj nawigacji na stanie.** Zakazane jako mechanizm przełączania stron: `useState('view')`, `setView`, `activeView`, `selectedPage`, `selectedTab` i pochodne.
- **Nie przełączaj całych stron warunkowym renderowaniem** bez zmiany adresu (`{view === 'x' && <Page />}`).
- **Do nawigacji używaj** `Link`, `NavLink`, `useNavigate` oraz tras zadeklarowanych w `Routes`.
- **Identyfikatory obiektów trzymaj w parametrach URL**: `/oferta/:id`, `/profil/:id`, `/wiadomosci/:conversationId` — nie w stanie Reacta.
- **Filtry, sortowanie, wyszukiwanie i paginację zapisuj w query string** wszędzie tam, gdzie użytkownik powinien móc skopiować lub odświeżyć widok.
- **Zakładki paneli twórz jako trasy zagnieżdżone**: `/konto/ustawienia`, `/admin/uzytkownicy`.
- **Trasy wymagające logowania zabezpieczaj `ProtectedRoute`** (`frontend/src/ProtectedRoute.tsx`). Pamiętaj, że to zabezpieczenie interfejsu — autoryzację egzekwuje backend.
- **Nie używaj `href="#"`** ani przycisków imitujących linki.
- **Każdy link musi działać** po odświeżeniu (F5), przy bezpośrednim wejściu z adresu, po użyciu Wstecz/Dalej i po otwarciu w nowej karcie.
Gdzie co leży:
- `frontend/src/routes.ts` — centralna mapa adresów (`ROUTES`) i helpery (`listingPath`, `mapPath`, `adminTabPath`). Nowy widok zaczynaj od wpisu tutaj, nie od literału ścieżki w JSX.
- `frontend/src/ProtectedRoute.tsx` — bramka autoryzacji, obsługuje stan `loading` z `AuthProvider`.
- `frontend/src/App.tsx` — deklaracja `<Routes>`.
- `nginx/default.conf``try_files $uri $uri/ /index.html` (SPA fallback, już skonfigurowany).
Przed zakończeniem każdego zadania sprawdź, czy dodane widoki mają trasy i czy adres zmienia się podczas nawigacji. Jeżeli istniejący kod łamie ten standard, nie powielaj starego rozwiązania — dostosuj go do React Routera.
## Behavioral Guidelines To Reduce Common LLM Coding Mistakes ## Behavioral Guidelines To Reduce Common LLM Coding Mistakes
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment. Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+123 -1
View File
@@ -11,9 +11,11 @@
"@types/pdfmake": "^0.3.3", "@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11", "pdfmake": "^0.3.11",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"react-router-dom": "^7.18.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
@@ -108,6 +110,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -536,6 +554,19 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -977,6 +1008,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/png-js": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
@@ -1039,6 +1117,44 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/restructure": { "node_modules/restructure": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
@@ -1097,6 +1213,12 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+5 -2
View File
@@ -6,15 +6,18 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "tsc && vite build", "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": { "dependencies": {
"@types/pdfmake": "^0.3.3", "@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11", "pdfmake": "^0.3.11",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"react-router-dom": "^7.18.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2", "@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,
},
});
+599 -436
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './auth';
import { ROUTES } from './routes';
/**
* Bramka dostępu dla tras wymagających zalogowania (i opcjonalnie roli ADMIN).
*
* Uwaga na `loading`: po odświeżeniu strony token jest w localStorage, ale profil
* dociąga się asynchronicznie z /auth/me. Bez tego warunku bezpośrednie wejście na
* /konto wyrzucałoby zalogowanego użytkownika na logowanie, zanim profil dotrze.
*
* To zabezpieczenie interfejsu, nie kontrola dostępu do danych — autoryzację
* egzekwuje backend przy każdym endpoincie.
*/
export function ProtectedRoute({ requireAdmin = false }: { requireAdmin?: boolean }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="route-loading" role="status" aria-live="polite">
Wczytywanie
</div>
);
}
if (!user) {
// Zapamiętujemy cel, żeby po zalogowaniu wrócić dokładnie tam, gdzie użytkownik zmierzał.
return <Navigate to={ROUTES.login} state={{ from: `${location.pathname}${location.search}` }} replace />;
}
if (requireAdmin && user.role !== 'ADMIN') {
return <Navigate to={ROUTES.account} replace />;
}
return <Outlet />;
}
+27 -6
View File
@@ -104,21 +104,42 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
let cancelled = false; let cancelled = false;
setLoading(true); 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) { if (!cancelled) {
setUser(me); setUser(me);
setLoading(false); setLoading(false);
} }
}) } catch (error) {
.catch(() => { if (cancelled) {
if (!cancelled) { return;
}
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
window.localStorage.removeItem(TOKEN_KEY); window.localStorage.removeItem(TOKEN_KEY);
setToken(null); setToken(null);
setUser(null); setUser(null);
setLoading(false); 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 () => { return () => {
cancelled = true; cancelled = true;
}; };
+3
View File
@@ -1,15 +1,18 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import { AuthProvider } from './auth'; import { AuthProvider } from './auth';
import { NotificationsProvider } from './notifications'; import { NotificationsProvider } from './notifications';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter>
<AuthProvider> <AuthProvider>
<NotificationsProvider> <NotificationsProvider>
<App /> <App />
</NotificationsProvider> </NotificationsProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
); );
+121
View File
@@ -0,0 +1,121 @@
// Centralna mapa adresów aplikacji. Jedyne źródło prawdy dla nawigacji —
// komponenty korzystają z ROUTES zamiast trzymać identyfikatory widoków w stanie.
export const ROUTES = {
home: '/',
buy: '/kupno',
rent: '/wynajem',
sell: '/jak-sprzedawac',
valuation: '/wycena',
priceHistory: '/historia-cen',
districtRanking: '/ranking-dzielnic',
negotiation: '/negocjacje',
comparison: '/konto/porownanie',
add: '/dodaj-ogloszenie',
services: '/firmy-i-uslugi',
companies: '/firmy',
guides: '/poradniki',
guideBuying: '/poradniki/kupno-mieszkania',
creditCalculator: '/kalkulator-zdolnosci',
map: '/mapa',
login: '/logowanie',
account: '/konto',
accountSearches: '/konto/wyszukiwania',
accountPriceAlerts: '/konto/alerty-cenowe',
accountMeetings: '/konto/spotkania',
accountListings: '/konto/moje-oferty',
accountSettings: '/konto/ustawienia',
accountSecurity: '/konto/bezpieczenstwo',
accountProfileEdit: '/konto/profil/edycja',
accountPublicProfile: '/konto/profil',
accountHelpContact: '/konto/pomoc',
notifications: '/powiadomienia',
favorites: '/ulubione',
messages: '/wiadomosci',
admin: '/admin',
listingDetail: '/oferta/:id',
} as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
// Szczegóły oferty: identyfikator trafia do adresu, nie do stanu Reacta.
export function listingPath(id: number): string {
return `/oferta/${id}`;
}
// Negocjacje dotyczą konkretnej oferty — bez identyfikatora pokazujemy pierwszą z listy.
export function negotiationPath(offerId?: number | null): string {
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
}
// Mapa: miasto i typ oferty jako parametry zapytania, dzięki czemu wynik da się udostępnić.
export function mapPath(city?: string, offerType?: 'SALE' | 'RENT'): string {
const params = new URLSearchParams();
if (city) {
params.set('miasto', city);
}
if (offerType === 'RENT') {
params.set('typ', 'wynajem');
}
const query = params.toString();
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: '',
listings: 'ogloszenia',
users: 'uzytkownicy',
messages: 'wiadomosci',
reports: 'zgloszenia',
payments: 'platnosci',
stats: 'statystyki',
settings: 'ustawienia',
moderation: 'weryfikacja',
categories: 'kategorie',
locations: 'lokalizacje',
promotions: 'promocje',
forbiddenWords: 'zakazane-slowa',
leads: 'leady',
campaigns: 'kampanie',
mailConfig: 'konfiguracja-wysylki',
} as const;
export type AdminTabKey = keyof typeof ADMIN_TAB_PATHS;
export function adminTabPath(tab: AdminTabKey): string {
const segment = ADMIN_TAB_PATHS[tab];
return segment ? `${ROUTES.admin}/${segment}` : ROUTES.admin;
}
export const ADMIN_PATH_TO_TAB = Object.entries(ADMIN_TAB_PATHS).reduce<Record<string, AdminTabKey>>(
(acc, [tab, segment]) => {
acc[segment] = tab as AdminTabKey;
return acc;
},
{},
);
// Trasy dostępne wyłącznie po zalogowaniu (bramka w ProtectedRoute).
export const PROTECTED_PATHS: string[] = [
ROUTES.add,
ROUTES.account,
ROUTES.accountSearches,
ROUTES.accountPriceAlerts,
ROUTES.accountMeetings,
ROUTES.accountListings,
ROUTES.accountSettings,
ROUTES.accountSecurity,
ROUTES.accountProfileEdit,
ROUTES.accountPublicProfile,
ROUTES.accountHelpContact,
ROUTES.notifications,
ROUTES.favorites,
ROUTES.messages,
ROUTES.comparison,
];
+235 -47
View File
@@ -86,13 +86,13 @@ svg {
row-gap: 6px; row-gap: 6px;
} }
.header-admin .nav button { .header-admin .nav button, .header-admin .nav a {
color: #dbe4f0; color: #dbe4f0;
font-size: 12px; font-size: 12px;
padding: 8px 0; padding: 8px 0;
} }
.header-admin .nav button.active::after { .header-admin .nav button.active::after, .header-admin .nav a.active::after {
bottom: 2px; bottom: 2px;
} }
@@ -194,7 +194,7 @@ svg {
min-width: 0; min-width: 0;
} }
.nav button { .nav button, .nav a {
background: transparent; background: transparent;
border: 0; border: 0;
color: #111a2e; color: #111a2e;
@@ -206,7 +206,7 @@ svg {
white-space: nowrap; white-space: nowrap;
} }
.nav button.active::after { .nav button.active::after, .nav a.active::after {
background: #13a360; background: #13a360;
border-radius: 999px; border-radius: 999px;
bottom: 20px; bottom: 20px;
@@ -589,7 +589,7 @@ svg {
width: max-content; width: max-content;
} }
.search-tabs button { .search-tabs button, .search-tabs a {
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
border: 0; border: 0;
color: #657284; color: #657284;
@@ -600,11 +600,11 @@ svg {
padding: 10px 20px; padding: 10px 20px;
} }
.search-tabs button:first-child { .search-tabs button:first-child, .search-tabs a:first-child {
border-radius: 6px 0 0 0; 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; border-radius: 0 6px 0 0;
} }
@@ -798,7 +798,7 @@ svg {
font-size: 12px; font-size: 12px;
} }
.tool-card button { .tool-card button, .tool-card a {
align-self: end; align-self: end;
background: #f8fafc; background: #f8fafc;
border: 1px solid #e2e8ef; border: 1px solid #e2e8ef;
@@ -3183,7 +3183,7 @@ svg {
margin-top: 6px; margin-top: 6px;
} }
.result-side button { .result-side button, .result-side a {
align-self: end; align-self: end;
background: #0b9f5c; background: #0b9f5c;
border: 0; border: 0;
@@ -5922,7 +5922,7 @@ svg {
padding: 15px 16px; padding: 15px 16px;
} }
.sell-valuation-card button { .sell-valuation-card button, .sell-valuation-card a {
background: #0b9f5c; background: #0b9f5c;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
@@ -6000,7 +6000,7 @@ svg {
margin-bottom: 22px; margin-bottom: 22px;
} }
.valuation-breadcrumb button { .valuation-breadcrumb button, .valuation-breadcrumb a {
background: transparent; background: transparent;
border: 0; border: 0;
color: #768398; color: #768398;
@@ -6009,7 +6009,7 @@ svg {
padding: 0; padding: 0;
} }
.valuation-breadcrumb button.active { .valuation-breadcrumb button.active, .valuation-breadcrumb a.active {
color: #14223a; color: #14223a;
} }
@@ -6876,7 +6876,7 @@ svg {
padding: 0; padding: 0;
} }
.history-breadcrumb button { .history-breadcrumb button, .history-breadcrumb a {
background: transparent; background: transparent;
border: 0; border: 0;
color: #7a8798; color: #7a8798;
@@ -6885,7 +6885,7 @@ svg {
padding: 0; padding: 0;
} }
.history-breadcrumb button.active { .history-breadcrumb button.active, .history-breadcrumb a.active {
color: #14223a; color: #14223a;
} }
@@ -8331,7 +8331,7 @@ svg {
margin-top: 3px; margin-top: 3px;
} }
.sell-bottom-cta button { .sell-bottom-cta button, .sell-bottom-cta a {
background: #0b9f5c; background: #0b9f5c;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
@@ -9553,7 +9553,7 @@ svg {
margin-bottom: 24px; margin-bottom: 24px;
} }
.district-breadcrumb button { .district-breadcrumb button, .district-breadcrumb a {
background: transparent; background: transparent;
border: 0; border: 0;
color: #768398; color: #768398;
@@ -9562,7 +9562,7 @@ svg {
padding: 0; padding: 0;
} }
.district-breadcrumb button.active { .district-breadcrumb button.active, .district-breadcrumb a.active {
color: #14223a; color: #14223a;
} }
@@ -10090,7 +10090,7 @@ svg {
margin-bottom: 18px; margin-bottom: 18px;
} }
.negotiation-breadcrumb button { .negotiation-breadcrumb button, .negotiation-breadcrumb a {
background: transparent; background: transparent;
border: 0; border: 0;
color: #768398; color: #768398;
@@ -10099,7 +10099,7 @@ svg {
padding: 0; padding: 0;
} }
.negotiation-breadcrumb button.active { .negotiation-breadcrumb button.active, .negotiation-breadcrumb a.active {
color: #14223a; color: #14223a;
} }
@@ -10798,7 +10798,7 @@ svg {
gap: 17px; gap: 17px;
} }
.nav button, .nav button, .nav a,
.icon-text span { .icon-text span {
font-size: 12px; font-size: 12px;
} }
@@ -11003,11 +11003,11 @@ svg {
padding-bottom: 4px; padding-bottom: 4px;
} }
.nav button { .nav button, .nav a {
padding: 8px 0 12px; padding: 8px 0 12px;
} }
.nav button.active::after { .nav button.active::after, .nav a.active::after {
bottom: 3px; bottom: 3px;
} }
@@ -11417,7 +11417,7 @@ svg {
width: 100%; width: 100%;
} }
.search-tabs button { .search-tabs button, .search-tabs a {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding-left: 10px; padding-left: 10px;
@@ -13947,7 +13947,7 @@ svg {
text-transform: uppercase; text-transform: uppercase;
} }
.account-menu button { .account-menu button, .account-menu a {
align-items: center; align-items: center;
background: transparent; background: transparent;
border: 0; border: 0;
@@ -13964,12 +13964,12 @@ svg {
width: 100%; width: 100%;
} }
.account-menu button svg { .account-menu button svg, .account-menu a svg {
color: #7d8ca0; color: #7d8ca0;
font-size: 14px; font-size: 14px;
} }
.account-menu button span { .account-menu button span, .account-menu a span {
background: #e8f8ef; background: #e8f8ef;
border-radius: 999px; border-radius: 999px;
color: #109253; color: #109253;
@@ -13978,13 +13978,13 @@ svg {
padding: 1px 6px; padding: 1px 6px;
} }
.account-menu button .tag { .account-menu button .tag, .account-menu a .tag {
border-radius: 4px; border-radius: 4px;
font-size: 9px; font-size: 9px;
padding: 1px 5px; padding: 1px 5px;
} }
.account-menu button.active { .account-menu button.active, .account-menu a.active {
background: #ebf6f0; background: #ebf6f0;
color: #109253; color: #109253;
} }
@@ -15192,7 +15192,7 @@ svg {
margin: 2px 0 8px; margin: 2px 0 8px;
} }
.messages-page .messages-breadcrumb button { .messages-page .messages-breadcrumb button, .messages-page .messages-breadcrumb a {
background: transparent; background: transparent;
border: 0; border: 0;
color: #718094; color: #718094;
@@ -15201,7 +15201,7 @@ svg {
padding: 0; padding: 0;
} }
.messages-page .messages-breadcrumb button.active { .messages-page .messages-breadcrumb button.active, .messages-page .messages-breadcrumb a.active {
color: #14223a; color: #14223a;
} }
@@ -17044,7 +17044,7 @@ svg {
.account-alerts article > span svg, .account-alerts article > span svg,
.account-searches article > span svg, .account-searches article > span svg,
.account-tools article > span svg, .account-tools article > span svg,
.account-menu button svg { .account-menu button svg, .account-menu a svg {
height: 14px; height: 14px;
width: 14px; width: 14px;
} }
@@ -18271,7 +18271,7 @@ svg {
gap: 8px; gap: 8px;
} }
.price-alerts-active-items button { .price-alerts-active-items button, .price-alerts-active-items a {
align-items: center; align-items: center;
background: #fbfcfe; background: #fbfcfe;
border: 1px solid #e2eaf3; border: 1px solid #e2eaf3;
@@ -18405,7 +18405,7 @@ svg {
} }
.settings-card-head.split > button, .settings-card-head.split > button,
.settings-profile-row > button { .settings-profile-row > button, .settings-profile-row > a {
background: #ffffff; background: #ffffff;
border: 1px solid #d9e2ec; border: 1px solid #d9e2ec;
border-radius: 6px; border-radius: 6px;
@@ -20757,7 +20757,7 @@ svg {
gap: 12px; gap: 12px;
} }
.public-profile-head > button { .public-profile-head > button, .public-profile-head > a {
align-items: center; align-items: center;
background: #ffffff; background: #ffffff;
border: 1px solid #d9e2ec; border: 1px solid #d9e2ec;
@@ -20771,7 +20771,7 @@ svg {
padding: 0 14px; padding: 0 14px;
} }
.public-profile-head > button svg { .public-profile-head > button svg, .public-profile-head > a svg {
height: 14px; height: 14px;
width: 14px; width: 14px;
} }
@@ -21387,7 +21387,7 @@ svg {
gap: 3px; gap: 3px;
} }
.admin-nav button { .admin-nav button, .admin-nav a {
align-items: center; align-items: center;
background: transparent; background: transparent;
border: 0; border: 0;
@@ -21404,22 +21404,22 @@ svg {
text-align: left; text-align: left;
} }
.admin-nav button svg { .admin-nav button svg, .admin-nav a svg {
color: #7186a0; color: #7186a0;
height: 15px; height: 15px;
width: 15px; width: 15px;
} }
.admin-nav button.active { .admin-nav button.active, .admin-nav a.active {
background: #ebf6f0; background: #ebf6f0;
color: #109253; color: #109253;
} }
.admin-nav button.active svg { .admin-nav button.active svg, .admin-nav a.active svg {
color: #109253; color: #109253;
} }
.admin-nav button b { .admin-nav button b, .admin-nav a b {
background: #129357; background: #129357;
border-radius: 999px; border-radius: 999px;
color: #ffffff; color: #ffffff;
@@ -21559,7 +21559,7 @@ svg {
margin: 0; margin: 0;
} }
.admin-card-head button { .admin-card-head button, .admin-card-head a {
background: transparent; background: transparent;
border: 0; border: 0;
color: #159e5d; color: #159e5d;
@@ -21636,7 +21636,7 @@ svg {
padding: 12px 18px 18px; padding: 12px 18px 18px;
} }
.admin-quick-actions-grid button { .admin-quick-actions-grid button, .admin-quick-actions-grid a {
align-items: center; align-items: center;
background: #f8fbff; background: #f8fbff;
border: 1px solid #e0e8f2; border: 1px solid #e0e8f2;
@@ -21650,11 +21650,11 @@ svg {
padding: 0 12px; 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; 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; margin-left: auto;
opacity: 0.6; opacity: 0.6;
} }
@@ -22853,7 +22853,7 @@ svg {
} }
.listing-detail-back, .listing-detail-back,
.listing-detail-crumbs button { .listing-detail-crumbs button, .listing-detail-crumbs a {
align-items: center; align-items: center;
background: none; background: none;
border: none; border: none;
@@ -24390,7 +24390,7 @@ svg {
gap: 6px; gap: 6px;
} }
.comparison-breadcrumb button { .comparison-breadcrumb button, .comparison-breadcrumb a {
background: transparent; background: transparent;
border: 0; border: 0;
color: inherit; color: inherit;
@@ -24398,7 +24398,7 @@ svg {
padding: 0; padding: 0;
} }
.comparison-breadcrumb button.active { .comparison-breadcrumb button.active, .comparison-breadcrumb a.active {
color: #152640; color: #152640;
} }
@@ -26749,3 +26749,191 @@ svg {
.mkt-error { color: #b3261e; font-size: 13px; font-weight: 600; margin: 0; } .mkt-error { color: #b3261e; font-size: 13px; font-weight: 600; margin: 0; }
.mkt-ok { color: #12784a; font-size: 13px; font-weight: 600; margin: 0; } .mkt-ok { color: #12784a; font-size: 13px; font-weight: 600; margin: 0; }
/* Routing: stan wczytywania profilu przy wejsciu na trase chroniona */
.route-loading { padding: 80px 24px; text-align: center; color: #5b6472; font-size: 15px; font-weight: 600; }
/* Routing: NavLink/Link renderuja <a> - zerujemy domyslne style linku,
zeby menu wygladalo dokladnie tak jak wczesniejsze <button>. */
.nav a,
.account-menu a,
.brand,
.icon-text {
color: inherit;
cursor: pointer;
text-decoration: none;
}
.not-found-page { display: grid; padding: 90px 24px 120px; place-items: center; }
.not-found-shell { max-width: 520px; text-align: center; }
.not-found-shell h1 { color: #13a360; font-size: 88px; font-weight: 900; line-height: 1; margin: 0; }
.not-found-shell h2 { color: #111a2e; font-size: 24px; font-weight: 800; margin: 16px 0 8px; }
.not-found-shell p { color: #5b6472; font-size: 15px; margin: 0 0 28px; }
.not-found-actions { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; }
.not-found-actions a { border-radius: 10px; font-size: 14px; font-weight: 800; padding: 12px 22px; text-decoration: none; }
.not-found-primary { background: #13a360; color: #ffffff; }
.not-found-secondary { border: 1px solid #d5dde7; color: #27384f; }
/* Routing: pozycje menu admina to teraz <a> (NavLink) */
.admin-nav a { color: inherit; cursor: pointer; text-decoration: none; }
/* Routing: okruszki nawigacyjne to teraz <Link> */
.breadcrumb a,
.district-breadcrumb a,
.negotiation-breadcrumb a,
.comparison-breadcrumb a,
.messages-breadcrumb a,
.valuation-breadcrumb a,
.history-breadcrumb a,
.credit-breadcrumb a { color: inherit; cursor: pointer; text-decoration: none; }
/* 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();
});
});