From 38998d8db54736fa2e848305983bbba13fbc71d6 Mon Sep 17 00:00:00 2001 From: pascal Date: Tue, 25 Aug 2026 15:31:40 +0200 Subject: [PATCH] storna glowna --- frontend/src/App.tsx | 223 +++++++++++++++++++++++++++++++++------- frontend/src/routes.ts | 8 ++ frontend/src/styles.css | 31 +++++- 3 files changed, 224 insertions(+), 38 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3684ca2..2141be8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Fragment, type CSSProperties, 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, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, GUIDE_TOPIC_ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ROUTES, GUIDE_TOPIC_ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, buyCityPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { guideContents, type GuideContent, type GuideBlock } from './guides-data'; import { ProtectedRoute } from './ProtectedRoute'; import { Avatar, initialsFrom } from './Avatar'; @@ -880,12 +880,35 @@ const listings = [ ]; const cities = [ - { name: 'Warszawa', count: '12 582 ogłoszenia', position: '48% 55%' }, - { name: 'Kraków', count: '8 150 ogłoszeń', position: '54% 54%' }, - { name: 'Wrocław', count: '6 843 ogłoszenia', position: '42% 58%' }, - { name: 'Gdańsk', count: '5 231 ogłoszeń', position: '62% 52%' }, + { name: 'Warszawa', position: '48% 55%' }, + { name: 'Kraków', position: '54% 54%' }, + { name: 'Wrocław', position: '42% 58%' }, + { name: 'Gdańsk', position: '62% 52%' }, ]; +// Normalizacja nazwy miasta jak w backendzie (ListingService.normalizeCity): trim, lower, ł->l, bez znakow diakrytycznych. +// Dzieki temu liczenie ogloszen po miescie dopasowuje sie tak samo jak filtr na serwerze. +function normalizeCityName(value: string | null | undefined): string { + if (!value) { + return ''; + } + return value.trim().toLowerCase().replace(/ł/g, 'l').normalize('NFD').replace(/\p{Diacritic}/gu, ''); +} + +// Polska odmiana rzeczownika "ogloszenie" przez liczbe. +function formatListingCountLabel(count: number): string { + const formatted = count.toLocaleString('pl-PL'); + const mod10 = count % 10; + const mod100 = count % 100; + if (count === 1) { + return `${formatted} ogłoszenie`; + } + if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) { + return `${formatted} ogłoszenia`; + } + return `${formatted} ogłoszeń`; +} + const rentQuickFilters = [ { icon: 'sun', @@ -12945,8 +12968,10 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) { } // Poziomy slajder kart (polecane, ostatnio przegladane) ze strzalkami lewo/prawo. -function CardSlider({ ariaLabel, children }: { ariaLabel: string; children: ReactNode }) { +function CardSlider({ ariaLabel, children, autoScroll = false }: { ariaLabel: string; children: ReactNode; autoScroll?: boolean }) { const trackRef = useRef(null); + // Pauza auto-przewijania trzymana w ref, zeby nie restartowac petli animacji przy kazdym najechaniu myszka. + const pausedRef = useRef(false); const scrollByCards = (direction: number) => { const track = trackRef.current; if (!track) { @@ -12954,8 +12979,74 @@ function CardSlider({ ariaLabel, children }: { ariaLabel: string; children: Reac } track.scrollBy({ left: direction * Math.max(260, track.clientWidth * 0.85), behavior: 'smooth' }); }; + + // Ciagle przewijanie w prawo, zapetlone. Zatrzymuje sie, gdy kursor jest nad karuzela (hover ustawia pausedRef). + useEffect(() => { + if (!autoScroll) { + return; + } + const track = trackRef.current; + if (!track) { + return; + } + // Bez CSS scroll-behavior/snapu, inaczej programowe przewijanie co klatke szarpie i "przyciaga" do kart. + const prevBehavior = track.style.scrollBehavior; + const prevSnap = track.style.scrollSnapType; + track.style.scrollBehavior = 'auto'; + track.style.scrollSnapType = 'none'; + + const SPEED = 40; // piksele na sekunde + // Wlasny akumulator zmiennoprzecinkowy: przegladarka zaokragla scrollLeft do liczby calkowitej, + // wiec przy ~0,66 px/klatke ulamek nigdy by sie nie skumulowal i pasek stalby w miejscu. + let pos = track.scrollLeft; + let raf = 0; + let last = 0; + const step = (ts: number) => { + if (last === 0) { + last = ts; + } + const dt = ts - last; + last = ts; + if (pausedRef.current) { + // W trakcie pauzy uzytkownik moze przewinac recznie (strzalki) - trzymajmy sie tego, co widac. + pos = track.scrollLeft; + } else { + const maxScroll = track.scrollWidth - track.clientWidth; + if (maxScroll > 0) { + pos += (SPEED * dt) / 1000; + if (pos >= maxScroll - 0.5) { + pos = 0; // koniec listy - wracamy na poczatek i przewijamy w kolko + } + track.scrollLeft = pos; + } + } + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => { + cancelAnimationFrame(raf); + track.style.scrollBehavior = prevBehavior; + track.style.scrollSnapType = prevSnap; + }; + }, [autoScroll]); + + const pause = () => { + pausedRef.current = true; + }; + const resume = () => { + pausedRef.current = false; + }; + return ( -
+
@@ -12977,6 +13068,41 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci const [recentlyViewed] = useState(() => readRecentlyViewed()); const { items: liveSaleListings } = useLiveListings('SALE'); + // Liczba ogloszen na zywo per miasto do sekcji "Przegladaj miasta". Bierzemy wszystkie zatwierdzone + // ogloszenia (sprzedaz + wynajem) z /api/listings i grupujemy po znormalizowanej nazwie miasta. + // cityCounts === null => jeszcze nie wczytano (pokazujemy placeholder zamiast falszywego "0"). + const [cityCounts, setCityCounts] = useState | null>(null); + useEffect(() => { + let cancelled = false; + let timerId: number | null = null; + const load = async () => { + try { + const all = await apiFetch('/listings'); + if (!cancelled) { + const counts: Record = {}; + for (const listing of all) { + const key = normalizeCityName(listing.city); + counts[key] = (counts[key] ?? 0) + 1; + } + setCityCounts(counts); + } + } catch { + // Brak danych - zostawiamy poprzednie liczby (albo placeholder przy pierwszym bledzie). + } finally { + if (!cancelled) { + timerId = window.setTimeout(() => void load(), LIVE_LISTINGS_REFRESH_MS); + } + } + }; + void load(); + return () => { + cancelled = true; + if (timerId !== null) { + window.clearTimeout(timerId); + } + }; + }, []); + const toggleFavoriteListing = (location: string) => { setFavoriteListings((current) => { const next = new Set(current); @@ -13089,7 +13215,7 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci Zobacz wszystkie
- + {liveSaleListings.length >= 4 ? ( liveSaleListings.slice(0, 12).map((item) => ( @@ -13134,31 +13260,6 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci )} - - - - {showRecentlyViewedOnHome && recentlyViewed.length > 0 && ( -
-
-

Ostatnio przeglądane

-
- - - {recentlyViewed.map((item) => ( - -
-
-

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

- {item.title} -
- {formatPrice(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''} -
-
- - ))} - -
- )}
@@ -13183,17 +13284,23 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci

Przeglądaj miasta

{cities.map((city) => ( -
{city.name} - {city.count} -
+ + {cityCounts === null + ? 'Ładowanie…' + : formatListingCountLabel(cityCounts[normalizeCityName(city.name)] ?? 0)} + + ))}
Zobacz wszystkie miasta @@ -13205,8 +13312,37 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
+
+ + {/* Para banerow reklamowych we wspolnym wierszu - zawsze wyrownana do gornej krawedzi, + niezaleznie od wysokosci kolumn powyzej i od szerokosci okna. */} +
+
+ + {showRecentlyViewedOnHome && recentlyViewed.length > 0 && ( +
+
+

Ostatnio przeglądane

+
+ + + {recentlyViewed.map((item) => ( + +
+
+

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

+ {item.title} +
+ {formatPrice(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''} +
+
+ + ))} + +
+ )} @@ -21827,8 +21963,21 @@ function BuyPage({ onToggleSavedSearch: () => void; }) { const { city, propertyType, pMin, pMax, aMin, aMax, rooms, wtorny, pierwotny, noFeeOnly, chips, resetAll, fromPreferences } = filters; - const [, setSearchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [isMapOpen, setIsMapOpen] = useState(false); + + // Miasto przekazane w adresie (np. z kafelka miasta na stronie glownej: /kupno?miasto=Warszawa) + // przenosimy do filtrow. Dzieki temu link dziala tez po odswiezeniu i przy wejsciu z zewnatrz. + const cityParam = searchParams.get('miasto') ?? ''; + useEffect(() => { + if (cityParam) { + filters.setCity(cityParam); + filters.setCityInput(cityParam); + } + // Zalezymy tylko od wartosci parametru - settery useState sa stabilne. Nie reagujemy na reczne + // czyszczenie pola miasta (parametr sie nie zmienia), wiec uzytkownik moze swobodnie edytowac filtr. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cityParam]); const applySort = (value: SortKey) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index f90e72b..e23d8d5 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -117,6 +117,14 @@ export function negotiationPath(offerId?: number | null): string { return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation; } +// Lista ofert kupna zawężona do miasta - miasto w query stringu, żeby link działał po odświeżeniu +// i dało się go udostępnić (np. kliknięcie kafelka miasta na stronie głównej). +export function buyCityPath(city: string): string { + const params = new URLSearchParams(); + params.set('miasto', city); + return `${ROUTES.buy}?${params.toString()}`; +} + // 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(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 860b930..82f5eda 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1207,6 +1207,14 @@ svg { padding: 14px 16px; place-content: end start; position: relative; + text-decoration: none; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +/* Kafelki miast prowadza do listy ofert w danym miescie - delikatny hover sygnalizuje klikalnosc. */ +a.city-card:hover { + transform: translateY(-2px); + box-shadow: 0 12px 26px rgba(15, 31, 58, 0.22); } .city-card strong, @@ -31461,7 +31469,28 @@ a.listing-detail-back { width: 100%; } .home-banner-wrap:not(:empty) { margin-top: 22px; } -.banner-slot-home_middle { display: block; margin: 22px 0; } +.banner-slot-home_middle { display: block; } + +/* Para banerow reklamowych (kredyt + wycena) stoi we wspolnym wierszu, zawsze wyrownana do gory. + Wczesniej kazdy baner byl w innej kolumnie siatki (o niezaleznej wysokosci), wiec ladowaly na roznej + wysokosci - i to sie zmienialo wraz z szerokoscia okna. Wspolny wiersz gwarantuje rowna linie. */ +.home-banner-duo { + align-items: start; + display: grid; + gap: 28px; + grid-column: 1 / -1; + grid-template-columns: minmax(0, 1.85fr) minmax(440px, 0.95fr); +} +.home-banner-duo .banner-slot { margin: 0; } + +/* Ponizej progu siatka strony jest jednokolumnowa - baner-duo tez schodzi do jednej kolumny. + Override musi stac PO regule bazowej powyzej, inaczej (przez kolejnosc w pliku) nie zadziala. */ +@media (max-width: 1380px) { + .home-banner-duo { grid-template-columns: 1fr; } +} + +/* "Ostatnio przegladane" na cala szerokosc siatki - konczy sie rowno z para banerow reklamowych. */ +.home-recent-row { grid-column: 1 / -1; } .banner-slot-sidebar { display: block; margin-top: 16px; } .promo-banner {