Template
Compare commits
3
Commits
main
..
e04f1e0405
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04f1e0405 | ||
|
|
69f7435d6e | ||
|
|
e3d5e75299 |
@@ -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=
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+16
-9
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
storeName="Shop"
|
||||||
|
logoUrl=""
|
||||||
|
links={[
|
||||||
|
{ label: 'Products', url: '/' },
|
||||||
|
{ label: 'Collections', url: '/collections' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Products
|
<Products
|
||||||
title="Shopify Hydrogen Storefront"
|
title="Shopify Hydrogen Storefront"
|
||||||
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
|
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
|
||||||
/>
|
/>
|
||||||
|
<Footer
|
||||||
|
storeName="Shop"
|
||||||
|
copyright="© 2026 Shop. All rights reserved."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +33,16 @@ export default async function PolicyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
storeName="Shop"
|
||||||
|
logoUrl=""
|
||||||
|
links={[
|
||||||
|
{ label: 'Products', url: '/' },
|
||||||
|
{ label: 'Collections', url: '/collections' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
|
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||||
@@ -48,5 +55,11 @@ export default async function PolicyPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
storeName="Shop"
|
||||||
|
copyright="© 2026 Shop. All rights reserved."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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";
|
|
||||||
@@ -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
@@ -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";
|
|
||||||
@@ -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);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface LogoProps {
|
|
||||||
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
|
|
||||||
src?: string | null;
|
|
||||||
/** Wordmark text, and the image's alt text. */
|
|
||||||
storeName?: string;
|
|
||||||
/** Where the logo links to. Pass null to render it unwrapped. */
|
|
||||||
href?: string | null;
|
|
||||||
/** Sizing for the image — height only, so the aspect ratio is preserved. */
|
|
||||||
imageClassName?: string;
|
|
||||||
/** Sizing and weight for the wordmark fallback. */
|
|
||||||
textClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Logo: React.FC<LogoProps> = ({
|
|
||||||
src,
|
|
||||||
storeName = 'Shop',
|
|
||||||
href = '/',
|
|
||||||
imageClassName = 'h-6',
|
|
||||||
textClassName = 'text-xl',
|
|
||||||
}) => {
|
|
||||||
const mark = src ? (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={storeName}
|
|
||||||
className={`w-auto object-contain ${imageClassName}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`font-medium tracking-tight text-foreground ${textClassName}`}
|
|
||||||
>
|
|
||||||
{storeName}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (href === null) {
|
|
||||||
return <span className="flex items-center">{mark}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={href} className="flex items-center">
|
|
||||||
{mark}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Logo;
|
|
||||||
@@ -1,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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const page = await getCollectionProductsPage(handle, {
|
|
||||||
first: PAGE_SIZE,
|
|
||||||
sortKey: sort.sortKey,
|
|
||||||
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 (
|
return (
|
||||||
<section className="bg-background py-10">
|
<div className="py-16 bg-background">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8">
|
<div className="max-w-screen-2xl mx-auto px-8">
|
||||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
<CollectionTitle title={formattedTitle} />
|
||||||
{title || formattedTitle}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
{/* Loading Skeleton */}
|
||||||
<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}>
|
<div className={GRID_CLASSES}>
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
<div key={index} className="animate-pulse">
|
<div key={index} className="animate-pulse">
|
||||||
<div className="aspect-square bg-zinc-100"></div>
|
<div className="aspect-square bg-zinc-100"></div>
|
||||||
<div className="pt-4 space-y-2">
|
<div className="pt-4 space-y-2">
|
||||||
<div className="h-4 w-4/5 bg-zinc-200"></div>
|
<div className="h-4 bg-zinc-200 w-4/5"></div>
|
||||||
<div className="h-4 w-1/4 bg-zinc-200"></div>
|
<div className="h-4 bg-zinc-200 w-1/4"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
</div>
|
||||||
) : products.length === 0 ? (
|
);
|
||||||
<p className="text-sm text-muted-foreground">
|
}
|
||||||
{activeFilters.length > 0
|
|
||||||
? 'No products matched your filters.'
|
if (error) {
|
||||||
: "This collection doesn't have any products yet."}
|
return (
|
||||||
|
<div className="py-16 bg-background">
|
||||||
|
<div className="max-w-screen-2xl mx-auto px-8 text-center">
|
||||||
|
<CollectionTitle title={formattedTitle} />
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">{error}</p>
|
||||||
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
|
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't have any products yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<div className={GRID_CLASSES}>
|
<div className={GRID_CLASSES}>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<ProductCard key={product.id} product={product} />
|
<ProductCard key={product.id} product={product} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProductFilters
|
|
||||||
open={filtersOpen}
|
|
||||||
onOpenChange={setFiltersOpen}
|
|
||||||
filters={filters}
|
|
||||||
activeFilters={activeFilters}
|
|
||||||
onActiveFiltersChange={setActiveFilters}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +56,9 @@ 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}
|
||||||
@@ -80,11 +69,6 @@ const Footer: React.FC<FooterProps> = ({
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
|
||||||
{/* 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">
|
<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}
|
||||||
@@ -146,7 +122,6 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
<CartDrawer />
|
<CartDrawer />
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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'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;
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'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;
|
|
||||||
+40
-34
@@ -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> = {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90',
|
||||||
destructive:
|
destructive:
|
||||||
"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-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
outline:
|
outline:
|
||||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
'text-foreground border-border hover:bg-accent hover:text-accent-foreground',
|
||||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
};
|
||||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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 };
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
'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';
|
||||||
|
|
||||||
|
const orientationStyles = {
|
||||||
horizontal:
|
horizontal:
|
||||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||||
vertical:
|
vertical:
|
||||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||||
},
|
};
|
||||||
},
|
|
||||||
defaultVariants: {
|
return cn(baseStyles, orientationStyles[orientation]);
|
||||||
orientation: "horizontal",
|
}
|
||||||
},
|
|
||||||
}
|
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,
|
||||||
}
|
};
|
||||||
|
|||||||
+56
-50
@@ -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?:
|
||||||
|
| 'default'
|
||||||
|
| 'destructive'
|
||||||
|
| 'outline'
|
||||||
|
| 'secondary'
|
||||||
|
| 'ghost'
|
||||||
|
| 'link';
|
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
"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",
|
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||||
{
|
const baseClasses = cn(
|
||||||
variants: {
|
// Base styles
|
||||||
variant: {
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all',
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
'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:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
outline:
|
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",
|
'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:
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
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({
|
const sizeClasses = {
|
||||||
className,
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
variant = "default",
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||||
size = "default",
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
asChild = false,
|
icon: 'size-9',
|
||||||
...props
|
'icon-sm': 'size-8',
|
||||||
}: React.ComponentProps<"button"> &
|
'icon-lg': 'size-10',
|
||||||
VariantProps<typeof buttonVariants> & {
|
};
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<button
|
||||||
|
ref={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(
|
||||||
|
baseClasses,
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
|
export default Button;
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
+182
-72
@@ -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>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<motion.div
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={clsx(
|
||||||
"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",
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
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}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogClose
|
||||||
data-slot="dialog-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"
|
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"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<svg
|
||||||
<span className="sr-only">Close</span>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</DialogPrimitive.Close>
|
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>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
+230
-133
@@ -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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children || <span className="text-muted-foreground">{placeholder}</span>}
|
||||||
<SelectPrimitive.Icon asChild>
|
<svg
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</SelectPrimitive.Icon>
|
width="24"
|
||||||
</SelectPrimitive.Trigger>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
interface SelectValueProps {
|
||||||
className,
|
children?: React.ReactNode;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
children,
|
children,
|
||||||
position = "item-aligned",
|
placeholder = 'Select...',
|
||||||
align = "center",
|
}: SelectValueProps) {
|
||||||
...props
|
const { value } = useSelect();
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<span data-slot="select-value">{children || value || placeholder}</span>
|
||||||
<SelectPrimitive.Content
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
data-slot="select-content"
|
||||||
className={cn(
|
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",
|
'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',
|
||||||
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
|
className
|
||||||
)}
|
)}
|
||||||
position={position}
|
|
||||||
align={align}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<div className="p-1 overflow-y-auto max-h-60">{children}</div>
|
||||||
<SelectPrimitive.Viewport
|
</div>
|
||||||
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({
|
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
className,
|
value: string;
|
||||||
...props
|
children: React.ReactNode;
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
disabled?: boolean;
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
value,
|
||||||
children,
|
children,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
}: SelectItemProps) {
|
||||||
|
const { value: selectedValue, setValue } = useSelect();
|
||||||
|
const isSelected = selectedValue === value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<div
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
|
onClick={() => !disabled && setValue(value)}
|
||||||
className={cn(
|
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",
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
{isSelected && (
|
||||||
data-slot="select-item-indicator"
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
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"
|
||||||
>
|
>
|
||||||
<SelectPrimitive.ItemIndicator>
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
<CheckIcon className="size-4" />
|
</svg>
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
)}
|
||||||
</SelectPrimitive.Item>
|
{children}
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
interface SelectGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
className,
|
children: React.ReactNode;
|
||||||
...props
|
}
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
|
function SelectGroup({ className, children, ...props }: SelectGroupProps) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<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
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,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
@@ -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 };
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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[],
|
|
||||||
};
|
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
@@ -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(`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`;
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
+54
-67
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
|
import { shopifyFetch } from '@/services/shopify/client';
|
||||||
import {
|
import {
|
||||||
CREATE_CART_MUTATION,
|
CREATE_CART_MUTATION,
|
||||||
ADD_CART_LINES_MUTATION,
|
ADD_CART_LINES_MUTATION,
|
||||||
@@ -45,12 +45,12 @@ interface CartLine {
|
|||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
};
|
};
|
||||||
image?: {
|
image?: {
|
||||||
id?: string | null;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
altText?: string | null;
|
altText?: string;
|
||||||
width?: number | null;
|
width: number;
|
||||||
height?: number | null;
|
height: number;
|
||||||
} | null;
|
};
|
||||||
product: {
|
product: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -82,7 +82,7 @@ export interface Cart {
|
|||||||
totalTaxAmount?: {
|
totalTaxAmount?: {
|
||||||
amount: string;
|
amount: string;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
} | null;
|
};
|
||||||
};
|
};
|
||||||
lines: {
|
lines: {
|
||||||
edges: Array<{
|
edges: Array<{
|
||||||
@@ -93,105 +93,92 @@ export interface Cart {
|
|||||||
|
|
||||||
// ─── Shopify API functions ───────────────────────────────────────────
|
// ─── Shopify API functions ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Every cart mutation returns the same `{ cart, userErrors }` payload, and both
|
|
||||||
* the payload and the cart inside it are nullable — Shopify returns no cart when
|
|
||||||
* the mutation could not be applied. Callers want a cart or an exception.
|
|
||||||
*/
|
|
||||||
function unwrapCartPayload<T>(
|
|
||||||
payload:
|
|
||||||
| {
|
|
||||||
cart?: T | null;
|
|
||||||
userErrors: ReadonlyArray<{ message: string }>;
|
|
||||||
}
|
|
||||||
| null
|
|
||||||
| undefined
|
|
||||||
): T {
|
|
||||||
if (payload?.userErrors.length) {
|
|
||||||
throw new Error(payload.userErrors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload?.cart) {
|
|
||||||
throw new Error('Cart update failed. Please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload.cart;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
|
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
|
||||||
const data = unwrapStorefrontResult(
|
const response = await shopifyFetch({
|
||||||
await storefront.graphql(CREATE_CART_MUTATION, {
|
query: CREATE_CART_MUTATION,
|
||||||
variables: { lines: lines.length > 0 ? lines : null },
|
variables: { lines: lines.length > 0 ? lines : null },
|
||||||
}),
|
});
|
||||||
'CreateCart'
|
|
||||||
);
|
|
||||||
|
|
||||||
return unwrapCartPayload(data.cartCreate);
|
if (response.data.cartCreate.userErrors.length > 0) {
|
||||||
|
throw new Error(response.data.cartCreate.userErrors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.cartCreate.cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addCartLinesApi(
|
async function addCartLinesApi(
|
||||||
cartId: string,
|
cartId: string,
|
||||||
lines: CartLineInput[]
|
lines: CartLineInput[]
|
||||||
): Promise<Cart> {
|
): Promise<Cart> {
|
||||||
const data = unwrapStorefrontResult(
|
const response = await shopifyFetch({
|
||||||
await storefront.graphql(ADD_CART_LINES_MUTATION, {
|
query: ADD_CART_LINES_MUTATION,
|
||||||
variables: { cartId, lines },
|
variables: { cartId, lines },
|
||||||
}),
|
});
|
||||||
'AddCartLines'
|
|
||||||
);
|
|
||||||
|
|
||||||
return unwrapCartPayload(data.cartLinesAdd);
|
if (response.data.cartLinesAdd.userErrors.length > 0) {
|
||||||
|
throw new Error(response.data.cartLinesAdd.userErrors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.cartLinesAdd.cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCartLinesApi(
|
async function updateCartLinesApi(
|
||||||
cartId: string,
|
cartId: string,
|
||||||
lines: CartLineUpdateInput[]
|
lines: CartLineUpdateInput[]
|
||||||
): Promise<Cart> {
|
): Promise<Cart> {
|
||||||
const data = unwrapStorefrontResult(
|
const response = await shopifyFetch({
|
||||||
await storefront.graphql(UPDATE_CART_LINES_MUTATION, {
|
query: UPDATE_CART_LINES_MUTATION,
|
||||||
variables: { cartId, lines },
|
variables: { cartId, lines },
|
||||||
}),
|
});
|
||||||
'UpdateCartLines'
|
|
||||||
);
|
|
||||||
|
|
||||||
return unwrapCartPayload(data.cartLinesUpdate);
|
if (response.data.cartLinesUpdate.userErrors.length > 0) {
|
||||||
|
throw new Error(response.data.cartLinesUpdate.userErrors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.cartLinesUpdate.cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeCartLinesApi(
|
async function removeCartLinesApi(
|
||||||
cartId: string,
|
cartId: string,
|
||||||
lineIds: string[]
|
lineIds: string[]
|
||||||
): Promise<Cart> {
|
): Promise<Cart> {
|
||||||
const data = unwrapStorefrontResult(
|
const response = await shopifyFetch({
|
||||||
await storefront.graphql(REMOVE_CART_LINES_MUTATION, {
|
query: REMOVE_CART_LINES_MUTATION,
|
||||||
variables: { cartId, lineIds },
|
variables: { cartId, lineIds },
|
||||||
}),
|
});
|
||||||
'RemoveCartLines'
|
|
||||||
);
|
|
||||||
|
|
||||||
return unwrapCartPayload(data.cartLinesRemove);
|
if (response.data.cartLinesRemove.userErrors.length > 0) {
|
||||||
|
throw new Error(response.data.cartLinesRemove.userErrors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.cartLinesRemove.cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCartDiscountCodesApi(
|
async function updateCartDiscountCodesApi(
|
||||||
cartId: string,
|
cartId: string,
|
||||||
discountCodes: string[]
|
discountCodes: string[]
|
||||||
): Promise<Cart> {
|
): Promise<Cart> {
|
||||||
const data = unwrapStorefrontResult(
|
const response = await shopifyFetch({
|
||||||
await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, {
|
query: UPDATE_CART_DISCOUNT_CODES_MUTATION,
|
||||||
variables: { cartId, discountCodes },
|
variables: { cartId, discountCodes },
|
||||||
}),
|
});
|
||||||
'UpdateCartDiscountCodes'
|
|
||||||
);
|
|
||||||
|
|
||||||
return unwrapCartPayload(data.cartDiscountCodesUpdate);
|
if (response.data.cartDiscountCodesUpdate.userErrors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
response.data.cartDiscountCodesUpdate.userErrors[0].message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.cartDiscountCodesUpdate.cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCartApi(cartId: string): Promise<Cart | null> {
|
async function getCartApi(cartId: string): Promise<Cart | null> {
|
||||||
const data = unwrapStorefrontResult(
|
const response = await shopifyFetch({
|
||||||
await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }),
|
query: GET_CART_QUERY,
|
||||||
'GetCart'
|
variables: { cartId },
|
||||||
);
|
});
|
||||||
|
|
||||||
return data.cart;
|
return response.data.cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redirectToCheckout(checkoutUrl: string): void {
|
export function redirectToCheckout(checkoutUrl: string): void {
|
||||||
|
|||||||
@@ -1,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,
|
||||||
|
|||||||
@@ -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
@@ -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[]>([]);
|
||||||
|
|||||||
@@ -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';
|
|
||||||
Vendored
-6
@@ -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.
|
|
||||||
+2
-2
@@ -4,15 +4,15 @@ export default {
|
|||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'images.unsplash.com',
|
hostname: 'images.unsplash.com',
|
||||||
},
|
},
|
||||||
// Covers cdn.shopify.com plus any other Shopify-hosted image subdomain.
|
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '**.shopify.com',
|
hostname: 'cdn.shopify.com',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
|
|||||||
+5
-23
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -20,9 +20,6 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "@shopify/hydrogen/ts-plugin"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
Reference in New Issue
Block a user