Compare commits

..
13 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
57 changed files with 1924 additions and 2732 deletions
@@ -0,0 +1,33 @@
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Activate your account — Shop' };
// Shopify's emailed activation link is /account/activate/{id}/{token}.
export default async function Page({
params,
}: {
params: Promise<{ id: string; token: string }>;
}) {
const { id, token } = await params;
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Activate your account"
description="Choose a password to finish setting up your account."
fields={[
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'new-password',
},
]}
submitLabel="Activate account"
endpoint="/api/account/activate"
extraPayload={{ id, activationToken: token }}
redirectTo="/account"
/>
</main>
);
}
+38
View File
@@ -0,0 +1,38 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Sign in — Shop' };
export default function Page() {
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Sign in"
fields={[
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'current-password',
},
]}
submitLabel="Sign in"
endpoint="/api/account/login"
redirectTo="/account"
footer={
<>
<span>
New here?{' '}
<AccountFormLink href="/account/register">
Create an account
</AccountFormLink>
</span>
<AccountFormLink href="/account/recover">
Forgot your password?
</AccountFormLink>
</>
}
/>
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from 'next/navigation';
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 — Shop' };
export default async function Page() {
const token = await getSessionToken();
if (!token) redirect('/account/login');
const customer = await getCustomer(token, 20);
// An expired or revoked token reads as signed out.
if (!customer) redirect('/account/login');
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
Order history
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{customer.displayName} · {customer.email}
</p>
<div className="mt-10">
<OrderHistory customer={customer} />
</div>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Reset password — Shop' };
export default function Page() {
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Reset password"
description="Enter your email and we'll send you a link to set a new password."
fields={[
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
]}
submitLabel="Send reset link"
endpoint="/api/account/recover"
successMessage="If that email has an account, a reset link is on its way."
footer={
<AccountFormLink href="/account/login">Back to sign in</AccountFormLink>
}
/>
</main>
);
}
+43
View File
@@ -0,0 +1,43 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Create account — Shop' };
export default function Page() {
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Create account"
fields={[
{
name: 'firstName',
label: 'First name',
autoComplete: 'given-name',
required: false,
},
{
name: 'lastName',
label: 'Last name',
autoComplete: 'family-name',
required: false,
},
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'new-password',
},
]}
submitLabel="Create account"
endpoint="/api/account/register"
redirectTo="/account"
footer={
<span>
Already have an account?{' '}
<AccountFormLink href="/account/login">Sign in</AccountFormLink>
</span>
}
/>
</main>
);
}
+32
View File
@@ -0,0 +1,32 @@
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Set a new password — Shop' };
// Shopify's emailed reset link is /account/reset/{id}/{token}.
export default async function Page({
params,
}: {
params: Promise<{ id: string; token: string }>;
}) {
const { id, token } = await params;
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Set a new password"
fields={[
{
name: 'password',
label: 'New password',
type: 'password',
autoComplete: 'new-password',
},
]}
submitLabel="Save password"
endpoint="/api/account/reset"
extraPayload={{ id, resetToken: token }}
redirectTo="/account"
/>
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import {
activateAccount,
toCustomerGid,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { id, activationToken, password } = await req.json();
if (!id || !activationToken || !password) {
return Response.json(
{ error: 'This activation link is incomplete.' },
{ status: 400 }
);
}
const { token, errors } = await activateAccount(
toCustomerGid(id),
activationToken,
password
);
if (!token) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
+31
View File
@@ -0,0 +1,31 @@
import { login, customerErrorMessage } from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { email, password } = await req.json();
if (!email || !password) {
return Response.json(
{ error: 'Enter your email and password.' },
{ status: 400 }
);
}
const { token, errors } = await login(email, password);
if (!token) {
// Shopify distinguishes wrong-password from unknown-email; collapse both so
// the form can't be used to enumerate accounts.
return Response.json(
{
error: errors.length
? 'Incorrect email or password.'
: customerErrorMessage(errors),
},
{ status: 401 }
);
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
+10
View File
@@ -0,0 +1,10 @@
import { logout } from '@/services/shopify/customer';
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
export async function POST() {
const token = await getSessionToken();
if (token) await logout(token);
await clearSessionToken();
return Response.json({ ok: true });
}
+19
View File
@@ -0,0 +1,19 @@
import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer';
// Minimal session probe for the header menu — never returns the access token.
export async function GET() {
const token = await getSessionToken();
if (!token) return Response.json({ customer: null });
const customer = await getCustomer(token, 0);
if (!customer) return Response.json({ customer: null });
return Response.json({
customer: {
displayName: customer.displayName,
email: customer.email,
firstName: customer.firstName,
},
});
}
+10
View File
@@ -0,0 +1,10 @@
import { recoverPassword } from '@/services/shopify/customer';
export async function POST(req: Request) {
const { email } = await req.json();
if (email) await recoverPassword(email);
// Always the same response, so the form can't reveal who has an account.
return Response.json({ ok: true });
}
+39
View File
@@ -0,0 +1,39 @@
import {
createCustomer,
login,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { email, password, firstName, lastName } = await req.json();
if (!email || !password) {
return Response.json(
{ error: 'Enter your email and password.' },
{ status: 400 }
);
}
const { errors } = await createCustomer({
email,
password,
firstName,
lastName,
});
if (errors.length) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
// Sign the new customer straight in. Accounts needing email confirmation
// won't return a token yet, which is not an error.
const { token } = await login(email, password);
if (token) {
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true, signedIn: true });
}
return Response.json({ ok: true, signedIn: false });
}
+27
View File
@@ -0,0 +1,27 @@
import {
resetPassword,
toCustomerGid,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { id, resetToken, password } = await req.json();
if (!id || !resetToken || !password) {
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
}
const { token, errors } = await resetPassword(
toCustomerGid(id),
resetToken,
password
);
if (!token) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
+11 -5
View File
@@ -7,6 +7,8 @@ import {
getProductsPage,
getCollections,
getCollectionProductsPage,
isDefaultTitleOption,
isDefaultTitleSelection,
} from '@/services/shopify/catalog';
// Streaming needs the Node runtime here because the Storefront helpers run
@@ -63,10 +65,12 @@ const summariseProduct = (product: {
description: product.description?.slice(0, 300) ?? null,
productType: product.productType || null,
tags: product.tags ?? [],
options: product.options?.map((option) => ({
name: option.name,
values: option.values,
})),
options: product.options
?.filter((option) => !isDefaultTitleOption(option))
.map((option) => ({
name: option.name,
values: option.values,
})),
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
});
@@ -132,7 +136,9 @@ export async function POST(req: Request) {
title: node.title,
available: node.availableForSale,
price: `${node.price.amount} ${node.price.currencyCode}`,
options: node.selectedOptions,
options: node.selectedOptions?.filter(
(option) => !isDefaultTitleSelection(option)
),
})),
};
},
+1 -19
View File
@@ -1,23 +1,5 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import CollectionDetail from '@/components/shopify/collection-detail';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<CollectionDetail />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
return <CollectionDetail />;
}
+1 -19
View File
@@ -1,23 +1,5 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
return <Collections title="Our Collections" />;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Error({
reset: () => void;
}) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
Something went wrong
+13 -4
View File
@@ -1,9 +1,10 @@
'use client';
import React from 'react';
import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import StoreAssistant from '@/components/shopify/store-assistant';
import { site } from '@/config/site';
const geist = Geist({
subsets: ['latin'],
@@ -22,8 +23,16 @@ export default function RootLayout({
}) {
return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-body antialiased bg-background text-foreground m-0 p-0">
{children}
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
{/* Header owns the cart drawer, search and account menu, so every
route gets them by rendering here rather than page by page. */}
<Header
storeName={site.storeName}
logoUrl={site.logoUrl}
links={site.navLinks}
/>
<div className="flex-1">{children}</div>
<Footer storeName={site.storeName} copyright={site.copyright} />
<StoreAssistant />
</body>
</html>
+1 -1
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
404
+4 -20
View File
@@ -1,26 +1,10 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Products from '@/components/shopify/products';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Products
title="Shopify Hydrogen Storefront"
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
<Products
title="Shopify Hydrogen Storefront"
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
);
}
+11 -29
View File
@@ -1,6 +1,4 @@
import { notFound } from 'next/navigation';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
export function generateStaticParams() {
@@ -33,33 +31,17 @@ export default async function PolicyPage({
}
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{policy.title}
</h1>
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{policy.title}
</h1>
<div
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
dangerouslySetInnerHTML={{ __html: policy.body }}
/>
</div>
</main>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
<div
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
dangerouslySetInnerHTML={{ __html: policy.body }}
/>
</div>
</main>
);
}
-14
View File
@@ -1,25 +1,11 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import ProductDetail from '@/components/shopify/product-detail';
import ProductRecommendations from '@/components/shopify/product-recommendations';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<ProductDetail addToCartLabel="Add to Cart" />
<ProductRecommendations title="You May Also Like" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
}
+4 -22
View File
@@ -1,6 +1,4 @@
import { Suspense } from 'react';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import SearchResults from '@/components/shopify/search-results';
export const metadata = {
@@ -9,25 +7,9 @@ export const metadata = {
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
{/* useSearchParams needs a Suspense boundary to prerender this route. */}
<Suspense fallback={<div className="py-10" />}>
<SearchResults />
</Suspense>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
// useSearchParams needs a Suspense boundary to prerender this route.
<Suspense fallback={<div className="py-10" />}>
<SearchResults />
</Suspense>
);
}
+1 -19
View File
@@ -1,23 +1,5 @@
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections';
export default function Page() {
return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/shop/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
return <Collections title="Our Collections" />;
}
-562
View File
@@ -1,562 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// oxlint-disable-next-line eslint(no-bitwise)
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Synchronous cache lookup — avoids setState in effect for cached results
const syncTokens = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens]
);
// Async highlighting result (populated after shiki loads)
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
const asyncKeyRef = useRef({ code, language });
// Invalidate stale async tokens synchronously during render
if (
asyncKeyRef.current.code !== code ||
asyncKeyRef.current.language !== language
) {
asyncKeyRef.current = { code, language };
setAsyncTokens(null);
}
useEffect(() => {
let cancelled = false;
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncTokens(result);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
const tokenized = asyncTokens ?? syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Nodes } from "hast";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import type { Components } from "hast-util-to-jsx-runtime";
import type { ComponentProps, HTMLAttributes, MouseEvent } from "react";
import { Fragment, memo, useCallback, useMemo, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import remend from "remend";
import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
// is off by default) and rehype-sanitize is the second line of defence, mainly
// for its href protocol allow-list — that is what stops `javascript:` URLs.
// Relative hrefs carry no protocol, so storefront links pass through untouched.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows
// `type` and `disabled` but not `checked`, so every box would read unticked.
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
},
};
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
// bullet. Prices in italics are ordinary storefront copy, so settled messages
// render verbatim and only the in-flight one gets repaired.
const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig {
enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean;
}
type AnchorProps = ComponentProps<"a"> & {
linkSafety?: LinkSafetyConfig;
onUntrusted: (url: string) => void;
};
const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground";
// Route-relative hrefs are the storefront links the assistant emits. Deciding
// this from the string alone keeps the server and client passes identical;
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
const isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({
href,
linkSafety,
onUntrusted,
...props
}: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
if (linkSafety.onLinkCheck(href)) return;
event.preventDefault();
onUntrusted(href);
},
[href, linkSafety, onUntrusted]
);
// In-app destinations navigate client-side in the same tab; sending a shopper
// to a product page in a new tab would strand the conversation behind it.
if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />;
}
return (
<a
className={LINK_CLASS}
href={href}
onClick={handleClick}
rel="noreferrer"
target="_blank"
{...props}
/>
);
};
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = (
linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void
): Partial<Components> => ({
a: (props: ComponentProps<"a">) => (
<MarkdownLink {...props} linkSafety={linkSafety} onUntrusted={onUntrusted} />
),
blockquote: (props: ComponentProps<"blockquote">) => (
<blockquote
className="my-3 border-border border-l-2 pl-3 text-muted-foreground"
{...props}
/>
),
code: ({ className, ...props }: ComponentProps<"code">) => {
// Only fenced blocks carry a language class, and those already sit inside a
// <pre>, so they must not get the inline pill treatment.
const isBlock =
typeof className === "string" && className.includes("language-");
return (
<code
className={cn(
isBlock
? "font-mono text-xs"
: "rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
className
)}
{...props}
/>
);
},
em: (props: ComponentProps<"em">) => <em className="italic" {...props} />,
h1: (props: ComponentProps<"h1">) => (
<h1 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h2: (props: ComponentProps<"h2">) => (
<h2 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h3: (props: ComponentProps<"h3">) => (
<h3 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h4: (props: ComponentProps<"h4">) => (
<h4 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h5: (props: ComponentProps<"h5">) => (
<h5 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h6: (props: ComponentProps<"h6">) => (
<h6 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} />
),
// GFM task-list boxes are display only; readOnly silences React's warning
// about a `checked` input with no change handler.
input: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} />
),
img: ({ alt, ...props }: ComponentProps<"img">) => (
// biome-ignore lint/nursery/noImgElement: model output, not a known asset
<img alt={alt ?? ""} className="my-2 max-w-full rounded-md" {...props} />
),
li: (props: ComponentProps<"li">) => <li className="my-0.5" {...props} />,
ol: (props: ComponentProps<"ol">) => (
<ol className="my-2 list-decimal space-y-0.5 pl-5" {...props} />
),
p: (props: ComponentProps<"p">) => (
<p className="my-2 leading-relaxed" {...props} />
),
pre: (props: ComponentProps<"pre">) => (
<pre
className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs"
{...props}
/>
),
strong: (props: ComponentProps<"strong">) => (
<strong className="font-semibold" {...props} />
),
table: (props: ComponentProps<"table">) => (
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-left text-xs" {...props} />
</div>
),
td: (props: ComponentProps<"td">) => (
<td className="border border-border px-2 py-1" {...props} />
),
th: (props: ComponentProps<"th">) => (
<th
className="border border-border bg-muted px-2 py-1 font-medium"
{...props}
/>
),
ul: (props: ComponentProps<"ul">) => (
<ul className="my-2 list-disc space-y-0.5 pl-5" {...props} />
),
});
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean;
linkSafety?: LinkSafetyConfig;
};
export const Markdown = memo(
({
children,
className,
isAnimating,
linkSafety,
...props
}: MarkdownProps) => {
const [pending, setPending] = useState<string | null>(null);
const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => {
// While streaming, close syntax the model has not finished emitting so a
// partial `**bold` renders as bold rather than as literal asterisks.
const source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes;
return toJsxRuntime(tree, {
components: buildComponents(linkSafety, handleUntrusted),
Fragment,
jsx,
jsxs,
});
}, [children, isAnimating, linkSafety, handleUntrusted]);
return (
<div
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
>
{content}
<Dialog
onOpenChange={(open) => !open && setPending(null)}
open={pending !== null}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Leave this site?</DialogTitle>
<DialogDescription>
This link points somewhere outside the store. Continue only if
you trust it.
</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
{pending}
</p>
<DialogFooter>
<Button onClick={() => setPending(null)} variant="outline">
Cancel
</Button>
<Button
onClick={() => {
if (pending) {
window.open(pending, "_blank", "noopener,noreferrer");
}
setPending(null);
}}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
},
(prev, next) =>
prev.children === next.children && prev.isAnimating === next.isAnimating
);
Markdown.displayName = "Markdown";
+5 -26
View File
@@ -12,23 +12,19 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Markdown } from "./markdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -319,27 +315,10 @@ export const MessageBranchPage = ({
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export type MessageResponseProps = ComponentProps<typeof Markdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating
);
MessageResponse.displayName = "MessageResponse";
// Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown;
export type MessageToolbarProps = ComponentProps<"div">;
+17 -19
View File
@@ -7,10 +7,6 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -23,8 +19,8 @@ import {
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Markdown } from "./markdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
@@ -204,21 +200,23 @@ export type ReasoningContentProps = ComponentProps<
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
</CollapsibleContent>
)
({ className, children, ...props }: ReasoningContentProps) => {
const { isStreaming } = useReasoning();
return (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Markdown isAnimating={isStreaming}>{children}</Markdown>
</CollapsibleContent>
);
}
);
Reasoning.displayName = "Reasoning";
-173
View File
@@ -1,173 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import Link from 'next/link';
interface LogoProps {
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
src?: string | null;
/** Wordmark text, and the image's alt text. */
storeName?: string;
/** Where the logo links to. Pass null to render it unwrapped. */
href?: string | null;
/** Sizing for the image — height only, so the aspect ratio is preserved. */
imageClassName?: string;
/** Sizing and weight for the wordmark fallback. */
textClassName?: string;
}
const Logo: React.FC<LogoProps> = ({
src,
storeName = 'Shop',
href = '/',
imageClassName = 'h-6',
textClassName = 'text-xl',
}) => {
const mark = src ? (
<img
src={src}
alt={storeName}
className={`w-auto object-contain ${imageClassName}`}
/>
) : (
<span
className={`font-medium tracking-tight text-foreground ${textClassName}`}
>
{storeName}
</span>
);
if (href === null) {
return <span className="flex items-center">{mark}</span>;
}
return (
<Link href={href} className="flex items-center">
{mark}
</Link>
);
};
export default Logo;
+142
View File
@@ -0,0 +1,142 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
export interface AccountFormField {
name: string;
label: string;
type?: 'text' | 'email' | 'password';
autoComplete?: string;
required?: boolean;
}
interface AccountFormProps {
title: string;
description?: string;
fields: AccountFormField[];
submitLabel: string;
endpoint: string;
/** Merged into the request body alongside the field values. */
extraPayload?: Record<string, string>;
/** Where to go on success; omit to show `successMessage` instead. */
redirectTo?: string;
successMessage?: string;
footer?: React.ReactNode;
}
const AccountForm: React.FC<AccountFormProps> = ({
title,
description,
fields,
submitLabel,
endpoint,
extraPayload,
redirectTo,
successMessage,
footer,
}) => {
const router = useRouter();
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (submitting) return;
try {
setSubmitting(true);
setError(null);
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, ...extraPayload }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? 'Something went wrong. Please try again.');
return;
}
if (redirectTo) {
// refresh() so server components re-read the new session cookie.
router.push(redirectTo);
router.refresh();
} else {
setDone(true);
}
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<div className="mx-auto w-full max-w-sm">
<h1 className="text-2xl font-normal text-foreground">{title}</h1>
{description && (
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
)}
{done && successMessage ? (
<p className="mt-6 text-sm text-foreground">{successMessage}</p>
) : (
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
{fields.map((field) => (
<label key={field.name} className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
<input
type={field.type ?? 'text'}
name={field.name}
required={field.required ?? true}
autoComplete={field.autoComplete}
value={values[field.name] ?? ''}
onChange={(event) =>
setValues((prev) => ({
...prev,
[field.name]: event.target.value,
}))
}
className="h-11 rounded-md border border-border px-3 text-sm outline-none transition-colors focus:border-foreground"
/>
</label>
))}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={submitting} className="h-11">
{submitting && <Loader size={16} />}
{submitLabel}
</Button>
</form>
)}
{footer && (
<div className="mt-6 flex flex-col gap-2 text-sm text-muted-foreground">
{footer}
</div>
)}
</div>
);
};
export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
href,
children,
}) => (
<Link href={href} className="underline underline-offset-2 hover:text-foreground">
{children}
</Link>
);
export default AccountForm;
+112
View File
@@ -0,0 +1,112 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { RiUserLine } from '@remixicon/react';
interface SessionCustomer {
displayName: string;
email: string;
firstName?: string | null;
}
const AccountMenu: React.FC = () => {
const router = useRouter();
const [customer, setCustomer] = useState<SessionCustomer | null>(null);
const [open, setOpen] = useState(false);
// The token lives in an httpOnly cookie, so the signed-in state has to come
// from the server rather than being read directly.
useEffect(() => {
let cancelled = false;
fetch('/api/account/me')
.then((response) => response.json())
.then((data) => {
if (!cancelled) setCustomer(data.customer ?? null);
})
.catch(() => {
if (!cancelled) setCustomer(null);
});
return () => {
cancelled = true;
};
}, []);
const handleSignOut = async () => {
setOpen(false);
await fetch('/api/account/logout', { method: 'POST' });
setCustomer(null);
router.push('/');
router.refresh();
};
// Signed out: straight to the sign-in page, no menu.
if (!customer) {
return (
<Button
asChild
variant="ghost"
size="icon"
aria-label="Sign in"
className="rounded-full"
>
<Link href="/account/login">
<RiUserLine className="size-5" />
</Link>
</Button>
);
}
return (
<div className="relative">
<Button
onClick={() => setOpen((prev) => !prev)}
variant="ghost"
size="icon"
aria-label="Account menu"
aria-expanded={open}
className="rounded-full"
>
<RiUserLine className="size-5" />
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-md border border-border bg-background py-1 shadow-md">
<div className="px-4 py-2">
<p className="truncate text-sm font-medium text-foreground">
{customer.firstName || customer.displayName}
</p>
<p className="truncate text-xs text-muted-foreground">
{customer.email}
</p>
</div>
<div className="my-1 h-px bg-border" />
<Link
href="/account"
onClick={() => setOpen(false)}
className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
>
Order history
</Link>
<button
onClick={handleSignOut}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
Sign out
</button>
</div>
</>
)}
</div>
);
};
export default AccountMenu;
+6 -2
View File
@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import { Loader } from '@/components/ui/loader';
import {
Sheet,
SheetContent,
@@ -25,6 +25,7 @@ import {
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
const CartDrawer: React.FC = () => {
const isOpen = useCartStore((s) => s.isOpen);
@@ -102,7 +103,10 @@ const CartDrawer: React.FC = () => {
};
const getSelectedOptions = (item: (typeof items)[0]) => {
return item.merchandise.selectedOptions ?? [];
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option)
);
};
return (
+1 -1
View File
@@ -25,7 +25,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
className="group block h-full"
>
{/* Collection Image */}
<div className="relative aspect-square overflow-hidden bg-white">
<div className="relative aspect-square overflow-hidden">
{collection.image ? (
<img
src={collection.image.url}
+1 -1
View File
@@ -6,7 +6,7 @@ import ProductCard from './product-card';
import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import { Loader } from '@/components/ui/loader';
import {
getCollectionProductsPage,
type CollectionSortKey,
+10
View File
@@ -4,6 +4,7 @@ import {
RiTiktokLine,
RiFacebookFill,
} from '@remixicon/react';
import Logo from '@/components/logo';
export interface FooterLink {
label: string;
@@ -12,6 +13,7 @@ export interface FooterLink {
interface FooterProps {
storeName?: string;
logoUrl?: string;
copyright?: string;
links?: FooterLink[];
instagramUrl?: string;
@@ -21,6 +23,7 @@ interface FooterProps {
const Footer: React.FC<FooterProps> = ({
storeName = 'Shop',
logoUrl,
copyright,
links = [
{ label: 'Terms of Service', url: '/policies/terms-of-service' },
@@ -46,6 +49,13 @@ const Footer: React.FC<FooterProps> = ({
assistant launcher. Links and socials sit on separate rows so the
icons aren't crammed onto the end of the link list. */}
<div className="flex flex-col items-center gap-y-5 sm:items-start">
<Logo
src={logoUrl}
storeName={storeName}
imageClassName="h-5"
textClassName="text-base"
/>
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
{links.map((link) => (
<a
+9 -15
View File
@@ -5,8 +5,11 @@ import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog';
import AccountMenu from '@/components/shopify/account-menu';
import ShopMenu from '@/components/shopify/shop-menu';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import Logo from '@/components/logo';
const CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart);
@@ -62,7 +65,7 @@ const Header: React.FC<HeaderProps> = ({
<>
{/* Sits above the sticky nav, so it scrolls away on its own. */}
{announcement && (
<div className="bg-foreground text-background">
<div className="bg-muted text-foreground">
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
{announcementUrl ? (
<Link
@@ -82,27 +85,16 @@ const Header: React.FC<HeaderProps> = ({
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14">
{/* Logo */}
<Link href="/" className="flex items-center">
{logoUrl ? (
<img
src={logoUrl}
alt={storeName}
className="h-6 w-auto object-contain"
/>
) : (
<span className="text-xl font-medium tracking-tight text-foreground">
{storeName}
</span>
)}
</Link>
<Logo src={logoUrl} storeName={storeName} />
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-8 text-sm">
<ShopMenu />
{links.map((link, index) => (
<Link
key={index}
href={link.url}
className="text-foreground relative after:absolute after:bottom-[-4px] after:left-1/2 after:-translate-x-1/2 after:h-px after:w-0 after:bg-foreground after:transition-[width] after:duration-300 hover:after:w-full"
className="text-foreground hover:text-muted-foreground transition-colors"
>
{link.label}
</Link>
@@ -112,6 +104,7 @@ const Header: React.FC<HeaderProps> = ({
{/* Actions */}
<div className="flex items-center">
<SearchDialog />
<AccountMenu />
<CartIcon />
{/* Mobile hamburger */}
@@ -136,6 +129,7 @@ const Header: React.FC<HeaderProps> = ({
{menuOpen && (
<div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
<ShopMenu mobile onNavigate={() => setMenuOpen(false)} />
{links.map((link, index) => (
<Link
key={index}
+166
View File
@@ -0,0 +1,166 @@
'use client';
import React, { useState } from 'react';
import { RiImageLine } from '@remixicon/react';
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
interface OrderHistoryProps {
customer: Customer;
}
const formatMoney = (amount: string, currencyCode: string) => {
const value = parseFloat(amount);
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(value);
} catch {
return `$${value.toFixed(2)}`;
}
};
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => {
if (!label) return null;
return (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
{label.replace(/_/g, ' ').toLowerCase()}
</span>
);
};
const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
const orders: CustomerOrder[] =
customer.orders?.edges.map((edge) => edge.node) ?? [];
// The Storefront API has no standalone order-by-id query for customers, so
// the detail is rendered from the order already loaded in this list.
const [selectedId, setSelectedId] = useState<string | null>(
orders[0]?.id ?? null
);
const selected = orders.find((order) => order.id === selectedId) ?? null;
if (orders.length === 0) {
return (
<p className="text-sm text-muted-foreground">
You haven&apos;t placed any orders yet.
</p>
);
}
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
{/* List */}
<div className="lg:col-span-2">
<h2 className="mb-3 text-sm text-muted-foreground">Orders</h2>
<ul className="flex flex-col">
{orders.map((order) => {
const isSelected = order.id === selectedId;
return (
<li key={order.id}>
<button
onClick={() => setSelectedId(order.id)}
aria-current={isSelected}
className={`flex w-full items-baseline justify-between gap-x-4 rounded-md px-3 py-3 text-left transition-colors ${
isSelected ? 'bg-secondary' : 'hover:bg-secondary/60'
}`}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
Order #{order.orderNumber}
</span>
<span className="block text-xs text-muted-foreground">
{formatDate(order.processedAt)}
</span>
</span>
<span className="shrink-0 font-mono text-sm tabular-nums tracking-tight text-foreground">
{formatMoney(
order.currentTotalPrice.amount,
order.currentTotalPrice.currencyCode
)}
</span>
</button>
</li>
);
})}
</ul>
</div>
{/* Detail — same screen, no navigation */}
<div className="lg:col-span-3">
{selected && (
<>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 className="text-lg text-foreground">
Order #{selected.orderNumber}
</h2>
<span className="font-mono text-base tabular-nums tracking-tight text-foreground">
{formatMoney(
selected.currentTotalPrice.amount,
selected.currentTotalPrice.currencyCode
)}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Placed {formatDate(selected.processedAt)}
</span>
<StatusPill label={selected.financialStatus} />
<StatusPill label={selected.fulfillmentStatus} />
</div>
<ul className="mt-6 flex flex-col gap-4">
{selected.lineItems.edges.map(({ node }, index) => (
<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 ? (
<img
src={node.variant.image.url}
alt={node.variant.image.altText || node.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine className="size-5" />
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{node.title}
</p>
<p className="text-xs text-muted-foreground">
Qty {node.quantity}
</p>
</div>
</li>
))}
</ul>
{selected.statusUrl && (
<a
href={selected.statusUrl}
target="_blank"
rel="noreferrer"
className="mt-6 inline-block text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
View order status
</a>
)}
</>
)}
</div>
</div>
);
};
export default OrderHistory;
+1 -1
View File
@@ -66,7 +66,7 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
className="group block h-full"
>
{/* Product Image */}
<div className="relative aspect-square overflow-hidden bg-white">
<div className="relative aspect-square overflow-hidden">
{firstImage ? (
<img
src={firstImage.url}
@@ -132,7 +132,7 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
key={index}
onClick={() => setZoomedIndex(index)}
aria-label={`Zoom ${image.altText || 'product image'}`}
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden bg-white pointer-events-none sm:pointer-events-auto sm:shrink sm:cursor-zoom-in ${
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden pointer-events-none sm:pointer-events-auto sm:shrink sm:cursor-zoom-in ${
isSingle ? 'sm:col-span-2' : ''
}`}
>
@@ -177,7 +177,7 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'}
onClick={(event) => event.stopPropagation()}
className="max-h-full max-w-full object-contain bg-white"
className="max-h-full max-w-full object-contain"
/>
<Button
@@ -1,10 +1,11 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import { Loader } from '@/components/ui/loader';
import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
import { isDefaultTitleOption } from '@/services/shopify/catalog';
interface ProductPrice {
amount: string;
@@ -103,9 +104,10 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way.
const orderedOptions = [...(product.options ?? [])].sort(
(a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))
);
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
const orderedOptions = [...(product.options ?? [])]
.filter((option) => !isDefaultTitleOption(option))
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
// `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
+2 -2
View File
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import ProductCard from './product-card';
import { getProductsPage } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import { Loader } from '@/components/ui/loader';
interface ProductImage {
url: string;
@@ -172,7 +172,7 @@ const Products: React.FC<ProductsProps> = ({
}
return (
<div className="py-20 bg-white">
<div className="py-20">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
CommandItem,
} from '@/components/ui/command';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import { Loader } from '@/components/ui/loader';
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
import {
searchSuggestions,
@@ -111,7 +111,7 @@ const SearchDialog: React.FC = () => {
if (event.key === 'Enter') goToSearchPage();
}}
/>
<div className="absolute right-2 top-0 flex h-9 items-center gap-1">
<div className="absolute right-2 top-0 flex h-12 items-center gap-1">
{hasQuery && (
<Button
onClick={() => setTerm('')}
+1 -1
View File
@@ -6,7 +6,7 @@ import ProductCard from './product-card';
import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import { Loader } from '@/components/ui/loader';
import {
searchProducts,
type SearchFilter,
+152
View File
@@ -0,0 +1,152 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { RiArrowDownSLine } from '@remixicon/react';
import { useCollectionsOnDemand } from '@/hooks/use-shopify-collections';
import { cn } from '@/lib/utils';
interface ShopMenuProps {
label?: string;
/** Renders inline inside the mobile menu instead of as a floating panel. */
mobile?: boolean;
/** Fires after a collection is picked, so the mobile menu can close itself. */
onNavigate?: () => void;
}
const ShopMenu: React.FC<ShopMenuProps> = ({
label = 'Shop',
mobile = false,
onNavigate,
}) => {
const [open, setOpen] = useState(false);
const { collections, loading, error, load } = useCollectionsOnDemand();
// The collection list is only worth fetching once someone opens the menu.
const toggle = () => {
const next = !open;
setOpen(next);
if (next) load();
};
const close = () => {
setOpen(false);
onNavigate?.();
};
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open]);
const itemClasses = cn(
'block text-sm text-foreground hover:bg-accent transition-colors',
mobile ? 'px-3 py-2' : 'px-4 py-2'
);
const body = (
<>
{loading &&
Array.from({ length: 5 }).map((_, index) => (
<div key={index} className={cn(itemClasses, 'py-2.5')}>
<div className="h-3 w-2/3 animate-pulse bg-muted" />
</div>
))}
{error && (
<div className={cn(itemClasses, 'hover:bg-transparent')}>
<p className="text-muted-foreground">{error}</p>
<button
onClick={load}
className="mt-1 underline underline-offset-2 hover:text-muted-foreground"
>
Try again
</button>
</div>
)}
{!loading && !error && collections.length === 0 && (
<p className={cn(itemClasses, 'text-muted-foreground hover:bg-transparent')}>
No collections yet.
</p>
)}
{collections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.handle}`}
onClick={close}
className={itemClasses}
>
{collection.title}
</Link>
))}
{collections.length > 0 && (
<>
<div className="my-1 h-px bg-border" />
<Link
href="/collections"
onClick={close}
className={cn(itemClasses, 'text-muted-foreground')}
>
View all collections
</Link>
</>
)}
</>
);
const trigger = (
<button
onClick={toggle}
aria-expanded={open}
className={cn(
'flex items-center gap-x-1 text-foreground hover:text-muted-foreground transition-colors',
mobile && 'w-full justify-between'
)}
>
<span>{label}</span>
<RiArrowDownSLine
className={cn('size-4 transition-transform', open && 'rotate-180')}
/>
</button>
);
if (mobile) {
return (
<div>
{trigger}
{open && (
<div className="mt-2 flex flex-col border-l border-border pl-1">
{body}
</div>
)}
</div>
);
}
return (
<div className="relative">
{trigger}
{open && (
<>
{/* Catches the click that dismisses the panel. */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
{body}
</div>
</>
)}
</div>
);
};
export default ShopMenu;
+1 -1
View File
@@ -3,7 +3,7 @@
import React from 'react';
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client';
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import { Loader } from '@/app/components/ui/loader';
import { Loader } from '@/components/ui/loader';
import { Button } from '@/components/ui/button';
export interface ShopPayVariant {
+5
View File
@@ -393,6 +393,11 @@ const StoreAssistant: React.FC = () => {
return (
<MessageResponse
key={index}
// Only repair half-written markdown while it streams;
// settled text is rendered exactly as sent.
isAnimating={
status === 'streaming' && part.state === 'streaming'
}
linkSafety={{
enabled: true,
onLinkCheck: isInternalLink,
+8 -1
View File
@@ -22,6 +22,7 @@ function Command({
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
"transition-none animate-none",
className
)}
{...props}
@@ -53,7 +54,13 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
className={cn(
"overflow-hidden p-0",
// No fade/zoom on the command palette — it should appear instantly.
"transition-none animate-none duration-0",
"data-[state=closed]:animate-none data-[state=open]:animate-none",
className
)}
showCloseButton={showCloseButton}
>
<Command shouldFilter={shouldFilter} className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+12
View File
@@ -0,0 +1,12 @@
import type { NavLink } from '@/components/shopify/header';
/** Chrome shared by every route — rendered once in the root layout. */
export const site = {
storeName: 'Shop',
logoUrl: '',
copyright: '© 2026 Shop. All rights reserved.',
navLinks: [
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
] as NavLink[],
};
+173
View File
@@ -0,0 +1,173 @@
// Customer account operations (classic Storefront customer accounts).
// https://shopify.dev/docs/api/storefront/latest/objects/Customer
const CustomerFragment = `
fragment CustomerFragment on Customer {
id
email
firstName
lastName
phone
displayName
acceptsMarketing
createdAt
defaultAddress {
id
firstName
lastName
address1
address2
city
province
zip
country
phone
}
}
`;
export const CUSTOMER_QUERY = `
${CustomerFragment}
query GetCustomer($customerAccessToken: String!, $orderCount: Int!) {
customer(customerAccessToken: $customerAccessToken) {
...CustomerFragment
orders(first: $orderCount, reverse: true) {
edges {
node {
id
orderNumber
processedAt
financialStatus
fulfillmentStatus
statusUrl
currentTotalPrice {
amount
currencyCode
}
lineItems(first: 5) {
edges {
node {
title
quantity
variant {
image {
url
altText
}
}
}
}
}
}
}
}
}
}
`;
export const CUSTOMER_CREATE_MUTATION = `
mutation CustomerCreate($input: CustomerCreateInput!) {
customerCreate(input: $input) {
customer {
id
email
}
customerUserErrors {
code
field
message
}
}
}
`;
export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = `
mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
customerAccessTokenCreate(input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`;
export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = `
mutation CustomerAccessTokenDelete($customerAccessToken: String!) {
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
deletedAccessToken
userErrors {
field
message
}
}
}
`;
// Sends the "reset your password" email.
export const CUSTOMER_RECOVER_MUTATION = `
mutation CustomerRecover($email: String!) {
customerRecover(email: $email) {
customerUserErrors {
code
field
message
}
}
}
`;
// Completes the reset using the id + token from the emailed link.
export const CUSTOMER_RESET_MUTATION = `
mutation CustomerReset($id: ID!, $input: CustomerResetInput!) {
customerReset(id: $id, input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`;
// Activation link sent to customers created by the merchant.
export const CUSTOMER_ACTIVATE_MUTATION = `
mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) {
customerActivate(id: $id, input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`;
export const CUSTOMER_UPDATE_MUTATION = `
${CustomerFragment}
mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
customer {
...CustomerFragment
}
customerUserErrors {
code
field
message
}
}
}
`;
+31 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
getCollections,
getCollectionProducts,
@@ -60,6 +60,36 @@ export function useCollections(first = 50) {
return { collections, loading, error, refetch: fetchCollections };
}
// Deferred variant of useCollections: nothing is requested until `load` runs,
// so a menu can hold off until it's actually opened. The fetch happens once —
// re-opening reuses what's already in state.
export function useCollectionsOnDemand(first = 50) {
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requested = useRef(false);
const load = useCallback(async () => {
if (requested.current) return;
requested.current = true;
try {
setLoading(true);
setError(null);
setCollections(await getCollections(first));
} catch (err) {
console.error('Error fetching collections:', err);
// Let the next open (or a retry) try again.
requested.current = false;
setError(err instanceof Error ? err.message : 'Failed to load collections');
} finally {
setLoading(false);
}
}, [first]);
return { collections, loading, error, load };
}
// Hook for fetching products in a collection
export function useCollectionProducts(
handle: string | null,
+10 -9
View File
@@ -12,20 +12,17 @@
"@ai-sdk/react": "^4.0.47",
"@next/swc-wasm-web": "16.2.10",
"@openrouter/ai-sdk-provider": "^3.0.0",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@remixicon/react": "^4.9.0",
"@shopify/storefront-api-client": "^1.0.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@supabase/supabase-js": "^2.51.0",
"ai": "^7",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"framer-motion": "12.42.2",
"hast-util-to-jsx-runtime": "^2.3.6",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
@@ -34,22 +31,26 @@
"radix-ui": "^1.6.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shiki": "^3.19.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remend": "^1.3.0",
"sonner": "^2.0.7",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4",
"tw-animate-css": "1.4.0",
"unified": "^11.0.5",
"use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/hast": "^3.0.5",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5.7.3"
},
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e"
}
}
+19
View File
@@ -64,6 +64,25 @@ export interface ProductOption {
optionValues?: ProductOptionValue[];
}
/**
* Single-variant products still carry one synthetic option — `Title` with the
* lone value `Default Title`. It isn't a real choice, so keep it out of the UI.
*/
export const isDefaultTitleOption = (option: {
name: string;
values: string[];
}): boolean =>
option.name === 'Title' &&
option.values.length === 1 &&
option.values[0] === 'Default Title';
/** Same synthetic option, as it appears on a variant's `selectedOptions`. */
export const isDefaultTitleSelection = (selection: {
name: string;
value: string;
}): boolean =>
selection.name === 'Title' && selection.value === 'Default Title';
export interface Product {
id: string;
title: string;
+223
View File
@@ -0,0 +1,223 @@
// Server-safe customer account access.
//
// 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 { shopifyFetch } from '@/services/shopify/client';
import {
CUSTOMER_QUERY,
CUSTOMER_CREATE_MUTATION,
CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
CUSTOMER_RECOVER_MUTATION,
CUSTOMER_RESET_MUTATION,
CUSTOMER_ACTIVATE_MUTATION,
CUSTOMER_UPDATE_MUTATION,
} from '@/graphql/customer';
export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken';
export interface CustomerUserError {
code?: string;
field?: string[] | null;
message: string;
}
export interface CustomerAddress {
id: string;
firstName?: string | null;
lastName?: string | null;
address1?: string | null;
address2?: string | null;
city?: string | null;
province?: string | null;
zip?: string | null;
country?: string | null;
phone?: string | null;
}
export interface CustomerOrder {
id: string;
orderNumber: number;
processedAt: string;
financialStatus?: string | null;
fulfillmentStatus?: string | null;
statusUrl?: string | null;
currentTotalPrice: { amount: string; currencyCode: string };
lineItems: {
edges: Array<{
node: {
title: string;
quantity: number;
variant?: { image?: { url: string; altText?: string | null } | null } | null;
};
}>;
};
}
export interface Customer {
id: string;
email: string;
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
displayName: string;
acceptsMarketing?: boolean;
createdAt?: string;
defaultAddress?: CustomerAddress | null;
orders?: { edges: Array<{ node: CustomerOrder }> };
}
export interface AccessToken {
accessToken: string;
expiresAt: string;
}
/** Either a token (success) or the errors Shopify reported. */
export interface AuthResult {
token: AccessToken | null;
errors: CustomerUserError[];
}
const firstMessage = (errors: CustomerUserError[]) =>
errors[0]?.message ?? 'Something went wrong. Please try again.';
export { firstMessage as customerErrorMessage };
export async function createCustomer(input: {
email: string;
password: string;
firstName?: string;
lastName?: string;
acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> {
const response = await shopifyFetch({
query: CUSTOMER_CREATE_MUTATION,
variables: { input },
});
return { errors: response.data.customerCreate.customerUserErrors ?? [] };
}
export async function login(
email: string,
password: string
): Promise<AuthResult> {
const response = await shopifyFetch({
query: CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
variables: { input: { email, password } },
});
const result = response.data.customerAccessTokenCreate;
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function logout(accessToken: string): Promise<void> {
try {
await shopifyFetch({
query: CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
variables: { customerAccessToken: accessToken },
});
} catch (err) {
// The cookie is cleared regardless; a failed revoke shouldn't block logout.
console.error('Failed to revoke customer access token:', err);
}
}
// Always resolves without error detail: revealing whether an address exists
// would leak account membership.
export async function recoverPassword(email: string): Promise<void> {
try {
await shopifyFetch({
query: CUSTOMER_RECOVER_MUTATION,
variables: { email },
});
} catch (err) {
console.error('Password recovery request failed:', err);
}
}
export async function resetPassword(
id: string,
resetToken: string,
password: string
): Promise<AuthResult> {
const response = await shopifyFetch({
query: CUSTOMER_RESET_MUTATION,
variables: { id, input: { resetToken, password } },
});
const result = response.data.customerReset;
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function activateAccount(
id: string,
activationToken: string,
password: string
): Promise<AuthResult> {
const response = await shopifyFetch({
query: CUSTOMER_ACTIVATE_MUTATION,
variables: { id, input: { activationToken, password } },
});
const result = response.data.customerActivate;
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function getCustomer(
accessToken: string,
orderCount = 10
): Promise<Customer | null> {
try {
const response = await shopifyFetch({
query: CUSTOMER_QUERY,
variables: { customerAccessToken: accessToken, orderCount },
});
return response.data.customer ?? null;
} catch (err) {
// An expired or revoked token reads as "not signed in".
console.error('Failed to load customer:', err);
return null;
}
}
export async function updateCustomer(
accessToken: string,
customer: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const response = await shopifyFetch({
query: CUSTOMER_UPDATE_MUTATION,
variables: { customerAccessToken: accessToken, customer },
});
const result = response.data.customerUpdate;
return {
customer: result.customer ?? null,
errors: result.customerUserErrors ?? [],
};
}
// Shopify's emailed links carry a numeric id; the mutations want a GID.
export function toCustomerGid(id: string): string {
return id.startsWith('gid://') ? id : `gid://shopify/Customer/${id}`;
}
+31
View File
@@ -0,0 +1,31 @@
// Customer session, stored in an httpOnly cookie. Server-only: importing this
// from a client component will fail, which is deliberate — the access token
// must never reach the browser's JavaScript.
import { cookies } from 'next/headers';
import { CUSTOMER_TOKEN_COOKIE } from '@/services/shopify/customer';
export async function getSessionToken(): Promise<string | null> {
const store = await cookies();
return store.get(CUSTOMER_TOKEN_COOKIE)?.value ?? null;
}
export async function setSessionToken(
accessToken: string,
expiresAt: string
): Promise<void> {
const store = await cookies();
const expires = new Date(expiresAt);
store.set(CUSTOMER_TOKEN_COOKIE, accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: Number.isNaN(expires.getTime()) ? undefined : expires,
});
}
export async function clearSessionToken(): Promise<void> {
const store = await cookies();
store.delete(CUSTOMER_TOKEN_COOKIE);
}
+19 -1756
View File
File diff suppressed because it is too large Load Diff