Compare commits

..
21 Commits
Author SHA1 Message Date
Rami BitarandClaude Opus 5 95b191eeb8 Add a Shop menu that lists the store's collections
The header now carries a "Shop" dropdown that fetches the collection list
the first time it's opened and links each entry to its collection page.

Fetching is deferred so the request only happens on open: useCollections
loads on mount, which would put a collections query on every page. The new
useCollectionsOnDemand holds off until load() runs, keeps the result for
later opens, and clears its guard on failure so a retry can go through.

Rendered in the desktop nav as a floating panel and in the mobile menu as
an inline section, following the hand-rolled pattern in account-menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1vsTA4MX6qFd7nqSAKtdV
2026-08-05 15:03:53 -04:00
Rami BitarandClaude Opus 5 ff4adba2fb Render the header and footer from the root layout
Every page was importing Header and Footer and repeating the same store
name, logo, copyright and nav links. Hoisting both into the root layout
renders them once, and a new config/site.ts holds the values they shared.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013amafJZMQmXgeFEotYWfaw
2026-08-04 17:36:26 -04:00
Rami BitarandClaude Opus 5 ebdb0bee2f Extract a shared Logo component for the header and footer
The header inlined its own logo/wordmark fallback markup. Pull it into
components/logo.tsx so the footer can show the same mark, and drop the
now-unneeded 'use client' from the root layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUttSNLkLPvvR6Xf75QA1K
2026-08-04 14:58:20 -04:00
Rami BitarandClaude Opus 5 b139b30273 Hide the synthetic Default Title option on single-SKU products
Shopify gives every single-variant product one option named Title with the
lone value Default Title. It isn't a real choice, so filter it out of the
product detail options, the cart line summaries, and the payload the store
assistant sees. Variant matching still reads the raw selectedOptions, so
single-SKU products keep resolving their variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuRVxuyMUTzWKXW8P5Tn8A
2026-08-04 13:20:47 -04:00
Rami BitarandClaude Opus 5 c88613de03 Use a light gray announcement bar instead of black
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHUa6ik1KRSEFkF6ShUYpC
2026-08-03 13:20:39 -04:00
Rami BitarandClaude Opus 5 64af00e702 Replace Streamdown with a plugin-free markdown renderer
The assistant only ever renders prose, links and the odd list, but
Streamdown's plugin set dragged in shiki, mermaid and katex — and
streamdown core declares mermaid as a hard dependency, so dropping the
plugins alone would have left it installed. Rendering now runs on the
same unified pipeline Streamdown used internally, minus the plugin
surface: remark-parse -> remark-gfm -> remark-rehype -> rehype-sanitize
-> hast-util-to-jsx-runtime. GFM, streaming repair and the link-safety
hook all survive; fenced code renders as plain <pre>. Net -264/+101
packages.

remend now runs only while a part is still streaming. On settled text it
reads `*$89*` as an unclosed italic — the `$` throws off its
closing-delimiter scan — and appends a stray `*` that parses as an empty
bullet. Italic prices are ordinary storefront copy, so that would have
shown up in production.

Route-relative links go through next/link in the same tab; sending a
shopper to a product page in a new tab would strand the conversation
behind it. Only external destinations get the confirmation dialog.

code-block.tsx and tool.tsx were the sole users of shiki and had no
importers, so both are gone.

