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 +) => +
+ {children} +
+ + ); + + 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) => ( + + onValueChange?.(event.target.value)} className={cn( - 'flex h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground', + "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className )} {...props} /> - {hasValue && onClear && ( - - )} - {onClose && ( - - )}
- ); + ) } -function CommandList({ className, ...props }: React.ComponentProps<'div'>) { +function CommandList({ + className, + ...props +}: React.ComponentProps) { return ( -
- ); -} - -function CommandEmpty({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -interface CommandGroupProps extends React.ComponentProps<'div'> { - heading?: string; -} - -function CommandGroup({ className, heading, children, ...props }: CommandGroupProps) { - return ( -
- {heading && ( -
- {heading} -
- )} - {children} -
- ); -} - -function CommandItem({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); + ) } -function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) { +function CommandEmpty({ + ...props +}: React.ComponentProps) { return ( -
- ); + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) } export { @@ -192,5 +183,6 @@ export { CommandEmpty, CommandGroup, CommandItem, + CommandShortcut, CommandSeparator, -}; +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 0ea8f84..84bdef4 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -1,141 +1,50 @@ -import React, { useState, useCallback, useContext, createContext } from 'react'; -import { createPortal } from 'react-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import { clsx } from 'clsx'; +"use client" -interface DialogContextType { - open: boolean; - setOpen: (open: boolean) => void; -} +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" -const DialogContext = createContext(undefined); +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" -function useDialog() { - const context = useContext(DialogContext); - if (!context) { - throw new Error('Dialog components must be used within a Dialog'); - } - return context; -} - -interface DialogProps { - open?: boolean; - onOpenChange?: (open: boolean) => void; - children: React.ReactNode; -} - -function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) { - const [internalOpen, setInternalOpen] = useState(false); - const isControlled = controlledOpen !== undefined; - const open = isControlled ? controlledOpen : internalOpen; - - const setOpen = useCallback( - (newOpen: boolean) => { - if (!isControlled) { - setInternalOpen(newOpen); - } - onOpenChange?.(newOpen); - }, - [isControlled, onOpenChange] - ); - - return ( - - {children} - - ); +function Dialog({ + ...props +}: React.ComponentProps) { + return } function DialogTrigger({ - children, - asChild, ...props -}: React.ButtonHTMLAttributes & { asChild?: boolean }) { - const { setOpen } = useDialog(); - - if (asChild && React.isValidElement(children)) { - const child = children as React.ReactElement; - return React.cloneElement(child, { - ...props, - onClick: (e: React.MouseEvent) => { - setOpen(true); - child.props.onClick?.(e); - }, - } as any); - } - - return ( - - ); +}: React.ComponentProps) { + return } -function DialogPortal({ children }: { children: React.ReactNode }) { - return createPortal(children, document.body); +function DialogPortal({ + ...props +}: React.ComponentProps) { + return } function DialogClose({ - children, - asChild, ...props -}: React.ButtonHTMLAttributes & { asChild?: boolean }) { - const { setOpen } = useDialog(); - - if (asChild && React.isValidElement(children)) { - const child = children as React.ReactElement; - return React.cloneElement(child, { - ...props, - onClick: (e: React.MouseEvent) => { - setOpen(false); - child.props.onClick?.(e); - }, - } as any); - } - - return ( - - ); +}: React.ComponentProps) { + return } -interface DialogOverlayProps extends React.HTMLAttributes {} - -function DialogOverlay({ className, onClick, ...props }: DialogOverlayProps) { - const { setOpen } = useDialog(); - +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { return ( - { - setOpen(false); - onClick?.(e as any); - }} - {...(props as any)} + className={cn( + "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", + className + )} + {...props} /> - ); -} - -interface DialogContentProps extends React.HTMLAttributes { - showCloseButton?: boolean; + ) } function DialogContent({ @@ -143,114 +52,96 @@ function DialogContent({ children, showCloseButton = true, ...props -}: DialogContentProps) { - const { open } = useDialog(); - +}: React.ComponentProps & { + showCloseButton?: boolean +}) { return ( - - - {open && ( - <> - - - {children} - {showCloseButton && ( - - - - - - )} - - + + + + {...props} + > + {children} + {showCloseButton && ( + + + Close + + )} + - ); + ) } -function DialogHeader({ - className, - ...props -}: React.HTMLAttributes) { +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
- ); + ) } function DialogFooter({ className, + showCloseButton = false, + children, ...props -}: React.HTMLAttributes) { +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { return (
- ); + > + {children} + {showCloseButton && ( + + + + )} +
+ ) } 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 ( +