Template
Add AI store assistant with catalogue tool calls
- /api/chat streams via AI SDK v7 through OpenRouter (OPENROUTER_API_KEY), with a store-specific system prompt and five read-only tools: searchCatalogue, getProductDetails, listCollections, getCollectionProducts, browseProducts - Sidebar assistant on the right that opens from a launcher and expands, built on ai-elements (conversation, message, prompt-input, tool) with shimmer on in-flight tool calls - Extract services/shopify/catalog.ts so the Storefront fetchers have no React imports and can run in a Route Handler; the client hooks now re-export from it - ai-elements pulled in the canonical shadcn primitives, replacing the hand-rolled command/dialog/button variants; search dialog moved to the cmdk-based Command and CommandDialog forwards shouldFilter - Pin shiki to ^3.19.0 to match streamdown and drop the duplicate copy - Add .env.example documenting the required env vars Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
co-authored by
Claude Opus 5
parent
2ef5639a2e
commit
107959a4c3
@@ -0,0 +1,9 @@
|
||||
# Shopify Storefront
|
||||
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
|
||||
# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07
|
||||
|
||||
# Store assistant (/api/chat) — https://openrouter.ai/keys
|
||||
OPENROUTER_API_KEY=
|
||||
# Optional; defaults to anthropic/claude-sonnet-4.5
|
||||
# OPENROUTER_MODEL=
|
||||
@@ -0,0 +1,197 @@
|
||||
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
searchProducts,
|
||||
getProduct,
|
||||
getProductsPage,
|
||||
getCollections,
|
||||
getCollectionProductsPage,
|
||||
} from '@/services/shopify/catalog';
|
||||
|
||||
// Streaming needs the Node runtime here because the Storefront helpers run
|
||||
// server-side on each tool call.
|
||||
export const maxDuration = 30;
|
||||
|
||||
const MODEL = process.env.OPENROUTER_MODEL ?? 'anthropic/claude-sonnet-4.5';
|
||||
|
||||
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
|
||||
|
||||
You help shoppers find products, compare options, and understand what the store
|
||||
carries. You have tools that read live catalogue data — always use them rather
|
||||
than guessing, and never invent products, prices, availability, or policies.
|
||||
|
||||
Guidelines:
|
||||
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
|
||||
something vague like "what do you have?", call listCollections or
|
||||
searchCatalogue to ground your answer.
|
||||
- Prices returned by the tools are in the store's currency; show them as given.
|
||||
- Link products as /products/{handle} and collections as /collections/{handle}
|
||||
so the shopper can click through.
|
||||
- Keep replies short and conversational — a sentence or two plus a compact list.
|
||||
Do not repeat the raw tool output; the interface already shows it.
|
||||
- If a tool returns nothing, say so plainly and suggest a different search.
|
||||
- You cannot place orders, change carts, process payments, or look up customer
|
||||
or order data. Say so and point the shopper to the relevant page instead.`;
|
||||
|
||||
// Trim the Storefront payloads to what the model actually needs to answer.
|
||||
const summariseProduct = (product: {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
productType?: string;
|
||||
tags?: string[];
|
||||
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
|
||||
variants?: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
title: string;
|
||||
availableForSale: boolean;
|
||||
selectedOptions?: Array<{ name: string; value: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
images?: { edges: Array<{ node: { url: string } }> };
|
||||
options?: Array<{ name: string; values: string[] }>;
|
||||
}) => ({
|
||||
title: product.title,
|
||||
handle: product.handle,
|
||||
url: `/products/${product.handle}`,
|
||||
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
|
||||
image: product.images?.edges[0]?.node.url ?? null,
|
||||
description: product.description?.slice(0, 300) ?? null,
|
||||
productType: product.productType || null,
|
||||
tags: product.tags ?? [],
|
||||
options: product.options?.map((option) => ({
|
||||
name: option.name,
|
||||
values: option.values,
|
||||
})),
|
||||
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const { messages } = await req.json();
|
||||
const openrouter = createOpenRouter({ apiKey });
|
||||
|
||||
const result = streamText({
|
||||
model: openrouter(MODEL),
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
// Let the model call a tool, read the result, then answer.
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: {
|
||||
searchCatalogue: tool({
|
||||
description:
|
||||
'Search the store for products matching a term. Use for any question about what the store sells.',
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
}),
|
||||
execute: async ({ query, limit }) => {
|
||||
const { products, totalCount } = await searchProducts({
|
||||
query,
|
||||
first: limit,
|
||||
});
|
||||
return {
|
||||
totalCount,
|
||||
products: products.map(summariseProduct),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
getProductDetails: tool({
|
||||
description:
|
||||
'Get full details for one product by its handle, including options, variants, and stock.',
|
||||
inputSchema: z.object({
|
||||
handle: z
|
||||
.string()
|
||||
.describe('The product handle, e.g. "flowguard-jacket".'),
|
||||
}),
|
||||
execute: async ({ handle }) => {
|
||||
const product = await getProduct(handle);
|
||||
if (!product) return { found: false, handle };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
...summariseProduct(product),
|
||||
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
|
||||
title: node.title,
|
||||
available: node.availableForSale,
|
||||
price: `${node.price.amount} ${node.price.currencyCode}`,
|
||||
options: node.selectedOptions,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
listCollections: tool({
|
||||
description:
|
||||
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(25).default(10),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const collections = await getCollections(limit);
|
||||
return {
|
||||
collections: collections.map((collection) => ({
|
||||
title: collection.title,
|
||||
handle: collection.handle,
|
||||
url: `/collections/${collection.handle}`,
|
||||
description: collection.description?.slice(0, 200) ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
getCollectionProducts: tool({
|
||||
description:
|
||||
'List the products inside one collection, by collection handle.',
|
||||
inputSchema: z.object({
|
||||
handle: z.string().describe('The collection handle, e.g. "men".'),
|
||||
limit: z.number().int().min(1).max(20).default(8),
|
||||
}),
|
||||
execute: async ({ handle, limit }) => {
|
||||
const page = await getCollectionProductsPage(handle, { first: limit });
|
||||
if (!page.collection) return { found: false, handle };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
collection: page.collection.title,
|
||||
url: `/collections/${handle}`,
|
||||
products: page.products.map(summariseProduct),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
browseProducts: tool({
|
||||
description:
|
||||
'Browse the newest products when the shopper has no specific search term.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(20).default(8),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const page = await getProductsPage({
|
||||
first: limit,
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true,
|
||||
});
|
||||
return { products: page.products.map(summariseProduct) };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -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} />;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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()}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
value={term}
|
||||
onValueChange={setTerm}
|
||||
onClear={() => setTerm('')}
|
||||
onClose={close}
|
||||
placeholder="Search products"
|
||||
aria-label="Search products"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') goToSearchPage();
|
||||
}}
|
||||
/>
|
||||
<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
|
||||
value={term}
|
||||
onValueChange={setTerm}
|
||||
placeholder="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>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
PromptInputTextarea,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Tool,
|
||||
ToolHeader,
|
||||
ToolContent,
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
} from '@/components/ai-elements/tool';
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
RiSparkling2Line,
|
||||
RiCloseLine,
|
||||
RiExpandLeftRightLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'What do you sell?',
|
||||
'Show me hoodies under $100',
|
||||
'What collections are there?',
|
||||
];
|
||||
|
||||
// Human-readable labels for the tool names exposed by /api/chat.
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
searchCatalogue: 'Searching the catalogue',
|
||||
getProductDetails: 'Reading product details',
|
||||
listCollections: 'Listing collections',
|
||||
getCollectionProducts: 'Browsing a collection',
|
||||
browseProducts: 'Browsing new arrivals',
|
||||
};
|
||||
|
||||
const toolLabel = (type: string) => {
|
||||
const name = type.startsWith('tool-') ? type.slice(5) : type;
|
||||
return TOOL_LABELS[name] ?? name;
|
||||
};
|
||||
|
||||
const StoreAssistant: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const { messages, sendMessage, status, error } = useChat({
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
const isBusy = status === 'submitted' || status === 'streaming';
|
||||
|
||||
const send = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isBusy) return;
|
||||
sendMessage({ text: trimmed });
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Launcher */}
|
||||
{!open && (
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open store assistant"
|
||||
className="fixed bottom-6 right-6 z-50 h-12 gap-x-2 rounded-full px-5 shadow-lg"
|
||||
>
|
||||
<RiSparkling2Line className="size-5" />
|
||||
Ask
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
aria-label="Store assistant"
|
||||
aria-hidden={!open}
|
||||
className={`fixed inset-y-0 right-0 z-50 flex flex-col border-l border-border bg-background transition-[transform,width] duration-300 ${
|
||||
expanded ? 'w-full sm:w-[36rem]' : 'w-full sm:w-96'
|
||||
} ${open ? 'translate-x-0' : 'pointer-events-none translate-x-full'}`}
|
||||
>
|
||||
<header className="flex h-14 shrink-0 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<RiSparkling2Line className="size-5" />
|
||||
<span className="text-base font-medium">Store Assistant</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={expanded ? 'Collapse panel' : 'Expand panel'}
|
||||
className="hidden rounded-full sm:inline-flex"
|
||||
>
|
||||
<RiExpandLeftRightLine className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Close assistant"
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-4">
|
||||
{messages.length === 0 && (
|
||||
<ConversationEmptyState
|
||||
icon={<RiSparkling2Line className="size-6" />}
|
||||
title="Ask about the store"
|
||||
description="Find products, compare options, and browse collections."
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{SUGGESTIONS.map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion}
|
||||
onClick={() => send(suggestion)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-normal"
|
||||
>
|
||||
{suggestion}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ConversationEmptyState>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<MessageResponse key={index}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool calls render as collapsible cards; while the model
|
||||
// is still working, the header shimmers instead.
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const toolPart = part as typeof part & {
|
||||
state: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
const running =
|
||||
toolPart.state === 'input-streaming' ||
|
||||
toolPart.state === 'input-available';
|
||||
|
||||
return (
|
||||
<Tool key={index} className="my-1">
|
||||
{running ? (
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Shimmer className="text-sm">
|
||||
{toolLabel(part.type)}
|
||||
</Shimmer>
|
||||
</div>
|
||||
) : (
|
||||
<ToolHeader
|
||||
type={part.type as never}
|
||||
state={toolPart.state as never}
|
||||
title={toolLabel(part.type)}
|
||||
/>
|
||||
)}
|
||||
<ToolContent>
|
||||
<ToolInput input={toolPart.input} />
|
||||
<ToolOutput
|
||||
output={toolPart.output}
|
||||
errorText={toolPart.errorText}
|
||||
/>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
|
||||
{status === 'submitted' && (
|
||||
<Shimmer className="px-1 text-sm">Thinking</Shimmer>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<div className="shrink-0 p-3">
|
||||
<PromptInput
|
||||
onSubmit={(message) => send(message.text ?? input)}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder="Ask about products, sizes, collections…"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter>
|
||||
<div />
|
||||
<PromptInputSubmit status={status} disabled={!input.trim()} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoreAssistant;
|
||||
+37
-43
@@ -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',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground border-border hover:bg-accent hover:text-accent-foreground',
|
||||
};
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = 'default',
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
children,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const baseClasses = cn(
|
||||
'inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden',
|
||||
'[&>svg]:size-3 [&>svg]:pointer-events-none [&>svg]:shrink-0'
|
||||
);
|
||||
|
||||
const variantClasses = badgeVariants[variant];
|
||||
|
||||
const finalClassName = cn(baseClasses, variantClasses, className);
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
const child = children as React.ReactElement<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 }
|
||||
|
||||
@@ -1,97 +1,83 @@
|
||||
import React from 'react';
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
// Utility function to combine classNames
|
||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
// Button group variants helper
|
||||
function getButtonGroupVariants(
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
): string {
|
||||
const baseStyles =
|
||||
'flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*="w-"])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2';
|
||||
|
||||
const orientationStyles = {
|
||||
horizontal:
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
};
|
||||
|
||||
return cn(baseStyles, orientationStyles[orientation]);
|
||||
}
|
||||
|
||||
interface ButtonGroupProps extends React.ComponentProps<'div'> {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
orientation,
|
||||
...props
|
||||
}: ButtonGroupProps) {
|
||||
}: React.ComponentProps<"div"> & VariantProps<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,
|
||||
}
|
||||
|
||||
+61
-67
@@ -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 buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border border-border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
export default Button;
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+81
-191
@@ -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);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
setOpen(true);
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
setOpen(false);
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
function DialogOverlay({ className, onClick, ...props }: DialogOverlayProps) {
|
||||
const { setOpen } = useDialog();
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
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 && (
|
||||
<>
|
||||
<DialogOverlay />
|
||||
<motion.div
|
||||
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>
|
||||
</>
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
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
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{...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.HTMLAttributes<HTMLDivElement>) {
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={clsx(
|
||||
'flex flex-col gap-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+147
-244
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
placeholder?: string;
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"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}
|
||||
>
|
||||
<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 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;
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
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',
|
||||
className
|
||||
)}
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="p-1 overflow-y-auto max-h-60">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -9,24 +9,38 @@
|
||||
},
|
||||
"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",
|
||||
"@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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user