feat(27-01): create BottomTabBar component
- Fixed bottom tab bar for mobile (md:hidden) with z-20 stacking
- 4 tabs: Home, Collection, Setups, Search with Lucide icons
- Collection and Setups fire openAuthPrompt for anonymous users
- Search tab calls openCatalogSearch('collection') to open overlay
- Active route highlighting via useMatchRoute
- Framer Motion entry animation (y slide + fade)
- iOS safe area padding with env(safe-area-inset-bottom)
[Rule 1 - Bug] Used 'house' icon instead of 'home': lucide-react has no 'Home' icon (only 'House')
This commit is contained in:
95
src/client/components/BottomTabBar.tsx
Normal file
95
src/client/components/BottomTabBar.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { LucideIcon } from "../lib/iconData";
|
||||
import { useUIStore } from "../stores/uiStore";
|
||||
|
||||
interface TabItemProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function TabItemWrapper({
|
||||
icon,
|
||||
label,
|
||||
isActive,
|
||||
children,
|
||||
}: TabItemProps & { children?: React.ReactNode }) {
|
||||
const activeClass = "text-gray-900";
|
||||
const inactiveClass = "text-gray-400";
|
||||
const colorClass = isActive ? activeClass : inactiveClass;
|
||||
|
||||
return (
|
||||
<span className={`flex flex-col items-center gap-0.5 py-2 px-4 ${colorClass}`}>
|
||||
<LucideIcon name={icon} size={20} />
|
||||
<span className="text-xs">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomTabBar() {
|
||||
const { data: auth } = useAuth();
|
||||
const isAuthenticated = !!auth?.user;
|
||||
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
|
||||
const openAuthPrompt = useUIStore((s) => s.openAuthPrompt);
|
||||
const matchRoute = useMatchRoute();
|
||||
|
||||
const isHome = !!matchRoute({ to: "/" });
|
||||
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
|
||||
const isSetups = !!matchRoute({ to: "/setups", fuzzy: true });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="fixed bottom-0 left-0 right-0 md:hidden z-20 bg-white border-t border-gray-100 pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<div className="flex justify-around">
|
||||
{/* Home tab — always a Link */}
|
||||
<Link to="/">
|
||||
<TabItemWrapper icon="house" label="Home" isActive={isHome} />
|
||||
</Link>
|
||||
|
||||
{/* Collection tab — Link if authenticated, button if anonymous */}
|
||||
{isAuthenticated ? (
|
||||
<Link to="/collection">
|
||||
<TabItemWrapper
|
||||
icon="package"
|
||||
label="Collection"
|
||||
isActive={isCollection}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={openAuthPrompt}>
|
||||
<TabItemWrapper
|
||||
icon="package"
|
||||
label="Collection"
|
||||
isActive={isCollection}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Setups tab — Link if authenticated, button if anonymous */}
|
||||
{isAuthenticated ? (
|
||||
<Link to="/setups">
|
||||
<TabItemWrapper icon="layers" label="Setups" isActive={isSetups} />
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={openAuthPrompt}>
|
||||
<TabItemWrapper icon="layers" label="Setups" isActive={isSetups} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search tab — always a button, opens CatalogSearchOverlay */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCatalogSearch("collection")}
|
||||
>
|
||||
<TabItemWrapper icon="search" label="Search" isActive={false} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user