diff --git a/.gitignore b/.gitignore index c3c1758..a3f02be 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ backend/target/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +frontend/test-results/ +frontend/playwright-report/ # IDE .idea/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6c7e162..1553223 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index d3d753e..061f929 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..51eac2e --- /dev/null +++ b/frontend/playwright.config.ts @@ -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, + }, +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 86d9d35..1d1bb6f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> @@ -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([]); const [listings, setListings] = useState([]); const [reports, setReports] = useState([]); @@ -4248,9 +4286,9 @@ function AdminPage() { - +
@@ -4311,7 +4349,7 @@ function AdminPage() {

Ostatnie ogłoszenia

- + Zobacz wszystkie
{recentListings.length === 0 ? (

Brak ogłoszeń do wyświetlenia.

@@ -4341,7 +4379,7 @@ function AdminPage() {

Aktywność użytkowników

- + Zobacz wszystkich
{recentUsers.length === 0 ? (

Brak użytkowników do wyświetlenia.

@@ -4367,10 +4405,10 @@ function AdminPage() {

Szybkie akcje

- - - - + Dodaj nowe ogłoszenie + Zarządzaj kategoriami + Zarządzaj lokalizacjami + Zarządzaj banerami
@@ -5414,7 +5452,7 @@ function NegotiationPage({

Nasi eksperci pomogą Ci przejść przez proces zakupu i wynegocjować najlepszą cenę.

- + Skontaktuj się z ekspertem Pierwsza konsultacja gratis
@@ -5825,7 +5863,7 @@ function AccountDashboardPage({

Moje wyszukiwania

- + Zobacz wszystkie
{savedSearches.slice(0, 4).map((row) => ( @@ -5852,7 +5890,7 @@ function AccountDashboardPage({ ))} {savedSearches.length === 0 &&

Nie masz jeszcze zapisanych wyszukiwań. Wejdź w Kup lub Wynajem i kliknij dzwonek „Zapisz wyszukiwanie”.

} - + Utwórz nowe wyszukiwanie
), @@ -5863,24 +5901,14 @@ function AccountDashboardPage({
{tools.map((tool) => ( -
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". +
{tool.title} {tool.text}
-
+ ))}
@@ -6103,7 +6131,7 @@ function AccountSavedSearchesPage({

Zapisane wyszukiwania

Wróć do swoich filtrów jednym kliknięciem i przejdź od razu do odpowiedniej listy ofert.

- + Dodaj nowe
@@ -6199,9 +6227,9 @@ function AccountSavedSearchesPage({
Nie masz jeszcze zapisanych wyszukiwań

Ustaw filtry w Kup lub Wynajem i kliknij dzwonek „Zapisz wyszukiwanie”, a pojawią się tutaj.

- +
)} @@ -6834,7 +6862,7 @@ function AccountPriceAlertsPage({
)} - + Dodaj kolejne lokalizacje
@@ -6851,16 +6879,16 @@ function AccountPriceAlertsPage({ Usuń )) : ( - + )} - + Zarządzaj alertami
@@ -8913,7 +8941,7 @@ function AccountSettingsPage() { {getDisplayName(user)} Konto Premium - + Edytuj
Adres e-mail{user?.email}
@@ -9851,7 +9879,7 @@ function AccountProfileEditPage() { {coverPhotoName && (
- + Podgląd profilu
)} @@ -9905,9 +9933,9 @@ function AccountProfileEditPage() { {profileVisibility === 'publiczna' && 'Zobacz jak inni widzą Twój profil.'} {profileVisibility === 'kontakty' && 'Profil widzą tylko zaakceptowane kontakty.'} - +
@@ -10191,7 +10219,7 @@ function AccountPublicProfilePage() {

Jak inni widzą Twój profil

Zobacz, jak Twój profil prezentuje się publicznie dla innych użytkowników Mieszko.

- + Edytuj profil
@@ -10955,10 +10983,10 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
- - + + Wynajem
@@ -10979,7 +11007,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci

{card.title}

{card.text}

- + {card.action} ))} @@ -12372,13 +12400,13 @@ function AddListingPage() { znajdziesz je w „Moich ogłoszeniach” ze statusem „Oczekuje”.

- - +
) : ( @@ -12728,10 +12756,10 @@ function GuideBuyingPage() {
- + Kupno mieszkania @@ -13163,10 +13191,10 @@ function GuideBuyingPage() {

Gotowy, by znaleźć swoje mieszkanie?

Przeglądaj aktualne oferty dopasowane do Twoich potrzeb i budżetu.

- +
@@ -13174,22 +13202,22 @@ function GuideBuyingPage() {

Kalkulatory

- - - - +
@@ -13261,30 +13289,25 @@ function GuidesPage() {
{guideTopics.map((topic) => { const isBuying = topic.title === 'Kupno mieszkania'; - return ( -
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 = ( + <>

{topic.title}

{topic.text}

{topic.count} + + ); + + return isBuying ? ( + + {content} + + ) : ( +
+ {content}
); })} @@ -13418,7 +13441,7 @@ function SellPage() { Pełne wsparcie
- + Dodaj ogłoszenie za darmo Zajmie Ci to tylko 2 minuty @@ -13462,7 +13485,7 @@ function SellPage() {

Sprawdź wartość swojego mieszkania

Bezpłatna wycena online w 30 sekund

Podaj adres mieszkania
- + Wyceń mieszkanie
Przykładowa wycena Kraków, Zabłocie @@ -13545,7 +13568,7 @@ function SellPage() { Gotowy na szybką i bezpieczną sprzedaż? Dołącz do tysięcy osób, które już sprzedały z nami swoje mieszkanie.
- + Dodaj ogłoszenie za darmo Zajmie Ci to tylko 2 minuty
@@ -15123,7 +15146,7 @@ function ValuationPage() {
- + Rozpocznij sprzedaż
@@ -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 ( +
+
+
+
+ Strona główna + + Firmy i usługi + + {activeCategory ? activeCategory.title : 'Wszystkie firmy'} +
+ +
+

{activeCategory ? activeCategory.title : 'Wszystkie firmy i usługi'}

+

+ {activeCategory + ? activeCategory.text + : 'Sprawdzone firmy i specjaliści z całej Polski. Porównaj oferty, oceny i opinie klientów.'} +

+ + {companies.length === 1 ? '1 firma' : `${companies.length} firm`} + {activeCategory ? ` w kategorii „${activeCategory.title}”` : ' w bazie'} + +
+ + + +
+
+ + + + +
+ +
+ {companies.map((company) => ( +
+
+
+

{company.title}

+

{company.rating}

+ {company.text} + {company.city} + Zweryfikowana firma +
+ + +
+ ))} +
+
+ +
+

Jak to działa?

+
+ {serviceSteps.map((step) => ( +
+ + + +
+

{step.title}

+

{step.text}

+
+
+ ))} +
+
+
+
+
+ ); +} + function ServicesPage() { return (
@@ -15491,7 +15629,7 @@ function ServicesPage() {

{category.title}

{category.text}

- Zobacz firmy + Zobacz firmy ))} @@ -15500,7 +15638,7 @@ function ServicesPage() {

Polecane firmy

- Zobacz wszystkie + Zobacz wszystkie
{serviceCompanies.map((company) => ( @@ -17137,14 +17275,10 @@ function RealListingsBand({ item.yearBuilt ? `${item.yearBuilt} r.` : null, ].filter(Boolean).join(' • '); return ( -
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 - do skopiowania i + // otwarcia w nowej karcie - bez zagniezdzania przyciskow w . +
{offerType === 'SALE' && (
@@ -17166,7 +17300,7 @@ function RealListingsBand({
-

{item.title}

+

{item.title}

{[item.city, item.district].filter(Boolean).join(', ')}

{meta}
@@ -17174,9 +17308,9 @@ function RealListingsBand({
{formatPln(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''} {listingRelativeDate(item.createdAt)} - +
); @@ -18353,10 +18487,10 @@ function MapSearchPage({ initialCity, offerType }: { initialCity: string; offerT return (
- +
@@ -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 (
@@ -18627,15 +18759,9 @@ function Header({

Powiadomienia

- +
@@ -18697,15 +18823,15 @@ function Header({ {isAccountMenuOpen && (
- - - + + {activeTab === 'buy' ? 'Przejdź do ofert Kupuję' : 'Przejdź do ofert Wynajmuję'}
)} @@ -20885,9 +21011,9 @@ function ListingDetailPage({

{error ?? 'Nie znaleziono ogłoszenia.'}

- +
); @@ -21158,11 +21284,11 @@ function ListingDetailPage({ return (
- + Panel główny - + {listing.title}
@@ -21349,9 +21475,9 @@ function ListingDetailPage({
- +
Udostępnij ofertę diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx index 0c26e83..ae143eb 100644 --- a/frontend/src/auth.tsx +++ b/frontend/src/auth.tsx @@ -104,21 +104,42 @@ export function AuthProvider({ children }: { children: ReactNode }) { } let cancelled = false; setLoading(true); - apiFetch('/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 => { + try { + const me = await apiFetch('/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; }; diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index f5a4356..bfe8a61 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -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: '', diff --git a/frontend/src/styles.css b/frontend/src/styles.css index df27c4b..2e5bfcd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 - 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 (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