diff --git a/public/hamburger-menu.json b/public/hamburger-menu.json
new file mode 100644
index 00000000..6538aa0a
--- /dev/null
+++ b/public/hamburger-menu.json
@@ -0,0 +1,395 @@
+{
+ "nm": "Main Scene",
+ "ddd": 0,
+ "h": 667,
+ "w": 375,
+ "meta": { "g": "@lottiefiles/creator 1.82.0" },
+ "layers": [
+ {
+ "ty": 4,
+ "nm": "Top",
+ "sr": 1,
+ "st": 0,
+ "op": 300,
+ "ip": 0,
+ "hd": false,
+ "ddd": 0,
+ "bm": 0,
+ "hasMask": false,
+ "ao": 0,
+ "ks": {
+ "a": { "a": 0, "k": [0, 0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100, 100], "ix": 6 },
+ "sk": { "a": 0, "k": 0 },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 282.993],
+ "t": 0
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 332.993, 0],
+ "t": 15,
+ "ti": [0, 0.165, 0],
+ "to": [0, -0.118, 0]
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 332.993, 0],
+ "t": 58,
+ "ti": [0, 0.319, 0],
+ "to": [0, -0.118, 0]
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 333, 0],
+ "t": 60,
+ "ti": [0, 2.29, 0],
+ "to": [0, -5.714, 0]
+ },
+ { "s": [187.5, 283, 0], "t": 75 }
+ ],
+ "ix": 2
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [0],
+ "t": 15
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [45],
+ "t": 30
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [45],
+ "t": 45
+ },
+ { "s": [0], "t": 60 }
+ ],
+ "ix": 10
+ },
+ "sa": { "a": 0, "k": 0 },
+ "o": { "a": 0, "k": 100, "ix": 11 }
+ },
+ "shapes": [
+ {
+ "ty": "gr",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Group",
+ "nm": "Top",
+ "ix": 1,
+ "cix": 2,
+ "np": 2,
+ "it": [
+ {
+ "ty": "sh",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Shape - Group",
+ "nm": "Path 1",
+ "ix": 1,
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": false,
+ "i": [[0, 0], [0, 0]],
+ "o": [[0, 0], [0, 0]],
+ "v": [[-80.5, 0], [80.5, 0]]
+ },
+ "ix": 2
+ }
+ },
+ {
+ "ty": "st",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Graphic - Stroke",
+ "nm": "Stroke 1",
+ "lc": 2,
+ "lj": 1,
+ "ml": 4,
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 16, "ix": 5 },
+ "c": { "a": 0, "k": [0, 0, 0], "ix": 3 }
+ },
+ {
+ "ty": "tr",
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "p": { "a": 0, "k": [0, 0], "ix": 2 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "o": { "a": 0, "k": 100, "ix": 7 }
+ }
+ ]
+ }
+ ],
+ "ind": 1
+ },
+ {
+ "ty": 4,
+ "nm": "Middle",
+ "sr": 1,
+ "st": 0,
+ "op": 300,
+ "ip": 0,
+ "hd": false,
+ "ddd": 0,
+ "bm": 0,
+ "hasMask": false,
+ "ao": 0,
+ "ks": {
+ "a": { "a": 0, "k": [0, 0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100, 100], "ix": 6 },
+ "sk": { "a": 0, "k": 0 },
+ "p": { "a": 0, "k": [187.5, 332.993], "ix": 2 },
+ "r": { "a": 0, "k": 0, "ix": 10 },
+ "sa": { "a": 0, "k": 0 },
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [100],
+ "t": 13
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [0],
+ "t": 15
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [0],
+ "t": 60
+ },
+ { "s": [100], "t": 62 }
+ ],
+ "ix": 11
+ }
+ },
+ "shapes": [
+ {
+ "ty": "gr",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Group",
+ "nm": "Middle",
+ "ix": 1,
+ "cix": 2,
+ "np": 2,
+ "it": [
+ {
+ "ty": "sh",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Shape - Group",
+ "nm": "Path 1",
+ "ix": 1,
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": false,
+ "i": [[0, 0], [0, 0]],
+ "o": [[0, 0], [0, 0]],
+ "v": [[-80.5, 0], [80.5, 0]]
+ },
+ "ix": 2
+ }
+ },
+ {
+ "ty": "st",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Graphic - Stroke",
+ "nm": "Stroke 1",
+ "lc": 2,
+ "lj": 1,
+ "ml": 4,
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 16, "ix": 5 },
+ "c": { "a": 0, "k": [0, 0, 0], "ix": 3 }
+ },
+ {
+ "ty": "tr",
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "p": { "a": 0, "k": [0, 0], "ix": 2 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "o": { "a": 0, "k": 100, "ix": 7 }
+ }
+ ]
+ }
+ ],
+ "ind": 2
+ },
+ {
+ "ty": 4,
+ "nm": "Bottom",
+ "sr": 1,
+ "st": 0,
+ "op": 300,
+ "ip": 0,
+ "hd": false,
+ "ddd": 0,
+ "bm": 0,
+ "hasMask": false,
+ "ao": 0,
+ "ks": {
+ "a": { "a": 0, "k": [0, 0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100, 100], "ix": 6 },
+ "sk": { "a": 0, "k": 0 },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 382.993],
+ "t": 0
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 333, 0],
+ "t": 15,
+ "ti": [0, -0.001, 0],
+ "to": [0, 0.001, 0]
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 333, 0],
+ "t": 58,
+ "ti": [0, -0.7, 0],
+ "to": [0, 0.001, 0]
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [187.5, 333, 0],
+ "t": 60,
+ "ti": [0, -2.242, 0],
+ "to": [0, 5.605, 0]
+ },
+ { "s": [187.5, 383, 0], "t": 75 }
+ ],
+ "ix": 2
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [0],
+ "t": 15
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [-45],
+ "t": 30
+ },
+ {
+ "o": { "x": 0.333, "y": 0 },
+ "i": { "x": 0.667, "y": 1 },
+ "s": [-45],
+ "t": 45
+ },
+ { "s": [0], "t": 60 }
+ ],
+ "ix": 10
+ },
+ "sa": { "a": 0, "k": 0 },
+ "o": { "a": 0, "k": 100, "ix": 11 }
+ },
+ "shapes": [
+ {
+ "ty": "gr",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Group",
+ "nm": "Bottom",
+ "ix": 1,
+ "cix": 2,
+ "np": 2,
+ "it": [
+ {
+ "ty": "sh",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Shape - Group",
+ "nm": "Path 1",
+ "ix": 1,
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": false,
+ "i": [[0, 0], [0, 0]],
+ "o": [[0, 0], [0, 0]],
+ "v": [[-80.5, 0], [80.5, 0]]
+ },
+ "ix": 2
+ }
+ },
+ {
+ "ty": "st",
+ "bm": 0,
+ "hd": false,
+ "mn": "ADBE Vector Graphic - Stroke",
+ "nm": "Stroke 1",
+ "lc": 2,
+ "lj": 1,
+ "ml": 4,
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 16, "ix": 5 },
+ "c": { "a": 0, "k": [0, 0, 0], "ix": 3 }
+ },
+ {
+ "ty": "tr",
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "p": { "a": 0, "k": [0, 0], "ix": 2 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "o": { "a": 0, "k": 100, "ix": 7 }
+ }
+ ]
+ }
+ ],
+ "ind": 3
+ }
+ ],
+ "v": "5.7.0",
+ "fr": 60,
+ "op": 97,
+ "ip": 0,
+ "assets": []
+}
diff --git a/public/hamburger-menu.lottie b/public/hamburger-menu.lottie
new file mode 100644
index 00000000..97f7a91a
Binary files /dev/null and b/public/hamburger-menu.lottie differ
diff --git a/src/app/[country]/[locale]/(storefront)/layout.tsx b/src/app/[country]/[locale]/(storefront)/layout.tsx
index 3a9b372b..37ec088b 100644
--- a/src/app/[country]/[locale]/(storefront)/layout.tsx
+++ b/src/app/[country]/[locale]/(storefront)/layout.tsx
@@ -1,21 +1,58 @@
+import type { Category } from "@spree/sdk";
import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { getCategories } from "@/lib/data/categories";
interface StorefrontLayoutProps {
children: React.ReactNode;
+ params: Promise<{ country: string; locale: string }>;
+}
+
+function CategoryLinks({
+ categories,
+ basePath,
+}: {
+ categories: Category[];
+ basePath: string;
+}) {
+ return (
+
+ {categories.map((category) => (
+ -
+ {category.name}
+ {category.children && category.children.length > 0 && (
+
+ )}
+
+ ))}
+
+ );
}
export default async function StorefrontLayout({
children,
+ params,
}: StorefrontLayoutProps) {
- const rootCategories = await getCategories({ depth_eq: 0 })
+ const { country, locale } = await params;
+ const basePath = `/${country}/${locale}`;
+
+ const rootCategories = await getCategories({
+ depth_eq: 0,
+ expand: ["children.children"],
+ })
.then((res) => res.data)
.catch(() => []);
+ const menuCategories = rootCategories.flatMap((root) => root.children ?? []);
+
return (
<>
+ {menuCategories.length > 0 && (
+
+ )}
{children}
>
diff --git a/src/app/globals.css b/src/app/globals.css
index f1cba7c9..d718bb47 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -10,6 +10,12 @@
--font-sans: var(--font-geist);
--animate-slide-in-right: slide-in-right 0.3s ease-out;
--animate-slide-in-left: slide-in-left 0.3s ease-out;
+ --animate-hamburger-top-open: hamburger-top-open 0.35s ease-in-out forwards;
+ --animate-hamburger-top-close: hamburger-top-close 0.35s ease-in-out forwards;
+ --animate-hamburger-mid-open: hamburger-mid-open 0.15s ease-in-out forwards;
+ --animate-hamburger-mid-close: hamburger-mid-close 0.15s ease-in-out forwards;
+ --animate-hamburger-bottom-open: hamburger-bottom-open 0.35s ease-in-out forwards;
+ --animate-hamburger-bottom-close: hamburger-bottom-close 0.35s ease-in-out forwards;
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
@@ -78,11 +84,53 @@
}
}
+/* Hamburger ↔ X animations */
+@keyframes hamburger-top-open {
+ 0% { transform: translateY(0) rotate(0); }
+ 50% { transform: translateY(7px) rotate(0); }
+ 100% { transform: translateY(7px) rotate(45deg); }
+}
+@keyframes hamburger-top-close {
+ 0% { transform: translateY(7px) rotate(45deg); }
+ 50% { transform: translateY(7px) rotate(0); }
+ 100% { transform: translateY(0) rotate(0); }
+}
+@keyframes hamburger-mid-open {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+@keyframes hamburger-mid-close {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+@keyframes hamburger-bottom-open {
+ 0% { transform: translateY(0) rotate(0); }
+ 50% { transform: translateY(-7px) rotate(0); }
+ 100% { transform: translateY(-7px) rotate(-45deg); }
+}
+@keyframes hamburger-bottom-close {
+ 0% { transform: translateY(-7px) rotate(-45deg); }
+ 50% { transform: translateY(-7px) rotate(0); }
+ 100% { transform: translateY(0) rotate(0); }
+}
+
body {
font-family: var(--font-geist), system-ui, sans-serif;
font-weight: 400;
}
+/* Style native search clear button */
+input[type="search"]::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 14px;
+ width: 14px;
+ padding: 8px;
+ box-sizing: content-box;
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E") center/14px 14px no-repeat;
+ cursor: pointer;
+}
+
.product-carousel .swiper-button-next,
.product-carousel .swiper-button-prev {
display: none;
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 70ae4b93..5f31525a 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -1,16 +1,18 @@
"use client";
import type { Category } from "@spree/sdk";
-import { ShoppingBag, User } from "lucide-react";
+import { Search, ShoppingBag, User, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { useCallback, useRef, useState } from "react";
+import { CountrySwitcher } from "@/components/layout/CountrySwitcher";
+import { MobileMenu } from "@/components/layout/MobileMenu";
import { SearchBar } from "@/components/search/SearchBar";
import { Button } from "@/components/ui/button";
import { useCart } from "@/contexts/CartContext";
import { useStore } from "@/contexts/StoreContext";
import { extractBasePath } from "@/lib/utils/path";
-import { CountrySwitcher } from "./CountrySwitcher";
interface HeaderProps {
rootCategories: Category[];
@@ -21,51 +23,76 @@ export function Header({ rootCategories }: HeaderProps) {
const { storeName } = useStore();
const pathname = usePathname();
const basePath = extractBasePath(pathname);
+ const [searchOpen, setSearchOpen] = useState(false);
+ const searchTriggerRef = useRef(null);
+
+ const closeSearch = useCallback(() => {
+ setSearchOpen(false);
+ searchTriggerRef.current?.focus();
+ }, []);
return (
-
-
-
-
- {/* Logo */}
-
-
-
+
+ {/* Normal header content */}
+
+
+
+ {/* Left section: hamburger */}
+
+
+
- {/* Search */}
-
-
+ {/* Center section: logo (always centered via equal flex-1 siblings) */}
+
+
+
+
-
-
- {/* Navigation */}
-
+ {/* Right section: actions */}
+
+ {/* Country/Currency Switcher - desktop only (hidden on mobile+tablet) */}
+
+
+
- {/* Actions */}
-
- {/* Country/Currency Switcher */}
-
+ {/* Search toggle */}
+
+
+ {/* Account - desktop only */}
+
+
+
{/* Cart */}
-
- {/* Account */}
-
+
+ {/* Click-outside overlay — closes search when clicking anywhere outside */}
+ {searchOpen && (
+
+ )}
+
+ {/* Search bar - replaces header content */}
+
{
+ if (e.key === "Escape") closeSearch();
+ }}
+ className={`absolute inset-0 z-50 transition-all duration-300 ease-in-out ${
+ searchOpen
+ ? "translate-y-0 opacity-100"
+ : "-translate-y-4 opacity-0 pointer-events-none"
+ }`}
+ >
+
+
);
}
diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx
new file mode 100644
index 00000000..50e54e0c
--- /dev/null
+++ b/src/components/layout/MobileMenu.tsx
@@ -0,0 +1,450 @@
+"use client";
+
+import type { Category } from "@spree/sdk";
+import { ArrowLeft, Check, ChevronRight, User, X } from "lucide-react";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { useRef, useState } from "react";
+import { flushSync } from "react-dom";
+import { Button } from "@/components/ui/button";
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetFooter,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { useCart } from "@/contexts/CartContext";
+import { type CountryWithMarket, useStore } from "@/contexts/StoreContext";
+import { updateOrderMarket } from "@/lib/data/checkout";
+import { setStoreCookies } from "@/lib/utils/cookies";
+import { getPathWithoutPrefix } from "@/lib/utils/path";
+
+// Convert ISO country code to flag emoji
+function countryToFlag(countryCode: string): string {
+ const code = countryCode.toUpperCase();
+ if (code.length !== 2) return "";
+ const firstChar = code.charCodeAt(0) - 65 + 0x1f1e6;
+ const secondChar = code.charCodeAt(1) - 65 + 0x1f1e6;
+ return String.fromCodePoint(firstChar, secondChar);
+}
+
+type PanelType =
+ | { kind: "main" }
+ | { kind: "category"; category: Category }
+ | { kind: "country" };
+
+interface MobileMenuProps {
+ rootCategories: Category[];
+ basePath: string;
+}
+
+export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) {
+ const [open, setOpen] = useState(false);
+ const [hasInteracted, setHasInteracted] = useState(false);
+ const [panelStack, setPanelStack] = useState
([{ kind: "main" }]);
+ // animatedIndex trails panelStack — new panels mount off-screen, then animate in
+ const [animatedIndex, setAnimatedIndex] = useState(0);
+ const rafRef = useRef(null);
+ const timeoutRef = useRef(null);
+
+ const { country, currency, countries, setCountry } = useStore();
+ const { cart, refreshCart } = useCart();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const currentPanel = panelStack[panelStack.length - 1];
+
+ const cancelPendingCallbacks = () => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ };
+
+ const pushPanel = (panel: PanelType) => {
+ cancelPendingCallbacks();
+ // Step 1: mount the new panel off-screen (translate-x-full) via flushSync
+ flushSync(() => {
+ setPanelStack((prev) => [...prev, panel]);
+ });
+ // Step 2: on next frame, update animatedIndex to trigger slide-in
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ setAnimatedIndex((prev) => prev + 1);
+ });
+ };
+
+ const popPanel = () => {
+ cancelPendingCallbacks();
+ // Step 1: animate out by decrementing animatedIndex
+ setAnimatedIndex((prev) => Math.max(0, prev - 1));
+ // Step 2: after transition, remove the panel from the stack
+ timeoutRef.current = setTimeout(() => {
+ timeoutRef.current = null;
+ setPanelStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
+ }, 300);
+ };
+
+ const handleCountrySelect = async (entry: CountryWithMarket) => {
+ const newLocale = entry.default_locale || "en";
+ const newCurrency = entry.currency;
+ const pathRest = getPathWithoutPrefix(pathname);
+ const newPath = `/${entry.iso.toLowerCase()}/${newLocale}${pathRest}`;
+
+ if (cart && (cart.currency !== newCurrency || cart.locale !== newLocale)) {
+ const result = await updateOrderMarket(cart.id, {
+ currency: newCurrency,
+ locale: newLocale,
+ });
+ if (!result.success) return;
+ await refreshCart();
+ }
+
+ setStoreCookies(entry.iso.toLowerCase(), newLocale);
+ setCountry(entry.iso.toLowerCase());
+ router.push(newPath);
+ };
+
+ const handleOpenChange = (value: boolean) => {
+ setOpen(value);
+ if (!value) {
+ cancelPendingCallbacks();
+ setPanelStack([{ kind: "main" }]);
+ setAnimatedIndex(0);
+ }
+ };
+
+ const handleNavigate = (href: string) => {
+ setOpen(false);
+ router.push(href);
+ };
+
+ // Flatten root categories: show their children directly instead of taxonomy names
+ const menuCategories = rootCategories.flatMap((root) => root.children ?? []);
+
+ // Shared link style
+ const linkClass =
+ "text-left text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg px-3 py-2.5 text-base transition-colors";
+
+ // Shared button style for items with children (chevron)
+ const categoryButtonClass =
+ "flex items-center justify-between w-full text-left text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg px-3 py-2.5 text-base transition-colors";
+
+ return (
+
+ {/* Animated hamburger / X button — two-phase animation matching Lottie reference */}
+
+
+
+ Menu
+ {/* Menu header — changes based on active panel */}
+
+ {/* "Menu" title — visible when on main panel */}
+
+ Menu
+
+ {/* Back button + category name — visible on sub-panels */}
+
+
+
+
+ {/* Sliding panels container */}
+
+ {/* Main menu panel */}
+
+
+
+ {/* Footer: Country switcher (mobile + tablet) + Account (mobile only) */}
+
+
+
+
+
+
+ My Account
+
+
+
+
+
+ {/* Category sub-panels — one for each level in the stack */}
+ {panelStack.map((panel, index) => {
+ if (panel.kind !== "category") return null;
+ const isAnimatedIn = index <= animatedIndex;
+ let translateClass = "translate-x-full";
+ if (isAnimatedIn && index < panelStack.length - 1)
+ translateClass = "-translate-x-full";
+ else if (isAnimatedIn) translateClass = "translate-x-0";
+
+ return (
+
+ {/* Back button (mobile only — desktop uses the global header) */}
+
+
+
+
+ {/* Children */}
+
+
+ {/* "View all" at the bottom */}
+
+
+
+
+ );
+ })}
+
+ {/* Country selector panel */}
+
+
+
+
+ {countries.map((c) => {
+ const isSelected =
+ c.iso.toLowerCase() === country.toLowerCase();
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx
index 3b73b554..3af6c6a2 100644
--- a/src/components/products/ProductCard.tsx
+++ b/src/components/products/ProductCard.tsx
@@ -68,7 +68,7 @@ export const ProductCard = memo(function ProductCard({
alt={product.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
- sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 300px"
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 50vw, 300px"
iconClassName="w-16 h-16"
/>
{onSale && (
diff --git a/src/components/products/ProductGrid.tsx b/src/components/products/ProductGrid.tsx
index 432e24e8..731af7fb 100644
--- a/src/components/products/ProductGrid.tsx
+++ b/src/components/products/ProductGrid.tsx
@@ -23,7 +23,7 @@ export function ProductGrid({
}
return (
-
+
{products.map((product, index) => (
+
{[...Array(6)].map((_, i) => (
))}
diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx
index 9fa65ec1..d7238847 100644
--- a/src/components/search/SearchBar.tsx
+++ b/src/components/search/SearchBar.tsx
@@ -3,7 +3,7 @@
import type { Product } from "@spree/sdk";
import { Search } from "lucide-react";
import { useRouter } from "next/navigation";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import {
InputGroup,
InputGroupAddon,
@@ -16,9 +16,11 @@ import { getProducts } from "@/lib/data/products";
interface SearchBarProps {
basePath: string;
+ autoFocus?: boolean;
+ onNavigate?: () => void;
}
-export function SearchBar({ basePath }: SearchBarProps) {
+export function SearchBar({ basePath, autoFocus, onNavigate }: SearchBarProps) {
const router = useRouter();
const { currency } = useStore();
const [query, setQuery] = useState("");
@@ -27,8 +29,9 @@ export function SearchBar({ basePath }: SearchBarProps) {
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef
(null);
- const containerRef = useRef(null);
const debounceRef = useRef(null);
+ const blurTimeoutRef = useRef(null);
+ const requestIdRef = useRef(0);
// Fetch suggestions
const fetchSuggestions = useCallback(
@@ -38,6 +41,7 @@ export function SearchBar({ basePath }: SearchBarProps) {
return;
}
+ const currentRequestId = requestIdRef.current;
setLoading(true);
try {
const response = await getProducts({
@@ -45,55 +49,45 @@ export function SearchBar({ basePath }: SearchBarProps) {
fields: ["name", "slug", "price", "thumbnail_url"],
limit: 6,
});
+ // Discard stale responses if a newer query has been issued
+ if (requestIdRef.current !== currentRequestId) return;
setSuggestions(response.data);
if (response.data.length > 0) {
trackQuickSearch(response.data, searchQuery, currency);
}
} catch (error) {
+ if (requestIdRef.current !== currentRequestId) return;
console.error("Search failed:", error);
setSuggestions([]);
} finally {
- setLoading(false);
+ if (requestIdRef.current === currentRequestId) {
+ setLoading(false);
+ }
}
},
[currency],
);
- // Debounced search
- useEffect(() => {
+ // Debounced search — called from onChange handler, no useEffect needed
+ const handleQueryChange = (value: string) => {
+ setQuery(value);
+ setIsOpen(true);
+ setSelectedIndex(-1);
+ requestIdRef.current += 1;
+
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
- if (query.length >= 2) {
+ if (value.length >= 2) {
debounceRef.current = setTimeout(() => {
- fetchSuggestions(query);
+ fetchSuggestions(value);
}, 300);
} else {
setSuggestions([]);
+ setLoading(false);
}
-
- return () => {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- };
- }, [query, fetchSuggestions]);
-
- // Close on click outside
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- if (
- containerRef.current &&
- !containerRef.current.contains(e.target as Node)
- ) {
- setIsOpen(false);
- }
- };
-
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, []);
+ };
// Handle form submit
const handleSubmit = (e: React.FormEvent) => {
@@ -102,6 +96,7 @@ export function SearchBar({ basePath }: SearchBarProps) {
router.push(`${basePath}/products?q=${encodeURIComponent(query.trim())}`);
setIsOpen(false);
inputRef.current?.blur();
+ onNavigate?.();
}
};
@@ -111,6 +106,21 @@ export function SearchBar({ basePath }: SearchBarProps) {
router.push(`${basePath}/products/${product.slug}`);
setIsOpen(false);
setQuery("");
+ onNavigate?.();
+ };
+
+ // Close suggestions on blur — delayed to allow click on suggestions
+ const handleBlur = () => {
+ blurTimeoutRef.current = setTimeout(() => {
+ setIsOpen(false);
+ }, 200);
+ };
+
+ // Cancel blur timeout when interacting with suggestions
+ const handleSuggestionsMouseDown = () => {
+ if (blurTimeoutRef.current) {
+ clearTimeout(blurTimeoutRef.current);
+ }
};
// Handle keyboard navigation
@@ -144,21 +154,19 @@ export function SearchBar({ basePath }: SearchBarProps) {
isOpen && (suggestions.length > 0 || loading || query.length >= 2);
return (
-