storna glowna

This commit is contained in:
2026-08-25 15:31:40 +02:00
parent 1a067cce8c
commit 38998d8db5
3 changed files with 224 additions and 38 deletions
+186 -37
View File
@@ -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<HTMLDivElement>(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 (
<div className="card-slider">
<div
className="card-slider"
onMouseEnter={autoScroll ? pause : undefined}
onMouseLeave={autoScroll ? resume : undefined}
onFocusCapture={autoScroll ? pause : undefined}
onBlurCapture={autoScroll ? resume : undefined}
onTouchStart={autoScroll ? pause : undefined}
onTouchEnd={autoScroll ? resume : undefined}
>
<button type="button" className="card-slider-arrow prev" aria-label="Przewiń w lewo" onClick={() => scrollByCards(-1)}>
<Icon name="chevron" />
</button>
@@ -12977,6 +13068,41 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
const [recentlyViewed] = useState<RecentlyViewedItem[]>(() => 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<Record<string, number> | null>(null);
useEffect(() => {
let cancelled = false;
let timerId: number | null = null;
const load = async () => {
try {
const all = await apiFetch<ApiListingSummary[]>('/listings');
if (!cancelled) {
const counts: Record<string, number> = {};
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
<Link to={ROUTES.buy}>Zobacz wszystkie <Icon name="arrow" /></Link>
</div>
<CardSlider ariaLabel="Polecane ogłoszenia">
<CardSlider ariaLabel="Polecane ogłoszenia" autoScroll>
{liveSaleListings.length >= 4 ? (
liveSaleListings.slice(0, 12).map((item) => (
<Link className="listing-card listing-card-link" key={item.id} to={listingPath(item.id)}>
@@ -13134,31 +13260,6 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
)}
</CardSlider>
</section>
<BannerSlot slot="HOME_MIDDLE" />
{showRecentlyViewedOnHome && recentlyViewed.length > 0 && (
<section className="offers" aria-label="Ostatnio przeglądane">
<div className="section-head">
<h2>Ostatnio przeglądane</h2>
</div>
<CardSlider ariaLabel="Ostatnio przeglądane oferty">
{recentlyViewed.map((item) => (
<Link className="listing-card listing-card-link" key={item.id} to={listingPath(item.id)}>
<div className="property-media" style={{ backgroundImage: `url(${item.photo ?? heroImage})` }} />
<div className="listing-body">
<p>{[item.city, item.district].filter(Boolean).join(', ')}</p>
<small>{item.title}</small>
<div className="price-row">
<strong>{formatPrice(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''}</strong>
</div>
</div>
</Link>
))}
</CardSlider>
</section>
)}
</div>
<div className="home-right-stack">
@@ -13183,17 +13284,23 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
<h2>Przeglądaj miasta</h2>
<div className="city-grid">
{cities.map((city) => (
<article
<Link
className="city-card"
key={city.name}
to={buyCityPath(city.name)}
aria-label={`Zobacz oferty w mieście ${city.name}`}
style={{
backgroundImage: `linear-gradient(180deg, rgba(10, 21, 39, 0.1), rgba(10, 21, 39, 0.78)), url(${cityImage})`,
backgroundPosition: city.position,
}}
>
<strong>{city.name}</strong>
<span>{city.count}</span>
</article>
<span>
{cityCounts === null
? 'Ładowanie…'
: formatListingCountLabel(cityCounts[normalizeCityName(city.name)] ?? 0)}
</span>
</Link>
))}
<article className="city-card all-cities">
<span>Zobacz wszystkie miasta</span>
@@ -13205,8 +13312,37 @@ function HomePage({ filters, onOpenMap }: { filters: FilterState; onOpenMap: (ci
</div>
</section>
</div>
{/* Para banerow reklamowych we wspolnym wierszu - zawsze wyrownana do gornej krawedzi,
niezaleznie od wysokosci kolumn powyzej i od szerokosci okna. */}
<div className="home-banner-duo">
<BannerSlot slot="HOME_MIDDLE" />
<BannerSlot slot="HOME_TOP" />
</div>
{showRecentlyViewedOnHome && recentlyViewed.length > 0 && (
<section className="offers home-recent-row" aria-label="Ostatnio przeglądane">
<div className="section-head">
<h2>Ostatnio przeglądane</h2>
</div>
<CardSlider ariaLabel="Ostatnio przeglądane oferty" autoScroll>
{recentlyViewed.map((item) => (
<Link className="listing-card listing-card-link" key={item.id} to={listingPath(item.id)}>
<div className="property-media" style={{ backgroundImage: `url(${item.photo ?? heroImage})` }} />
<div className="listing-body">
<p>{[item.city, item.district].filter(Boolean).join(', ')}</p>
<small>{item.title}</small>
<div className="price-row">
<strong>{formatPrice(item.price)}{item.offerType === 'RENT' ? ' /mc' : ''}</strong>
</div>
</div>
</Link>
))}
</CardSlider>
</section>
)}
</div>
</section>
</>
@@ -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);
+8
View File
@@ -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();
+30 -1
View File
@@ -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 {