Template
Initial commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import AccountForm from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Activate your account — Shop' };
|
||||
|
||||
// Shopify's emailed activation link is /account/activate/{id}/{token}.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; token: string }>;
|
||||
}) {
|
||||
const { id, token } = await params;
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Activate your account"
|
||||
description="Choose a password to finish setting up your account."
|
||||
fields={[
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Activate account"
|
||||
endpoint="/api/account/activate"
|
||||
extraPayload={{ id, activationToken: token }}
|
||||
redirectTo="/account"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Sign in — Shop' };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Sign in"
|
||||
fields={[
|
||||
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'current-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Sign in"
|
||||
endpoint="/api/account/login"
|
||||
redirectTo="/account"
|
||||
footer={
|
||||
<>
|
||||
<span>
|
||||
New here?{' '}
|
||||
<AccountFormLink href="/account/register">
|
||||
Create an account
|
||||
</AccountFormLink>
|
||||
</span>
|
||||
<AccountFormLink href="/account/recover">
|
||||
Forgot your password?
|
||||
</AccountFormLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import OrderHistory from '@/components/shopify/order-history';
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
export const metadata = { title: 'Order history — Shop' };
|
||||
|
||||
export default async function Page() {
|
||||
const token = await getSessionToken();
|
||||
if (!token) redirect('/account/login');
|
||||
|
||||
const customer = await getCustomer(token, 20);
|
||||
// An expired or revoked token reads as signed out.
|
||||
if (!customer) redirect('/account/login');
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
|
||||
Order history
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{customer.displayName} · {customer.email}
|
||||
</p>
|
||||
|
||||
<div className="mt-10">
|
||||
<OrderHistory customer={customer} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Reset password — Shop' };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Reset password"
|
||||
description="Enter your email and we'll send you a link to set a new password."
|
||||
fields={[
|
||||
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||
]}
|
||||
submitLabel="Send reset link"
|
||||
endpoint="/api/account/recover"
|
||||
successMessage="If that email has an account, a reset link is on its way."
|
||||
footer={
|
||||
<AccountFormLink href="/account/login">Back to sign in</AccountFormLink>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Create account — Shop' };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Create account"
|
||||
fields={[
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'First name',
|
||||
autoComplete: 'given-name',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Last name',
|
||||
autoComplete: 'family-name',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Create account"
|
||||
endpoint="/api/account/register"
|
||||
redirectTo="/account"
|
||||
footer={
|
||||
<span>
|
||||
Already have an account?{' '}
|
||||
<AccountFormLink href="/account/login">Sign in</AccountFormLink>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import AccountForm from '@/components/shopify/account-form';
|
||||
|
||||
export const metadata = { title: 'Set a new password — Shop' };
|
||||
|
||||
// Shopify's emailed reset link is /account/reset/{id}/{token}.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; token: string }>;
|
||||
}) {
|
||||
const { id, token } = await params;
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title="Set a new password"
|
||||
fields={[
|
||||
{
|
||||
name: 'password',
|
||||
label: 'New password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
]}
|
||||
submitLabel="Save password"
|
||||
endpoint="/api/account/reset"
|
||||
extraPayload={{ id, resetToken: token }}
|
||||
redirectTo="/account"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
activateAccount,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, activationToken, password } = await req.json();
|
||||
|
||||
if (!id || !activationToken || !password) {
|
||||
return Response.json(
|
||||
{ error: 'This activation link is incomplete.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await activateAccount(
|
||||
toCustomerGid(id),
|
||||
activationToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { login, customerErrorMessage } from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await login(email, password);
|
||||
|
||||
if (!token) {
|
||||
// Shopify distinguishes wrong-password from unknown-email; collapse both so
|
||||
// the form can't be used to enumerate accounts.
|
||||
return Response.json(
|
||||
{
|
||||
error: errors.length
|
||||
? 'Incorrect email or password.'
|
||||
: customerErrorMessage(errors),
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { logout } from '@/services/shopify/customer';
|
||||
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST() {
|
||||
const token = await getSessionToken();
|
||||
if (token) await logout(token);
|
||||
|
||||
await clearSessionToken();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
// Minimal session probe for the header menu — never returns the access token.
|
||||
export async function GET() {
|
||||
const token = await getSessionToken();
|
||||
if (!token) return Response.json({ customer: null });
|
||||
|
||||
const customer = await getCustomer(token, 0);
|
||||
if (!customer) return Response.json({ customer: null });
|
||||
|
||||
return Response.json({
|
||||
customer: {
|
||||
displayName: customer.displayName,
|
||||
email: customer.email,
|
||||
firstName: customer.firstName,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { recoverPassword } from '@/services/shopify/customer';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email } = await req.json();
|
||||
|
||||
if (email) await recoverPassword(email);
|
||||
|
||||
// Always the same response, so the form can't reveal who has an account.
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
createCustomer,
|
||||
login,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password, firstName, lastName } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { errors } = await createCustomer({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sign the new customer straight in. Accounts needing email confirmation
|
||||
// won't return a token yet, which is not an error.
|
||||
const { token } = await login(email, password);
|
||||
|
||||
if (token) {
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true, signedIn: true });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, signedIn: false });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
resetPassword,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, resetToken, password } = await req.json();
|
||||
|
||||
if (!id || !resetToken || !password) {
|
||||
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { token, errors } = await resetPassword(
|
||||
toCustomerGid(id),
|
||||
resetToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import CollectionDetail from '@/components/shopify/collection-detail';
|
||||
|
||||
export default function Page() {
|
||||
return <CollectionDetail />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Collections from '@/components/shopify/collections';
|
||||
|
||||
export default function Page() {
|
||||
return <Collections title="Our Collections" />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
|
||||
Something went wrong
|
||||
</div>
|
||||
<div className="border-l border-gray-300 h-6"></div>
|
||||
<div className="text-sm text-gray-700 font-sans max-w-lg">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
/* Refined neutral modern palette */
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Card */
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Popover */
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Primary - Deep neutral slate for professional look */
|
||||
--color-primary: hsl(240 5.9% 10%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Secondary */
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
/* Muted */
|
||||
--color-muted: hsl(240 4.8% 95.9%);
|
||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||
|
||||
/* Accent - Subtle warm gray */
|
||||
--color-accent: hsl(30 6.7% 95%);
|
||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
/* Destructive */
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Shop (accelerated checkout) purple */
|
||||
--color-shop: #5a31f4;
|
||||
|
||||
/* Border */
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--color-ring: hsl(240 5.9% 10%);
|
||||
|
||||
/* Radius - Modern larger corners */
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
|
||||
/* Typography — Geist Sans / Geist Mono */
|
||||
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-body: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
font-feature-settings:
|
||||
'kern' 1,
|
||||
'tnum' 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Modern card design for products and collections */
|
||||
.card-modern {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-modern:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1);
|
||||
border-color: hsl(240 5.9% 85%);
|
||||
}
|
||||
|
||||
/* Heading styles — regular weight, slightly tightened like the Geist reference */
|
||||
h1,
|
||||
h2,
|
||||
.font-heading {
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Swipeable rows (mobile galleries) without a visible scrollbar */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
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 p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.product-description > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Shop policy body (HTML returned by the Storefront API) */
|
||||
.policy-body p,
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
padding-left: 1.25rem;
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
.policy-body li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.policy-body h2,
|
||||
.policy-body h3,
|
||||
.policy-body h4 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.policy-body strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Enhanced button styles */
|
||||
button,
|
||||
.btn-modern {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.btn-modern:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Hero section styles */
|
||||
.hero-bg {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
|
||||
}
|
||||
|
||||
/* Product gallery improvements */
|
||||
.product-gallery-main {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.product-gallery-main:hover {
|
||||
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
|
||||
}
|
||||
|
||||
/* Clean price display */
|
||||
.price-display {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import './globals.css';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import StoreAssistant from '@/components/shopify/store-assistant';
|
||||
import { site } from '@/config/site';
|
||||
|
||||
const geist = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
{/* Header owns the cart drawer, search and account menu, so every
|
||||
route gets them by rendering here rather than page by page. */}
|
||||
<Header
|
||||
storeName={site.storeName}
|
||||
logoUrl={site.logoUrl}
|
||||
links={site.navLinks}
|
||||
/>
|
||||
<div className="flex-1">{children}</div>
|
||||
<Footer storeName={site.storeName} copyright={site.copyright} />
|
||||
<StoreAssistant />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
|
||||
404
|
||||
</div>
|
||||
<div className="border-l border-gray-300 h-6"></div>
|
||||
<div className="text-sm text-gray-700 font-sans max-w-lg">
|
||||
This page could not be found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Products from '@/components/shopify/products';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Products
|
||||
title="Shopify Hydrogen Storefront"
|
||||
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return POLICY_HANDLES.map((handle) => ({ handle }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}) {
|
||||
const { handle } = await params;
|
||||
const policy = await getShopPolicy(handle);
|
||||
|
||||
return {
|
||||
title: policy ? `${policy.title} — Shop` : 'Policy — Shop',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PolicyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}) {
|
||||
const { handle } = await params;
|
||||
const policy = await getShopPolicy(handle);
|
||||
|
||||
if (!policy) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||
{policy.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: policy.body }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import ProductDetail from '@/components/shopify/product-detail';
|
||||
import ProductRecommendations from '@/components/shopify/product-recommendations';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<ProductDetail addToCartLabel="Add to Cart" />
|
||||
<ProductRecommendations title="You May Also Like" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Suspense } from 'react';
|
||||
import SearchResults from '@/components/shopify/search-results';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search — Shop',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
// useSearchParams needs a Suspense boundary to prerender this route.
|
||||
<Suspense fallback={<div className="py-10" />}>
|
||||
<SearchResults />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Collections from '@/components/shopify/collections';
|
||||
|
||||
export default function Page() {
|
||||
return <Collections title="Our Collections" />;
|
||||
}
|
||||
Reference in New Issue
Block a user