Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/app/[country]/[locale]/(storefront)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ export default async function StorefrontLayout({

return (
<>
<Header rootCategories={rootCategories} />
<Header rootCategories={rootCategories} basePath={basePath} />
{rootCategories.length > 0 && (
<nav aria-label="Category navigation" className="sr-only">
<CategoryLinks categories={rootCategories} basePath={basePath} />
</nav>
)}
<main className="flex-1">{children}</main>
<Footer />
<Footer rootCategories={rootCategories} basePath={basePath} />
</>
);
}
26 changes: 26 additions & 0 deletions src/components/layout/CartButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { ShoppingBag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCart } from "@/contexts/CartContext";

export function CartButton() {
const { itemCount, openCart } = useCart();

return (
<Button
variant="ghost"
size="icon-lg"
onClick={openCart}
aria-label="Open cart"
className="relative"
>
<ShoppingBag className="size-5" />
{itemCount > 0 && (
<span className="absolute top-0 right-0 bg-primary text-white text-xs font-medium rounded-full h-5 w-5 flex items-center justify-center">
{itemCount}
</span>
)}
</Button>
);
}
52 changes: 32 additions & 20 deletions src/components/layout/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
"use client";

import type { Category } from "@spree/sdk";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useStore } from "@/contexts/StoreContext";
import { POLICY_LINKS } from "@/lib/constants/policies";
import { extractBasePath } from "@/lib/utils/path";

