feat(mobile): native-feel sticky top bar + bottom tab nav
Build & Push Docker Images / test-backend (push) Successful in 22s
Build & Push Docker Images / test-frontend (push) Failing after 23s
Build & Push Docker Images / build-backend (push) Has been skipped
Build & Push Docker Images / build-frontend (push) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Ichitux
2026-06-28 12:52:46 +02:00
parent 5b75321406
commit 1e928e5a93
8 changed files with 405 additions and 24 deletions
+2 -20
View File
@@ -290,7 +290,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.app { .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 { .app-header h1 {
@@ -310,25 +310,7 @@
} }
.view-switcher { .view-switcher {
position: fixed; display: none; /* ponytail: replaced by BottomNav on mobile */
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;
} }
.view-switcher button { .view-switcher button {
+46 -1
View File
@@ -5,9 +5,12 @@ import AdminView from './views/AdminView';
import ProfileView from './views/ProfileView'; import ProfileView from './views/ProfileView';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import SavedNotifications from './components/SavedNotifications'; import SavedNotifications from './components/SavedNotifications';
import TopBar from './components/TopBar';
import BottomNav from './components/BottomNav';
function App() { function App() {
const [view, setView] = useState('public'); const [view, setView] = useState('public');
const [publicScreen, setPublicScreen] = useState('home'); // 'home' | 'scan' | 'search'
const [currentUser, setCurrentUser] = useState(null); const [currentUser, setCurrentUser] = useState(null);
const [authChecked, setAuthChecked] = useState(false); const [authChecked, setAuthChecked] = useState(false);
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
@@ -30,6 +33,7 @@ function App() {
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
setCurrentUser(null); setCurrentUser(null);
setView('public'); setView('public');
setPublicScreen('home');
} }
function handleProfileSaved(updated) { function handleProfileSaved(updated) {
@@ -55,12 +59,47 @@ function App() {
<PublicView <PublicView
currentUser={currentUser} currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)} onLoginRequest={() => 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 ( return (
<div className="app"> <div className="app">
<TopBar
currentUser={currentUser}
isAdmin={isAdmin}
authChecked={authChecked}
onShowSaved={() => setShowSaved(true)}
onLoginRequest={() => setShowLogin(true)}
onAdminClick={() => setView(view === 'admin' ? 'public' : 'admin')}
/>
<nav className="view-switcher" aria-label="Primary"> <nav className="view-switcher" aria-label="Primary">
<button <button
className={view === 'public' ? 'active' : ''} className={view === 'public' ? 'active' : ''}
@@ -97,6 +136,12 @@ function App() {
{activeView} {activeView}
<BottomNav
activeTab={navActiveTab()}
onChange={handleNavChange}
isLoggedIn={Boolean(currentUser)}
/>
{showLogin && ( {showLogin && (
<LoginModal <LoginModal
onLogin={handleLogin} onLogin={handleLogin}
@@ -111,4 +156,4 @@ function App() {
); );
} }
export default App; export default App;
+89
View File
@@ -0,0 +1,89 @@
/* BottomNav — mobile-only fixed bottom tab bar. Desktop hides via display:none above 768px. */
.bottom-nav {
display: none;
}
@media (max-width: 768px) {
.bottom-nav {
display: grid;
grid-template-columns: repeat(4, 1fr);
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 0.4rem 0.5rem calc(0.4rem + env(safe-area-inset-bottom));
background: var(--glass-bg);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-top: 1px solid var(--border);
}
.bottom-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0.1rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
font: inherit;
-webkit-tap-highlight-color: transparent;
transition: color 0.15s;
}
.bottom-nav-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nav-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
color: inherit;
transition: background 0.2s, color 0.2s, transform 0.2s;
}
/* Elevated center tab (Scan) — sits above the bar like iOS center FAB */
.bottom-nav-item:nth-child(2) {
margin-top: -1.1rem;
}
.nav-icon-wrap--elevated {
background: var(--primary);
color: #fff;
width: 3.25rem;
height: 3.25rem;
box-shadow:
0 0 0 4px var(--glass-bg),
0 8px 18px rgba(15, 118, 110, 0.4),
0 2px 6px rgba(15, 118, 110, 0.25);
}
.bottom-nav-item.active .nav-label {
color: var(--primary);
font-weight: 700;
}
.bottom-nav-item.active .nav-icon-wrap:not(.nav-icon-wrap--elevated) {
color: var(--primary);
}
.bottom-nav-item:active .nav-icon-wrap--elevated {
transform: scale(0.94);
}
.nav-label {
font-size: 0.68rem;
line-height: 1;
letter-spacing: 0.01em;
}
}
+44
View File
@@ -0,0 +1,44 @@
import { IconSearch, IconScan, IconBell, IconUser } from './icons';
import './BottomNav.css';
// ponytail: 4 tabs. Scan is the 2nd item (center) and gets the elevated teal pill.
// ponytail: when logged out, "saved" slot morphs into Login. Profile is disabled until login.
function BottomNav({ activeTab, onChange, isLoggedIn }) {
const tabs = [
{ id: 'search', label: 'Search', Icon: IconSearch },
{ id: 'scan', label: 'Scan', Icon: IconScan, elevated: true },
{ id: 'saved', label: 'Saved', Icon: IconBell, requiresAuth: true },
{ id: 'profile', label: 'Profile', Icon: IconUser, requiresAuth: true },
];
return (
<nav className="bottom-nav" aria-label="Primary">
{tabs.map(({ id, label, Icon, elevated, requiresAuth }) => {
const disabled = requiresAuth && !isLoggedIn;
const isActive = activeTab === id;
return (
<button
key={id}
type="button"
className={[
'bottom-nav-item',
isActive ? 'active' : '',
disabled ? 'disabled' : '',
].filter(Boolean).join(' ')}
onClick={() => onChange(id)}
disabled={disabled}
aria-current={isActive ? 'page' : undefined}
aria-label={label}
>
<span className={`nav-icon-wrap${elevated ? ' nav-icon-wrap--elevated' : ''}`}>
<Icon size={elevated ? 24 : 22} />
</span>
<span className="nav-label">{label}</span>
</button>
);
})}
</nav>
);
}
export default BottomNav;
+86
View File
@@ -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;
}
}
+66
View File
@@ -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 (
<button
type="button"
className="topbar-action topbar-action--admin"
onClick={onAdminClick}
aria-label="Admin panel"
>
<IconCog size={18} />
<span>Admin</span>
</button>
);
}
if (currentUser) {
return (
<button
type="button"
className="topbar-action"
onClick={onShowSaved}
aria-label="Saved notifications"
>
<IconBell size={22} />
</button>
);
}
if (authChecked) {
return (
<button
type="button"
className="topbar-action topbar-action--login"
onClick={onLoginRequest}
>
<IconUser size={18} />
<span>Login</span>
</button>
);
}
return <span className="topbar-action topbar-action--placeholder" />;
}
return (
<header className="top-bar">
<div className="topbar-brand">
<IconLogo size={22} className="topbar-brand-icon" />
<span className="topbar-brand-text">FarmaFinder</span>
</div>
<div className="topbar-actions">{renderAction()}</div>
</header>
);
}
export default TopBar;
+63
View File
@@ -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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
export function IconScan({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<path d="M7 8v8M11 8v8M15 8v8M19 8v8" />
</svg>
);
}
export function IconBell({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
);
}
export function IconUser({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
}
export function IconCog({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
export function IconLogo({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M19 8h-1V6a3 3 0 0 0-3-3H9a3 3 0 0 0-3 3v2H5a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a3 3 0 0 0-3-3zM8 6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2H8V6zm4 11a3 3 0 1 1 3-3 3 3 0 0 1-3 3z" />
</svg>
);
}
+9 -3
View File
@@ -8,9 +8,15 @@ import HomeView from './HomeView';
import ScannerView from './ScannerView'; import ScannerView from './ScannerView';
import { haversineKm, getUserPosition } from '../utils/geo'; import { haversineKm, getUserPosition } from '../utils/geo';
function PublicView({ currentUser, onLoginRequest }) { function PublicView({
// 'home' | 'scan' | 'search' currentUser,
const [screen, setScreen] = useState('home'); 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 [searchQuery, setSearchQuery] = useState('');
const [medicines, setMedicines] = useState([]); const [medicines, setMedicines] = useState([]);