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 React from 'react';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import StoreAssistant from '@/components/shopify/store-assistant';
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -23,6 +24,7 @@ export default function RootLayout({
|
|||||||
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
||||||
<body className="font-body antialiased bg-background text-foreground m-0 p-0">
|
<body className="font-body antialiased bg-background text-foreground m-0 p-0">
|
||||||
{children}
|
{children}
|
||||||
|
<StoreAssistant />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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';
|
} from '@/components/ui/command';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader } from '@/app/components/ui/loader';
|
import { Loader } from '@/app/components/ui/loader';
|
||||||
import { RiSearchLine, RiImageLine } from '@remixicon/react';
|
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
|
||||||
import {
|
import {
|
||||||
searchSuggestions,
|
searchSuggestions,
|
||||||
type SearchSuggestion,
|
type SearchSuggestion,
|
||||||
@@ -91,23 +91,51 @@ const SearchDialog: React.FC = () => {
|
|||||||
<RiSearchLine className="size-5" />
|
<RiSearchLine className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<CommandDialog open={open} onOpenChange={(next) => !next && close()}>
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => !next && close()}
|
||||||
|
title="Search"
|
||||||
|
description="Search products"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="top-24 max-w-xl translate-y-0"
|
||||||
|
// Results come from the Storefront API, so cmdk must not re-filter them.
|
||||||
|
shouldFilter={false}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
autoFocus
|
|
||||||
value={term}
|
value={term}
|
||||||
onValueChange={setTerm}
|
onValueChange={setTerm}
|
||||||
onClear={() => setTerm('')}
|
|
||||||
onClose={close}
|
|
||||||
placeholder="Search products"
|
placeholder="Search products"
|
||||||
aria-label="Search products"
|
className="pr-28"
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter') goToSearchPage();
|
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 && (
|
{hasQuery && (
|
||||||
<>
|
<>
|
||||||
<div className="px-4 pb-2">
|
<div className="px-3 pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={goToSearchPage}
|
onClick={goToSearchPage}
|
||||||
className="rounded-full border border-border px-3 py-1 text-sm text-foreground transition-colors hover:border-foreground"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommandList>
|
<CommandList className="max-h-80">
|
||||||
{searching && results.length === 0 ? (
|
{searching && results.length === 0 ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<Loader size={20} />
|
<Loader size={20} />
|
||||||
@@ -128,7 +156,9 @@ const SearchDialog: React.FC = () => {
|
|||||||
{results.map((product) => (
|
{results.map((product) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={product.id}
|
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">
|
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
|
||||||
{product.featuredImage ? (
|
{product.featuredImage ? (
|
||||||
@@ -139,7 +169,7 @@ const SearchDialog: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-zinc-400">
|
<div className="flex h-full w-full items-center justify-center text-zinc-400">
|
||||||
<RiImageLine size={18} />
|
<RiImageLine className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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;
|
||||||
+34
-40
@@ -1,54 +1,48 @@
|
|||||||
import React from 'react';
|
import * as React from "react"
|
||||||
import { cn } from '@/lib/utils';
|
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'> {
|
const badgeVariants = cva(
|
||||||
variant?: BadgeVariant;
|
"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",
|
||||||
asChild?: boolean;
|
{
|
||||||
}
|
variants: {
|
||||||
|
variant: {
|
||||||
const badgeVariants: Record<BadgeVariant, string> = {
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
default:
|
|
||||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
|
|
||||||
secondary:
|
secondary:
|
||||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90',
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'border-transparent bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
'text-foreground border-border hover:bg-accent hover:text-accent-foreground',
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
};
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
variant = 'default',
|
variant = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
children,
|
|
||||||
...props
|
...props
|
||||||
}: BadgeProps) {
|
}: React.ComponentProps<"span"> &
|
||||||
const baseClasses = cn(
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
'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',
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
'[&>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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span data-slot="badge" className={finalClassName} {...props}>
|
<Comp
|
||||||
{children}
|
data-slot="badge"
|
||||||
</span>
|
data-variant={variant}
|
||||||
);
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants }
|
||||||
export type { BadgeProps };
|
|
||||||
|
|||||||
@@ -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
|
import { cn } from "@/lib/utils"
|
||||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
import { Separator } from "@/components/ui/separator"
|
||||||
return classes.filter(Boolean).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button group variants helper
|
const buttonGroupVariants = cva(
|
||||||
function getButtonGroupVariants(
|
"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",
|
||||||
orientation: 'horizontal' | 'vertical'
|
{
|
||||||
): string {
|
variants: {
|
||||||
const baseStyles =
|
orientation: {
|
||||||
'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:
|
horizontal:
|
||||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||||
vertical:
|
vertical:
|
||||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
"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]);
|
defaultVariants: {
|
||||||
}
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
interface ButtonGroupProps extends React.ComponentProps<'div'> {
|
}
|
||||||
orientation?: 'horizontal' | 'vertical';
|
)
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonGroup({
|
function ButtonGroup({
|
||||||
className,
|
className,
|
||||||
orientation = 'horizontal',
|
orientation,
|
||||||
...props
|
...props
|
||||||
}: ButtonGroupProps) {
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
data-slot="button-group"
|
data-slot="button-group"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
className={cn(getButtonGroupVariants(orientation), className)}
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
|
|
||||||
interface ButtonGroupTextProps extends React.ComponentProps<'div'> {
|
|
||||||
asChild?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonGroupText({
|
function ButtonGroupText({
|
||||||
className,
|
className,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: ButtonGroupTextProps) {
|
}: React.ComponentProps<"div"> & {
|
||||||
const Comp = asChild ? 'div' : 'div';
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "div"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button-group-text"
|
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
|
|
||||||
interface ButtonGroupSeparatorProps extends React.ComponentProps<'div'> {
|
|
||||||
orientation?: 'horizontal' | 'vertical';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonGroupSeparator({
|
function ButtonGroupSeparator({
|
||||||
className,
|
className,
|
||||||
orientation = 'vertical',
|
orientation = "vertical",
|
||||||
...props
|
...props
|
||||||
}: ButtonGroupSeparatorProps) {
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
const separatorClasses =
|
|
||||||
orientation === 'vertical' ? 'w-px h-auto' : 'h-px w-auto';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Separator
|
||||||
data-slot="button-group-separator"
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-border relative !m-0 self-stretch',
|
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
|
||||||
separatorClasses,
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
|
export {
|
||||||
export type {
|
ButtonGroup,
|
||||||
ButtonGroupProps,
|
ButtonGroupSeparator,
|
||||||
ButtonGroupTextProps,
|
ButtonGroupText,
|
||||||
ButtonGroupSeparatorProps,
|
buttonGroupVariants,
|
||||||
};
|
}
|
||||||
|
|||||||
+50
-56
@@ -1,70 +1,64 @@
|
|||||||
import React from 'react';
|
import * as React from "react"
|
||||||
import { cn } from '@/lib/utils';
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
import { cn } from "@/lib/utils"
|
||||||
variant?:
|
|
||||||
| 'default'
|
|
||||||
| 'destructive'
|
|
||||||
| 'outline'
|
|
||||||
| 'secondary'
|
|
||||||
| 'ghost'
|
|
||||||
| 'link';
|
|
||||||
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const buttonVariants = cva(
|
||||||
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
"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",
|
||||||
const baseClasses = cn(
|
{
|
||||||
// Base styles
|
variants: {
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all',
|
variant: {
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
'[&_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:
|
destructive:
|
||||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
outline:
|
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',
|
"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',
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
};
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const sizeClasses = {
|
function Button({
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
className,
|
||||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
variant = "default",
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
size = "default",
|
||||||
icon: 'size-9',
|
asChild = false,
|
||||||
'icon-sm': 'size-8',
|
...props
|
||||||
'icon-lg': 'size-10',
|
}: React.ComponentProps<"button"> &
|
||||||
};
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Comp
|
||||||
ref={ref}
|
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
baseClasses,
|
|
||||||
variantClasses[variant],
|
|
||||||
sizeClasses[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
export { Button, buttonVariants }
|
||||||
|
|
||||||
export { Button };
|
|
||||||
export default Button;
|
|
||||||
|
|||||||
@@ -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 * as React from "react"
|
||||||
import { cn } from '@/lib/utils';
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
import { SearchIcon } from "lucide-react"
|
||||||
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
// A command palette in the shadcn shape, implemented on this project's own
|
import { cn } from "@/lib/utils"
|
||||||
// Dialog rather than cmdk so it stays dependency-free. Filtering is left to the
|
import {
|
||||||
// caller, which suits async sources like the Storefront API.
|
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 (
|
return (
|
||||||
<div
|
<CommandPrimitive
|
||||||
data-slot="command"
|
data-slot="command"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandDialog({
|
function CommandDialog({
|
||||||
open,
|
title = "Command Palette",
|
||||||
onOpenChange,
|
description = "Search for a command to run...",
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: CommandDialogProps) {
|
showCloseButton = true,
|
||||||
// Dialog portals straight into document.body, so hold off until mounted or
|
// Forwarded to the inner Command so callers can drive filtering themselves
|
||||||
// prerendering this component's page throws "document is not defined".
|
// (e.g. results already filtered server-side).
|
||||||
const [mounted, setMounted] = React.useState(false);
|
shouldFilter,
|
||||||
React.useEffect(() => setMounted(true), []);
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
if (!mounted) return null;
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
shouldFilter?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
className={cn("overflow-hidden p-0", className)}
|
||||||
// DialogContent merges with clsx, so its own `p-6`/`gap-4` need the
|
showCloseButton={showCloseButton}
|
||||||
// important flag to lose to these.
|
|
||||||
className={cn(
|
|
||||||
'top-24 max-w-xl translate-y-0 overflow-hidden p-0! gap-0!',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandInputProps
|
|
||||||
extends Omit<React.ComponentProps<'input'>, 'onChange'> {
|
|
||||||
onValueChange?: (value: string) => void;
|
|
||||||
onClear?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandInput({
|
function CommandInput({
|
||||||
className,
|
className,
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
onClear,
|
|
||||||
onClose,
|
|
||||||
...props
|
...props
|
||||||
}: CommandInputProps) {
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
const hasValue = Boolean(String(value ?? '').length);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="command-input-wrapper"
|
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" />
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
<input
|
<CommandPrimitive.Input
|
||||||
data-slot="command-input"
|
data-slot="command-input"
|
||||||
value={value}
|
|
||||||
onChange={(event) => onValueChange?.(event.target.value)}
|
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandList({ className, ...props }: React.ComponentProps<'div'>) {
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<CommandPrimitive.List
|
||||||
data-slot="command-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(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) {
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<CommandPrimitive.Empty
|
||||||
data-slot="command-separator"
|
data-slot="command-empty"
|
||||||
className={cn('h-px bg-border', className)}
|
className="py-6 text-center text-sm"
|
||||||
{...props}
|
{...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 {
|
export {
|
||||||
@@ -192,5 +183,6 @@ export {
|
|||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
};
|
}
|
||||||
|
|||||||
+84
-194
@@ -1,141 +1,50 @@
|
|||||||
import React, { useState, useCallback, useContext, createContext } from 'react';
|
"use client"
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
|
|
||||||
interface DialogContextType {
|
import * as React from "react"
|
||||||
open: boolean;
|
import { XIcon } from "lucide-react"
|
||||||
setOpen: (open: boolean) => void;
|
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() {
|
function Dialog({
|
||||||
const context = useContext(DialogContext);
|
...props
|
||||||
if (!context) {
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
throw new Error('Dialog components must be used within a Dialog');
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
}
|
|
||||||
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 DialogTrigger({
|
function DialogTrigger({
|
||||||
children,
|
|
||||||
asChild,
|
|
||||||
...props
|
...props
|
||||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
const { setOpen } = useDialog();
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({ children }: { children: React.ReactNode }) {
|
function DialogPortal({
|
||||||
return createPortal(children, document.body);
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({
|
||||||
children,
|
|
||||||
asChild,
|
|
||||||
...props
|
...props
|
||||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
const { setOpen } = useDialog();
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
function DialogOverlay({ className, onClick, ...props }: DialogOverlayProps) {
|
...props
|
||||||
const { setOpen } = useDialog();
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={clsx('fixed inset-0 z-50 bg-black/50', className)}
|
className={cn(
|
||||||
initial={{ opacity: 0 }}
|
"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",
|
||||||
animate={{ opacity: 1 }}
|
className
|
||||||
exit={{ opacity: 0 }}
|
)}
|
||||||
transition={{ duration: 0.2 }}
|
{...props}
|
||||||
onClick={(e) => {
|
|
||||||
setOpen(false);
|
|
||||||
onClick?.(e as any);
|
|
||||||
}}
|
|
||||||
{...(props as any)}
|
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
|
|
||||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
showCloseButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@@ -143,114 +52,96 @@ function DialogContent({
|
|||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: DialogContentProps) {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
const { open } = useDialog();
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<motion.div
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={clsx(
|
className={cn(
|
||||||
'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',
|
"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
|
|
||||||
)}
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 0 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 0 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
||||||
{...(props as any)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogClose
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="size-4"
|
|
||||||
>
|
|
||||||
<path d="M18 6l-12 12M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</DialogClose>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</DialogPortal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={clsx(
|
|
||||||
'flex flex-col gap-2 text-center sm:text-left',
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
className,
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={clsx(
|
className={cn(
|
||||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
);
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
return (
|
return (
|
||||||
<h2
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={clsx('text-lg leading-none font-semibold', className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
return (
|
return (
|
||||||
<p
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={clsx('text-muted-foreground text-sm', className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -264,5 +155,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
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}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
"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-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+143
-240
@@ -1,287 +1,190 @@
|
|||||||
import React, {
|
"use client"
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
} from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface SelectContextType {
|
import * as React from "react"
|
||||||
open: boolean;
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
setOpen: (open: boolean) => void;
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
value: string;
|
|
||||||
setValue: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SelectContext = createContext<SelectContextType | undefined>(undefined);
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
value: controlledValue,
|
...props
|
||||||
onValueChange,
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
children,
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
}: 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
function SelectGroup({
|
||||||
children: React.ReactNode;
|
...props
|
||||||
placeholder?: string;
|
}: 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({
|
function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
|
size = "default",
|
||||||
children,
|
children,
|
||||||
placeholder = 'Select...',
|
|
||||||
...props
|
...props
|
||||||
}: SelectTriggerProps) {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
const { open, setOpen, value } = useSelect();
|
size?: "sm" | "default"
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
}) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<SelectPrimitive.Trigger
|
||||||
ref={triggerRef}
|
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
onClick={() => setOpen(!open)}
|
data-size={size}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children || <span className="text-muted-foreground">{placeholder}</span>}
|
{children}
|
||||||
<svg
|
<SelectPrimitive.Icon asChild>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
width="24"
|
</SelectPrimitive.Icon>
|
||||||
height="24"
|
</SelectPrimitive.Trigger>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectValueProps {
|
function SelectContent({
|
||||||
children?: React.ReactNode;
|
className,
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
|
||||||
children,
|
children,
|
||||||
placeholder = 'Select...',
|
position = "item-aligned",
|
||||||
}: SelectValueProps) {
|
align = "center",
|
||||||
const { value } = useSelect();
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<span data-slot="select-value">{children || value || placeholder}</span>
|
<SelectPrimitive.Portal>
|
||||||
);
|
<SelectPrimitive.Content
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectContent({ className, children, ...props }: SelectContentProps) {
|
|
||||||
const { open } = useSelect();
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground absolute z-50 min-w-[8rem] rounded-md border border-border shadow-md overflow-hidden top-full mt-2 left-0',
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="p-1 overflow-y-auto max-h-60">{children}</div>
|
<SelectScrollUpButton />
|
||||||
</div>
|
<SelectPrimitive.Viewport
|
||||||
);
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
function SelectLabel({
|
||||||
value: string;
|
className,
|
||||||
children: React.ReactNode;
|
...props
|
||||||
disabled?: boolean;
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
value,
|
className,
|
||||||
children,
|
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,
|
className,
|
||||||
...props
|
...props
|
||||||
}: SelectItemProps) {
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
const { value: selectedValue, setValue } = useSelect();
|
|
||||||
const isSelected = selectedValue === value;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<SelectPrimitive.Separator
|
||||||
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
|
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn(
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
'bg-border pointer-events-none -mx-1 my-1 h-px',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...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 {
|
export {
|
||||||
Select,
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
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 React from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import Button from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export function SonnerDemo() {
|
export function SonnerDemo() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
import React from 'react';
|
import { Loader2Icon } from "lucide-react"
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface SpinnerProps extends React.ComponentProps<'svg'> {
|
import { cn } from "@/lib/utils"
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeClasses = {
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
sm: 'size-3',
|
|
||||||
md: 'size-4',
|
|
||||||
lg: 'size-6',
|
|
||||||
xl: 'size-8',
|
|
||||||
};
|
|
||||||
|
|
||||||
function Spinner({ className, size = 'md', ...props }: SpinnerProps) {
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<Loader2Icon
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading"
|
aria-label="Loading"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
className={cn("size-4 animate-spin", className)}
|
||||||
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)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
/>
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
)
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Spinner };
|
export { Spinner }
|
||||||
export type { SpinnerProps };
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { shopifyFetch } from '@/services/shopify/client';
|
|
||||||
import {
|
import {
|
||||||
GET_COLLECTIONS_QUERY,
|
getCollections,
|
||||||
GET_COLLECTION_PRODUCTS_QUERY,
|
getCollectionProducts,
|
||||||
} from '@/graphql/collections';
|
} from '@/services/shopify/catalog';
|
||||||
import type { Product } from '@/hooks/use-shopify-products';
|
|
||||||
|
|
||||||
interface CollectionImage {
|
export {
|
||||||
url: string;
|
getCollections,
|
||||||
altText?: string;
|
getCollectionProducts,
|
||||||
}
|
getCollectionProductsPage,
|
||||||
|
} from '@/services/shopify/catalog';
|
||||||
|
export type {
|
||||||
|
Collection,
|
||||||
|
CollectionWithProducts,
|
||||||
|
CollectionSortKey,
|
||||||
|
CollectionProductsPage,
|
||||||
|
ProductFilterFacet,
|
||||||
|
} from '@/services/shopify/catalog';
|
||||||
|
|
||||||
export interface Collection {
|
import type {
|
||||||
id: string;
|
Collection,
|
||||||
title: string;
|
CollectionWithProducts,
|
||||||
handle: string;
|
CollectionSortKey,
|
||||||
description?: string;
|
} from '@/services/shopify/catalog';
|
||||||
descriptionHtml?: string;
|
|
||||||
image?: CollectionImage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CollectionWithProducts extends Collection {
|
|
||||||
products: Product[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CollectionSortKey =
|
|
||||||
| 'COLLECTION_DEFAULT'
|
|
||||||
| 'BEST_SELLING'
|
|
||||||
| 'CREATED'
|
|
||||||
| 'PRICE'
|
|
||||||
| 'TITLE';
|
|
||||||
|
|
||||||
interface UseCollectionProductsOptions {
|
interface UseCollectionProductsOptions {
|
||||||
first?: number;
|
first?: number;
|
||||||
after?: string | null;
|
after?: string | null;
|
||||||
sortKey?: CollectionSortKey;
|
sortKey?: CollectionSortKey;
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
/** Raw `input` strings from the connection's `filters` facets. */
|
|
||||||
filterInputs?: string[];
|
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
|
// Hook for fetching all collections
|
||||||
export function useCollections(first = 50) {
|
export function useCollections(first = 50) {
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
|||||||
+19
-131
@@ -1,99 +1,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { shopifyFetch } from '@/services/shopify/client';
|
|
||||||
import {
|
import {
|
||||||
GET_PRODUCTS_QUERY,
|
getProducts,
|
||||||
GET_PRODUCT_QUERY,
|
getProduct,
|
||||||
QUERY_PRODUCT_RECOMMENDATIONS,
|
getProductRecommendations,
|
||||||
} from '@/graphql/products';
|
} from '@/services/shopify/catalog';
|
||||||
|
|
||||||
interface ProductImage {
|
// Pure fetchers live in services/shopify/catalog so server code can use them
|
||||||
url: string;
|
// too; re-exported here so existing imports keep working.
|
||||||
altText?: string;
|
export {
|
||||||
}
|
getProducts,
|
||||||
|
getProductsPage,
|
||||||
|
getProduct,
|
||||||
|
getProductRecommendations,
|
||||||
|
} from '@/services/shopify/catalog';
|
||||||
|
export type {
|
||||||
|
Product,
|
||||||
|
ProductOption,
|
||||||
|
ProductOptionValue,
|
||||||
|
ProductsPage,
|
||||||
|
} from '@/services/shopify/catalog';
|
||||||
|
|
||||||
interface ProductPrice {
|
import type { Product } from '@/services/shopify/catalog';
|
||||||
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 {
|
interface UseProductsOptions {
|
||||||
first?: number;
|
first?: number;
|
||||||
/** Cursor from a previous page's `endCursor`; omit for the first page. */
|
|
||||||
after?: string | null;
|
after?: string | null;
|
||||||
query?: string;
|
query?: string;
|
||||||
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
|
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductsPage {
|
|
||||||
products: Product[];
|
|
||||||
hasNextPage: boolean;
|
|
||||||
endCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseProductsReturn {
|
interface UseProductsReturn {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -101,56 +39,6 @@ interface UseProductsReturn {
|
|||||||
refetch: () => Promise<void>;
|
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
|
// Hook for fetching multiple products
|
||||||
export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
|
export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
|||||||
+10
-120
@@ -1,120 +1,10 @@
|
|||||||
'use client';
|
// Re-exported from the server-safe catalogue module so both client components
|
||||||
|
// and Route Handlers can search the storefront.
|
||||||
import { shopifyFetch } from '@/services/shopify/client';
|
export { searchProducts, searchSuggestions } from '@/services/shopify/catalog';
|
||||||
import {
|
export type {
|
||||||
SEARCH_PRODUCTS_QUERY,
|
SearchSortKey,
|
||||||
SEARCH_SUGGESTIONS_QUERY,
|
SearchFilter,
|
||||||
} from '@/graphql/search';
|
SearchFilterValue,
|
||||||
import type { Product } from '@/hooks/use-shopify-products';
|
SearchProductsResult,
|
||||||
|
SearchSuggestion,
|
||||||
export type SearchSortKey = 'RELEVANCE' | 'PRICE';
|
} from '@/services/shopify/catalog';
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,24 +9,38 @@
|
|||||||
},
|
},
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^4.0.47",
|
||||||
"@next/swc-wasm-web": "16.2.10",
|
"@next/swc-wasm-web": "16.2.10",
|
||||||
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
||||||
"@remixicon/react": "^4.9.0",
|
"@remixicon/react": "^4.9.0",
|
||||||
"@shopify/storefront-api-client": "^1.0.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",
|
"@supabase/supabase-js": "^2.51.0",
|
||||||
|
"ai": "^7",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "12.42.2",
|
"framer-motion": "12.42.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
|
"nanoid": "^6.0.0",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"shiki": "^3.19.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"streamdown": "^2.5.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
|
"use-stick-to-bottom": "^1.1.6",
|
||||||
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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