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
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:
+2
-20
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
<PublicView
|
||||
currentUser={currentUser}
|
||||
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 (
|
||||
<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">
|
||||
<button
|
||||
className={view === 'public' ? 'active' : ''}
|
||||
@@ -97,6 +136,12 @@ function App() {
|
||||
|
||||
{activeView}
|
||||
|
||||
<BottomNav
|
||||
activeTab={navActiveTab()}
|
||||
onChange={handleNavChange}
|
||||
isLoggedIn={Boolean(currentUser)}
|
||||
/>
|
||||
|
||||
{showLogin && (
|
||||
<LoginModal
|
||||
onLogin={handleLogin}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user