export function Footer() {
const { storeName, storeDescription } = useStore();
const pathname = usePathname();
const basePath = extractBasePath(pathname);
const storeName = process.env.NEXT_PUBLIC_STORE_NAME || "Spree Store";
const storeDescription =
process.env.NEXT_PUBLIC_STORE_DESCRIPTION ||
"A modern e-commerce storefront powered by Spree Commerce and Next.js.";

interface FooterProps {
rootCategories: Category[];
basePath: string;
}

export function Footer({ rootCategories, basePath }: FooterProps) {
return (
<footer className="bg-primary text-gray-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 gap-8 md:grid-cols-5">
{/* Brand */}
<div className="col-span-1 md:col-span-2">
Expand All @@ -32,14 +35,16 @@ export function Footer() {
All Products
</Link>
</li>
<li>
<Link
href={`${basePath}/products`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
Categories
</Link>
</li>
{rootCategories.map((category) => (
<li key={category.id}>
<Link
href={`${basePath}/c/${category.permalink}`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
{category.name}
</Link>
</li>
))}
</ul>
</div>

Expand Down Expand Up @@ -94,8 +99,15 @@ export function Footer() {

<div className="mt-8 pt-8 border-t border-neutral-800 text-xs text-neutral-500 text-center">
<p>
&copy; {new Date().getFullYear()} {storeName}. Powered by Spree
Commerce.
&copy; {new Date().getFullYear()} {storeName}. Powered by
<Link
href="https://spreecommerce.org"
target="_blank"
className="text-neutral-400 hover:text-neutral-200 transition-colors"
>
Spree Commerce
</Link>{" "}
Comment thread
damianlegawiec marked this conversation as resolved.
& Next.js.
</p>
</div>
</div>
Expand Down
183 changes: 40 additions & 143 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,160 +1,57 @@
"use client";

import type { Category } from "@spree/sdk";
import { Search, ShoppingBag, User, X } from "lucide-react";
import { User } 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 { CartButton } from "@/components/layout/CartButton";
import { CountrySwitcher } from "@/components/layout/CountrySwitcher";
import { MobileMenu } from "@/components/layout/MobileMenu";
import { SearchBar } from "@/components/search/SearchBar";
import { SearchToggle } from "@/components/layout/SearchToggle";
import { Button } from "@/components/ui/button";
import { useCart } from "@/contexts/CartContext";
import { useStore } from "@/contexts/StoreContext";
import { extractBasePath } from "@/lib/utils/path";

const storeName = process.env.NEXT_PUBLIC_STORE_NAME || "Spree Store";

interface HeaderProps {
rootCategories: Category[];
basePath: string;
}

export function Header({ rootCategories }: HeaderProps) {
const { itemCount, openCart } = useCart();
const { storeName } = useStore();
const pathname = usePathname();
const basePath = extractBasePath(pathname);
const [searchOpen, setSearchOpen] = useState(false);
const searchTriggerRef = useRef<HTMLButtonElement>(null);

const closeSearch = useCallback(() => {
setSearchOpen(false);
searchTriggerRef.current?.focus();
}, []);

export function Header({ rootCategories, basePath }: HeaderProps) {
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-200 h-16 relative">
{/* Normal header content */}
<div
className={`absolute inset-0 transition-all duration-300 ease-in-out ${
searchOpen
? "translate-y-4 opacity-0 pointer-events-none"
: "translate-y-0 opacity-100"
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
<div className="flex items-center h-full w-full">
{/* Left section: hamburger */}
<div className="flex items-center flex-1">
<MobileMenu rootCategories={rootCategories} basePath={basePath} />
</div>

{/* Center section: logo (always centered via equal flex-1 siblings) */}
<div className="flex justify-center min-w-0">
<Link
href={basePath || "/"}
className="flex items-center min-w-0"
>
<Image
src="/spree.png"
alt={storeName}
width={90}
height={32}
className="h-8 w-auto max-w-full object-contain"
priority
/>
<SearchToggle
basePath={basePath}
left={<MobileMenu rootCategories={rootCategories} basePath={basePath} />}
center={
<Link href={basePath || "/"} className="flex items-center min-w-0">
<Image
src="/spree.png"
alt={storeName}
width={90}
height={32}
className="h-8 w-auto max-w-full object-contain"
priority
/>
</Link>
}
rightStart={
<div className="hidden lg:block">
<CountrySwitcher />
</div>
}
rightEnd={
<>
{/* Account - desktop only */}
<div className="hidden md:block">
<Button variant="ghost" size="icon-lg" asChild>
<Link href={`${basePath}/account`} aria-label="Account">
<User className="size-5" />
</Link>
</div>

{/* Right section: actions */}
<div className="flex items-center flex-1 justify-end space-x-2">
{/* Country/Currency Switcher - desktop only (hidden on mobile+tablet) */}
<div className="hidden lg:block">
<CountrySwitcher />
</div>

{/* Search toggle */}
<Button
ref={searchTriggerRef}
variant="ghost"
size="icon-lg"
onClick={() => setSearchOpen(true)}
aria-label="Open search"
aria-expanded={searchOpen}
aria-controls="search-overlay"
>
<Search className="size-5" />
</Button>

{/* Account - desktop only */}
<div className="hidden md:block">
<Button variant="ghost" size="icon-lg" asChild>
<Link href={`${basePath}/account`} aria-label="Account">
<User className="size-5" />
</Link>
</Button>
</div>

{/* Cart */}
<Button
variant="ghost"
size="icon-lg"
onClick={openCart}
aria-label="Open cart"
className="relative"
>
<ShoppingBag className="size-5" />
{itemCount > 0 && (
<span className="absolute top-0 right-0 bg-primary text-white text-xs font-medium rounded-full h-5 w-5 flex items-center justify-center">
{itemCount}
</span>
)}
</Button>
</div>
</Button>
</div>
</div>
</div>

{/* Click-outside overlay — closes search when clicking anywhere outside */}
{searchOpen && (
<div
className="fixed inset-0 z-40"
onClick={closeSearch}
role="presentation"
/>
)}

{/* Search bar - replaces header content */}
<div
id="search-overlay"
inert={!searchOpen}
onKeyDown={(e) => {
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"
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex items-center gap-3">
<div className="flex-1">
<SearchBar
key={String(searchOpen)}
basePath={basePath}
autoFocus={searchOpen}
onNavigate={closeSearch}
/>
</div>
<Button
variant="ghost"
size="icon-lg"
onClick={closeSearch}
aria-label="Close search"
>
<X className="size-5" />
</Button>
</div>
</div>
</header>
{/* Cart */}
<CartButton />
</>
}
/>
);
}
Loading
Loading