diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..400a157 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Shopify Storefront +NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop +# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN= +# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-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/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..cce2cdd --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,201 @@ +import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { z } from 'zod'; +import { + searchProducts, + getProduct, + getProductsPage, + getCollections, + getCollectionProductsPage, +} 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?.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, + })), + }; + }, + }), + + 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/globals.css b/app/globals.css index ad08907..9615c91 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,7 @@ @import 'tailwindcss'; +@custom-variant dark (&:is(.dark *)); + @theme { /* Refined neutral modern palette */ --color-background: hsl(0 0% 100%); @@ -56,6 +58,15 @@ } /* 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, @@ -184,3 +195,36 @@ button, .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 index 554c4c1..df9f297 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import React from 'react'; import './globals.css'; import { Geist, Geist_Mono } from 'next/font/google'; +import StoreAssistant from '@/components/shopify/store-assistant'; const geist = Geist({ subsets: ['latin'], @@ -23,6 +24,7 @@ export default function RootLayout({ {children} + ); diff --git a/components.json b/components.json new file mode 100644 index 0000000..c5d8163 --- /dev/null +++ b/components.json @@ -0,0 +1,24 @@ +{ + "$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}" + } +} diff --git a/components/ai-elements/attachments.tsx b/components/ai-elements/attachments.tsx new file mode 100644 index 0000000..d4e4e34 --- /dev/null +++ b/components/ai-elements/attachments.tsx @@ -0,0 +1,426 @@ +"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 = { + 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 ? ( + {filename + ) : ( + {filename + ); + +// ============================================================================ +// Contexts +// ============================================================================ + +interface AttachmentsContextValue { + variant: AttachmentVariant; +} + +const AttachmentsContext = createContext(null); + +interface AttachmentContextValue { + data: AttachmentData; + mediaCategory: AttachmentMediaCategory; + onRemove?: () => void; + variant: AttachmentVariant; +} + +const AttachmentContext = createContext(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 "); + } + return ctx; +}; + +// ============================================================================ +// Attachments - Container +// ============================================================================ + +export type AttachmentsProps = HTMLAttributes & { + variant?: AttachmentVariant; +}; + +export const Attachments = ({ + variant = "grid", + className, + children, + ...props +}: AttachmentsProps) => { + const contextValue = useMemo(() => ({ variant }), [variant]); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// Attachment - Item +// ============================================================================ + +export type AttachmentProps = HTMLAttributes & { + data: AttachmentData; + onRemove?: () => void; +}; + +export const Attachment = ({ + data, + onRemove, + className, + children, + ...props +}: AttachmentProps) => { + const { variant } = useAttachmentsContext(); + const mediaCategory = getMediaCategory(data); + + const contextValue = useMemo( + () => ({ data, mediaCategory, onRemove, variant }), + [data, mediaCategory, onRemove, variant] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// AttachmentPreview - Media preview +// ============================================================================ + +export type AttachmentPreviewProps = HTMLAttributes & { + 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) => ( + + ); + + 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