Compare commits

...
4 Commits
Author SHA1 Message Date
Rami BitarandClaude Opus 5 95b191eeb8 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
2026-08-05 15:03:53 -04:00
Rami BitarandClaude Opus 5 ff4adba2fb Render the header and footer from the root layout
Every page was importing Header and Footer and repeating the same store
name, logo, copyright and nav links. Hoisting both into the root layout
renders them once, and a new config/site.ts holds the values they shared.

The body becomes a flex column so the footer sits at the bottom of short
pages. not-found and error now render inside that chrome, so their
full-viewport centering drops to 60vh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1vsTA4MX6qFd7nqSAKtdV
2026-08-05 15:03:44 -04:00
Rami BitarandClaude Opus 5 8602e03079 Revert "Add Handlebars templates for theming the storefront"
This reverts commit 3442f19816.

Removes the three .hbr siblings, returning the tree to ebdb0be. The
compiled files themselves were never generated from the templates, so
app/globals.css, app/layout.tsx and components/logo.tsx are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013amafJZMQmXgeFEotYWfaw
2026-08-04 22:08:22 -04:00
Rami BitarandClaude Opus 5 3442f19816 Add Handlebars templates for theming the storefront
Three files gain a .hbr sibling so a storefront can be compiled with a
store's own branding: app/globals.css, app/layout.tsx and
components/logo.tsx. The committed files stay as they are and remain the
hand-maintained default.

Fourteen variables drive them: shopName, logoUrl, primaryColor,
accentColor, bgColor, fontHeader and fontBody are supplied per store,
while fgColor, primaryFgColor, accentFgColor, surfaceColor, mutedColor,
mutedFgColor and borderColor derive from those unless pinned.

Two changes were needed for the fonts to be swappable. The CSS variables
next/font declares are now generic (--font-heading-family,
--font-body-family, --font-mono-family) rather than named after Geist,
and headings set font-family: var(--font-heading) so fontHeader has an
effect when it differs from fontBody.

