Template
Add a Shop menu that lists the store's collections
The header now carries a "Shop" dropdown that fetches the collection list the first time it's opened and links each entry to its collection page. Fetching is deferred so the request only happens on open: useCollections loads on mount, which would put a collections query on every page. The new useCollectionsOnDemand holds off until load() runs, keeps the result for later opens, and clears its guard on failure so a retry can go through. Rendered in the desktop nav as a floating panel and in the mobile menu as an inline section, following the hand-rolled pattern in account-menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1vsTA4MX6qFd7nqSAKtdV
This commit is contained in:
co-authored by
Claude Opus 5
parent
ff4adba2fb
commit
95b191eeb8
@@ -6,6 +6,7 @@ import { useCartStore } from '@/hooks/use-shopify-cart';
|
|||||||
import CartDrawer from '@/components/shopify/cart-drawer';
|
import CartDrawer from '@/components/shopify/cart-drawer';
|
||||||
import SearchDialog from '@/components/shopify/search-dialog';
|
import SearchDialog from '@/components/shopify/search-dialog';
|
||||||
import AccountMenu from '@/components/shopify/account-menu';
|
import AccountMenu from '@/components/shopify/account-menu';
|
||||||
|
import ShopMenu from '@/components/shopify/shop-menu';
|
||||||
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Logo from '@/components/logo';
|
import Logo from '@/components/logo';
|
||||||
@@ -88,6 +89,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<div className="hidden md:flex items-center gap-x-8 text-sm">
|
<div className="hidden md:flex items-center gap-x-8 text-sm">
|
||||||
|
<ShopMenu />
|
||||||
{links.map((link, index) => (
|
{links.map((link, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
@@ -127,6 +129,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="md:hidden bg-background">
|
<div className="md:hidden bg-background">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
|
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
|
||||||
|
<ShopMenu mobile onNavigate={() => setMenuOpen(false)} />
|
||||||
{links.map((link, index) => (
|
{links.map((link, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { RiArrowDownSLine } from '@remixicon/react';
|
||||||
|
import { useCollectionsOnDemand } from '@/hooks/use-shopify-collections';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ShopMenuProps {
|
||||||
|
label?: string;
|
||||||
|
/** Renders inline inside the mobile menu instead of as a floating panel. */
|
||||||
|
mobile?: boolean;
|
||||||
|
/** Fires after a collection is picked, so the mobile menu can close itself. */
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShopMenu: React.FC<ShopMenuProps> = ({
|
||||||
|
label = 'Shop',
|
||||||
|
mobile = false,
|
||||||
|
onNavigate,
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { collections, loading, error, load } = useCollectionsOnDemand();
|
||||||
|
|
||||||
|
// The collection list is only worth fetching once someone opens the menu.
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !open;
|
||||||
|
setOpen(next);
|
||||||
|
if (next) load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
onNavigate?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const itemClasses = cn(
|
||||||
|
'block text-sm text-foreground hover:bg-accent transition-colors',
|
||||||
|
mobile ? 'px-3 py-2' : 'px-4 py-2'
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{loading &&
|
||||||
|
Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div key={index} className={cn(itemClasses, 'py-2.5')}>
|
||||||
|
<div className="h-3 w-2/3 animate-pulse bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={cn(itemClasses, 'hover:bg-transparent')}>
|
||||||
|
<p className="text-muted-foreground">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="mt-1 underline underline-offset-2 hover:text-muted-foreground"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && collections.length === 0 && (
|
||||||
|
<p className={cn(itemClasses, 'text-muted-foreground hover:bg-transparent')}>
|
||||||
|
No collections yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<Link
|
||||||
|
key={collection.id}
|
||||||
|
href={`/collections/${collection.handle}`}
|
||||||
|
onClick={close}
|
||||||
|
className={itemClasses}
|
||||||
|
>
|
||||||
|
{collection.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<Link
|
||||||
|
href="/collections"
|
||||||
|
onClick={close}
|
||||||
|
className={cn(itemClasses, 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
View all collections
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-x-1 text-foreground hover:text-muted-foreground transition-colors',
|
||||||
|
mobile && 'w-full justify-between'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<RiArrowDownSLine
|
||||||
|
className={cn('size-4 transition-transform', open && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{trigger}
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 flex flex-col border-l border-border pl-1">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{trigger}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{/* Catches the click that dismisses the panel. */}
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShopMenu;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
getCollections,
|
getCollections,
|
||||||
getCollectionProducts,
|
getCollectionProducts,
|
||||||
@@ -60,6 +60,36 @@ export function useCollections(first = 50) {
|
|||||||
return { collections, loading, error, refetch: fetchCollections };
|
return { collections, loading, error, refetch: fetchCollections };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deferred variant of useCollections: nothing is requested until `load` runs,
|
||||||
|
// so a menu can hold off until it's actually opened. The fetch happens once —
|
||||||
|
// re-opening reuses what's already in state.
|
||||||
|
export function useCollectionsOnDemand(first = 50) {
|
||||||
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const requested = useRef(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (requested.current) return;
|
||||||
|
requested.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setCollections(await getCollections(first));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching collections:', err);
|
||||||
|
// Let the next open (or a retry) try again.
|
||||||
|
requested.current = false;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load collections');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [first]);
|
||||||
|
|
||||||
|
return { collections, loading, error, load };
|
||||||
|
}
|
||||||
|
|
||||||
// Hook for fetching products in a collection
|
// Hook for fetching products in a collection
|
||||||
export function useCollectionProducts(
|
export function useCollectionProducts(
|
||||||
handle: string | null,
|
handle: string | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user