diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 417c18f..6c7e162 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,8 @@
"@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11",
"react": "18.3.1",
- "react-dom": "18.3.1"
+ "react-dom": "18.3.1",
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "18.3.12",
@@ -536,6 +537,19 @@
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1039,6 +1053,44 @@
"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": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
@@ -1097,6 +1149,12 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 755e641..d3d753e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,8 @@
"@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11",
"react": "18.3.1",
- "react-dom": "18.3.1"
+ "react-dom": "18.3.1",
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "18.3.12",
diff --git a/frontend/src/ProtectedRoute.tsx b/frontend/src/ProtectedRoute.tsx
new file mode 100644
index 0000000..0d0ce02
--- /dev/null
+++ b/frontend/src/ProtectedRoute.tsx
@@ -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 (
+
+ Wczytywanie…
+
+ );
+ }
+
+ if (!user) {
+ // Zapamiętujemy cel, żeby po zalogowaniu wrócić dokładnie tam, gdzie użytkownik zmierzał.
+ return ;
+ }
+
+ if (requireAdmin && user.role !== 'ADMIN') {
+ return ;
+ }
+
+ return ;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index cc6c8d1..b286115 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,15 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './auth';
import { NotificationsProvider } from './notifications';
ReactDOM.createRoot(document.getElementById('root')!).render(
-
-
-
-
-
+
+
+
+
+
+
+
,
);
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
new file mode 100644
index 0000000..f5a4356
--- /dev/null
+++ b/frontend/src/routes.ts
@@ -0,0 +1,114 @@
+// 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',
+ 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;
+}
+
+// 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>(
+ (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,
+];
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 9d194ea..9239dd0 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -26749,3 +26749,6 @@ svg {
.mkt-error { color: #b3261e; 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; }