Compare commits

..
5 Commits
Author SHA1 Message Date
Rami BitarandClaude Opus 5 0263a59c6a Add page metadata across every route
The root layout exported no metadata, so routes without their own export
rendered no title at all, and no route had a description. Add a root
metadata block with a title template, Open Graph and Twitter defaults,
and fill in the routes that were missing tags.

- config/site.ts: description and env-driven absolute url for metadataBase
- home, /collections and /shop/collections: titles and descriptions
- products/[handle] and collections/[handle]: generateMetadata off the
  existing catalog fetchers, with canonical and OG image
- existing titles: drop the hand-written " — Shop" suffix now that the
  root template appends it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5jT4fJJCnqPx3umEvXRk
2026-08-09 09:35:57 -04:00
Rami BitarandClaude Opus 5 a230187e9f Re-enable image optimization and default header logo text
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK7oe8iYPiNvVCvg5z1eyQ
2026-08-09 09:07:42 -04:00
Rami BitarandClaude Opus 5 70ebd65a18 Disable the image optimizer again
Restore images.unoptimized so <Image> renders as a passthrough. The
next/image call sites stay as they are; they simply serve the original
Shopify URLs rather than routing through /_next/image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uSQQNWrmiYDRW9L9VnsGC
2026-08-08 17:57:17 -04:00
Rami BitarandClaude Opus 5 82c9626c75 Render Shopify imagery through next/image
Turn the optimizer back on by dropping images.unoptimized, and widen the
Shopify remote pattern to **.shopify.com so any image subdomain resolves.
With the optimizer live, remotePatterns is now actually enforced.

Convert the seven Shopify CDN call sites: the product/collection cards and
the gallery carousel size with fill + sizes, while the cart, order and
search thumbnails carry explicit dimensions. The avatar, markdown,
attachment, assistant and logo images stay plain <img> — they take
blob:/data: or arbitrary hosts that next/image cannot process.

