Merge feat/agent: AI store assistant with catalogue tool calls

Adds /api/chat (AI SDK v7 via OpenRouter) with read-only catalogue tools
and a popover assistant, gated behind NEXT_PUBLIC_ENABLE_AI=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
Rami Bitar
2026-08-01 14:56:08 -04:00
co-authored by Claude Opus 5
42 changed files with 10600 additions and 1186 deletions
+12
View File
@@ -0,0 +1,12 @@
# Shopify Storefront
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07
# Store assistant — set to 1 to show the Ask launcher; anything else hides it
NEXT_PUBLIC_ENABLE_AI=0
# Store assistant (/api/chat) — https://openrouter.ai/keys
OPENROUTER_API_KEY=
# Optional; defaults to openai/gpt-5.6-luna-pro
# OPENROUTER_MODEL=
+201
View File
@@ -0,0 +1,201 @@
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { z } from 'zod';
import {
searchProducts,
getProduct,
getProductsPage,
getCollections,
getCollectionProductsPage,
} from '@/services/shopify/catalog';
// Streaming needs the Node runtime here because the Storefront helpers run
// server-side on each tool call.
export const maxDuration = 30;
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
You help shoppers find products, compare options, and understand what the store
carries. You have tools that read live catalogue data — always use them rather
than guessing, and never invent products, prices, availability, or policies.
Guidelines:
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
something vague like "what do you have?", call listCollections or
searchCatalogue to ground your answer.
- Prices returned by the tools are in the store's currency; show them as given.
- Link products as /products/{handle} and collections as /collections/{handle}
so the shopper can click through.
- Keep replies short and conversational — a sentence or two plus a compact list.
Do not repeat the raw tool output; the interface already shows it.
- If a tool returns nothing, say so plainly and suggest a different search.
- You cannot place orders, change carts, process payments, or look up customer
or order data. Say so and point the shopper to the relevant page instead.`;
// Trim the Storefront payloads to what the model actually needs to answer.
const summariseProduct = (product: {
id: string;
title: string;
handle: string;
description?: string;
productType?: string;
tags?: string[];
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
variants?: {
edges: Array<{
node: {
title: string;
availableForSale: boolean;
selectedOptions?: Array<{ name: string; value: string }>;
};
}>;
};
images?: { edges: Array<{ node: { url: string } }> };
options?: Array<{ name: string; values: string[] }>;
}) => ({
title: product.title,
handle: product.handle,
url: `/products/${product.handle}`,
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
image: product.images?.edges[0]?.node.url ?? null,
description: product.description?.slice(0, 300) ?? null,
productType: product.productType || null,
tags: product.tags ?? [],
options: product.options?.map((option) => ({
name: option.name,
values: option.values,
})),
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
});
export async function POST(req: Request) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
const { messages } = await req.json();
const openrouter = createOpenRouter({ apiKey });
const result = streamText({
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
// renders it via the Reasoning component.
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
// Let the model call a tool, read the result, then answer.
stopWhen: stepCountIs(5),
tools: {
searchCatalogue: tool({
description:
'Search the store for products matching a term. Use for any question about what the store sells.',
inputSchema: z.object({
query: z
.string()
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
limit: z.number().int().min(1).max(10).default(5),
}),
execute: async ({ query, limit }) => {
const { products, totalCount } = await searchProducts({
query,
first: limit,
});
return {
totalCount,
products: products.map(summariseProduct),
};
},
}),
getProductDetails: tool({
description:
'Get full details for one product by its handle, including options, variants, and stock.',
inputSchema: z.object({
handle: z
.string()
.describe('The product handle, e.g. "flowguard-jacket".'),
}),
execute: async ({ handle }) => {
const product = await getProduct(handle);
if (!product) return { found: false, handle };
return {
found: true,
...summariseProduct(product),
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
title: node.title,
available: node.availableForSale,
price: `${node.price.amount} ${node.price.currencyCode}`,
options: node.selectedOptions,
})),
};
},
}),
listCollections: tool({
description:
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
inputSchema: z.object({
limit: z.number().int().min(1).max(25).default(10),
}),
execute: async ({ limit }) => {
const collections = await getCollections(limit);
return {
collections: collections.map((collection) => ({
title: collection.title,
handle: collection.handle,
url: `/collections/${collection.handle}`,
description: collection.description?.slice(0, 200) ?? null,
})),
};
},
}),
getCollectionProducts: tool({
description:
'List the products inside one collection, by collection handle.',
inputSchema: z.object({
handle: z.string().describe('The collection handle, e.g. "men".'),
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ handle, limit }) => {
const page = await getCollectionProductsPage(handle, { first: limit });
if (!page.collection) return { found: false, handle };
return {
found: true,
collection: page.collection.title,
url: `/collections/${handle}`,
products: page.products.map(summariseProduct),
};
},
}),
browseProducts: tool({
description:
'Browse the newest products when the shopper has no specific search term.',
inputSchema: z.object({
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ limit }) => {
const page = await getProductsPage({
first: limit,
sortKey: 'CREATED_AT',
reverse: true,
});
return { products: page.products.map(summariseProduct) };
},
}),
},
});
// sendReasoning forwards reasoning parts to the client; without it the
// stream carries text and tool calls only.
return result.toUIMessageStreamResponse({ sendReasoning: true });
}
+44
View File
@@ -1,5 +1,7 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme {
/* Refined neutral modern palette */
--color-background: hsl(0 0% 100%);
@@ -56,6 +58,15 @@
}
/* Base styles for light, modern Shopify storefront */
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
components (e.g. Button's outline variant) rely on this base layer to pick up
the theme's border colour instead of the text colour. */
@layer base {
* {
border-color: var(--color-border);
}
}
body {
font-feature-settings:
'kern' 1,
@@ -184,3 +195,36 @@ button,
.price-display {
font-feature-settings: 'tnum';
}
@theme inline {
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
--color-color-5: var(--color-5);
--color-color-4: var(--color-4);
--color-color-3: var(--color-3);
--color-color-2: var(--color-2);
--color-color-1: var(--color-1);
@keyframes rainbow {
0% {
background-position: 0%;
}
100% {
background-position: 200%;
}
}
}
:root {
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
.dark {
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
+2
View File
@@ -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({
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-body antialiased bg-background text-foreground m-0 p-0">
{children}
<StoreAssistant />
</body>
</html>
);
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}"
}
}
+426
View File
@@ -0,0 +1,426 @@
"use client";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import type { FileUIPart, SourceDocumentUIPart } from "ai";
import {
FileTextIcon,
GlobeIcon,
ImageIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData =
| (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string });
export type AttachmentMediaCategory =
| "image"
| "video"
| "audio"
| "document"
| "source"
| "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (
data: AttachmentData
): AttachmentMediaCategory => {
if (data.type === "source-document") {
return "source";
}
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) {
return "image";
}
if (mediaType.startsWith("video/")) {
return "video";
}
if (mediaType.startsWith("audio/")) {
return "audio";
}
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
return "document";
}
return "unknown";
};
export const getAttachmentLabel = (data: AttachmentData): string => {
if (data.type === "source-document") {
return data.title || data.filename || "Source";
}
const category = getMediaCategory(data);
return data.filename || (category === "image" ? "Image" : "Attachment");
};
const renderAttachmentImage = (
url: string,
filename: string | undefined,
isGrid: boolean
) =>
isGrid ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
onRemove?: () => void;
variant: AttachmentVariant;
}
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const };
export const useAttachmentContext = () => {
const ctx = useContext(AttachmentContext);
if (!ctx) {
throw new Error("Attachment components must be used within <Attachment>");
}
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant]);
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
};
export const Attachment = ({
data,
onRemove,
className,
children,
...props
}: AttachmentProps) => {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant]
);
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode;
};
export const AttachmentPreview = ({
fallbackIcon,
className,
...props
}: AttachmentPreviewProps) => {
const { data, mediaCategory, variant } = useAttachmentContext();
const iconSize = variant === "inline" ? "size-3" : "size-4";
const renderIcon = (Icon: typeof ImageIcon) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
);
const renderContent = () => {
if (mediaCategory === "image" && data.type === "file" && data.url) {
return renderAttachmentImage(data.url, data.filename, variant === "grid");
}
if (mediaCategory === "video" && data.type === "file" && data.url) {
return <video className="size-full object-cover" muted src={data.url} />;
}
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className
)}
{...props}
>
{renderContent()}
</div>
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext();
const label = getAttachmentLabel(data);
if (variant === "grid") {
return null;
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">
{data.mediaType}
</span>
)}
</div>
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
},
[onRemove]
);
if (!onRemove) {
return null;
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className
)}
onClick={handleClick}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const AttachmentHoverCardTrigger = (
props: AttachmentHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type AttachmentHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent
align={align}
className={cn("w-auto p-2", className)}
{...props}
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
className,
children,
...props
}: AttachmentEmptyProps) => (
<div
className={cn(
"flex items-center justify-center p-4 text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? "No attachments"}
</div>
);
+562
View File
@@ -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 }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// 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;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => 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<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
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 (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(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<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
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<TokenizedCode | null>(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 (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
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<number>(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 (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;
+168
View File
@@ -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<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
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) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"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 (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
+360
View File
@@ -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<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
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<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
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<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
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
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*: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) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
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 { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
+77
View File
@@ -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<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
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 (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};
+173
View File
@@ -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<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
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<ToolPart["state"], string> = {
"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<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
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 = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};
+13 -7
View File
@@ -42,11 +42,11 @@ const Footer: React.FC<FooterProps> = ({
return (
<footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-5">
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-x-5 gap-y-2">
<p className="text-sm text-muted-foreground leading-5">
{copyright || `© ${storeName}. All rights reserved.`}
</p>
{/* Left-aligned: the bottom-right corner belongs to the floating
assistant launcher. Links and socials sit on separate rows so the
icons aren't crammed onto the end of the link list. */}
<div className="flex flex-col items-center gap-y-5 sm:items-start">
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
{links.map((link) => (
<a
key={link.label}
@@ -56,9 +56,10 @@ const Footer: React.FC<FooterProps> = ({
{link.label}
</a>
))}
</div>
</nav>
<div className="flex items-center gap-x-4">
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:gap-x-5">
<span className="flex items-center gap-x-4">
{socials.map(({ label, url, Icon }) => (
<a
key={label}
@@ -69,6 +70,11 @@ const Footer: React.FC<FooterProps> = ({
<Icon size={18} />
</a>
))}
</span>
<p className="text-sm text-muted-foreground leading-5">
{copyright || `© ${storeName}. All rights reserved.`}
</p>
</div>
</div>
</div>
+25
View File
@@ -41,6 +41,9 @@ interface HeaderProps {
storeName?: string;
logoUrl?: string;
links?: NavLink[];
/** Thin bar above the nav. Pass null to hide it. */
announcement?: string | null;
announcementUrl?: string;
}
const Header: React.FC<HeaderProps> = ({
@@ -50,10 +53,31 @@ const Header: React.FC<HeaderProps> = ({
{ label: 'Shop', url: '/' },
{ label: 'Collections', url: '/collections' },
],
announcement = 'Free shipping on orders over $100',
announcementUrl,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
{/* Sits above the sticky nav, so it scrolls away on its own. */}
{announcement && (
<div className="bg-foreground text-background">
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
{announcementUrl ? (
<Link
href={announcementUrl}
className="underline-offset-2 hover:underline"
>
{announcement}
</Link>
) : (
<span>{announcement}</span>
)}
</div>
</div>
)}
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14">
@@ -128,6 +152,7 @@ const Header: React.FC<HeaderProps> = ({
<CartDrawer />
</nav>
</>
);
};
+40 -10
View File
@@ -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 = () => {
<RiSearchLine className="size-5" />
</Button>
<CommandDialog open={open} onOpenChange={(next) => !next && close()}>
<CommandDialog
open={open}
onOpenChange={(next) => !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}
>
<div className="relative">
<CommandInput
autoFocus
value={term}
onValueChange={setTerm}
onClear={() => setTerm('')}
onClose={close}
placeholder="Search products"
aria-label="Search products"
className="pr-28"
onKeyDown={(event) => {
if (event.key === 'Enter') goToSearchPage();
}}
/>
<div className="absolute right-2 top-0 flex h-9 items-center gap-1">
{hasQuery && (
<Button
onClick={() => setTerm('')}
variant="ghost"
size="sm"
className="font-normal text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
<Button
onClick={close}
variant="ghost"
size="icon-sm"
aria-label="Close search"
>
<RiCloseLine className="size-4" />
</Button>
</div>
</div>
{hasQuery && (
<>
<div className="px-4 pb-2">
<div className="px-3 pt-3">
<button
onClick={goToSearchPage}
className="rounded-full border border-border px-3 py-1 text-sm text-foreground transition-colors hover:border-foreground"
@@ -116,7 +144,7 @@ const SearchDialog: React.FC = () => {
</button>
</div>
<CommandList>
<CommandList className="max-h-80">
{searching && results.length === 0 ? (
<div className="flex justify-center py-8">
<Loader size={20} />
@@ -128,7 +156,9 @@ const SearchDialog: React.FC = () => {
{results.map((product) => (
<CommandItem
key={product.id}
onClick={() => goToProduct(product.handle)}
value={product.handle}
onSelect={() => goToProduct(product.handle)}
className="gap-3 py-2"
>
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
{product.featuredImage ? (
@@ -139,7 +169,7 @@ const SearchDialog: React.FC = () => {
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine size={18} />
<RiImageLine className="size-4" />
</div>
)}
</div>
+569
View File
@@ -0,0 +1,569 @@
'use client';
import React, { memo, useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
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,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputBody,
PromptInputFooter,
PromptInputProvider,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputAttachments,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Attachment,
AttachmentHoverCard,
AttachmentHoverCardContent,
AttachmentHoverCardTrigger,
AttachmentInfo,
AttachmentPreview,
AttachmentRemove,
Attachments,
getAttachmentLabel,
getMediaCategory,
type AttachmentData,
} from '@/components/ai-elements/attachments';
import {
Suggestions,
Suggestion,
} from '@/components/ai-elements/suggestion';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { Button } from '@/components/ui/button';
import { RainbowButton } from '@/components/ui/rainbow-button';
import {
RiCloseLine,
RiSearchLine,
RiPriceTag3Line,
RiStore2Line,
RiLayoutGridLine,
RiShoppingBag3Line,
} from '@remixicon/react';
// Feature flag. Written as a static member expression so Next inlines it at
// build time; the assistant is off unless the env var is explicitly "1".
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
const SUGGESTIONS = [
'What do you sell?',
'Show me hoodies under $100',
'What collections are there?',
];
interface ToolProduct {
handle: string;
title: string;
image: string | null;
price?: string;
}
interface ToolSummary {
label: string;
icon: React.ReactNode;
products: ToolProduct[];
}
const LOADING_LABELS: Record<string, string> = {
searchCatalogue: 'Searching the catalogue',
getProductDetails: 'Reading product details',
listCollections: 'Listing collections',
getCollectionProducts: 'Browsing a collection',
browseProducts: 'Browsing new arrivals',
};
const toolName = (type: string) =>
type.startsWith('tool-') ? type.slice(5) : type;
const plural = (count: number, noun: string) =>
`${count} ${noun}${count === 1 ? '' : 's'}`;
// Turns a finished tool result into the one-line summary plus any products
// worth previewing.
const summariseTool = (
name: string,
output: Record<string, unknown> | undefined
): ToolSummary => {
const products = (output?.products as ToolProduct[] | undefined) ?? [];
switch (name) {
case 'searchCatalogue': {
const total = (output?.totalCount as number) ?? products.length;
return {
label: `Found ${plural(total, 'product')}`,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
case 'getProductDetails':
return {
label: output?.found
? `Read ${output.title as string}`
: 'Product not found',
icon: <RiPriceTag3Line className="size-3.5" />,
products: output?.found
? [
{
handle: output.handle as string,
title: output.title as string,
image: (output.image as string) ?? null,
},
]
: [],
};
case 'listCollections': {
const collections =
(output?.collections as Array<unknown> | undefined) ?? [];
return {
label: `Found ${plural(collections.length, 'collection')}`,
icon: <RiStore2Line className="size-3.5" />,
products: [],
};
}
case 'getCollectionProducts':
return {
label: output?.found
? `Found ${plural(products.length, 'product')} in ${output.collection}`
: 'Collection not found',
icon: <RiLayoutGridLine className="size-3.5" />,
products,
};
case 'browseProducts':
return {
label: `Browsed ${plural(products.length, 'new arrival')}`,
icon: <RiShoppingBag3Line className="size-3.5" />,
products,
};
default:
return {
label: name,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
};
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
const isInternalLink = (url: string) => {
if (url.startsWith('/')) return true;
try {
return new URL(url, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
};
const ProductPreviews: React.FC<{ products: ToolProduct[] }> = ({
products,
}) => {
const withImages = products.filter((product) => product.image);
if (withImages.length === 0) return null;
return (
<Attachments variant="grid" className="ml-0 mt-2">
{withImages.slice(0, 6).map((product) => (
<Link
key={product.handle}
href={`/products/${product.handle}`}
title={product.title}
className="group/product w-20"
>
<Attachment
data={{
id: product.handle,
type: 'file',
url: product.image as string,
mediaType: 'image/jpeg',
filename: product.title,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
<span className="mt-1 line-clamp-2 block text-[11px] leading-tight text-muted-foreground group-hover/product:text-foreground">
{product.title}
</span>
</Link>
))}
</Attachments>
);
};
interface AttachmentItemProps {
attachment: AttachmentData;
onRemove: (id: string) => void;
}
const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
const handleRemove = useCallback(
() => onRemove(attachment.id),
[onRemove, attachment.id]
);
const mediaCategory = getMediaCategory(attachment);
const label = getAttachmentLabel(attachment);
return (
<AttachmentHoverCard key={attachment.id}>
<AttachmentHoverCardTrigger asChild>
<Attachment data={attachment} onRemove={handleRemove}>
{/* Thumbnail swaps to the remove button on hover. */}
<div className="group relative size-5 shrink-0">
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
<AttachmentPreview />
</div>
<AttachmentRemove className="absolute inset-0" />
</div>
<AttachmentInfo />
</Attachment>
</AttachmentHoverCardTrigger>
<AttachmentHoverCardContent>
<div className="space-y-2">
{mediaCategory === 'image' &&
attachment.type === 'file' &&
attachment.url && (
<div className="flex max-h-96 w-80 items-center justify-center overflow-hidden rounded-md border">
<img
alt={label}
className="max-h-full max-w-full object-contain"
height={384}
src={attachment.url}
width={320}
/>
</div>
)}
<div className="space-y-1 px-0.5">
<h4 className="text-sm font-semibold leading-none">{label}</h4>
{attachment.mediaType && (
<p className="font-mono text-xs text-muted-foreground">
{attachment.mediaType}
</p>
)}
</div>
</div>
</AttachmentHoverCardContent>
</AttachmentHoverCard>
);
});
AttachmentItem.displayName = 'AttachmentItem';
// Pending uploads, shown inline above the textarea.
const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments();
const handleRemove = useCallback(
(id: string) => attachments.remove(id),
[attachments]
);
if (attachments.files.length === 0) return null;
return (
<Attachments variant="inline" className="w-full justify-start px-2 pt-2">
{attachments.files.map((attachment) => (
<AttachmentItem
attachment={attachment}
key={attachment.id}
onRemove={handleRemove}
/>
))}
</Attachments>
);
};
const StoreAssistant: React.FC = () => {
// Returns before any hooks run — safe because the flag is a build-time
// constant and cannot change between renders.
if (!AI_ENABLED) return null;
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [input, setInput] = useState('');
// Drives the launcher's slide-in on first paint.
useEffect(() => setMounted(true), []);
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const isBusy = status === 'submitted' || status === 'streaming';
const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${
mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0'
}`;
const send = (text: string) => {
const trimmed = text.trim();
if (!trimmed || isBusy) return;
sendMessage({ text: trimmed });
setInput('');
};
// Attachments arrive on the submitted message, so images go out with it.
const handleSubmit = (message: PromptInputMessage) => {
const text = (message.text ?? input).trim();
const files = message.files ?? [];
if ((!text && files.length === 0) || isBusy) return;
sendMessage({ text, files });
setInput('');
};
return (
<>
{/* Popover panel, anchored above the launcher */}
<div
aria-hidden={!open}
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
open
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-2 opacity-0'
}`}
style={{ height: 'min(32rem, calc(100vh - 8rem))' }}
>
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-2">
<span className="text-sm font-medium">Store Assistant</span>
<Button
onClick={() => setOpen(false)}
variant="ghost"
size="icon-sm"
aria-label="Close assistant"
className="rounded-full"
>
<RiCloseLine className="size-4" />
</Button>
</header>
<Conversation className="flex-1">
<ConversationContent className="gap-4 p-3">
{messages.length === 0 && (
<ConversationEmptyState
title="Ask about the store"
description="Find products, compare options, browse collections."
>
{/* w-full + wrap so chips stack in the narrow popover rather
than scrolling off the edge. */}
<Suggestions className="mt-3 w-full flex-wrap justify-center">
{SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
onClick={send}
suggestion={suggestion}
className="font-normal"
/>
))}
</Suggestions>
</ConversationEmptyState>
)}
{messages.map((message) => {
const fileParts = message.parts.filter(
(part) => part.type === 'file'
);
return (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return (
<MessageResponse
key={index}
linkSafety={{
enabled: true,
onLinkCheck: isInternalLink,
}}
>
{part.text}
</MessageResponse>
);
}
if (part.type === 'reasoning') {
return (
<Reasoning
key={index}
className="w-full"
isStreaming={
status === 'streaming' &&
part.state === 'streaming'
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type.startsWith('tool-')) {
const toolPart = part as typeof part & {
state: string;
output?: Record<string, unknown>;
errorText?: string;
};
const name = toolName(part.type);
// Shimmer while the call is in flight; a quiet summary
// line once it returns.
if (
toolPart.state === 'input-streaming' ||
toolPart.state === 'input-available'
) {
return (
<Shimmer key={index} className="text-xs">
{LOADING_LABELS[name] ?? name}
</Shimmer>
);
}
if (toolPart.state === 'output-error') {
return (
<p key={index} className="text-xs text-muted-foreground">
Couldn&apos;t load that.
</p>
);
}
const { label, icon, products } = summariseTool(
name,
toolPart.output
);
return (
<div key={index} className="my-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
{label}
</div>
<ProductPreviews products={products} />
</div>
);
}
return null;
})}
{/* Images the shopper attached, shown under their message. */}
{fileParts.length > 0 && (
<Attachments variant="grid" className="ml-0 justify-start">
{fileParts.map((part, index) => (
<Attachment
key={`${message.id}-file-${index}`}
data={{
id: `${message.id}-file-${index}`,
type: 'file',
url: part.url,
mediaType: part.mediaType,
filename: part.filename,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
))}
</Attachments>
)}
</MessageContent>
</Message>
);
})}
{status === 'submitted' && (
<Shimmer className="text-xs">Thinking</Shimmer>
)}
{error && (
<p className="text-xs text-destructive">
Something went wrong. Please try again.
</p>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="shrink-0 p-2">
<PromptInputProvider>
<PromptInput
globalDrop
multiple
accept="image/*"
onSubmit={handleSubmit}
className="rounded-lg"
>
<PromptInputAttachmentsDisplay />
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask about products…"
className="min-h-12"
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</PromptInputProvider>
</div>
</div>
{/* Launcher */}
{/* Rainbow treatment only while the assistant is open; otherwise the
launcher matches the rest of the site's buttons. */}
{open ? (
<RainbowButton
onClick={() => setOpen(false)}
aria-label="Close store assistant"
aria-expanded
size="lg"
className={launcherClasses}
>
Ask
</RainbowButton>
) : (
<Button
onClick={() => setOpen(true)}
aria-label="Open store assistant"
aria-expanded={false}
className={`h-11 ${launcherClasses}`}
>
Ask
</Button>
)}
</>
);
};
export default StoreAssistant;
+34 -40
View File
@@ -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<BadgeVariant, string> = {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
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:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90',
"bg-secondary text-secondary-foreground [a&]: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',
"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:
'text-foreground border-border hover:bg-accent hover:text-accent-foreground',
};
"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<any>;
return React.cloneElement(child, {
className: cn(child.props.className, finalClassName),
...props,
} as any);
}
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<span data-slot="badge" className={finalClassName} {...props}>
{children}
</span>
);
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants };
export type { BadgeProps };
export { Badge, badgeVariants }
+40 -54
View File
@@ -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 = {
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',
"[&>*: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';
"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<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(getButtonGroupVariants(orientation), className)}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
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 (
<Comp
data-slot="button-group-text"
className={cn(
'bg-muted flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4',
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
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<typeof Separator>) {
return (
<div
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-border relative !m-0 self-stretch',
separatorClasses,
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
);
)
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
export type {
ButtonGroupProps,
ButtonGroupTextProps,
ButtonGroupSeparatorProps,
};
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+49 -55
View File
@@ -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<HTMLButtonElement> {
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 Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ 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',
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:focus-visible:ring-destructive/40 dark:bg-destructive/60',
"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 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',
"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',
};
"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",
},
}
)
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',
};
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<button
ref={ref}
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(
baseClasses,
variantClasses[variant],
sizeClasses[size],
className
)}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
)
}
);
Button.displayName = 'Button';
export { Button };
export default Button;
export { Button, buttonVariants }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+127 -135
View File
@@ -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<typeof CommandPrimitive>) {
return (
<div
<CommandPrimitive
data-slot="command"
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground',
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
);
}
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<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
shouldFilter?: boolean
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
showCloseButton={false}
// DialogContent merges with clsx, so its own `p-6`/`gap-4` need the
// important flag to lose to these.
className={cn(
'top-24 max-w-xl translate-y-0 overflow-hidden p-0! gap-0!',
className
)}
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command>{children}</Command>
<Command shouldFilter={shouldFilter} className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
interface CommandInputProps
extends Omit<React.ComponentProps<'input'>, 'onChange'> {
onValueChange?: (value: string) => void;
onClear?: () => void;
onClose?: () => void;
)
}
function CommandInput({
className,
value,
onValueChange,
onClear,
onClose,
...props
}: CommandInputProps) {
const hasValue = Boolean(String(value ?? '').length);
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-12 items-center gap-2 px-3"
className="flex h-9 items-center gap-2 border-b px-3"
>
<RiSearchLine size={18} className="shrink-0 text-muted-foreground" />
<input
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
value={value}
onChange={(event) => 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 && (
<Button
type="button"
onClick={onClear}
variant="ghost"
size="sm"
className="font-normal text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
{onClose && (
<Button
type="button"
onClick={onClose}
variant="ghost"
size="icon-sm"
aria-label="Close search"
>
<RiCloseLine size={18} />
</Button>
)}
</div>
);
)
}
function CommandList({ className, ...props }: React.ComponentProps<'div'>) {
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<div
<CommandPrimitive.List
data-slot="command-list"
className={cn('max-h-80 overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
);
}
function CommandEmpty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="command-empty"
className={cn('px-4 py-8 text-center text-sm text-muted-foreground', className)}
{...props}
/>
);
}
interface CommandGroupProps extends React.ComponentProps<'div'> {
heading?: string;
}
function CommandGroup({ className, heading, children, ...props }: CommandGroupProps) {
return (
<div
data-slot="command-group"
className={cn('px-2 py-2', className)}
{...props}
>
{heading && (
<div className="px-2 pb-2 text-xs uppercase tracking-wide text-muted-foreground">
{heading}
</div>
)}
{children}
</div>
);
}
function CommandItem({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="command-item"
role="option"
className={cn(
'flex cursor-pointer select-none items-center gap-3 rounded-md px-2 py-2 text-sm outline-none hover:bg-accent',
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
);
)
}
function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) {
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<div
data-slot="command-separator"
className={cn('h-px bg-border', className)}
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
@@ -192,5 +183,6 @@ export {
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
}
+84 -194
View File
@@ -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<DialogContextType | undefined>(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 (
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</DialogContext.Provider>
);
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
children,
asChild,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
const { setOpen } = useDialog();
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
...props,
onClick: (e: React.MouseEvent) => {
setOpen(true);
child.props.onClick?.(e);
},
} as any);
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
return (
<button
{...props}
onClick={(e) => {
setOpen(true);
props.onClick?.(e);
}}
>
{children}
</button>
);
}
function DialogPortal({ children }: { children: React.ReactNode }) {
return createPortal(children, document.body);
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
children,
asChild,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
const { setOpen } = useDialog();
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
...props,
onClick: (e: React.MouseEvent) => {
setOpen(false);
child.props.onClick?.(e);
},
} as any);
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<button
{...props}
onClick={(e) => {
setOpen(false);
props.onClick?.(e);
}}
>
{children}
</button>
);
}
interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
function DialogOverlay({ className, onClick, ...props }: DialogOverlayProps) {
const { setOpen } = useDialog();
return (
<motion.div
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={clsx('fixed inset-0 z-50 bg-black/50', className)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={(e) => {
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<HTMLDivElement> {
showCloseButton?: boolean;
)
}
function DialogContent({
@@ -143,114 +52,96 @@ function DialogContent({
children,
showCloseButton = true,
...props
}: DialogContentProps) {
const { open } = useDialog();
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<AnimatePresence>
{open && (
<>
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<motion.div
<DialogPrimitive.Content
data-slot="dialog-content"
className={clsx(
'bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-screen-md max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border p-6 shadow-lg',
className
)}
initial={{ opacity: 0, scale: 0.95, y: 0 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
{...(props as any)}
>
{children}
{showCloseButton && (
<DialogClose
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<path d="M18 6l-12 12M6 6l12 12" />
</svg>
</DialogClose>
)}
</motion.div>
</>
)}
</AnimatePresence>
</DialogPortal>
);
}
function DialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="dialog-header"
className={clsx(
'flex flex-col gap-2 text-center sm:text-left',
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={clsx(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<h2
<DialogPrimitive.Title
data-slot="dialog-title"
className={clsx('text-lg leading-none font-semibold', className)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
)
}
function DialogDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<p
<DialogPrimitive.Description
data-slot="dialog-description"
className={clsx('text-muted-foreground text-sm', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
)
}
export {
@@ -264,5 +155,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
AnimatePresence,
};
}
+257
View File
@@ -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<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+44
View File
@@ -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<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+170
View File
@@ -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 (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>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<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
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<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+3 -3
View File
@@ -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}
+61
View File
@@ -0,0 +1,61 @@
import React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const rainbowButtonVariants = cva(
cn(
"relative cursor-pointer group transition-all animate-rainbow",
"inline-flex items-center justify-center gap-2 shrink-0",
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
"text-sm font-medium whitespace-nowrap",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
),
{
variants: {
variant: {
default:
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
outline:
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-xl px-3 text-xs",
lg: "h-11 rounded-xl px-8",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
interface RainbowButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof rainbowButtonVariants> {
asChild?: boolean
}
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(rainbowButtonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
RainbowButton.displayName = "RainbowButton"
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+141 -238
View File
@@ -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<SelectContextType | undefined>(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<HTMLDivElement>(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);
}
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [open]);
return (
<SelectContext.Provider
value={{ open, setOpen, value, setValue: handleValueChange }}
>
<div ref={containerRef} data-slot="select" className="relative">
{children}
</div>
</SelectContext.Provider>
);
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
placeholder?: string;
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
placeholder = 'Select...',
...props
}: SelectTriggerProps) {
const { open, setOpen, value } = useSelect();
const triggerRef = useRef<HTMLButtonElement>(null);
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<button
ref={triggerRef}
<SelectPrimitive.Trigger
data-slot="select-trigger"
onClick={() => setOpen(!open)}
data-size={size}
className={cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*="text-"])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 h-9 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children || <span className="text-muted-foreground">{placeholder}</span>}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'size-4 opacity-50 transition-transform',
open && 'rotate-180'
)}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
);
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
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<typeof SelectPrimitive.Content>) {
return (
<span data-slot="select-value">{children || value || placeholder}</span>
);
}
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function SelectContent({ className, children, ...props }: SelectContentProps) {
const { open } = useSelect();
const contentRef = useRef<HTMLDivElement>(null);
if (!open) return null;
return (
<div
ref={contentRef}
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground absolute z-50 min-w-[8rem] rounded-md border border-border shadow-md overflow-hidden top-full mt-2 left-0',
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<div className="p-1 overflow-y-auto max-h-60">{children}</div>
</div>
);
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
children: React.ReactNode;
disabled?: boolean;
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
value,
className,
children,
disabled = false,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"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 focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectItemProps) {
const { value: selectedValue, setValue } = useSelect();
const isSelected = selectedValue === value;
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<div
data-slot="select-item"
onClick={() => !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 && (
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
)}
{children}
</div>
);
}
interface SelectGroupProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function SelectGroup({ className, children, ...props }: SelectGroupProps) {
return (
<div
data-slot="select-group"
className={cn('overflow-hidden', className)}
{...props}
>
{children}
</div>
);
}
interface SelectLabelProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function SelectLabel({ className, children, ...props }: SelectLabelProps) {
return (
<div
data-slot="select-label"
className={cn(
'text-muted-foreground px-2 py-1.5 text-xs font-semibold',
className
)}
{...props}
>
{children}
</div>
);
}
interface SelectSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}
function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
return (
<div
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
'bg-border pointer-events-none -mx-1 my-1 h-px',
className
)}
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
};
}
+28
View File
@@ -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<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+1 -1
View File
@@ -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 (
+8 -30
View File
@@ -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 (
<svg
<Loader2Icon
role="status"
aria-label="Loading"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('animate-spin', sizeClasses[size], className)}
className={cn("size-4 animate-spin", className)}
{...props}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
/>
)
}
export { Spinner };
export type { SpinnerProps };
export { Spinner }
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+20 -124
View File
@@ -1,142 +1,38 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { shopifyFetch } from '@/services/shopify/client';
import {
GET_COLLECTIONS_QUERY,
GET_COLLECTION_PRODUCTS_QUERY,
} from '@/graphql/collections';
import type { Product } from '@/hooks/use-shopify-products';
getCollections,
getCollectionProducts,
} from '@/services/shopify/catalog';
interface CollectionImage {
url: string;
altText?: string;
}
export {
getCollections,
getCollectionProducts,
getCollectionProductsPage,
} from '@/services/shopify/catalog';
export type {
Collection,
CollectionWithProducts,
CollectionSortKey,
CollectionProductsPage,
ProductFilterFacet,
} from '@/services/shopify/catalog';
export interface Collection {
id: string;
title: string;
handle: string;
description?: string;
descriptionHtml?: string;
image?: CollectionImage;
}
export interface CollectionWithProducts extends Collection {
products: Product[];
}
export type CollectionSortKey =
| 'COLLECTION_DEFAULT'
| 'BEST_SELLING'
| 'CREATED'
| 'PRICE'
| 'TITLE';
import type {
Collection,
CollectionWithProducts,
CollectionSortKey,
} from '@/services/shopify/catalog';
interface UseCollectionProductsOptions {
first?: number;
after?: string | null;
sortKey?: CollectionSortKey;
reverse?: boolean;
/** Raw `input` strings from the connection's `filters` facets. */
filterInputs?: string[];
}
export interface CollectionProductsPage {
collection: Collection | null;
products: Product[];
filters: ProductFilterFacet[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: Array<{
id: string;
label: string;
count: number;
input: string;
}>;
}
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> {
const response = await shopifyFetch({
query: GET_COLLECTIONS_QUERY,
variables: { first },
});
return response.data.collections.edges.map((edge: { node: Collection }) => edge.node);
}
// Fetch products in a collection by handle
export async function getCollectionProducts(
handle: string,
options: UseCollectionProductsOptions = {}
): Promise<CollectionWithProducts | null> {
const page = await getCollectionProductsPage(handle, options);
if (!page.collection) return null;
return { ...page.collection, products: page.products };
}
// Same fetch, but keeps the cursor and facet list for filtering and paging.
export async function getCollectionProductsPage(
handle: string,
{
first = 50,
after = null,
sortKey = 'COLLECTION_DEFAULT',
reverse = false,
filterInputs = [],
}: UseCollectionProductsOptions = {}
): Promise<CollectionProductsPage> {
const filters = filterInputs.flatMap((input) => {
try {
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
const response = await shopifyFetch({
query: GET_COLLECTION_PRODUCTS_QUERY,
variables: {
handle,
first,
after,
sortKey,
reverse,
filters: filters.length ? filters : null,
},
});
const collection = response.data.collection;
if (!collection) {
return {
collection: null,
products: [],
filters: [],
hasNextPage: false,
endCursor: null,
};
}
const { edges, pageInfo, filters: facets } = collection.products;
return {
collection,
products: edges.map((edge: { node: Product }) => edge.node),
filters: facets ?? [],
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
// Hook for fetching all collections
export function useCollections(first = 50) {
const [collections, setCollections] = useState<Collection[]>([]);
+19 -131
View File
@@ -1,99 +1,37 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { shopifyFetch } from '@/services/shopify/client';
import {
GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY,
QUERY_PRODUCT_RECOMMENDATIONS,
} from '@/graphql/products';
getProducts,
getProduct,
getProductRecommendations,
} from '@/services/shopify/catalog';
interface ProductImage {
url: string;
altText?: string;
}
// Pure fetchers live in services/shopify/catalog so server code can use them
// too; re-exported here so existing imports keep working.
export {
getProducts,
getProductsPage,
getProduct,
getProductRecommendations,
} from '@/services/shopify/catalog';
export type {
Product,
ProductOption,
ProductOptionValue,
ProductsPage,
} from '@/services/shopify/catalog';
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: ProductImage;
}
export interface ProductOptionValue {
id: string;
name: string;
swatch?: {
color?: string | null;
image?: {
previewImage?: {
url: string;
} | null;
} | null;
} | null;
firstSelectableVariant?: {
id: string;
image?: ProductImage | null;
} | null;
}
export interface ProductOption {
id: string;
name: string;
values: string[];
optionValues?: ProductOptionValue[];
}
export interface Product {
id: string;
title: string;
description?: string;
descriptionHtml?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
options: ProductOption[];
}
import type { Product } from '@/services/shopify/catalog';
interface UseProductsOptions {
first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null;
query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean;
}
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn {
products: Product[];
loading: boolean;
@@ -101,56 +39,6 @@ interface UseProductsReturn {
refetch: () => Promise<void>;
}
// Fetch multiple products
export async function getProducts(
options: UseProductsOptions = {}
): Promise<Product[]> {
const { products } = await getProductsPage(options);
return products;
}
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({
first = 20,
after = null,
query = '',
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> {
const response = await shopifyFetch({
query: GET_PRODUCTS_QUERY,
variables: { first, after, query, sortKey, reverse },
});
const { edges, pageInfo } = response.data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> {
const response = await shopifyFetch({
query: GET_PRODUCT_QUERY,
variables: { handle },
});
return response.data.product;
}
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const response = await shopifyFetch({
query: QUERY_PRODUCT_RECOMMENDATIONS,
variables: { productId },
});
return response.data.productRecommendations || [];
}
// Hook for fetching multiple products
export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
const [products, setProducts] = useState<Product[]>([]);
+10 -120
View File
@@ -1,120 +1,10 @@
'use client';
import { shopifyFetch } from '@/services/shopify/client';
import {
SEARCH_PRODUCTS_QUERY,
SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search';
import type { Product } from '@/hooks/use-shopify-products';
export type SearchSortKey = 'RELEVANCE' | 'PRICE';
export interface SearchFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface SearchFilter {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: SearchFilterValue[];
}
export interface SearchProductsResult {
products: Product[];
totalCount: number;
filters: SearchFilter[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface SearchSuggestion {
id: string;
title: string;
handle: string;
featuredImage?: {
url: string;
altText?: string;
} | null;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
}
interface SearchProductsOptions {
query: string;
first?: number;
after?: string | null;
sortKey?: SearchSortKey;
reverse?: boolean;
/** Raw `input` strings from the facets, parsed back into filter objects. */
filterInputs?: string[];
}
function parseFilterInputs(inputs: string[]): unknown[] {
return inputs.flatMap((input) => {
try {
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
}
export async function searchProducts({
query,
first = 24,
after = null,
sortKey = 'RELEVANCE',
reverse = false,
filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> {
const response = await shopifyFetch({
query: SEARCH_PRODUCTS_QUERY,
variables: {
query,
first,
after,
sortKey,
reverse,
productFilters: filterInputs.length
? parseFilterInputs(filterInputs)
: null,
},
});
const search = response.data.search;
return {
products: search.edges.map((edge: { node: Product }) => edge.node),
totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage),
endCursor: search.pageInfo?.endCursor ?? null,
};
}
export async function searchSuggestions(
query: string,
first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const response = await shopifyFetch({
query: SEARCH_SUGGESTIONS_QUERY,
variables: { query, first },
});
const search = response.data.search;
return {
products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node),
totalCount: search.totalCount ?? 0,
};
}
// Re-exported from the server-safe catalogue module so both client components
// and Route Handlers can search the storefront.
export { searchProducts, searchSuggestions } from '@/services/shopify/catalog';
export type {
SearchSortKey,
SearchFilter,
SearchFilterValue,
SearchProductsResult,
SearchSuggestion,
} from '@/services/shopify/catalog';
+15
View File
@@ -9,24 +9,39 @@
},
"version": "0.0.0",
"dependencies": {
"@ai-sdk/react": "^4.0.47",
"@next/swc-wasm-web": "16.2.10",
"@openrouter/ai-sdk-provider": "^3.0.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@remixicon/react": "^4.9.0",
"@shopify/storefront-api-client": "^1.0.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@supabase/supabase-js": "^2.51.0",
"ai": "^7",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"framer-motion": "12.42.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"nanoid": "^6.0.0",
"next": "16.2.10",
"radix-ui": "^1.6.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shiki": "^3.19.0",
"sonner": "^2.0.7",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4",
"tw-animate-css": "1.4.0",
"use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3",
"zustand": "^5.0.11"
},
"devDependencies": {
+405
View File
@@ -0,0 +1,405 @@
// Server-safe Shopify catalogue access.
//
// These are plain async functions with no React imports, so they can be called
// from Route Handlers (see app/api/chat/route.ts) as well as from the client
// hooks in hooks/use-shopify-*.ts, which re-export them.
import { shopifyFetch } from '@/services/shopify/client';
import {
GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY,
QUERY_PRODUCT_RECOMMENDATIONS,
} from '@/graphql/products';
import {
GET_COLLECTIONS_QUERY,
GET_COLLECTION_PRODUCTS_QUERY,
} from '@/graphql/collections';
import {
SEARCH_PRODUCTS_QUERY,
SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search';
interface ProductImage {
url: string;
altText?: string;
}
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: ProductImage;
}
export interface ProductOptionValue {
id: string;
name: string;
swatch?: {
color?: string | null;
image?: {
previewImage?: {
url: string;
} | null;
} | null;
} | null;
firstSelectableVariant?: {
id: string;
image?: ProductImage | null;
} | null;
}
export interface ProductOption {
id: string;
name: string;
values: string[];
optionValues?: ProductOptionValue[];
}
export interface Product {
id: string;
title: string;
description?: string;
descriptionHtml?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
options: ProductOption[];
}
interface UseProductsOptions {
first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null;
query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean;
}
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn {
products: Product[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
// Fetch multiple products
export async function getProducts(
options: UseProductsOptions = {}
): Promise<Product[]> {
const { products } = await getProductsPage(options);
return products;
}
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({
first = 20,
after = null,
query = '',
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> {
const response = await shopifyFetch({
query: GET_PRODUCTS_QUERY,
variables: { first, after, query, sortKey, reverse },
});
const { edges, pageInfo } = response.data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> {
const response = await shopifyFetch({
query: GET_PRODUCT_QUERY,
variables: { handle },
});
return response.data.product;
}
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const response = await shopifyFetch({
query: QUERY_PRODUCT_RECOMMENDATIONS,
variables: { productId },
});
return response.data.productRecommendations || [];
}
interface CollectionImage {
url: string;
altText?: string;
}
export interface Collection {
id: string;
title: string;
handle: string;
description?: string;
descriptionHtml?: string;
image?: CollectionImage;
}
export interface CollectionWithProducts extends Collection {
products: Product[];
}
export type CollectionSortKey =
| 'COLLECTION_DEFAULT'
| 'BEST_SELLING'
| 'CREATED'
| 'PRICE'
| 'TITLE';
interface UseCollectionProductsOptions {
first?: number;
after?: string | null;
sortKey?: CollectionSortKey;
reverse?: boolean;
/** Raw `input` strings from the connection's `filters` facets. */
filterInputs?: string[];
}
export interface CollectionProductsPage {
collection: Collection | null;
products: Product[];
filters: ProductFilterFacet[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: Array<{
id: string;
label: string;
count: number;
input: string;
}>;
}
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> {
const response = await shopifyFetch({
query: GET_COLLECTIONS_QUERY,
variables: { first },
});
return response.data.collections.edges.map((edge: { node: Collection }) => edge.node);
}
// Fetch products in a collection by handle
export async function getCollectionProducts(
handle: string,
options: UseCollectionProductsOptions = {}
): Promise<CollectionWithProducts | null> {
const page = await getCollectionProductsPage(handle, options);
if (!page.collection) return null;
return { ...page.collection, products: page.products };
}
// Same fetch, but keeps the cursor and facet list for filtering and paging.
export async function getCollectionProductsPage(
handle: string,
{
first = 50,
after = null,
sortKey = 'COLLECTION_DEFAULT',
reverse = false,
filterInputs = [],
}: UseCollectionProductsOptions = {}
): Promise<CollectionProductsPage> {
const filters = filterInputs.flatMap((input) => {
try {
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
const response = await shopifyFetch({
query: GET_COLLECTION_PRODUCTS_QUERY,
variables: {
handle,
first,
after,
sortKey,
reverse,
filters: filters.length ? filters : null,
},
});
const collection = response.data.collection;
if (!collection) {
return {
collection: null,
products: [],
filters: [],
hasNextPage: false,
endCursor: null,
};
}
const { edges, pageInfo, filters: facets } = collection.products;
return {
collection,
products: edges.map((edge: { node: Product }) => edge.node),
filters: facets ?? [],
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
export type SearchSortKey = 'RELEVANCE' | 'PRICE';
export interface SearchFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface SearchFilter {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: SearchFilterValue[];
}
export interface SearchProductsResult {
products: Product[];
totalCount: number;
filters: SearchFilter[];
hasNextPage: boolean;
endCursor: string | null;
}
export interface SearchSuggestion {
id: string;
title: string;
handle: string;
featuredImage?: {
url: string;
altText?: string;
} | null;
priceRange: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
}
interface SearchProductsOptions {
query: string;
first?: number;
after?: string | null;
sortKey?: SearchSortKey;
reverse?: boolean;
/** Raw `input` strings from the facets, parsed back into filter objects. */
filterInputs?: string[];
}
function parseFilterInputs(inputs: string[]): unknown[] {
return inputs.flatMap((input) => {
try {
return [JSON.parse(input)];
} catch {
console.warn('Ignoring malformed product filter input:', input);
return [];
}
});
}
export async function searchProducts({
query,
first = 24,
after = null,
sortKey = 'RELEVANCE',
reverse = false,
filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> {
const response = await shopifyFetch({
query: SEARCH_PRODUCTS_QUERY,
variables: {
query,
first,
after,
sortKey,
reverse,
productFilters: filterInputs.length
? parseFilterInputs(filterInputs)
: null,
},
});
const search = response.data.search;
return {
products: search.edges.map((edge: { node: Product }) => edge.node),
totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage),
endCursor: search.pageInfo?.endCursor ?? null,
};
}
export async function searchSuggestions(
query: string,
first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const response = await shopifyFetch({
query: SEARCH_SUGGESTIONS_QUERY,
variables: { query, first },
});
const search = response.data.search;
return {
products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node),
totalCount: search.totalCount ?? 0,
};
}
+4451 -1
View File
File diff suppressed because it is too large Load Diff