The compiler lives outside this repo so the template carries no build
script or dependency for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013amafJZMQmXgeFEotYWfaw
2026-08-04 17:36:26 -04:00
20 changed files with 360 additions and 310 deletions
@@ -1,5 +1,3 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Activate your account — Shop' };
@@ -13,8 +11,6 @@ export default async function Page({
const { id, token } = await params;
return (
<>
<Header storeName="Shop" logoUrl="" />
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Activate your account"
@@ -33,7 +29,5 @@ export default async function Page({
redirectTo="/account"
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
);
}
-6
View File
@@ -1,13 +1,9 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Sign in — Shop' };
export default function Page() {
return (
<>
<Header storeName="Shop" logoUrl="" />
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Sign in"
@@ -38,7 +34,5 @@ export default function Page() {
}
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
);
}
-8
View File
@@ -1,6 +1,4 @@
import { redirect } from 'next/navigation';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import OrderHistory from '@/components/shopify/order-history';
import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer';
@@ -16,9 +14,6 @@ export default async function Page() {
if (!customer) redirect('/account/login');
return (
<>
<Header storeName="Shop" logoUrl="" />
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
Order history
@@ -31,8 +26,5 @@ export default async function Page() {
<OrderHistory customer={customer} />
</div>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
);
}
-6
View File
@@ -1,13 +1,9 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Reset password — Shop' };
export default function Page() {
return (
<>
<Header storeName="Shop" logoUrl="" />
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Reset password"
@@ -23,7 +19,5 @@ export default function Page() {
}
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
);
}
-6
View File
@@ -1,13 +1,9 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Create account — Shop' };
export default function Page() {
return (
<>
<Header storeName="Shop" logoUrl="" />
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Create account"
@@ -43,7 +39,5 @@ export default function Page() {
}
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
);
}
-6
View File
@@ -1,5 +1,3 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Set a new password — Shop' };
@@ -13,8 +11,6 @@ export default async function Page({
const { id, token } = await params;
return (
<>
<Header storeName="Shop" logoUrl="" />
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Set a new password"
@@ -32,7 +28,5 @@ export default async function Page({
redirectTo="/account"
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
);
}
+1 -19
View File
@@ -1,23 +1,5 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import CollectionDetail from '@/components/shopify/collection-detail';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<CollectionDetail />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
return <CollectionDetail />;
}
+1 -19
View File
@@ -1,23 +1,5 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
return <Collections title="Our Collections" />;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Error({
reset: () => void;
}) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
Something went wrong
+13 -2
View File
@@ -1,7 +1,10 @@
import React from 'react';
import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import StoreAssistant from '@/components/shopify/store-assistant';
import { site } from '@/config/site';
const geist = Geist({
subsets: ['latin'],
@@ -20,8 +23,16 @@ export default function RootLayout({
}) {
return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-body antialiased bg-background text-foreground m-0 p-0">
{children}
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
{/* Header owns the cart drawer, search and account menu, so every
route gets them by rendering here rather than page by page. */}
<Header
storeName={site.storeName}
logoUrl={site.logoUrl}
links={site.navLinks}
/>
<div className="flex-1">{children}</div>
<Footer storeName={site.storeName} copyright={site.copyright} />
<StoreAssistant />
</body>
</html>
+1 -1
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
404
-16
View File
@@ -1,26 +1,10 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Products from '@/components/shopify/products';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Products
title="Shopify Hydrogen Storefront"
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
}
-18
View File
@@ -1,6 +1,4 @@
import { notFound } from 'next/navigation';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
export function generateStaticParams() {
@@ -33,16 +31,6 @@ export default async function PolicyPage({
}
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
@@ -55,11 +43,5 @@ export default async function PolicyPage({
/>
</div>
</main>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
}
-14
View File
@@ -1,25 +1,11 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import ProductDetail from '@/components/shopify/product-detail';
import ProductRecommendations from '@/components/shopify/product-recommendations';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<ProductDetail addToCartLabel="Add to Cart" />
<ProductRecommendations title="You May Also Like" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
}
+1 -19
View File
@@ -1,6 +1,4 @@
import { Suspense } from 'react';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import SearchResults from '@/components/shopify/search-results';
export const metadata = {
@@ -9,25 +7,9 @@ export const metadata = {
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
{/* useSearchParams needs a Suspense boundary to prerender this route. */}
// useSearchParams needs a Suspense boundary to prerender this route.
<Suspense fallback={<div className="py-10" />}>
<SearchResults />
</Suspense>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
}
+1 -19
View File
@@ -1,23 +1,5 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/shop/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
return <Collections title="Our Collections" />;
}
+3
View File
@@ -6,6 +6,7 @@ import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog';
import AccountMenu from '@/components/shopify/account-menu';
import ShopMenu from '@/components/shopify/shop-menu';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import Logo from '@/components/logo';
@@ -88,6 +89,7 @@ const Header: React.FC<HeaderProps> = ({
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-8 text-sm">
<ShopMenu />
{links.map((link, index) => (
<Link
key={index}
@@ -127,6 +129,7 @@ const Header: React.FC<HeaderProps> = ({
{menuOpen && (
<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">
<ShopMenu mobile onNavigate={() => setMenuOpen(false)} />
{links.map((link, index) => (
<Link
key={index}
+152
View File
@@ -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;
+12
View File
@@ -0,0 +1,12 @@
import type { NavLink } from '@/components/shopify/header';
/** Chrome shared by every route — rendered once in the root layout. */
export const site = {
storeName: 'Shop',
logoUrl: '',
copyright: '© 2026 Shop. All rights reserved.',
navLinks: [
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
] as NavLink[],
};
+31 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
getCollections,
getCollectionProducts,
@@ -60,6 +60,36 @@ export function useCollections(first = 50) {
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
export function useCollectionProducts(
handle: string | null,