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:
2026-04-10 23:44:56 +02:00
parent be3759b53a
commit 24ed71975f

View 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>
);
}