From 58742d5d0092363308154b62865ffd85d2b4fe39 Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Sat, 8 Aug 2026 14:21:24 -0400 Subject: [PATCH] Initial commit --- .env.example | 12 + .gitignore | 10 + app.globals.json | 1 + app/account/activate/[id]/[token]/page.tsx | 33 + app/account/login/page.tsx | 38 + app/account/page.tsx | 30 + app/account/recover/page.tsx | 23 + app/account/register/page.tsx | 43 + app/account/reset/[id]/[token]/page.tsx | 32 + app/api/account/activate/route.ts | 30 + app/api/account/login/route.ts | 31 + app/api/account/logout/route.ts | 10 + app/api/account/me/route.ts | 19 + app/api/account/recover/route.ts | 10 + app/api/account/register/route.ts | 39 + app/api/account/reset/route.ts | 27 + app/api/chat/route.ts | 207 + app/collections/[handle]/page.tsx | 5 + app/collections/page.tsx | 5 + app/error.tsx | 25 + app/globals.css | 230 + app/layout.tsx | 40 + app/not-found.tsx | 20 + app/page.tsx | 10 + app/policies/[handle]/page.tsx | 47 + app/products/[handle]/page.tsx | 11 + app/search/page.tsx | 15 + app/shop/collections/page.tsx | 5 + components.json | 24 + components/About.tsx | 13 + components/ai-elements/attachments.tsx | 426 ++ components/ai-elements/conversation.tsx | 168 + components/ai-elements/markdown.tsx | 295 ++ components/ai-elements/message.tsx | 339 ++ components/ai-elements/prompt-input.tsx | 1463 ++++++ components/ai-elements/reasoning.tsx | 224 + components/ai-elements/shimmer.tsx | 77 + components/ai-elements/suggestion.tsx | 57 + components/logo.tsx | 49 + components/magicui/aurora-text.tsx | 40 + components/magicui/blur-fade.tsx | 63 + components/shopify/account-form.tsx | 142 + components/shopify/account-menu.tsx | 112 + components/shopify/cart-drawer.tsx | 348 ++ components/shopify/collection-card.tsx | 52 + components/shopify/collection-detail.tsx | 208 + components/shopify/collections.tsx | 100 + components/shopify/footer.tsx | 95 + components/shopify/header.tsx | 153 + components/shopify/order-history.tsx | 166 + components/shopify/product-card.tsx | 116 + components/shopify/product-detail.tsx | 3 + components/shopify/product-detail/index.tsx | 230 + .../product-detail/product-detail-gallery.tsx | 198 + .../product-detail/product-detail-info.tsx | 282 ++ components/shopify/product-filters.tsx | 258 + .../shopify/product-recommendations.tsx | 73 + components/shopify/product-toolbar.tsx | 100 + components/shopify/products.tsx | 209 + components/shopify/search-dialog.tsx | 206 + components/shopify/search-results.tsx | 197 + components/shopify/shop-menu.tsx | 152 + components/shopify/shop-pay-button.tsx | 100 + components/shopify/shop-pay-logo.tsx | 50 + components/shopify/store-assistant.tsx | 574 +++ components/ui/accordion.tsx | 197 + components/ui/alert.tsx | 60 + components/ui/avatar.tsx | 81 + components/ui/badge.tsx | 48 + components/ui/breadcrumb.tsx | 149 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 64 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 245 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 195 + components/ui/dialog.tsx | 158 + components/ui/dropdown-menu.tsx | 257 + components/ui/empty.tsx | 105 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 170 + components/ui/input-otp.tsx | 86 + components/ui/input.tsx | 21 + components/ui/item.tsx | 213 + components/ui/loader.tsx | 96 + components/ui/pagination.tsx | 128 + components/ui/progress.tsx | 44 + components/ui/rainbow-button.tsx | 61 + components/ui/remix-icon.tsx | 44 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 190 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 308 ++ components/ui/skeleton.tsx | 13 + components/ui/sonner.tsx | 23 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 74 + components/ui/table.tsx | 113 + components/ui/tabs.tsx | 143 + components/ui/textarea.tsx | 18 + components/ui/tooltip.tsx | 57 + config/site.ts | 12 + config/swatches.ts | 51 + graphql/cart.ts | 177 + graphql/collections.ts | 91 + graphql/customer.ts | 178 + graphql/policies.ts | 45 + graphql/products.ts | 150 + graphql/search.ts | 89 + hooks/use-shopify-cart.ts | 442 ++ hooks/use-shopify-collections.ts | 129 + hooks/use-shopify-policies.ts | 48 + hooks/use-shopify-products.ts | 134 + hooks/use-shopify-search.ts | 10 + instrumentation.ts | 1 + lib/cloud.ts | 7 + lib/utils.ts | 12 + next.config.js | 33 + package.json | 58 + postcss.config.mjs | 5 + services/shopify/catalog.ts | 446 ++ services/shopify/client.ts | 105 + services/shopify/config.ts | 16 + services/shopify/customer.ts | 248 + services/shopify/session.ts | 31 + tsconfig.json | 44 + yarn.lock | 4212 +++++++++++++++++ 127 files changed, 18859 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app.globals.json create mode 100644 app/account/activate/[id]/[token]/page.tsx create mode 100644 app/account/login/page.tsx create mode 100644 app/account/page.tsx create mode 100644 app/account/recover/page.tsx create mode 100644 app/account/register/page.tsx create mode 100644 app/account/reset/[id]/[token]/page.tsx create mode 100644 app/api/account/activate/route.ts create mode 100644 app/api/account/login/route.ts create mode 100644 app/api/account/logout/route.ts create mode 100644 app/api/account/me/route.ts create mode 100644 app/api/account/recover/route.ts create mode 100644 app/api/account/register/route.ts create mode 100644 app/api/account/reset/route.ts create mode 100644 app/api/chat/route.ts create mode 100644 app/collections/[handle]/page.tsx create mode 100644 app/collections/page.tsx create mode 100644 app/error.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/policies/[handle]/page.tsx create mode 100644 app/products/[handle]/page.tsx create mode 100644 app/search/page.tsx create mode 100644 app/shop/collections/page.tsx create mode 100644 components.json create mode 100644 components/About.tsx create mode 100644 components/ai-elements/attachments.tsx create mode 100644 components/ai-elements/conversation.tsx create mode 100644 components/ai-elements/markdown.tsx create mode 100644 components/ai-elements/message.tsx create mode 100644 components/ai-elements/prompt-input.tsx create mode 100644 components/ai-elements/reasoning.tsx create mode 100644 components/ai-elements/shimmer.tsx create mode 100644 components/ai-elements/suggestion.tsx create mode 100644 components/logo.tsx create mode 100644 components/magicui/aurora-text.tsx create mode 100644 components/magicui/blur-fade.tsx create mode 100644 components/shopify/account-form.tsx create mode 100644 components/shopify/account-menu.tsx create mode 100644 components/shopify/cart-drawer.tsx create mode 100644 components/shopify/collection-card.tsx create mode 100644 components/shopify/collection-detail.tsx create mode 100644 components/shopify/collections.tsx create mode 100644 components/shopify/footer.tsx create mode 100644 components/shopify/header.tsx create mode 100644 components/shopify/order-history.tsx create mode 100644 components/shopify/product-card.tsx create mode 100644 components/shopify/product-detail.tsx create mode 100644 components/shopify/product-detail/index.tsx create mode 100644 components/shopify/product-detail/product-detail-gallery.tsx create mode 100644 components/shopify/product-detail/product-detail-info.tsx create mode 100644 components/shopify/product-filters.tsx create mode 100644 components/shopify/product-recommendations.tsx create mode 100644 components/shopify/product-toolbar.tsx create mode 100644 components/shopify/products.tsx create mode 100644 components/shopify/search-dialog.tsx create mode 100644 components/shopify/search-results.tsx create mode 100644 components/shopify/shop-menu.tsx create mode 100644 components/shopify/shop-pay-button.tsx create mode 100644 components/shopify/shop-pay-logo.tsx create mode 100644 components/shopify/store-assistant.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/loader.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/rainbow-button.tsx create mode 100644 components/ui/remix-icon.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 config/site.ts create mode 100644 config/swatches.ts create mode 100644 graphql/cart.ts create mode 100644 graphql/collections.ts create mode 100644 graphql/customer.ts create mode 100644 graphql/policies.ts create mode 100644 graphql/products.ts create mode 100644 graphql/search.ts create mode 100644 hooks/use-shopify-cart.ts create mode 100644 hooks/use-shopify-collections.ts create mode 100644 hooks/use-shopify-policies.ts create mode 100644 hooks/use-shopify-products.ts create mode 100644 hooks/use-shopify-search.ts create mode 100644 instrumentation.ts create mode 100644 lib/cloud.ts create mode 100644 lib/utils.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 services/shopify/catalog.ts create mode 100644 services/shopify/client.ts create mode 100644 services/shopify/config.ts create mode 100644 services/shopify/customer.ts create mode 100644 services/shopify/session.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..afffa50 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Shopify Storefront +NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop +# NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN= +# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07 + +# 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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dc6cbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules/* +.next +next-env.d.ts +.yarn/install-state.gz +.env*.local +tsconfig.tsbuildinfo + +# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit +.hydrogen-gql-*.json diff --git a/app.globals.json b/app.globals.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/app.globals.json @@ -0,0 +1 @@ +{} diff --git a/app/account/activate/[id]/[token]/page.tsx b/app/account/activate/[id]/[token]/page.tsx new file mode 100644 index 0000000..c6d5dc1 --- /dev/null +++ b/app/account/activate/[id]/[token]/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/account/login/page.tsx b/app/account/login/page.tsx new file mode 100644 index 0000000..e816ce4 --- /dev/null +++ b/app/account/login/page.tsx @@ -0,0 +1,38 @@ +import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; + +export const metadata = { title: 'Sign in — Shop' }; + +export default function Page() { + return ( +
+ + + New here?{' '} + + Create an account + + + + Forgot your password? + + + } + /> +
+ ); +} diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000..be24866 --- /dev/null +++ b/app/account/page.tsx @@ -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 ( +
+

+ Order history +

+

+ {customer.displayName} · {customer.email} +

+ +
+ +
+
+ ); +} diff --git a/app/account/recover/page.tsx b/app/account/recover/page.tsx new file mode 100644 index 0000000..1d921c3 --- /dev/null +++ b/app/account/recover/page.tsx @@ -0,0 +1,23 @@ +import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; + +export const metadata = { title: 'Reset password — Shop' }; + +export default function Page() { + return ( +
+ Back to sign in + } + /> +
+ ); +} diff --git a/app/account/register/page.tsx b/app/account/register/page.tsx new file mode 100644 index 0000000..5403b7d --- /dev/null +++ b/app/account/register/page.tsx @@ -0,0 +1,43 @@ +import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; + +export const metadata = { title: 'Create account — Shop' }; + +export default function Page() { + return ( +
+ + Already have an account?{' '} + Sign in + + } + /> +
+ ); +} diff --git a/app/account/reset/[id]/[token]/page.tsx b/app/account/reset/[id]/[token]/page.tsx new file mode 100644 index 0000000..a80cb3b --- /dev/null +++ b/app/account/reset/[id]/[token]/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/api/account/activate/route.ts b/app/api/account/activate/route.ts new file mode 100644 index 0000000..98e77c0 --- /dev/null +++ b/app/api/account/activate/route.ts @@ -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 }); +} diff --git a/app/api/account/login/route.ts b/app/api/account/login/route.ts new file mode 100644 index 0000000..27eea71 --- /dev/null +++ b/app/api/account/login/route.ts @@ -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 }); +} diff --git a/app/api/account/logout/route.ts b/app/api/account/logout/route.ts new file mode 100644 index 0000000..175c55e --- /dev/null +++ b/app/api/account/logout/route.ts @@ -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 }); +} diff --git a/app/api/account/me/route.ts b/app/api/account/me/route.ts new file mode 100644 index 0000000..46e1950 --- /dev/null +++ b/app/api/account/me/route.ts @@ -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, + }, + }); +} diff --git a/app/api/account/recover/route.ts b/app/api/account/recover/route.ts new file mode 100644 index 0000000..15fc1c9 --- /dev/null +++ b/app/api/account/recover/route.ts @@ -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 }); +} diff --git a/app/api/account/register/route.ts b/app/api/account/register/route.ts new file mode 100644 index 0000000..9ed72a2 --- /dev/null +++ b/app/api/account/register/route.ts @@ -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 }); +} diff --git a/app/api/account/reset/route.ts b/app/api/account/reset/route.ts new file mode 100644 index 0000000..96ada16 --- /dev/null +++ b/app/api/account/reset/route.ts @@ -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 }); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..a9ea3e2 --- /dev/null +++ b/app/api/chat/route.ts @@ -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 }); +} diff --git a/app/collections/[handle]/page.tsx b/app/collections/[handle]/page.tsx new file mode 100644 index 0000000..1ad0e03 --- /dev/null +++ b/app/collections/[handle]/page.tsx @@ -0,0 +1,5 @@ +import CollectionDetail from '@/components/shopify/collection-detail'; + +export default function Page() { + return ; +} diff --git a/app/collections/page.tsx b/app/collections/page.tsx new file mode 100644 index 0000000..e5da58c --- /dev/null +++ b/app/collections/page.tsx @@ -0,0 +1,5 @@ +import Collections from '@/components/shopify/collections'; + +export default function Page() { + return ; +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..6739a11 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from 'react'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+
+ Something went wrong +
+
+
+ {error.message} +
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..9615c91 --- /dev/null +++ b/app/globals.css @@ -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); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..56b06f6 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {/* Header owns the cart drawer, search and account menu, so every + route gets them by rendering here rather than page by page. */} +
+
{children}
+