feat: Implement mobile-first navigation with bottom bar and FAB

Implemented mobile-optimized navigation structure:

1. Bottom Navigation (Mobile Only)
   - 5 items: Dashboard, Orders, Products, Customers, More
   - Fixed at bottom, always visible
   - Thumb-friendly positioning
   - Active state indication
   - Hidden on desktop (md:hidden)

2. More Menu Page
   - Overflow menu for Coupons and Settings
   - Clean list layout with icons
   - Descriptions for each item
   - Chevron indicators

3. FAB (Floating Action Button)
   - Context-aware system via FABContext
   - Fixed bottom-right (72px from bottom)
   - Hidden on desktop (md:hidden)
   - Ready for contextual actions per page

4. FAB Context System
   - Global state for FAB configuration
   - setFAB() / clearFAB() methods
   - Supports icon, label, onClick, visibility
   - Allows pages to control FAB behavior

5. Layout Updates
   - Added pb-14 to main for bottom nav spacing
   - BottomNav and FAB in mobile fullscreen layout
   - Wrapped app with FABProvider

Structure (Mobile):
┌─────────────────────────────────┐
│ App Bar (will hide on scroll)   │
├─────────────────────────────────┤
│ Page Header (sticky, contextual)│
├─────────────────────────────────┤
│ Submenu (sticky)                │
├─────────────────────────────────┤
│ Content (scrollable)            │
│                        [+] FAB  │
├─────────────────────────────────┤
│ Bottom Nav (fixed)              │
└─────────────────────────────────┘

Next Steps:
- Implement scroll-hide for app bar
- Add contextual FAB per page
- Test on real devices

Files Created:
- BottomNav.tsx: Bottom navigation component
- More/index.tsx: More menu page
- FABContext.tsx: FAB state management
- FAB.tsx: Floating action button component
- useScrollDirection.ts: Scroll detection hook

Files Modified:
- App.tsx: Added bottom nav, FAB, More route, providers
This commit is contained in:
dwindown
2025-11-06 20:21:12 +07:00
parent 4be283c4a4
commit 76624bb473
6 changed files with 261 additions and 6 deletions

View File

