Compare commits

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111qSr3KopyGRsJ6LznR1xZ
2026-07-31 22:12:19 -04:00
100 changed files with 1626 additions and 12193 deletions
-12
View File
@@ -1,12 +0,0 @@
# Shopify Storefront
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
# NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-10
# Store assistant — set to 1 to show the Ask launcher; anything else hides it
NEXT_PUBLIC_ENABLE_AI=0
# Store assistant (/api/chat) — https://openrouter.ai/keys
OPENROUTER_API_KEY=
# Optional; defaults to openai/gpt-5.6-luna-pro
# OPENROUTER_MODEL=
-3
View File
@@ -5,6 +5,3 @@ next-env.d.ts
.yarn/install-state.gz .yarn/install-state.gz
.env*.local .env*.local
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit
.hydrogen-gql-*.json
@@ -1,33 +0,0 @@
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Activate your account' };
// 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
@@ -1,38 +0,0 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Sign in' };
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
@@ -1,30 +0,0 @@
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' };
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
@@ -1,23 +0,0 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Reset password' };
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
@@ -1,43 +0,0 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Create account' };
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
@@ -1,32 +0,0 @@
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Set a new password' };
// 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
@@ -1,30 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,27 +0,0 @@
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 });
}
-207
View File
@@ -1,207 +0,0 @@
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { z } from 'zod';
import {
searchProducts,
getProduct,
getProductsPage,
getCollections,
getCollectionProductsPage,
isDefaultTitleOption,
isDefaultTitleSelection,
} from '@/services/shopify/catalog';
// Streaming needs the Node runtime here because the Storefront helpers run
// server-side on each tool call.
export const maxDuration = 30;
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
You help shoppers find products, compare options, and understand what the store
carries. You have tools that read live catalogue data — always use them rather
than guessing, and never invent products, prices, availability, or policies.
Guidelines:
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
something vague like "what do you have?", call listCollections or
searchCatalogue to ground your answer.
- Prices returned by the tools are in the store's currency; show them as given.
- Link products as /products/{handle} and collections as /collections/{handle}
so the shopper can click through.
- Keep replies short and conversational — a sentence or two plus a compact list.
Do not repeat the raw tool output; the interface already shows it.
- If a tool returns nothing, say so plainly and suggest a different search.
- You cannot place orders, change carts, process payments, or look up customer
or order data. Say so and point the shopper to the relevant page instead.`;
// Trim the Storefront payloads to what the model actually needs to answer.
const summariseProduct = (product: {
id: string;
title: string;
handle: string;
description?: string;
productType?: string;
tags?: string[];
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
variants?: {
edges: Array<{
node: {
title: string;
availableForSale: boolean;
selectedOptions?: Array<{ name: string; value: string }>;
};
}>;
};
images?: { edges: Array<{ node: { url: string } }> };
options?: Array<{ name: string; values: string[] }>;
}) => ({
title: product.title,
handle: product.handle,
url: `/products/${product.handle}`,
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
image: product.images?.edges[0]?.node.url ?? null,
description: product.description?.slice(0, 300) ?? null,
productType: product.productType || null,
tags: product.tags ?? [],
options: product.options
?.filter((option) => !isDefaultTitleOption(option))
.map((option) => ({
name: option.name,
values: option.values,
})),
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
});
export async function POST(req: Request) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
const { messages } = await req.json();
const openrouter = createOpenRouter({ apiKey });
const result = streamText({
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
// renders it via the Reasoning component.
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
// Let the model call a tool, read the result, then answer.
stopWhen: stepCountIs(5),
tools: {
searchCatalogue: tool({
description:
'Search the store for products matching a term. Use for any question about what the store sells.',
inputSchema: z.object({
query: z
.string()
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
limit: z.number().int().min(1).max(10).default(5),
}),
execute: async ({ query, limit }) => {
const { products, totalCount } = await searchProducts({
query,
first: limit,
});
return {
totalCount,
products: products.map(summariseProduct),
};
},
}),
getProductDetails: tool({
description:
'Get full details for one product by its handle, including options, variants, and stock.',
inputSchema: z.object({
handle: z
.string()
.describe('The product handle, e.g. "flowguard-jacket".'),
}),
execute: async ({ handle }) => {
const product = await getProduct(handle);
if (!product) return { found: false, handle };
return {
found: true,
...summariseProduct(product),
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
title: node.title,
available: node.availableForSale,
price: `${node.price.amount} ${node.price.currencyCode}`,
options: node.selectedOptions?.filter(
(option) => !isDefaultTitleSelection(option)
),
})),
};
},
}),
listCollections: tool({
description:
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
inputSchema: z.object({
limit: z.number().int().min(1).max(25).default(10),
}),
execute: async ({ limit }) => {
const collections = await getCollections(limit);
return {
collections: collections.map((collection) => ({
title: collection.title,
handle: collection.handle,
url: `/collections/${collection.handle}`,
description: collection.description?.slice(0, 200) ?? null,
})),
};
},
}),
getCollectionProducts: tool({
description:
'List the products inside one collection, by collection handle.',
inputSchema: z.object({
handle: z.string().describe('The collection handle, e.g. "men".'),
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ handle, limit }) => {
const page = await getCollectionProductsPage(handle, { first: limit });
if (!page.collection) return { found: false, handle };
return {
found: true,
collection: page.collection.title,
url: `/collections/${handle}`,
products: page.products.map(summariseProduct),
};
},
}),
browseProducts: tool({
description:
'Browse the newest products when the shopper has no specific search term.',
inputSchema: z.object({
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ limit }) => {
const page = await getProductsPage({
first: limit,
sortKey: 'CREATED_AT',
reverse: true,
});
return { products: page.products.map(summariseProduct) };
},
}),
},
});
// sendReasoning forwards reasoning parts to the client; without it the
// stream carries text and tool calls only.
return result.toUIMessageStreamResponse({ sendReasoning: true });
}
+19 -49
View File
@@ -1,53 +1,23 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import CollectionDetail from '@/components/shopify/collection-detail'; import CollectionDetail from '@/components/shopify/collection-detail';
import { getCollectionProductsPage } from '@/services/shopify/catalog';
import { truncate } from '@/lib/utils';
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await params;
// No collection-only query exists, so ask for the smallest page of products
// and use just the collection node off it.
const { collection } = await getCollectionProductsPage(handle, {
first: 1,
}).catch(() => ({ collection: null }));
if (!collection) return { title: 'Collection' };
const description = collection.description
? truncate(collection.description, 160)
: `Shop the ${collection.title} collection.`;
return {
title: collection.title,
description,
alternates: { canonical: `/collections/${collection.handle}` },
openGraph: {
title: collection.title,
description,
url: `/collections/${collection.handle}`,
images: collection.image
? [
{
url: collection.image.url,
alt: collection.image.altText ?? collection.title,
},
]
: undefined,
},
twitter: {
card: 'summary_large_image',
title: collection.title,
description,
images: collection.image ? [collection.image.url] : undefined,
},
};
}
export default function Page() { export default function Page() {
return <CollectionDetail />; return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<CollectionDetail />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
} }
+19 -7
View File
@@ -1,11 +1,23 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections'; import Collections from '@/components/shopify/collections';
export const metadata: Metadata = {
title: 'Collections',
description: 'Browse every collection in the store.',
};
export default function Page() { export default function Page() {
return <Collections title="Our Collections" />; return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
} }
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Error({
reset: () => void; reset: () => void;
}) { }) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-screen">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap"> <div className="text-lg font-medium text-black font-sans whitespace-nowrap">
Something went wrong Something went wrong
-55
View File
@@ -1,7 +1,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme { @theme {
/* Refined neutral modern palette */ /* Refined neutral modern palette */
--color-background: hsl(0 0% 100%); --color-background: hsl(0 0% 100%);
@@ -58,15 +56,6 @@
} }
/* Base styles for light, modern Shopify storefront */ /* Base styles for light, modern Shopify storefront */
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
components (e.g. Button's outline variant) rely on this base layer to pick up
the theme's border colour instead of the text colour. */
@layer base {
* {
border-color: var(--color-border);
}
}
body { body {
font-feature-settings: font-feature-settings:
'kern' 1, 'kern' 1,
@@ -105,17 +94,6 @@ h2,
display: none; display: none;
} }
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
.option-unavailable {
background-image: linear-gradient(
to top right,
transparent calc(50% - 0.5px),
currentColor calc(50% - 0.5px),
currentColor calc(50% + 0.5px),
transparent calc(50% + 0.5px)
);
}
/* Product description (HTML returned by the Storefront API) */ /* Product description (HTML returned by the Storefront API) */
.product-description p { .product-description p {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -195,36 +173,3 @@ button,
.price-display { .price-display {
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
} }
@theme inline {
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
--color-color-5: var(--color-5);
--color-color-4: var(--color-4);
--color-color-3: var(--color-3);
--color-color-2: var(--color-2);
--color-color-1: var(--color-1);
@keyframes rainbow {
0% {
background-position: 0%;
}
100% {
background-position: 200%;
}
}
}
:root {
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
.dark {
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
+4 -39
View File
@@ -1,11 +1,8 @@
'use client';
import React from 'react'; import React from 'react';
import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google'; import { Geist, Geist_Mono } from 'next/font/google';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import StoreAssistant from '@/components/shopify/store-assistant';
import { site } from '@/config/site';
const geist = Geist({ const geist = Geist({
subsets: ['latin'], subsets: ['latin'],
@@ -17,29 +14,6 @@ const geistMono = Geist_Mono({
variable: '--font-geist-mono', variable: '--font-geist-mono',
}); });
// Routes only set their own `title`; the template appends the store name, and
// everything else here is inherited unless a page overrides it.
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: {
default: site.storeName,
template: `%s — ${site.storeName}`,
},
description: site.description,
openGraph: {
type: 'website',
siteName: site.storeName,
title: site.storeName,
description: site.description,
url: '/',
},
twitter: {
card: 'summary_large_image',
title: site.storeName,
description: site.description,
},
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
@@ -47,17 +21,8 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}> <html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col"> <body className="font-body antialiased bg-background text-foreground m-0 p-0">
{/* Header owns the cart drawer, search and account menu, so every {children}
route gets them by rendering here rather than page by page. */}
<Header
storeName={site.storeName}
logoUrl={site.logoUrl}
links={site.navLinks}
/>
<div className="flex-1">{children}</div>
<Footer storeName={site.storeName} copyright={site.copyright} />
<StoreAssistant />
</body> </body>
</html> </html>
); );
+1 -1
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-screen">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap"> <div className="text-lg font-medium text-black font-sans whitespace-nowrap">
404 404
+20 -13
View File
@@ -1,19 +1,26 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Products from '@/components/shopify/products'; import Products from '@/components/shopify/products';
import { site } from '@/config/site';
// The home page keeps the bare store name, so `title.absolute` opts out of the
// root template rather than rendering "Shop — Shop".
export const metadata: Metadata = {
title: { absolute: site.storeName },
description: site.description,
};
export default function Page() { export default function Page() {
return ( return (
<Products <>
title="Shopify Hydrogen Storefront" <Header
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen." storeName="Shop"
/> logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<Products
title="Shopify Hydrogen Storefront"
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
); );
} }
+30 -17
View File
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies'; import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
import { site } from '@/config/site';
export function generateStaticParams() { export function generateStaticParams() {
return POLICY_HANDLES.map((handle) => ({ handle })); return POLICY_HANDLES.map((handle) => ({ handle }));
@@ -14,12 +15,8 @@ export async function generateMetadata({
const { handle } = await params; const { handle } = await params;
const policy = await getShopPolicy(handle); const policy = await getShopPolicy(handle);
if (!policy) return { title: 'Policy' };
return { return {
title: policy.title, title: policy ? `${policy.title} — Shop` : 'Policy — Shop',
description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`,
alternates: { canonical: `/policies/${policy.handle}` },
}; };
} }
@@ -36,17 +33,33 @@ export default async function PolicyPage({
} }
return ( return (
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16"> <>
<div className="max-w-2xl mx-auto"> <Header
<h1 className="text-3xl md:text-4xl font-normal text-foreground"> storeName="Shop"
{policy.title} logoUrl=""
</h1> links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<div <main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
className="policy-body mt-8 text-[15px] leading-7 text-foreground" <div className="max-w-2xl mx-auto">
dangerouslySetInnerHTML={{ __html: policy.body }} <h1 className="text-3xl md:text-4xl font-normal text-foreground">
/> {policy.title}
</div> </h1>
</main>
<div
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
dangerouslySetInnerHTML={{ __html: policy.body }}
/>
</div>
</main>
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
); );
} }
+14 -42
View File
@@ -1,53 +1,25 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import ProductDetail from '@/components/shopify/product-detail'; import ProductDetail from '@/components/shopify/product-detail';
import ProductRecommendations from '@/components/shopify/product-recommendations'; import ProductRecommendations from '@/components/shopify/product-recommendations';
import { getProduct } from '@/services/shopify/catalog';
import { truncate } from '@/lib/utils';
import { site } from '@/config/site';
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await params;
// ProductDetail fetches client-side and renders its own empty state, so a
// failed lookup here falls back to generic tags rather than a 500.
const product = await getProduct(handle).catch(() => null);
if (!product) return { title: 'Product' };
const description = product.description
? truncate(product.description, 160)
: site.description;
const image = product.images.edges[0]?.node;
return {
title: product.title,
description,
alternates: { canonical: `/products/${product.handle}` },
openGraph: {
title: product.title,
description,
url: `/products/${product.handle}`,
images: image
? [{ url: image.url, alt: image.altText ?? product.title }]
: undefined,
},
twitter: {
card: 'summary_large_image',
title: product.title,
description,
images: image ? [image.url] : undefined,
},
};
}
export default function Page() { export default function Page() {
return ( return (
<> <>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<ProductDetail addToCartLabel="Add to Cart" /> <ProductDetail addToCartLabel="Add to Cart" />
<ProductRecommendations title="You May Also Like" /> <ProductRecommendations title="You May Also Like" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</> </>
); );
} }
-16
View File
@@ -1,16 +0,0 @@
import { Suspense } from 'react';
import SearchResults from '@/components/shopify/search-results';
export const metadata = {
title: 'Search',
description: 'Search the catalogue by product name, description or type.',
};
export default function Page() {
return (
// useSearchParams needs a Suspense boundary to prerender this route.
<Suspense fallback={<div className="py-10" />}>
<SearchResults />
</Suspense>
);
}
+19 -9
View File
@@ -1,13 +1,23 @@
import type { Metadata } from 'next'; import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import Collections from '@/components/shopify/collections'; import Collections from '@/components/shopify/collections';
export const metadata: Metadata = {
title: 'Collections',
description: 'Browse every collection in the store.',
// Same listing as /collections; point crawlers at the canonical route.
alternates: { canonical: '/collections' },
};
export default function Page() { export default function Page() {
return <Collections title="Our Collections" />; return (
<>
<Header
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/shop/collections' },
]}
/>
<Collections title="Our Collections" />
<Footer
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
} }
-24
View File
@@ -1,24 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}"
}
}
-426
View File
@@ -1,426 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import type { FileUIPart, SourceDocumentUIPart } from "ai";
import {
FileTextIcon,
GlobeIcon,
ImageIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData =
| (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string });
export type AttachmentMediaCategory =
| "image"
| "video"
| "audio"
| "document"
| "source"
| "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (
data: AttachmentData
): AttachmentMediaCategory => {
if (data.type === "source-document") {
return "source";
}
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) {
return "image";
}
if (mediaType.startsWith("video/")) {
return "video";
}
if (mediaType.startsWith("audio/")) {
return "audio";
}
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
return "document";
}
return "unknown";
};
export const getAttachmentLabel = (data: AttachmentData): string => {
if (data.type === "source-document") {
return data.title || data.filename || "Source";
}
const category = getMediaCategory(data);
return data.filename || (category === "image" ? "Image" : "Attachment");
};
const renderAttachmentImage = (
url: string,
filename: string | undefined,
isGrid: boolean
) =>
isGrid ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
onRemove?: () => void;
variant: AttachmentVariant;
}
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const };
export const useAttachmentContext = () => {
const ctx = useContext(AttachmentContext);
if (!ctx) {
throw new Error("Attachment components must be used within <Attachment>");
}
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant]);
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
};
export const Attachment = ({
data,
onRemove,
className,
children,
...props
}: AttachmentProps) => {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant]
);
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode;
};
export const AttachmentPreview = ({
fallbackIcon,
className,
...props
}: AttachmentPreviewProps) => {
const { data, mediaCategory, variant } = useAttachmentContext();
const iconSize = variant === "inline" ? "size-3" : "size-4";
const renderIcon = (Icon: typeof ImageIcon) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
);
const renderContent = () => {
if (mediaCategory === "image" && data.type === "file" && data.url) {
return renderAttachmentImage(data.url, data.filename, variant === "grid");
}
if (mediaCategory === "video" && data.type === "file" && data.url) {
return <video className="size-full object-cover" muted src={data.url} />;
}
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className
)}
{...props}
>
{renderContent()}
</div>
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext();
const label = getAttachmentLabel(data);
if (variant === "grid") {
return null;
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">
{data.mediaType}
</span>
)}
</div>
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
},
[onRemove]
);
if (!onRemove) {
return null;
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className
)}
onClick={handleClick}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const AttachmentHoverCardTrigger = (
props: AttachmentHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type AttachmentHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent
align={align}
className={cn("w-auto p-2", className)}
{...props}
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
className,
children,
...props
}: AttachmentEmptyProps) => (
<div
className={cn(
"flex items-center justify-center p-4 text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? "No attachments"}
</div>
);
-168
View File
@@ -1,168 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
-295
View File
@@ -1,295 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Nodes } from "hast";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import type { Components } from "hast-util-to-jsx-runtime";
import type { ComponentProps, HTMLAttributes, MouseEvent } from "react";
import { Fragment, memo, useCallback, useMemo, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import remend from "remend";
import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
// is off by default) and rehype-sanitize is the second line of defence, mainly
// for its href protocol allow-list — that is what stops `javascript:` URLs.
// Relative hrefs carry no protocol, so storefront links pass through untouched.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows
// `type` and `disabled` but not `checked`, so every box would read unticked.
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
},
};
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
// bullet. Prices in italics are ordinary storefront copy, so settled messages
// render verbatim and only the in-flight one gets repaired.
const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig {
enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean;
}
type AnchorProps = ComponentProps<"a"> & {
linkSafety?: LinkSafetyConfig;
onUntrusted: (url: string) => void;
};
const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground";
// Route-relative hrefs are the storefront links the assistant emits. Deciding
// this from the string alone keeps the server and client passes identical;
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
const isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({
href,
linkSafety,
onUntrusted,
...props
}: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
if (linkSafety.onLinkCheck(href)) return;
event.preventDefault();
onUntrusted(href);
},
[href, linkSafety, onUntrusted]
);
// In-app destinations navigate client-side in the same tab; sending a shopper
// to a product page in a new tab would strand the conversation behind it.
if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />;
}
return (
<a
className={LINK_CLASS}
href={href}
onClick={handleClick}
rel="noreferrer"
target="_blank"
{...props}
/>
);
};
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = (
linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void
): Partial<Components> => ({
a: (props: ComponentProps<"a">) => (
<MarkdownLink {...props} linkSafety={linkSafety} onUntrusted={onUntrusted} />
),
blockquote: (props: ComponentProps<"blockquote">) => (
<blockquote
className="my-3 border-border border-l-2 pl-3 text-muted-foreground"
{...props}
/>
),
code: ({ className, ...props }: ComponentProps<"code">) => {
// Only fenced blocks carry a language class, and those already sit inside a
// <pre>, so they must not get the inline pill treatment.
const isBlock =
typeof className === "string" && className.includes("language-");
return (
<code
className={cn(
isBlock
? "font-mono text-xs"
: "rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
className
)}
{...props}
/>
);
},
em: (props: ComponentProps<"em">) => <em className="italic" {...props} />,
h1: (props: ComponentProps<"h1">) => (
<h1 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h2: (props: ComponentProps<"h2">) => (
<h2 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h3: (props: ComponentProps<"h3">) => (
<h3 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h4: (props: ComponentProps<"h4">) => (
<h4 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h5: (props: ComponentProps<"h5">) => (
<h5 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h6: (props: ComponentProps<"h6">) => (
<h6 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} />
),
// GFM task-list boxes are display only; readOnly silences React's warning
// about a `checked` input with no change handler.
input: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} />
),
img: ({ alt, ...props }: ComponentProps<"img">) => (
// biome-ignore lint/nursery/noImgElement: model output, not a known asset
<img alt={alt ?? ""} className="my-2 max-w-full rounded-md" {...props} />
),
li: (props: ComponentProps<"li">) => <li className="my-0.5" {...props} />,
ol: (props: ComponentProps<"ol">) => (
<ol className="my-2 list-decimal space-y-0.5 pl-5" {...props} />
),
p: (props: ComponentProps<"p">) => (
<p className="my-2 leading-relaxed" {...props} />
),
pre: (props: ComponentProps<"pre">) => (
<pre
className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs"
{...props}
/>
),
strong: (props: ComponentProps<"strong">) => (
<strong className="font-semibold" {...props} />
),
table: (props: ComponentProps<"table">) => (
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-left text-xs" {...props} />
</div>
),
td: (props: ComponentProps<"td">) => (
<td className="border border-border px-2 py-1" {...props} />
),
th: (props: ComponentProps<"th">) => (
<th
className="border border-border bg-muted px-2 py-1 font-medium"
{...props}
/>
),
ul: (props: ComponentProps<"ul">) => (
<ul className="my-2 list-disc space-y-0.5 pl-5" {...props} />
),
});
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean;
linkSafety?: LinkSafetyConfig;
};
export const Markdown = memo(
({
children,
className,
isAnimating,
linkSafety,
...props
}: MarkdownProps) => {
const [pending, setPending] = useState<string | null>(null);
const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => {
// While streaming, close syntax the model has not finished emitting so a
// partial `**bold` renders as bold rather than as literal asterisks.
const source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes;
return toJsxRuntime(tree, {
components: buildComponents(linkSafety, handleUntrusted),
Fragment,
jsx,
jsxs,
});
}, [children, isAnimating, linkSafety, handleUntrusted]);
return (
<div
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
>
{content}
<Dialog
onOpenChange={(open) => !open && setPending(null)}
open={pending !== null}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Leave this site?</DialogTitle>
<DialogDescription>
This link points somewhere outside the store. Continue only if
you trust it.
</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
{pending}
</p>
<DialogFooter>
<Button onClick={() => setPending(null)} variant="outline">
Cancel
</Button>
<Button
onClick={() => {
if (pending) {
window.open(pending, "_blank", "noopener,noreferrer");
}
setPending(null);
}}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
},
(prev, next) =>
prev.children === next.children && prev.isAnimating === next.isAnimating
);
Markdown.displayName = "Markdown";
-339
View File
@@ -1,339 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Markdown } from "./markdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Markdown>;
// Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown;
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
-224
View File
@@ -1,224 +0,0 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Markdown } from "./markdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ 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";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
-77
View File
@@ -1,77 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
-57
View File
@@ -1,57 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};
-49
View File
@@ -1,49 +0,0 @@
import React from 'react';
import Link from 'next/link';
interface LogoProps {
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
src?: string | null;
/** Wordmark text, and the image's alt text. */
storeName?: string;
/** Where the logo links to. Pass null to render it unwrapped. */
href?: string | null;
/** Sizing for the image — height only, so the aspect ratio is preserved. */
imageClassName?: string;
/** Sizing and weight for the wordmark fallback. */
textClassName?: string;
}
const Logo: React.FC<LogoProps> = ({
src,
storeName = 'Shop',
href = '/',
imageClassName = 'h-6',
textClassName = 'text-xl',
}) => {
const mark = src ? (
<img
src={src}
alt={storeName}
className={`w-auto object-contain ${imageClassName}`}
/>
) : (
<span
className={`font-medium tracking-tight text-foreground ${textClassName}`}
>
{storeName}
</span>
);
if (href === null) {
return <span className="flex items-center">{mark}</span>;
}
return (
<Link href={href} className="flex items-center">
{mark}
</Link>
);
};
export default Logo;
-142
View File
@@ -1,142 +0,0 @@
'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
@@ -1,112 +0,0 @@
'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 -14
View File
@@ -1,10 +1,9 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Image from 'next/image';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart'; import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -26,7 +25,6 @@ import {
EmptyDescription, EmptyDescription,
EmptyContent, EmptyContent,
} from '@/components/ui/empty'; } from '@/components/ui/empty';
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
const CartDrawer: React.FC = () => { const CartDrawer: React.FC = () => {
const isOpen = useCartStore((s) => s.isOpen); const isOpen = useCartStore((s) => s.isOpen);
@@ -104,10 +102,7 @@ const CartDrawer: React.FC = () => {
}; };
const getSelectedOptions = (item: (typeof items)[0]) => { const getSelectedOptions = (item: (typeof items)[0]) => {
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line. return item.merchandise.selectedOptions ?? [];
return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option)
);
}; };
return ( return (
@@ -131,11 +126,10 @@ const CartDrawer: React.FC = () => {
<Button <Button
onClick={closeCart} onClick={closeCart}
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
aria-label="Close cart" aria-label="Close cart"
className="rounded-full"
> >
<RiCloseLine className="size-5" /> <RiCloseLine size={20} />
</Button> </Button>
</div> </div>
</SheetHeader> </SheetHeader>
@@ -144,7 +138,7 @@ const CartDrawer: React.FC = () => {
<SheetBody className="px-5"> <SheetBody className="px-5">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader size={20} /> <Loader size={32} />
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Empty> <Empty>
@@ -172,11 +166,9 @@ const CartDrawer: React.FC = () => {
{/* Product Image */} {/* Product Image */}
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0"> <div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? ( {image ? (
<Image <img
src={image} src={image}
alt={item.merchandise.product.title} alt={item.merchandise.product.title}
width={64}
height={64}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
+5 -8
View File
@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
interface CollectionImage { interface CollectionImage {
url: string; url: string;
altText?: string | null; altText?: string;
} }
interface Collection { interface Collection {
@@ -12,7 +11,7 @@ interface Collection {
title: string; title: string;
handle: string; handle: string;
description?: string; description?: string;
image?: CollectionImage | null; image?: CollectionImage;
} }
interface CollectionCardProps { interface CollectionCardProps {
@@ -26,14 +25,12 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
className="group block h-full" className="group block h-full"
> >
{/* Collection Image */} {/* Collection Image */}
<div className="relative aspect-square overflow-hidden"> <div className="relative aspect-square overflow-hidden bg-white">
{collection.image ? ( {collection.image ? (
<Image <img
src={collection.image.url} src={collection.image.url}
alt={collection.image.altText || collection.title} alt={collection.image.altText || collection.title}
fill className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
sizes="(min-width: 1024px) 25vw, 50vw"
className="object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/> />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300"> <div className="absolute inset-0 flex items-center justify-center text-zinc-300">
+62 -180
View File
@@ -1,207 +1,89 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
import ProductCard from './product-card'; import ProductCard from './product-card';
import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
getCollectionProductsPage,
type CollectionSortKey,
} from '@/hooks/use-shopify-collections';
import type { Product } from '@/hooks/use-shopify-products';
const PAGE_SIZE = 24;
const GRID_CLASSES = const GRID_CLASSES =
'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12'; 'grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16';
interface SortOption { const CollectionTitle: React.FC<{ title: string }> = ({ title }) => (
label: string; <h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground mb-16">
sortKey: CollectionSortKey; {title}
reverse: boolean; </h2>
} );
const SORT_OPTIONS: SortOption[] = [
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
];
const CollectionDetail: React.FC = () => { const CollectionDetail: React.FC = () => {
const params = useParams(); const params = useParams();
const handle = params?.handle as string; const handle = params?.handle as string;
const [products, setProducts] = useState<Product[]>([]); const { collection, loading, error, refetch } = useCollectionProducts(handle);
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
const [title, setTitle] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0); // Format title from handle
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Fall back to the handle until the collection's real title arrives.
const formattedTitle = handle const formattedTitle = handle
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) ? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection'; : 'Collection';
useEffect(() => { if (loading) {
if (!handle) return; return (
let cancelled = false; <div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={formattedTitle} />
const run = async () => { {/* Loading Skeleton */}
try { <div className={GRID_CLASSES}>
setLoading(true); {Array.from({ length: 8 }).map((_, index) => (
setError(null); <div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
const page = await getCollectionProductsPage(handle, { <div className="pt-4 space-y-2">
first: PAGE_SIZE, <div className="h-4 bg-zinc-200 w-4/5"></div>
sortKey: sort.sortKey, <div className="h-4 bg-zinc-200 w-1/4"></div>
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
if (!page.collection) {
setError('Collection not found');
return;
}
setTitle(page.collection.title);
setProducts(page.products);
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
// Keep the facet list stable while a selection is active, so options
// don't disappear out from under the panel.
if (activeFilters.length === 0) setFilters(page.filters);
} catch (err) {
if (cancelled) return;
console.error('Error fetching collection products:', err);
setError(err instanceof Error ? err.message : 'Failed to load collection');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [handle, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
after: cursor,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
setProducts((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...page.products.filter((p) => !seen.has(p.id))];
});
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
} catch (err) {
console.error('Failed to load more products:', err);
} finally {
setLoadingMore(false);
}
};
return (
<section className="bg-background py-10">
<div className="max-w-screen-2xl mx-auto px-8">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{title || formattedTitle}
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={products.length > 0 ? products.length : null}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
<div className="mt-8">
{loading ? (
<div className={GRID_CLASSES}>
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 w-1/4 bg-zinc-200"></div>
</div>
</div> </div>
))}
</div>
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? (
<p className="text-sm text-muted-foreground">
{activeFilters.length > 0
? 'No products matched your filters.'
: "This collection doesn't have any products yet."}
</p>
) : (
<>
<div className={GRID_CLASSES}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div> </div>
))}
{hasNextPage && ( </div>
<div className="mt-16 flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</>
)}
</div> </div>
</div> </div>
);
}
<ProductFilters if (error) {
open={filtersOpen} return (
onOpenChange={setFiltersOpen} <div className="py-16 bg-background">
filters={filters} <div className="max-w-screen-2xl mx-auto px-8 text-center">
activeFilters={activeFilters} <CollectionTitle title={formattedTitle} />
onActiveFiltersChange={setActiveFilters} <p className="text-sm text-muted-foreground mb-6">{error}</p>
/> <Button onClick={() => refetch()} variant="outline">
</section> Try Again
</Button>
</div>
</div>
);
}
const products = collection?.products || [];
const title = collection?.title || formattedTitle;
return (
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={title} />
{products.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">
This collection doesn&apos;t have any products yet.
</p>
) : (
<div className={GRID_CLASSES}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
</div>
); );
}; };
+17 -33
View File
@@ -4,7 +4,6 @@ import {
RiTiktokLine, RiTiktokLine,
RiFacebookFill, RiFacebookFill,
} from '@remixicon/react'; } from '@remixicon/react';
import Logo from '@/components/logo';
export interface FooterLink { export interface FooterLink {
label: string; label: string;
@@ -13,7 +12,6 @@ export interface FooterLink {
interface FooterProps { interface FooterProps {
storeName?: string; storeName?: string;
logoUrl?: string;
copyright?: string; copyright?: string;
links?: FooterLink[]; links?: FooterLink[];
instagramUrl?: string; instagramUrl?: string;
@@ -23,7 +21,6 @@ interface FooterProps {
const Footer: React.FC<FooterProps> = ({ const Footer: React.FC<FooterProps> = ({
storeName = 'Shop', storeName = 'Shop',
logoUrl,
copyright, copyright,
links = [ links = [
{ label: 'Terms of Service', url: '/policies/terms-of-service' }, { label: 'Terms of Service', url: '/policies/terms-of-service' },
@@ -45,18 +42,11 @@ const Footer: React.FC<FooterProps> = ({
return ( return (
<footer className="bg-background"> <footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-10"> <div className="max-w-screen-2xl mx-auto px-8 py-10">
{/* Left-aligned: the bottom-right corner belongs to the floating <div className="flex flex-col sm:flex-row items-center justify-between gap-5">
assistant launcher. Links and socials sit on separate rows so the <div className="flex flex-wrap items-center justify-center sm:justify-start gap-x-5 gap-y-2">
icons aren't crammed onto the end of the link list. */} <p className="text-sm text-muted-foreground leading-5">
<div className="flex flex-col items-center gap-y-5 sm:items-start"> {copyright || `© ${storeName}. All rights reserved.`}
<Logo </p>
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) => ( {links.map((link) => (
<a <a
key={link.label} key={link.label}
@@ -66,25 +56,19 @@ const Footer: React.FC<FooterProps> = ({
{link.label} {link.label}
</a> </a>
))} ))}
</nav> </div>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:gap-x-5"> <div className="flex items-center gap-x-4">
<span className="flex items-center gap-x-4"> {socials.map(({ label, url, Icon }) => (
{socials.map(({ label, url, Icon }) => ( <a
<a key={label}
key={label} href={url}
href={url} aria-label={label}
aria-label={label} className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground hover:text-foreground transition-colors" >
> <Icon size={18} />
<Icon size={18} /> </a>
</a> ))}
))}
</span>
<p className="text-sm text-muted-foreground leading-5">
{copyright || `© ${storeName}. All rights reserved.`}
</p>
</div> </div>
</div> </div>
</div> </div>
+22 -47
View File
@@ -4,12 +4,8 @@ import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart'; import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer'; import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog';
import AccountMenu from '@/components/shopify/account-menu';
import ShopMenu from '@/components/shopify/shop-menu';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react'; import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Logo from '@/components/logo';
const CartIcon: React.FC = () => { const CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart); const toggleCart = useCartStore((s) => s.toggleCart);
@@ -23,9 +19,9 @@ const CartIcon: React.FC = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={`Open bag (${itemCount})`} aria-label={`Open bag (${itemCount})`}
className="relative rounded-full" className="relative"
> >
<RiShoppingBagLine className="size-5" /> <RiShoppingBagLine size={20} />
{itemCount > 0 && ( {itemCount > 0 && (
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center"> <span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center">
{itemCount > 99 ? '99+' : itemCount} {itemCount > 99 ? '99+' : itemCount}
@@ -44,57 +40,44 @@ interface HeaderProps {
storeName?: string; storeName?: string;
logoUrl?: string; logoUrl?: string;
links?: NavLink[]; links?: NavLink[];
/** Thin bar above the nav. Pass null to hide it. */
announcement?: string | null;
announcementUrl?: string;
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({
storeName = 'Logo', storeName = 'Shop',
logoUrl, logoUrl,
links = [ links = [
{ label: 'Shop', url: '/' }, { label: 'Shop', url: '/' },
{ label: 'Collections', url: '/collections' }, { label: 'Collections', url: '/collections' },
], ],
announcement = 'Free shipping on orders over $100',
announcementUrl,
}) => { }) => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
return ( return (
<> <nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
{/* Sits above the sticky nav, so it scrolls away on its own. */}
{announcement && (
<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
href={announcementUrl}
className="underline-offset-2 hover:underline"
>
{announcement}
</Link>
) : (
<span>{announcement}</span>
)}
</div>
</div>
)}
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-screen-2xl mx-auto px-8"> <div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14"> <div className="flex justify-between items-center h-14">
{/* Logo */} {/* Logo */}
<Logo src={logoUrl} storeName={storeName} /> <Link href="/" className="flex items-center">
{logoUrl ? (
<img
src={logoUrl}
alt={storeName}
className="h-6 w-auto object-contain"
/>
) : (
<span className="text-xl font-medium tracking-tight text-foreground">
{storeName}
</span>
)}
</Link>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-8 text-sm"> <div className="hidden md:flex items-center gap-x-8 text-sm">
<ShopMenu />
{links.map((link, index) => ( {links.map((link, index) => (
<Link <Link
key={index} key={index}
href={link.url} href={link.url}
className="text-foreground hover:text-muted-foreground transition-colors" 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"
> >
{link.label} {link.label}
</Link> </Link>
@@ -102,9 +85,7 @@ const Header: React.FC<HeaderProps> = ({
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center"> <div className="flex items-center gap-x-1">
<SearchDialog />
<AccountMenu />
<CartIcon /> <CartIcon />
{/* Mobile hamburger */} {/* Mobile hamburger */}
@@ -115,11 +96,7 @@ const Header: React.FC<HeaderProps> = ({
className="md:hidden" className="md:hidden"
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{menuOpen ? ( {menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
<RiCloseLine className="size-5" />
) : (
<RiMenu3Line className="size-5" />
)}
</Button> </Button>
</div> </div>
</div> </div>
@@ -129,7 +106,6 @@ const Header: React.FC<HeaderProps> = ({
{menuOpen && ( {menuOpen && (
<div className="md:hidden bg-background"> <div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm"> <div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
<ShopMenu mobile onNavigate={() => setMenuOpen(false)} />
{links.map((link, index) => ( {links.map((link, index) => (
<Link <Link
key={index} key={index}
@@ -144,9 +120,8 @@ const Header: React.FC<HeaderProps> = ({
</div> </div>
)} )}
<CartDrawer /> <CartDrawer />
</nav> </nav>
</>
); );
}; };
-169
View File
@@ -1,169 +0,0 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { RiImageLine } from '@remixicon/react';
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
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 ? (
<Image
src={node.variant.image.url}
alt={node.variant.image.altText || node.title}
width={64}
height={64}
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;
+4 -7
View File
@@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { truncate } from '@/lib/utils'; import { truncate } from '@/lib/utils';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string | null; altText?: string;
} }
interface ProductPrice { interface ProductPrice {
@@ -67,14 +66,12 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
className="group block h-full" className="group block h-full"
> >
{/* Product Image */} {/* Product Image */}
<div className="relative aspect-square overflow-hidden"> <div className="relative aspect-square overflow-hidden bg-white">
{firstImage ? ( {firstImage ? (
<Image <img
src={firstImage.url} src={firstImage.url}
alt={firstImage.altText || product.title} alt={firstImage.altText || product.title}
fill className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]"
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
className="object-contain transition-transform duration-500 group-hover:scale-[1.04]"
/> />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300"> <div className="absolute inset-0 flex items-center justify-center text-zinc-300">
+2 -21
View File
@@ -29,8 +29,8 @@ interface ProductVariant {
}>; }>;
image?: { image?: {
url: string; url: string;
altText?: string | null; altText?: string;
} | null; };
} }
export type { Product }; export type { Product };
@@ -78,24 +78,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
} }
}, [product]); }, [product]);
// A value is available if some in-stock variant carries it alongside the
// other currently-selected options. Options the shopper hasn't chosen yet
// act as wildcards, so nothing is struck through before a full selection.
const isOptionValueAvailable = (optionName: string, value: string) => {
const variants = product?.variants.edges ?? [];
if (variants.length === 0) return true;
return variants.some(({ node }) => {
if (!node.availableForSale) return false;
return node.selectedOptions.every((option) => {
if (option.name === optionName) return option.value === value;
const selected = selectedOptions[option.name];
return selected === undefined || selected === option.value;
});
});
};
const handleOptionChange = (optionName: string, value: string) => { const handleOptionChange = (optionName: string, value: string) => {
const newOptions = { ...selectedOptions, [optionName]: value }; const newOptions = { ...selectedOptions, [optionName]: value };
setSelectedOptions(newOptions); setSelectedOptions(newOptions);
@@ -215,7 +197,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
handleAddToCart={handleAddToCart} handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow} handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange} onOptionChange={handleOptionChange}
isOptionValueAvailable={isOptionValueAvailable}
loading={addingToCart} loading={addingToCart}
buyingNow={buyingNow} buyingNow={buyingNow}
addToCartLabel={addToCartLabel} addToCartLabel={addToCartLabel}
@@ -1,15 +1,12 @@
'use client'; 'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { RiCloseLine } from '@remixicon/react'; import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string | null; altText?: string;
width?: number | null;
height?: number | null;
} }
interface ProductDetailGalleryProps { interface ProductDetailGalleryProps {
@@ -47,37 +44,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
setActiveIndex(nearest); setActiveIndex(nearest);
}, []); }, []);
// Touch already scrolls natively; this adds click-and-drag for pointers that
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'touch') return;
const scroller = scrollerRef.current;
if (!scroller) return;
drag.current = { startX: event.clientX, startScroll: scroller.scrollLeft };
scroller.style.scrollSnapType = 'none';
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
event.preventDefault();
scroller.scrollLeft =
drag.current.startScroll - (event.clientX - drag.current.startX);
};
const endDrag = () => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
drag.current = null;
// Restoring snap lets the browser settle on the nearest slide.
scroller.style.scrollSnapType = '';
};
const scrollToIndex = (index: number) => { const scrollToIndex = (index: number) => {
const scroller = scrollerRef.current; const scroller = scrollerRef.current;
const slide = scroller?.children[index]; const slide = scroller?.children[index];
@@ -123,30 +89,21 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
<div <div
ref={scrollerRef} ref={scrollerRef}
onScroll={handleScroll} onScroll={handleScroll}
onPointerDown={onPointerDown} className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar sm:grid sm:grid-cols-2 sm:overflow-visible"
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerCancel={endDrag}
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar touch-pan-x sm:grid sm:grid-cols-2 sm:touch-auto sm:overflow-visible"
> >
{images.map((image, index) => ( {images.map((image, index) => (
<button <button
key={index} key={index}
onClick={() => setZoomedIndex(index)} onClick={() => setZoomedIndex(index)}
aria-label={`Zoom ${image.altText || 'product image'}`} aria-label={`Zoom ${image.altText || 'product image'}`}
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 ${ 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 ${
isSingle ? 'sm:col-span-2' : '' isSingle ? 'sm:col-span-2' : ''
}`} }`}
> >
<Image <img
src={image.url} src={image.url}
alt={image.altText || 'Product image'} alt={image.altText || 'Product image'}
fill className="w-full h-full object-cover"
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
priority={index === 0}
draggable={false}
className="object-cover select-none"
/> />
</button> </button>
))} ))}
@@ -179,19 +136,11 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
onClick={close} onClick={close}
className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10" className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10"
> >
<Image <img
src={zoomedImage.url} src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'} alt={zoomedImage.altText || 'Product image'}
// Shopify gives us the intrinsic size; the square fallback only
// reserves space until CSS scales it down to fit the overlay.
width={zoomedImage.width || 1600}
height={zoomedImage.height || 1600}
sizes="100vw"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
// w/h-auto keeps the box at the image's own ratio; without it the className="max-h-full max-w-full object-contain bg-white"
// width+height attributes make both axes definite and the element
// stretches to the overlay, swallowing backdrop clicks that close it.
className="max-h-full max-w-full w-auto h-auto object-contain"
/> />
<Button <Button
@@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { RiSubtractLine, RiAddLine } from '@remixicon/react'; import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayButton from '@/components/shopify/shop-pay-button'; import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products'; import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches'; import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
import { isDefaultTitleOption } from '@/services/shopify/catalog';
interface ProductPrice { interface ProductPrice {
amount: string; amount: string;
@@ -52,8 +51,6 @@ interface ProductDetailInfoProps {
handleAddToCart: () => void; handleAddToCart: () => void;
handleBuyNow?: () => void; handleBuyNow?: () => void;
onOptionChange: (optionName: string, value: string) => void; onOptionChange: (optionName: string, value: string) => void;
/** Whether an option value still has an in-stock variant behind it. */
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
loading?: boolean; loading?: boolean;
buyingNow?: boolean; buyingNow?: boolean;
addToCartLabel?: string; addToCartLabel?: string;
@@ -84,7 +81,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
handleAddToCart, handleAddToCart,
handleBuyNow, handleBuyNow,
onOptionChange, onOptionChange,
isOptionValueAvailable,
loading = false, loading = false,
buyingNow = false, buyingNow = false,
addToCartLabel = 'Add to Cart', addToCartLabel = 'Add to Cart',
@@ -104,10 +100,9 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
isSwatchOptionName(option.name); isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way. // Some products return Size before Color; show the swatches first either way.
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it. const orderedOptions = [...(product.options ?? [])].sort(
const orderedOptions = [...(product.options ?? [])] (a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))
.filter((option) => !isDefaultTitleOption(option)) );
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
// `optionValues` carries the swatch data; fall back to plain `values`. // `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] => const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
@@ -148,8 +143,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{optionValuesFor(option).map((value) => { {optionValuesFor(option).map((value) => {
const isSelected = selected === value.name; const isSelected = selected === value.name;
const isSoldOut =
isOptionValueAvailable?.(option.name, value.name) === false;
if (isSwatch) { if (isSwatch) {
const { background, image } = swatchStyle(value); const { background, image } = swatchStyle(value);
@@ -160,34 +153,21 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onClick={() => onOptionChange(option.name, value.name)} onClick={() => onOptionChange(option.name, value.name)}
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
title={value.name}
aria-label={value.name} aria-label={value.name}
aria-pressed={isSelected} aria-pressed={isSelected}
title={
isSoldOut ? `${value.name} — out of stock` : value.name
}
data-available={!isSoldOut}
className={`rounded-full bg-cover bg-center hover:bg-transparent ${ className={`rounded-full bg-cover bg-center hover:bg-transparent ${
isSelected isSelected
? 'ring-2 ring-foreground ring-offset-2' ? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40' : 'ring-1 ring-border hover:ring-foreground/40'
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`} }`}
style={{ style={{
// With no colour and no image the circle would be fully backgroundColor: background,
// transparent, leaving just a hairline ring that
// antialiases unevenly and reads as a speckled border.
// A neutral fill makes the initial-letter fallback look
// deliberate. Set inline so it beats the ghost variant's
// hover background.
backgroundColor:
background ??
(image ? undefined : 'var(--color-muted)'),
backgroundImage: image ? `url(${image})` : undefined, backgroundImage: image ? `url(${image})` : undefined,
}} }}
> >
{!background && !image && ( {!background && !image && (
<span className="text-[10px] font-medium uppercase text-muted-foreground"> <span className="text-[10px]">{value.name.at(0)}</span>
{value.name.at(0)}
</span>
)} )}
</Button> </Button>
); );
@@ -199,12 +179,11 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onClick={() => onOptionChange(option.name, value.name)} onClick={() => onOptionChange(option.name, value.name)}
variant="outline" variant="outline"
aria-pressed={isSelected} aria-pressed={isSelected}
title={isSoldOut ? `${value.name} — out of stock` : undefined}
className={`min-w-14 px-5 font-normal shadow-none ${ className={`min-w-14 px-5 font-normal shadow-none ${
isSelected isSelected
? 'border-foreground text-foreground' ? 'border-foreground text-foreground'
: 'text-muted-foreground hover:border-foreground hover:text-foreground' : 'text-muted-foreground hover:border-foreground hover:text-foreground'
} ${isSoldOut ? 'option-unavailable text-muted-foreground/60' : ''}`} }`}
> >
{value.name} {value.name}
</Button> </Button>
-258
View File
@@ -1,258 +0,0 @@
'use client';
import React, { useState } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
import { swatchColorForName } from '@/config/swatches';
// Shape of a Storefront facet — identical for `search.productFilters` and
// `collection.products.filters`, so both pages share this panel.
export interface ProductFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: ProductFilterValue[];
}
interface ProductFiltersProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: ProductFilterFacet[];
activeFilters: string[];
onActiveFiltersChange: (filters: string[]) => void;
}
// Colour facets render as swatches; everything else as a labelled list.
const isColorFilter = (filter: ProductFilterFacet) =>
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
const ProductFilters: React.FC<ProductFiltersProps> = ({
open,
onOpenChange,
filters,
activeFilters,
onActiveFiltersChange,
}) => {
const [priceMin, setPriceMin] = useState('');
const [priceMax, setPriceMax] = useState('');
const listFilters = filters.filter((filter) => filter.type === 'LIST');
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
const activeSet = new Set(activeFilters);
// Price is rebuilt from the inputs rather than toggled, so track it apart.
const activePriceInput = activeFilters.find((input) =>
input.includes('"price"')
);
const toggleValue = (value: ProductFilterValue) => {
onActiveFiltersChange(
activeSet.has(value.input)
? activeFilters.filter((input) => input !== value.input)
: [...activeFilters, value.input]
);
};
const applyPrice = () => {
const min = parseFloat(priceMin);
const max = parseFloat(priceMax);
const withoutPrice = activeFilters.filter(
(input) => !input.includes('"price"')
);
if (Number.isNaN(min) && Number.isNaN(max)) {
onActiveFiltersChange(withoutPrice);
return;
}
const price: { min?: number; max?: number } = {};
if (!Number.isNaN(min)) price.min = min;
if (!Number.isNaN(max)) price.max = max;
onActiveFiltersChange([...withoutPrice, JSON.stringify({ price })]);
};
const clearAll = () => {
setPriceMin('');
setPriceMax('');
onActiveFiltersChange([]);
};
// Labels for the chips at the top of the panel.
const activeChips = filters
.flatMap((filter) => filter.values)
.filter((value) => activeSet.has(value.input));
return (
<Sheet open={open} onOpenChange={onOpenChange} side="left">
<AnimatePresence>
{open && (
<SheetContent className="w-full max-w-sm" showCloseButton={false}>
<SheetHeader className="min-h-0 border-b-0 px-5 py-4">
<div className="flex w-full items-center justify-between">
<SheetTitle className="text-lg font-medium">Filters</SheetTitle>
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
size="icon-sm"
aria-label="Close filters"
>
<RiCloseLine size={20} />
</Button>
</div>
</SheetHeader>
<SheetBody className="px-5">
{/* Active selections */}
{(activeChips.length > 0 || activePriceInput) && (
<div className="mb-6 flex flex-wrap items-center gap-2">
{activeChips.map((value) => (
<button
key={value.id}
onClick={() => toggleValue(value)}
className="flex items-center gap-x-1 rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
>
{value.label}
<RiCloseLine size={12} />
</button>
))}
<Button
onClick={clearAll}
variant="link"
className="h-auto px-0 text-xs font-normal text-muted-foreground"
>
Clear all
</Button>
</div>
)}
{filters.length === 0 && (
<p className="text-sm text-muted-foreground">
No filters available for these results.
</p>
)}
{/* Price */}
{priceFilter && (
<div className="mb-8">
<h3 className="mb-3 text-base text-foreground">Price</h3>
<div className="flex items-center gap-x-2">
<input
type="number"
inputMode="decimal"
value={priceMin}
onChange={(event) => setPriceMin(event.target.value)}
placeholder="$ From"
aria-label="Minimum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<span className="text-muted-foreground">–</span>
<input
type="number"
inputMode="decimal"
value={priceMax}
onChange={(event) => setPriceMax(event.target.value)}
placeholder="$ To"
aria-label="Maximum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<Button
onClick={applyPrice}
size="icon"
aria-label="Apply price range"
className="shrink-0"
>
<RiCheckLine size={16} />
</Button>
</div>
</div>
)}
{/* Facets */}
{listFilters.map((filter) => (
<div key={filter.id} className="mb-8">
<h3 className="mb-3 text-base text-foreground">
{filter.label}
</h3>
{isColorFilter(filter) ? (
<div className="flex flex-wrap gap-2">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
const color = swatchColorForName(value.label);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
title={`${value.label} (${value.count})`}
aria-label={value.label}
aria-pressed={isActive}
className={`h-8 w-8 rounded-full transition-shadow ${
isActive
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
style={{
// A colourless swatch would otherwise be a fully
// transparent circle behind a hairline ring,
// which antialiases into a speckled border.
backgroundColor: color ?? 'var(--color-muted)',
}}
>
{!color && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.label.at(0)}
</span>
)}
</button>
);
})}
</div>
) : (
<div className="flex flex-col">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
aria-pressed={isActive}
className="flex items-center justify-between py-1.5 text-left text-sm text-foreground hover:text-muted-foreground"
>
<span>
{value.label} ({value.count})
</span>
{isActive && <RiCheckLine size={16} />}
</button>
);
})}
</div>
)}
</div>
))}
</SheetBody>
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
export default ProductFilters;
-100
View File
@@ -1,100 +0,0 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
RiEqualizerLine,
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react';
export interface ToolbarSortOption {
label: string;
}
interface ProductToolbarProps {
totalCount?: number | null;
onOpenFilters: () => void;
sortOptions: ToolbarSortOption[];
sortIndex: number;
onSortChange: (index: number) => void;
activeFilterCount?: number;
}
// Filters trigger on the left, item count and sort menu on the right — shared
// by the search results and collection pages.
const ProductToolbar: React.FC<ProductToolbarProps> = ({
totalCount,
onOpenFilters,
sortOptions,
sortIndex,
onSortChange,
activeFilterCount = 0,
}) => {
const [sortOpen, setSortOpen] = useState(false);
return (
<div className="flex items-center justify-between">
<Button
onClick={onOpenFilters}
variant="ghost"
className="gap-x-2 px-0 font-normal hover:bg-transparent"
>
<RiEqualizerLine size={18} />
Filters
{activeFilterCount > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{activeFilterCount}
</span>
)}
</Button>
<div className="flex items-center gap-x-4">
{typeof totalCount === 'number' && (
<span className="text-sm text-muted-foreground tabular-nums">
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
</span>
)}
<div className="relative">
<Button
onClick={() => setSortOpen((prev) => !prev)}
variant="ghost"
aria-expanded={sortOpen}
className="gap-x-1 px-0 font-normal hover:bg-transparent"
>
Sort
<RiArrowDownSLine size={16} />
</Button>
{sortOpen && (
<>
{/* Click-away layer sits under the menu, above the page. */}
<div
className="fixed inset-0 z-40"
onClick={() => setSortOpen(false)}
/>
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
{sortOptions.map((option, index) => (
<button
key={option.label}
onClick={() => {
onSortChange(index);
setSortOpen(false);
}}
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
{option.label}
{index === sortIndex && <RiCheckLine size={16} />}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};
export default ProductToolbar;
+3 -3
View File
@@ -4,11 +4,11 @@ import React, { useState, useEffect } from 'react';
import ProductCard from './product-card'; import ProductCard from './product-card';
import { getProductsPage } from '@/hooks/use-shopify-products'; import { getProductsPage } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string | null; altText?: string;
} }
interface ProductPrice { interface ProductPrice {
@@ -172,7 +172,7 @@ const Products: React.FC<ProductsProps> = ({
} }
return ( return (
<div className="py-20"> <div className="py-20 bg-white">
<div className="max-w-screen-2xl mx-auto px-8"> <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"> <h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title} {title}
-209
View File
@@ -1,209 +0,0 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
import {
searchSuggestions,
type SearchSuggestion,
} from '@/hooks/use-shopify-search';
const DEBOUNCE_MS = 250;
const SUGGESTION_COUNT = 3;
const formatPrice = (amount: string) => `$${parseFloat(amount).toFixed(2)}`;
const SearchDialog: React.FC = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState('');
const [results, setResults] = useState<SearchSuggestion[]>([]);
const [searching, setSearching] = useState(false);
// Guards against a slow early request overwriting a newer one's results.
const requestId = useRef(0);
useEffect(() => {
const query = term.trim();
if (!query) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
const id = ++requestId.current;
const timer = setTimeout(async () => {
try {
const { products } = await searchSuggestions(query, SUGGESTION_COUNT);
if (id !== requestId.current) return;
setResults(products);
} catch (err) {
console.error('Search failed:', err);
if (id === requestId.current) setResults([]);
} finally {
if (id === requestId.current) setSearching(false);
}
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [term]);
const close = () => {
setOpen(false);
setTerm('');
setResults([]);
};
const goToSearchPage = () => {
const query = term.trim();
close();
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search');
};
const goToProduct = (handle: string) => {
close();
router.push(`/products/${handle}`);
};
const hasQuery = Boolean(term.trim());
return (
<>
<Button
onClick={() => setOpen(true)}
variant="ghost"
size="icon"
aria-label="Search"
className="rounded-full"
>
<RiSearchLine className="size-5" />
</Button>
<CommandDialog
open={open}
onOpenChange={(next) => !next && close()}
title="Search"
description="Search products"
showCloseButton={false}
className="top-24 max-w-xl translate-y-0"
// Results come from the Storefront API, so cmdk must not re-filter them.
shouldFilter={false}
>
<div className="relative">
<CommandInput
value={term}
onValueChange={setTerm}
placeholder="Search products"
className="pr-28"
onKeyDown={(event) => {
if (event.key === 'Enter') goToSearchPage();
}}
/>
<div className="absolute right-2 top-0 flex h-12 items-center gap-1">
{hasQuery && (
<Button
onClick={() => setTerm('')}
variant="ghost"
size="sm"
className="font-normal text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
<Button
onClick={close}
variant="ghost"
size="icon-sm"
aria-label="Close search"
>
<RiCloseLine className="size-4" />
</Button>
</div>
</div>
{hasQuery && (
<>
<div className="px-3 pt-3">
<button
onClick={goToSearchPage}
className="rounded-full border border-border px-3 py-1 text-sm text-foreground transition-colors hover:border-foreground"
>
{term.trim()}
</button>
</div>
<CommandList className="max-h-80">
{searching && results.length === 0 ? (
<div className="flex justify-center py-8">
<Loader size={20} />
</div>
) : results.length === 0 ? (
<CommandEmpty>No products found.</CommandEmpty>
) : (
<CommandGroup heading="Products">
{results.map((product) => (
<CommandItem
key={product.id}
value={product.handle}
onSelect={() => goToProduct(product.handle)}
className="gap-3 py-2"
>
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
{product.featuredImage ? (
<Image
src={product.featuredImage.url}
alt={product.featuredImage.altText || product.title}
width={56}
height={56}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine className="size-4" />
</div>
)}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">
{product.title}
</div>
<div className="font-mono text-sm tabular-nums tracking-tight text-foreground">
{formatPrice(
product.priceRange.minVariantPrice.amount
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
{results.length > 0 && (
<div className="flex justify-center px-4 pb-5 pt-2">
<Button onClick={goToSearchPage} className="px-8">
View All
</Button>
</div>
)}
</>
)}
</CommandDialog>
</>
);
};
export default SearchDialog;
-197
View File
@@ -1,197 +0,0 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import ProductCard from './product-card';
import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
searchProducts,
type SearchFilter,
type SearchSortKey,
} from '@/hooks/use-shopify-search';
import type { Product } from '@/hooks/use-shopify-products';
const PAGE_SIZE = 24;
interface SortOption {
label: string;
sortKey: SearchSortKey;
reverse: boolean;
}
const SORT_OPTIONS: SortOption[] = [
{ label: 'Best Matches', sortKey: 'RELEVANCE', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
];
const SearchResults: React.FC = () => {
const searchParams = useSearchParams();
const query = searchParams.get('q') ?? '';
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<SearchFilter[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
// Serialised so the effect re-runs when the selection changes, not the array.
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
setLoading(true);
setError(null);
const result = await searchProducts({
// An empty term still returns the catalogue, which is what an
// unqualified /search visit should show.
query,
first: PAGE_SIZE,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
setProducts(result.products);
setTotalCount(result.totalCount);
setCursor(result.endCursor);
setHasNextPage(result.hasNextPage);
// Facet counts change with the result set, but keep the panel stable
// while filters are applied so options don't vanish mid-selection.
if (activeFilters.length === 0) setFilters(result.filters);
} catch (err) {
if (cancelled) return;
console.error('Search failed:', err);
setError(err instanceof Error ? err.message : 'Search failed');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [query, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const result = await searchProducts({
query,
first: PAGE_SIZE,
after: cursor,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
setProducts((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...result.products.filter((p) => !seen.has(p.id))];
});
setCursor(result.endCursor);
setHasNextPage(result.hasNextPage);
} catch (err) {
console.error('Failed to load more results:', err);
} finally {
setLoadingMore(false);
}
};
return (
<section className="bg-background py-10">
<div className="max-w-screen-2xl mx-auto px-8">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
Search
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={totalCount}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
{/* Results */}
<div className="mt-8">
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 w-1/4 bg-zinc-200"></div>
</div>
</div>
))}
</div>
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? (
<p className="text-sm text-muted-foreground">
No products matched{query ? ` “${query}”` : ' your filters'}.
</p>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</>
)}
</div>
</div>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
);
};
export default SearchResults;
-152
View File
@@ -1,152 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { RiArrowDownSLine } from '@remixicon/react';
import { useCollectionsOnDemand } from '@/hooks/use-shopify-collections';
import { cn } from '@/lib/utils';
interface ShopMenuProps {
label?: string;
/** Renders inline inside the mobile menu instead of as a floating panel. */
mobile?: boolean;
/** Fires after a collection is picked, so the mobile menu can close itself. */
onNavigate?: () => void;
}
const ShopMenu: React.FC<ShopMenuProps> = ({
label = 'Shop',
mobile = false,
onNavigate,
}) => {
const [open, setOpen] = useState(false);
const { collections, loading, error, load } = useCollectionsOnDemand();
// The collection list is only worth fetching once someone opens the menu.
const toggle = () => {
const next = !open;
setOpen(next);
if (next) load();
};
const close = () => {
setOpen(false);
onNavigate?.();
};
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open]);
const itemClasses = cn(
'block text-sm text-foreground hover:bg-accent transition-colors',
mobile ? 'px-3 py-2' : 'px-4 py-2'
);
const body = (
<>
{loading &&
Array.from({ length: 5 }).map((_, index) => (
<div key={index} className={cn(itemClasses, 'py-2.5')}>
<div className="h-3 w-2/3 animate-pulse bg-muted" />
</div>
))}
{error && (
<div className={cn(itemClasses, 'hover:bg-transparent')}>
<p className="text-muted-foreground">{error}</p>
<button
onClick={load}
className="mt-1 underline underline-offset-2 hover:text-muted-foreground"
>
Try again
</button>
</div>
)}
{!loading && !error && collections.length === 0 && (
<p className={cn(itemClasses, 'text-muted-foreground hover:bg-transparent')}>
No collections yet.
</p>
)}
{collections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.handle}`}
onClick={close}
className={itemClasses}
>
{collection.title}
</Link>
))}
{collections.length > 0 && (
<>
<div className="my-1 h-px bg-border" />
<Link
href="/collections"
onClick={close}
className={cn(itemClasses, 'text-muted-foreground')}
>
View all collections
</Link>
</>
)}
</>
);
const trigger = (
<button
onClick={toggle}
aria-expanded={open}
className={cn(
'flex items-center gap-x-1 text-foreground hover:text-muted-foreground transition-colors',
mobile && 'w-full justify-between'
)}
>
<span>{label}</span>
<RiArrowDownSLine
className={cn('size-4 transition-transform', open && 'rotate-180')}
/>
</button>
);
if (mobile) {
return (
<div>
{trigger}
{open && (
<div className="mt-2 flex flex-col border-l border-border pl-1">
{body}
</div>
)}
</div>
);
}
return (
<div className="relative">
{trigger}
{open && (
<>
{/* Catches the click that dismisses the panel. */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
{body}
</div>
</>
)}
</div>
);
};
export default ShopMenu;
+2 -2
View File
@@ -1,9 +1,9 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/config'; import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client';
import ShopPayLogo from '@/components/shopify/shop-pay-logo'; import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export interface ShopPayVariant { export interface ShopPayVariant {
-574
View File
@@ -1,574 +0,0 @@
'use client';
import React, { memo, useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputBody,
PromptInputFooter,
PromptInputProvider,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputAttachments,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Attachment,
AttachmentHoverCard,
AttachmentHoverCardContent,
AttachmentHoverCardTrigger,
AttachmentInfo,
AttachmentPreview,
AttachmentRemove,
Attachments,
getAttachmentLabel,
getMediaCategory,
type AttachmentData,
} from '@/components/ai-elements/attachments';
import {
Suggestions,
Suggestion,
} from '@/components/ai-elements/suggestion';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { Button } from '@/components/ui/button';
import { RainbowButton } from '@/components/ui/rainbow-button';
import {
RiCloseLine,
RiSearchLine,
RiPriceTag3Line,
RiStore2Line,
RiLayoutGridLine,
RiShoppingBag3Line,
} from '@remixicon/react';
// Feature flag. Written as a static member expression so Next inlines it at
// build time; the assistant is off unless the env var is explicitly "1".
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
const SUGGESTIONS = [
'What do you sell?',
'Show me hoodies under $100',
'What collections are there?',
];
interface ToolProduct {
handle: string;
title: string;
image: string | null;
price?: string;
}
interface ToolSummary {
label: string;
icon: React.ReactNode;
products: ToolProduct[];
}
const LOADING_LABELS: Record<string, string> = {
searchCatalogue: 'Searching the catalogue',
getProductDetails: 'Reading product details',
listCollections: 'Listing collections',
getCollectionProducts: 'Browsing a collection',
browseProducts: 'Browsing new arrivals',
};
const toolName = (type: string) =>
type.startsWith('tool-') ? type.slice(5) : type;
const plural = (count: number, noun: string) =>
`${count} ${noun}${count === 1 ? '' : 's'}`;
// Turns a finished tool result into the one-line summary plus any products
// worth previewing.
const summariseTool = (
name: string,
output: Record<string, unknown> | undefined
): ToolSummary => {
const products = (output?.products as ToolProduct[] | undefined) ?? [];
switch (name) {
case 'searchCatalogue': {
const total = (output?.totalCount as number) ?? products.length;
return {
label: `Found ${plural(total, 'product')}`,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
case 'getProductDetails':
return {
label: output?.found
? `Read ${output.title as string}`
: 'Product not found',
icon: <RiPriceTag3Line className="size-3.5" />,
products: output?.found
? [
{
handle: output.handle as string,
title: output.title as string,
image: (output.image as string) ?? null,
},
]
: [],
};
case 'listCollections': {
const collections =
(output?.collections as Array<unknown> | undefined) ?? [];
return {
label: `Found ${plural(collections.length, 'collection')}`,
icon: <RiStore2Line className="size-3.5" />,
products: [],
};
}
case 'getCollectionProducts':
return {
label: output?.found
? `Found ${plural(products.length, 'product')} in ${output.collection}`
: 'Collection not found',
icon: <RiLayoutGridLine className="size-3.5" />,
products,
};
case 'browseProducts':
return {
label: `Browsed ${plural(products.length, 'new arrival')}`,
icon: <RiShoppingBag3Line className="size-3.5" />,
products,
};
default:
return {
label: name,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
};
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
const isInternalLink = (url: string) => {
if (url.startsWith('/')) return true;
try {
return new URL(url, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
};
const ProductPreviews: React.FC<{ products: ToolProduct[] }> = ({
products,
}) => {
const withImages = products.filter((product) => product.image);
if (withImages.length === 0) return null;
return (
<Attachments variant="grid" className="ml-0 mt-2">
{withImages.slice(0, 6).map((product) => (
<Link
key={product.handle}
href={`/products/${product.handle}`}
title={product.title}
className="group/product w-20"
>
<Attachment
data={{
id: product.handle,
type: 'file',
url: product.image as string,
mediaType: 'image/jpeg',
filename: product.title,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
<span className="mt-1 line-clamp-2 block text-[11px] leading-tight text-muted-foreground group-hover/product:text-foreground">
{product.title}
</span>
</Link>
))}
</Attachments>
);
};
interface AttachmentItemProps {
attachment: AttachmentData;
onRemove: (id: string) => void;
}
const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
const handleRemove = useCallback(
() => onRemove(attachment.id),
[onRemove, attachment.id]
);
const mediaCategory = getMediaCategory(attachment);
const label = getAttachmentLabel(attachment);
return (
<AttachmentHoverCard key={attachment.id}>
<AttachmentHoverCardTrigger asChild>
<Attachment data={attachment} onRemove={handleRemove}>
{/* Thumbnail swaps to the remove button on hover. */}
<div className="group relative size-5 shrink-0">
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
<AttachmentPreview />
</div>
<AttachmentRemove className="absolute inset-0" />
</div>
<AttachmentInfo />
</Attachment>
</AttachmentHoverCardTrigger>
<AttachmentHoverCardContent>
<div className="space-y-2">
{mediaCategory === 'image' &&
attachment.type === 'file' &&
attachment.url && (
<div className="flex max-h-96 w-80 items-center justify-center overflow-hidden rounded-md border">
<img
alt={label}
className="max-h-full max-w-full object-contain"
height={384}
src={attachment.url}
width={320}
/>
</div>
)}
<div className="space-y-1 px-0.5">
<h4 className="text-sm font-semibold leading-none">{label}</h4>
{attachment.mediaType && (
<p className="font-mono text-xs text-muted-foreground">
{attachment.mediaType}
</p>
)}
</div>
</div>
</AttachmentHoverCardContent>
</AttachmentHoverCard>
);
});
AttachmentItem.displayName = 'AttachmentItem';
// Pending uploads, shown inline above the textarea.
const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments();
const handleRemove = useCallback(
(id: string) => attachments.remove(id),
[attachments]
);
if (attachments.files.length === 0) return null;
return (
<Attachments variant="inline" className="w-full justify-start px-2 pt-2">
{attachments.files.map((attachment) => (
<AttachmentItem
attachment={attachment}
key={attachment.id}
onRemove={handleRemove}
/>
))}
</Attachments>
);
};
const StoreAssistant: React.FC = () => {
// Returns before any hooks run — safe because the flag is a build-time
// constant and cannot change between renders.
if (!AI_ENABLED) return null;
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [input, setInput] = useState('');
// Drives the launcher's slide-in on first paint.
useEffect(() => setMounted(true), []);
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const isBusy = status === 'submitted' || status === 'streaming';
const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${
mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0'
}`;
const send = (text: string) => {
const trimmed = text.trim();
if (!trimmed || isBusy) return;
sendMessage({ text: trimmed });
setInput('');
};
// Attachments arrive on the submitted message, so images go out with it.
const handleSubmit = (message: PromptInputMessage) => {
const text = (message.text ?? input).trim();
const files = message.files ?? [];
if ((!text && files.length === 0) || isBusy) return;
sendMessage({ text, files });
setInput('');
};
return (
<>
{/* Popover panel, anchored above the launcher */}
<div
aria-hidden={!open}
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
open
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-2 opacity-0'
}`}
style={{ height: 'min(32rem, calc(100vh - 8rem))' }}
>
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-2">
<span className="text-sm font-medium">Store Assistant</span>
<Button
onClick={() => setOpen(false)}
variant="ghost"
size="icon-sm"
aria-label="Close assistant"
className="rounded-full"
>
<RiCloseLine className="size-4" />
</Button>
</header>
<Conversation className="flex-1">
<ConversationContent className="gap-4 p-3">
{messages.length === 0 && (
<ConversationEmptyState
title="Ask about the store"
description="Find products, compare options, browse collections."
>
{/* w-full + wrap so chips stack in the narrow popover rather
than scrolling off the edge. */}
<Suggestions className="mt-3 w-full flex-wrap justify-center">
{SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
onClick={send}
suggestion={suggestion}
className="font-normal"
/>
))}
</Suggestions>
</ConversationEmptyState>
)}
{messages.map((message) => {
const fileParts = message.parts.filter(
(part) => part.type === 'file'
);
return (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts.map((part, index) => {
if (part.type === 'text') {
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,
}}
>
{part.text}
</MessageResponse>
);
}
if (part.type === 'reasoning') {
return (
<Reasoning
key={index}
className="w-full"
isStreaming={
status === 'streaming' &&
part.state === 'streaming'
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type.startsWith('tool-')) {
const toolPart = part as typeof part & {
state: string;
output?: Record<string, unknown>;
errorText?: string;
};
const name = toolName(part.type);
// Shimmer while the call is in flight; a quiet summary
// line once it returns.
if (
toolPart.state === 'input-streaming' ||
toolPart.state === 'input-available'
) {
return (
<Shimmer key={index} className="text-xs">
{LOADING_LABELS[name] ?? name}
</Shimmer>
);
}
if (toolPart.state === 'output-error') {
return (
<p key={index} className="text-xs text-muted-foreground">
Couldn&apos;t load that.
</p>
);
}
const { label, icon, products } = summariseTool(
name,
toolPart.output
);
return (
<div key={index} className="my-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
{label}
</div>
<ProductPreviews products={products} />
</div>
);
}
return null;
})}
{/* Images the shopper attached, shown under their message. */}
{fileParts.length > 0 && (
<Attachments variant="grid" className="ml-0 justify-start">
{fileParts.map((part, index) => (
<Attachment
key={`${message.id}-file-${index}`}
data={{
id: `${message.id}-file-${index}`,
type: 'file',
url: part.url,
mediaType: part.mediaType,
filename: part.filename,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
))}
</Attachments>
)}
</MessageContent>
</Message>
);
})}
{status === 'submitted' && (
<Shimmer className="text-xs">Thinking</Shimmer>
)}
{error && (
<p className="text-xs text-destructive">
Something went wrong. Please try again.
</p>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="shrink-0 p-2">
<PromptInputProvider>
<PromptInput
globalDrop
multiple
accept="image/*"
onSubmit={handleSubmit}
className="rounded-lg"
>
<PromptInputAttachmentsDisplay />
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask about products…"
className="min-h-12"
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</PromptInputProvider>
</div>
</div>
{/* Launcher */}
{/* Rainbow treatment only while the assistant is open; otherwise the
launcher matches the rest of the site's buttons. */}
{open ? (
<RainbowButton
onClick={() => setOpen(false)}
aria-label="Close store assistant"
aria-expanded
size="lg"
className={launcherClasses}
>
Ask
</RainbowButton>
) : (
<Button
onClick={() => setOpen(true)}
aria-label="Open store assistant"
aria-expanded={false}
className={`h-11 ${launcherClasses}`}
>
Ask
</Button>
)}
</>
);
};
export default StoreAssistant;
+43 -37
View File
@@ -1,48 +1,54 @@
import * as React from "react" import React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cn } from '@/lib/utils';
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
const badgeVariants = cva( interface BadgeProps extends React.ComponentProps<'span'> {
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", variant?: BadgeVariant;
{ asChild?: boolean;
variants: { }
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", const badgeVariants: Record<BadgeVariant, string> = {
secondary: default:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
destructive: secondary:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90',
outline: destructive:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 'border-transparent bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", outline:
link: "text-primary underline-offset-4 [a&]:hover:underline", 'text-foreground border-border hover:bg-accent hover:text-accent-foreground',
}, };
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ function Badge({
className, className,
variant = "default", variant = 'default',
asChild = false, asChild = false,
children,
...props ...props
}: React.ComponentProps<"span"> & }: BadgeProps) {
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { const baseClasses = cn(
const Comp = asChild ? Slot.Root : "span" 'inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden',
'[&>svg]:size-3 [&>svg]:pointer-events-none [&>svg]:shrink-0'
);
const variantClasses = badgeVariants[variant];
const finalClassName = cn(baseClasses, variantClasses, className);
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
className: cn(child.props.className, finalClassName),
...props,
} as any);
}
return ( return (
<Comp <span data-slot="badge" className={finalClassName} {...props}>
data-slot="badge" {children}
data-variant={variant} </span>
className={cn(badgeVariants({ variant }), className)} );
{...props}
/>
)
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
export type { BadgeProps };
+57 -43
View File
@@ -1,83 +1,97 @@
import { cva, type VariantProps } from "class-variance-authority" import React from 'react';
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" // Utility function to combine classNames
import { Separator } from "@/components/ui/separator" function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}
const buttonGroupVariants = cva( // Button group variants helper
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", function getButtonGroupVariants(
{ orientation: 'horizontal' | 'vertical'
variants: { ): string {
orientation: { const baseStyles =
horizontal: 'flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*="w-"])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2';
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical: const orientationStyles = {
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", horizontal:
}, '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
}, vertical:
defaultVariants: { 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
orientation: "horizontal", };
},
} return cn(baseStyles, orientationStyles[orientation]);
) }
interface ButtonGroupProps extends React.ComponentProps<'div'> {
orientation?: 'horizontal' | 'vertical';
}
function ButtonGroup({ function ButtonGroup({
className, className,
orientation, orientation = 'horizontal',
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) { }: ButtonGroupProps) {
return ( return (
<div <div
role="group" role="group"
data-slot="button-group" data-slot="button-group"
data-orientation={orientation} data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)} className={cn(getButtonGroupVariants(orientation), className)}
{...props} {...props}
/> />
) );
}
interface ButtonGroupTextProps extends React.ComponentProps<'div'> {
asChild?: boolean;
} }
function ButtonGroupText({ function ButtonGroupText({
className, className,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { }: ButtonGroupTextProps) {
asChild?: boolean const Comp = asChild ? 'div' : 'div';
}) {
const Comp = asChild ? Slot.Root : "div"
return ( return (
<Comp <Comp
data-slot="button-group-text"
className={cn( className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", 'bg-muted flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4',
className className
)} )}
{...props} {...props}
/> />
) );
}
interface ButtonGroupSeparatorProps extends React.ComponentProps<'div'> {
orientation?: 'horizontal' | 'vertical';
} }
function ButtonGroupSeparator({ function ButtonGroupSeparator({
className, className,
orientation = "vertical", orientation = 'vertical',
...props ...props
}: React.ComponentProps<typeof Separator>) { }: ButtonGroupSeparatorProps) {
const separatorClasses =
orientation === 'vertical' ? 'w-px h-auto' : 'h-px w-auto';
return ( return (
<Separator <div
data-slot="button-group-separator" data-slot="button-group-separator"
orientation={orientation}
className={cn( className={cn(
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto", 'bg-border relative !m-0 self-stretch',
separatorClasses,
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
ButtonGroup, export type {
ButtonGroupSeparator, ButtonGroupProps,
ButtonGroupText, ButtonGroupTextProps,
buttonGroupVariants, ButtonGroupSeparatorProps,
} };
+67 -61
View File
@@ -1,64 +1,70 @@
import * as React from "react" import React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cn } from '@/lib/utils';
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
const buttonVariants = cva( | 'default'
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | 'destructive'
{ | 'outline'
variants: { | 'secondary'
variant: { | 'ghost'
default: "bg-primary text-primary-foreground hover:bg-primary/90", | 'link';
destructive: size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", children?: React.ReactNode;
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
} }
export { Button, buttonVariants } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
const baseClasses = cn(
// Base styles
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4',
'shrink-0 [&_svg]:shrink-0',
'outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive'
);
const variantClasses = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border border-border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
};
const sizeClasses = {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
};
return (
<button
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(
baseClasses,
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button };
export default Button;
-33
View File
@@ -1,33 +0,0 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
-195
View File
@@ -1,195 +0,0 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
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}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
// Forwarded to the inner Command so callers can drive filtering themselves
// (e.g. results already filtered server-side).
shouldFilter,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
shouldFilter?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
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">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+191 -81
View File
@@ -1,50 +1,141 @@
"use client" import React, { useState, useCallback, useContext, createContext } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { clsx } from 'clsx';
import * as React from "react" interface DialogContextType {
import { XIcon } from "lucide-react" open: boolean;
import { Dialog as DialogPrimitive } from "radix-ui" setOpen: (open: boolean) => void;
}
import { cn } from "@/lib/utils" const DialogContext = createContext<DialogContextType | undefined>(undefined);
import { Button } from "@/components/ui/button"
function Dialog({ function useDialog() {
...props const context = useContext(DialogContext);
}: React.ComponentProps<typeof DialogPrimitive.Root>) { if (!context) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> throw new Error('Dialog components must be used within a Dialog');
}
return context;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(newOpen: boolean) => {
if (!isControlled) {
setInternalOpen(newOpen);
}
onOpenChange?.(newOpen);
},
[isControlled, onOpenChange]
);
return (
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</DialogContext.Provider>
);
} }
function DialogTrigger({ function DialogTrigger({
children,
asChild,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> const { setOpen } = useDialog();
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
...props,
onClick: (e: React.MouseEvent) => {
setOpen(true);
child.props.onClick?.(e);
},
} as any);
}
return (
<button
{...props}
onClick={(e) => {
setOpen(true);
props.onClick?.(e);
}}
>
{children}
</button>
);
} }
function DialogPortal({ function DialogPortal({ children }: { children: React.ReactNode }) {
...props return createPortal(children, document.body);
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ function DialogClose({
children,
asChild,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> const { setOpen } = useDialog();
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
...props,
onClick: (e: React.MouseEvent) => {
setOpen(false);
child.props.onClick?.(e);
},
} as any);
}
return (
<button
{...props}
onClick={(e) => {
setOpen(false);
props.onClick?.(e);
}}
>
{children}
</button>
);
} }
function DialogOverlay({ interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
className,
...props function DialogOverlay({ className, onClick, ...props }: DialogOverlayProps) {
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { const { setOpen } = useDialog();
return ( return (
<DialogPrimitive.Overlay <motion.div
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={clsx('fixed inset-0 z-50 bg-black/50', className)}
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", initial={{ opacity: 0 }}
className animate={{ opacity: 1 }}
)} exit={{ opacity: 0 }}
{...props} transition={{ duration: 0.2 }}
onClick={(e) => {
setOpen(false);
onClick?.(e as any);
}}
{...(props as any)}
/> />
) );
}
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
showCloseButton?: boolean;
} }
function DialogContent({ function DialogContent({
@@ -52,96 +143,114 @@ function DialogContent({
children, children,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: DialogContentProps) {
showCloseButton?: boolean const { open } = useDialog();
}) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal>
<DialogOverlay /> <AnimatePresence>
<DialogPrimitive.Content {open && (
data-slot="dialog-content" <>
className={cn( <DialogOverlay />
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg", <motion.div
className data-slot="dialog-content"
className={clsx(
'bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-screen-md max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border p-6 shadow-lg',
className
)}
initial={{ opacity: 0, scale: 0.95, y: 0 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
{...(props as any)}
>
{children}
{showCloseButton && (
<DialogClose
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<path d="M18 6l-12 12M6 6l12 12" />
</svg>
</DialogClose>
)}
</motion.div>
</>
)} )}
{...props} </AnimatePresence>
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={clsx(
'flex flex-col gap-2 text-center sm:text-left',
className
)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
className, className,
showCloseButton = false,
children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.HTMLAttributes<HTMLDivElement>) {
showCloseButton?: boolean
}) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={clsx(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", 'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className className
)} )}
{...props} {...props}
> />
{children} );
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
} }
function DialogTitle({ function DialogTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) { }: React.HTMLAttributes<HTMLHeadingElement>) {
return ( return (
<DialogPrimitive.Title <h2
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={clsx('text-lg leading-none font-semibold', className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) { }: React.HTMLAttributes<HTMLParagraphElement>) {
return ( return (
<DialogPrimitive.Description <p
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)} className={clsx('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -155,4 +264,5 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} AnimatePresence,
};
-257
View File
@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
-44
View File
@@ -1,44 +0,0 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
-170
View File
@@ -1,170 +0,0 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+3 -3
View File
@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", "text-foreground file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-background px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}
{...props} {...props}
-61
View File
@@ -1,61 +0,0 @@
import React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const rainbowButtonVariants = cva(
cn(
"relative cursor-pointer group transition-all animate-rainbow",
"inline-flex items-center justify-center gap-2 shrink-0",
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
"text-sm font-medium whitespace-nowrap",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
),
{
variants: {
variant: {
default:
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
outline:
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-xl px-3 text-xs",
lg: "h-11 rounded-xl px-8",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
interface RainbowButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof rainbowButtonVariants> {
asChild?: boolean
}
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(rainbowButtonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
RainbowButton.displayName = "RainbowButton"
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
-58
View File
@@ -1,58 +0,0 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+254 -157
View File
@@ -1,190 +1,287 @@
"use client" import React, {
createContext,
useContext,
useState,
useRef,
useEffect,
useCallback,
} from 'react';
import { cn } from '@/lib/utils';
import * as React from "react" interface SelectContextType {
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" open: boolean;
import { Select as SelectPrimitive } from "radix-ui" setOpen: (open: boolean) => void;
value: string;
setValue: (value: string) => void;
}
import { cn } from "@/lib/utils" const SelectContext = createContext<SelectContextType | undefined>(undefined);
function useSelect() {
const context = useContext(SelectContext);
if (!context) {
throw new Error('Select components must be used within a Select');
}
return context;
}
interface SelectProps {
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
}
function Select({ function Select({
...props value: controlledValue,
}: React.ComponentProps<typeof SelectPrimitive.Root>) { onValueChange,
return <SelectPrimitive.Root data-slot="select" {...props} /> children,
}: SelectProps) {
const [internalValue, setInternalValue] = useState('');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const handleValueChange = useCallback(
(newValue: string) => {
if (!isControlled) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
setOpen(false);
},
[isControlled, onValueChange]
);
// Handle clicking outside to close the menu
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [open]);
return (
<SelectContext.Provider
value={{ open, setOpen, value, setValue: handleValueChange }}
>
<div ref={containerRef} data-slot="select" className="relative">
{children}
</div>
</SelectContext.Provider>
);
} }
function SelectGroup({ interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
...props children: React.ReactNode;
}: React.ComponentProps<typeof SelectPrimitive.Group>) { placeholder?: string;
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
} }
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default",
children, children,
placeholder = 'Select...',
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: SelectTriggerProps) {
size?: "sm" | "default" const { open, setOpen, value } = useSelect();
}) { const triggerRef = useRef<HTMLButtonElement>(null);
return ( return (
<SelectPrimitive.Trigger <button
ref={triggerRef}
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} onClick={() => setOpen(!open)}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", 'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*="text-"])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 h-9 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
className
)}
{...props}
>
{children || <span className="text-muted-foreground">{placeholder}</span>}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'size-4 opacity-50 transition-transform',
open && 'rotate-180'
)}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
);
}
interface SelectValueProps {
children?: React.ReactNode;
placeholder?: string;
}
function SelectValue({
children,
placeholder = 'Select...',
}: SelectValueProps) {
const { value } = useSelect();
return (
<span data-slot="select-value">{children || value || placeholder}</span>
);
}
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function SelectContent({ className, children, ...props }: SelectContentProps) {
const { open } = useSelect();
const contentRef = useRef<HTMLDivElement>(null);
if (!open) return null;
return (
<div
ref={contentRef}
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground absolute z-50 min-w-[8rem] rounded-md border border-border shadow-md overflow-hidden top-full mt-2 left-0',
className
)}
{...props}
>
<div className="p-1 overflow-y-auto max-h-60">{children}</div>
</div>
);
}
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
children: React.ReactNode;
disabled?: boolean;
}
function SelectItem({
value,
children,
disabled = false,
className,
...props
}: SelectItemProps) {
const { value: selectedValue, setValue } = useSelect();
const isSelected = selectedValue === value;
return (
<div
data-slot="select-item"
onClick={() => !disabled && setValue(value)}
className={cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*="text-"])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none transition-colors',
!disabled &&
'hover:bg-accent hover:text-accent-foreground cursor-pointer',
disabled && 'pointer-events-none opacity-50',
isSelected && 'bg-accent text-accent-foreground',
className
)}
{...props}
>
{isSelected && (
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
)}
{children}
</div>
);
}
interface SelectGroupProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function SelectGroup({ className, children, ...props }: SelectGroupProps) {
return (
<div
data-slot="select-group"
className={cn('overflow-hidden', className)}
{...props}
>
{children}
</div>
);
}
interface SelectLabelProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function SelectLabel({ className, children, ...props }: SelectLabelProps) {
return (
<div
data-slot="select-label"
className={cn(
'text-muted-foreground px-2 py-1.5 text-xs font-semibold',
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> </div>
<ChevronDownIcon className="size-4 opacity-50" /> );
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
} }
function SelectContent({ interface SelectSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <div
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} className={cn(
'bg-border pointer-events-none -mx-1 my-1 h-px',
className
)}
{...props} {...props}
/> />
) );
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
} }
export { export {
Select, Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
};
-28
View File
@@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import Button from '@/components/ui/button';
export function SonnerDemo() { export function SonnerDemo() {
return ( return (
+35 -13
View File
@@ -1,16 +1,38 @@
import { Loader2Icon } from "lucide-react" import React from 'react';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils" interface SpinnerProps extends React.ComponentProps<'svg'> {
size?: 'sm' | 'md' | 'lg' | 'xl';
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
} }
export { Spinner } const sizeClasses = {
sm: 'size-3',
md: 'size-4',
lg: 'size-6',
xl: 'size-8',
};
function Spinner({ className, size = 'md', ...props }: SpinnerProps) {
return (
<svg
role="status"
aria-label="Loading"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('animate-spin', sizeClasses[size], className)}
{...props}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
}
export { Spinner };
export type { SpinnerProps };
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40", "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
{...props} {...props}
-57
View File
@@ -1,57 +0,0 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
-21
View File
@@ -1,21 +0,0 @@
import type { NavLink } from '@/components/shopify/header';
/** Chrome shared by every route — rendered once in the root layout. */
export const site = {
storeName: 'Shop',
description:
'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
// Absolute origin behind `metadataBase`, so Open Graph images resolve to full
// URLs. Vercel injects VERCEL_PROJECT_PRODUCTION_URL on deployed builds.
url:
process.env.NEXT_PUBLIC_SITE_URL ??
(process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000'),
logoUrl: '',
copyright: '© 2026 Shop. All rights reserved.',
navLinks: [
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
] as NavLink[],
};
+26 -46
View File
@@ -1,7 +1,5 @@
import { gql } from '@shopify/hydrogen';
// Cart Fragment for consistent cart data // Cart Fragment for consistent cart data
const CartFragment = gql(` const CartFragment = `
fragment CartFragment on Cart { fragment CartFragment on Cart {
id id
checkoutUrl checkoutUrl
@@ -66,13 +64,12 @@ const CartFragment = gql(`
} }
} }
} }
`); `;
// Create a new cart // Create a new cart
export const CREATE_CART_MUTATION = gql( export const CREATE_CART_MUTATION = `
` ${CartFragment}
mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode) mutation CreateCart($lines: [CartLineInput!]) {
@inContext(country: $country, language: $language) {
cartCreate(input: { lines: $lines }) { cartCreate(input: { lines: $lines }) {
cart { cart {
...CartFragment ...CartFragment
@@ -83,15 +80,12 @@ export const CREATE_CART_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Add lines to cart // Add lines to cart
export const ADD_CART_LINES_MUTATION = gql( export const ADD_CART_LINES_MUTATION = `
` ${CartFragment}
mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode) mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!) {
@inContext(country: $country, language: $language) {
cartLinesAdd(cartId: $cartId, lines: $lines) { cartLinesAdd(cartId: $cartId, lines: $lines) {
cart { cart {
...CartFragment ...CartFragment
@@ -102,15 +96,12 @@ export const ADD_CART_LINES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Update cart lines // Update cart lines
export const UPDATE_CART_LINES_MUTATION = gql( export const UPDATE_CART_LINES_MUTATION = `
` ${CartFragment}
mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode) mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
@inContext(country: $country, language: $language) {
cartLinesUpdate(cartId: $cartId, lines: $lines) { cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart { cart {
...CartFragment ...CartFragment
@@ -121,15 +112,12 @@ export const UPDATE_CART_LINES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Remove lines from cart // Remove lines from cart
export const REMOVE_CART_LINES_MUTATION = gql( export const REMOVE_CART_LINES_MUTATION = `
` ${CartFragment}
mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode) mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!) {
@inContext(country: $country, language: $language) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart { cart {
...CartFragment ...CartFragment
@@ -140,15 +128,12 @@ export const REMOVE_CART_LINES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Apply (or clear) discount codes on the cart // Apply (or clear) discount codes on the cart
export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql( export const UPDATE_CART_DISCOUNT_CODES_MUTATION = `
` ${CartFragment}
mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode) mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]) {
@inContext(country: $country, language: $language) {
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
cart { cart {
...CartFragment ...CartFragment
@@ -159,19 +144,14 @@ export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql(
} }
} }
} }
`, `;
[CartFragment]
);
// Get cart by ID // Get cart by ID
export const GET_CART_QUERY = gql( export const GET_CART_QUERY = `
` ${CartFragment}
query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode) query GetCart($cartId: ID!) {
@inContext(country: $country, language: $language) {
cart(id: $cartId) { cart(id: $cartId) {
...CartFragment ...CartFragment
} }
} }
`, `;
[CartFragment]
);
+61
View File
@@ -0,0 +1,61 @@
import { ProductFragment } from '@/graphql/products';
// Get all collections
export const GET_COLLECTIONS_QUERY = `
query GetCollections($first: Int!) {
collections(first: $first) {
edges {
node {
id
title
handle
description
descriptionHtml
image {
id
url
altText
width
height
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
// Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = `
${ProductFragment}
query GetCollectionProducts($handle: String!, $first: Int!, $sortKey: ProductCollectionSortKeys, $reverse: Boolean) {
collection(handle: $handle) {
id
title
handle
description
descriptionHtml
image {
id
url
altText
width
height
}
products(first: $first, sortKey: $sortKey, reverse: $reverse) {
edges {
node {
...ProductFragment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
-91
View File
@@ -1,91 +0,0 @@
import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products';
// Get all collections
export const GET_COLLECTIONS_QUERY = gql(`
query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: $first) {
edges {
node {
id
title
handle
description
descriptionHtml
image {
id
url
altText
width
height
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`);
// Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = gql(
`
query GetCollectionProducts(
$handle: String!
$first: Int!
$after: String
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
$filters: [ProductFilter!]
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
collection(handle: $handle) {
id
title
handle
description
descriptionHtml
image {
id
url
altText
width
height
}
products(
first: $first
after: $after
sortKey: $sortKey
reverse: $reverse
filters: $filters
) {
filters {
id
label
type
values {
id
label
count
input
}
}
edges {
node {
...ProductFragment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
[ProductFragment]
);
-178
View File
@@ -1,178 +0,0 @@
// Customer account operations (classic Storefront customer accounts).
// https://shopify.dev/docs/api/storefront/latest/objects/Customer
import { gql } from '@shopify/hydrogen';
const CustomerFragment = gql(`
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 = gql(
`
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
}
}
}
}
}
}
}
}
}
}
`,
[CustomerFragment]
);
export const CUSTOMER_CREATE_MUTATION = gql(`
mutation CustomerCreate($input: CustomerCreateInput!) {
customerCreate(input: $input) {
customer {
id
email
}
customerUserErrors {
code
field
message
}
}
}
`);
export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(`
mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
customerAccessTokenCreate(input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`);
export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(`
mutation CustomerAccessTokenDelete($customerAccessToken: String!) {
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
deletedAccessToken
userErrors {
field
message
}
}
}
`);
// Sends the "reset your password" email.
export const CUSTOMER_RECOVER_MUTATION = gql(`
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 = gql(`
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 = gql(`
mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) {
customerActivate(id: $id, input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`);
export const CUSTOMER_UPDATE_MUTATION = gql(
`
mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
customer {
...CustomerFragment
}
customerUserErrors {
code
field
message
}
}
}
`,
[CustomerFragment]
);
+2 -4
View File
@@ -1,8 +1,6 @@
import { gql } from '@shopify/hydrogen';
// Shop policies are exposed on the `shop` object of the Storefront API. // Shop policies are exposed on the `shop` object of the Storefront API.
// There is no lookup-by-handle field, so we fetch all of them and match. // There is no lookup-by-handle field, so we fetch all of them and match.
export const GET_SHOP_POLICIES_QUERY = gql(` export const GET_SHOP_POLICIES_QUERY = `
query GetShopPolicies { query GetShopPolicies {
shop { shop {
privacyPolicy { privacyPolicy {
@@ -42,4 +40,4 @@ export const GET_SHOP_POLICIES_QUERY = gql(`
} }
} }
} }
`); `;
+15 -25
View File
@@ -1,7 +1,5 @@
import { gql } from '@shopify/hydrogen';
// Product Fragment for consistent product data // Product Fragment for consistent product data
export const ProductFragment = gql(` export const ProductFragment = `
fragment ProductFragment on Product { fragment ProductFragment on Product {
id id
title title
@@ -49,6 +47,7 @@ export const ProductFragment = gql(`
id id
title title
availableForSale availableForSale
quantityAvailable
selectedOptions { selectedOptions {
name name
value value
@@ -99,13 +98,12 @@ export const ProductFragment = gql(`
} }
} }
} }
`); `;
// Get multiple products // Get multiple products
export const GET_PRODUCTS_QUERY = gql( export const GET_PRODUCTS_QUERY = `
` ${ProductFragment}
query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode) query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
@inContext(country: $country, language: $language) {
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) { products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
edges { edges {
node { node {
@@ -118,32 +116,24 @@ export const GET_PRODUCTS_QUERY = gql(
} }
} }
} }
`, `;
[ProductFragment]
);
// Get a single product by handle // Get a single product by handle
export const GET_PRODUCT_QUERY = gql( export const GET_PRODUCT_QUERY = `
` ${ProductFragment}
query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode) query GetProduct($handle: String!) {
@inContext(country: $country, language: $language) {
product(handle: $handle) { product(handle: $handle) {
...ProductFragment ...ProductFragment
} }
} }
`, `;
[ProductFragment]
);
// Get product recommendations // Get product recommendations
export const QUERY_PRODUCT_RECOMMENDATIONS = gql( export const QUERY_PRODUCT_RECOMMENDATIONS = `
` ${ProductFragment}
query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode) query GetProductRecommendations($productId: ID!) {
@inContext(country: $country, language: $language) {
productRecommendations(productId: $productId) { productRecommendations(productId: $productId) {
...ProductFragment ...ProductFragment
} }
} }
`, `;
[ProductFragment]
);
-89
View File
@@ -1,89 +0,0 @@
import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products';
// Storefront search over products. `productFilters` accepts the raw `input`
// values returned in `productFilters[].values[].input`, so facets round-trip
// without the client needing to know each filter's shape.
export const SEARCH_PRODUCTS_QUERY = gql(
`
query SearchProducts(
$query: String!
$first: Int!
$after: String
$sortKey: SearchSortKeys
$reverse: Boolean
$productFilters: [ProductFilter!]
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
search(
query: $query
first: $first
after: $after
types: PRODUCT
sortKey: $sortKey
reverse: $reverse
productFilters: $productFilters
) {
totalCount
pageInfo {
hasNextPage
endCursor
}
productFilters {
id
label
type
values {
id
label
count
input
}
}
edges {
node {
# types: PRODUCT already narrows the results, but the schema still
# types nodes as a union — __typename lets callers narrow too.
__typename
... on Product {
...ProductFragment
}
}
}
}
}
`,
[ProductFragment]
);
// Lightweight variant for the autocomplete dropdown — just enough to render a
// row, so the dialog stays responsive while typing.
export const SEARCH_SUGGESTIONS_QUERY = gql(`
query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
search(query: $query, first: $first, types: PRODUCT) {
totalCount
edges {
node {
__typename
... on Product {
id
title
handle
featuredImage {
url
altText
}
priceRange {
minVariantPrice {
amount
currencyCode
}
}
}
}
}
}
}
`);
+59 -72
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; import { shopifyFetch } from '@/services/shopify/client';
import { import {
CREATE_CART_MUTATION, CREATE_CART_MUTATION,
ADD_CART_LINES_MUTATION, ADD_CART_LINES_MUTATION,
@@ -45,12 +45,12 @@ interface CartLine {
currencyCode: string; currencyCode: string;
}; };
image?: { image?: {
id?: string | null; id: string;
url: string; url: string;
altText?: string | null; altText?: string;
width?: number | null; width: number;
height?: number | null; height: number;
} | null; };
product: { product: {
id: string; id: string;
title: string; title: string;
@@ -82,7 +82,7 @@ export interface Cart {
totalTaxAmount?: { totalTaxAmount?: {
amount: string; amount: string;
currencyCode: string; currencyCode: string;
} | null; };
}; };
lines: { lines: {
edges: Array<{ edges: Array<{
@@ -93,105 +93,92 @@ export interface Cart {
// ─── Shopify API functions ─────────────────────────────────────────── // ─── Shopify API functions ───────────────────────────────────────────
/**
* Every cart mutation returns the same `{ cart, userErrors }` payload, and both
* the payload and the cart inside it are nullable — Shopify returns no cart when
* the mutation could not be applied. Callers want a cart or an exception.
*/
function unwrapCartPayload<T>(
payload:
| {
cart?: T | null;
userErrors: ReadonlyArray<{ message: string }>;
}
| null
| undefined
): T {
if (payload?.userErrors.length) {
throw new Error(payload.userErrors[0].message);
}
if (!payload?.cart) {
throw new Error('Cart update failed. Please try again.');
}
return payload.cart;
}
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> { async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(CREATE_CART_MUTATION, { query: CREATE_CART_MUTATION,
variables: { lines: lines.length > 0 ? lines : null }, variables: { lines: lines.length > 0 ? lines : null },
}), });
'CreateCart'
);
return unwrapCartPayload(data.cartCreate); if (response.data.cartCreate.userErrors.length > 0) {
throw new Error(response.data.cartCreate.userErrors[0].message);
}
return response.data.cartCreate.cart;
} }
async function addCartLinesApi( async function addCartLinesApi(
cartId: string, cartId: string,
lines: CartLineInput[] lines: CartLineInput[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(ADD_CART_LINES_MUTATION, { query: ADD_CART_LINES_MUTATION,
variables: { cartId, lines }, variables: { cartId, lines },
}), });
'AddCartLines'
);
return unwrapCartPayload(data.cartLinesAdd); if (response.data.cartLinesAdd.userErrors.length > 0) {
throw new Error(response.data.cartLinesAdd.userErrors[0].message);
}
return response.data.cartLinesAdd.cart;
} }
async function updateCartLinesApi( async function updateCartLinesApi(
cartId: string, cartId: string,
lines: CartLineUpdateInput[] lines: CartLineUpdateInput[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(UPDATE_CART_LINES_MUTATION, { query: UPDATE_CART_LINES_MUTATION,
variables: { cartId, lines }, variables: { cartId, lines },
}), });
'UpdateCartLines'
);
return unwrapCartPayload(data.cartLinesUpdate); if (response.data.cartLinesUpdate.userErrors.length > 0) {
throw new Error(response.data.cartLinesUpdate.userErrors[0].message);
}
return response.data.cartLinesUpdate.cart;
} }
async function removeCartLinesApi( async function removeCartLinesApi(
cartId: string, cartId: string,
lineIds: string[] lineIds: string[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(REMOVE_CART_LINES_MUTATION, { query: REMOVE_CART_LINES_MUTATION,
variables: { cartId, lineIds }, variables: { cartId, lineIds },
}), });
'RemoveCartLines'
);
return unwrapCartPayload(data.cartLinesRemove); if (response.data.cartLinesRemove.userErrors.length > 0) {
throw new Error(response.data.cartLinesRemove.userErrors[0].message);
}
return response.data.cartLinesRemove.cart;
} }
async function updateCartDiscountCodesApi( async function updateCartDiscountCodesApi(
cartId: string, cartId: string,
discountCodes: string[] discountCodes: string[]
): Promise<Cart> { ): Promise<Cart> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, { query: UPDATE_CART_DISCOUNT_CODES_MUTATION,
variables: { cartId, discountCodes }, variables: { cartId, discountCodes },
}), });
'UpdateCartDiscountCodes'
);
return unwrapCartPayload(data.cartDiscountCodesUpdate); if (response.data.cartDiscountCodesUpdate.userErrors.length > 0) {
throw new Error(
response.data.cartDiscountCodesUpdate.userErrors[0].message
);
}
return response.data.cartDiscountCodesUpdate.cart;
} }
async function getCartApi(cartId: string): Promise<Cart | null> { async function getCartApi(cartId: string): Promise<Cart | null> {
const data = unwrapStorefrontResult( const response = await shopifyFetch({
await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }), query: GET_CART_QUERY,
'GetCart' variables: { cartId },
); });
return data.cart; return response.data.cart;
} }
export function redirectToCheckout(checkoutUrl: string): void { export function redirectToCheckout(checkoutUrl: string): void {
+52 -54
View File
@@ -1,36 +1,64 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { shopifyFetch } from '@/services/shopify/client';
import { import {
getCollections, GET_COLLECTIONS_QUERY,
getCollectionProducts, GET_COLLECTION_PRODUCTS_QUERY,
} from '@/services/shopify/catalog'; } from '@/graphql/collections';
import type { Product } from '@/hooks/use-shopify-products';
export { interface CollectionImage {
getCollections, url: string;
getCollectionProducts, altText?: string;
getCollectionProductsPage, }
} from '@/services/shopify/catalog';
export type {
Collection,
CollectionWithProducts,
CollectionSortKey,
CollectionProductsPage,
ProductFilterFacet,
} from '@/services/shopify/catalog';
import type { export interface Collection {
Collection, id: string;
CollectionWithProducts, title: string;
CollectionSortKey, handle: string;
} from '@/services/shopify/catalog'; description?: string;
descriptionHtml?: string;
image?: CollectionImage;
}
export interface CollectionWithProducts extends Collection {
products: Product[];
}
interface UseCollectionProductsOptions { interface UseCollectionProductsOptions {
first?: number; first?: number;
after?: string | null; sortKey?: 'BEST_SELLING' | 'CREATED' | 'PRICE' | 'TITLE';
sortKey?: CollectionSortKey;
reverse?: boolean; reverse?: boolean;
filterInputs?: string[]; }
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> {
const response = await shopifyFetch({
query: GET_COLLECTIONS_QUERY,
variables: { first },
});
return response.data.collections.edges.map((edge: { node: Collection }) => edge.node);
}
// Fetch products in a collection by handle
export async function getCollectionProducts(
handle: string,
{ first = 50, sortKey = 'BEST_SELLING', reverse = false }: UseCollectionProductsOptions = {}
): Promise<CollectionWithProducts | null> {
const response = await shopifyFetch({
query: GET_COLLECTION_PRODUCTS_QUERY,
variables: { handle, first, sortKey, reverse },
});
const collection = response.data.collection;
if (!collection) return null;
return {
...collection,
products: collection.products.edges.map((edge: { node: Product }) => edge.node),
};
} }
// Hook for fetching all collections // Hook for fetching all collections
@@ -60,36 +88,6 @@ export function useCollections(first = 50) {
return { collections, loading, error, refetch: fetchCollections }; return { collections, loading, error, refetch: fetchCollections };
} }
// Deferred variant of useCollections: nothing is requested until `load` runs,
// so a menu can hold off until it's actually opened. The fetch happens once —
// re-opening reuses what's already in state.
export function useCollectionsOnDemand(first = 50) {
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requested = useRef(false);
const load = useCallback(async () => {
if (requested.current) return;
requested.current = true;
try {
setLoading(true);
setError(null);
setCollections(await getCollections(first));
} catch (err) {
console.error('Error fetching collections:', err);
// Let the next open (or a retry) try again.
requested.current = false;
setError(err instanceof Error ? err.message : 'Failed to load collections');
} finally {
setLoading(false);
}
}, [first]);
return { collections, loading, error, load };
}
// Hook for fetching products in a collection // Hook for fetching products in a collection
export function useCollectionProducts( export function useCollectionProducts(
handle: string | null, handle: string | null,
+29 -17
View File
@@ -1,7 +1,4 @@
import { import { SHOPIFY_STOREFRONT_API_URL } from '@/services/shopify/client';
cachedStorefront,
unwrapStorefrontResult,
} from '@/services/shopify/client';
import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies'; import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies';
export interface ShopPolicy { export interface ShopPolicy {
@@ -12,6 +9,12 @@ export interface ShopPolicy {
url: string; url: string;
} }
interface ShopPoliciesResponse {
data?: {
shop?: Record<string, ShopPolicy | null>;
};
}
// Handles Shopify uses for each policy — also the routes under /policies/[handle]. // Handles Shopify uses for each policy — also the routes under /policies/[handle].
export const POLICY_HANDLES = [ export const POLICY_HANDLES = [
'terms-of-service', 'terms-of-service',
@@ -21,23 +24,32 @@ export const POLICY_HANDLES = [
'subscription-policy', 'subscription-policy',
] as const; ] as const;
// Policies change rarely, so this reads through the cached client (revalidated // Policies change rarely, so this uses its own fetch (revalidated hourly)
// hourly) rather than the no-store one used for carts and products. // rather than the no-store `shopifyFetch` used for carts and products.
export async function getShopPolicies(): Promise<ShopPolicy[]> { export async function getShopPolicies(): Promise<ShopPolicy[]> {
try { const token = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
const data = unwrapStorefrontResult(
await cachedStorefront.graphql(GET_SHOP_POLICIES_QUERY),
'GetShopPolicies'
);
return Object.values(data.shop ?? {}).filter( const response = await fetch(SHOPIFY_STOREFRONT_API_URL, {
(policy): policy is ShopPolicy => Boolean(policy?.handle) method: 'POST',
); headers: {
} catch (err) { 'Content-Type': 'application/json',
// A storefront without policies configured shouldn't break the footer. ...(token ? { 'X-Shopify-Storefront-Access-Token': token } : {}),
console.error('Failed to load shop policies:', err); },
body: JSON.stringify({ query: GET_SHOP_POLICIES_QUERY }),
next: { revalidate: 3600 },
});
if (!response.ok) {
console.error('Failed to load shop policies:', response.status);
return []; return [];
} }
const json: ShopPoliciesResponse = await response.json();
const shop = json.data?.shop ?? {};
return Object.values(shop).filter((policy): policy is ShopPolicy =>
Boolean(policy?.handle)
);
} }
export async function getShopPolicy( export async function getShopPolicy(
+131 -19
View File
@@ -1,37 +1,99 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { shopifyFetch } from '@/services/shopify/client';
import { import {
getProducts, GET_PRODUCTS_QUERY,
getProduct, GET_PRODUCT_QUERY,
getProductRecommendations, QUERY_PRODUCT_RECOMMENDATIONS,
} from '@/services/shopify/catalog'; } from '@/graphql/products';
// Pure fetchers live in services/shopify/catalog so server code can use them interface ProductImage {
// too; re-exported here so existing imports keep working. url: string;
export { altText?: string;
getProducts, }
getProductsPage,
getProduct,
getProductRecommendations,
} from '@/services/shopify/catalog';
export type {
Product,
ProductOption,
ProductOptionValue,
ProductsPage,
} from '@/services/shopify/catalog';
import type { Product } from '@/services/shopify/catalog'; interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: ProductImage;
}
export interface ProductOptionValue {
id: string;
name: string;
swatch?: {
color?: string | null;
image?: {
previewImage?: {
url: string;
} | null;
} | null;
} | null;
firstSelectableVariant?: {
id: string;
image?: ProductImage | null;
} | null;
}
export interface ProductOption {
id: string;
name: string;
values: string[];
optionValues?: ProductOptionValue[];
}
export interface Product {
id: string;
title: string;
description?: string;
descriptionHtml?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
options: ProductOption[];
}
interface UseProductsOptions { interface UseProductsOptions {
first?: number; first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null; after?: string | null;
query?: string; query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE'; sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean; reverse?: boolean;
} }
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn { interface UseProductsReturn {
products: Product[]; products: Product[];
loading: boolean; loading: boolean;
@@ -39,6 +101,56 @@ interface UseProductsReturn {
refetch: () => Promise<void>; refetch: () => Promise<void>;
} }
// Fetch multiple products
export async function getProducts(
options: UseProductsOptions = {}
): Promise<Product[]> {
const { products } = await getProductsPage(options);
return products;
}
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({
first = 20,
after = null,
query = '',
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> {
const response = await shopifyFetch({
query: GET_PRODUCTS_QUERY,
variables: { first, after, query, sortKey, reverse },
});
const { edges, pageInfo } = response.data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> {
const response = await shopifyFetch({
query: GET_PRODUCT_QUERY,
variables: { handle },
});
return response.data.product;
}
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const response = await shopifyFetch({
query: QUERY_PRODUCT_RECOMMENDATIONS,
variables: { productId },
});
return response.data.productRecommendations || [];
}
// Hook for fetching multiple products // Hook for fetching multiple products
export function useProducts(options: UseProductsOptions = {}): UseProductsReturn { export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
-10
View File
@@ -1,10 +0,0 @@
// Re-exported from the server-safe catalogue module so both client components
// and Route Handlers can search the storefront.
export { searchProducts, searchSuggestions } from '@/services/shopify/catalog';
export type {
SearchSortKey,
SearchFilter,
SearchFilterValue,
SearchProductsResult,
SearchSuggestion,
} from '@/services/shopify/catalog';
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+16 -16
View File
@@ -3,23 +3,23 @@ export default {
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
images: { images: {
unoptimized: true,
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: 'https',
hostname: 'images.unsplash.com', hostname: 'images.unsplash.com',
}, },
// Covers cdn.shopify.com plus any other Shopify-hosted image subdomain. {
{ protocol: 'https',
protocol: 'https', hostname: 'cdn.shopify.com',
hostname: '**.shopify.com', },
}, {
{ protocol: 'https',
protocol: 'https', hostname: '**.frontend.co',
hostname: '**.frontend.co', },
}, ],
], },
},
experimental: { experimental: {
reactDebugChannel: false reactDebugChannel: false
}, },
+5 -23
View File
@@ -5,50 +5,32 @@
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev --webpack",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start"
"typecheck": "tsc --noEmit && hydrogen gql check"
}, },
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ai-sdk/react": "^4.0.47", "@next/swc-wasm-web": "16.2.10",
"@next/swc-wasm-web": "16.3.6",
"@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", "@remixicon/react": "^4.9.0",
"@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607", "@shopify/storefront-api-client": "^1.0.0",
"ai": "^7", "@supabase/supabase-js": "^2.51.0",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "12.42.2", "framer-motion": "12.42.2",
"hast-util-to-jsx-runtime": "^2.3.6",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.23.26", "motion": "^12.23.26",
"nanoid": "^6.0.0", "next": "16.2.10",
"next": "16.3.6",
"radix-ui": "^1.6.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remend": "^1.3.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"unified": "^11.0.5",
"use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/hast": "^3.0.5",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
-446
View File
@@ -1,446 +0,0 @@
// Server-safe Shopify catalogue access.
//
// These are plain async functions with no React imports, so they can be called
// from Route Handlers (see app/api/chat/route.ts) as well as from the client
// hooks in hooks/use-shopify-*.ts, which re-export them.
import type { StorefrontApi } from '@shopify/hydrogen';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import {
GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY,
QUERY_PRODUCT_RECOMMENDATIONS,
} from '@/graphql/products';
import {
GET_COLLECTIONS_QUERY,
GET_COLLECTION_PRODUCTS_QUERY,
} from '@/graphql/collections';
import {
SEARCH_PRODUCTS_QUERY,
SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search';
// Optional fields are `| null` rather than just optional: the Storefront API
// returns explicit nulls, and the typed `gql()` documents now surface that.
interface ProductImage {
url: string;
altText?: string | null;
}
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: ProductImage | null;
}
export interface ProductOptionValue {
id: string;
name: string;
swatch?: {
color?: string | null;
image?: {
previewImage?: {
url: string;
} | null;
} | null;
} | null;
firstSelectableVariant?: {
id: string;
image?: ProductImage | null;
} | null;
}
export interface ProductOption {
id: string;
name: string;
values: string[];
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;
description?: string;
descriptionHtml?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
options: ProductOption[];
}
interface UseProductsOptions {
first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null;
query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean;
}
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn {
products: Product[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
// Fetch multiple products
export async function getProducts(
options: UseProductsOptions = {}
): Promise<Product[]> {
const { products } = await getProductsPage(options);
return products;
}
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({
first = 20,
after = null,
query = '',
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCTS_QUERY, {
variables: { first, after, query, sortKey, reverse },
}),
'GetProducts'
);
const { edges, pageInfo } = data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }),
'GetProduct'
);
return data.product;
}
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, {
variables: { productId },
}),
'GetProductRecommendations'
);
return data.productRecommendations ?? [];
}
interface CollectionImage {
url: string;
altText?: string | null;
}
export interface Collection {
id: string;
title: string;
handle: string;
description?: string;
descriptionHtml?: string;
image?: CollectionImage | null;
}
export interface CollectionWithProducts extends Collection {
products: Product[];
}
export type CollectionSortKey =
| 'COLLECTION_DEFAULT'
| 'BEST_SELLING'
| 'CREATED'
| 'PRICE'
| 'TITLE';
interface UseCollectionProductsOptions {
first?: number;
after?: string | null;
sortKey?: CollectionSortKey;
reverse?: boolean;
/** Raw `input` strings from the connection's `filters` facets. */
filterInputs?: string[];
}
export interface CollectionProductsPage {
collection: Collection | null;
products: Product[];
filters: ProductFilterFacet[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: Array<{
id: string;
label: string;
count: number;
input: string;
}>;
}
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
'GetCollections'
);
return data.collections.edges.map((edge) => edge.node);
}
// Fetch products in a collection by handle
export async function getCollectionProducts(
handle: string,
options: UseCollectionProductsOptions = {}
): Promise<CollectionWithProducts | null> {
const page = await getCollectionProductsPage(handle, options);
if (!page.collection) return null;
return { ...page.collection, products: page.products };
}
// Same fetch, but keeps the cursor and facet list for filtering and paging.
export async function getCollectionProductsPage(
handle: string,
{
first = 50,
after = null,
sortKey = 'COLLECTION_DEFAULT',
reverse = false,
filterInputs = [],
}: UseCollectionProductsOptions = {}
): Promise<CollectionProductsPage> {
const filters = parseFilterInputs(filterInputs);
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, {
variables: {
handle,
first,
after,
sortKey,
reverse,
filters: filters.length ? filters : null,
},
}),
'GetCollectionProducts'
);
const collection = data.collection;
if (!collection) {
return {
collection: null,
products: [],
filters: [],
hasNextPage: false,
endCursor: null,
};
}
const { edges, pageInfo, filters: facets } = collection.products;
return {
collection,
products: edges.map((edge: { node: Product }) => edge.node),
filters: facets ?? [],
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
export type SearchSortKey = 'RELEVANCE' | 'PRICE';
export interface SearchFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface SearchFilter {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: SearchFilterValue[];
}
export interface SearchProductsResult {
products: Product[];
totalCount: number;
filters: SearchFilter[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface SearchSuggestion {
id: string;
title: string;
handle: string;
featuredImage?: {
url: string;
altText?: string | null;
} | null;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
}
interface SearchProductsOptions {
query: string;
first?: number;
after?: string | null;
sortKey?: SearchSortKey;
reverse?: boolean;
/** Raw `input` strings from the facets, parsed back into filter objects. */
filterInputs?: string[];
}
// The `ProductFilter` input shape, taken from the query that consumes it so it
// tracks the schema rather than being restated here.
type ProductFilterInput = NonNullable<
StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters']
>[number];
// Facet `input` values are opaque JSON strings produced by Shopify and handed
// straight back as filter inputs, so they are parsed, not constructed.
function parseFilterInputs(inputs: string[]): ProductFilterInput[] {
return inputs.flatMap((input) => {
try {
return [JSON.parse(input) as ProductFilterInput];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
}
export async function searchProducts({
query,
first = 24,
after = null,
sortKey = 'RELEVANCE',
reverse = false,
filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_PRODUCTS_QUERY, {
variables: {
query,
first,
after,
sortKey,
reverse,
productFilters: filterInputs.length
? parseFilterInputs(filterInputs)
: null,
},
}),
'SearchProducts'
);
const search = data.search;
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage),
endCursor: search.pageInfo?.endCursor ?? null,
};
}
export async function searchSuggestions(
query: string,
first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, {
variables: { query, first },
}),
'SearchSuggestions'
);
const search = data.search;
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
totalCount: search.totalCount ?? 0,
};
}
+48
View File
@@ -0,0 +1,48 @@
// Shopify Storefront API Service
const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
const SHOPIFY_STOREFRONT_ACCESS_TOKEN = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
const SHOPIFY_API_VERSION = process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2025-07';
const SHOPIFY_STOREFRONT_API_URL = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;
// Shopify API request with optional access token
async function shopifyFetch({ query, variables = {} }) {
try {
const headers = {
'Content-Type': 'application/json',
};
// Add access token if available
if (SHOPIFY_STOREFRONT_ACCESS_TOKEN) {
headers['X-Shopify-Storefront-Access-Token'] = SHOPIFY_STOREFRONT_ACCESS_TOKEN;
}
const response = await fetch(SHOPIFY_STOREFRONT_API_URL, {
method: 'POST',
headers,
body: JSON.stringify({
query,
variables,
}),
cache: 'no-store', // Ensure fresh data for cart operations
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Shopify API HTTP error! Status: ${response.status}, Body: ${errorBody}`);
}
const json = await response.json();
if (json.errors) {
console.error('Shopify API errors:', json.errors);
throw new Error(`Shopify GraphQL errors: ${JSON.stringify(json.errors)}`);
}
return json;
} catch (error) {
console.error('Shopify fetch error:', error);
throw error;
}
}
export { shopifyFetch, SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_API_URL };
-105
View File
@@ -1,105 +0,0 @@
// Storefront API clients, built on `@shopify/hydrogen`.
//
// Call sites use the package's own API — `storefront.graphql(DOCUMENT, {
// variables })` — and pass the result through `unwrapStorefrontResult`, which
// applies this app's error policy: fail loudly. Hydrogen deliberately does not
// do that itself, because a 200 carrying partial data and GraphQL errors is a
// valid response that some callers want to render.
import {
createShopifyRequestContext,
createStorefrontClient,
type GraphQLFormattedError,
} from '@shopify/hydrogen';
import {
SHOPIFY_API_VERSION,
SHOPIFY_PUBLIC_ACCESS_TOKEN,
SHOPIFY_STORE_DOMAIN,
} from '@/services/shopify/config';
// No incoming request and no buyer context: these clients are module-scoped and
// serve both server rendering and the browser-side hooks, so they must not close
// over per-request state. `$country`/`$language` are injected from this i18n.
const requestContext = createShopifyRequestContext({
request: { headers: new Headers() },
i18n: { country: 'US', language: 'EN' },
});
/**
* Workaround for a bug in this preview build of `@shopify/hydrogen`.
*
* The client tags every request with `X-Hydrogen-Version`, but the Storefront
* API does not list that header in its CORS `access-control-allow-headers`.
* Browsers therefore reject the preflight and `fetch` throws, which hydrogen
* reports as the generic "SFAPI request failed". It only bites against real
* stores — `mock.shop` answers `access-control-allow-headers: *`.
*
* Stripped in the browser only: server-side requests are not subject to CORS,
* so they keep sending the header. Remove this once the API allows it (or once
* these queries move server-side, which is the better long-term fix).
*/
const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version'];
// Hydrogen calls `fetch(url, init, cacheOptions)`; Next's caching hints ride
// along on `init`, which is how the two ways of caching get to coexist.
const fetchWith = (overrides: RequestInit): typeof globalThis.fetch =>
((url, init) => {
const headers = new Headers(init?.headers);
if (typeof document !== 'undefined') {
for (const header of CORS_BLOCKED_HEADERS) headers.delete(header);
}
return globalThis.fetch(url, { ...init, ...overrides, headers });
}) as typeof globalThis.fetch;
const config = {
storeDomain: SHOPIFY_STORE_DOMAIN!,
apiVersion: SHOPIFY_API_VERSION,
publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN,
};
/**
* Default client. Uncached, because carts and customer reads must never serve a
* stale response.
*/
export const storefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ cache: 'no-store' }) },
});
/**
* Client for data that changes rarely (shop policies, and anything else safe to
* serve from Next's data cache for an hour).
*/
export const cachedStorefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) },
});
/**
* Returns the data from a `graphql()` result, throwing if Shopify reported any
* GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies —
* have already thrown as `StorefrontApiError` by this point.
*
* `operation` names the query in the log and the thrown message, so a failure
* points at the call site rather than just at "Shopify".
*/
export function unwrapStorefrontResult<TData>(
result: { data: TData | null; errors?: GraphQLFormattedError[] },
operation: string
): TData {
if (result.errors?.length) {
console.error(`Shopify GraphQL errors (${operation}):`, result.errors);
throw new Error(
`Shopify GraphQL errors (${operation}): ${JSON.stringify(result.errors)}`
);
}
if (result.data == null) {
throw new Error(`Shopify returned no data for ${operation}.`);
}
return result.data;
}
-16
View File
@@ -1,16 +0,0 @@
// Storefront configuration, read from the environment in one place.
//
// These are all `NEXT_PUBLIC_*`, so Next inlines them at build time and they are
// safe to read from client components as well as server code.
export const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
/**
* Public Storefront API access token. Safe to expose to the browser — that is
* what "public" means here. Omitted for tokenless storefronts such as
* `mock.shop`. Never put a *private* token behind a `NEXT_PUBLIC_` name.
*/
export const SHOPIFY_PUBLIC_ACCESS_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN;
export const SHOPIFY_API_VERSION =
process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2026-07';
-248
View File
@@ -1,248 +0,0 @@
// 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 { storefront, unwrapStorefrontResult } 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 {
/** One of `CustomerErrorCode`; kept as a string so new codes don't break. */
code?: string | null;
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 | null;
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 };
// Shopify returns a null mutation payload when the mutation could not run at
// all. That is not a success, so it surfaces as a generic error rather than an
// empty error list, which callers read as "it worked".
const MUTATION_FAILED: CustomerUserError[] = [
{ message: 'Something went wrong. Please try again.' },
];
export async function createCustomer(input: {
email: string;
password: string;
firstName?: string;
lastName?: string;
acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_CREATE_MUTATION, {
variables: { input },
}),
'CustomerCreate'
);
const result = data.customerCreate;
if (!result) return { errors: MUTATION_FAILED };
return { errors: result.customerUserErrors ?? [] };
}
export async function login(
email: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, {
variables: { input: { email, password } },
}),
'CustomerAccessTokenCreate'
);
const result = data.customerAccessTokenCreate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function logout(accessToken: string): Promise<void> {
try {
await storefront.graphql(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 storefront.graphql(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 data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_RESET_MUTATION, {
variables: { id, input: { resetToken, password } },
}),
'CustomerReset'
);
const result = data.customerReset;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function activateAccount(
id: string,
activationToken: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, {
variables: { id, input: { activationToken, password } },
}),
'CustomerActivate'
);
const result = data.customerActivate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function getCustomer(
accessToken: string,
orderCount = 10
): Promise<Customer | null> {
try {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_QUERY, {
variables: { customerAccessToken: accessToken, orderCount },
}),
'GetCustomer'
);
return 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 data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, {
variables: { customerAccessToken: accessToken, customer },
}),
'CustomerUpdate'
);
const result = data.customerUpdate;
if (!result) return { customer: null, errors: MUTATION_FAILED };
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
@@ -1,31 +0,0 @@
// 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);
}
-3
View File
@@ -20,9 +20,6 @@
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
},
{
"name": "@shopify/hydrogen/ts-plugin"
} }
], ],
"paths": { "paths": {
+264 -3099
View File
File diff suppressed because it is too large Load Diff