Compare commits

..
11 Commits
Author SHA1 Message Date
Rami BitarandClaude Opus 5 2377f5c3d2 Remove the animated underline on header nav links
Nav links now use the same plain colour transition as the mobile menu
and footer links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 17:36:17 -04:00
Rami BitarandClaude Opus 5 489dfd5e97 Drop hardcoded white backgrounds
Product and collection card images, gallery slides, the zoom overlay
image, and the products section were pinned to bg-white while every
other section uses bg-background. They are transparent now and inherit
the page.

Skeleton fills and image-placeholder tiles keep their zinc backgrounds —
those are load-state and empty-state affordances, not theming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 17:33:31 -04:00
Rami BitarandClaude Opus 5 8167acb231 Add customer accounts: login, signup, reset, activate, orders
- Storefront customer operations in graphql/customer.js and a server-safe
  services/shopify/customer.ts
- Session held in an httpOnly, sameSite=lax cookie set by the
  /api/account handlers; the access token never reaches client JS
- Pages: /account/login, /register, /recover, /reset/[id]/[token] and
  /activate/[id]/[token] for Shopify's emailed links
- /account renders order history as master-detail on one screen, since
  the Storefront API has no standalone order-by-id query for customers
- Header user icon: links to sign-in when signed out, otherwise a menu
  with name, email, order history, and sign out
- Login errors are collapsed and password recovery responds identically
  for known and unknown emails, so neither form enumerates accounts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 16:11:47 -04:00
Rami BitarandClaude Opus 5 8163f99bd7 Merge feat/agent: AI store assistant with catalogue tool calls
Adds /api/chat (AI SDK v7 via OpenRouter) with read-only catalogue tools
and a popover assistant, gated behind NEXT_PUBLIC_ENABLE_AI=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 14:56:08 -04:00
Rami BitarandClaude Opus 5 7c6abb4648 Refine the store assistant UI and gate it behind a flag
- Gate the assistant on NEXT_PUBLIC_ENABLE_AI=1 (UI only; the /api/chat
  route still responds if called directly)
- Chat model is openai/gpt-5.6-luna-pro with reasoning requested and
  sendReasoning enabled, rendered via the Reasoning component
- Suggestion chips and reasoning come from ai-elements; attachments gain
  hover-card previews, left alignment, and render under the user message
- Launcher: plain Button when closed, magicui RainbowButton when open
- Tool results replace the collapsible Tool UI with a shimmer while
  running and a muted icon + summary line after, plus product previews
- Commerce icons in place of the folder icon for collections
- Add a thin announcement bar above the header; footer splits links and
  socials onto separate rows and clears the launcher corner
- Add the shadcn base layer so Tailwind v4's bare `border` picks up the
  theme colour instead of currentColor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 14:55:04 -04:00
Rami BitarandClaude Opus 5 107959a4c3 Add AI store assistant with catalogue tool calls
- /api/chat streams via AI SDK v7 through OpenRouter (OPENROUTER_API_KEY),
  with a store-specific system prompt and five read-only tools:
  searchCatalogue, getProductDetails, listCollections,
  getCollectionProducts, browseProducts
- Sidebar assistant on the right that opens from a launcher and expands,
  built on ai-elements (conversation, message, prompt-input, tool) with
  shimmer on in-flight tool calls
- Extract services/shopify/catalog.ts so the Storefront fetchers have no
  React imports and can run in a Route Handler; the client hooks now
  re-export from it
- ai-elements pulled in the canonical shadcn primitives, replacing the
  hand-rolled command/dialog/button variants; search dialog moved to the
  cmdk-based Command and CommandDialog forwards shouldFilter
- Pin shiki to ^3.19.0 to match streamdown and drop the duplicate copy
- Add .env.example documenting the required env vars

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 13:03:01 -04:00
Rami BitarandClaude Opus 5 2ef5639a2e Add collection filters and sort, out-of-stock options, gallery drag
- Collections: filters and sort via the Storefront API — the products
  connection now takes `filters`/`after` and returns its facets, with
  cursor-based load more on the collection page
- Extract ProductFilters (renamed from SearchFilters) and a shared
  ProductToolbar so search and collections use the same chrome
- PDP: mark option values with no in-stock variant using a diagonal
  strike, computed against the other selected options
- PDP: pointer drag-scrolling for the mobile image carousel, since a
  scroll container doesn't drag with a mouse
