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; }