Compare commits

..
25 Commits
Author SHA1 Message Date
Rami Bitar 3884e9524d Fix 2026-08-08 11:30:53 -04:00
Rami BitarandClaude Opus 5 1c690ca593 Target SFAPI 2026-07, rename the token var, work around a hydrogen CORS bug
Three changes needed to run against a real storefront rather than mock.shop.

Storefront API version 2025-07 -> 2026-07. Verified before switching: the
API validates the version segment (a bogus one returns NOT_FOUND), and the
fields this app selects all still resolve at 2026-07 — including the two
deprecated ones, ProductOption.values and CartCost.totalTaxAmount, and the
classic customer account mutations.

BREAKING (config): NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN is now
NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN. Deploy environments must set the
new name *before* the next build — NEXT_PUBLIC_* values are inlined at
build time, so a stale name silently degrades to a tokenless client and
real-store queries start failing.

Works around a bug in this preview build of @shopify/hydrogen: the client
tags every request with X-Hydrogen-Version, which the Storefront API does
not list in its CORS access-control-allow-headers. Browsers reject the
preflight, fetch throws, and hydrogen reports the generic "SFAPI request
failed". Confirmed by isolating the single header in the browser against a
real store: without it the request reaches the server (401 on a dummy
token), with it fetch throws TypeError: Failed to fetch. This never showed
up on mock.shop, which answers access-control-allow-headers: *.

The header is stripped in the browser only, inside the fetch wrapper the
client already owns; server-side requests are not subject to CORS and keep
sending it. Remove once the API allows the header, or once these queries
move server-side — which is the better fix, and what hydrogen's own
guidance recommends for browser UI.

Verified against a real store: product grid, search (519 results),
product detail with variants, and cartCreate all succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC
2026-08-08 11:15:00 -04:00
Rami BitarandClaude Opus 5 fe78293312 Call hydrogen's storefront.graphql directly, drop the compat shim
Completes the migration started in f8c3ef9. That commit put hydrogen
underneath the existing shopifyFetch wrapper; this one removes the
wrapper so every query uses the package's own client API.

- All 22 call sites now call storefront.graphql(DOCUMENT, { variables })
  (cachedStorefront for shop policies) instead of shopifyFetch, which
  emulated the old client's {query, variables} -> {data} shape.
- shopifyFetch is replaced by unwrapStorefrontResult(result, operation),
  which is only an error policy, not a transport wrapper: it returns
  data and throws when Shopify reports GraphQL errors, since hydrogen
  returns those rather than throwing. Naming the operation means a
  failure points at the call site instead of just at "Shopify".
- Environment reads move to services/shopify/config, so client.ts is
  only the hydrogen clients and shop-pay-button no longer imports from
  a query module just to get the store domain.
- Removes @shopify/storefront-api-client, the superseded client. It had
  no importers.

The SHOPIFY_STOREFRONT_API_URL export is gone too — hydrogen builds the
endpoint from storeDomain and apiVersion, and nothing else used it.

No behaviour change intended: same documents, same env var names, same
2025-07 API pin, same caching split between the two clients.

yarn typecheck passes with 0 errors. Verified against mock.shop: build
prerenders the policy pages, and in the browser product grid, search,
collection filters, cart restore, quantity update and discount-code
apply all work with no console errors. Customer account flows remain
untested — they need real credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC
2026-08-08 10:38:12 -04:00
Rami BitarandClaude Opus 5 f8c3ef9e0e Move Storefront queries onto @shopify/hydrogen preview client
Replaces the hand-rolled shopifyFetch wrapper and raw query strings with
the hydrogen preview package (0.0.0-preview-116d5d7-20260730141607), the
same build pinned in hydrogen-preview/.

- services/shopify/client: createStorefrontClient({type: "public"}) with a
  static request context. Two module-scoped clients — uncached for carts,
  products and customers; revalidate-3600 for shop policies — using the
  client's custom fetch option to carry Next's caching hints.
