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:
@@ -30,7 +30,10 @@ import SubmenuBar from './components/nav/SubmenuBar';
|
|||||||
import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar';
|
import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar';
|
||||||
import { DashboardProvider } from '@/contexts/DashboardContext';
|
import { DashboardProvider } from '@/contexts/DashboardContext';
|
||||||
import { PageHeaderProvider } from '@/contexts/PageHeaderContext';
|
import { PageHeaderProvider } from '@/contexts/PageHeaderContext';
|
||||||
|
import { FABProvider } from '@/contexts/FABContext';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
import { BottomNav } from '@/components/nav/BottomNav';
|
||||||
|
import { FAB } from '@/components/FAB';
|
||||||
import { useActiveSection } from '@/hooks/useActiveSection';
|
import { useActiveSection } from '@/hooks/useActiveSection';
|
||||||
import { NAV_TREE_VERSION } from '@/nav/tree';
|
import { NAV_TREE_VERSION } from '@/nav/tree';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
@@ -192,6 +195,7 @@ import SettingsIndex from '@/routes/Settings';
|
|||||||
import SettingsStore from '@/routes/Settings/Store';
|
import SettingsStore from '@/routes/Settings/Store';
|
||||||
import SettingsPayments from '@/routes/Settings/Payments';
|
import SettingsPayments from '@/routes/Settings/Payments';
|
||||||
import SettingsShipping from '@/routes/Settings/Shipping';
|
import SettingsShipping from '@/routes/Settings/Shipping';
|
||||||
|
import MorePage from '@/routes/More';
|
||||||
|
|
||||||
// Addon Route Component - Dynamically loads addon components
|
// Addon Route Component - Dynamically loads addon components
|
||||||
function AddonRoute({ config }: { config: any }) {
|
function AddonRoute({ config }: { config: any }) {
|
||||||
@@ -369,6 +373,9 @@ function AppRoutes() {
|
|||||||
{/* Customers */}
|
{/* Customers */}
|
||||||
<Route path="/customers" element={<CustomersIndex />} />
|
<Route path="/customers" element={<CustomersIndex />} />
|
||||||
|
|
||||||
|
{/* More */}
|
||||||
|
<Route path="/more" element={<MorePage />} />
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={<SettingsIndex />} />
|
<Route path="/settings" element={<SettingsIndex />} />
|
||||||
<Route path="/settings/store" element={<SettingsStore />} />
|
<Route path="/settings/store" element={<SettingsStore />} />
|
||||||
@@ -436,12 +443,14 @@ function Shell() {
|
|||||||
) : (
|
) : (
|
||||||
<SubmenuBar items={main.children} fullscreen={true} />
|
<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} />
|
<PageHeader fullscreen={true} />
|
||||||
<div className="flex-1 overflow-auto p-4 min-w-0">
|
<div className="flex-1 overflow-auto p-4 min-w-0">
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<BottomNav />
|
||||||
|
<FAB />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -510,11 +519,13 @@ function AuthWrapper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FABProvider>
|
||||||
<PageHeaderProvider>
|
<PageHeaderProvider>
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
</DashboardProvider>
|
</DashboardProvider>
|
||||||
</PageHeaderProvider>
|
</PageHeaderProvider>
|
||||||
|
</FABProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
admin-spa/src/components/FAB.tsx
Normal file
23
admin-spa/src/components/FAB.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
admin-spa/src/components/nav/BottomNav.tsx
Normal file
82
admin-spa/src/components/nav/BottomNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
admin-spa/src/contexts/FABContext.tsx
Normal file
43
admin-spa/src/contexts/FABContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
30
admin-spa/src/hooks/useScrollDirection.ts
Normal file
30
admin-spa/src/hooks/useScrollDirection.ts
Normal 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;
|
||||||
|
}
|
||||||
66
admin-spa/src/routes/More/index.tsx
Normal file
66
admin-spa/src/routes/More/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user