- Header/cart: circular icon buttons, tighter spacing, and icon sizes
  set by class (Button's base CSS clamps un-classed svgs to size-4)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 12:27:05 -04:00
Rami BitarandClaude Opus 5 cc901b9ce8 Add product search with autocomplete, filters, and sort
- Header: search button opening a command-palette dialog with debounced
  product suggestions, a term chip, and View All
- Command: shadcn-shaped primitives built on the project's own Dialog so
  no cmdk/radix dependency is introduced
- /search: results grid with item count, sort (relevance, price asc/desc),
  and cursor-based load more
- Filters: driven by the API's productFilters facets — colour swatches,
  labelled lists with counts, and a price range — round-tripped through
  each facet's raw input string so no filter shape is hardcoded

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 12:15:49 -04:00
Rami BitarandClaude Opus 5 e04f1e0405 Redesign cart and collections, fix product pagination
- Cart drawer: single-column layout matching the site — no bag icon,
  no dividers, square images, tighter type, discount code form,
  estimated total, and a Go to Checkout button
- Cart: per-line loading state so one row's update no longer disables
  every other row or the checkout button
- Discounts: cartDiscountCodesUpdate mutation plus discountCodes on the
  cart fragment, wired to an applyDiscountCode store action
- Products: fix "load more" returning the same first page — the query
  now takes an `after` cursor and getProductsPage exposes pageInfo
- Collections: cards match the product cards (no border, radius, blurb,
  or CTA row) at 4-up on desktop and 2-up on mobile; section headers
  match the products section
- Shop Pay: cart permalink with payment=shop_pay instead of the hosted
  shop-js element
- Buttons converted to the shadcn Button component throughout
- PDP: swipeable image carousel with dots on mobile, sticky info column,
  colour swatches never fall back to variant photos
- Rename the store from Stride to Shop; larger header wordmark

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 12:05:57 -04:00
Rami BitarandClaude Opus 5 69f7435d6e Redesign storefront to match minimal reference design
Product cards, header, footer, PDP, and typography reworked toward a
leaner layout; adds shop policy pages backed by the Storefront API.

- Type: switch to Geist Sans/Mono, regular-weight headings
- Product cards: drop borders, rounded corners, and action buttons
- Header: shorter bar, no bottom border, center-out hover underline,
  bag icon replacing the cart icon (drawer wording updated to match)
- Footer: single line with policy links and social icons on bg-background
- Policies: /policies/[handle] renders shop.privacyPolicy,
  termsOfService, refundPolicy, shippingPolicy, subscriptionPolicy
  (SSG, hourly revalidation)
- PDP: image grid with mobile carousel + dots and click-to-zoom,
  sticky info column, colour swatches from option optionValues with a
  configurable name-to-colour fallback in config/swatches.ts,
  Shop-purple checkout button
- Recommendations: left-aligned heading on bg-background
- Ignore .env*.local and tsconfig.tsbuildinfo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 11:11:25 -04:00
Rami BitarandClaude Opus 5 e3d5e75299 Initial commit: Shopify storefront Next.js template
Next.js 16 + React 19 storefront template with Shopify Storefront API
integration, Tailwind v4, and shadcn/ui components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111qSr3KopyGRsJ6LznR1xZ
2026-07-31 22:12:19 -04:00
64 changed files with 3261 additions and 1678 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_PUBLIC_ACCESS_TOKEN= # NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07 # NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-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,6 +5,3 @@ 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
+25 -19
View File
@@ -1,6 +1,8 @@
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' }; export const metadata = { title: 'Activate your account — Shop' };
// 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({
@@ -11,23 +13,27 @@ 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"> <>
<AccountForm <Header storeName="Shop" logoUrl="" />
title="Activate your account" <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
description="Choose a password to finish setting up your account." <AccountForm
fields={[ title="Activate your account"
{ description="Choose a password to finish setting up your account."
name: 'password', fields={[
label: 'Password', {
type: 'password', name: 'password',
autoComplete: 'new-password', label: 'Password',
}, type: 'password',
]} autoComplete: 'new-password',
submitLabel="Activate account" },
endpoint="/api/account/activate" ]}
extraPayload={{ id, activationToken: token }} submitLabel="Activate account"
redirectTo="/account" endpoint="/api/account/activate"
/> extraPayload={{ id, activationToken: token }}
</main> redirectTo="/account"
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+36 -30
View File
@@ -1,38 +1,44 @@
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' }; export const metadata = { title: 'Sign in — Shop' };
export default function Page() { export default function Page() {
return ( return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> <>
<AccountForm <Header storeName="Shop" logoUrl="" />
title="Sign in" <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
fields={[ <AccountForm
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' }, title="Sign in"
{ fields={[
name: 'password', { name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
label: 'Password', {
type: 'password', name: 'password',
autoComplete: 'current-password', label: 'Password',
}, type: 'password',
]} autoComplete: 'current-password',
submitLabel="Sign in" },
endpoint="/api/account/login" ]}
redirectTo="/account" submitLabel="Sign in"
footer={ endpoint="/api/account/login"
<> redirectTo="/account"
<span> footer={
New here?{' '} <>
<AccountFormLink href="/account/register"> <span>
Create an account New here?{' '}
<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? />
</AccountFormLink> </main>
</> <Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
} </>
/>
</main>
); );
} }
+20 -12
View File
@@ -1,9 +1,11 @@
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' }; export const metadata = { title: 'Order history — Shop' };
export default async function Page() { export default async function Page() {
const token = await getSessionToken(); const token = await getSessionToken();
@@ -14,17 +16,23 @@ 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"> <>
<h1 className="text-2xl md:text-3xl font-normal text-foreground"> <Header storeName="Shop" logoUrl="" />
Order history
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{customer.displayName} · {customer.email}
</p>
<div className="mt-10"> <main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
<OrderHistory customer={customer} /> <h1 className="text-2xl md:text-3xl font-normal text-foreground">
</div> Order history
</main> </h1>
<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." />
</>
); );
} }
+22 -16
View File
@@ -1,23 +1,29 @@
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' }; export const metadata = { title: 'Reset password — Shop' };
export default function Page() { export default function Page() {
return ( return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> <>
<AccountForm <Header storeName="Shop" logoUrl="" />
title="Reset password" <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
description="Enter your email and we'll send you a link to set a new password." <AccountForm
fields={[ title="Reset password"
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' }, description="Enter your email and we'll send you a link to set a new password."
]} fields={[
submitLabel="Send reset link" { name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
endpoint="/api/account/recover" ]}
successMessage="If that email has an account, a reset link is on its way." submitLabel="Send reset link"
footer={ endpoint="/api/account/recover"
<AccountFormLink href="/account/login">Back to sign in</AccountFormLink> successMessage="If that email has an account, a reset link is on its way."
} footer={
/> <AccountFormLink href="/account/login">Back to sign in</AccountFormLink>
</main> }
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+42 -36
View File
@@ -1,43 +1,49 @@
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' }; export const metadata = { title: 'Create account — Shop' };
export default function Page() { export default function Page() {
return ( return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16"> <>
<AccountForm <Header storeName="Shop" logoUrl="" />
title="Create account" <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
fields={[ <AccountForm
{ title="Create account"
name: 'firstName', fields={[
label: 'First name', {
autoComplete: 'given-name', name: 'firstName',
required: false, label: 'First name',
}, autoComplete: 'given-name',
{ required: false,
name: 'lastName', },
label: 'Last name', {
autoComplete: 'family-name', name: 'lastName',
required: false, label: 'Last name',
}, autoComplete: 'family-name',
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' }, required: false,
{ },
name: 'password', { name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
label: 'Password', {
type: 'password', name: 'password',
autoComplete: 'new-password', label: 'Password',
}, type: 'password',
]} autoComplete: 'new-password',
submitLabel="Create account" },
endpoint="/api/account/register" ]}
redirectTo="/account" submitLabel="Create account"
footer={ endpoint="/api/account/register"
<span> redirectTo="/account"
Already have an account?{' '} footer={
<AccountFormLink href="/account/login">Sign in</AccountFormLink> <span>
</span> Already have an account?{' '}
} <AccountFormLink href="/account/login">Sign in</AccountFormLink>
/> </span>
</main> }
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+24 -18
View File
@@ -1,6 +1,8 @@
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' }; export const metadata = { title: 'Set a new password — Shop' };
// 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({
@@ -11,22 +13,26 @@ 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"> <>
<AccountForm <Header storeName="Shop" logoUrl="" />
title="Set a new password" <main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
fields={[ <AccountForm
{ title="Set a new password"
name: 'password', fields={[
label: 'New password', {
type: 'password', name: 'password',
autoComplete: 'new-password', label: 'New password',
}, type: 'password',
]} autoComplete: 'new-password',
submitLabel="Save password" },
endpoint="/api/account/reset" ]}
extraPayload={{ id, resetToken: token }} submitLabel="Save password"
redirectTo="/account" endpoint="/api/account/reset"
/> extraPayload={{ id, resetToken: token }}
</main> redirectTo="/account"
/>
</main>
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
</>
); );
} }
+5 -11
View File
@@ -7,8 +7,6 @@ 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
@@ -65,12 +63,10 @@ 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 options: product.options?.map((option) => ({
?.filter((option) => !isDefaultTitleOption(option)) name: option.name,
.map((option) => ({ values: option.values,
name: option.name, })),
values: option.values,
})),
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale), inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
}); });
@@ -136,9 +132,7 @@ 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?.filter( options: node.selectedOptions,
(option) => !isDefaultTitleSelection(option)
),
})), })),
}; };
}, },
+19 -49
View File
@@ -1,53 +1,23 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
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 <CollectionDetail />; return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<CollectionDetail />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
} }
+19 -7
View File
@@ -1,11 +1,23 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
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 <Collections title="Our Collections" />; return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
} }
+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-[60vh]"> <div className="flex items-center justify-center min-h-screen">
<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
+4 -37
View File
@@ -1,11 +1,9 @@
'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'],
@@ -17,29 +15,6 @@ 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,
}: { }: {
@@ -47,16 +22,8 @@ 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 flex min-h-screen flex-col"> <body className="font-body antialiased bg-background text-foreground m-0 p-0">
{/* Header owns the cart drawer, search and account menu, so every {children}
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-[60vh]"> <div className="flex items-center justify-center min-h-screen">
<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
+20 -13
View File
@@ -1,19 +1,26 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
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 <>
title="Shopify Hydrogen Storefront" <Header
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen." storeName="Shop"
/> logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Products
title="Shopify Hydrogen Storefront"
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
); );
} }
+30 -17
View File
@@ -1,6 +1,7 @@
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 }));
@@ -14,12 +15,8 @@ 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.title, title: policy ? `${policy.title} — Shop` : 'Policy — Shop',
description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`,
alternates: { canonical: `/policies/${policy.handle}` },
}; };
} }
@@ -36,17 +33,33 @@ export default async function PolicyPage({
} }
return ( return (
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16"> <>
<div className="max-w-2xl mx-auto"> <Header
<h1 className="text-3xl md:text-4xl font-normal text-foreground"> storeName="Shop"
{policy.title} logoUrl=""
</h1> links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<div <main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
className="policy-body mt-8 text-[15px] leading-7 text-foreground" <div className="max-w-2xl mx-auto">
dangerouslySetInnerHTML={{ __html: policy.body }} <h1 className="text-3xl md:text-4xl font-normal text-foreground">
/> {policy.title}
</div> </h1>
</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."
/>
</>
); );
} }
+14 -42
View File
@@ -1,53 +1,25 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
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."
/>
</> </>
); );
} }
+23 -6
View File
@@ -1,16 +1,33 @@
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', title: 'Search — Shop',
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. <>
<Suspense fallback={<div className="py-10" />}> <Header
<SearchResults /> storeName="Shop"
</Suspense> logoUrl=""
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."
/>
</>
); );
} }
+19 -9
View File
@@ -1,13 +1,23 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
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 <Collections title="Our Collections" />; return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/shop/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
} }
+562
View File
@@ -0,0 +1,562 @@
"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
@@ -1,295 +0,0 @@
"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";
+26 -5
View File
@@ -12,19 +12,23 @@ 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"];
@@ -315,10 +319,27 @@ export const MessageBranchPage = ({
); );
}; };
export type MessageResponseProps = ComponentProps<typeof Markdown>; export type MessageResponseProps = ComponentProps<typeof Streamdown>;
// Markdown already memoises on children/isAnimating. const streamdownPlugins = { cjk, code, math, mermaid };
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">;
+19 -17
View File
@@ -7,6 +7,10 @@ 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 {
@@ -19,8 +23,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 {
@@ -200,23 +204,21 @@ export type ReasoningContentProps = ComponentProps<
children: string; children: string;
}; };
export const ReasoningContent = memo( const streamdownPlugins = { cjk, code, math, mermaid };
({ className, children, ...props }: ReasoningContentProps) => {
const { isStreaming } = useReasoning();
return ( export const ReasoningContent = memo(
<CollapsibleContent ({ className, children, ...props }: ReasoningContentProps) => (
className={cn( <CollapsibleContent
"mt-4 text-sm", className={cn(
"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", "mt-4 text-sm",
className "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
{...props} )}
> {...props}
<Markdown isAnimating={isStreaming}>{children}</Markdown> >
</CollapsibleContent> <Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
); </CollapsibleContent>
} )
); );
Reasoning.displayName = "Reasoning"; Reasoning.displayName = "Reasoning";
+173
View File
@@ -0,0 +1,173 @@
"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
@@ -1,49 +0,0 @@
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 '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
export interface AccountFormField { export interface AccountFormField {
name: string; name: string;
+3 -10
View File
@@ -1,10 +1,9 @@
'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 '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -26,7 +25,6 @@ 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);
@@ -104,10 +102,7 @@ const CartDrawer: React.FC = () => {
}; };
const getSelectedOptions = (item: (typeof items)[0]) => { const getSelectedOptions = (item: (typeof items)[0]) => {
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line. return item.merchandise.selectedOptions ?? [];
return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option)
);
}; };
return ( return (
@@ -172,11 +167,9 @@ 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 ? (
<Image <img
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"
/> />
) : ( ) : (
+4 -7
View File
@@ -1,10 +1,9 @@
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 | null; altText?: string;
} }
interface Collection { interface Collection {
@@ -12,7 +11,7 @@ interface Collection {
title: string; title: string;
handle: string; handle: string;
description?: string; description?: string;
image?: CollectionImage | null; image?: CollectionImage;
} }
interface CollectionCardProps { interface CollectionCardProps {
@@ -28,12 +27,10 @@ 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 ? (
<Image <img
src={collection.image.url} src={collection.image.url}
alt={collection.image.altText || collection.title} alt={collection.image.altText || collection.title}
fill className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
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 '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { import {
getCollectionProductsPage, getCollectionProductsPage,
type CollectionSortKey, type CollectionSortKey,
-10
View File
@@ -4,7 +4,6 @@ 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;
@@ -13,7 +12,6 @@ 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;
@@ -23,7 +21,6 @@ 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' },
@@ -49,13 +46,6 @@ 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
+15 -7
View File
@@ -6,10 +6,8 @@ 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);
@@ -50,7 +48,7 @@ interface HeaderProps {
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({
storeName = 'Logo', storeName = 'Shop',
logoUrl, logoUrl,
links = [ links = [
{ label: 'Shop', url: '/' }, { label: 'Shop', url: '/' },
@@ -65,7 +63,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-muted text-foreground"> <div className="bg-foreground text-background">
<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
@@ -85,11 +83,22 @@ 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 */}
<Logo src={logoUrl} storeName={storeName} /> <Link href="/" className="flex items-center">
{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}
@@ -129,7 +138,6 @@ 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}
+1 -4
View File
@@ -1,7 +1,6 @@
'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';
@@ -124,11 +123,9 @@ 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 ? (
<Image <img
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"
/> />
) : ( ) : (
+3 -6
View File
@@ -1,11 +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';
import { truncate } from '@/lib/utils'; import { truncate } from '@/lib/utils';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string | null; altText?: string;
} }
interface ProductPrice { interface ProductPrice {
@@ -69,12 +68,10 @@ 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 ? (
<Image <img
src={firstImage.url} src={firstImage.url}
alt={firstImage.altText || product.title} alt={firstImage.altText || product.title}
fill className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]"
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 | null; altText?: string;
} | null; };
} }
export type { Product }; export type { Product };
@@ -1,15 +1,12 @@
'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 | null; altText?: string;
width?: number | null;
height?: number | null;
} }
interface ProductDetailGalleryProps { interface ProductDetailGalleryProps {
@@ -139,14 +136,11 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
isSingle ? 'sm:col-span-2' : '' isSingle ? 'sm:col-span-2' : ''
}`} }`}
> >
<Image <img
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="object-cover select-none" className="w-full h-full object-cover select-none"
/> />
</button> </button>
))} ))}
@@ -179,19 +173,11 @@ 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"
> >
<Image <img
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()}
// w/h-auto keeps the box at the image's own ratio; without it the className="max-h-full max-w-full object-contain"
// 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,11 +1,10 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/app/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;
@@ -104,10 +103,9 @@ 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.
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it. const orderedOptions = [...(product.options ?? [])].sort(
const orderedOptions = [...(product.options ?? [])] (a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))
.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[] =>
@@ -172,22 +170,12 @@ 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={{
// With no colour and no image the circle would be fully backgroundColor: background,
// 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] font-medium uppercase text-muted-foreground"> <span className="text-[10px]">{value.name.at(0)}</span>
{value.name.at(0)}
</span>
)} )}
</Button> </Button>
); );
+2 -7
View File
@@ -208,15 +208,10 @@ 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={{ style={{ backgroundColor: color ?? undefined }}
// 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] font-medium uppercase text-muted-foreground"> <span className="text-[10px]">
{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 '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string | null; altText?: string;
} }
interface ProductPrice { interface ProductPrice {
+3 -6
View File
@@ -1,7 +1,6 @@
'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,
@@ -12,7 +11,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 '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react'; import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
import { import {
searchSuggestions, searchSuggestions,
@@ -112,7 +111,7 @@ const SearchDialog: React.FC = () => {
if (event.key === 'Enter') goToSearchPage(); if (event.key === 'Enter') goToSearchPage();
}} }}
/> />
<div className="absolute right-2 top-0 flex h-12 items-center gap-1"> <div className="absolute right-2 top-0 flex h-9 items-center gap-1">
{hasQuery && ( {hasQuery && (
<Button <Button
onClick={() => setTerm('')} onClick={() => setTerm('')}
@@ -163,11 +162,9 @@ 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 ? (
<Image <img
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 '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { import {
searchProducts, searchProducts,
type SearchFilter, type SearchFilter,
-152
View File
@@ -1,152 +0,0 @@
'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/config'; import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client';
import ShopPayLogo from '@/components/shopify/shop-pay-logo'; import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/app/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,11 +393,6 @@ 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,
+1 -8
View File
@@ -22,7 +22,6 @@ 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}
@@ -54,13 +53,7 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogContent <DialogContent
className={cn( className={cn("overflow-hidden p-0", className)}
"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
@@ -1,21 +0,0 @@
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[],
};
+26 -46
View File
@@ -1,7 +1,5 @@
import { gql } from '@shopify/hydrogen';
// Cart Fragment for consistent cart data // Cart Fragment for consistent cart data
const CartFragment = gql(` const CartFragment = `
fragment CartFragment on Cart { fragment CartFragment on Cart {
id id
checkoutUrl checkoutUrl
@@ -66,13 +64,12 @@ const CartFragment = gql(`
} }
} }
} }
`); `;
// Create a new cart // Create a new cart
export const CREATE_CART_MUTATION = gql( export const CREATE_CART_MUTATION = `
` ${CartFragment}
mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode) mutation CreateCart($lines: [CartLineInput!]) {
@inContext(country: $country, language: $language) {
cartCreate(input: { lines: $lines }) { cartCreate(input: { lines: $lines }) {
cart { cart {
...CartFragment ...CartFragment
@@ -83,15 +80,12 @@ export const CREATE_CART_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Add lines to cart // Add lines to cart
export const ADD_CART_LINES_MUTATION = gql( export const ADD_CART_LINES_MUTATION = `
` ${CartFragment}
mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode) mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!) {
@inContext(country: $country, language: $language) {
cartLinesAdd(cartId: $cartId, lines: $lines) { cartLinesAdd(cartId: $cartId, lines: $lines) {
cart { cart {
...CartFragment ...CartFragment
@@ -102,15 +96,12 @@ export const ADD_CART_LINES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Update cart lines // Update cart lines
export const UPDATE_CART_LINES_MUTATION = gql( export const UPDATE_CART_LINES_MUTATION = `
` ${CartFragment}
mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode) mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
@inContext(country: $country, language: $language) {
cartLinesUpdate(cartId: $cartId, lines: $lines) { cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart { cart {
...CartFragment ...CartFragment
@@ -121,15 +112,12 @@ export const UPDATE_CART_LINES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Remove lines from cart // Remove lines from cart
export const REMOVE_CART_LINES_MUTATION = gql( export const REMOVE_CART_LINES_MUTATION = `
` ${CartFragment}
mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode) mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!) {
@inContext(country: $country, language: $language) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart { cart {
...CartFragment ...CartFragment
@@ -140,15 +128,12 @@ export const REMOVE_CART_LINES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Apply (or clear) discount codes on the cart // Apply (or clear) discount codes on the cart
export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql( export const UPDATE_CART_DISCOUNT_CODES_MUTATION = `
` ${CartFragment}
mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode) mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]) {
@inContext(country: $country, language: $language) {
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
cart { cart {
...CartFragment ...CartFragment
@@ -159,19 +144,14 @@ export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Get cart by ID // Get cart by ID
export const GET_CART_QUERY = gql( export const GET_CART_QUERY = `
` ${CartFragment}
query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode) query GetCart($cartId: ID!) {
@inContext(country: $country, language: $language) {
cart(id: $cartId) { cart(id: $cartId) {
...CartFragment ...CartFragment
} }
} }
`, `;
[CartFragment]
);
@@ -1,10 +1,8 @@
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 = gql(` export const GET_COLLECTIONS_QUERY = `
query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode) query GetCollections($first: Int!) {
@inContext(country: $country, language: $language) {
collections(first: $first) { collections(first: $first) {
edges { edges {
node { node {
@@ -28,11 +26,11 @@ export const GET_COLLECTIONS_QUERY = gql(`
} }
} }
} }
`); `;
// Get products in a collection // Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = gql( export const GET_COLLECTION_PRODUCTS_QUERY = `
` ${ProductFragment}
query GetCollectionProducts( query GetCollectionProducts(
$handle: String! $handle: String!
$first: Int! $first: Int!
@@ -40,9 +38,7 @@ export const GET_COLLECTION_PRODUCTS_QUERY = gql(
$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
@@ -86,6 +82,4 @@ export const GET_COLLECTION_PRODUCTS_QUERY = gql(
} }
} }
} }
`, `;
[ProductFragment]
);
+20 -25
View File
@@ -1,8 +1,7 @@
// 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 = gql(` const CustomerFragment = `
fragment CustomerFragment on Customer { fragment CustomerFragment on Customer {
id id
email email
@@ -25,10 +24,10 @@ const CustomerFragment = gql(`
phone phone
} }
} }
`); `;
export const CUSTOMER_QUERY = gql( export const CUSTOMER_QUERY = `
` ${CustomerFragment}
query GetCustomer($customerAccessToken: String!, $orderCount: Int!) { query GetCustomer($customerAccessToken: String!, $orderCount: Int!) {
customer(customerAccessToken: $customerAccessToken) { customer(customerAccessToken: $customerAccessToken) {
...CustomerFragment ...CustomerFragment
@@ -64,11 +63,9 @@ export const CUSTOMER_QUERY = gql(
} }
} }
} }
`, `;
[CustomerFragment]
);
export const CUSTOMER_CREATE_MUTATION = gql(` export const CUSTOMER_CREATE_MUTATION = `
mutation CustomerCreate($input: CustomerCreateInput!) { mutation CustomerCreate($input: CustomerCreateInput!) {
customerCreate(input: $input) { customerCreate(input: $input) {
customer { customer {
@@ -82,9 +79,9 @@ export const CUSTOMER_CREATE_MUTATION = gql(`
} }
} }
} }
`); `;
export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(` export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = `
mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) { mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
customerAccessTokenCreate(input: $input) { customerAccessTokenCreate(input: $input) {
customerAccessToken { customerAccessToken {
@@ -98,9 +95,9 @@ export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(`
} }
} }
} }
`); `;
export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(` export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = `
mutation CustomerAccessTokenDelete($customerAccessToken: String!) { mutation CustomerAccessTokenDelete($customerAccessToken: String!) {
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) { customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
deletedAccessToken deletedAccessToken
@@ -110,10 +107,10 @@ export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(`
} }
} }
} }
`); `;
// Sends the "reset your password" email. // Sends the "reset your password" email.
export const CUSTOMER_RECOVER_MUTATION = gql(` export const CUSTOMER_RECOVER_MUTATION = `
mutation CustomerRecover($email: String!) { mutation CustomerRecover($email: String!) {
customerRecover(email: $email) { customerRecover(email: $email) {
customerUserErrors { customerUserErrors {
@@ -123,10 +120,10 @@ export const CUSTOMER_RECOVER_MUTATION = gql(`
} }
} }
} }
`); `;
// 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 = gql(` export const CUSTOMER_RESET_MUTATION = `
mutation CustomerReset($id: ID!, $input: CustomerResetInput!) { mutation CustomerReset($id: ID!, $input: CustomerResetInput!) {
customerReset(id: $id, input: $input) { customerReset(id: $id, input: $input) {
customerAccessToken { customerAccessToken {
@@ -140,10 +137,10 @@ export const CUSTOMER_RESET_MUTATION = gql(`
} }
} }
} }
`); `;
// Activation link sent to customers created by the merchant. // Activation link sent to customers created by the merchant.
export const CUSTOMER_ACTIVATE_MUTATION = gql(` export const CUSTOMER_ACTIVATE_MUTATION = `
mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) { mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) {
customerActivate(id: $id, input: $input) { customerActivate(id: $id, input: $input) {
customerAccessToken { customerAccessToken {
@@ -157,10 +154,10 @@ export const CUSTOMER_ACTIVATE_MUTATION = gql(`
} }
} }
} }
`); `;
export const CUSTOMER_UPDATE_MUTATION = gql( export const CUSTOMER_UPDATE_MUTATION = `
` ${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 {
@@ -173,6 +170,4 @@ export const CUSTOMER_UPDATE_MUTATION = gql(
} }
} }
} }
`, `;
[CustomerFragment]
);
+2 -4
View File
@@ -1,8 +1,6 @@
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 = gql(` export const GET_SHOP_POLICIES_QUERY = `
query GetShopPolicies { query GetShopPolicies {
shop { shop {
privacyPolicy { privacyPolicy {
@@ -42,4 +40,4 @@ export const GET_SHOP_POLICIES_QUERY = gql(`
} }
} }
} }
`); `;
+14 -25
View File
@@ -1,7 +1,5 @@
import { gql } from '@shopify/hydrogen';
// Product Fragment for consistent product data // Product Fragment for consistent product data
export const ProductFragment = gql(` export const ProductFragment = `
fragment ProductFragment on Product { fragment ProductFragment on Product {
id id
title title
@@ -100,13 +98,12 @@ export const ProductFragment = gql(`
} }
} }
} }
`); `;
// Get multiple products // Get multiple products
export const GET_PRODUCTS_QUERY = gql( export const GET_PRODUCTS_QUERY = `
` ${ProductFragment}
query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode) query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
@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 {
@@ -119,32 +116,24 @@ export const GET_PRODUCTS_QUERY = gql(
} }
} }
} }
`, `;
[ProductFragment]
);
// Get a single product by handle // Get a single product by handle
export const GET_PRODUCT_QUERY = gql( export const GET_PRODUCT_QUERY = `
` ${ProductFragment}
query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode) query GetProduct($handle: String!) {
@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 = gql( export const QUERY_PRODUCT_RECOMMENDATIONS = `
` ${ProductFragment}
query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode) query GetProductRecommendations($productId: ID!) {
@inContext(country: $country, language: $language) {
productRecommendations(productId: $productId) { productRecommendations(productId: $productId) {
...ProductFragment ...ProductFragment
} }
} }
`, `;
[ProductFragment]
);
+7 -17
View File
@@ -1,11 +1,10 @@
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 = gql( export const SEARCH_PRODUCTS_QUERY = `
` ${ProductFragment}
query SearchProducts( query SearchProducts(
$query: String! $query: String!
$first: Int! $first: Int!
@@ -13,9 +12,7 @@ export const SEARCH_PRODUCTS_QUERY = gql(
$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
@@ -43,9 +40,6 @@ export const SEARCH_PRODUCTS_QUERY = gql(
} }
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
} }
@@ -53,20 +47,16 @@ export const SEARCH_PRODUCTS_QUERY = gql(
} }
} }
} }
`, `;
[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 = gql(` export const SEARCH_SUGGESTIONS_QUERY = `
query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode) query SearchSuggestions($query: String!, $first: Int!) {
@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
@@ -86,4 +76,4 @@ export const SEARCH_SUGGESTIONS_QUERY = gql(`
} }
} }
} }
`); `;
+59 -72
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; import { shopifyFetch } 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 | null; id: string;
url: string; url: string;
altText?: string | null; altText?: string;
width?: number | null; width: number;
height?: number | null; height: number;
} | 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,105 +93,92 @@ export interface Cart {
// ─── Shopify API functions ─────────────────────────────────────────── // ─── Shopify API functions ───────────────────────────────────────────
/**
* Every cart mutation returns the same `{ cart, userErrors }` payload, and both
* the payload and the cart inside it are nullable — Shopify returns no cart when
* the mutation could not be applied. Callers want a cart or an exception.
*/
function unwrapCartPayload<T>(
payload:
| {
cart?: T | null;
userErrors: ReadonlyArray<{ message: string }>;
}
| null
| undefined
): T {
if (payload?.userErrors.length) {
throw new Error(payload.userErrors[0].message);
}
if (!payload?.cart) {
throw new Error('Cart update failed. Please try again.');
}
return payload.cart;
}
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> { async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CREATE_CART_MUTATION, { query: CREATE_CART_MUTATION,
variables: { lines: lines.length > 0 ? lines : null }, variables: { lines: lines.length > 0 ? lines : null },
}), });
'CreateCart'
);
return unwrapCartPayload(data.cartCreate); if (response.data.cartCreate.userErrors.length > 0) {
throw new Error(response.data.cartCreate.userErrors[0].message);
}
return response.data.cartCreate.cart;
} }
async function addCartLinesApi( async function addCartLinesApi(
cartId: string, cartId: string,
lines: CartLineInput[] lines: CartLineInput[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(ADD_CART_LINES_MUTATION, { query: ADD_CART_LINES_MUTATION,
variables: { cartId, lines }, variables: { cartId, lines },
}), });
'AddCartLines'
);
return unwrapCartPayload(data.cartLinesAdd); if (response.data.cartLinesAdd.userErrors.length > 0) {
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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(UPDATE_CART_LINES_MUTATION, { query: UPDATE_CART_LINES_MUTATION,
variables: { cartId, lines }, variables: { cartId, lines },
}), });
'UpdateCartLines'
);
return unwrapCartPayload(data.cartLinesUpdate); if (response.data.cartLinesUpdate.userErrors.length > 0) {
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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(REMOVE_CART_LINES_MUTATION, { query: REMOVE_CART_LINES_MUTATION,
variables: { cartId, lineIds }, variables: { cartId, lineIds },
}), });
'RemoveCartLines'
);
return unwrapCartPayload(data.cartLinesRemove); if (response.data.cartLinesRemove.userErrors.length > 0) {
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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, { query: UPDATE_CART_DISCOUNT_CODES_MUTATION,
variables: { cartId, discountCodes }, variables: { cartId, discountCodes },
}), });
'UpdateCartDiscountCodes'
);
return unwrapCartPayload(data.cartDiscountCodesUpdate); if (response.data.cartDiscountCodesUpdate.userErrors.length > 0) {
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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }), query: GET_CART_QUERY,
'GetCart' variables: { cartId },
); });
return data.cart; return response.data.cart;
} }
export function redirectToCheckout(checkoutUrl: string): void { export function redirectToCheckout(checkoutUrl: string): void {
+1 -31
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
getCollections, getCollections,
getCollectionProducts, getCollectionProducts,
@@ -60,36 +60,6 @@ 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,
+29 -17
View File
@@ -1,7 +1,4 @@
import { import { SHOPIFY_STOREFRONT_API_URL } from '@/services/shopify/client';
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 {
@@ -12,6 +9,12 @@ 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',
@@ -21,23 +24,32 @@ export const POLICY_HANDLES = [
'subscription-policy', 'subscription-policy',
] as const; ] as const;
// Policies change rarely, so this reads through the cached client (revalidated // Policies change rarely, so this uses its own fetch (revalidated hourly)
// hourly) rather than the no-store one used for carts and products. // rather than the no-store `shopifyFetch` used for carts and products.
export async function getShopPolicies(): Promise<ShopPolicy[]> { export async function getShopPolicies(): Promise<ShopPolicy[]> {
try { const token = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
const data = unwrapStorefrontResult(
await cachedStorefront.graphql(GET_SHOP_POLICIES_QUERY),
'GetShopPolicies'
);
return Object.values(data.shop ?? {}).filter( const response = await fetch(SHOPIFY_STOREFRONT_API_URL, {
(policy): policy is ShopPolicy => Boolean(policy?.handle) method: 'POST',
); headers: {
} catch (err) { 'Content-Type': 'application/json',
// A storefront without policies configured shouldn't break the footer. ...(token ? { 'X-Shopify-Storefront-Access-Token': token } : {}),
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(
+14 -14
View File
@@ -4,21 +4,21 @@ export default {
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',
protocol: 'https', hostname: 'cdn.shopify.com',
hostname: '**.shopify.com', },
}, {
{ protocol: 'https',
protocol: 'https', hostname: '**.frontend.co',
hostname: '**.frontend.co', },
}, ],
],
}, },
experimental: { experimental: {
reactDebugChannel: false reactDebugChannel: false
+9 -12
View File
@@ -5,25 +5,27 @@
"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/hydrogen": "0.0.0-preview-116d5d7-20260730141607", "@shopify/storefront-api-client": "^1.0.0",
"@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",
@@ -32,23 +34,18 @@
"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",
"rehype-sanitize": "^6.0.0", "shiki": "^3.19.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",
+69 -110
View File
@@ -3,8 +3,7 @@
// 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 type { StorefrontApi } from '@shopify/hydrogen'; import { shopifyFetch } from '@/services/shopify/client';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { import {
GET_PRODUCTS_QUERY, GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY, GET_PRODUCT_QUERY,
@@ -19,11 +18,9 @@ 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 | null; altText?: string;
} }
interface ProductPrice { interface ProductPrice {
@@ -40,7 +37,7 @@ interface ProductVariant {
name: string; name: string;
value: string; value: string;
}>; }>;
image?: ProductImage | null; image?: ProductImage;
} }
export interface ProductOptionValue { export interface ProductOptionValue {
@@ -67,25 +64,6 @@ 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;
@@ -149,14 +127,12 @@ export async function getProductsPage({
sortKey = 'BEST_SELLING', sortKey = 'BEST_SELLING',
reverse = false, reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> { }: UseProductsOptions = {}): Promise<ProductsPage> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(GET_PRODUCTS_QUERY, { query: GET_PRODUCTS_QUERY,
variables: { first, after, query, sortKey, reverse }, variables: { first, after, query, sortKey, reverse },
}), });
'GetProducts'
);
const { edges, pageInfo } = data.products; const { edges, pageInfo } = response.data.products;
return { return {
products: edges.map((edge: { node: Product }) => edge.node), products: edges.map((edge: { node: Product }) => edge.node),
@@ -167,30 +143,28 @@ 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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }), query: GET_PRODUCT_QUERY,
'GetProduct' variables: { handle },
); });
return data.product; return response.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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, { query: QUERY_PRODUCT_RECOMMENDATIONS,
variables: { productId }, variables: { productId },
}), });
'GetProductRecommendations'
);
return data.productRecommendations ?? []; return response.data.productRecommendations || [];
} }
interface CollectionImage { interface CollectionImage {
url: string; url: string;
altText?: string | null; altText?: string;
} }
export interface Collection { export interface Collection {
@@ -199,7 +173,7 @@ export interface Collection {
handle: string; handle: string;
description?: string; description?: string;
descriptionHtml?: string; descriptionHtml?: string;
image?: CollectionImage | null; image?: CollectionImage;
} }
export interface CollectionWithProducts extends Collection { export interface CollectionWithProducts extends Collection {
@@ -244,12 +218,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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }), query: GET_COLLECTIONS_QUERY,
'GetCollections' variables: { first },
); });
return data.collections.edges.map((edge) => edge.node); return response.data.collections.edges.map((edge: { node: Collection }) => edge.node);
} }
// Fetch products in a collection by handle // Fetch products in a collection by handle
@@ -274,23 +248,28 @@ export async function getCollectionProductsPage(
filterInputs = [], filterInputs = [],
}: UseCollectionProductsOptions = {} }: UseCollectionProductsOptions = {}
): Promise<CollectionProductsPage> { ): Promise<CollectionProductsPage> {
const filters = parseFilterInputs(filterInputs); const filters = filterInputs.flatMap((input) => {
try {
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, { query: 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 = data.collection; const collection = response.data.collection;
if (!collection) { if (!collection) {
return { return {
collection: null, collection: null,
@@ -344,7 +323,7 @@ export interface SearchSuggestion {
handle: string; handle: string;
featuredImage?: { featuredImage?: {
url: string; url: string;
altText?: string | null; altText?: string;
} | null; } | null;
priceRange: { priceRange: {
minVariantPrice: { minVariantPrice: {
@@ -364,18 +343,10 @@ interface SearchProductsOptions {
filterInputs?: string[]; filterInputs?: string[];
} }
// The `ProductFilter` input shape, taken from the query that consumes it so it function parseFilterInputs(inputs: string[]): unknown[] {
// 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) as ProductFilterInput]; return [JSON.parse(input)];
} catch { } catch {
console.warn('Ignoring malformed product filter input:', input); console.warn('Ignoring malformed product filter input:', input);
return []; return [];
@@ -391,30 +362,24 @@ export async function searchProducts({
reverse = false, reverse = false,
filterInputs = [], filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> { }: SearchProductsOptions): Promise<SearchProductsResult> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(SEARCH_PRODUCTS_QUERY, { query: 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 = data.search; const search = response.data.search;
return { return {
products: search.edges products: search.edges.map((edge: { node: Product }) => edge.node),
.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),
@@ -426,21 +391,15 @@ export async function searchSuggestions(
query: string, query: string,
first = 3 first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> { ): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, { query: SEARCH_SUGGESTIONS_QUERY,
variables: { query, first }, variables: { query, first },
}), });
'SearchSuggestions'
);
const search = data.search; const search = response.data.search;
return { return {
products: search.edges products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node),
.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
@@ -0,0 +1,48 @@
// 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
@@ -1,105 +0,0 @@
// 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
@@ -1,16 +0,0 @@
// 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';
+37 -62
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 { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; import { shopifyFetch } from '@/services/shopify/client';
import { import {
CUSTOMER_QUERY, CUSTOMER_QUERY,
CUSTOMER_CREATE_MUTATION, CUSTOMER_CREATE_MUTATION,
@@ -18,8 +18,7 @@ import {
export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken'; export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken';
export interface CustomerUserError { export interface CustomerUserError {
/** One of `CustomerErrorCode`; kept as a string so new codes don't break. */ code?: string;
code?: string | null;
field?: string[] | null; field?: string[] | null;
message: string; message: string;
} }
@@ -58,7 +57,7 @@ export interface CustomerOrder {
export interface Customer { export interface Customer {
id: string; id: string;
email: string | null; email: string;
firstName?: string | null; firstName?: string | null;
lastName?: string | null; lastName?: string | null;
phone?: string | null; phone?: string | null;
@@ -85,13 +84,6 @@ 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;
@@ -99,32 +91,24 @@ export async function createCustomer(input: {
lastName?: string; lastName?: string;
acceptsMarketing?: boolean; acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> { }): Promise<{ errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CUSTOMER_CREATE_MUTATION, { query: CUSTOMER_CREATE_MUTATION,
variables: { input }, variables: { input },
}), });
'CustomerCreate'
);
const result = data.customerCreate; return { errors: response.data.customerCreate.customerUserErrors ?? [] };
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 data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, { query: CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
variables: { input: { email, password } }, variables: { input: { email, password } },
}), });
'CustomerAccessTokenCreate'
);
const result = data.customerAccessTokenCreate; const result = response.data.customerAccessTokenCreate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return { return {
token: result.customerAccessToken ?? null, token: result.customerAccessToken ?? null,
@@ -134,7 +118,8 @@ export async function login(
export async function logout(accessToken: string): Promise<void> { export async function logout(accessToken: string): Promise<void> {
try { try {
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, { await shopifyFetch({
query: CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
variables: { customerAccessToken: accessToken }, variables: { customerAccessToken: accessToken },
}); });
} catch (err) { } catch (err) {
@@ -147,7 +132,8 @@ 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 storefront.graphql(CUSTOMER_RECOVER_MUTATION, { await shopifyFetch({
query: CUSTOMER_RECOVER_MUTATION,
variables: { email }, variables: { email },
}); });
} catch (err) { } catch (err) {
@@ -160,15 +146,12 @@ export async function resetPassword(
resetToken: string, resetToken: string,
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CUSTOMER_RESET_MUTATION, { query: CUSTOMER_RESET_MUTATION,
variables: { id, input: { resetToken, password } }, variables: { id, input: { resetToken, password } },
}), });
'CustomerReset'
);
const result = data.customerReset; const result = response.data.customerReset;
if (!result) return { token: null, errors: MUTATION_FAILED };
return { return {
token: result.customerAccessToken ?? null, token: result.customerAccessToken ?? null,
@@ -181,15 +164,12 @@ export async function activateAccount(
activationToken: string, activationToken: string,
password: string password: string
): Promise<AuthResult> { ): Promise<AuthResult> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, { query: CUSTOMER_ACTIVATE_MUTATION,
variables: { id, input: { activationToken, password } }, variables: { id, input: { activationToken, password } },
}), });
'CustomerActivate'
);
const result = data.customerActivate; const result = response.data.customerActivate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return { return {
token: result.customerAccessToken ?? null, token: result.customerAccessToken ?? null,
@@ -202,14 +182,12 @@ export async function getCustomer(
orderCount = 10 orderCount = 10
): Promise<Customer | null> { ): Promise<Customer | null> {
try { try {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CUSTOMER_QUERY, { query: CUSTOMER_QUERY,
variables: { customerAccessToken: accessToken, orderCount }, variables: { customerAccessToken: accessToken, orderCount },
}), });
'GetCustomer'
);
return data.customer ?? null; return response.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);
@@ -226,15 +204,12 @@ export async function updateCustomer(
phone?: string; phone?: string;
} }
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> { ): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, { query: CUSTOMER_UPDATE_MUTATION,
variables: { customerAccessToken: accessToken, customer }, variables: { customerAccessToken: accessToken, customer },
}), });
'CustomerUpdate'
);
const result = data.customerUpdate; const result = response.data.customerUpdate;
if (!result) return { customer: null, errors: MUTATION_FAILED };
return { return {
customer: result.customer ?? null, customer: result.customer ?? null,
-3
View File
@@ -20,9 +20,6 @@
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
},
{
"name": "@shopify/hydrogen/ts-plugin"
} }
], ],
"paths": { "paths": {
+1759 -120
View File
File diff suppressed because it is too large Load Diff