The lightbox needs w-auto h-auto: the width and height attributes
next/image requires make both axes definite, so max-w/max-h clamp them
independently instead of preserving the ratio. object-contain masked that,
but the stretched element covered the overlay and ate the backdrop clicks
that close the dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uSQQNWrmiYDRW9L9VnsGC
2026-08-08 17:48:19 -04:00
Rami Bitar 58742d5d00 Initial commit 2026-08-08 14:21:24 -04:00
64 changed files with 1678 additions and 3261 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# Shopify Storefront # Shopify Storefront
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN= # NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07 # NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07
# Store assistant — set to 1 to show the Ask launcher; anything else hides it # Store assistant — set to 1 to show the Ask launcher; anything else hides it
NEXT_PUBLIC_ENABLE_AI=0 NEXT_PUBLIC_ENABLE_AI=0
+3
View File
@@ -5,3 +5,6 @@ next-env.d.ts
.yarn/install-state.gz .yarn/install-state.gz
.env*.local .env*.local
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit
.hydrogen-gql-*.json
+19 -25
View File
@@ -1,8 +1,6 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm from '@/components/shopify/account-form'; import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Activate your account — Shop' }; export const metadata = { title: 'Activate your account' };
// Shopify's emailed activation link is /account/activate/{id}/{token}. // Shopify's emailed activation link is /account/activate/{id}/{token}.
export default async function Page({ export default async function Page({
@@ -13,27 +11,23 @@ export default async function Page({
const { id, token } = await params; const { id, token } = await params;
return ( return (
<> <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<Header storeName="Shop" logoUrl="" /> <AccountForm
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> title="Activate your account"
<AccountForm description="Choose a password to finish setting up your account."
title="Activate your account" fields={[
description="Choose a password to finish setting up your account." {
fields={[ name: 'password',
{ label: 'Password',
name: 'password', type: 'password',
label: 'Password', autoComplete: 'new-password',
type: 'password', },
autoComplete: 'new-password', ]}
}, submitLabel="Activate account"
]} endpoint="/api/account/activate"
submitLabel="Activate account" extraPayload={{ id, activationToken: token }}
endpoint="/api/account/activate" redirectTo="/account"
extraPayload={{ id, activationToken: token }} />
redirectTo="/account" </main>
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+30 -36
View File
@@ -1,44 +1,38 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Sign in — Shop' }; export const metadata = { title: 'Sign in' };
export default function Page() { export default function Page() {
return ( return (
<> <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<Header storeName="Shop" logoUrl="" /> <AccountForm
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> title="Sign in"
<AccountForm fields={[
title="Sign in" { name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
fields={[ {
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' }, name: 'password',
{ label: 'Password',
name: 'password', type: 'password',
label: 'Password', autoComplete: 'current-password',
type: 'password', },
autoComplete: 'current-password', ]}
}, submitLabel="Sign in"
]} endpoint="/api/account/login"
submitLabel="Sign in" redirectTo="/account"
endpoint="/api/account/login" footer={
redirectTo="/account" <>
footer={ <span>
<> New here?{' '}
<span> <AccountFormLink href="/account/register">
New here?{' '} Create an account
<AccountFormLink href="/account/register">
Create an account
</AccountFormLink>
</span>
<AccountFormLink href="/account/recover">
Forgot your password?
</AccountFormLink> </AccountFormLink>
</> </span>
} <AccountFormLink href="/account/recover">
/> Forgot your password?
</main> </AccountFormLink>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." /> </>
</> }
/>
</main>
); );
} }
+12 -20
View File
@@ -1,11 +1,9 @@
import { redirect } from 'next/navigation'; 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 OrderHistory from '@/components/shopify/order-history';
import { getSessionToken } from '@/services/shopify/session'; import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer'; import { getCustomer } from '@/services/shopify/customer';
export const metadata = { title: 'Order history — Shop' }; export const metadata = { title: 'Order history' };
export default async function Page() { export default async function Page() {
const token = await getSessionToken(); const token = await getSessionToken();
@@ -16,23 +14,17 @@ export default async function Page() {
if (!customer) redirect('/account/login'); if (!customer) redirect('/account/login');
return ( return (
<> <main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
<Header storeName="Shop" logoUrl="" /> <h1 className="text-2xl md:text-3xl font-normal text-foreground">
Order history
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{customer.displayName} · {customer.email}
</p>
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12"> <div className="mt-10">
<h1 className="text-2xl md:text-3xl font-normal text-foreground"> <OrderHistory customer={customer} />
Order history </div>
</h1> </main>
<p className="mt-1 text-sm text-muted-foreground">
{customer.displayName} · {customer.email}
</p>
<div className="mt-10">
<OrderHistory customer={customer} />
</div>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+16 -22
View File
@@ -1,29 +1,23 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Reset password — Shop' }; export const metadata = { title: 'Reset password' };
export default function Page() { export default function Page() {
return ( return (
<> <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<Header storeName="Shop" logoUrl="" /> <AccountForm
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> title="Reset password"
<AccountForm description="Enter your email and we'll send you a link to set a new password."
title="Reset password" fields={[
description="Enter your email and we'll send you a link to set a new password." { name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
fields={[ ]}
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' }, submitLabel="Send reset link"
]} endpoint="/api/account/recover"
submitLabel="Send reset link" successMessage="If that email has an account, a reset link is on its way."
endpoint="/api/account/recover" footer={
successMessage="If that email has an account, a reset link is on its way." <AccountFormLink href="/account/login">Back to sign in</AccountFormLink>
footer={ }
<AccountFormLink href="/account/login">Back to sign in</AccountFormLink> />
} </main>
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+36 -42
View File
@@ -1,49 +1,43 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Create account — Shop' }; export const metadata = { title: 'Create account' };
export default function Page() { export default function Page() {
return ( return (
<> <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<Header storeName="Shop" logoUrl="" /> <AccountForm
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> title="Create account"
<AccountForm fields={[
title="Create account" {
fields={[ name: 'firstName',
{ label: 'First name',
name: 'firstName', autoComplete: 'given-name',
label: 'First name', required: false,
autoComplete: 'given-name', },
required: false, {
}, name: 'lastName',
{ label: 'Last name',
name: 'lastName', autoComplete: 'family-name',
label: 'Last name', required: false,
autoComplete: 'family-name', },
required: false, { name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
}, {
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' }, name: 'password',
{ label: 'Password',
name: 'password', type: 'password',
label: 'Password', autoComplete: 'new-password',
type: 'password', },
autoComplete: 'new-password', ]}
}, submitLabel="Create account"
]} endpoint="/api/account/register"
submitLabel="Create account" redirectTo="/account"
endpoint="/api/account/register" footer={
redirectTo="/account" <span>
footer={ Already have an account?{' '}
<span> <AccountFormLink href="/account/login">Sign in</AccountFormLink>
Already have an account?{' '} </span>
<AccountFormLink href="/account/login">Sign in</AccountFormLink> }
</span> />
} </main>
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+18 -24
View File
@@ -1,8 +1,6 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import AccountForm from '@/components/shopify/account-form'; import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Set a new password — Shop' }; export const metadata = { title: 'Set a new password' };
// Shopify's emailed reset link is /account/reset/{id}/{token}. // Shopify's emailed reset link is /account/reset/{id}/{token}.
export default async function Page({ export default async function Page({
@@ -13,26 +11,22 @@ export default async function Page({
const { id, token } = await params; const { id, token } = await params;
return ( return (
<> <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<Header storeName="Shop" logoUrl="" /> <AccountForm
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> title="Set a new password"
<AccountForm fields={[
title="Set a new password" {
fields={[ name: 'password',
{ label: 'New password',
name: 'password', type: 'password',
label: 'New password', autoComplete: 'new-password',
type: 'password', },
autoComplete: 'new-password', ]}
}, submitLabel="Save password"
]} endpoint="/api/account/reset"
submitLabel="Save password" extraPayload={{ id, resetToken: token }}
endpoint="/api/account/reset" redirectTo="/account"
extraPayload={{ id, resetToken: token }} />
redirectTo="/account" </main>
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+11 -5
View File
@@ -7,6 +7,8 @@ import {
getProductsPage, getProductsPage,
getCollections, getCollections,
getCollectionProductsPage, getCollectionProductsPage,
isDefaultTitleOption,
isDefaultTitleSelection,
} from '@/services/shopify/catalog'; } from '@/services/shopify/catalog';
// Streaming needs the Node runtime here because the Storefront helpers run // Streaming needs the Node runtime here because the Storefront helpers run
@@ -63,10 +65,12 @@ const summariseProduct = (product: {
description: product.description?.slice(0, 300) ?? null, description: product.description?.slice(0, 300) ?? null,
productType: product.productType || null, productType: product.productType || null,
tags: product.tags ?? [], tags: product.tags ?? [],
options: product.options?.map((option) => ({ options: product.options
name: option.name, ?.filter((option) => !isDefaultTitleOption(option))
values: option.values, .map((option) => ({
})), name: option.name,
values: option.values,
})),
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale), inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
}); });
@@ -132,7 +136,9 @@ export async function POST(req: Request) {
title: node.title, title: node.title,
available: node.availableForSale, available: node.availableForSale,
price: `${node.price.amount} ${node.price.currencyCode}`, price: `${node.price.amount} ${node.price.currencyCode}`,
options: node.selectedOptions, options: node.selectedOptions?.filter(
(option) => !isDefaultTitleSelection(option)
),
})), })),
}; };
}, },
+49 -19
View File
@@ -1,23 +1,53 @@
import Header from '@/components/shopify/header'; import type { Metadata } from 'next';
import Footer from '@/components/shopify/footer';
import CollectionDetail from '@/components/shopify/collection-detail'; import CollectionDetail from '@/components/shopify/collection-detail';
import { getCollectionProductsPage } from '@/services/shopify/catalog';
import { truncate } from '@/lib/utils';
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await params;
// No collection-only query exists, so ask for the smallest page of products
// and use just the collection node off it.
const { collection } = await getCollectionProductsPage(handle, {
first: 1,
}).catch(() => ({ collection: null }));
if (!collection) return { title: 'Collection' };
const description = collection.description
? truncate(collection.description, 160)
: `Shop the ${collection.title} collection.`;
return {
title: collection.title,
description,
alternates: { canonical: `/collections/${collection.handle}` },
openGraph: {
title: collection.title,
description,
url: `/collections/${collection.handle}`,
images: collection.image
? [
{
url: collection.image.url,
alt: collection.image.altText ?? collection.title,
},
]
: undefined,
},
twitter: {
card: 'summary_large_image',
title: collection.title,
description,
images: collection.image ? [collection.image.url] : undefined,
},
};
}
export default function Page() { export default function Page() {
return ( return <CollectionDetail />;
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<CollectionDetail />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
} }
+7 -19
View File
@@ -1,23 +1,11 @@
import Header from '@/components/shopify/header'; import type { Metadata } from 'next';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections'; import Collections from '@/components/shopify/collections';
export const metadata: Metadata = {
title: 'Collections',
description: 'Browse every collection in the store.',
};
export default function Page() { export default function Page() {
return ( return <Collections title="Our Collections" />;
<>
<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."
/>
</>
);
} }
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Error({
reset: () => void; reset: () => void;
}) { }) {
return ( 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="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap"> <div className="text-lg font-medium text-black font-sans whitespace-nowrap">
Something went wrong Something went wrong
+37 -4
View File
@@ -1,9 +1,11 @@
'use client';
import React from 'react'; import React from 'react';
import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google'; 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 StoreAssistant from '@/components/shopify/store-assistant';
import { site } from '@/config/site';
const geist = Geist({ const geist = Geist({
subsets: ['latin'], subsets: ['latin'],
@@ -15,6 +17,29 @@ const geistMono = Geist_Mono({
variable: '--font-geist-mono', variable: '--font-geist-mono',
}); });
// Routes only set their own `title`; the template appends the store name, and
// everything else here is inherited unless a page overrides it.
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: {
default: site.storeName,
template: `%s — ${site.storeName}`,
},
description: site.description,
openGraph: {
type: 'website',
siteName: site.storeName,
title: site.storeName,
description: site.description,
url: '/',
},
twitter: {
card: 'summary_large_image',
title: site.storeName,
description: site.description,
},
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
@@ -22,8 +47,16 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}> <html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-body antialiased bg-background text-foreground m-0 p-0"> <body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
{children} {/* 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 /> <StoreAssistant />
</body> </body>
</html> </html>
+1 -1
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
export default function NotFound() { export default function NotFound() {
return ( 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="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap"> <div className="text-lg font-medium text-black font-sans whitespace-nowrap">
404 404
+13 -20
View File
@@ -1,26 +1,19 @@
import Header from '@/components/shopify/header'; import type { Metadata } from 'next';
import Footer from '@/components/shopify/footer';
import Products from '@/components/shopify/products'; import Products from '@/components/shopify/products';
import { site } from '@/config/site';
// The home page keeps the bare store name, so `title.absolute` opts out of the
// root template rather than rendering "Shop — Shop".
export const metadata: Metadata = {
title: { absolute: site.storeName },
description: site.description,
};
export default function Page() { export default function Page() {
return ( return (
<> <Products
<Header title="Shopify Hydrogen Storefront"
storeName="Shop" subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
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."
/>
</>
); );
} }
+17 -30
View File
@@ -1,7 +1,6 @@
import { notFound } from 'next/navigation'; 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'; import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
import { site } from '@/config/site';
export function generateStaticParams() { export function generateStaticParams() {
return POLICY_HANDLES.map((handle) => ({ handle })); return POLICY_HANDLES.map((handle) => ({ handle }));
@@ -15,8 +14,12 @@ export async function generateMetadata({
const { handle } = await params; const { handle } = await params;
const policy = await getShopPolicy(handle); const policy = await getShopPolicy(handle);
if (!policy) return { title: 'Policy' };
return { return {
title: policy ? `${policy.title} — Shop` : 'Policy — Shop', title: policy.title,
description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`,
alternates: { canonical: `/policies/${policy.handle}` },
}; };
} }
@@ -33,33 +36,17 @@ export default async function PolicyPage({
} }
return ( return (
<> <main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
<Header <div className="max-w-2xl mx-auto">
storeName="Shop" <h1 className="text-3xl md:text-4xl font-normal text-foreground">
logoUrl="" {policy.title}
links={[ </h1>
{ 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
<div className="max-w-2xl mx-auto"> className="policy-body mt-8 text-[15px] leading-7 text-foreground"
<h1 className="text-3xl md:text-4xl font-normal text-foreground"> dangerouslySetInnerHTML={{ __html: policy.body }}
{policy.title} />
</h1> </div>
</main>
<div
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
dangerouslySetInnerHTML={{ __html: policy.body }}
/>
</div>
</main>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
); );
} }
+42 -14
View File
@@ -1,25 +1,53 @@
import Header from '@/components/shopify/header'; import type { Metadata } from 'next';
import Footer from '@/components/shopify/footer';
import ProductDetail from '@/components/shopify/product-detail'; import ProductDetail from '@/components/shopify/product-detail';
import ProductRecommendations from '@/components/shopify/product-recommendations'; import ProductRecommendations from '@/components/shopify/product-recommendations';
import { getProduct } from '@/services/shopify/catalog';
import { truncate } from '@/lib/utils';
import { site } from '@/config/site';
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await params;
// ProductDetail fetches client-side and renders its own empty state, so a
// failed lookup here falls back to generic tags rather than a 500.
const product = await getProduct(handle).catch(() => null);
if (!product) return { title: 'Product' };
const description = product.description
? truncate(product.description, 160)
: site.description;
const image = product.images.edges[0]?.node;
return {
title: product.title,
description,
alternates: { canonical: `/products/${product.handle}` },
openGraph: {
title: product.title,
description,
url: `/products/${product.handle}`,
images: image
? [{ url: image.url, alt: image.altText ?? product.title }]
: undefined,
},
twitter: {
card: 'summary_large_image',
title: product.title,
description,
images: image ? [image.url] : undefined,
},
};
}
export default function Page() { export default function Page() {
return ( return (
<> <>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<ProductDetail addToCartLabel="Add to Cart" /> <ProductDetail addToCartLabel="Add to Cart" />
<ProductRecommendations title="You May Also Like" /> <ProductRecommendations title="You May Also Like" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</> </>
); );
} }
+6 -23
View File
@@ -1,33 +1,16 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import SearchResults from '@/components/shopify/search-results'; import SearchResults from '@/components/shopify/search-results';
export const metadata = { export const metadata = {
title: 'Search — Shop', title: 'Search',
description: 'Search the catalogue by product name, description or type.',
}; };
export default function Page() { export default function Page() {
return ( return (
<> // useSearchParams needs a Suspense boundary to prerender this route.
<Header <Suspense fallback={<div className="py-10" />}>
storeName="Shop" <SearchResults />
logoUrl="" </Suspense>
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
{/* 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."
/>
</>
); );
} }
+9 -19
View File
@@ -1,23 +1,13 @@
import Header from '@/components/shopify/header'; import type { Metadata } from 'next';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections'; import Collections from '@/components/shopify/collections';
export const metadata: Metadata = {
title: 'Collections',
description: 'Browse every collection in the store.',
// Same listing as /collections; point crawlers at the canonical route.
alternates: { canonical: '/collections' },
};
export default function Page() { export default function Page() {
return ( return <Collections title="Our Collections" />;
<>
<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."
/>
</>
);
} }
-562
View File
@@ -1,562 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// oxlint-disable-next-line eslint(no-bitwise)
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Synchronous cache lookup — avoids setState in effect for cached results
const syncTokens = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens]
);
// Async highlighting result (populated after shiki loads)
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
const asyncKeyRef = useRef({ code, language });
// Invalidate stale async tokens synchronously during render
if (
asyncKeyRef.current.code !== code ||
asyncKeyRef.current.language !== language
) {
asyncKeyRef.current = { code, language };
setAsyncTokens(null);
}
useEffect(() => {
let cancelled = false;
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncTokens(result);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
const tokenized = asyncTokens ?? syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Nodes } from "hast";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import type { Components } from "hast-util-to-jsx-runtime";
import type { ComponentProps, HTMLAttributes, MouseEvent } from "react";
import { Fragment, memo, useCallback, useMemo, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import remend from "remend";
import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
// is off by default) and rehype-sanitize is the second line of defence, mainly
// for its href protocol allow-list — that is what stops `javascript:` URLs.
// Relative hrefs carry no protocol, so storefront links pass through untouched.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows
// `type` and `disabled` but not `checked`, so every box would read unticked.
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
},
};
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
// bullet. Prices in italics are ordinary storefront copy, so settled messages
// render verbatim and only the in-flight one gets repaired.
const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig {
enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean;
}
type AnchorProps = ComponentProps<"a"> & {
linkSafety?: LinkSafetyConfig;
onUntrusted: (url: string) => void;
};
const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground";
// Route-relative hrefs are the storefront links the assistant emits. Deciding
// this from the string alone keeps the server and client passes identical;
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
const isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({
href,
linkSafety,
onUntrusted,
...props
}: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
if (linkSafety.onLinkCheck(href)) return;
event.preventDefault();
onUntrusted(href);
},
[href, linkSafety, onUntrusted]
);
// In-app destinations navigate client-side in the same tab; sending a shopper
// to a product page in a new tab would strand the conversation behind it.
if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />;
}
return (
<a
className={LINK_CLASS}
href={href}
onClick={handleClick}
rel="noreferrer"
target="_blank"
{...props}
/>
);
};
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = (
linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void
): Partial<Components> => ({
a: (props: ComponentProps<"a">) => (
<MarkdownLink {...props} linkSafety={linkSafety} onUntrusted={onUntrusted} />
),
blockquote: (props: ComponentProps<"blockquote">) => (
<blockquote
className="my-3 border-border border-l-2 pl-3 text-muted-foreground"
{...props}
/>
),
code: ({ className, ...props }: ComponentProps<"code">) => {
// Only fenced blocks carry a language class, and those already sit inside a
// <pre>, so they must not get the inline pill treatment.
const isBlock =
typeof className === "string" && className.includes("language-");
return (
<code
className={cn(
isBlock
? "font-mono text-xs"
: "rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
className
)}
{...props}
/>
);
},
em: (props: ComponentProps<"em">) => <em className="italic" {...props} />,
h1: (props: ComponentProps<"h1">) => (
<h1 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h2: (props: ComponentProps<"h2">) => (
<h2 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h3: (props: ComponentProps<"h3">) => (
<h3 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h4: (props: ComponentProps<"h4">) => (
<h4 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h5: (props: ComponentProps<"h5">) => (
<h5 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h6: (props: ComponentProps<"h6">) => (
<h6 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} />
),
// GFM task-list boxes are display only; readOnly silences React's warning
// about a `checked` input with no change handler.
input: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} />
),
img: ({ alt, ...props }: ComponentProps<"img">) => (
// biome-ignore lint/nursery/noImgElement: model output, not a known asset
<img alt={alt ?? ""} className="my-2 max-w-full rounded-md" {...props} />
),
li: (props: ComponentProps<"li">) => <li className="my-0.5" {...props} />,
ol: (props: ComponentProps<"ol">) => (
<ol className="my-2 list-decimal space-y-0.5 pl-5" {...props} />
),
p: (props: ComponentProps<"p">) => (
<p className="my-2 leading-relaxed" {...props} />
),
pre: (props: ComponentProps<"pre">) => (
<pre
className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs"
{...props}
/>
),
strong: (props: ComponentProps<"strong">) => (
<strong className="font-semibold" {...props} />
),
table: (props: ComponentProps<"table">) => (
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-left text-xs" {...props} />
</div>
),
td: (props: ComponentProps<"td">) => (
<td className="border border-border px-2 py-1" {...props} />
),
th: (props: ComponentProps<"th">) => (
<th
className="border border-border bg-muted px-2 py-1 font-medium"
{...props}
/>
),
ul: (props: ComponentProps<"ul">) => (
<ul className="my-2 list-disc space-y-0.5 pl-5" {...props} />
),
});
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean;
linkSafety?: LinkSafetyConfig;
};
export const Markdown = memo(
({
children,
className,
isAnimating,
linkSafety,
...props
}: MarkdownProps) => {
const [pending, setPending] = useState<string | null>(null);
const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => {
// While streaming, close syntax the model has not finished emitting so a
// partial `**bold` renders as bold rather than as literal asterisks.
const source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes;
return toJsxRuntime(tree, {
components: buildComponents(linkSafety, handleUntrusted),
Fragment,
jsx,
jsxs,
});
}, [children, isAnimating, linkSafety, handleUntrusted]);
return (
<div
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
>
{content}
<Dialog
onOpenChange={(open) => !open && setPending(null)}
open={pending !== null}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Leave this site?</DialogTitle>
<DialogDescription>
This link points somewhere outside the store. Continue only if
you trust it.
</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
{pending}
</p>
<DialogFooter>
<Button onClick={() => setPending(null)} variant="outline">
Cancel
</Button>
<Button
onClick={() => {
if (pending) {
window.open(pending, "_blank", "noopener,noreferrer");
}
setPending(null);
}}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
},
(prev, next) =>
prev.children === next.children && prev.isAnimating === next.isAnimating
);
Markdown.displayName = "Markdown";
+5 -26
View File
@@ -12,23 +12,19 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { import {
createContext, createContext,
memo,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { Streamdown } from "streamdown";
import { Markdown } from "./markdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & { export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]; from: UIMessage["role"];
@@ -319,27 +315,10 @@ export const MessageBranchPage = ({
); );
}; };
export type MessageResponseProps = ComponentProps<typeof Streamdown>; export type MessageResponseProps = ComponentProps<typeof Markdown>;
const streamdownPlugins = { cjk, code, math, mermaid }; // Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">; export type MessageToolbarProps = ComponentProps<"div">;
+17 -19
View File
@@ -7,10 +7,6 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react"; import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { import {
@@ -23,8 +19,8 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Streamdown } from "streamdown";
import { Markdown } from "./markdown";
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
interface ReasoningContextValue { interface ReasoningContextValue {
@@ -204,21 +200,23 @@ export type ReasoningContentProps = ComponentProps<
children: string; children: string;
}; };
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo( export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => ( ({ className, children, ...props }: ReasoningContentProps) => {
<CollapsibleContent const { isStreaming } = useReasoning();
className={cn(
"mt-4 text-sm", return (
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in", <CollapsibleContent
className className={cn(
)} "mt-4 text-sm",
{...props} "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
> className
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown> )}
</CollapsibleContent> {...props}
) >
<Markdown isAnimating={isStreaming}>{children}</Markdown>
</CollapsibleContent>
);
}
); );
Reasoning.displayName = "Reasoning"; Reasoning.displayName = "Reasoning";
-173
View File
@@ -1,173 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import Link from 'next/link';
interface LogoProps {
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
src?: string | null;
/** Wordmark text, and the image's alt text. */
storeName?: string;
/** Where the logo links to. Pass null to render it unwrapped. */
href?: string | null;
/** Sizing for the image — height only, so the aspect ratio is preserved. */
imageClassName?: string;
/** Sizing and weight for the wordmark fallback. */
textClassName?: string;
}
const Logo: React.FC<LogoProps> = ({
src,
storeName = 'Shop',
href = '/',
imageClassName = 'h-6',
textClassName = 'text-xl',
}) => {
const mark = src ? (
<img
src={src}
alt={storeName}
className={`w-auto object-contain ${imageClassName}`}
/>
) : (
<span
className={`font-medium tracking-tight text-foreground ${textClassName}`}
>
{storeName}
</span>
);
if (href === null) {
return <span className="flex items-center">{mark}</span>;
}
return (
<Link href={href} className="flex items-center">
{mark}
</Link>
);
};
export default Logo;
+1 -1
View File
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
export interface AccountFormField { export interface AccountFormField {
name: string; name: string;
+10 -3
View File
@@ -1,9 +1,10 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Image from 'next/image';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart'; import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -25,6 +26,7 @@ import {
EmptyDescription, EmptyDescription,
EmptyContent, EmptyContent,
} from '@/components/ui/empty'; } from '@/components/ui/empty';
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
const CartDrawer: React.FC = () => { const CartDrawer: React.FC = () => {
const isOpen = useCartStore((s) => s.isOpen); const isOpen = useCartStore((s) => s.isOpen);
@@ -102,7 +104,10 @@ const CartDrawer: React.FC = () => {
}; };
const getSelectedOptions = (item: (typeof items)[0]) => { const getSelectedOptions = (item: (typeof items)[0]) => {
return item.merchandise.selectedOptions ?? []; // Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option)
);
}; };
return ( return (
@@ -167,9 +172,11 @@ const CartDrawer: React.FC = () => {
{/* Product Image */} {/* Product Image */}
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0"> <div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? ( {image ? (
<img <Image
src={image} src={image}
alt={item.merchandise.product.title} alt={item.merchandise.product.title}
width={64}
height={64}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
+7 -4
View File
@@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
interface CollectionImage { interface CollectionImage {
url: string; url: string;
altText?: string; altText?: string | null;
} }
interface Collection { interface Collection {
@@ -11,7 +12,7 @@ interface Collection {
title: string; title: string;
handle: string; handle: string;
description?: string; description?: string;
image?: CollectionImage; image?: CollectionImage | null;
} }
interface CollectionCardProps { interface CollectionCardProps {
@@ -27,10 +28,12 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
{/* Collection Image */} {/* Collection Image */}
<div className="relative aspect-square overflow-hidden"> <div className="relative aspect-square overflow-hidden">
{collection.image ? ( {collection.image ? (
<img <Image
src={collection.image.url} src={collection.image.url}
alt={collection.image.altText || collection.title} alt={collection.image.altText || collection.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]" fill
sizes="(min-width: 1024px) 25vw, 50vw"
className="object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/> />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300"> <div className="absolute inset-0 flex items-center justify-center text-zinc-300">
+1 -1
View File
@@ -6,7 +6,7 @@ import ProductCard from './product-card';
import ProductFilters, { type ProductFilterFacet } from './product-filters'; import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar'; import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import { import {
getCollectionProductsPage, getCollectionProductsPage,
type CollectionSortKey, type CollectionSortKey,
+10
View File
@@ -4,6 +4,7 @@ import {
RiTiktokLine, RiTiktokLine,
RiFacebookFill, RiFacebookFill,
} from '@remixicon/react'; } from '@remixicon/react';
import Logo from '@/components/logo';
export interface FooterLink { export interface FooterLink {
label: string; label: string;
@@ -12,6 +13,7 @@ export interface FooterLink {
interface FooterProps { interface FooterProps {
storeName?: string; storeName?: string;
logoUrl?: string;
copyright?: string; copyright?: string;
links?: FooterLink[]; links?: FooterLink[];
instagramUrl?: string; instagramUrl?: string;
@@ -21,6 +23,7 @@ interface FooterProps {
const Footer: React.FC<FooterProps> = ({ const Footer: React.FC<FooterProps> = ({
storeName = 'Shop', storeName = 'Shop',
logoUrl,
copyright, copyright,
links = [ links = [
{ label: 'Terms of Service', url: '/policies/terms-of-service' }, { label: 'Terms of Service', url: '/policies/terms-of-service' },
@@ -46,6 +49,13 @@ const Footer: React.FC<FooterProps> = ({
assistant launcher. Links and socials sit on separate rows so the assistant launcher. Links and socials sit on separate rows so the
icons aren't crammed onto the end of the link list. */} icons aren't crammed onto the end of the link list. */}
<div className="flex flex-col items-center gap-y-5 sm:items-start"> <div className="flex flex-col items-center gap-y-5 sm:items-start">
<Logo
src={logoUrl}
storeName={storeName}
imageClassName="h-5"
textClassName="text-base"
/>
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start"> <nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
{links.map((link) => ( {links.map((link) => (
<a <a
+7 -15
View File
@@ -6,8 +6,10 @@ 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';
const CartIcon: React.FC = () => { const CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart); const toggleCart = useCartStore((s) => s.toggleCart);
@@ -48,7 +50,7 @@ interface HeaderProps {
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({
storeName = 'Shop', storeName = 'Logo',
logoUrl, logoUrl,
links = [ links = [
{ label: 'Shop', url: '/' }, { label: 'Shop', url: '/' },
@@ -63,7 +65,7 @@ const Header: React.FC<HeaderProps> = ({
<> <>
{/* Sits above the sticky nav, so it scrolls away on its own. */} {/* Sits above the sticky nav, so it scrolls away on its own. */}
{announcement && ( {announcement && (
<div className="bg-foreground text-background"> <div className="bg-muted text-foreground">
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs"> <div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
{announcementUrl ? ( {announcementUrl ? (
<Link <Link
@@ -83,22 +85,11 @@ const Header: React.FC<HeaderProps> = ({
<div className="max-w-screen-2xl mx-auto px-8"> <div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14"> <div className="flex justify-between items-center h-14">
{/* Logo */} {/* Logo */}
<Link href="/" className="flex items-center"> <Logo src={logoUrl} storeName={storeName} />
{logoUrl ? (
<img
src={logoUrl}
alt={storeName}
className="h-6 w-auto object-contain"
/>
) : (
<span className="text-xl font-medium tracking-tight text-foreground">
{storeName}
</span>
)}
</Link>
{/* 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}
@@ -138,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}
+4 -1
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Image from 'next/image';
import { RiImageLine } from '@remixicon/react'; import { RiImageLine } from '@remixicon/react';
import type { Customer, CustomerOrder } from '@/services/shopify/customer'; import type { Customer, CustomerOrder } from '@/services/shopify/customer';
@@ -123,9 +124,11 @@ const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
<li key={index} className="flex items-start gap-x-3"> <li key={index} className="flex items-start gap-x-3">
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100"> <div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
{node.variant?.image ? ( {node.variant?.image ? (
<img <Image
src={node.variant.image.url} src={node.variant.image.url}
alt={node.variant.image.altText || node.title} alt={node.variant.image.altText || node.title}
width={64}
height={64}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : ( ) : (
+6 -3
View File
@@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { truncate } from '@/lib/utils'; import { truncate } from '@/lib/utils';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string; altText?: string | null;
} }
interface ProductPrice { interface ProductPrice {
@@ -68,10 +69,12 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
{/* Product Image */} {/* Product Image */}
<div className="relative aspect-square overflow-hidden"> <div className="relative aspect-square overflow-hidden">
{firstImage ? ( {firstImage ? (
<img <Image
src={firstImage.url} src={firstImage.url}
alt={firstImage.altText || product.title} alt={firstImage.altText || product.title}
className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]" fill
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
className="object-contain transition-transform duration-500 group-hover:scale-[1.04]"
/> />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300"> <div className="absolute inset-0 flex items-center justify-center text-zinc-300">
+2 -2
View File
@@ -29,8 +29,8 @@ interface ProductVariant {
}>; }>;
image?: { image?: {
url: string; url: string;
altText?: string; altText?: string | null;
}; } | null;
} }
export type { Product }; export type { Product };
@@ -1,12 +1,15 @@
'use client'; 'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { RiCloseLine } from '@remixicon/react'; import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string; altText?: string | null;
width?: number | null;
height?: number | null;
} }
interface ProductDetailGalleryProps { interface ProductDetailGalleryProps {
@@ -136,11 +139,14 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
isSingle ? 'sm:col-span-2' : '' isSingle ? 'sm:col-span-2' : ''
}`} }`}
> >
<img <Image
src={image.url} src={image.url}
alt={image.altText || 'Product image'} alt={image.altText || 'Product image'}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
priority={index === 0}
draggable={false} draggable={false}
className="w-full h-full object-cover select-none" className="object-cover select-none"
/> />
</button> </button>
))} ))}
@@ -173,11 +179,19 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
onClick={close} onClick={close}
className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10" className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10"
> >
<img <Image
src={zoomedImage.url} src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'} alt={zoomedImage.altText || 'Product image'}
// Shopify gives us the intrinsic size; the square fallback only
// reserves space until CSS scales it down to fit the overlay.
width={zoomedImage.width || 1600}
height={zoomedImage.height || 1600}
sizes="100vw"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
className="max-h-full max-w-full object-contain" // w/h-auto keeps the box at the image's own ratio; without it the
// width+height attributes make both axes definite and the element
// stretches to the overlay, swallowing backdrop clicks that close it.
className="max-h-full max-w-full w-auto h-auto object-contain"
/> />
<Button <Button
@@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import { RiSubtractLine, RiAddLine } from '@remixicon/react'; import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayButton from '@/components/shopify/shop-pay-button'; import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products'; import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches'; import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
import { isDefaultTitleOption } from '@/services/shopify/catalog';
interface ProductPrice { interface ProductPrice {
amount: string; amount: string;
@@ -103,9 +104,10 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
isSwatchOptionName(option.name); isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way. // Some products return Size before Color; show the swatches first either way.
const orderedOptions = [...(product.options ?? [])].sort( // Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
(a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)) const orderedOptions = [...(product.options ?? [])]
); .filter((option) => !isDefaultTitleOption(option))
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
// `optionValues` carries the swatch data; fall back to plain `values`. // `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] => const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
@@ -170,12 +172,22 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
: 'ring-1 ring-border hover:ring-foreground/40' : 'ring-1 ring-border hover:ring-foreground/40'
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`} } ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
style={{ style={{
backgroundColor: background, // With no colour and no image the circle would be fully
// transparent, leaving just a hairline ring that
// antialiases unevenly and reads as a speckled border.
// A neutral fill makes the initial-letter fallback look
// deliberate. Set inline so it beats the ghost variant's
// hover background.
backgroundColor:
background ??
(image ? undefined : 'var(--color-muted)'),
backgroundImage: image ? `url(${image})` : undefined, backgroundImage: image ? `url(${image})` : undefined,
}} }}
> >
{!background && !image && ( {!background && !image && (
<span className="text-[10px]">{value.name.at(0)}</span> <span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.name.at(0)}
</span>
)} )}
</Button> </Button>
); );
+7 -2
View File
@@ -208,10 +208,15 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
? 'ring-2 ring-foreground ring-offset-2' ? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40' : 'ring-1 ring-border hover:ring-foreground/40'
}`} }`}
style={{ backgroundColor: color ?? undefined }} style={{
// A colourless swatch would otherwise be a fully
// transparent circle behind a hairline ring,
// which antialiases into a speckled border.
backgroundColor: color ?? 'var(--color-muted)',
}}
> >
{!color && ( {!color && (
<span className="text-[10px]"> <span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.label.at(0)} {value.label.at(0)}
</span> </span>
)} )}
+2 -2
View File
@@ -4,11 +4,11 @@ import React, { useState, useEffect } from 'react';
import ProductCard from './product-card'; import ProductCard from './product-card';
import { getProductsPage } from '@/hooks/use-shopify-products'; import { getProductsPage } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string; altText?: string | null;
} }
interface ProductPrice { interface ProductPrice {
+6 -3
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { import {
CommandDialog, CommandDialog,
@@ -11,7 +12,7 @@ import {
CommandItem, CommandItem,
} from '@/components/ui/command'; } from '@/components/ui/command';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react'; import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
import { import {
searchSuggestions, searchSuggestions,
@@ -111,7 +112,7 @@ const SearchDialog: React.FC = () => {
if (event.key === 'Enter') goToSearchPage(); if (event.key === 'Enter') goToSearchPage();
}} }}
/> />
<div className="absolute right-2 top-0 flex h-9 items-center gap-1"> <div className="absolute right-2 top-0 flex h-12 items-center gap-1">
{hasQuery && ( {hasQuery && (
<Button <Button
onClick={() => setTerm('')} onClick={() => setTerm('')}
@@ -162,9 +163,11 @@ const SearchDialog: React.FC = () => {
> >
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100"> <div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
{product.featuredImage ? ( {product.featuredImage ? (
<img <Image
src={product.featuredImage.url} src={product.featuredImage.url}
alt={product.featuredImage.altText || product.title} alt={product.featuredImage.altText || product.title}
width={56}
height={56}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : ( ) : (
+1 -1
View File
@@ -6,7 +6,7 @@ import ProductCard from './product-card';
import ProductFilters from './product-filters'; import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar'; import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import { import {
searchProducts, searchProducts,
type SearchFilter, type SearchFilter,
+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;
+2 -2
View File
@@ -1,9 +1,9 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client'; import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/config';
import ShopPayLogo from '@/components/shopify/shop-pay-logo'; import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export interface ShopPayVariant { export interface ShopPayVariant {
+5
View File
@@ -393,6 +393,11 @@ const StoreAssistant: React.FC = () => {
return ( return (
<MessageResponse <MessageResponse
key={index} key={index}
// Only repair half-written markdown while it streams;
// settled text is rendered exactly as sent.
isAnimating={
status === 'streaming' && part.state === 'streaming'
}
linkSafety={{ linkSafety={{
enabled: true, enabled: true,
onLinkCheck: isInternalLink, onLinkCheck: isInternalLink,
+8 -1
View File
@@ -22,6 +22,7 @@ function Command({
data-slot="command" data-slot="command"
className={cn( className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
"transition-none animate-none",
className className
)} )}
{...props} {...props}
@@ -53,7 +54,13 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogContent <DialogContent
className={cn("overflow-hidden p-0", className)} className={cn(
"overflow-hidden p-0",
// No fade/zoom on the command palette — it should appear instantly.
"transition-none animate-none duration-0",
"data-[state=closed]:animate-none data-[state=open]:animate-none",
className
)}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
> >
<Command shouldFilter={shouldFilter} className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command shouldFilter={shouldFilter} className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+21
View File
@@ -0,0 +1,21 @@
import type { NavLink } from '@/components/shopify/header';
/** Chrome shared by every route — rendered once in the root layout. */
export const site = {
storeName: 'Shop',
description:
'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
// Absolute origin behind `metadataBase`, so Open Graph images resolve to full
// URLs. Vercel injects VERCEL_PROJECT_PRODUCTION_URL on deployed builds.
url:
process.env.NEXT_PUBLIC_SITE_URL ??
(process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000'),
logoUrl: '',
copyright: '© 2026 Shop. All rights reserved.',
navLinks: [
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
] as NavLink[],
};
+46 -26
View File
@@ -1,5 +1,7 @@
import { gql } from '@shopify/hydrogen';
// Cart Fragment for consistent cart data // Cart Fragment for consistent cart data
const CartFragment = ` const CartFragment = gql(`
fragment CartFragment on Cart { fragment CartFragment on Cart {
id id
checkoutUrl checkoutUrl
@@ -64,12 +66,13 @@ const CartFragment = `
} }
} }
} }
`; `);
// Create a new cart // Create a new cart
export const CREATE_CART_MUTATION = ` export const CREATE_CART_MUTATION = gql(
${CartFragment} `
mutation CreateCart($lines: [CartLineInput!]) { mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
cartCreate(input: { lines: $lines }) { cartCreate(input: { lines: $lines }) {
cart { cart {
...CartFragment ...CartFragment
@@ -80,12 +83,15 @@ export const CREATE_CART_MUTATION = `
} }
} }
} }
`; `,
[CartFragment]
);
// Add lines to cart // Add lines to cart
export const ADD_CART_LINES_MUTATION = ` export const ADD_CART_LINES_MUTATION = gql(
${CartFragment} `
mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!) { mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
cartLinesAdd(cartId: $cartId, lines: $lines) { cartLinesAdd(cartId: $cartId, lines: $lines) {
cart { cart {
...CartFragment ...CartFragment
@@ -96,12 +102,15 @@ export const ADD_CART_LINES_MUTATION = `
} }
} }
} }
`; `,
[CartFragment]
);
// Update cart lines // Update cart lines
export const UPDATE_CART_LINES_MUTATION = ` export const UPDATE_CART_LINES_MUTATION = gql(
${CartFragment} `
mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!) { mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
cartLinesUpdate(cartId: $cartId, lines: $lines) { cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart { cart {
...CartFragment ...CartFragment
@@ -112,12 +121,15 @@ export const UPDATE_CART_LINES_MUTATION = `
} }
} }
} }
`; `,
[CartFragment]
);
// Remove lines from cart // Remove lines from cart
export const REMOVE_CART_LINES_MUTATION = ` export const REMOVE_CART_LINES_MUTATION = gql(
${CartFragment} `
mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!) { mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart { cart {
...CartFragment ...CartFragment
@@ -128,12 +140,15 @@ export const REMOVE_CART_LINES_MUTATION = `
} }
} }
} }
`; `,
[CartFragment]
);
// Apply (or clear) discount codes on the cart // Apply (or clear) discount codes on the cart
export const UPDATE_CART_DISCOUNT_CODES_MUTATION = ` export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql(
${CartFragment} `
mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]) { mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
cart { cart {
...CartFragment ...CartFragment
@@ -144,14 +159,19 @@ export const UPDATE_CART_DISCOUNT_CODES_MUTATION = `
} }
} }
} }
`; `,
[CartFragment]
);
// Get cart by ID // Get cart by ID
export const GET_CART_QUERY = ` export const GET_CART_QUERY = gql(
${CartFragment} `
query GetCart($cartId: ID!) { query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
cart(id: $cartId) { cart(id: $cartId) {
...CartFragment ...CartFragment
} }
} }
`; `,
[CartFragment]
);
@@ -1,8 +1,10 @@
import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products'; import { ProductFragment } from '@/graphql/products';
// Get all collections // Get all collections
export const GET_COLLECTIONS_QUERY = ` export const GET_COLLECTIONS_QUERY = gql(`
query GetCollections($first: Int!) { query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: $first) { collections(first: $first) {
edges { edges {
node { node {
@@ -26,11 +28,11 @@ export const GET_COLLECTIONS_QUERY = `
} }
} }
} }
`; `);
// Get products in a collection // Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = ` export const GET_COLLECTION_PRODUCTS_QUERY = gql(
${ProductFragment} `
query GetCollectionProducts( query GetCollectionProducts(
$handle: String! $handle: String!
$first: Int! $first: Int!
@@ -38,7 +40,9 @@ export const GET_COLLECTION_PRODUCTS_QUERY = `
$sortKey: ProductCollectionSortKeys $sortKey: ProductCollectionSortKeys
$reverse: Boolean $reverse: Boolean
$filters: [ProductFilter!] $filters: [ProductFilter!]
) { $country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
collection(handle: $handle) { collection(handle: $handle) {
id id
title title
@@ -82,4 +86,6 @@ export const GET_COLLECTION_PRODUCTS_QUERY = `
} }
} }
} }
`; `,
[ProductFragment]
);
+25 -20
View File
@@ -1,7 +1,8 @@
// Customer account operations (classic Storefront customer accounts). // Customer account operations (classic Storefront customer accounts).
// https://shopify.dev/docs/api/storefront/latest/objects/Customer // https://shopify.dev/docs/api/storefront/latest/objects/Customer
import { gql } from '@shopify/hydrogen';
const CustomerFragment = ` const CustomerFragment = gql(`
fragment CustomerFragment on Customer { fragment CustomerFragment on Customer {
id id
email email
@@ -24,10 +25,10 @@ const CustomerFragment = `
phone phone
} }
} }
`; `);
export const CUSTOMER_QUERY = ` export const CUSTOMER_QUERY = gql(
${CustomerFragment} `
query GetCustomer($customerAccessToken: String!, $orderCount: Int!) { query GetCustomer($customerAccessToken: String!, $orderCount: Int!) {
customer(customerAccessToken: $customerAccessToken) { customer(customerAccessToken: $customerAccessToken) {
...CustomerFragment ...CustomerFragment
@@ -63,9 +64,11 @@ export const CUSTOMER_QUERY = `
} }
} }
} }
`; `,
[CustomerFragment]
);
export const CUSTOMER_CREATE_MUTATION = ` export const CUSTOMER_CREATE_MUTATION = gql(`
mutation CustomerCreate($input: CustomerCreateInput!) { mutation CustomerCreate($input: CustomerCreateInput!) {
customerCreate(input: $input) { customerCreate(input: $input) {
customer { customer {
@@ -79,9 +82,9 @@ export const CUSTOMER_CREATE_MUTATION = `
} }
} }
} }
`; `);
export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = ` export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(`
mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) { mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
customerAccessTokenCreate(input: $input) { customerAccessTokenCreate(input: $input) {
customerAccessToken { customerAccessToken {
@@ -95,9 +98,9 @@ export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = `
} }
} }
} }
`; `);
export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = ` export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(`
mutation CustomerAccessTokenDelete($customerAccessToken: String!) { mutation CustomerAccessTokenDelete($customerAccessToken: String!) {
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) { customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
deletedAccessToken deletedAccessToken
@@ -107,10 +110,10 @@ export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = `
} }
} }
} }
`; `);
// Sends the "reset your password" email. // Sends the "reset your password" email.
export const CUSTOMER_RECOVER_MUTATION = ` export const CUSTOMER_RECOVER_MUTATION = gql(`
mutation CustomerRecover($email: String!) { mutation CustomerRecover($email: String!) {
customerRecover(email: $email) { customerRecover(email: $email) {
customerUserErrors { customerUserErrors {
@@ -120,10 +123,10 @@ export const CUSTOMER_RECOVER_MUTATION = `
} }
} }
} }
`; `);
// Completes the reset using the id + token from the emailed link. // Completes the reset using the id + token from the emailed link.
export const CUSTOMER_RESET_MUTATION = ` export const CUSTOMER_RESET_MUTATION = gql(`
mutation CustomerReset($id: ID!, $input: CustomerResetInput!) { mutation CustomerReset($id: ID!, $input: CustomerResetInput!) {
customerReset(id: $id, input: $input) { customerReset(id: $id, input: $input) {
customerAccessToken { customerAccessToken {
@@ -137,10 +140,10 @@ export const CUSTOMER_RESET_MUTATION = `
} }
} }
} }
`; `);
// Activation link sent to customers created by the merchant. // Activation link sent to customers created by the merchant.
export const CUSTOMER_ACTIVATE_MUTATION = ` export const CUSTOMER_ACTIVATE_MUTATION = gql(`
mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) { mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) {
customerActivate(id: $id, input: $input) { customerActivate(id: $id, input: $input) {
customerAccessToken { customerAccessToken {
@@ -154,10 +157,10 @@ export const CUSTOMER_ACTIVATE_MUTATION = `
} }
} }
} }
`; `);
export const CUSTOMER_UPDATE_MUTATION = ` export const CUSTOMER_UPDATE_MUTATION = gql(
${CustomerFragment} `
mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) { mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) { customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
customer { customer {
@@ -170,4 +173,6 @@ export const CUSTOMER_UPDATE_MUTATION = `
} }
} }
} }
`; `,
[CustomerFragment]
);
+4 -2
View File
@@ -1,6 +1,8 @@
import { gql } from '@shopify/hydrogen';
// Shop policies are exposed on the `shop` object of the Storefront API. // Shop policies are exposed on the `shop` object of the Storefront API.
// There is no lookup-by-handle field, so we fetch all of them and match. // There is no lookup-by-handle field, so we fetch all of them and match.
export const GET_SHOP_POLICIES_QUERY = ` export const GET_SHOP_POLICIES_QUERY = gql(`
query GetShopPolicies { query GetShopPolicies {
shop { shop {
privacyPolicy { privacyPolicy {
@@ -40,4 +42,4 @@ export const GET_SHOP_POLICIES_QUERY = `
} }
} }
} }
`; `);
+25 -14
View File
@@ -1,5 +1,7 @@
import { gql } from '@shopify/hydrogen';
// Product Fragment for consistent product data // Product Fragment for consistent product data
export const ProductFragment = ` export const ProductFragment = gql(`
fragment ProductFragment on Product { fragment ProductFragment on Product {
id id
title title
@@ -98,12 +100,13 @@ export const ProductFragment = `
} }
} }
} }
`; `);
// Get multiple products // Get multiple products
export const GET_PRODUCTS_QUERY = ` export const GET_PRODUCTS_QUERY = gql(
${ProductFragment} `
query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) { query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) { products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
edges { edges {
node { node {
@@ -116,24 +119,32 @@ export const GET_PRODUCTS_QUERY = `
} }
} }
} }
`; `,
[ProductFragment]
);
// Get a single product by handle // Get a single product by handle
export const GET_PRODUCT_QUERY = ` export const GET_PRODUCT_QUERY = gql(
${ProductFragment} `
query GetProduct($handle: String!) { query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
product(handle: $handle) { product(handle: $handle) {
...ProductFragment ...ProductFragment
} }
} }
`; `,
[ProductFragment]
);
// Get product recommendations // Get product recommendations
export const QUERY_PRODUCT_RECOMMENDATIONS = ` export const QUERY_PRODUCT_RECOMMENDATIONS = gql(
${ProductFragment} `
query GetProductRecommendations($productId: ID!) { query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
productRecommendations(productId: $productId) { productRecommendations(productId: $productId) {
...ProductFragment ...ProductFragment
} }
} }
`; `,
[ProductFragment]
);
+17 -7
View File
@@ -1,10 +1,11 @@
import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products'; import { ProductFragment } from '@/graphql/products';
// Storefront search over products. `productFilters` accepts the raw `input` // Storefront search over products. `productFilters` accepts the raw `input`
// values returned in `productFilters[].values[].input`, so facets round-trip // values returned in `productFilters[].values[].input`, so facets round-trip
// without the client needing to know each filter's shape. // without the client needing to know each filter's shape.
export const SEARCH_PRODUCTS_QUERY = ` export const SEARCH_PRODUCTS_QUERY = gql(
${ProductFragment} `
query SearchProducts( query SearchProducts(
$query: String! $query: String!
$first: Int! $first: Int!
@@ -12,7 +13,9 @@ export const SEARCH_PRODUCTS_QUERY = `
$sortKey: SearchSortKeys $sortKey: SearchSortKeys
$reverse: Boolean $reverse: Boolean
$productFilters: [ProductFilter!] $productFilters: [ProductFilter!]
) { $country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
search( search(
query: $query query: $query
first: $first first: $first
@@ -40,6 +43,9 @@ export const SEARCH_PRODUCTS_QUERY = `
} }
edges { edges {
node { node {
# types: PRODUCT already narrows the results, but the schema still
# types nodes as a union __typename lets callers narrow too.
__typename
... on Product { ... on Product {
...ProductFragment ...ProductFragment
} }
@@ -47,16 +53,20 @@ export const SEARCH_PRODUCTS_QUERY = `
} }
} }
} }
`; `,
[ProductFragment]
);
// Lightweight variant for the autocomplete dropdown — just enough to render a // Lightweight variant for the autocomplete dropdown — just enough to render a
// row, so the dialog stays responsive while typing. // row, so the dialog stays responsive while typing.
export const SEARCH_SUGGESTIONS_QUERY = ` export const SEARCH_SUGGESTIONS_QUERY = gql(`
query SearchSuggestions($query: String!, $first: Int!) { query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
search(query: $query, first: $first, types: PRODUCT) { search(query: $query, first: $first, types: PRODUCT) {
totalCount totalCount
edges { edges {
node { node {
__typename
... on Product { ... on Product {
id id
title title
@@ -76,4 +86,4 @@ export const SEARCH_SUGGESTIONS_QUERY = `
} }
} }
} }
`; `);
+72 -59
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { shopifyFetch } from '@/services/shopify/client'; import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { import {
CREATE_CART_MUTATION, CREATE_CART_MUTATION,
ADD_CART_LINES_MUTATION, ADD_CART_LINES_MUTATION,
@@ -45,12 +45,12 @@ interface CartLine {
currencyCode: string; currencyCode: string;
}; };
image?: { image?: {
id: string; id?: string | null;
url: string; url: string;
altText?: string; altText?: string | null;
width: number; width?: number | null;
height: number; height?: number | null;
}; } | null;
product: { product: {
id: string; id: string;
title: string; title: string;
@@ -82,7 +82,7 @@ export interface Cart {
totalTaxAmount?: { totalTaxAmount?: {
amount: string; amount: string;
currencyCode: string; currencyCode: string;
}; } | null;
}; };
lines: { lines: {
edges: Array<{ edges: Array<{
@@ -93,92 +93,105 @@ export interface Cart {
// ─── Shopify API functions ─────────────────────────────────────────── // ─── Shopify API functions ───────────────────────────────────────────
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> { /**
const response = await shopifyFetch({ * Every cart mutation returns the same `{ cart, userErrors }` payload, and both
query: CREATE_CART_MUTATION, * the payload and the cart inside it are nullable — Shopify returns no cart when
variables: { lines: lines.length > 0 ? lines : null }, * the mutation could not be applied. Callers want a cart or an exception.
}); */
function unwrapCartPayload<T>(
if (response.data.cartCreate.userErrors.length > 0) { payload:
throw new Error(response.data.cartCreate.userErrors[0].message); | {
cart?: T | null;
userErrors: ReadonlyArray<{ message: string }>;
}
| null
| undefined
): T {
if (payload?.userErrors.length) {
throw new Error(payload.userErrors[0].message);
} }
return response.data.cartCreate.cart; if (!payload?.cart) {
throw new Error('Cart update failed. Please try again.');
}
return payload.cart;
}
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
const data = unwrapStorefrontResult(
await storefront.graphql(CREATE_CART_MUTATION, {
variables: { lines: lines.length > 0 ? lines : null },
}),
'CreateCart'
);
return unwrapCartPayload(data.cartCreate);
} }
async function addCartLinesApi( async function addCartLinesApi(
cartId: string, cartId: string,
lines: CartLineInput[] lines: CartLineInput[]
): Promise<Cart> { ): Promise<Cart> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: ADD_CART_LINES_MUTATION, await storefront.graphql(ADD_CART_LINES_MUTATION, {
variables: { cartId, lines }, variables: { cartId, lines },
}); }),
'AddCartLines'
);
if (response.data.cartLinesAdd.userErrors.length > 0) { return unwrapCartPayload(data.cartLinesAdd);
throw new Error(response.data.cartLinesAdd.userErrors[0].message);
}
return response.data.cartLinesAdd.cart;
} }
async function updateCartLinesApi( async function updateCartLinesApi(
cartId: string, cartId: string,
lines: CartLineUpdateInput[] lines: CartLineUpdateInput[]
): Promise<Cart> { ): Promise<Cart> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: UPDATE_CART_LINES_MUTATION, await storefront.graphql(UPDATE_CART_LINES_MUTATION, {
variables: { cartId, lines }, variables: { cartId, lines },
}); }),
'UpdateCartLines'
);
if (response.data.cartLinesUpdate.userErrors.length > 0) { return unwrapCartPayload(data.cartLinesUpdate);
throw new Error(response.data.cartLinesUpdate.userErrors[0].message);
}
return response.data.cartLinesUpdate.cart;
} }
async function removeCartLinesApi( async function removeCartLinesApi(
cartId: string, cartId: string,
lineIds: string[] lineIds: string[]
): Promise<Cart> { ): Promise<Cart> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: REMOVE_CART_LINES_MUTATION, await storefront.graphql(REMOVE_CART_LINES_MUTATION, {
variables: { cartId, lineIds }, variables: { cartId, lineIds },
}); }),
'RemoveCartLines'
);
if (response.data.cartLinesRemove.userErrors.length > 0) { return unwrapCartPayload(data.cartLinesRemove);
throw new Error(response.data.cartLinesRemove.userErrors[0].message);
}
return response.data.cartLinesRemove.cart;
} }
async function updateCartDiscountCodesApi( async function updateCartDiscountCodesApi(
cartId: string, cartId: string,
discountCodes: string[] discountCodes: string[]
): Promise<Cart> { ): Promise<Cart> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: UPDATE_CART_DISCOUNT_CODES_MUTATION, await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, {
variables: { cartId, discountCodes }, variables: { cartId, discountCodes },
}); }),
'UpdateCartDiscountCodes'
);
if (response.data.cartDiscountCodesUpdate.userErrors.length > 0) { return unwrapCartPayload(data.cartDiscountCodesUpdate);
throw new Error(
response.data.cartDiscountCodesUpdate.userErrors[0].message
);
}
return response.data.cartDiscountCodesUpdate.cart;
} }
async function getCartApi(cartId: string): Promise<Cart | null> { async function getCartApi(cartId: string): Promise<Cart | null> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: GET_CART_QUERY, await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }),
variables: { cartId }, 'GetCart'
}); );
return response.data.cart; return data.cart;
} }
export function redirectToCheckout(checkoutUrl: string): void { export function redirectToCheckout(checkoutUrl: string): void {
+31 -1
View File
@@ -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,
+17 -29
View File
@@ -1,4 +1,7 @@
import { SHOPIFY_STOREFRONT_API_URL } from '@/services/shopify/client'; import {
cachedStorefront,
unwrapStorefrontResult,
} from '@/services/shopify/client';
import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies'; import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies';
export interface ShopPolicy { export interface ShopPolicy {
@@ -9,12 +12,6 @@ export interface ShopPolicy {
url: string; url: string;
} }
interface ShopPoliciesResponse {
data?: {
shop?: Record<string, ShopPolicy | null>;
};
}
// Handles Shopify uses for each policy — also the routes under /policies/[handle]. // Handles Shopify uses for each policy — also the routes under /policies/[handle].
export const POLICY_HANDLES = [ export const POLICY_HANDLES = [
'terms-of-service', 'terms-of-service',
@@ -24,32 +21,23 @@ export const POLICY_HANDLES = [
'subscription-policy', 'subscription-policy',
] as const; ] as const;
// Policies change rarely, so this uses its own fetch (revalidated hourly) // Policies change rarely, so this reads through the cached client (revalidated
// rather than the no-store `shopifyFetch` used for carts and products. // hourly) rather than the no-store one used for carts and products.
export async function getShopPolicies(): Promise<ShopPolicy[]> { export async function getShopPolicies(): Promise<ShopPolicy[]> {
const token = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN; try {
const data = unwrapStorefrontResult(
await cachedStorefront.graphql(GET_SHOP_POLICIES_QUERY),
'GetShopPolicies'
);
const response = await fetch(SHOPIFY_STOREFRONT_API_URL, { return Object.values(data.shop ?? {}).filter(
method: 'POST', (policy): policy is ShopPolicy => Boolean(policy?.handle)
headers: { );
'Content-Type': 'application/json', } catch (err) {
...(token ? { 'X-Shopify-Storefront-Access-Token': token } : {}), // A storefront without policies configured shouldn't break the footer.
}, console.error('Failed to load shop policies:', err);
body: JSON.stringify({ query: GET_SHOP_POLICIES_QUERY }),
next: { revalidate: 3600 },
});
if (!response.ok) {
console.error('Failed to load shop policies:', response.status);
return []; return [];
} }
const json: ShopPoliciesResponse = await response.json();
const shop = json.data?.shop ?? {};
return Object.values(shop).filter((policy): policy is ShopPolicy =>
Boolean(policy?.handle)
);
} }
export async function getShopPolicy( export async function getShopPolicy(
+16 -16
View File
@@ -3,23 +3,23 @@ export default {
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
images: { images: {
unoptimized: true,
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: 'https',
hostname: 'images.unsplash.com', hostname: 'images.unsplash.com',
}, },
{ // Covers cdn.shopify.com plus any other Shopify-hosted image subdomain.
protocol: 'https', {
hostname: 'cdn.shopify.com', protocol: 'https',
}, hostname: '**.shopify.com',
{ },
protocol: 'https', {
hostname: '**.frontend.co', protocol: 'https',
}, hostname: '**.frontend.co',
], },
}, ],
},
experimental: { experimental: {
reactDebugChannel: false reactDebugChannel: false
}, },
+12 -9
View File
@@ -5,27 +5,25 @@
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev --webpack",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start" "start": "next start",
"typecheck": "tsc --noEmit && hydrogen gql check"
}, },
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ai-sdk/react": "^4.0.47", "@ai-sdk/react": "^4.0.47",
"@next/swc-wasm-web": "16.2.10", "@next/swc-wasm-web": "16.2.10",
"@openrouter/ai-sdk-provider": "^3.0.0", "@openrouter/ai-sdk-provider": "^3.0.0",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-use-controllable-state": "^1.2.6", "@radix-ui/react-use-controllable-state": "^1.2.6",
"@remixicon/react": "^4.9.0", "@remixicon/react": "^4.9.0",
"@shopify/storefront-api-client": "^1.0.0", "@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@supabase/supabase-js": "^2.51.0",
"ai": "^7", "ai": "^7",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "12.42.2", "framer-motion": "12.42.2",
"hast-util-to-jsx-runtime": "^2.3.6",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.23.26", "motion": "^12.23.26",
@@ -34,18 +32,23 @@
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"shiki": "^3.19.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remend": "^1.3.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"unified": "^11.0.5",
"use-stick-to-bottom": "^1.1.6", "use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/hast": "^3.0.5",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
+110 -69
View File
@@ -3,7 +3,8 @@
// These are plain async functions with no React imports, so they can be called // These are plain async functions with no React imports, so they can be called
// from Route Handlers (see app/api/chat/route.ts) as well as from the client // from Route Handlers (see app/api/chat/route.ts) as well as from the client
// hooks in hooks/use-shopify-*.ts, which re-export them. // hooks in hooks/use-shopify-*.ts, which re-export them.
import { shopifyFetch } from '@/services/shopify/client'; import type { StorefrontApi } from '@shopify/hydrogen';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { import {
GET_PRODUCTS_QUERY, GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY, GET_PRODUCT_QUERY,
@@ -18,9 +19,11 @@ import {
SEARCH_SUGGESTIONS_QUERY, SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search'; } from '@/graphql/search';
// Optional fields are `| null` rather than just optional: the Storefront API
// returns explicit nulls, and the typed `gql()` documents now surface that.
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string; altText?: string | null;
} }
interface ProductPrice { interface ProductPrice {
@@ -37,7 +40,7 @@ interface ProductVariant {
name: string; name: string;
value: string; value: string;
}>; }>;
image?: ProductImage; image?: ProductImage | null;
} }
export interface ProductOptionValue { export interface ProductOptionValue {
@@ -64,6 +67,25 @@ export interface ProductOption {
optionValues?: ProductOptionValue[]; optionValues?: ProductOptionValue[];
} }
/**
* Single-variant products still carry one synthetic option — `Title` with the
* lone value `Default Title`. It isn't a real choice, so keep it out of the UI.
*/
export const isDefaultTitleOption = (option: {
name: string;
values: string[];
}): boolean =>
option.name === 'Title' &&
option.values.length === 1 &&
option.values[0] === 'Default Title';
/** Same synthetic option, as it appears on a variant's `selectedOptions`. */
export const isDefaultTitleSelection = (selection: {
name: string;
value: string;
}): boolean =>
selection.name === 'Title' && selection.value === 'Default Title';
export interface Product { export interface Product {
id: string; id: string;
title: string; title: string;
@@ -127,12 +149,14 @@ export async function getProductsPage({
sortKey = 'BEST_SELLING', sortKey = 'BEST_SELLING',
reverse = false, reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> { }: UseProductsOptions = {}): Promise<ProductsPage> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: GET_PRODUCTS_QUERY, await storefront.graphql(GET_PRODUCTS_QUERY, {
variables: { first, after, query, sortKey, reverse }, variables: { first, after, query, sortKey, reverse },
}); }),
'GetProducts'
);
const { edges, pageInfo } = response.data.products; const { edges, pageInfo } = data.products;
return { return {
products: edges.map((edge: { node: Product }) => edge.node), products: edges.map((edge: { node: Product }) => edge.node),
@@ -143,28 +167,30 @@ export async function getProductsPage({
// Fetch a single product by handle // Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> { export async function getProduct(handle: string): Promise<Product | null> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: GET_PRODUCT_QUERY, await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }),
variables: { handle }, 'GetProduct'
}); );
return response.data.product; return data.product;
} }
// Fetch product recommendations // Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> { export async function getProductRecommendations(productId: string): Promise<Product[]> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: QUERY_PRODUCT_RECOMMENDATIONS, await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, {
variables: { productId }, variables: { productId },
}); }),
'GetProductRecommendations'
);
return response.data.productRecommendations || []; return data.productRecommendations ?? [];
} }
interface CollectionImage { interface CollectionImage {
url: string; url: string;
altText?: string; altText?: string | null;
} }
export interface Collection { export interface Collection {
@@ -173,7 +199,7 @@ export interface Collection {
handle: string; handle: string;
description?: string; description?: string;
descriptionHtml?: string; descriptionHtml?: string;
image?: CollectionImage; image?: CollectionImage | null;
} }
export interface CollectionWithProducts extends Collection { export interface CollectionWithProducts extends Collection {
@@ -218,12 +244,12 @@ export interface ProductFilterFacet {
// Fetch all collections // Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> { export async function getCollections(first = 50): Promise<Collection[]> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: GET_COLLECTIONS_QUERY, await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
variables: { first }, 'GetCollections'
}); );
return response.data.collections.edges.map((edge: { node: Collection }) => edge.node); return data.collections.edges.map((edge) => edge.node);
} }
// Fetch products in a collection by handle // Fetch products in a collection by handle
@@ -248,28 +274,23 @@ export async function getCollectionProductsPage(
filterInputs = [], filterInputs = [],
}: UseCollectionProductsOptions = {} }: UseCollectionProductsOptions = {}
): Promise<CollectionProductsPage> { ): Promise<CollectionProductsPage> {
const filters = filterInputs.flatMap((input) => { const filters = parseFilterInputs(filterInputs);
try {
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: GET_COLLECTION_PRODUCTS_QUERY, await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, {
variables: { variables: {
handle, handle,
first, first,
after, after,
sortKey, sortKey,
reverse, reverse,
filters: filters.length ? filters : null, filters: filters.length ? filters : null,
}, },
}); }),
'GetCollectionProducts'
);
const collection = response.data.collection; const collection = data.collection;
if (!collection) { if (!collection) {
return { return {
collection: null, collection: null,
@@ -323,7 +344,7 @@ export interface SearchSuggestion {
handle: string; handle: string;
featuredImage?: { featuredImage?: {
url: string; url: string;
altText?: string; altText?: string | null;
} | null; } | null;
priceRange: { priceRange: {
minVariantPrice: { minVariantPrice: {
@@ -343,10 +364,18 @@ interface SearchProductsOptions {
filterInputs?: string[]; filterInputs?: string[];
} }
function parseFilterInputs(inputs: string[]): unknown[] { // The `ProductFilter` input shape, taken from the query that consumes it so it
// tracks the schema rather than being restated here.
type ProductFilterInput = NonNullable<
StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters']
>[number];
// Facet `input` values are opaque JSON strings produced by Shopify and handed
// straight back as filter inputs, so they are parsed, not constructed.
function parseFilterInputs(inputs: string[]): ProductFilterInput[] {
return inputs.flatMap((input) => { return inputs.flatMap((input) => {
try { try {
return [JSON.parse(input)]; return [JSON.parse(input) as ProductFilterInput];
} catch { } catch {
console.warn('Ignoring malformed product filter input:', input); console.warn('Ignoring malformed product filter input:', input);
return []; return [];
@@ -362,24 +391,30 @@ export async function searchProducts({
reverse = false, reverse = false,
filterInputs = [], filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> { }: SearchProductsOptions): Promise<SearchProductsResult> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: SEARCH_PRODUCTS_QUERY, await storefront.graphql(SEARCH_PRODUCTS_QUERY, {
variables: { variables: {
query, query,
first, first,
after, after,
sortKey, sortKey,
reverse, reverse,
productFilters: filterInputs.length productFilters: filterInputs.length
? parseFilterInputs(filterInputs) ? parseFilterInputs(filterInputs)
: null, : null,
}, },
}); }),
'SearchProducts'
);
const search = response.data.search; const search = data.search;
return { return {
products: search.edges.map((edge: { node: Product }) => edge.node), products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0, totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [], filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage), hasNextPage: Boolean(search.pageInfo?.hasNextPage),
@@ -391,15 +426,21 @@ export async function searchSuggestions(
query: string, query: string,
first = 3 first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> { ): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: SEARCH_SUGGESTIONS_QUERY, await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, {
variables: { query, first }, variables: { query, first },
}); }),
'SearchSuggestions'
);
const search = response.data.search; const search = data.search;
return { return {
products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node), products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0, totalCount: search.totalCount ?? 0,
}; };
} }
-48
View File
@@ -1,48 +0,0 @@
// Shopify Storefront API Service
const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
const SHOPIFY_STOREFRONT_ACCESS_TOKEN = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
const SHOPIFY_API_VERSION = process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2025-07';
const SHOPIFY_STOREFRONT_API_URL = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;
// Shopify API request with optional access token
async function shopifyFetch({ query, variables = {} }) {
try {
const headers = {
'Content-Type': 'application/json',
};
// Add access token if available
if (SHOPIFY_STOREFRONT_ACCESS_TOKEN) {
headers['X-Shopify-Storefront-Access-Token'] = SHOPIFY_STOREFRONT_ACCESS_TOKEN;
}
const response = await fetch(SHOPIFY_STOREFRONT_API_URL, {
method: 'POST',
headers,
body: JSON.stringify({
query,
variables,
}),
cache: 'no-store', // Ensure fresh data for cart operations
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Shopify API HTTP error! Status: ${response.status}, Body: ${errorBody}`);
}
const json = await response.json();
if (json.errors) {
console.error('Shopify API errors:', json.errors);
throw new Error(`Shopify GraphQL errors: ${JSON.stringify(json.errors)}`);
}
return json;
} catch (error) {
console.error('Shopify fetch error:', error);
throw error;
}
}
export { shopifyFetch, SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_API_URL };
+105
View File
@@ -0,0 +1,105 @@
// Storefront API clients, built on `@shopify/hydrogen`.
//
// Call sites use the package's own API — `storefront.graphql(DOCUMENT, {
// variables })` — and pass the result through `unwrapStorefrontResult`, which
// applies this app's error policy: fail loudly. Hydrogen deliberately does not
// do that itself, because a 200 carrying partial data and GraphQL errors is a
// valid response that some callers want to render.
import {
createShopifyRequestContext,
createStorefrontClient,
type GraphQLFormattedError,
} from '@shopify/hydrogen';
import {
SHOPIFY_API_VERSION,
SHOPIFY_PUBLIC_ACCESS_TOKEN,
SHOPIFY_STORE_DOMAIN,
} from '@/services/shopify/config';
// No incoming request and no buyer context: these clients are module-scoped and
// serve both server rendering and the browser-side hooks, so they must not close
// over per-request state. `$country`/`$language` are injected from this i18n.
const requestContext = createShopifyRequestContext({
request: { headers: new Headers() },
i18n: { country: 'US', language: 'EN' },
});
/**
* Workaround for a bug in this preview build of `@shopify/hydrogen`.
*
* The client tags every request with `X-Hydrogen-Version`, but the Storefront
* API does not list that header in its CORS `access-control-allow-headers`.
* Browsers therefore reject the preflight and `fetch` throws, which hydrogen
* reports as the generic "SFAPI request failed". It only bites against real
* stores — `mock.shop` answers `access-control-allow-headers: *`.
*
* Stripped in the browser only: server-side requests are not subject to CORS,
* so they keep sending the header. Remove this once the API allows it (or once
* these queries move server-side, which is the better long-term fix).
*/
const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version'];
// Hydrogen calls `fetch(url, init, cacheOptions)`; Next's caching hints ride
// along on `init`, which is how the two ways of caching get to coexist.
const fetchWith = (overrides: RequestInit): typeof globalThis.fetch =>
((url, init) => {
const headers = new Headers(init?.headers);
if (typeof document !== 'undefined') {
for (const header of CORS_BLOCKED_HEADERS) headers.delete(header);
}
return globalThis.fetch(url, { ...init, ...overrides, headers });
}) as typeof globalThis.fetch;
const config = {
storeDomain: SHOPIFY_STORE_DOMAIN!,
apiVersion: SHOPIFY_API_VERSION,
publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN,
};
/**
* Default client. Uncached, because carts and customer reads must never serve a
* stale response.
*/
export const storefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ cache: 'no-store' }) },
});
/**
* Client for data that changes rarely (shop policies, and anything else safe to
* serve from Next's data cache for an hour).
*/
export const cachedStorefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) },
});
/**
* Returns the data from a `graphql()` result, throwing if Shopify reported any
* GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies —
* have already thrown as `StorefrontApiError` by this point.
*
* `operation` names the query in the log and the thrown message, so a failure
* points at the call site rather than just at "Shopify".
*/
export function unwrapStorefrontResult<TData>(
result: { data: TData | null; errors?: GraphQLFormattedError[] },
operation: string
): TData {
if (result.errors?.length) {
console.error(`Shopify GraphQL errors (${operation}):`, result.errors);
throw new Error(
`Shopify GraphQL errors (${operation}): ${JSON.stringify(result.errors)}`
);
}
if (result.data == null) {
throw new Error(`Shopify returned no data for ${operation}.`);
}
return result.data;
}
+16
View File
@@ -0,0 +1,16 @@
// Storefront configuration, read from the environment in one place.
//
// These are all `NEXT_PUBLIC_*`, so Next inlines them at build time and they are
// safe to read from client components as well as server code.
export const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
/**
* Public Storefront API access token. Safe to expose to the browser — that is
* what "public" means here. Omitted for tokenless storefronts such as
* `mock.shop`. Never put a *private* token behind a `NEXT_PUBLIC_` name.
*/
export const SHOPIFY_PUBLIC_ACCESS_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN;
export const SHOPIFY_API_VERSION =
process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2026-07';
+62 -37
View File
@@ -3,7 +3,7 @@
// The customer access token is a credential: it is only ever handled here and // The customer access token is a credential: it is only ever handled here and
// in the /api/account route handlers, and is stored in an httpOnly cookie so // in the /api/account route handlers, and is stored in an httpOnly cookie so
// client JavaScript can never read it. // client JavaScript can never read it.
import { shopifyFetch } from '@/services/shopify/client'; import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { import {
CUSTOMER_QUERY, CUSTOMER_QUERY,
CUSTOMER_CREATE_MUTATION, CUSTOMER_CREATE_MUTATION,
@@ -18,7 +18,8 @@ import {
export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken'; export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken';
export interface CustomerUserError { export interface CustomerUserError {
code?: string; /** One of `CustomerErrorCode`; kept as a string so new codes don't break. */
code?: string | null;
field?: string[] | null; field?: string[] | null;
message: string; message: string;
} }
@@ -57,7 +58,7 @@ export interface CustomerOrder {
export interface Customer { export interface Customer {
id: string; id: string;
email: string; email: string | null;
firstName?: string | null; firstName?: string | null;
lastName?: string | null; lastName?: string | null;
phone?: string | null; phone?: string | null;
@@ -84,6 +85,13 @@ const firstMessage = (errors: CustomerUserError[]) =>
export { firstMessage as customerErrorMessage }; export { firstMessage as customerErrorMessage };
// Shopify returns a null mutation payload when the mutation could not run at
// all. That is not a success, so it surfaces as a generic error rather than an
// empty error list, which callers read as "it worked".
const MUTATION_FAILED: CustomerUserError[] = [
{ message: 'Something went wrong. Please try again.' },
];
export async function createCustomer(input: { export async function createCustomer(input: {
email: string; email: string;
password: string; password: string;
@@ -91,24 +99,32 @@ export async function createCustomer(input: {
lastName?: string; lastName?: string;
acceptsMarketing?: boolean; acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> { }): Promise<{ errors: CustomerUserError[] }> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: CUSTOMER_CREATE_MUTATION, await storefront.graphql(CUSTOMER_CREATE_MUTATION, {
variables: { input }, variables: { input },
}); }),
'CustomerCreate'
);
return { errors: response.data.customerCreate.customerUserErrors ?? [] }; const result = data.customerCreate;
if (!result) return { errors: MUTATION_FAILED };
return { errors: result.customerUserErrors ?? [] };
} }
export async function login( export async function login(
email: string, email: string,
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, {
variables: { input: { email, password } }, variables: { input: { email, password } },
}); }),
'CustomerAccessTokenCreate'
);
const result = response.data.customerAccessTokenCreate; const result = data.customerAccessTokenCreate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return { return {
token: result.customerAccessToken ?? null, token: result.customerAccessToken ?? null,
@@ -118,8 +134,7 @@ export async function login(
export async function logout(accessToken: string): Promise<void> { export async function logout(accessToken: string): Promise<void> {
try { try {
await shopifyFetch({ await storefront.graphql(CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, {
query: CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
variables: { customerAccessToken: accessToken }, variables: { customerAccessToken: accessToken },
}); });
} catch (err) { } catch (err) {
@@ -132,8 +147,7 @@ export async function logout(accessToken: string): Promise<void> {
// would leak account membership. // would leak account membership.
export async function recoverPassword(email: string): Promise<void> { export async function recoverPassword(email: string): Promise<void> {
try { try {
await shopifyFetch({ await storefront.graphql(CUSTOMER_RECOVER_MUTATION, {
query: CUSTOMER_RECOVER_MUTATION,
variables: { email }, variables: { email },
}); });
} catch (err) { } catch (err) {
@@ -146,12 +160,15 @@ export async function resetPassword(
resetToken: string, resetToken: string,
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: CUSTOMER_RESET_MUTATION, await storefront.graphql(CUSTOMER_RESET_MUTATION, {
variables: { id, input: { resetToken, password } }, variables: { id, input: { resetToken, password } },
}); }),
'CustomerReset'
);
const result = response.data.customerReset; const result = data.customerReset;
if (!result) return { token: null, errors: MUTATION_FAILED };
return { return {
token: result.customerAccessToken ?? null, token: result.customerAccessToken ?? null,
@@ -164,12 +181,15 @@ export async function activateAccount(
activationToken: string, activationToken: string,
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: CUSTOMER_ACTIVATE_MUTATION, await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, {
variables: { id, input: { activationToken, password } }, variables: { id, input: { activationToken, password } },
}); }),
'CustomerActivate'
);
const result = response.data.customerActivate; const result = data.customerActivate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return { return {
token: result.customerAccessToken ?? null, token: result.customerAccessToken ?? null,
@@ -182,12 +202,14 @@ export async function getCustomer(
orderCount = 10 orderCount = 10
): Promise<Customer | null> { ): Promise<Customer | null> {
try { try {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: CUSTOMER_QUERY, await storefront.graphql(CUSTOMER_QUERY, {
variables: { customerAccessToken: accessToken, orderCount }, variables: { customerAccessToken: accessToken, orderCount },
}); }),
'GetCustomer'
);
return response.data.customer ?? null; return data.customer ?? null;
} catch (err) { } catch (err) {
// An expired or revoked token reads as "not signed in". // An expired or revoked token reads as "not signed in".
console.error('Failed to load customer:', err); console.error('Failed to load customer:', err);
@@ -204,12 +226,15 @@ export async function updateCustomer(
phone?: string; phone?: string;
} }
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> { ): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const response = await shopifyFetch({ const data = unwrapStorefrontResult(
query: CUSTOMER_UPDATE_MUTATION, await storefront.graphql(CUSTOMER_UPDATE_MUTATION, {
variables: { customerAccessToken: accessToken, customer }, variables: { customerAccessToken: accessToken, customer },
}); }),
'CustomerUpdate'
);
const result = response.data.customerUpdate; const result = data.customerUpdate;
if (!result) return { customer: null, errors: MUTATION_FAILED };
return { return {
customer: result.customer ?? null, customer: result.customer ?? null,
+3
View File
@@ -20,6 +20,9 @@
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
},
{
"name": "@shopify/hydrogen/ts-plugin"
} }
], ],
"paths": { "paths": {
+120 -1759
View File
File diff suppressed because it is too large Load Diff