From 09d4b2f32b21d64e1c982124a60ae20456c4ed3d Mon Sep 17 00:00:00 2001 From: Przemek Zatorski Date: Thu, 24 Sep 2026 07:11:14 +0200 Subject: [PATCH] Nawigacja mobilna - dolny pasek i menu w szufladzie Gorne menu na telefonie mialo poziomy pasek przewijania, a najwazniejsze skroty nie mialy sie gdzie zmiescic. - dolny pasek (<= 760 px): Start, Szukaj, Dodaj, Ulubione, Konto - pasek dzialow (<= 1100 px) przenosi sie do szuflady pod przyciskiem menu - w naglowku na telefonie zostaja wiadomosci, dzwonek i menu - kafelki narzedzi schodza do dwoch i jednej kolumny - karty hero wracaja do przeplywu, zamiast nachodzic na liste korzysci - spis tresci stron prawnych nie rozpycha juz strony przez tabele Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 211 ++++++++++++++++++-- frontend/src/styles.css | 340 ++++++++++++++++++++++++++++++--- frontend/tests/routing.spec.ts | 59 ++++++ 3 files changed, 561 insertions(+), 49 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0d5f15..b850776 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,17 @@ const plLogoDark = new URL('./assets/pl_logo_dark.png', import.meta.url).href; // Podswietlenie aktywnej pozycji menu wyznacza NavLink na podstawie adresu. const navLinkClass = ({ isActive }: { isActive: boolean }) => (isActive ? 'active' : ''); +// Glowne menu serwisu. Jedno zrodlo dla paska na desktopie i dla szuflady na telefonie - +// dzieki temu nowy dzial nie zniknie z ktorejs wersji nawigacji. +const MAIN_NAV: { to: string; label: string; icon: string }[] = [ + { to: ROUTES.buy, label: 'Kupuję', icon: 'search' }, + { to: ROUTES.rent, label: 'Wynajmuję', icon: 'key' }, + { to: ROUTES.sell, label: 'Sprzedaję', icon: 'tag' }, + { to: ROUTES.valuation, label: 'Wycena mieszkania', icon: 'calculator' }, + { to: ROUTES.services, label: 'Firmy i usługi', icon: 'hammer' }, + { to: ROUTES.guides, label: 'Poradniki', icon: 'document' }, +]; + type MessageThreadListing = { listingId: number; title: string; @@ -3319,6 +3330,7 @@ function App() { + ); @@ -23607,6 +23619,9 @@ function Header({ const notificationPopoverRef = useRef(null); const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); const accountMenuRef = useRef(null); + // Szuflada z pelnym menu - na telefonach i tabletach poziomy pasek dzialow sie nie miesci. + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const location = useLocation(); // Dane powiadomień z centralnego providera. W dzwonku pokazujemy nieprzeczytane (znikają po kliknięciu). const displayNotifications = toDisplayNotifications(serverNotifications); @@ -23668,6 +23683,33 @@ function Header({ }; }, [isAccountMenuOpen]); + // Szuflada zamyka sie po przejsciu na inna trase i klawiszem Escape; przy otwartej + // blokujemy przewijanie strony pod spodem. + useEffect(() => { + setIsMobileMenuOpen(false); + }, [location.pathname, location.search]); + + useEffect(() => { + if (!isMobileMenuOpen) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const onEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsMobileMenuOpen(false); + } + }; + + window.addEventListener('keydown', onEscape); + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener('keydown', onEscape); + }; + }, [isMobileMenuOpen]); + // Pozycje menu sa linkami; zamkniecie popovera to efekt uboczny kliknięcia, nie nawigacja. const closeAccountMenu = () => setIsAccountMenuOpen(false); @@ -23688,24 +23730,11 @@ function Header({
@@ -23837,11 +23866,157 @@ function Header({ Dodaj ogłoszenie za darmo 0 zł • Bez prowizji + +
+ + {isMobileMenuOpen && ( +
setIsMobileMenuOpen(false)}> + +
+ )} ); } +// Dolny pasek nawigacji na telefonach - pieć najczesciej uzywanych miejsc w serwisie. +// Wyrozniony srodkowy przycisk prowadzi do dodawania ogloszenia (gosc trafi na logowanie). +function MobileNav() { + const { user } = useAuth(); + + return ( + + ); +} + function NotificationsPage({ onOpenNotification }: { onOpenNotification: (notification: DisplayNotification) => void }) { const [activeTab, setActiveTab] = useState<'all' | NotificationCategory>('all'); const { notifications: serverNotifications, unreadCount, markAllRead, clearAll } = useNotifications(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fba934d..79b8f55 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -144,6 +144,13 @@ svg { padding: 11px 14px; } +/* Przycisk menu na ciemnym pasku administratora. */ +.header-admin .header-burger { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.18); + color: #dbe4f0; +} + .brand-group { align-items: center; display: flex; @@ -366,6 +373,219 @@ svg { opacity: 0.9; } +/* --- Nawigacja mobilna -------------------------------------------------------------- + Na waskich ekranach poziomy pasek dzialow sie nie miesci. Zamiast przewijania w bok: + przycisk z trzema kreskami otwiera szuflade z pelnym menu (<= 1100 px), a pieć + najczesciej uzywanych miejsc siedzi w dolnym pasku (<= 760 px). Oba elementy sa + domyslnie ukryte i wlaczaja sie w regulach @media nizej w pliku. */ +.header-burger { + align-items: center; + background: #f1f5fa; + border: 1px solid #dce5f0; + border-radius: 10px; + color: #16233b; + display: none; + height: 42px; + justify-content: center; + padding: 0; + width: 42px; +} + +.header-burger svg { + font-size: 22px; +} + +.mobile-menu-overlay { + background: rgba(9, 20, 38, 0.5); + inset: 0; + position: fixed; + z-index: 200; +} + +.mobile-menu { + animation: mobile-menu-in 0.18s ease-out; + background: #ffffff; + box-shadow: -18px 0 40px rgba(9, 20, 38, 0.25); + display: flex; + flex-direction: column; + height: 100%; + margin-left: auto; + max-width: 340px; + overflow-y: auto; + padding: 16px 16px 26px; + width: 86%; +} + +@keyframes mobile-menu-in { + from { transform: translateX(16px); opacity: 0.4; } + to { transform: translateX(0); opacity: 1; } +} + +.mobile-menu-head { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 6px; +} + +.mobile-menu-head strong { + font-size: 18px; +} + +.mobile-menu-head button { + background: transparent; + border: 0; + color: #526171; + font-size: 30px; + line-height: 1; + padding: 0 6px; +} + +.mobile-menu-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.mobile-menu-group h3 { + color: #7b8a9c; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + margin: 14px 0 4px; + text-transform: uppercase; +} + +.mobile-menu-group a, +.mobile-menu-logout { + align-items: center; + background: transparent; + border: 0; + border-radius: 10px; + color: #111a2e; + display: flex; + font-size: 15px; + font-weight: 800; + gap: 12px; + padding: 12px 10px; + text-align: left; + text-decoration: none; +} + +.mobile-menu-group a svg, +.mobile-menu-logout svg { + color: #526171; + flex: 0 0 auto; + font-size: 20px; +} + +.mobile-menu-group a.active { + background: rgba(18, 167, 100, 0.1); + color: #0f8f55; +} + +.mobile-menu-group a.active svg { + color: #0f8f55; +} + +.mobile-menu-group a b { + background: #e4372e; + border-radius: 999px; + color: #ffffff; + font-size: 11px; + font-weight: 900; + margin-left: auto; + padding: 2px 7px; +} + +.mobile-menu-logout, +.mobile-menu-logout svg { + color: #c53a2f; +} + +.mobile-menu-cta { + align-items: center; + background: #089858; + border-radius: 10px; + color: #ffffff; + display: flex; + font-size: 15px; + font-weight: 900; + gap: 10px; + justify-content: center; + margin-top: 22px; + padding: 14px; + text-decoration: none; +} + +.mobile-menu-cta svg { + font-size: 20px; +} + +/* Dolny pasek: nad trescia strony, ale pod oknami modalnymi (z-index 50 i wyzej). */ +.mobile-nav { + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.97); + border-top: 1px solid #e2e9f2; + bottom: 0; + display: none; + grid-template-columns: repeat(5, minmax(0, 1fr)); + left: 0; + padding: 8px 0 max(8px, env(safe-area-inset-bottom)); + position: fixed; + right: 0; + z-index: 40; +} + +.mobile-nav a { + align-items: center; + color: #526171; + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + text-decoration: none; +} + +.mobile-nav a svg { + font-size: 22px; +} + +.mobile-nav a span { + font-size: 10px; + font-weight: 800; +} + +.mobile-nav a.active { + color: #0f8f55; +} + +.mobile-nav-add { + color: #0f8f55; +} + +.mobile-nav-add-circle { + align-items: center; + background: #089858; + border: 4px solid #ffffff; + border-radius: 999px; + box-shadow: 0 8px 18px rgba(8, 152, 88, 0.35); + color: #ffffff; + display: flex; + height: 52px; + justify-content: center; + margin-top: -26px; + width: 52px; +} + +.mobile-nav-add-circle svg { + font-size: 26px; +} + +.mobile-nav-add:active .mobile-nav-add-circle { + transform: scale(0.95); +} + .hero { min-height: 700px; overflow: hidden; @@ -806,6 +1026,21 @@ svg { grid-template-columns: repeat(4, minmax(0, 1fr)); } +/* Cztery kolumny mieszcza sie tylko na duzym ekranie - nizej kafelki schodza do dwoch, + a na telefonie do jednej. Bez tego na 390 px kazdy kafelek mial ok. 70 px szerokosci. */ +@media (max-width: 1100px) { + .tool-grid { + gap: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .tool-grid { + grid-template-columns: minmax(0, 1fr); + } +} + .tool-card, .free-card, .listing-card { @@ -12399,32 +12634,39 @@ a.city-card:hover { @media (max-width: 1100px) { .header { - grid-template-columns: 1fr auto; - height: auto; - padding-bottom: 18px; - padding-top: 18px; + gap: 12px; + grid-template-columns: auto minmax(0, 1fr); + height: 72px; + padding: 0 20px; } + /* Pasek dzialow nie miesci sie w jednej linii - caly przenosi sie do szuflady. */ .nav { - grid-column: 1 / -1; - justify-content: flex-start; - overflow-x: auto; - padding-bottom: 4px; + display: none; } - .nav button, .nav a { - padding: 8px 0 12px; - } - - .nav button.active::after, .nav a.active::after { - bottom: 3px; + .header-burger { + display: inline-flex; } .header-actions { gap: 14px; } - .icon-text span { + /* Pasek administratora ma wlasne, luzniejsze reguly na szerokim ekranie - na telefonie + ikony musza zostac w jednej linii, inaczej przycisk menu spada pod naglowek. */ + .header-admin { + min-height: 72px; + padding: 0 20px; + } + + .header-admin .header-actions { + flex-wrap: nowrap; + } + + /* Podpisy skrotow ustepuja miejsca, ale awatar konta to tez - musi zostac, + inaczej po zalogowaniu w naglowku wisi sama strzalka menu. */ + .icon-text span:not(.avatar) { display: none; } @@ -12716,9 +12958,27 @@ a.city-card:hover { @media (max-width: 760px) { .header { - display: flex; - flex-wrap: wrap; - padding: 16px 18px; + gap: 8px; + height: 66px; + padding: 0 14px; + } + + /* Dolny pasek przejmuje najwazniejsze skroty; tresc dostaje na niego miejsce, + a baner zgody podnosi sie ponad pasek, zeby go nie zaslanial. */ + .mobile-nav { + display: grid; + } + + .site-shell { + padding-bottom: 66px; + /* `clip` zamiast `hidden`: ucina przypadkowe wystawanie w bok (na telefonie potrafilo + dac 1 px przewijania), ale nie tworzy kontenera przewijania, wiec naglowek + nadal trzyma sie gory (position: sticky). */ + overflow-x: clip; + } + + .cookie-banner { + bottom: 60px; } .map-field-num, @@ -12737,8 +12997,23 @@ a.city-card:hover { min-width: 0; } + /* Na telefonie w naglowku zostaja tylko skroty, ktorych nie ma nigdzie indziej: + wiadomosci, dzwonek, konto i menu. Ulubione sa w dolnym pasku, odznaka ADMIN + powtarza sie w panelu, wiec tu ustepuja miejsca. */ .brand strong { - font-size: 24px; + font-size: 20px; + } + + .brand-logo-img { + height: 38px; + } + + /* Awatar z rozwijanym menu konta jest zbedny - caly panel konta jest w szufladzie + pod przyciskiem menu, a skrot "Konto" w dolnym pasku. */ + .brand-admin-badge, + .favorites-shortcut, + .account-menu-wrap { + display: none; } .header-actions { @@ -12769,8 +13044,12 @@ a.city-card:hover { padding-right: 18px; } + /* Karty "Zainteresowani kupujący" i "360°" stoja na sztywnych wspolrzednych, ktore na + telefonie nachodzily na liste korzysci i na siebie nawzajem. Zamiast dobierac kolejne + piksele wracaja do normalnego przeplywu pod trescia, a sekcja rosnie do ich wysokosci. */ .hero-inner { - height: 760px; + height: auto; + padding-bottom: 118px; padding-top: 44px; } @@ -12789,17 +13068,14 @@ a.city-card:hover { margin-top: 34px; } - .ai-card { - left: 18px; - right: auto; - top: 388px; - width: min(270px, calc(100% - 36px)); - } - + .ai-card, .tour-card { - left: 18px; + left: auto; + margin-top: 18px; + position: static; right: auto; - top: 590px; + top: auto; + width: 100%; } .search-wrap { @@ -13259,7 +13535,6 @@ a.city-card:hover { .tour-card { align-items: flex-start; flex-direction: column; - top: 610px; } .free-card button, @@ -33022,6 +33297,9 @@ a.listing-detail-back { .legal-main { display: grid; gap: 16px; + /* Bez minmax(0, ...) szeroka tabela (min-width 620 px) rozpychala kolumne i cala strone + na telefonie - tak zostaje w swoim kontenerze i to on przewija sie w bok. */ + grid-template-columns: minmax(0, 1fr); } .legal-card { @@ -33148,7 +33426,7 @@ a.listing-detail-back { } .legal-layout { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } .legal-toc { diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index 14e71b0..d4bc894 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -689,3 +689,62 @@ test.describe('Prywatnosc', () => { await expect(page.locator('.listing-detail-tour-embed iframe')).toHaveCount(1); }); }); + +// Telefon: poziome menu nie miesci sie w naglowku, wiec dzialy sa w szufladzie, +// a najczestsze miejsca w dolnym pasku. Kazda pozycja to link ze zmiana adresu. +test.describe('Nawigacja mobilna', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('naglowek nie przewija sie w bok, menu glowne chowa sie w szufladzie', async ({ page }) => { + await goto(page, ROUTES.home); + await expect(page.locator('.nav')).toBeHidden(); + + const overflow = await page.evaluate(() => { + const de = document.documentElement; + return de.scrollWidth - de.clientWidth; + }); + expect(overflow).toBeLessThanOrEqual(0); + + await page.getByRole('button', { name: 'Menu serwisu' }).click(); + await expect(page.locator('.mobile-menu')).toBeVisible(); + await page.locator('.mobile-menu').getByRole('link', { name: 'Wynajmuję' }).click(); + await expect(page).toHaveURL(new RegExp(`${ROUTES.rent}$`)); + await expect(page.locator('.mobile-menu')).toHaveCount(0); + }); + + test('dolny pasek prowadzi pod wlasne adresy', async ({ page }) => { + await goto(page, ROUTES.home); + // Baner zgody stoi nad dolnym paskiem - na telefonie trzeba go najpierw zamknac. + await page.getByRole('button', { name: 'Tylko niezbędne' }).click(); + const bar = page.locator('.mobile-nav'); + await expect(bar).toBeVisible(); + + await bar.getByRole('link', { name: 'Szukaj' }).click(); + await expect(page).toHaveURL(new RegExp(`${ROUTES.buy}$`)); + + await bar.getByRole('link', { name: 'Dodaj ogłoszenie' }).click(); + // Dodawanie wymaga logowania - goscia przejmuje ProtectedRoute. + await expect(page).toHaveURL(new RegExp(`${ROUTES.login}$`)); + + await bar.getByRole('link', { name: 'Start' }).click(); + await expect(page).toHaveURL(/\/$/); + }); + + test('dolny pasek nie zaslania stopki', async ({ page }) => { + await goto(page, ROUTES.buy); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(400); + + const footerBottom = await page.locator('.site-footer').evaluate((el) => el.getBoundingClientRect().bottom); + const barTop = await page.locator('.mobile-nav').evaluate((el) => el.getBoundingClientRect().top); + expect(footerBottom).toBeLessThanOrEqual(barTop + 1); + }); + + test('na desktopie nie ma dolnego paska ani przycisku menu', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await goto(page, ROUTES.home); + await expect(page.locator('.mobile-nav')).toBeHidden(); + await expect(page.locator('.header-burger')).toBeHidden(); + await expect(page.locator('.nav')).toBeVisible(); + }); +});