- graphql/*: every document wrapped in gql(), fragments composed via the
  second argument instead of string interpolation, and $country/$language
  declared with @inContext so hydrogen injects them.
- shopifyFetch keeps its {query, variables} call shape so call sites are
  unchanged, but is now generic over the document, so data is inferred. It
  re-raises GraphQL errors, which hydrogen returns rather than throws.

Env var names, the 2025-07 API version pin, and the client-side fetching
architecture are unchanged.

Turning on type coverage surfaced real defects, not just annotations:

- hydrogen gql check caught $discountCodes: [String!] used where the field
  requires [String!]!.
- Cart and customer mutation payloads are nullable and were dereferenced
  unconditionally, so a failed mutation threw a TypeError. Adds a shared
  unwrapCartPayload helper (collapsing five copies of the same userErrors
  check) and explicit null handling in the customer service, so a null
  payload reads as an error rather than success with no errors.
- Search results are a Product | Page | Article union; adds __typename to
  the queries and narrows on it.
- Widens nullable fields (altText, image, customer.email, totalTaxAmount)
  in the domain interfaces and in the structural duplicates some
  components declare locally.

Adds a typecheck script (tsc --noEmit && hydrogen gql check); it passes
with 0 errors. Two deprecation warnings are left alone as acting on them
would change behaviour: ProductOption.values and CartCost.totalTaxAmount.

Verified against mock.shop: build prerenders the policy pages through the
cached client, and in the browser the product grid, search with facets,
collection filter round-trip, add-to-cart and quantity update all work.
Customer account flows are untested — they need real credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC
2026-08-08 10:24:38 -04:00
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
23 changed files with 36 additions and 217 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
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({
+1 -1
View File
@@ -1,6 +1,6 @@
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 (
+1 -1
View File
@@ -3,7 +3,7 @@ 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();
+1 -1
View File
@@ -1,6 +1,6 @@
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 (
+1 -1
View File
@@ -1,6 +1,6 @@
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 (
+1 -1
View File
@@ -1,6 +1,6 @@
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({
-48
View File
@@ -1,52 +1,4 @@
import type { Metadata } from 'next';
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 <CollectionDetail />;
-6
View File
@@ -1,11 +1,5 @@
import type { Metadata } from 'next';
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 <Collections title="Our Collections" />;
} }
-24
View File
@@ -1,5 +1,4 @@
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 Header from '@/components/shopify/header';
@@ -17,29 +16,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,
}: { }: {
-9
View File
@@ -1,13 +1,4 @@
import type { Metadata } from 'next';
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 (
+1 -6
View File
@@ -1,6 +1,5 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
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 +13,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}` },
}; };
} }
-42
View File
@@ -1,47 +1,5 @@
import type { Metadata } from 'next';
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 (
+1 -2
View File
@@ -2,8 +2,7 @@ import { Suspense } from 'react';
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() {
-8
View File
@@ -1,13 +1,5 @@
import type { Metadata } from 'next';
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 <Collections title="Our Collections" />;
} }
+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 { 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 '@/components/ui/loader';
@@ -172,11 +171,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"
/> />
) : ( ) : (
+2 -5
View File
@@ -1,6 +1,5 @@
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;
@@ -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
@@ -50,7 +50,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: '/' },
+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"
/> />
) : ( ) : (
+2 -5
View File
@@ -1,6 +1,5 @@
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 {
@@ -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">
@@ -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 | null;
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 -4
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,
@@ -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"
/> />
) : ( ) : (
-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. */ /** Chrome shared by every route — rendered once in the root layout. */
export const site = { export const site = {
storeName: 'Shop', 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: '', logoUrl: '',
copyright: '© 2026 Shop. All rights reserved.', copyright: '© 2026 Shop. All rights reserved.',
navLinks: [ navLinks: [
+2 -2
View File
@@ -4,15 +4,15 @@ 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: '**.shopify.com', hostname: 'cdn.shopify.com',
}, },
{ {
protocol: 'https', protocol: 'https',