From 1e928e5a93922b488b67f883336ac9cde982a7e4 Mon Sep 17 00:00:00 2001 From: Ichitux Date: Sun, 28 Jun 2026 12:52:46 +0200 Subject: [PATCH] feat(mobile): native-feel sticky top bar + bottom tab nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the floating pill nav with persistent mobile chrome that frames scrolling content the way iOS/Android native apps do. New components: - TopBar: brand left, contextual action right (Admin pill for admins, bell for saved, Login button when logged out) - BottomNav: 4-tab fixed nav (Search, Scan [elevated center FAB], Saved, Profile). Saved/Profile disabled when logged out, tapping opens LoginModal - icons.jsx: 5 stroke-based SVG icons (Search, Scan, Bell, User, Cog) plus Logo; replaces emoji in nav chrome Behavior changes: - App.jsx: tracks publicScreen ('home'|'scan'|'search') so BottomNav can drive both top-level view + inner screen - PublicView: screen state lifted to App via props (back-compat defaults preserved so existing call sites don't break) - App.css: old mobile .view-switcher block hidden (replaced by BottomNav); .app padding bumped to clear the fixed bottom bar Desktop (>768px) unchanged — existing centered pill nav still works, new components hidden via display:none. Co-Authored-By: Claude --- frontend/src/App.css | 22 +------ frontend/src/App.jsx | 47 +++++++++++++- frontend/src/components/BottomNav.css | 89 +++++++++++++++++++++++++++ frontend/src/components/BottomNav.jsx | 44 +++++++++++++ frontend/src/components/TopBar.css | 86 ++++++++++++++++++++++++++ frontend/src/components/TopBar.jsx | 66 ++++++++++++++++++++ frontend/src/components/icons.jsx | 63 +++++++++++++++++++ frontend/src/views/PublicView.jsx | 12 +++- 8 files changed, 405 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/BottomNav.css create mode 100644 frontend/src/components/BottomNav.jsx create mode 100644 frontend/src/components/TopBar.css create mode 100644 frontend/src/components/TopBar.jsx create mode 100644 frontend/src/components/icons.jsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 248fe3c..a5a8d5f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -290,7 +290,7 @@ @media (max-width: 768px) { .app { - padding: 1.5rem 1rem calc(5rem + env(safe-area-inset-bottom)); + padding: 1rem 1rem calc(7rem + env(safe-area-inset-bottom)); } .app-header h1 { @@ -310,25 +310,7 @@ } .view-switcher { - position: fixed; - left: 0.75rem; - right: 0.75rem; - bottom: calc(0.75rem + env(safe-area-inset-bottom)); - z-index: 100; - width: auto; - max-width: 32rem; - margin: 0 auto; - border-radius: 999px; - border: 1px solid var(--border); - background: var(--surface); - box-shadow: - 0 10px 30px rgba(28, 25, 23, 0.12), - 0 2px 6px rgba(28, 25, 23, 0.06); - padding: 0.5rem; - overflow: visible; - justify-content: space-around; - align-items: stretch; - gap: 0.25rem; + display: none; /* ponytail: replaced by BottomNav on mobile */ } .view-switcher button { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 000dd37..b170a8b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,9 +5,12 @@ import AdminView from './views/AdminView'; import ProfileView from './views/ProfileView'; import LoginModal from './components/LoginModal'; import SavedNotifications from './components/SavedNotifications'; +import TopBar from './components/TopBar'; +import BottomNav from './components/BottomNav'; function App() { const [view, setView] = useState('public'); + const [publicScreen, setPublicScreen] = useState('home'); // 'home' | 'scan' | 'search' const [currentUser, setCurrentUser] = useState(null); const [authChecked, setAuthChecked] = useState(false); const [showLogin, setShowLogin] = useState(false); @@ -30,6 +33,7 @@ function App() { await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); setCurrentUser(null); setView('public'); + setPublicScreen('home'); } function handleProfileSaved(updated) { @@ -55,12 +59,47 @@ function App() { setShowLogin(true)} + screen={publicScreen} + onScreenChange={setPublicScreen} /> ); } + // ponytail: BottomNav active tab derived from view + publicScreen. Hidden on desktop via CSS. + function navActiveTab() { + if (view === 'profile') return 'profile'; + if (view === 'admin') return 'profile'; // no admin tab in bottom nav; profile still highlighted + if (publicScreen === 'scan') return 'scan'; + if (publicScreen === 'search') return 'search'; + return 'search'; // home → search is the default landing active + } + + function handleNavChange(tab) { + if (tab === 'search') { setView('public'); setPublicScreen('search'); return; } + if (tab === 'scan') { setView('public'); setPublicScreen('scan'); return; } + if (tab === 'saved') { + if (currentUser) { setShowSaved(true); setView('public'); setPublicScreen('home'); } + else setShowLogin(true); + return; + } + if (tab === 'profile') { + if (currentUser) setView('profile'); + else setShowLogin(true); + return; + } + } + return (
+ setShowSaved(true)} + onLoginRequest={() => setShowLogin(true)} + onAdminClick={() => setView(view === 'admin' ? 'public' : 'admin')} + /> + + ); +} + +export default BottomNav; \ No newline at end of file diff --git a/frontend/src/components/TopBar.css b/frontend/src/components/TopBar.css new file mode 100644 index 0000000..71353df --- /dev/null +++ b/frontend/src/components/TopBar.css @@ -0,0 +1,86 @@ +/* TopBar — mobile-only sticky header. Desktop hides via display:none above 768px. */ + +.top-bar { + display: none; +} + +@media (max-width: 768px) { + .top-bar { + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 50; + padding: calc(0.65rem + env(safe-area-inset-top)) 1rem 0.65rem; + background: var(--glass-bg); + -webkit-backdrop-filter: saturate(180%) blur(20px); + backdrop-filter: saturate(180%) blur(20px); + border-bottom: 1px solid var(--border); + } + + .topbar-brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--text-main); + } + + .topbar-brand-icon { + color: var(--primary); + flex-shrink: 0; + } + + .topbar-brand-text { + font-size: 1.05rem; + font-weight: 800; + letter-spacing: -0.02em; + } + + .topbar-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2.25rem; + } + + .topbar-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 2.25rem; + padding: 0.45rem 0.7rem; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-main); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + transition: background 0.15s, color 0.15s, border-color 0.15s; + } + + .topbar-action:hover { + background: var(--surface-muted); + } + + .topbar-action--admin { + color: var(--primary); + border-color: rgba(15, 118, 110, 0.25); + background: rgba(15, 118, 110, 0.08); + } + + .topbar-action--admin:hover { + background: rgba(15, 118, 110, 0.15); + } + + .topbar-action--login { + color: var(--primary); + border-color: rgba(15, 118, 110, 0.25); + } + + .topbar-action--placeholder { + width: 2.25rem; + } +} \ No newline at end of file diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx new file mode 100644 index 0000000..e84a86b --- /dev/null +++ b/frontend/src/components/TopBar.jsx @@ -0,0 +1,66 @@ +import { IconBell, IconCog, IconUser, IconLogo } from './icons'; +import './TopBar.css'; + +function TopBar({ + currentUser, + isAdmin, + onShowSaved, + onLoginRequest, + onAdminClick, + authChecked, +}) { + // ponytail: action slot priority — admin pill > bell (logged-in) > login button (logged-out). + // ponytail: hidden on desktop via .top-bar { display: none } above 768px. + function renderAction() { + if (isAdmin) { + return ( + + ); + } + if (currentUser) { + return ( + + ); + } + if (authChecked) { + return ( + + ); + } + return ; + } + + return ( +
+
+ + FarmaFinder +
+
{renderAction()}
+
+ ); +} + +export default TopBar; \ No newline at end of file diff --git a/frontend/src/components/icons.jsx b/frontend/src/components/icons.jsx new file mode 100644 index 0000000..a70c6d8 --- /dev/null +++ b/frontend/src/components/icons.jsx @@ -0,0 +1,63 @@ +// ponytail: stroke-based line icons, 24x24, currentColor. Emojis stay for in-content use. +// ponytail: add when a filled variant is needed for active states — swap stroke="currentColor" for fill. + +export function IconSearch({ size = 24, ...props }) { + return ( + + ); +} + +export function IconScan({ size = 24, ...props }) { + return ( + + ); +} + +export function IconBell({ size = 24, ...props }) { + return ( + + ); +} + +export function IconUser({ size = 24, ...props }) { + return ( + + ); +} + +export function IconCog({ size = 24, ...props }) { + return ( + + ); +} + +export function IconLogo({ size = 24, ...props }) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/views/PublicView.jsx b/frontend/src/views/PublicView.jsx index 119db93..3fd76d2 100644 --- a/frontend/src/views/PublicView.jsx +++ b/frontend/src/views/PublicView.jsx @@ -8,9 +8,15 @@ import HomeView from './HomeView'; import ScannerView from './ScannerView'; import { haversineKm, getUserPosition } from '../utils/geo'; -function PublicView({ currentUser, onLoginRequest }) { - // 'home' | 'scan' | 'search' - const [screen, setScreen] = useState('home'); +function PublicView({ + currentUser, + onLoginRequest, + screen: screenProp, + onScreenChange, +}) { + // ponytail: screen state lifted to App.jsx so BottomNav can switch directly. + const screen = screenProp ?? 'home'; + const setScreen = onScreenChange ?? (() => {}); const [searchQuery, setSearchQuery] = useState(''); const [medicines, setMedicines] = useState([]);