@@ -30,7 +30,10 @@ import SubmenuBar from './components/nav/SubmenuBar';
import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar';
import { DashboardProvider } from '@/contexts/DashboardContext';
import { PageHeaderProvider } from '@/contexts/PageHeaderContext';
import { FABProvider } from '@/contexts/FABContext';
import { PageHeader } from '@/components/PageHeader';
import { BottomNav } from '@/components/nav/BottomNav';
import { FAB } from '@/components/FAB';
import { useActiveSection } from '@/hooks/useActiveSection';
import { NAV_TREE_VERSION } from '@/nav/tree';
import { __ } from '@/lib/i18n';
@@ -192,6 +195,7 @@ import SettingsIndex from '@/routes/Settings';
import SettingsStore from '@/routes/Settings/Store';
import SettingsPayments from '@/routes/Settings/Payments';
import SettingsShipping from '@/routes/Settings/Shipping';
import MorePage from '@/routes/More';
// Addon Route Component - Dynamically loads addon components
function AddonRoute({ config }: { config: any }) {
@@ -369,6 +373,9 @@ function AppRoutes() {
{/* Customers */}
<Route path="/customers" element={<CustomersIndex />} />
{/* More */}
<Route path="/more" element={<MorePage />} />
{/* Settings */}
<Route path="/settings" element={<SettingsIndex />} />
<Route path="/settings/store" element={<SettingsStore />} />
@@ -436,12 +443,14 @@ function Shell() {
) : (
<SubmenuBar items={main.children} fullscreen={true} />
)}
<main className="flex-1 flex flex-col min-h-0 min-w-0">
<main className="flex-1 flex flex-col min-h-0 min-w-0 pb-14">
<PageHeader fullscreen={true} />
<div className="flex-1 overflow-auto p-4 min-w-0">
<AppRoutes />
</div>
</main>
<BottomNav />
<FAB />
</div>
)
) : (
@@ -510,11 +519,13 @@ function AuthWrapper() {
}
return (
<FABProvider>
<PageHeaderProvider>
<DashboardProvider>
<Shell />
</DashboardProvider>
</PageHeaderProvider>
</FABProvider>
);
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { useFAB } from '@/contexts/FABContext';
import { Button } from '@/components/ui/button';
export function FAB() {
const { config } = useFAB();
if (!config || config.visible === false) {
return null;
}
return (
<Button
onClick={config.onClick}
size="lg"
className="fixed bottom-[72px] right-4 z-40 w-14 h-14 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-200 md:hidden"
aria-label={config.label}
>
{config.icon || <Plus className="w-6 h-6" />}
</Button>
);
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { LayoutDashboard, ReceiptText, Package, Users, MoreHorizontal } from 'lucide-react';
import { __ } from '@/lib/i18n';
interface BottomNavItem {
to: string;
icon: React.ReactNode;
label: string;
startsWith?: string;
}
const navItems: BottomNavItem[] = [
{
to: '/dashboard',
icon: <LayoutDashboard className="w-5 h-5" />,
label: __('Dashboard'),
startsWith: '/dashboard'
},
{
to: '/orders',
icon: <ReceiptText className="w-5 h-5" />,
label: __('Orders'),
startsWith: '/orders'
},
{
to: '/products',
icon: <Package className="w-5 h-5" />,
label: __('Products'),
startsWith: '/products'
},
{
to: '/customers',
icon: <Users className="w-5 h-5" />,
label: __('Customers'),
startsWith: '/customers'
},
{
to: '/more',
icon: <MoreHorizontal className="w-5 h-5" />,
label: __('More'),
startsWith: '/more'
}
];
export function BottomNav() {
const location = useLocation();
const isActive = (item: BottomNavItem) => {
if (item.startsWith) {
return location.pathname.startsWith(item.startsWith);
}
return location.pathname === item.to;
};
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-border safe-area-inset-bottom md:hidden">
<div className="flex items-center justify-around h-14">
{navItems.map((item) => {
const active = isActive(item);
return (
<NavLink
key={item.to}
to={item.to}
className={`flex flex-col items-center justify-center flex-1 h-full gap-0.5 transition-colors ${
active
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{item.icon}
<span className="text-[10px] font-medium leading-none">
{item.label}
</span>
</NavLink>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,43 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
export interface FABConfig {
icon?: ReactNode;
label: string;
onClick: () => void;
visible?: boolean;
variant?: 'primary' | 'secondary';
}
interface FABContextType {
config: FABConfig | null;
setFAB: (config: FABConfig | null) => void;
clearFAB: () => void;
}
const FABContext = createContext<FABContextType | undefined>(undefined);
export function FABProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<FABConfig | null>(null);
const setFAB = (newConfig: FABConfig | null) => {
setConfig(newConfig);
};
const clearFAB = () => {
setConfig(null);
};
return (
<FABContext.Provider value={{ config, setFAB, clearFAB }}>
{children}
</FABContext.Provider>
);
}
export function useFAB() {
const context = useContext(FABContext);
if (!context) {
throw new Error('useFAB must be used within FABProvider');
}
return context;
}

View File

@@ -0,0 +1,30 @@
import { useState, useEffect } from 'react';
export function useScrollDirection() {
const [scrollDirection, setScrollDirection] = useState<'up' | 'down'>('up');
const [lastScrollY, setLastScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY;
if (currentScrollY > lastScrollY && currentScrollY > 50) {
// Scrolling down
setScrollDirection('down');
} else if (currentScrollY < lastScrollY) {
// Scrolling up
setScrollDirection('up');
}
setLastScrollY(currentScrollY);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [lastScrollY]);
return scrollDirection;
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Tag, Settings as SettingsIcon, ChevronRight } from 'lucide-react';
import { __ } from '@/lib/i18n';
interface MenuItem {
icon: React.ReactNode;
label: string;
description: string;
to: string;
}
const menuItems: MenuItem[] = [
{
icon: <Tag className="w-5 h-5" />,
label: __('Coupons'),
description: __('Manage discount codes and promotions'),
to: '/coupons'
},
{
icon: <SettingsIcon className="w-5 h-5" />,
label: __('Settings'),
description: __('Configure your store settings'),
to: '/settings'
}
];
export default function MorePage() {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-background pb-20">
{/* Header */}
<div className="sticky top-0 z-10 bg-background border-b">
<div className="px-4 py-4">
<h1 className="text-2xl font-bold">{__('More')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{__('Additional features and settings')}
</p>
</div>
</div>
{/* Menu Items */}
<div className="divide-y">
{menuItems.map((item) => (
<button
key={item.to}
onClick={() => navigate(item.to)}
className="w-full flex items-center gap-4 px-4 py-4 hover:bg-accent transition-colors"
>
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
{item.icon}
</div>
<div className="flex-1 text-left min-w-0">
<div className="font-medium">{item.label}</div>
<div className="text-sm text-muted-foreground truncate">
{item.description}
</div>
</div>
<ChevronRight className="w-5 h-5 text-muted-foreground flex-shrink-0" />
</button>
))}
</div>
</div>
);
}