Also folded in two unrelated changes that were already in the tree: the
loader move out of app/components/ui, and dropping the fade on the
command palette so it opens instantly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuySwKLSF4uEPpZjHkdHBP
2026-08-02 09:43:55 -04:00
Rami BitarandClaude Opus 5 526982691c Remove the unused @supabase/supabase-js dependency
Nothing in the app imported it; a leftover from the original template.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-02 00:32:08 -04:00
Rami BitarandClaude Opus 5 1592b265a1 Add @radix-ui/react-slot and drop the packageManager pin
Note: removing packageManager means Corepack no longer pins Yarn 4 for
this repo, so `yarn` resolves to whatever is installed globally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-02 00:29:28 -04:00
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
46 changed files with 384 additions and 861 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
# Shopify Storefront
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
# NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07
# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07
# Store assistant — set to 1 to show the Ask launcher; anything else hides it
NEXT_PUBLIC_ENABLE_AI=0
-3
View File
@@ -5,6 +5,3 @@ next-env.d.ts
.yarn/install-state.gz
.env*.local
tsconfig.tsbuildinfo
# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit
.hydrogen-gql-*.json
+1 -1
View File
@@ -1,6 +1,6 @@
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}.
export default async function Page({
+1 -1
View File
@@ -1,6 +1,6 @@
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() {
return (
+1 -1
View File
@@ -3,7 +3,7 @@ import OrderHistory from '@/components/shopify/order-history';
import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer';
export const metadata = { title: 'Order history' };
export const metadata = { title: 'Order history — Shop' };
export default async function Page() {
const token = await getSessionToken();
+1 -1
View File
@@ -1,6 +1,6 @@
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() {
return (
+1 -1
View File
@@ -1,6 +1,6 @@
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() {
return (
+1 -1
View File
@@ -1,6 +1,6 @@
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}.
export default async function Page({
-48
View File
@@ -1,52 +1,4 @@
import type { Metadata } from 'next';
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() {
return <CollectionDetail />;
-6
View File
@@ -1,11 +1,5 @@
import type { Metadata } from 'next';
import Collections from '@/components/shopify/collections';
export const metadata: Metadata = {
title: 'Collections',
description: 'Browse every collection in the store.',
};
export default function Page() {
return <Collections title="Our Collections" />;
}
-24
View File
@@ -1,5 +1,4 @@
import React from 'react';
import type { Metadata } from 'next';
import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google';
import Header from '@/components/shopify/header';
@@ -17,29 +16,6 @@ const geistMono = 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({
children,
}: {
-9
View File
@@ -1,13 +1,4 @@
import type { Metadata } from 'next';
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() {
return (
+1 -6
View File
@@ -1,6 +1,5 @@
import { notFound } from 'next/navigation';
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
import { site } from '@/config/site';
export function generateStaticParams() {
return POLICY_HANDLES.map((handle) => ({ handle }));
@@ -14,12 +13,8 @@ export async function generateMetadata({
const { handle } = await params;
const policy = await getShopPolicy(handle);
if (!policy) return { title: 'Policy' };
return {
title: policy.title,
description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`,
alternates: { canonical: `/policies/${policy.handle}` },
title: policy ? `${policy.title} — Shop` : 'Policy — Shop',
};
}
-42
View File
@@ -1,47 +1,5 @@
import type { Metadata } from 'next';
import ProductDetail from '@/components/shopify/product-detail';
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() {
return (
+1 -2
View File
@@ -2,8 +2,7 @@ import { Suspense } from 'react';
import SearchResults from '@/components/shopify/search-results';
export const metadata = {
title: 'Search',
description: 'Search the catalogue by product name, description or type.',
title: 'Search — Shop',
};
export default function Page() {
-8
View File
@@ -1,13 +1,5 @@
import type { Metadata } from 'next';
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() {
return <Collections title="Our Collections" />;
}
+1 -4
View File
@@ -1,7 +1,6 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
@@ -172,11 +171,9 @@ const CartDrawer: React.FC = () => {
{/* Product Image */}
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? (
<Image
<img
src={image}
alt={item.merchandise.product.title}
width={64}
height={64}
className="w-full h-full object-cover"
/>
) : (
+4 -7
View File
@@ -1,10 +1,9 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
interface CollectionImage {
url: string;
altText?: string | null;
altText?: string;
}
interface Collection {
@@ -12,7 +11,7 @@ interface Collection {
title: string;
handle: string;
description?: string;
image?: CollectionImage | null;
image?: CollectionImage;
}
interface CollectionCardProps {
@@ -28,12 +27,10 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
{/* Collection Image */}
<div className="relative aspect-square overflow-hidden">
{collection.image ? (
<Image
<img
src={collection.image.url}
alt={collection.image.altText || collection.title}
fill
sizes="(min-width: 1024px) 25vw, 50vw"
className="object-cover transition-transform duration-500 group-hover:scale-[1.04]"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
+1 -1
View File
@@ -50,7 +50,7 @@ interface HeaderProps {
}
const Header: React.FC<HeaderProps> = ({
storeName = 'Logo',
storeName = 'Shop',
logoUrl,
links = [
{ label: 'Shop', url: '/' },
+1 -4
View File
@@ -1,7 +1,6 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { RiImageLine } from '@remixicon/react';
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">
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
{node.variant?.image ? (
<Image
<img
src={node.variant.image.url}
alt={node.variant.image.altText || node.title}
width={64}
height={64}
className="h-full w-full object-cover"
/>
) : (
+3 -6
View File
@@ -1,11 +1,10 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { truncate } from '@/lib/utils';
interface ProductImage {
url: string;
altText?: string | null;
altText?: string;
}
interface ProductPrice {
@@ -69,12 +68,10 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
{/* Product Image */}
<div className="relative aspect-square overflow-hidden">
{firstImage ? (
<Image
<img
src={firstImage.url}
alt={firstImage.altText || product.title}
fill
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
className="object-contain transition-transform duration-500 group-hover:scale-[1.04]"
className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
+2 -2
View File
@@ -29,8 +29,8 @@ interface ProductVariant {
}>;
image?: {
url: string;
altText?: string | null;
} | null;
altText?: string;
};
}
export type { Product };
@@ -1,15 +1,12 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface ProductImage {
url: string;
altText?: string | null;
width?: number | null;
height?: number | null;
altText?: string;
}
interface ProductDetailGalleryProps {
@@ -139,14 +136,11 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
isSingle ? 'sm:col-span-2' : ''
}`}
>
<Image
<img
src={image.url}
alt={image.altText || 'Product image'}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
priority={index === 0}
draggable={false}
className="object-cover select-none"
className="w-full h-full object-cover select-none"
/>
</button>
))}
@@ -179,19 +173,11 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
onClick={close}
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}
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()}
// w/h-auto keeps the box at the image's own ratio; without it the
// width+height attributes make both axes definite and the element
// stretches to the overlay, swallowing backdrop clicks that close it.
className="max-h-full max-w-full w-auto h-auto object-contain"
className="max-h-full max-w-full object-contain"
/>
<Button
@@ -172,22 +172,12 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
: 'ring-1 ring-border hover:ring-foreground/40'
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
style={{
// With no colour and no image the circle would be fully
// transparent, leaving just a hairline ring that
// antialiases unevenly and reads as a speckled border.
// A neutral fill makes the initial-letter fallback look
// deliberate. Set inline so it beats the ghost variant's
// hover background.
backgroundColor:
background ??
(image ? undefined : 'var(--color-muted)'),
backgroundColor: background,
backgroundImage: image ? `url(${image})` : undefined,
}}
>
{!background && !image && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.name.at(0)}
</span>
<span className="text-[10px]">{value.name.at(0)}</span>
)}
</Button>
);
+2 -7
View File
@@ -208,15 +208,10 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
style={{
// A colourless swatch would otherwise be a fully
// transparent circle behind a hairline ring,
// which antialiases into a speckled border.
backgroundColor: color ?? 'var(--color-muted)',
}}
style={{ backgroundColor: color ?? undefined }}
>
{!color && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
<span className="text-[10px]">
{value.label.at(0)}
</span>
)}
+1 -1
View File
@@ -8,7 +8,7 @@ import { Loader } from '@/components/ui/loader';
interface ProductImage {
url: string;
altText?: string | null;
altText?: string;
}
interface ProductPrice {
+1 -4
View File
@@ -1,7 +1,6 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import {
CommandDialog,
@@ -163,11 +162,9 @@ const SearchDialog: React.FC = () => {
>
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
{product.featuredImage ? (
<Image
<img
src={product.featuredImage.url}
alt={product.featuredImage.altText || product.title}
width={56}
height={56}
className="h-full w-full object-cover"
/>
) : (
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
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 { Loader } from '@/components/ui/loader';
import { Button } from '@/components/ui/button';
-9
View File
@@ -3,15 +3,6 @@ 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: [
+26 -46
View File
@@ -1,7 +1,5 @@
import { gql } from '@shopify/hydrogen';
// Cart Fragment for consistent cart data
const CartFragment = gql(`
const CartFragment = `
fragment CartFragment on Cart {
id
checkoutUrl
@@ -66,13 +64,12 @@ const CartFragment = gql(`
}
}
}
`);
`;
// Create a new cart
export const CREATE_CART_MUTATION = gql(
`
mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const CREATE_CART_MUTATION = `
${CartFragment}
mutation CreateCart($lines: [CartLineInput!]) {
cartCreate(input: { lines: $lines }) {
cart {
...CartFragment
@@ -83,15 +80,12 @@ export const CREATE_CART_MUTATION = gql(
}
}
}
`,
[CartFragment]
);
`;
// Add lines to cart
export const ADD_CART_LINES_MUTATION = gql(
`
mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const ADD_CART_LINES_MUTATION = `
${CartFragment}
mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...CartFragment
@@ -102,15 +96,12 @@ export const ADD_CART_LINES_MUTATION = gql(
}
}
}
`,
[CartFragment]
);
`;
// Update cart lines
export const UPDATE_CART_LINES_MUTATION = gql(
`
mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const UPDATE_CART_LINES_MUTATION = `
${CartFragment}
mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...CartFragment
@@ -121,15 +112,12 @@ export const UPDATE_CART_LINES_MUTATION = gql(
}
}
}
`,
[CartFragment]
);
`;
// Remove lines from cart
export const REMOVE_CART_LINES_MUTATION = gql(
`
mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const REMOVE_CART_LINES_MUTATION = `
${CartFragment}
mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...CartFragment
@@ -140,15 +128,12 @@ export const REMOVE_CART_LINES_MUTATION = gql(
}
}
}
`,
[CartFragment]
);
`;
// Apply (or clear) discount codes on the cart
export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql(
`
mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const UPDATE_CART_DISCOUNT_CODES_MUTATION = `
${CartFragment}
mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]) {
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
cart {
...CartFragment
@@ -159,19 +144,14 @@ export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql(
}
}
}
`,
[CartFragment]
);
`;
// Get cart by ID
export const GET_CART_QUERY = gql(
`
query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const GET_CART_QUERY = `
${CartFragment}
query GetCart($cartId: ID!) {
cart(id: $cartId) {
...CartFragment
}
}
`,
[CartFragment]
);
`;
@@ -1,10 +1,8 @@
import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products';
// Get all collections
export const GET_COLLECTIONS_QUERY = gql(`
query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const GET_COLLECTIONS_QUERY = `
query GetCollections($first: Int!) {
collections(first: $first) {
edges {
node {
@@ -28,11 +26,11 @@ export const GET_COLLECTIONS_QUERY = gql(`
}
}
}
`);
`;
// Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = gql(
`
export const GET_COLLECTION_PRODUCTS_QUERY = `
${ProductFragment}
query GetCollectionProducts(
$handle: String!
$first: Int!
@@ -40,9 +38,7 @@ export const GET_COLLECTION_PRODUCTS_QUERY = gql(
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
$filters: [ProductFilter!]
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
) {
collection(handle: $handle) {
id
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).
// https://shopify.dev/docs/api/storefront/latest/objects/Customer
import { gql } from '@shopify/hydrogen';
const CustomerFragment = gql(`
const CustomerFragment = `
fragment CustomerFragment on Customer {
id
email
@@ -25,10 +24,10 @@ const CustomerFragment = gql(`
phone
}
}
`);
`;
export const CUSTOMER_QUERY = gql(
`
export const CUSTOMER_QUERY = `
${CustomerFragment}
query GetCustomer($customerAccessToken: String!, $orderCount: Int!) {
customer(customerAccessToken: $customerAccessToken) {
...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!) {
customerCreate(input: $input) {
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!) {
customerAccessTokenCreate(input: $input) {
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!) {
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
deletedAccessToken
@@ -110,10 +107,10 @@ export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(`
}
}
}
`);
`;
// Sends the "reset your password" email.
export const CUSTOMER_RECOVER_MUTATION = gql(`
export const CUSTOMER_RECOVER_MUTATION = `
mutation CustomerRecover($email: String!) {
customerRecover(email: $email) {
customerUserErrors {
@@ -123,10 +120,10 @@ export const CUSTOMER_RECOVER_MUTATION = gql(`
}
}
}
`);
`;
// 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!) {
customerReset(id: $id, input: $input) {
customerAccessToken {
@@ -140,10 +137,10 @@ export const CUSTOMER_RESET_MUTATION = gql(`
}
}
}
`);
`;
// 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!) {
customerActivate(id: $id, input: $input) {
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!) {
customerUpdate(customerAccessToken: $customerAccessToken, 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.
// 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 {
shop {
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
export const ProductFragment = gql(`
export const ProductFragment = `
fragment ProductFragment on Product {
id
title
@@ -100,13 +98,12 @@ export const ProductFragment = gql(`
}
}
}
`);
`;
// Get multiple products
export const GET_PRODUCTS_QUERY = gql(
`
query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const GET_PRODUCTS_QUERY = `
${ProductFragment}
query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
edges {
node {
@@ -119,32 +116,24 @@ export const GET_PRODUCTS_QUERY = gql(
}
}
}
`,
[ProductFragment]
);
`;
// Get a single product by handle
export const GET_PRODUCT_QUERY = gql(
`
query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const GET_PRODUCT_QUERY = `
${ProductFragment}
query GetProduct($handle: String!) {
product(handle: $handle) {
...ProductFragment
}
}
`,
[ProductFragment]
);
`;
// Get product recommendations
export const QUERY_PRODUCT_RECOMMENDATIONS = gql(
`
query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const QUERY_PRODUCT_RECOMMENDATIONS = `
${ProductFragment}
query GetProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...ProductFragment
}
}
`,
[ProductFragment]
);
`;
+7 -17
View File
@@ -1,11 +1,10 @@
import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products';
// Storefront search over products. `productFilters` accepts the raw `input`
// values returned in `productFilters[].values[].input`, so facets round-trip
// 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: String!
$first: Int!
@@ -13,9 +12,7 @@ export const SEARCH_PRODUCTS_QUERY = gql(
$sortKey: SearchSortKeys
$reverse: Boolean
$productFilters: [ProductFilter!]
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
) {
search(
query: $query
first: $first
@@ -43,9 +40,6 @@ export const SEARCH_PRODUCTS_QUERY = gql(
}
edges {
node {
# types: PRODUCT already narrows the results, but the schema still
# types nodes as a union __typename lets callers narrow too.
__typename
... on Product {
...ProductFragment
}
@@ -53,20 +47,16 @@ export const SEARCH_PRODUCTS_QUERY = gql(
}
}
}
`,
[ProductFragment]
);
`;
// Lightweight variant for the autocomplete dropdown — just enough to render a
// row, so the dialog stays responsive while typing.
export const SEARCH_SUGGESTIONS_QUERY = gql(`
query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
export const SEARCH_SUGGESTIONS_QUERY = `
query SearchSuggestions($query: String!, $first: Int!) {
search(query: $query, first: $first, types: PRODUCT) {
totalCount
edges {
node {
__typename
... on Product {
id
title
@@ -86,4 +76,4 @@ export const SEARCH_SUGGESTIONS_QUERY = gql(`
}
}
}
`);
`;
+59 -72
View File
@@ -1,7 +1,7 @@
'use client';
import { create } from 'zustand';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { shopifyFetch } from '@/services/shopify/client';
import {
CREATE_CART_MUTATION,
ADD_CART_LINES_MUTATION,
@@ -45,12 +45,12 @@ interface CartLine {
currencyCode: string;
};
image?: {
id?: string | null;
id: string;
url: string;
altText?: string | null;
width?: number | null;
height?: number | null;
} | null;
altText?: string;
width: number;
height: number;
};
product: {
id: string;
title: string;
@@ -82,7 +82,7 @@ export interface Cart {
totalTaxAmount?: {
amount: string;
currencyCode: string;
} | null;
};
};
lines: {
edges: Array<{
@@ -93,105 +93,92 @@ export interface Cart {
// ─── 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> {
const data = unwrapStorefrontResult(
await storefront.graphql(CREATE_CART_MUTATION, {
variables: { lines: lines.length > 0 ? lines : null },
}),
'CreateCart'
);
const response = await shopifyFetch({
query: CREATE_CART_MUTATION,
variables: { lines: lines.length > 0 ? lines : null },
});
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(
cartId: string,
lines: CartLineInput[]
): Promise<Cart> {
const data = unwrapStorefrontResult(
await storefront.graphql(ADD_CART_LINES_MUTATION, {
variables: { cartId, lines },
}),
'AddCartLines'
);
const response = await shopifyFetch({
query: ADD_CART_LINES_MUTATION,
variables: { cartId, lines },
});
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(
cartId: string,
lines: CartLineUpdateInput[]
): Promise<Cart> {
const data = unwrapStorefrontResult(
await storefront.graphql(UPDATE_CART_LINES_MUTATION, {
variables: { cartId, lines },
}),
'UpdateCartLines'
);
const response = await shopifyFetch({
query: UPDATE_CART_LINES_MUTATION,
variables: { cartId, lines },
});
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(
cartId: string,
lineIds: string[]
): Promise<Cart> {
const data = unwrapStorefrontResult(
await storefront.graphql(REMOVE_CART_LINES_MUTATION, {
variables: { cartId, lineIds },
}),
'RemoveCartLines'
);
const response = await shopifyFetch({
query: REMOVE_CART_LINES_MUTATION,
variables: { cartId, lineIds },
});
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(
cartId: string,
discountCodes: string[]
): Promise<Cart> {
const data = unwrapStorefrontResult(
await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, {
variables: { cartId, discountCodes },
}),
'UpdateCartDiscountCodes'
);
const response = await shopifyFetch({
query: UPDATE_CART_DISCOUNT_CODES_MUTATION,
variables: { cartId, discountCodes },
});
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> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }),
'GetCart'
);
const response = await shopifyFetch({
query: GET_CART_QUERY,
variables: { cartId },
});
return data.cart;
return response.data.cart;
}
export function redirectToCheckout(checkoutUrl: string): void {
+29 -17
View File
@@ -1,7 +1,4 @@
import {
cachedStorefront,
unwrapStorefrontResult,
} from '@/services/shopify/client';
import { SHOPIFY_STOREFRONT_API_URL } from '@/services/shopify/client';
import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies';
export interface ShopPolicy {
@@ -12,6 +9,12 @@ export interface ShopPolicy {
url: string;
}
interface ShopPoliciesResponse {
data?: {
shop?: Record<string, ShopPolicy | null>;
};
}
// Handles Shopify uses for each policy — also the routes under /policies/[handle].
export const POLICY_HANDLES = [
'terms-of-service',
@@ -21,23 +24,32 @@ export const POLICY_HANDLES = [
'subscription-policy',
] as const;
// Policies change rarely, so this reads through the cached client (revalidated
// hourly) rather than the no-store one used for carts and products.
// Policies change rarely, so this uses its own fetch (revalidated hourly)
// rather than the no-store `shopifyFetch` used for carts and products.
export async function getShopPolicies(): Promise<ShopPolicy[]> {
try {
const data = unwrapStorefrontResult(
await cachedStorefront.graphql(GET_SHOP_POLICIES_QUERY),
'GetShopPolicies'
);
const token = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
return Object.values(data.shop ?? {}).filter(
(policy): policy is ShopPolicy => Boolean(policy?.handle)
);
} catch (err) {
// A storefront without policies configured shouldn't break the footer.
console.error('Failed to load shop policies:', err);
const response = await fetch(SHOPIFY_STOREFRONT_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'X-Shopify-Storefront-Access-Token': token } : {}),
},
body: JSON.stringify({ query: GET_SHOP_POLICIES_QUERY }),
next: { revalidate: 3600 },
});
if (!response.ok) {
console.error('Failed to load shop policies:', response.status);
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(
+16 -16
View File
@@ -3,23 +3,23 @@ export default {
typescript: {
ignoreBuildErrors: true,
},
images: {
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
// Covers cdn.shopify.com plus any other Shopify-hosted image subdomain.
{
protocol: 'https',
hostname: '**.shopify.com',
},
{
protocol: 'https',
hostname: '**.frontend.co',
},
],
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: 'cdn.shopify.com',
},
{
protocol: 'https',
hostname: '**.frontend.co',
},
],
},
experimental: {
reactDebugChannel: false
},
+3 -5
View File
@@ -5,8 +5,7 @@
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit && hydrogen gql check"
"start": "next start"
},
"version": "0.0.0",
"dependencies": {
@@ -16,7 +15,7 @@
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@remixicon/react": "^4.9.0",
"@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607",
"@shopify/storefront-api-client": "^1.0.0",
"ai": "^7",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
@@ -53,6 +52,5 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5.7.3"
},
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e"
}
}
+69 -91
View File
@@ -3,8 +3,7 @@
// 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
// hooks in hooks/use-shopify-*.ts, which re-export them.
import type { StorefrontApi } from '@shopify/hydrogen';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { shopifyFetch } from '@/services/shopify/client';
import {
GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY,
@@ -19,11 +18,9 @@ import {
SEARCH_SUGGESTIONS_QUERY,
} 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 {
url: string;
altText?: string | null;
altText?: string;
}
interface ProductPrice {
@@ -40,7 +37,7 @@ interface ProductVariant {
name: string;
value: string;
}>;
image?: ProductImage | null;
image?: ProductImage;
}
export interface ProductOptionValue {
@@ -149,14 +146,12 @@ export async function getProductsPage({
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCTS_QUERY, {
variables: { first, after, query, sortKey, reverse },
}),
'GetProducts'
);
const response = await shopifyFetch({
query: GET_PRODUCTS_QUERY,
variables: { first, after, query, sortKey, reverse },
});
const { edges, pageInfo } = data.products;
const { edges, pageInfo } = response.data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
@@ -167,30 +162,28 @@ export async function getProductsPage({
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }),
'GetProduct'
);
const response = await shopifyFetch({
query: GET_PRODUCT_QUERY,
variables: { handle },
});
return data.product;
return response.data.product;
}
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, {
variables: { productId },
}),
'GetProductRecommendations'
);
const response = await shopifyFetch({
query: QUERY_PRODUCT_RECOMMENDATIONS,
variables: { productId },
});
return data.productRecommendations ?? [];
return response.data.productRecommendations || [];
}
interface CollectionImage {
url: string;
altText?: string | null;
altText?: string;
}
export interface Collection {
@@ -199,7 +192,7 @@ export interface Collection {
handle: string;
description?: string;
descriptionHtml?: string;
image?: CollectionImage | null;
image?: CollectionImage;
}
export interface CollectionWithProducts extends Collection {
@@ -244,12 +237,12 @@ export interface ProductFilterFacet {
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
'GetCollections'
);
const response = await shopifyFetch({
query: GET_COLLECTIONS_QUERY,
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
@@ -274,23 +267,28 @@ export async function getCollectionProductsPage(
filterInputs = [],
}: UseCollectionProductsOptions = {}
): 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(
await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, {
variables: {
handle,
first,
after,
sortKey,
reverse,
filters: filters.length ? filters : null,
},
}),
'GetCollectionProducts'
);
const response = await shopifyFetch({
query: GET_COLLECTION_PRODUCTS_QUERY,
variables: {
handle,
first,
after,
sortKey,
reverse,
filters: filters.length ? filters : null,
},
});
const collection = data.collection;
const collection = response.data.collection;
if (!collection) {
return {
collection: null,
@@ -344,7 +342,7 @@ export interface SearchSuggestion {
handle: string;
featuredImage?: {
url: string;
altText?: string | null;
altText?: string;
} | null;
priceRange: {
minVariantPrice: {
@@ -364,18 +362,10 @@ interface SearchProductsOptions {
filterInputs?: string[];
}
// The `ProductFilter` input shape, taken from the query that consumes it so it
// tracks the schema rather than being restated here.
type ProductFilterInput = NonNullable<
StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters']
>[number];
// Facet `input` values are opaque JSON strings produced by Shopify and handed
// straight back as filter inputs, so they are parsed, not constructed.
function parseFilterInputs(inputs: string[]): ProductFilterInput[] {
function parseFilterInputs(inputs: string[]): unknown[] {
return inputs.flatMap((input) => {
try {
return [JSON.parse(input) as ProductFilterInput];
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
@@ -391,30 +381,24 @@ export async function searchProducts({
reverse = false,
filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_PRODUCTS_QUERY, {
variables: {
query,
first,
after,
sortKey,
reverse,
productFilters: filterInputs.length
? parseFilterInputs(filterInputs)
: null,
},
}),
'SearchProducts'
);
const response = await shopifyFetch({
query: SEARCH_PRODUCTS_QUERY,
variables: {
query,
first,
after,
sortKey,
reverse,
productFilters: filterInputs.length
? parseFilterInputs(filterInputs)
: null,
},
});
const search = data.search;
const search = response.data.search;
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
products: search.edges.map((edge: { node: Product }) => edge.node),
totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage),
@@ -426,21 +410,15 @@ export async function searchSuggestions(
query: string,
first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, {
variables: { query, first },
}),
'SearchSuggestions'
);
const response = await shopifyFetch({
query: SEARCH_SUGGESTIONS_QUERY,
variables: { query, first },
});
const search = data.search;
const search = response.data.search;
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node),
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
// in the /api/account route handlers, and is stored in an httpOnly cookie so
// client JavaScript can never read it.
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { shopifyFetch } from '@/services/shopify/client';
import {
CUSTOMER_QUERY,
CUSTOMER_CREATE_MUTATION,
@@ -18,8 +18,7 @@ import {
export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken';
export interface CustomerUserError {
/** One of `CustomerErrorCode`; kept as a string so new codes don't break. */
code?: string | null;
code?: string;
field?: string[] | null;
message: string;
}
@@ -58,7 +57,7 @@ export interface CustomerOrder {
export interface Customer {
id: string;
email: string | null;
email: string;
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
@@ -85,13 +84,6 @@ const firstMessage = (errors: CustomerUserError[]) =>
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: {
email: string;
password: string;
@@ -99,32 +91,24 @@ export async function createCustomer(input: {
lastName?: string;
acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_CREATE_MUTATION, {
variables: { input },
}),
'CustomerCreate'
);
const response = await shopifyFetch({
query: CUSTOMER_CREATE_MUTATION,
variables: { input },
});
const result = data.customerCreate;
if (!result) return { errors: MUTATION_FAILED };
return { errors: result.customerUserErrors ?? [] };
return { errors: response.data.customerCreate.customerUserErrors ?? [] };
}
export async function login(
email: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, {
variables: { input: { email, password } },
}),
'CustomerAccessTokenCreate'
);
const response = await shopifyFetch({
query: CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
variables: { input: { email, password } },
});
const result = data.customerAccessTokenCreate;
if (!result) return { token: null, errors: MUTATION_FAILED };
const result = response.data.customerAccessTokenCreate;
return {
token: result.customerAccessToken ?? null,
@@ -134,7 +118,8 @@ export async function login(
export async function logout(accessToken: string): Promise<void> {
try {
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, {
await shopifyFetch({
query: CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
variables: { customerAccessToken: accessToken },
});
} catch (err) {
@@ -147,7 +132,8 @@ export async function logout(accessToken: string): Promise<void> {
// would leak account membership.
export async function recoverPassword(email: string): Promise<void> {
try {
await storefront.graphql(CUSTOMER_RECOVER_MUTATION, {
await shopifyFetch({
query: CUSTOMER_RECOVER_MUTATION,
variables: { email },
});
} catch (err) {
@@ -160,15 +146,12 @@ export async function resetPassword(
resetToken: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_RESET_MUTATION, {
variables: { id, input: { resetToken, password } },
}),
'CustomerReset'
);
const response = await shopifyFetch({
query: CUSTOMER_RESET_MUTATION,
variables: { id, input: { resetToken, password } },
});
const result = data.customerReset;
if (!result) return { token: null, errors: MUTATION_FAILED };
const result = response.data.customerReset;
return {
token: result.customerAccessToken ?? null,
@@ -181,15 +164,12 @@ export async function activateAccount(
activationToken: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, {
variables: { id, input: { activationToken, password } },
}),
'CustomerActivate'
);
const response = await shopifyFetch({
query: CUSTOMER_ACTIVATE_MUTATION,
variables: { id, input: { activationToken, password } },
});
const result = data.customerActivate;
if (!result) return { token: null, errors: MUTATION_FAILED };
const result = response.data.customerActivate;
return {
token: result.customerAccessToken ?? null,
@@ -202,14 +182,12 @@ export async function getCustomer(
orderCount = 10
): Promise<Customer | null> {
try {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_QUERY, {
variables: { customerAccessToken: accessToken, orderCount },
}),
'GetCustomer'
);
const response = await shopifyFetch({
query: CUSTOMER_QUERY,
variables: { customerAccessToken: accessToken, orderCount },
});
return data.customer ?? null;
return response.data.customer ?? null;
} catch (err) {
// An expired or revoked token reads as "not signed in".
console.error('Failed to load customer:', err);
@@ -226,15 +204,12 @@ export async function updateCustomer(
phone?: string;
}
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, {
variables: { customerAccessToken: accessToken, customer },
}),
'CustomerUpdate'
);
const response = await shopifyFetch({
query: CUSTOMER_UPDATE_MUTATION,
variables: { customerAccessToken: accessToken, customer },
});
const result = data.customerUpdate;
if (!result) return { customer: null, errors: MUTATION_FAILED };
const result = response.data.customerUpdate;
return {
customer: result.customer ?? null,
-3
View File
@@ -20,9 +20,6 @@
"plugins": [
{
"name": "next"
},
{
"name": "@shopify/hydrogen/ts-plugin"
}
],
"paths": {
+13 -111
View File
@@ -5,31 +5,6 @@ __metadata:
version: 10
cacheKey: 10c0
"@0no-co/graphql.web@npm:^1.0.5, @0no-co/graphql.web@npm:^1.3.1":
version: 1.3.3
resolution: "@0no-co/graphql.web@npm:1.3.3"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0
peerDependenciesMeta:
graphql:
optional: true
checksum: 10c0/7237b805118049e9023e5fa4fd71c9334e1f85df6f98933038cc1b919f388e814fd1beae9e41b6a7509643f6a208ece0422dbc5d8474d24bec957020c899fb93
languageName: node
linkType: hard
"@0no-co/graphqlsp@npm:^1.12.13":
version: 1.17.3
resolution: "@0no-co/graphqlsp@npm:1.17.3"
dependencies:
"@gql.tada/internal": "npm:^1.2.0"
graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0"
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0
checksum: 10c0/fa0f2d74275827e1c8aee36e07aaa0d86138b9f450e3e06e51be05cd507a95c1f7e7f603eaf611f6defdb2634f19acd0c2be16c222eff4802c67916ca77d4d7d
languageName: node
linkType: hard
"@ai-sdk/gateway@npm:4.0.33":
version: 4.0.33
resolution: "@ai-sdk/gateway@npm:4.0.33"
@@ -169,52 +144,6 @@ __metadata:
languageName: node
linkType: hard
"@gql.tada/cli-utils@npm:1.7.3":
version: 1.7.3
resolution: "@gql.tada/cli-utils@npm:1.7.3"
dependencies:
"@0no-co/graphqlsp": "npm:^1.12.13"
"@gql.tada/internal": "npm:1.0.9"
graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0"
peerDependencies:
"@0no-co/graphqlsp": ^1.12.13
"@gql.tada/svelte-support": 1.0.2
"@gql.tada/vue-support": 1.0.2
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0
peerDependenciesMeta:
"@gql.tada/svelte-support":
optional: true
"@gql.tada/vue-support":
optional: true
checksum: 10c0/55405687375cbda9a9e963fc627939a55c64e81816c8d6433add3564f2fc2c0600ec60c98e01de77fa21511713daf9e84b644362d9ce571f0ea5c43851c68b6f
languageName: node
linkType: hard
"@gql.tada/internal@npm:1.0.9":
version: 1.0.9
resolution: "@gql.tada/internal@npm:1.0.9"
dependencies:
"@0no-co/graphql.web": "npm:^1.0.5"
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0
checksum: 10c0/5599ab58bc5859bf9d38f13e4ca0aae564cf7e94fbfa9afdb9932d4719fb945cb19da917fe23ed0cf115550ce07d31bcac89c229385fe7077754b54c2bed16e9
languageName: node
linkType: hard
"@gql.tada/internal@npm:^1.2.0":
version: 1.2.2
resolution: "@gql.tada/internal@npm:1.2.2"
dependencies:
"@0no-co/graphql.web": "npm:^1.3.1"
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
checksum: 10c0/2861061a64fa88dbd8bac84cff87bc33a30e9ed971cbee9f8d9a52ec27a17ffc64c389fb6a75f2231458524556829b81f4744b55ccb433e46d28e1066a5d4655
languageName: node
linkType: hard
"@img/colour@npm:^1.0.0":
version: 1.1.0
resolution: "@img/colour@npm:1.1.0"
@@ -1881,22 +1810,19 @@ __metadata:
languageName: node
linkType: hard
"@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607":
version: 0.0.0-preview-116d5d7-20260730141607
resolution: "@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607"
"@shopify/graphql-client@npm:^1.4.2":
version: 1.4.2
resolution: "@shopify/graphql-client@npm:1.4.2"
checksum: 10c0/f18d898ec3fb12a819b6c2941d9ddaebd1ab7152ffe55450ab9255c11860de9422ba1e9eadee5f44ef314f664fe411c642ba3debd6671851b9cdc647eb85e0be
languageName: node
linkType: hard
"@shopify/storefront-api-client@npm:^1.0.0":
version: 1.0.10
resolution: "@shopify/storefront-api-client@npm:1.0.10"
dependencies:
gql.tada: "npm:1.9.2"
peerDependencies:
react: ^18.0.0 || ^19.0.0
vue: ^3.5.0
peerDependenciesMeta:
react:
optional: true
vue:
optional: true
bin:
hydrogen: bin/hydrogen.mjs
checksum: 10c0/b12e230d174a910d8abb7ab18ecb99357dce82d2125054b30af6dcbecf280c6fea40c8ca5fd67d8931e1079fd73e587e3ea1484f50c294f4a79ad714c9664ecd
"@shopify/graphql-client": "npm:^1.4.2"
checksum: 10c0/e8eff260e91857e87cffb543ca57c8a4a6e549316e0b6ccef2c345b857ef59e4092e92ecd2904706aa386bca1d21af4ef3e7447f9966b8dcceda06b3ad7dadc9
languageName: node
linkType: hard
@@ -2501,23 +2427,6 @@ __metadata:
languageName: node
linkType: hard
"gql.tada@npm:1.9.2":
version: 1.9.2
resolution: "gql.tada@npm:1.9.2"
dependencies:
"@0no-co/graphql.web": "npm:^1.0.5"
"@0no-co/graphqlsp": "npm:^1.12.13"
"@gql.tada/cli-utils": "npm:1.7.3"
"@gql.tada/internal": "npm:1.0.9"
peerDependencies:
typescript: ^5.0.0 || ^6.0.0
bin:
gql-tada: bin/cli.js
gql.tada: bin/cli.js
checksum: 10c0/e6995d5ddd41a0049f7e2494758d605567027c57b270e59c48355d7fbd0ca95db77db2385c14d2add1198ef59b91c1b5a7f9b70d9eac8668c2be534e55a56517
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.4":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
@@ -2525,13 +2434,6 @@ __metadata:
languageName: node
linkType: hard
"graphql@npm:^15.5.0 || ^16.0.0 || ^17.0.0":
version: 17.0.2
resolution: "graphql@npm:17.0.2"
checksum: 10c0/766546216b891439ed09fd8584963df2013e26f2f79f096036eaaa2788c38964e049c8a142d68e3afdabdd8bbe70042639e83f0f4b82edbc7c6850421f072bb6
languageName: node
linkType: hard
"hast-util-sanitize@npm:^5.0.0":
version: 5.0.2
resolution: "hast-util-sanitize@npm:5.0.2"
@@ -3469,7 +3371,7 @@ __metadata:
"@radix-ui/react-slot": "npm:^1.3.3"
"@radix-ui/react-use-controllable-state": "npm:^1.2.6"
"@remixicon/react": "npm:^4.9.0"
"@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607"
"@shopify/storefront-api-client": "npm:^1.0.0"
"@tailwindcss/postcss": "npm:^4"
"@types/hast": "npm:^3.0.5"
"@types/node": "npm:^22"