diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..6b94237
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# Shopify Storefront
+NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
+# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
+# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07
+
+# Store assistant (/api/chat) — https://openrouter.ai/keys
+OPENROUTER_API_KEY=
+# Optional; defaults to anthropic/claude-sonnet-4.5
+# OPENROUTER_MODEL=
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
new file mode 100644
index 0000000..5330ad5
--- /dev/null
+++ b/app/api/chat/route.ts
@@ -0,0 +1,197 @@
+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 ?? 'anthropic/claude-sonnet-4.5';
+
+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({
+ model: openrouter(MODEL),
+ 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) };
+ },
+ }),
+ },
+ });
+
+ return result.toUIMessageStreamResponse();
+}
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..4ee62ee
--- /dev/null
+++ b/components.json
@@ -0,0 +1,21 @@
+{
+ "$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": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx
new file mode 100644
index 0000000..bc1441a
--- /dev/null
+++ b/components/ai-elements/code-block.tsx
@@ -0,0 +1,562 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
+import {
+ createContext,
+ memo,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type {
+ BundledLanguage,
+ BundledTheme,
+ HighlighterGeneric,
+ ThemedToken,
+} from "shiki";
+import { createHighlighter } from "shiki";
+
+// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
+// oxlint-disable-next-line eslint(no-bitwise)
+const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
+// oxlint-disable-next-line eslint(no-bitwise)
+const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
+const isUnderline = (fontStyle: number | undefined) =>
+ // oxlint-disable-next-line eslint(no-bitwise)
+ fontStyle && fontStyle & 4;
+
+// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
+interface KeyedToken {
+ token: ThemedToken;
+ key: string;
+}
+interface KeyedLine {
+ tokens: KeyedToken[];
+ key: string;
+}
+
+const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
+ lines.map((line, lineIdx) => ({
+ key: `line-${lineIdx}`,
+ tokens: line.map((token, tokenIdx) => ({
+ key: `line-${lineIdx}-${tokenIdx}`,
+ token,
+ })),
+ }));
+
+// Token rendering component
+const TokenSpan = ({ token }: { token: ThemedToken }) => (
+
+ {token.content}
+
+);
+
+// Line number styles using CSS counters
+const LINE_NUMBER_CLASSES = cn(
+ "block",
+ "before:content-[counter(line)]",
+ "before:inline-block",
+ "before:[counter-increment:line]",
+ "before:w-8",
+ "before:mr-4",
+ "before:text-right",
+ "before:text-muted-foreground/50",
+ "before:font-mono",
+ "before:select-none"
+);
+
+// Line rendering component
+const LineSpan = ({
+ keyedLine,
+ showLineNumbers,
+}: {
+ keyedLine: KeyedLine;
+ showLineNumbers: boolean;
+}) => (
+
+ {keyedLine.tokens.length === 0
+ ? "\n"
+ : keyedLine.tokens.map(({ token, key }) => (
+
+ ))}
+
+);
+
+// Types
+type CodeBlockProps = HTMLAttributes & {
+ code: string;
+ language: BundledLanguage;
+ showLineNumbers?: boolean;
+};
+
+interface TokenizedCode {
+ tokens: ThemedToken[][];
+ fg: string;
+ bg: string;
+}
+
+interface CodeBlockContextType {
+ code: string;
+}
+
+// Context
+const CodeBlockContext = createContext({
+ code: "",
+});
+
+// Highlighter cache (singleton per language)
+const highlighterCache = new Map<
+ string,
+ Promise>
+>();
+
+// Token cache
+const tokensCache = new Map();
+
+// Subscribers for async token updates
+const subscribers = new Map void>>();
+
+const getTokensCacheKey = (code: string, language: BundledLanguage) => {
+ const start = code.slice(0, 100);
+ const end = code.length > 100 ? code.slice(-100) : "";
+ return `${language}:${code.length}:${start}:${end}`;
+};
+
+const getHighlighter = (
+ language: BundledLanguage
+): Promise> => {
+ const cached = highlighterCache.get(language);
+ if (cached) {
+ return cached;
+ }
+
+ const highlighterPromise = createHighlighter({
+ langs: [language],
+ themes: ["github-light", "github-dark"],
+ });
+
+ highlighterCache.set(language, highlighterPromise);
+ return highlighterPromise;
+};
+
+// Create raw tokens for immediate display while highlighting loads
+const createRawTokens = (code: string): TokenizedCode => ({
+ bg: "transparent",
+ fg: "inherit",
+ tokens: code.split("\n").map((line) =>
+ line === ""
+ ? []
+ : [
+ {
+ color: "inherit",
+ content: line,
+ } as ThemedToken,
+ ]
+ ),
+});
+
+// Synchronous highlight with callback for async results
+export const highlightCode = (
+ code: string,
+ language: BundledLanguage,
+ // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
+ callback?: (result: TokenizedCode) => void
+): TokenizedCode | null => {
+ const tokensCacheKey = getTokensCacheKey(code, language);
+
+ // Return cached result if available
+ const cached = tokensCache.get(tokensCacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ // Subscribe callback if provided
+ if (callback) {
+ if (!subscribers.has(tokensCacheKey)) {
+ subscribers.set(tokensCacheKey, new Set());
+ }
+ subscribers.get(tokensCacheKey)?.add(callback);
+ }
+
+ // Start highlighting in background - fire-and-forget async pattern
+ getHighlighter(language)
+ // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
+ .then((highlighter) => {
+ const availableLangs = highlighter.getLoadedLanguages();
+ const langToUse = availableLangs.includes(language) ? language : "text";
+
+ const result = highlighter.codeToTokens(code, {
+ lang: langToUse,
+ themes: {
+ dark: "github-dark",
+ light: "github-light",
+ },
+ });
+
+ const tokenized: TokenizedCode = {
+ bg: result.bg ?? "transparent",
+ fg: result.fg ?? "inherit",
+ tokens: result.tokens,
+ };
+
+ // Cache the result
+ tokensCache.set(tokensCacheKey, tokenized);
+
+ // Notify all subscribers
+ const subs = subscribers.get(tokensCacheKey);
+ if (subs) {
+ for (const sub of subs) {
+ sub(tokenized);
+ }
+ subscribers.delete(tokensCacheKey);
+ }
+ })
+ // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
+ .catch((error) => {
+ console.error("Failed to highlight code:", error);
+ subscribers.delete(tokensCacheKey);
+ });
+
+ return null;
+};
+
+const CodeBlockBody = memo(
+ ({
+ tokenized,
+ showLineNumbers,
+ className,
+ }: {
+ tokenized: TokenizedCode;
+ showLineNumbers: boolean;
+ className?: string;
+ }) => {
+ const preStyle = useMemo(
+ () => ({
+ backgroundColor: tokenized.bg,
+ color: tokenized.fg,
+ }),
+ [tokenized.bg, tokenized.fg]
+ );
+
+ const keyedLines = useMemo(
+ () => addKeysToTokens(tokenized.tokens),
+ [tokenized.tokens]
+ );
+
+ return (
+
+
+ {keyedLines.map((keyedLine) => (
+
+ ))}
+
+
+ );
+ },
+ (prevProps, nextProps) =>
+ prevProps.tokenized === nextProps.tokenized &&
+ prevProps.showLineNumbers === nextProps.showLineNumbers &&
+ prevProps.className === nextProps.className
+);
+
+CodeBlockBody.displayName = "CodeBlockBody";
+
+export const CodeBlockContainer = ({
+ className,
+ language,
+ style,
+ ...props
+}: HTMLAttributes & { language: string }) => (
+
+);
+
+export const CodeBlockHeader = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+export const CodeBlockTitle = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+export const CodeBlockFilename = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+export const CodeBlockActions = ({
+ children,
+ className,
+ ...props
+}: HTMLAttributes) => (
+
+ {children}
+
+);
+
+export const CodeBlockContent = ({
+ code,
+ language,
+ showLineNumbers = false,
+}: {
+ code: string;
+ language: BundledLanguage;
+ showLineNumbers?: boolean;
+}) => {
+ // Memoized raw tokens for immediate display
+ const rawTokens = useMemo(() => createRawTokens(code), [code]);
+
+ // Synchronous cache lookup — avoids setState in effect for cached results
+ const syncTokens = useMemo(
+ () => highlightCode(code, language) ?? rawTokens,
+ [code, language, rawTokens]
+ );
+
+ // Async highlighting result (populated after shiki loads)
+ const [asyncTokens, setAsyncTokens] = useState(null);
+ const asyncKeyRef = useRef({ code, language });
+
+ // Invalidate stale async tokens synchronously during render
+ if (
+ asyncKeyRef.current.code !== code ||
+ asyncKeyRef.current.language !== language
+ ) {
+ asyncKeyRef.current = { code, language };
+ setAsyncTokens(null);
+ }
+
+ useEffect(() => {
+ let cancelled = false;
+
+ highlightCode(code, language, (result) => {
+ if (!cancelled) {
+ setAsyncTokens(result);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [code, language]);
+
+ const tokenized = asyncTokens ?? syncTokens;
+
+ return (
+
+
+
+ );
+};
+
+export const CodeBlock = ({
+ code,
+ language,
+ showLineNumbers = false,
+ className,
+ children,
+ ...props
+}: CodeBlockProps) => {
+ const contextValue = useMemo(() => ({ code }), [code]);
+
+ return (
+
+
+ {children}
+
+
+
+ );
+};
+
+export type CodeBlockCopyButtonProps = ComponentProps & {
+ onCopy?: () => void;
+ onError?: (error: Error) => void;
+ timeout?: number;
+};
+
+export const CodeBlockCopyButton = ({
+ onCopy,
+ onError,
+ timeout = 2000,
+ children,
+ className,
+ ...props
+}: CodeBlockCopyButtonProps) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const timeoutRef = useRef(0);
+ const { code } = useContext(CodeBlockContext);
+
+ const copyToClipboard = useCallback(async () => {
+ if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
+ onError?.(new Error("Clipboard API not available"));
+ return;
+ }
+
+ try {
+ if (!isCopied) {
+ await navigator.clipboard.writeText(code);
+ setIsCopied(true);
+ onCopy?.();
+ timeoutRef.current = window.setTimeout(
+ () => setIsCopied(false),
+ timeout
+ );
+ }
+ } catch (error) {
+ onError?.(error as Error);
+ }
+ }, [code, onCopy, onError, timeout, isCopied]);
+
+ useEffect(
+ () => () => {
+ window.clearTimeout(timeoutRef.current);
+ },
+ []
+ );
+
+ const Icon = isCopied ? CheckIcon : CopyIcon;
+
+ return (
+
+ );
+};
+
+export type CodeBlockLanguageSelectorProps = ComponentProps;
+
+export const CodeBlockLanguageSelector = (
+ props: CodeBlockLanguageSelectorProps
+) => ;
+
+export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
+ typeof SelectTrigger
+>;
+
+export const CodeBlockLanguageSelectorTrigger = ({
+ className,
+ ...props
+}: CodeBlockLanguageSelectorTriggerProps) => (
+
+);
+
+export type CodeBlockLanguageSelectorValueProps = ComponentProps<
+ typeof SelectValue
+>;
+
+export const CodeBlockLanguageSelectorValue = (
+ props: CodeBlockLanguageSelectorValueProps
+) => ;
+
+export type CodeBlockLanguageSelectorContentProps = ComponentProps<
+ typeof SelectContent
+>;
+
+export const CodeBlockLanguageSelectorContent = ({
+ align = "end",
+ ...props
+}: CodeBlockLanguageSelectorContentProps) => (
+
+);
+
+export type CodeBlockLanguageSelectorItemProps = ComponentProps<
+ typeof SelectItem
+>;
+
+export const CodeBlockLanguageSelectorItem = (
+ props: CodeBlockLanguageSelectorItemProps
+) => ;
diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx
new file mode 100644
index 0000000..21e5d82
--- /dev/null
+++ b/components/ai-elements/conversation.tsx
@@ -0,0 +1,168 @@
+"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;
+
+export const Conversation = ({ className, ...props }: ConversationProps) => (
+
+);
+
+export type ConversationContentProps = ComponentProps<
+ typeof StickToBottom.Content
+>;
+
+export const ConversationContent = ({
+ className,
+ ...props
+}: ConversationContentProps) => (
+
+);
+
+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) => (
+
+ {children ?? (
+ <>
+ {icon &&
{icon}
}
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ >
+ )}
+
+);
+
+export type ConversationScrollButtonProps = ComponentProps;
+
+export const ConversationScrollButton = ({
+ className,
+ ...props
+}: ConversationScrollButtonProps) => {
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
+
+ const handleScrollToBottom = useCallback(() => {
+ scrollToBottom();
+ }, [scrollToBottom]);
+
+ return (
+ !isAtBottom && (
+
+ )
+ );
+};
+
+const getMessageText = (message: UIMessage): string =>
+ message.parts
+ .filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("");
+
+export type ConversationDownloadProps = Omit<
+ ComponentProps,
+ "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 (
+
+ );
+};
diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx
new file mode 100644
index 0000000..cc0e62c
--- /dev/null
+++ b/components/ai-elements/message.tsx
@@ -0,0 +1,360 @@
+"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 { cjk } from "@streamdown/cjk";
+import { code } from "@streamdown/code";
+import { math } from "@streamdown/math";
+import { mermaid } from "@streamdown/mermaid";
+import type { UIMessage } from "ai";
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
+import {
+ createContext,
+ memo,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { Streamdown } from "streamdown";
+
+export type MessageProps = HTMLAttributes & {
+ from: UIMessage["role"];
+};
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+
+);
+
+export type MessageContentProps = HTMLAttributes;
+
+export const MessageContent = ({
+ children,
+ className,
+ ...props
+}: MessageContentProps) => (
+
+ {children}
+
+);
+
+export type MessageActionsProps = ComponentProps<"div">;
+
+export const MessageActions = ({
+ className,
+ children,
+ ...props
+}: MessageActionsProps) => (
+
+ {children}
+
+);
+
+export type MessageActionProps = ComponentProps & {
+ tooltip?: string;
+ label?: string;
+};
+
+export const MessageAction = ({
+ tooltip,
+ children,
+ label,
+ variant = "ghost",
+ size = "icon-sm",
+ ...props
+}: MessageActionProps) => {
+ const button = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
+
+interface MessageBranchContextType {
+ currentBranch: number;
+ totalBranches: number;
+ goToPrevious: () => void;
+ goToNext: () => void;
+ branches: ReactElement[];
+ setBranches: (branches: ReactElement[]) => void;
+}
+
+const MessageBranchContext = createContext(
+ 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 & {
+ defaultBranch?: number;
+ onBranchChange?: (branchIndex: number) => void;
+};
+
+export const MessageBranch = ({
+ defaultBranch = 0,
+ onBranchChange,
+ className,
+ ...props
+}: MessageBranchProps) => {
+ const [currentBranch, setCurrentBranch] = useState(defaultBranch);
+ const [branches, setBranches] = useState([]);
+
+ 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(
+ () => ({
+ branches,
+ currentBranch,
+ goToNext,
+ goToPrevious,
+ setBranches,
+ totalBranches: branches.length,
+ }),
+ [branches, currentBranch, goToNext, goToPrevious]
+ );
+
+ return (
+
+ div]:pb-0", className)}
+ {...props}
+ />
+
+ );
+};
+
+export type MessageBranchContentProps = HTMLAttributes
;
+
+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]:pb-0",
+ index === currentBranch ? "block" : "hidden"
+ )}
+ key={branch.key}
+ {...props}
+ >
+ {branch}
+
+ ));
+};
+
+export type MessageBranchSelectorProps = ComponentProps;
+
+export const MessageBranchSelector = ({
+ className,
+ ...props
+}: MessageBranchSelectorProps) => {
+ const { totalBranches } = useMessageBranch();
+
+ // Don't render if there's only one branch
+ if (totalBranches <= 1) {
+ return null;
+ }
+
+ return (
+ *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
+ className
+ )}
+ orientation="horizontal"
+ {...props}
+ />
+ );
+};
+
+export type MessageBranchPreviousProps = ComponentProps;
+
+export const MessageBranchPrevious = ({
+ children,
+ ...props
+}: MessageBranchPreviousProps) => {
+ const { goToPrevious, totalBranches } = useMessageBranch();
+
+ return (
+
+ );
+};
+
+export type MessageBranchNextProps = ComponentProps;
+
+export const MessageBranchNext = ({
+ children,
+ ...props
+}: MessageBranchNextProps) => {
+ const { goToNext, totalBranches } = useMessageBranch();
+
+ return (
+
+ );
+};
+
+export type MessageBranchPageProps = HTMLAttributes;
+
+export const MessageBranchPage = ({
+ className,
+ ...props
+}: MessageBranchPageProps) => {
+ const { currentBranch, totalBranches } = useMessageBranch();
+
+ return (
+
+ {currentBranch + 1} of {totalBranches}
+
+ );
+};
+
+export type MessageResponseProps = ComponentProps;
+
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+export const MessageResponse = memo(
+ ({ className, ...props }: MessageResponseProps) => (
+ *:first-child]:mt-0 [&>*:last-child]:mb-0",
+ className
+ )}
+ plugins={streamdownPlugins}
+ {...props}
+ />
+ ),
+ (prevProps, nextProps) =>
+ prevProps.children === nextProps.children &&
+ nextProps.isAnimating === prevProps.isAnimating
+);
+
+MessageResponse.displayName = "MessageResponse";
+
+export type MessageToolbarProps = ComponentProps<"div">;
+
+export const MessageToolbar = ({
+ className,
+ children,
+ ...props
+}: MessageToolbarProps) => (
+
+ {children}
+
+);
diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx
new file mode 100644
index 0000000..5be6019
--- /dev/null
+++ b/components/ai-elements/prompt-input.tsx
@@ -0,0 +1,1463 @@
+"use client";
+
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupTextarea,
+} from "@/components/ui/input-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
+import {
+ CornerDownLeftIcon,
+ ImageIcon,
+ Monitor,
+ PlusIcon,
+ SquareIcon,
+ XIcon,
+} from "lucide-react";
+import { nanoid } from "nanoid";
+import type {
+ ChangeEvent,
+ ChangeEventHandler,
+ ClipboardEventHandler,
+ ComponentProps,
+ FormEvent,
+ FormEventHandler,
+ HTMLAttributes,
+ KeyboardEventHandler,
+ PropsWithChildren,
+ ReactNode,
+ RefObject,
+} from "react";
+import {
+ Children,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+const convertBlobUrlToDataUrl = async (url: string): Promise => {
+ try {
+ const response = await fetch(url);
+ const blob = await response.blob();
+ // FileReader uses callback-based API, wrapping in Promise is necessary
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ reader.onloadend = () => resolve(reader.result as string);
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ reader.onerror = () => resolve(null);
+ reader.readAsDataURL(blob);
+ });
+ } catch {
+ return null;
+ }
+};
+
+const captureScreenshot = async (): Promise => {
+ if (
+ typeof navigator === "undefined" ||
+ !navigator.mediaDevices?.getDisplayMedia
+ ) {
+ return null;
+ }
+
+ let stream: MediaStream | null = null;
+ const video = document.createElement("video");
+ video.muted = true;
+ video.playsInline = true;
+
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({
+ audio: false,
+ video: true,
+ });
+
+ video.srcObject = stream;
+
+ // Video element uses callback-based API, wrapping in Promise is necessary
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+ await new Promise((resolve, reject) => {
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ video.onloadedmetadata = () => resolve();
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ video.onerror = () => reject(new Error("Failed to load screen stream"));
+ });
+
+ await video.play();
+
+ const width = video.videoWidth;
+ const height = video.videoHeight;
+ if (!width || !height) {
+ return null;
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return null;
+ }
+
+ context.drawImage(video, 0, 0, width, height);
+ // canvas.toBlob uses callback-based API, wrapping in Promise is necessary
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+ const blob = await new Promise((resolve) => {
+ canvas.toBlob(resolve, "image/png");
+ });
+ if (!blob) {
+ return null;
+ }
+
+ const timestamp = new Date()
+ .toISOString()
+ .replaceAll(/[:.]/g, "-")
+ .replace("T", "_")
+ .replace("Z", "");
+
+ return new File([blob], `screenshot-${timestamp}.png`, {
+ lastModified: Date.now(),
+ type: "image/png",
+ });
+ } finally {
+ if (stream) {
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+ video.pause();
+ video.srcObject = null;
+ }
+};
+
+// ============================================================================
+// Provider Context & Types
+// ============================================================================
+
+export interface AttachmentsContext {
+ files: (FileUIPart & { id: string })[];
+ add: (files: File[] | FileList) => void;
+ remove: (id: string) => void;
+ clear: () => void;
+ openFileDialog: () => void;
+ fileInputRef: RefObject;
+}
+
+export interface TextInputContext {
+ value: string;
+ setInput: (v: string) => void;
+ clear: () => void;
+}
+
+export interface PromptInputControllerProps {
+ textInput: TextInputContext;
+ attachments: AttachmentsContext;
+ /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
+ __registerFileInput: (
+ ref: RefObject,
+ open: () => void
+ ) => void;
+}
+
+const PromptInputController = createContext(
+ null
+);
+const ProviderAttachmentsContext = createContext(
+ null
+);
+
+export const usePromptInputController = () => {
+ const ctx = useContext(PromptInputController);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside to use usePromptInputController()."
+ );
+ }
+ return ctx;
+};
+
+// Optional variants (do NOT throw). Useful for dual-mode components.
+const useOptionalPromptInputController = () =>
+ useContext(PromptInputController);
+
+export const useProviderAttachments = () => {
+ const ctx = useContext(ProviderAttachmentsContext);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside to use useProviderAttachments()."
+ );
+ }
+ return ctx;
+};
+
+const useOptionalProviderAttachments = () =>
+ useContext(ProviderAttachmentsContext);
+
+export type PromptInputProviderProps = PropsWithChildren<{
+ initialInput?: string;
+}>;
+
+/**
+ * Optional global provider that lifts PromptInput state outside of PromptInput.
+ * If you don't use it, PromptInput stays fully self-managed.
+ */
+export const PromptInputProvider = ({
+ initialInput: initialTextInput = "",
+ children,
+}: PromptInputProviderProps) => {
+ // ----- textInput state
+ const [textInput, setTextInput] = useState(initialTextInput);
+ const clearInput = useCallback(() => setTextInput(""), []);
+
+ // ----- attachments state (global when wrapped)
+ const [attachmentFiles, setAttachmentFiles] = useState<
+ (FileUIPart & { id: string })[]
+ >([]);
+ const fileInputRef = useRef(null);
+ // oxlint-disable-next-line eslint(no-empty-function)
+ const openRef = useRef<() => void>(() => {});
+
+ const add = useCallback((files: File[] | FileList) => {
+ const incoming = [...files];
+ if (incoming.length === 0) {
+ return;
+ }
+
+ setAttachmentFiles((prev) => [
+ ...prev,
+ ...incoming.map((file) => ({
+ filename: file.name,
+ id: nanoid(),
+ mediaType: file.type,
+ type: "file" as const,
+ url: URL.createObjectURL(file),
+ })),
+ ]);
+ }, []);
+
+ const remove = useCallback((id: string) => {
+ setAttachmentFiles((prev) => {
+ const found = prev.find((f) => f.id === id);
+ if (found?.url) {
+ URL.revokeObjectURL(found.url);
+ }
+ return prev.filter((f) => f.id !== id);
+ });
+ }, []);
+
+ const clear = useCallback(() => {
+ setAttachmentFiles((prev) => {
+ for (const f of prev) {
+ if (f.url) {
+ URL.revokeObjectURL(f.url);
+ }
+ }
+ return [];
+ });
+ }, []);
+
+ // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
+ const attachmentsRef = useRef(attachmentFiles);
+
+ useEffect(() => {
+ attachmentsRef.current = attachmentFiles;
+ }, [attachmentFiles]);
+
+ // Cleanup blob URLs on unmount to prevent memory leaks
+ useEffect(
+ () => () => {
+ for (const f of attachmentsRef.current) {
+ if (f.url) {
+ URL.revokeObjectURL(f.url);
+ }
+ }
+ },
+ []
+ );
+
+ const openFileDialog = useCallback(() => {
+ openRef.current?.();
+ }, []);
+
+ const attachments = useMemo(
+ () => ({
+ add,
+ clear,
+ fileInputRef,
+ files: attachmentFiles,
+ openFileDialog,
+ remove,
+ }),
+ [attachmentFiles, add, remove, clear, openFileDialog]
+ );
+
+ const __registerFileInput = useCallback(
+ (ref: RefObject, open: () => void) => {
+ fileInputRef.current = ref.current;
+ openRef.current = open;
+ },
+ []
+ );
+
+ const controller = useMemo(
+ () => ({
+ __registerFileInput,
+ attachments,
+ textInput: {
+ clear: clearInput,
+ setInput: setTextInput,
+ value: textInput,
+ },
+ }),
+ [textInput, clearInput, attachments, __registerFileInput]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext(null);
+
+export const usePromptInputAttachments = () => {
+ // Prefer local context (inside PromptInput) as it has validation, fall back to provider
+ const provider = useOptionalProviderAttachments();
+ const local = useContext(LocalAttachmentsContext);
+ const context = local ?? provider;
+ if (!context) {
+ throw new Error(
+ "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
+ );
+ }
+ return context;
+};
+
+// ============================================================================
+// Referenced Sources (Local to PromptInput)
+// ============================================================================
+
+export interface ReferencedSourcesContext {
+ sources: (SourceDocumentUIPart & { id: string })[];
+ add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
+ remove: (id: string) => void;
+ clear: () => void;
+}
+
+export const LocalReferencedSourcesContext =
+ createContext(null);
+
+export const usePromptInputReferencedSources = () => {
+ const ctx = useContext(LocalReferencedSourcesContext);
+ if (!ctx) {
+ throw new Error(
+ "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider"
+ );
+ }
+ return ctx;
+};
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+ typeof DropdownMenuItem
+> & {
+ label?: string;
+};
+
+export const PromptInputActionAddAttachments = ({
+ label = "Add photos or files",
+ ...props
+}: PromptInputActionAddAttachmentsProps) => {
+ const attachments = usePromptInputAttachments();
+
+ const handleSelect = useCallback(
+ (e: Event) => {
+ e.preventDefault();
+ attachments.openFileDialog();
+ },
+ [attachments]
+ );
+
+ return (
+
+ {label}
+
+ );
+};
+
+export type PromptInputActionAddScreenshotProps = ComponentProps<
+ typeof DropdownMenuItem
+> & {
+ label?: string;
+};
+
+export const PromptInputActionAddScreenshot = ({
+ label = "Take screenshot",
+ onSelect,
+ ...props
+}: PromptInputActionAddScreenshotProps) => {
+ const attachments = usePromptInputAttachments();
+
+ const handleSelect = useCallback(
+ async (event: Event) => {
+ onSelect?.(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ try {
+ const screenshot = await captureScreenshot();
+ if (screenshot) {
+ attachments.add([screenshot]);
+ }
+ } catch (error) {
+ if (
+ error instanceof DOMException &&
+ (error.name === "NotAllowedError" || error.name === "AbortError")
+ ) {
+ return;
+ }
+ throw error;
+ }
+ },
+ [onSelect, attachments]
+ );
+
+ return (
+
+
+ {label}
+
+ );
+};
+
+export interface PromptInputMessage {
+ text: string;
+ files: FileUIPart[];
+}
+
+export type PromptInputProps = Omit<
+ HTMLAttributes,
+ "onSubmit" | "onError"
+> & {
+ // e.g., "image/*" or leave undefined for any
+ accept?: string;
+ multiple?: boolean;
+ // When true, accepts drops anywhere on document. Default false (opt-in).
+ globalDrop?: boolean;
+ // Render a hidden input with given name and keep it in sync for native form posts. Default false.
+ syncHiddenInput?: boolean;
+ // Minimal constraints
+ maxFiles?: number;
+ // bytes
+ maxFileSize?: number;
+ onError?: (err: {
+ code: "max_files" | "max_file_size" | "accept";
+ message: string;
+ }) => void;
+ onSubmit: (
+ message: PromptInputMessage,
+ event: FormEvent
+ ) => void | Promise;
+};
+
+export const PromptInput = ({
+ className,
+ accept,
+ multiple,
+ globalDrop,
+ syncHiddenInput,
+ maxFiles,
+ maxFileSize,
+ onError,
+ onSubmit,
+ children,
+ ...props
+}: PromptInputProps) => {
+ // Try to use a provider controller if present
+ const controller = useOptionalPromptInputController();
+ const usingProvider = !!controller;
+
+ // Refs
+ const inputRef = useRef(null);
+ const formRef = useRef(null);
+
+ // ----- Local attachments (only used when no provider)
+ const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
+ const files = usingProvider ? controller.attachments.files : items;
+
+ // ----- Local referenced sources (always local to PromptInput)
+ const [referencedSources, setReferencedSources] = useState<
+ (SourceDocumentUIPart & { id: string })[]
+ >([]);
+
+ // Keep a ref to files for cleanup on unmount (avoids stale closure)
+ const filesRef = useRef(files);
+
+ useEffect(() => {
+ filesRef.current = files;
+ }, [files]);
+
+ const openFileDialogLocal = useCallback(() => {
+ inputRef.current?.click();
+ }, []);
+
+ const matchesAccept = useCallback(
+ (f: File) => {
+ if (!accept || accept.trim() === "") {
+ return true;
+ }
+
+ const patterns = accept
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ return patterns.some((pattern) => {
+ if (pattern.endsWith("/*")) {
+ // e.g: image/* -> image/
+ const prefix = pattern.slice(0, -1);
+ return f.type.startsWith(prefix);
+ }
+ return f.type === pattern;
+ });
+ },
+ [accept]
+ );
+
+ const addLocal = useCallback(
+ (fileList: File[] | FileList) => {
+ const incoming = [...fileList];
+ const accepted = incoming.filter((f) => matchesAccept(f));
+ if (incoming.length && accepted.length === 0) {
+ onError?.({
+ code: "accept",
+ message: "No files match the accepted types.",
+ });
+ return;
+ }
+ const withinSize = (f: File) =>
+ maxFileSize ? f.size <= maxFileSize : true;
+ const sized = accepted.filter(withinSize);
+ if (accepted.length > 0 && sized.length === 0) {
+ onError?.({
+ code: "max_file_size",
+ message: "All files exceed the maximum size.",
+ });
+ return;
+ }
+
+ setItems((prev) => {
+ const capacity =
+ typeof maxFiles === "number"
+ ? Math.max(0, maxFiles - prev.length)
+ : undefined;
+ const capped =
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ if (typeof capacity === "number" && sized.length > capacity) {
+ onError?.({
+ code: "max_files",
+ message: "Too many files. Some were not added.",
+ });
+ }
+ const next: (FileUIPart & { id: string })[] = [];
+ for (const file of capped) {
+ next.push({
+ filename: file.name,
+ id: nanoid(),
+ mediaType: file.type,
+ type: "file",
+ url: URL.createObjectURL(file),
+ });
+ }
+ return [...prev, ...next];
+ });
+ },
+ [matchesAccept, maxFiles, maxFileSize, onError]
+ );
+
+ const removeLocal = useCallback(
+ (id: string) =>
+ setItems((prev) => {
+ const found = prev.find((file) => file.id === id);
+ if (found?.url) {
+ URL.revokeObjectURL(found.url);
+ }
+ return prev.filter((file) => file.id !== id);
+ }),
+ []
+ );
+
+ // Wrapper that validates files before calling provider's add
+ const addWithProviderValidation = useCallback(
+ (fileList: File[] | FileList) => {
+ const incoming = [...fileList];
+ const accepted = incoming.filter((f) => matchesAccept(f));
+ if (incoming.length && accepted.length === 0) {
+ onError?.({
+ code: "accept",
+ message: "No files match the accepted types.",
+ });
+ return;
+ }
+ const withinSize = (f: File) =>
+ maxFileSize ? f.size <= maxFileSize : true;
+ const sized = accepted.filter(withinSize);
+ if (accepted.length > 0 && sized.length === 0) {
+ onError?.({
+ code: "max_file_size",
+ message: "All files exceed the maximum size.",
+ });
+ return;
+ }
+
+ const currentCount = files.length;
+ const capacity =
+ typeof maxFiles === "number"
+ ? Math.max(0, maxFiles - currentCount)
+ : undefined;
+ const capped =
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ if (typeof capacity === "number" && sized.length > capacity) {
+ onError?.({
+ code: "max_files",
+ message: "Too many files. Some were not added.",
+ });
+ }
+
+ if (capped.length > 0) {
+ controller?.attachments.add(capped);
+ }
+ },
+ [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
+ );
+
+ const clearAttachments = useCallback(
+ () =>
+ usingProvider
+ ? controller?.attachments.clear()
+ : setItems((prev) => {
+ for (const file of prev) {
+ if (file.url) {
+ URL.revokeObjectURL(file.url);
+ }
+ }
+ return [];
+ }),
+ [usingProvider, controller]
+ );
+
+ const clearReferencedSources = useCallback(
+ () => setReferencedSources([]),
+ []
+ );
+
+ const add = usingProvider ? addWithProviderValidation : addLocal;
+ const remove = usingProvider ? controller.attachments.remove : removeLocal;
+ const openFileDialog = usingProvider
+ ? controller.attachments.openFileDialog
+ : openFileDialogLocal;
+
+ const clear = useCallback(() => {
+ clearAttachments();
+ clearReferencedSources();
+ }, [clearAttachments, clearReferencedSources]);
+
+ // Let provider know about our hidden file input so external menus can call openFileDialog()
+ useEffect(() => {
+ if (!usingProvider) {
+ return;
+ }
+ controller.__registerFileInput(inputRef, () => inputRef.current?.click());
+ }, [usingProvider, controller]);
+
+ // Note: File input cannot be programmatically set for security reasons
+ // The syncHiddenInput prop is no longer functional
+ useEffect(() => {
+ if (syncHiddenInput && inputRef.current && files.length === 0) {
+ inputRef.current.value = "";
+ }
+ }, [files, syncHiddenInput]);
+
+ // Attach drop handlers on nearest form and document (opt-in)
+ useEffect(() => {
+ const form = formRef.current;
+ if (!form) {
+ return;
+ }
+ if (globalDrop) {
+ // when global drop is on, let the document-level handler own drops
+ return;
+ }
+
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ };
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ add(e.dataTransfer.files);
+ }
+ };
+ form.addEventListener("dragover", onDragOver);
+ form.addEventListener("drop", onDrop);
+ return () => {
+ form.removeEventListener("dragover", onDragOver);
+ form.removeEventListener("drop", onDrop);
+ };
+ }, [add, globalDrop]);
+
+ useEffect(() => {
+ if (!globalDrop) {
+ return;
+ }
+
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ };
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ add(e.dataTransfer.files);
+ }
+ };
+ document.addEventListener("dragover", onDragOver);
+ document.addEventListener("drop", onDrop);
+ return () => {
+ document.removeEventListener("dragover", onDragOver);
+ document.removeEventListener("drop", onDrop);
+ };
+ }, [add, globalDrop]);
+
+ useEffect(
+ () => () => {
+ if (!usingProvider) {
+ for (const f of filesRef.current) {
+ if (f.url) {
+ URL.revokeObjectURL(f.url);
+ }
+ }
+ }
+ },
+ [usingProvider]
+ );
+
+ const handleChange: ChangeEventHandler = useCallback(
+ (event) => {
+ if (event.currentTarget.files) {
+ add(event.currentTarget.files);
+ }
+ // Reset input value to allow selecting files that were previously removed
+ event.currentTarget.value = "";
+ },
+ [add]
+ );
+
+ const attachmentsCtx = useMemo(
+ () => ({
+ add,
+ clear: clearAttachments,
+ fileInputRef: inputRef,
+ files: files.map((item) => ({ ...item, id: item.id })),
+ openFileDialog,
+ remove,
+ }),
+ [files, add, remove, clearAttachments, openFileDialog]
+ );
+
+ const refsCtx = useMemo(
+ () => ({
+ add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
+ const array = Array.isArray(incoming) ? incoming : [incoming];
+ setReferencedSources((prev) => [
+ ...prev,
+ ...array.map((s) => ({ ...s, id: nanoid() })),
+ ]);
+ },
+ clear: clearReferencedSources,
+ remove: (id: string) => {
+ setReferencedSources((prev) => prev.filter((s) => s.id !== id));
+ },
+ sources: referencedSources,
+ }),
+ [referencedSources, clearReferencedSources]
+ );
+
+ const handleSubmit: FormEventHandler = useCallback(
+ async (event) => {
+ event.preventDefault();
+
+ const form = event.currentTarget;
+ const text = usingProvider
+ ? controller.textInput.value
+ : (() => {
+ const formData = new FormData(form);
+ return (formData.get("message") as string) || "";
+ })();
+
+ // Reset form immediately after capturing text to avoid race condition
+ // where user input during async blob conversion would be lost
+ if (!usingProvider) {
+ form.reset();
+ }
+
+ try {
+ // Convert blob URLs to data URLs asynchronously
+ const convertedFiles: FileUIPart[] = await Promise.all(
+ files.map(async ({ id: _id, ...item }) => {
+ if (item.url?.startsWith("blob:")) {
+ const dataUrl = await convertBlobUrlToDataUrl(item.url);
+ // If conversion failed, keep the original blob URL
+ return {
+ ...item,
+ url: dataUrl ?? item.url,
+ };
+ }
+ return item;
+ })
+ );
+
+ const result = onSubmit({ files: convertedFiles, text }, event);
+
+ // Handle both sync and async onSubmit
+ if (result instanceof Promise) {
+ try {
+ await result;
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ } catch {
+ // Don't clear on error - user may want to retry
+ }
+ } else {
+ // Sync function completed without throwing, clear inputs
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ }
+ } catch {
+ // Don't clear on error - user may want to retry
+ }
+ },
+ [usingProvider, controller, files, onSubmit, clear]
+ );
+
+ // Render with or without local provider
+ const inner = (
+ <>
+
+
+ >
+ );
+
+ const withReferencedSources = (
+
+ {inner}
+
+ );
+
+ // Always provide LocalAttachmentsContext so children get validated add function
+ return (
+
+ {withReferencedSources}
+
+ );
+};
+
+export type PromptInputBodyProps = HTMLAttributes;
+
+export const PromptInputBody = ({
+ className,
+ ...props
+}: PromptInputBodyProps) => (
+
+);
+
+export type PromptInputTextareaProps = ComponentProps<
+ typeof InputGroupTextarea
+>;
+
+export const PromptInputTextarea = ({
+ onChange,
+ onKeyDown,
+ className,
+ placeholder = "What would you like to know?",
+ ...props
+}: PromptInputTextareaProps) => {
+ const controller = useOptionalPromptInputController();
+ const attachments = usePromptInputAttachments();
+ const [isComposing, setIsComposing] = useState(false);
+
+ const handleKeyDown: KeyboardEventHandler = useCallback(
+ (e) => {
+ // Call the external onKeyDown handler first
+ onKeyDown?.(e);
+
+ // If the external handler prevented default, don't run internal logic
+ if (e.defaultPrevented) {
+ return;
+ }
+
+ if (e.key === "Enter") {
+ if (isComposing || e.nativeEvent.isComposing) {
+ return;
+ }
+ if (e.shiftKey) {
+ return;
+ }
+ e.preventDefault();
+
+ // Check if the submit button is disabled before submitting
+ const { form } = e.currentTarget;
+ const submitButton = form?.querySelector(
+ 'button[type="submit"]'
+ ) as HTMLButtonElement | null;
+ if (submitButton?.disabled) {
+ return;
+ }
+
+ form?.requestSubmit();
+ }
+
+ // Remove last attachment when Backspace is pressed and textarea is empty
+ if (
+ e.key === "Backspace" &&
+ e.currentTarget.value === "" &&
+ attachments.files.length > 0
+ ) {
+ e.preventDefault();
+ const lastAttachment = attachments.files.at(-1);
+ if (lastAttachment) {
+ attachments.remove(lastAttachment.id);
+ }
+ }
+ },
+ [onKeyDown, isComposing, attachments]
+ );
+
+ const handlePaste: ClipboardEventHandler = useCallback(
+ (event) => {
+ const items = event.clipboardData?.items;
+
+ if (!items) {
+ return;
+ }
+
+ const files: File[] = [];
+
+ for (const item of items) {
+ if (item.kind === "file") {
+ const file = item.getAsFile();
+ if (file) {
+ files.push(file);
+ }
+ }
+ }
+
+ if (files.length > 0) {
+ event.preventDefault();
+ attachments.add(files);
+ }
+ },
+ [attachments]
+ );
+
+ const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
+
+ const controlledProps = controller
+ ? {
+ onChange: (e: ChangeEvent) => {
+ controller.textInput.setInput(e.currentTarget.value);
+ onChange?.(e);
+ },
+ value: controller.textInput.value,
+ }
+ : {
+ onChange,
+ };
+
+ return (
+
+ );
+};
+
+export type PromptInputHeaderProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputHeader = ({
+ className,
+ ...props
+}: PromptInputHeaderProps) => (
+
+);
+
+export type PromptInputFooterProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputFooter = ({
+ className,
+ ...props
+}: PromptInputFooterProps) => (
+
+);
+
+export type PromptInputToolsProps = HTMLAttributes;
+
+export const PromptInputTools = ({
+ className,
+ ...props
+}: PromptInputToolsProps) => (
+
+);
+
+export type PromptInputButtonTooltip =
+ | string
+ | {
+ content: ReactNode;
+ shortcut?: string;
+ side?: ComponentProps["side"];
+ };
+
+export type PromptInputButtonProps = ComponentProps & {
+ tooltip?: PromptInputButtonTooltip;
+};
+
+export const PromptInputButton = ({
+ variant = "ghost",
+ className,
+ size,
+ tooltip,
+ ...props
+}: PromptInputButtonProps) => {
+ const newSize =
+ size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ const tooltipContent =
+ typeof tooltip === "string" ? tooltip : tooltip.content;
+ const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
+ const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
+
+ return (
+
+ {button}
+
+ {tooltipContent}
+ {shortcut && (
+ {shortcut}
+ )}
+
+
+ );
+};
+
+export type PromptInputActionMenuProps = ComponentProps;
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+
+);
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
+
+export const PromptInputActionMenuTrigger = ({
+ className,
+ children,
+ ...props
+}: PromptInputActionMenuTriggerProps) => (
+
+
+ {children ?? }
+
+
+);
+
+export type PromptInputActionMenuContentProps = ComponentProps<
+ typeof DropdownMenuContent
+>;
+export const PromptInputActionMenuContent = ({
+ className,
+ ...props
+}: PromptInputActionMenuContentProps) => (
+
+);
+
+export type PromptInputActionMenuItemProps = ComponentProps<
+ typeof DropdownMenuItem
+>;
+export const PromptInputActionMenuItem = ({
+ className,
+ ...props
+}: PromptInputActionMenuItemProps) => (
+
+);
+
+// Note: Actions that perform side-effects (like opening a file dialog)
+// are provided in opt-in modules (e.g., prompt-input-attachments).
+
+export type PromptInputSubmitProps = ComponentProps & {
+ status?: ChatStatus;
+ onStop?: () => void;
+};
+
+export const PromptInputSubmit = ({
+ className,
+ variant = "default",
+ size = "icon-sm",
+ status,
+ onStop,
+ onClick,
+ children,
+ ...props
+}: PromptInputSubmitProps) => {
+ const isGenerating = status === "submitted" || status === "streaming";
+
+ let Icon = ;
+
+ if (status === "submitted") {
+ Icon = ;
+ } else if (status === "streaming") {
+ Icon = ;
+ } else if (status === "error") {
+ Icon = ;
+ }
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (isGenerating && onStop) {
+ e.preventDefault();
+ onStop();
+ return;
+ }
+ onClick?.(e);
+ },
+ [isGenerating, onStop, onClick]
+ );
+
+ return (
+
+ {children ?? Icon}
+
+ );
+};
+
+export type PromptInputSelectProps = ComponentProps;
+
+export const PromptInputSelect = (props: PromptInputSelectProps) => (
+
+);
+
+export type PromptInputSelectTriggerProps = ComponentProps<
+ typeof SelectTrigger
+>;
+
+export const PromptInputSelectTrigger = ({
+ className,
+ ...props
+}: PromptInputSelectTriggerProps) => (
+
+);
+
+export type PromptInputSelectContentProps = ComponentProps<
+ typeof SelectContent
+>;
+
+export const PromptInputSelectContent = ({
+ className,
+ ...props
+}: PromptInputSelectContentProps) => (
+
+);
+
+export type PromptInputSelectItemProps = ComponentProps;
+
+export const PromptInputSelectItem = ({
+ className,
+ ...props
+}: PromptInputSelectItemProps) => (
+
+);
+
+export type PromptInputSelectValueProps = ComponentProps;
+
+export const PromptInputSelectValue = ({
+ className,
+ ...props
+}: PromptInputSelectValueProps) => (
+
+);
+
+export type PromptInputHoverCardProps = ComponentProps;
+
+export const PromptInputHoverCard = ({
+ openDelay = 0,
+ closeDelay = 0,
+ ...props
+}: PromptInputHoverCardProps) => (
+
+);
+
+export type PromptInputHoverCardTriggerProps = ComponentProps<
+ typeof HoverCardTrigger
+>;
+
+export const PromptInputHoverCardTrigger = (
+ props: PromptInputHoverCardTriggerProps
+) => ;
+
+export type PromptInputHoverCardContentProps = ComponentProps<
+ typeof HoverCardContent
+>;
+
+export const PromptInputHoverCardContent = ({
+ align = "start",
+ ...props
+}: PromptInputHoverCardContentProps) => (
+
+);
+
+export type PromptInputTabsListProps = HTMLAttributes;
+
+export const PromptInputTabsList = ({
+ className,
+ ...props
+}: PromptInputTabsListProps) => ;
+
+export type PromptInputTabProps = HTMLAttributes;
+
+export const PromptInputTab = ({
+ className,
+ ...props
+}: PromptInputTabProps) => ;
+
+export type PromptInputTabLabelProps = HTMLAttributes;
+
+export const PromptInputTabLabel = ({
+ className,
+ ...props
+}: PromptInputTabLabelProps) => (
+ // Content provided via children in props
+ // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
+
+);
+
+export type PromptInputTabBodyProps = HTMLAttributes;
+
+export const PromptInputTabBody = ({
+ className,
+ ...props
+}: PromptInputTabBodyProps) => (
+
+);
+
+export type PromptInputTabItemProps = HTMLAttributes;
+
+export const PromptInputTabItem = ({
+ className,
+ ...props
+}: PromptInputTabItemProps) => (
+
+);
+
+export type PromptInputCommandProps = ComponentProps;
+
+export const PromptInputCommand = ({
+ className,
+ ...props
+}: PromptInputCommandProps) => ;
+
+export type PromptInputCommandInputProps = ComponentProps;
+
+export const PromptInputCommandInput = ({
+ className,
+ ...props
+}: PromptInputCommandInputProps) => (
+
+);
+
+export type PromptInputCommandListProps = ComponentProps;
+
+export const PromptInputCommandList = ({
+ className,
+ ...props
+}: PromptInputCommandListProps) => (
+
+);
+
+export type PromptInputCommandEmptyProps = ComponentProps;
+
+export const PromptInputCommandEmpty = ({
+ className,
+ ...props
+}: PromptInputCommandEmptyProps) => (
+
+);
+
+export type PromptInputCommandGroupProps = ComponentProps;
+
+export const PromptInputCommandGroup = ({
+ className,
+ ...props
+}: PromptInputCommandGroupProps) => (
+
+);
+
+export type PromptInputCommandItemProps = ComponentProps;
+
+export const PromptInputCommandItem = ({
+ className,
+ ...props
+}: PromptInputCommandItemProps) => (
+
+);
+
+export type PromptInputCommandSeparatorProps = ComponentProps<
+ typeof CommandSeparator
+>;
+
+export const PromptInputCommandSeparator = ({
+ className,
+ ...props
+}: PromptInputCommandSeparatorProps) => (
+
+);
diff --git a/components/ai-elements/shimmer.tsx b/components/ai-elements/shimmer.tsx
new file mode 100644
index 0000000..6b635d0
--- /dev/null
+++ b/components/ai-elements/shimmer.tsx
@@ -0,0 +1,77 @@
+"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;
+
+// Cache motion components at module level to avoid creating during render
+const motionComponentCache = new Map<
+ keyof JSX.IntrinsicElements,
+ React.ComponentType
+>();
+
+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 (
+
+ {children}
+
+ );
+};
+
+export const Shimmer = memo(ShimmerComponent);
diff --git a/components/ai-elements/tool.tsx b/components/ai-elements/tool.tsx
new file mode 100644
index 0000000..18c4bb8
--- /dev/null
+++ b/components/ai-elements/tool.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import type { DynamicToolUIPart, ToolUIPart } from "ai";
+import {
+ CheckCircleIcon,
+ ChevronDownIcon,
+ CircleIcon,
+ ClockIcon,
+ WrenchIcon,
+ XCircleIcon,
+} from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { isValidElement } from "react";
+
+import { CodeBlock } from "./code-block";
+
+export type ToolProps = ComponentProps;
+
+export const Tool = ({ className, ...props }: ToolProps) => (
+
+);
+
+export type ToolPart = ToolUIPart | DynamicToolUIPart;
+
+export type ToolHeaderProps = {
+ title?: string;
+ className?: string;
+} & (
+ | { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
+ | {
+ type: DynamicToolUIPart["type"];
+ state: DynamicToolUIPart["state"];
+ toolName: string;
+ }
+);
+
+const statusLabels: Record = {
+ "approval-requested": "Awaiting Approval",
+ "approval-responded": "Responded",
+ "input-available": "Running",
+ "input-streaming": "Pending",
+ "output-available": "Completed",
+ "output-denied": "Denied",
+ "output-error": "Error",
+};
+
+const statusIcons: Record = {
+ "approval-requested": ,
+ "approval-responded": ,
+ "input-available": ,
+ "input-streaming": ,
+ "output-available": ,
+ "output-denied": ,
+ "output-error": ,
+};
+
+export const getStatusBadge = (status: ToolPart["state"]) => (
+
+ {statusIcons[status]}
+ {statusLabels[status]}
+
+);
+
+export const ToolHeader = ({
+ className,
+ title,
+ type,
+ state,
+ toolName,
+ ...props
+}: ToolHeaderProps) => {
+ const derivedName =
+ type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
+
+ return (
+
+
+
+ {title ?? derivedName}
+ {getStatusBadge(state)}
+
+
+
+ );
+};
+
+export type ToolContentProps = ComponentProps;
+
+export const ToolContent = ({ className, ...props }: ToolContentProps) => (
+
+);
+
+export type ToolInputProps = ComponentProps<"div"> & {
+ input: ToolPart["input"];
+};
+
+export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
+
+);
+
+export type ToolOutputProps = ComponentProps<"div"> & {
+ output: ToolPart["output"];
+ errorText: ToolPart["errorText"];
+};
+
+export const ToolOutput = ({
+ className,
+ output,
+ errorText,
+ ...props
+}: ToolOutputProps) => {
+ if (!(output || errorText)) {
+ return null;
+ }
+
+ let Output = {output as ReactNode}
;
+
+ if (typeof output === "object" && !isValidElement(output)) {
+ Output = (
+
+ );
+ } else if (typeof output === "string") {
+ Output = ;
+ }
+
+ return (
+
+
+ {errorText ? "Error" : "Result"}
+
+
+ {errorText &&
{errorText}
}
+ {Output}
+
+
+ );
+};
diff --git a/components/shopify/search-dialog.tsx b/components/shopify/search-dialog.tsx
index 3f867a4..9bb12f1 100644
--- a/components/shopify/search-dialog.tsx
+++ b/components/shopify/search-dialog.tsx
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/command';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
-import { RiSearchLine, RiImageLine } from '@remixicon/react';
+import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
import {
searchSuggestions,
type SearchSuggestion,
@@ -91,23 +91,51 @@ const SearchDialog: React.FC = () => {
- !next && close()}>
- setTerm('')}
- onClose={close}
- placeholder="Search products"
- aria-label="Search products"
- onKeyDown={(event) => {
- if (event.key === 'Enter') goToSearchPage();
- }}
- />
+ !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}
+ >
+
+
{
+ if (event.key === 'Enter') goToSearchPage();
+ }}
+ />
+
+ {hasQuery && (
+
+ )}
+
+
+
{hasQuery && (
<>
-
+
-
+
{searching && results.length === 0 ? (
@@ -128,7 +156,9 @@ const SearchDialog: React.FC = () => {
{results.map((product) => (
goToProduct(product.handle)}
+ value={product.handle}
+ onSelect={() => goToProduct(product.handle)}
+ className="gap-3 py-2"
>
{product.featuredImage ? (
@@ -139,7 +169,7 @@ const SearchDialog: React.FC = () => {
/>
) : (
-
+
)}
diff --git a/components/shopify/store-assistant.tsx b/components/shopify/store-assistant.tsx
new file mode 100644
index 0000000..9a5cba6
--- /dev/null
+++ b/components/shopify/store-assistant.tsx
@@ -0,0 +1,243 @@
+'use client';
+
+import React, { useState } from 'react';
+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,
+ PromptInputBody,
+ PromptInputTextarea,
+ PromptInputFooter,
+ PromptInputSubmit,
+} from '@/components/ai-elements/prompt-input';
+import {
+ Tool,
+ ToolHeader,
+ ToolContent,
+ ToolInput,
+ ToolOutput,
+} from '@/components/ai-elements/tool';
+import { Shimmer } from '@/components/ai-elements/shimmer';
+import { Button } from '@/components/ui/button';
+import {
+ RiSparkling2Line,
+ RiCloseLine,
+ RiExpandLeftRightLine,
+} from '@remixicon/react';
+
+const SUGGESTIONS = [
+ 'What do you sell?',
+ 'Show me hoodies under $100',
+ 'What collections are there?',
+];
+
+// Human-readable labels for the tool names exposed by /api/chat.
+const TOOL_LABELS: Record = {
+ searchCatalogue: 'Searching the catalogue',
+ getProductDetails: 'Reading product details',
+ listCollections: 'Listing collections',
+ getCollectionProducts: 'Browsing a collection',
+ browseProducts: 'Browsing new arrivals',
+};
+
+const toolLabel = (type: string) => {
+ const name = type.startsWith('tool-') ? type.slice(5) : type;
+ return TOOL_LABELS[name] ?? name;
+};
+
+const StoreAssistant: React.FC = () => {
+ const [open, setOpen] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [input, setInput] = useState('');
+
+ const { messages, sendMessage, status, error } = useChat({
+ transport: new DefaultChatTransport({ api: '/api/chat' }),
+ });
+
+ const isBusy = status === 'submitted' || status === 'streaming';
+
+ const send = (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed || isBusy) return;
+ sendMessage({ text: trimmed });
+ setInput('');
+ };
+
+ return (
+ <>
+ {/* Launcher */}
+ {!open && (
+
+ )}
+
+ {/* Sidebar */}
+
+ >
+ );
+};
+
+export default StoreAssistant;
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index 20171c8..6eb2a05 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -1,54 +1,48 @@
-import React from 'react';
-import { cn } from '@/lib/utils';
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
-type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
+import { cn } from "@/lib/utils"
-interface BadgeProps extends React.ComponentProps<'span'> {
- variant?: BadgeVariant;
- asChild?: boolean;
-}
-
-const badgeVariants: Record = {
- default:
- 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
- secondary:
- 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90',
- destructive:
- '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:
- 'text-foreground border-border hover:bg-accent hover:text-accent-foreground',
-};
+const badgeVariants = cva(
+ "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",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ 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",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]: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({
className,
- variant = 'default',
+ variant = "default",
asChild = false,
- children,
...props
-}: BadgeProps) {
- const baseClasses = cn(
- '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;
- return React.cloneElement(child, {
- className: cn(child.props.className, finalClassName),
- ...props,
- } as any);
- }
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span"
return (
-
- {children}
-
- );
+
+ )
}
-export { Badge, badgeVariants };
-export type { BadgeProps };
+export { Badge, badgeVariants }
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
index dc05be0..cd550d7 100644
--- a/components/ui/button-group.tsx
+++ b/components/ui/button-group.tsx
@@ -1,97 +1,83 @@
-import React from 'react';
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
-// Utility function to combine classNames
-function cn(...classes: (string | undefined | null | false)[]): string {
- return classes.filter(Boolean).join(' ');
-}
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
-// Button group variants helper
-function getButtonGroupVariants(
- orientation: 'horizontal' | 'vertical'
-): string {
- 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:
- '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
- vertical:
- 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
- };
-
- return cn(baseStyles, orientationStyles[orientation]);
-}
-
-interface ButtonGroupProps extends React.ComponentProps<'div'> {
- orientation?: 'horizontal' | 'vertical';
-}
+const buttonGroupVariants = cva(
+ "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",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
function ButtonGroup({
className,
- orientation = 'horizontal',
+ orientation,
...props
-}: ButtonGroupProps) {
+}: React.ComponentProps<"div"> & VariantProps) {
return (
- );
-}
-
-interface ButtonGroupTextProps extends React.ComponentProps<'div'> {
- asChild?: boolean;
+ )
}
function ButtonGroupText({
className,
asChild = false,
...props
-}: ButtonGroupTextProps) {
- const Comp = asChild ? 'div' : 'div';
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "div"
+
return (
- );
-}
-
-interface ButtonGroupSeparatorProps extends React.ComponentProps<'div'> {
- orientation?: 'horizontal' | 'vertical';
+ )
}
function ButtonGroupSeparator({
className,
- orientation = 'vertical',
+ orientation = "vertical",
...props
-}: ButtonGroupSeparatorProps) {
- const separatorClasses =
- orientation === 'vertical' ? 'w-px h-auto' : 'h-px w-auto';
-
+}: React.ComponentProps) {
return (
-
- );
+ )
}
-export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
-export type {
- ButtonGroupProps,
- ButtonGroupTextProps,
- ButtonGroupSeparatorProps,
-};
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index d5ed246..4d38506 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -1,70 +1,64 @@
-import React from 'react';
-import { cn } from '@/lib/utils';
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
-interface ButtonProps extends React.ButtonHTMLAttributes {
- variant?:
- | 'default'
- | 'destructive'
- | 'outline'
- | 'secondary'
- | 'ghost'
- | 'link';
- size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
- children?: React.ReactNode;
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "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",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+
+ )
}
-const Button = React.forwardRef(
- ({ className, variant = 'default', size = 'default', ...props }, ref) => {
- const baseClasses = cn(
- // Base styles
- 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all',
- 'disabled:pointer-events-none disabled:opacity-50',
- '[&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4',
- 'shrink-0 [&_svg]:shrink-0',
- 'outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
- 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive'
- );
-
- const variantClasses = {
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
- destructive:
- 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
- outline:
- 'border border-border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
- secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
- ghost:
- 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
- link: 'text-primary underline-offset-4 hover:underline',
- };
-
- const sizeClasses = {
- default: 'h-9 px-4 py-2 has-[>svg]:px-3',
- sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
- lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
- icon: 'size-9',
- 'icon-sm': 'size-8',
- 'icon-lg': 'size-10',
- };
-
- return (
-
- );
- }
-);
-
-Button.displayName = 'Button';
-
-export { Button };
-export default Button;
+export { Button, buttonVariants }
diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx
new file mode 100644
index 0000000..2f7a4e7
--- /dev/null
+++ b/components/ui/collapsible.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import { Collapsible as CollapsiblePrimitive } from "radix-ui"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/components/ui/command.tsx b/components/ui/command.tsx
index a499e02..cdd7e1d 100644
--- a/components/ui/command.tsx
+++ b/components/ui/command.tsx
@@ -1,187 +1,178 @@
-'use client';
+"use client"
-import React from 'react';
-import { cn } from '@/lib/utils';
-import { Dialog, DialogContent } from '@/components/ui/dialog';
-import { RiSearchLine, RiCloseLine } from '@remixicon/react';
-import { Button } from '@/components/ui/button';
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+import { SearchIcon } from "lucide-react"
-// A command palette in the shadcn shape, implemented on this project's own
-// Dialog rather than cmdk so it stays dependency-free. Filtering is left to the
-// caller, which suits async sources like the Storefront API.
+import { cn } from "@/lib/utils"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
-function Command({ className, ...props }: React.ComponentProps<'div'>) {
+function Command({
+ className,
+ ...props
+}: React.ComponentProps) {
return (
-
- );
-}
-
-interface CommandDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- children: React.ReactNode;
- className?: string;
+ )
}
function CommandDialog({
- open,
- onOpenChange,
+ title = "Command Palette",
+ description = "Search for a command to run...",
children,
className,
-}: CommandDialogProps) {
- // Dialog portals straight into document.body, so hold off until mounted or
- // prerendering this component's page throws "document is not defined".
- const [mounted, setMounted] = React.useState(false);
- React.useEffect(() => setMounted(true), []);
-
- if (!mounted) return null;
-
+ showCloseButton = true,
+ // Forwarded to the inner Command so callers can drive filtering themselves
+ // (e.g. results already filtered server-side).
+ shouldFilter,
+ ...props
+}: React.ComponentProps & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+ shouldFilter?: boolean
+}) {
return (
-
+ )
}
function DialogTitle({
className,
...props
-}: React.HTMLAttributes) {
+}: React.ComponentProps) {
return (
-
- );
+ )
}
function DialogDescription({
className,
...props
-}: React.HTMLAttributes) {
+}: React.ComponentProps) {
return (
-
- );
+ )
}
export {
@@ -264,5 +155,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
- AnimatePresence,
-};
+}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..ae1fcf6
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,257 @@
+"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) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/components/ui/hover-card.tsx b/components/ui/hover-card.tsx
new file mode 100644
index 0000000..91e869c
--- /dev/null
+++ b/components/ui/hover-card.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import { HoverCard as HoverCardPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function HoverCard({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function HoverCardTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function HoverCardContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }
diff --git a/components/ui/input-group.tsx b/components/ui/input-group.tsx
new file mode 100644
index 0000000..a7652d9
--- /dev/null
+++ b/components/ui/input-group.tsx
@@ -0,0 +1,170 @@
+"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 (
+ 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
) {
+ return (
+ {
+ 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
, "size"> &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
index 1a6d2e3..f1124ae 100644
--- a/components/ui/input.tsx
+++ b/components/ui/input.tsx
@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
- "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-ring/50 focus-visible:ring-[3px]",
- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "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",
+ "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",
className
)}
{...props}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
index c5adcec..c0dc712 100644
--- a/components/ui/select.tsx
+++ b/components/ui/select.tsx
@@ -1,287 +1,190 @@
-import React, {
- createContext,
- useContext,
- useState,
- useRef,
- useEffect,
- useCallback,
-} from 'react';
-import { cn } from '@/lib/utils';
+"use client"
-interface SelectContextType {
- open: boolean;
- setOpen: (open: boolean) => void;
- value: string;
- setValue: (value: string) => void;
-}
+import * as React from "react"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Select as SelectPrimitive } from "radix-ui"
-const SelectContext = createContext(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;
-}
+import { cn } from "@/lib/utils"
function Select({
- value: controlledValue,
- onValueChange,
- children,
-}: SelectProps) {
- const [internalValue, setInternalValue] = useState('');
- const [open, setOpen] = useState(false);
- const containerRef = useRef(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 (
-
-
- {children}
-
-
- );
+ ...props
+}: React.ComponentProps) {
+ return
}
-interface SelectTriggerProps extends React.ButtonHTMLAttributes {
- children: React.ReactNode;
- placeholder?: string;
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
}
function SelectTrigger({
className,
+ size = "default",
children,
- placeholder = 'Select...',
...props
-}: SelectTriggerProps) {
- const { open, setOpen, value } = useSelect();
- const triggerRef = useRef(null);
-
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
return (
-
- );
+ {children}
+
+
+
+
+ )
}
-interface SelectValueProps {
- children?: React.ReactNode;
- placeholder?: string;
-}
-
-function SelectValue({
+function SelectContent({
+ className,
children,
- placeholder = 'Select...',
-}: SelectValueProps) {
- const { value } = useSelect();
-
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
return (
- {children || value || placeholder}
- );
+
+
+
+
+ {children}
+
+
+
+
+ )
}
-interface SelectContentProps extends React.HTMLAttributes {
- children: React.ReactNode;
-}
-
-function SelectContent({ className, children, ...props }: SelectContentProps) {
- const { open } = useSelect();
- const contentRef = useRef(null);
-
- if (!open) return null;
-
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
return (
-
- );
-}
-
-interface SelectItemProps extends React.HTMLAttributes {
- value: string;
- children: React.ReactNode;
- disabled?: boolean;
+ />
+ )
}
function SelectItem({
- value,
+ className,
children,
- disabled = false,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
className,
...props
-}: SelectItemProps) {
- const { value: selectedValue, setValue } = useSelect();
- const isSelected = selectedValue === value;
-
+}: React.ComponentProps) {
return (
- !disabled && setValue(value)}
- className={cn(
- 'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*="text-"])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none transition-colors',
- !disabled &&
- 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
- disabled && 'pointer-events-none opacity-50',
- isSelected && 'bg-accent text-accent-foreground',
- className
- )}
- {...props}
- >
- {isSelected && (
-
-
-
- )}
- {children}
-
- );
-}
-
-interface SelectGroupProps extends React.HTMLAttributes {
- children: React.ReactNode;
-}
-
-function SelectGroup({ className, children, ...props }: SelectGroupProps) {
- return (
-
- {children}
-
- );
-}
-
-interface SelectLabelProps extends React.HTMLAttributes {
- children: React.ReactNode;
-}
-
-function SelectLabel({ className, children, ...props }: SelectLabelProps) {
- return (
-
- {children}
-
- );
-}
-
-interface SelectSeparatorProps extends React.HTMLAttributes {}
-
-function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
- return (
-
- );
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
}
export {
Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
SelectTrigger,
SelectValue,
- SelectContent,
- SelectItem,
- SelectGroup,
- SelectLabel,
- SelectSeparator,
-};
+}
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..cd873e3
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"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) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx
index 76b7843..bb7dd5e 100644
--- a/components/ui/sonner.tsx
+++ b/components/ui/sonner.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { toast } from 'sonner';
-import Button from '@/components/ui/button';
+import { Button } from '@/components/ui/button';
export function SonnerDemo() {
return (
diff --git a/components/ui/spinner.tsx b/components/ui/spinner.tsx
index 485ea8d..a70e713 100644
--- a/components/ui/spinner.tsx
+++ b/components/ui/spinner.tsx
@@ -1,38 +1,16 @@
-import React from 'react';
-import { cn } from '@/lib/utils';
+import { Loader2Icon } from "lucide-react"
-interface SpinnerProps extends React.ComponentProps<'svg'> {
- size?: 'sm' | 'md' | 'lg' | 'xl';
-}
+import { cn } from "@/lib/utils"
-const sizeClasses = {
- sm: 'size-3',
- md: 'size-4',
- lg: 'size-6',
- xl: 'size-8',
-};
-
-function Spinner({ className, size = 'md', ...props }: SpinnerProps) {
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
-
- );
+ />
+ )
}
-export { Spinner };
-export type { SpinnerProps };
+export { Spinner }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
index 7f21b5e..e67d8fe 100644
--- a/components/ui/textarea.tsx
+++ b/components/ui/textarea.tsx
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {