Template
Refine the store assistant UI and gate it behind a flag
- Gate the assistant on NEXT_PUBLIC_ENABLE_AI=1 (UI only; the /api/chat route still responds if called directly) - Chat model is openai/gpt-5.6-luna-pro with reasoning requested and sendReasoning enabled, rendered via the Reasoning component - Suggestion chips and reasoning come from ai-elements; attachments gain hover-card previews, left alignment, and render under the user message - Launcher: plain Button when closed, magicui RainbowButton when open - Tool results replace the collapsible Tool UI with a shimmer while running and a muted icon + summary line after, plus product previews - Commerce icons in place of the folder icon for collections - Add a thin announcement bar above the header; footer splits links and socials onto separate rows and clears the launcher corner - Add the shadcn base layer so Tailwind v4's bare `border` picks up the theme colour instead of currentColor 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
107959a4c3
commit
7c6abb4648
+4
-1
@@ -3,7 +3,10 @@ NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
|
|||||||
# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
# NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||||
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07
|
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2025-07
|
||||||
|
|
||||||
|
# Store assistant — set to 1 to show the Ask launcher; anything else hides it
|
||||||
|
NEXT_PUBLIC_ENABLE_AI=0
|
||||||
|
|
||||||
# Store assistant (/api/chat) — https://openrouter.ai/keys
|
# Store assistant (/api/chat) — https://openrouter.ai/keys
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
# Optional; defaults to anthropic/claude-sonnet-4.5
|
# Optional; defaults to openai/gpt-5.6-luna-pro
|
||||||
# OPENROUTER_MODEL=
|
# OPENROUTER_MODEL=
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
// server-side on each tool call.
|
// server-side on each tool call.
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
const MODEL = process.env.OPENROUTER_MODEL ?? 'anthropic/claude-sonnet-4.5';
|
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
|
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
|
||||||
|
|
||||||
@@ -84,7 +84,9 @@ export async function POST(req: Request) {
|
|||||||
const openrouter = createOpenRouter({ apiKey });
|
const openrouter = createOpenRouter({ apiKey });
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: openrouter(MODEL),
|
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
|
||||||
|
// renders it via the Reasoning component.
|
||||||
|
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
|
||||||
system: SYSTEM_PROMPT,
|
system: SYSTEM_PROMPT,
|
||||||
messages: await convertToModelMessages(messages),
|
messages: await convertToModelMessages(messages),
|
||||||
// Let the model call a tool, read the result, then answer.
|
// Let the model call a tool, read the result, then answer.
|
||||||
@@ -193,5 +195,7 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse();
|
// sendReasoning forwards reasoning parts to the client; without it the
|
||||||
|
// stream carries text and tool calls only.
|
||||||
|
return result.toUIMessageStreamResponse({ sendReasoning: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Refined neutral modern palette */
|
/* Refined neutral modern palette */
|
||||||
--color-background: hsl(0 0% 100%);
|
--color-background: hsl(0 0% 100%);
|
||||||
@@ -56,6 +58,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles for light, modern Shopify storefront */
|
/* Base styles for light, modern Shopify storefront */
|
||||||
|
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
|
||||||
|
components (e.g. Button's outline variant) rely on this base layer to pick up
|
||||||
|
the theme's border colour instead of the text colour. */
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-feature-settings:
|
font-feature-settings:
|
||||||
'kern' 1,
|
'kern' 1,
|
||||||
@@ -184,3 +195,36 @@ button,
|
|||||||
.price-display {
|
.price-display {
|
||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
|
||||||
|
--color-color-5: var(--color-5);
|
||||||
|
--color-color-4: var(--color-4);
|
||||||
|
--color-color-3: var(--color-3);
|
||||||
|
--color-color-2: var(--color-2);
|
||||||
|
--color-color-1: var(--color-1);
|
||||||
|
@keyframes rainbow {
|
||||||
|
0% {
|
||||||
|
background-position: 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-1: oklch(66.2% 0.225 25.9);
|
||||||
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-1: oklch(66.2% 0.225 25.9);
|
||||||
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
|
}
|
||||||
+4
-1
@@ -10,6 +10,7 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -17,5 +18,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"registries": {
|
||||||
|
"@magicui": "https://magicui.design/r/{name}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { FileUIPart, SourceDocumentUIPart } from "ai";
|
||||||
|
import {
|
||||||
|
FileTextIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
ImageIcon,
|
||||||
|
Music2Icon,
|
||||||
|
PaperclipIcon,
|
||||||
|
VideoIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentData =
|
||||||
|
| (FileUIPart & { id: string })
|
||||||
|
| (SourceDocumentUIPart & { id: string });
|
||||||
|
|
||||||
|
export type AttachmentMediaCategory =
|
||||||
|
| "image"
|
||||||
|
| "video"
|
||||||
|
| "audio"
|
||||||
|
| "document"
|
||||||
|
| "source"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type AttachmentVariant = "grid" | "inline" | "list";
|
||||||
|
|
||||||
|
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
|
||||||
|
audio: Music2Icon,
|
||||||
|
document: FileTextIcon,
|
||||||
|
image: ImageIcon,
|
||||||
|
source: GlobeIcon,
|
||||||
|
unknown: PaperclipIcon,
|
||||||
|
video: VideoIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const getMediaCategory = (
|
||||||
|
data: AttachmentData
|
||||||
|
): AttachmentMediaCategory => {
|
||||||
|
if (data.type === "source-document") {
|
||||||
|
return "source";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = data.mediaType ?? "";
|
||||||
|
|
||||||
|
if (mediaType.startsWith("image/")) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith("video/")) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith("audio/")) {
|
||||||
|
return "audio";
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAttachmentLabel = (data: AttachmentData): string => {
|
||||||
|
if (data.type === "source-document") {
|
||||||
|
return data.title || data.filename || "Source";
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = getMediaCategory(data);
|
||||||
|
return data.filename || (category === "image" ? "Image" : "Attachment");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAttachmentImage = (
|
||||||
|
url: string,
|
||||||
|
filename: string | undefined,
|
||||||
|
isGrid: boolean
|
||||||
|
) =>
|
||||||
|
isGrid ? (
|
||||||
|
<img
|
||||||
|
alt={filename || "Image"}
|
||||||
|
className="size-full object-cover"
|
||||||
|
height={96}
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
alt={filename || "Image"}
|
||||||
|
className="size-full rounded object-cover"
|
||||||
|
height={20}
|
||||||
|
src={url}
|
||||||
|
width={20}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contexts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface AttachmentsContextValue {
|
||||||
|
variant: AttachmentVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
|
||||||
|
|
||||||
|
interface AttachmentContextValue {
|
||||||
|
data: AttachmentData;
|
||||||
|
mediaCategory: AttachmentMediaCategory;
|
||||||
|
onRemove?: () => void;
|
||||||
|
variant: AttachmentVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useAttachmentsContext = () =>
|
||||||
|
useContext(AttachmentsContext) ?? { variant: "grid" as const };
|
||||||
|
|
||||||
|
export const useAttachmentContext = () => {
|
||||||
|
const ctx = useContext(AttachmentContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("Attachment components must be used within <Attachment>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attachments - Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
variant?: AttachmentVariant;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Attachments = ({
|
||||||
|
variant = "grid",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentsProps) => {
|
||||||
|
const contextValue = useMemo(() => ({ variant }), [variant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttachmentsContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-start",
|
||||||
|
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
|
||||||
|
variant === "grid" && "ml-auto w-fit",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AttachmentsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attachment - Item
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
data: AttachmentData;
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Attachment = ({
|
||||||
|
data,
|
||||||
|
onRemove,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentProps) => {
|
||||||
|
const { variant } = useAttachmentsContext();
|
||||||
|
const mediaCategory = getMediaCategory(data);
|
||||||
|
|
||||||
|
const contextValue = useMemo<AttachmentContextValue>(
|
||||||
|
() => ({ data, mediaCategory, onRemove, variant }),
|
||||||
|
[data, mediaCategory, onRemove, variant]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttachmentContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative",
|
||||||
|
variant === "grid" && "size-24 overflow-hidden rounded-lg",
|
||||||
|
variant === "inline" && [
|
||||||
|
"flex h-8 cursor-pointer select-none items-center gap-1.5",
|
||||||
|
"rounded-md border border-border px-1.5",
|
||||||
|
"font-medium text-sm transition-all",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
],
|
||||||
|
variant === "list" && [
|
||||||
|
"flex w-full items-center gap-3 rounded-lg border p-3",
|
||||||
|
"hover:bg-accent/50",
|
||||||
|
],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AttachmentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentPreview - Media preview
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
fallbackIcon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttachmentPreview = ({
|
||||||
|
fallbackIcon,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AttachmentPreviewProps) => {
|
||||||
|
const { data, mediaCategory, variant } = useAttachmentContext();
|
||||||
|
|
||||||
|
const iconSize = variant === "inline" ? "size-3" : "size-4";
|
||||||
|
|
||||||
|
const renderIcon = (Icon: typeof ImageIcon) => (
|
||||||
|
<Icon className={cn(iconSize, "text-muted-foreground")} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (mediaCategory === "image" && data.type === "file" && data.url) {
|
||||||
|
return renderAttachmentImage(data.url, data.filename, variant === "grid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaCategory === "video" && data.type === "file" && data.url) {
|
||||||
|
return <video className="size-full object-cover" muted src={data.url} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = mediaCategoryIcons[mediaCategory];
|
||||||
|
return fallbackIcon ?? renderIcon(Icon);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center justify-center overflow-hidden",
|
||||||
|
variant === "grid" && "size-full bg-muted",
|
||||||
|
variant === "inline" && "size-5 rounded bg-background",
|
||||||
|
variant === "list" && "size-12 rounded bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentInfo - Name and type display
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
showMediaType?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttachmentInfo = ({
|
||||||
|
showMediaType = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AttachmentInfoProps) => {
|
||||||
|
const { data, variant } = useAttachmentContext();
|
||||||
|
const label = getAttachmentLabel(data);
|
||||||
|
|
||||||
|
if (variant === "grid") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("min-w-0 flex-1", className)} {...props}>
|
||||||
|
<span className="block truncate">{label}</span>
|
||||||
|
{showMediaType && data.mediaType && (
|
||||||
|
<span className="block truncate text-muted-foreground text-xs">
|
||||||
|
{data.mediaType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentRemove - Remove button
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttachmentRemove = ({
|
||||||
|
label = "Remove",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentRemoveProps) => {
|
||||||
|
const { onRemove, variant } = useAttachmentContext();
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove?.();
|
||||||
|
},
|
||||||
|
[onRemove]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onRemove) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
variant === "grid" && [
|
||||||
|
"absolute top-2 right-2 size-6 rounded-full p-0",
|
||||||
|
"bg-background/80 backdrop-blur-sm",
|
||||||
|
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||||
|
"hover:bg-background",
|
||||||
|
"[&>svg]:size-3",
|
||||||
|
],
|
||||||
|
variant === "inline" && [
|
||||||
|
"size-5 rounded p-0",
|
||||||
|
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||||
|
"[&>svg]:size-2.5",
|
||||||
|
],
|
||||||
|
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <XIcon />}
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentHoverCard - Hover preview
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||||
|
|
||||||
|
export const AttachmentHoverCard = ({
|
||||||
|
openDelay = 0,
|
||||||
|
closeDelay = 0,
|
||||||
|
...props
|
||||||
|
}: AttachmentHoverCardProps) => (
|
||||||
|
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AttachmentHoverCardTriggerProps = ComponentProps<
|
||||||
|
typeof HoverCardTrigger
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const AttachmentHoverCardTrigger = (
|
||||||
|
props: AttachmentHoverCardTriggerProps
|
||||||
|
) => <HoverCardTrigger {...props} />;
|
||||||
|
|
||||||
|
export type AttachmentHoverCardContentProps = ComponentProps<
|
||||||
|
typeof HoverCardContent
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const AttachmentHoverCardContent = ({
|
||||||
|
align = "start",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AttachmentHoverCardContentProps) => (
|
||||||
|
<HoverCardContent
|
||||||
|
align={align}
|
||||||
|
className={cn("w-auto p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentEmpty - Empty state
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const AttachmentEmpty = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentEmptyProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center p-4 text-muted-foreground text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? "No attachments"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cjk } from "@streamdown/cjk";
|
||||||
|
import { code } from "@streamdown/code";
|
||||||
|
import { math } from "@streamdown/math";
|
||||||
|
import { mermaid } from "@streamdown/mermaid";
|
||||||
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
import { Shimmer } from "./shimmer";
|
||||||
|
|
||||||
|
interface ReasoningContextValue {
|
||||||
|
isStreaming: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
duration: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useReasoning = () => {
|
||||||
|
const context = useContext(ReasoningContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Reasoning components must be used within Reasoning");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTO_CLOSE_DELAY = 1000;
|
||||||
|
const MS_IN_S = 1000;
|
||||||
|
|
||||||
|
export const Reasoning = memo(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
isStreaming = false,
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
duration: durationProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ReasoningProps) => {
|
||||||
|
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||||
|
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||||
|
const isExplicitlyClosed = defaultOpen === false;
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||||
|
defaultProp: resolvedDefaultOpen,
|
||||||
|
onChange: onOpenChange,
|
||||||
|
prop: open,
|
||||||
|
});
|
||||||
|
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||||
|
defaultProp: undefined,
|
||||||
|
prop: durationProp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasEverStreamedRef = useRef(isStreaming);
|
||||||
|
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Track when streaming starts and compute duration
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
|
hasEverStreamedRef.current = true;
|
||||||
|
if (startTimeRef.current === null) {
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
}
|
||||||
|
} else if (startTimeRef.current !== null) {
|
||||||
|
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||||
|
startTimeRef.current = null;
|
||||||
|
}
|
||||||
|
}, [isStreaming, setDuration]);
|
||||||
|
|
||||||
|
// Auto-open when streaming starts (unless explicitly closed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||||
|
|
||||||
|
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
hasEverStreamedRef.current &&
|
||||||
|
!isStreaming &&
|
||||||
|
isOpen &&
|
||||||
|
!hasAutoClosed
|
||||||
|
) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setHasAutoClosed(true);
|
||||||
|
}, AUTO_CLOSE_DELAY);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(newOpen: boolean) => {
|
||||||
|
setIsOpen(newOpen);
|
||||||
|
},
|
||||||
|
[setIsOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||||
|
[duration, isOpen, isStreaming, setIsOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReasoningContext.Provider value={contextValue}>
|
||||||
|
<Collapsible
|
||||||
|
className={cn("not-prose mb-4", className)}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
open={isOpen}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
</ReasoningContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ReasoningTriggerProps = ComponentProps<
|
||||||
|
typeof CollapsibleTrigger
|
||||||
|
> & {
|
||||||
|
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||||
|
if (isStreaming || duration === 0) {
|
||||||
|
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||||
|
}
|
||||||
|
if (duration === undefined) {
|
||||||
|
return <p>Thought for a few seconds</p>;
|
||||||
|
}
|
||||||
|
return <p>Thought for {duration} seconds</p>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReasoningTrigger = memo(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
getThinkingMessage = defaultGetThinkingMessage,
|
||||||
|
...props
|
||||||
|
}: ReasoningTriggerProps) => {
|
||||||
|
const { isStreaming, isOpen, duration } = useReasoning();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<BrainIcon className="size-4" />
|
||||||
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
"size-4 transition-transform",
|
||||||
|
isOpen ? "rotate-180" : "rotate-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ReasoningContentProps = ComponentProps<
|
||||||
|
typeof CollapsibleContent
|
||||||
|
> & {
|
||||||
|
children: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||||
|
|
||||||
|
export const ReasoningContent = memo(
|
||||||
|
({ className, children, ...props }: ReasoningContentProps) => (
|
||||||
|
<CollapsibleContent
|
||||||
|
className={cn(
|
||||||
|
"mt-4 text-sm",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Reasoning.displayName = "Reasoning";
|
||||||
|
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||||
|
ReasoningContent.displayName = "ReasoningContent";
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ScrollArea,
|
||||||
|
ScrollBar,
|
||||||
|
} from "@/components/ui/scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||||
|
|
||||||
|
export const Suggestions = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SuggestionsProps) => (
|
||||||
|
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||||
|
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<ScrollBar className="hidden" orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||||
|
suggestion: string;
|
||||||
|
onClick?: (suggestion: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Suggestion = ({
|
||||||
|
suggestion,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "sm",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SuggestionProps) => {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onClick?.(suggestion);
|
||||||
|
}, [onClick, suggestion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
size={size}
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children || suggestion}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -42,11 +42,11 @@ const Footer: React.FC<FooterProps> = ({
|
|||||||
return (
|
return (
|
||||||
<footer className="bg-background">
|
<footer className="bg-background">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8 py-10">
|
<div className="max-w-screen-2xl mx-auto px-8 py-10">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-5">
|
{/* Left-aligned: the bottom-right corner belongs to the floating
|
||||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-x-5 gap-y-2">
|
assistant launcher. Links and socials sit on separate rows so the
|
||||||
<p className="text-sm text-muted-foreground leading-5">
|
icons aren't crammed onto the end of the link list. */}
|
||||||
{copyright || `© ${storeName}. All rights reserved.`}
|
<div className="flex flex-col items-center gap-y-5 sm:items-start">
|
||||||
</p>
|
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.label}
|
key={link.label}
|
||||||
@@ -56,9 +56,10 @@ const Footer: React.FC<FooterProps> = ({
|
|||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:gap-x-5">
|
||||||
|
<span className="flex items-center gap-x-4">
|
||||||
{socials.map(({ label, url, Icon }) => (
|
{socials.map(({ label, url, Icon }) => (
|
||||||
<a
|
<a
|
||||||
key={label}
|
key={label}
|
||||||
@@ -69,6 +70,11 @@ const Footer: React.FC<FooterProps> = ({
|
|||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground leading-5">
|
||||||
|
{copyright || `© ${storeName}. All rights reserved.`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ interface HeaderProps {
|
|||||||
storeName?: string;
|
storeName?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
links?: NavLink[];
|
links?: NavLink[];
|
||||||
|
/** Thin bar above the nav. Pass null to hide it. */
|
||||||
|
announcement?: string | null;
|
||||||
|
announcementUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({
|
const Header: React.FC<HeaderProps> = ({
|
||||||
@@ -50,10 +53,31 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
{ label: 'Shop', url: '/' },
|
{ label: 'Shop', url: '/' },
|
||||||
{ label: 'Collections', url: '/collections' },
|
{ label: 'Collections', url: '/collections' },
|
||||||
],
|
],
|
||||||
|
announcement = 'Free shipping on orders over $100',
|
||||||
|
announcementUrl,
|
||||||
}) => {
|
}) => {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Sits above the sticky nav, so it scrolls away on its own. */}
|
||||||
|
{announcement && (
|
||||||
|
<div className="bg-foreground text-background">
|
||||||
|
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
|
||||||
|
{announcementUrl ? (
|
||||||
|
<Link
|
||||||
|
href={announcementUrl}
|
||||||
|
className="underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{announcement}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span>{announcement}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
|
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8">
|
<div className="max-w-screen-2xl mx-auto px-8">
|
||||||
<div className="flex justify-between items-center h-14">
|
<div className="flex justify-between items-center h-14">
|
||||||
@@ -128,6 +152,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
<CartDrawer />
|
<CartDrawer />
|
||||||
</nav>
|
</nav>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useChat } from '@ai-sdk/react';
|
import { useChat } from '@ai-sdk/react';
|
||||||
import { DefaultChatTransport } from 'ai';
|
import { DefaultChatTransport } from 'ai';
|
||||||
import {
|
import {
|
||||||
@@ -16,34 +17,77 @@ import {
|
|||||||
} from '@/components/ai-elements/message';
|
} from '@/components/ai-elements/message';
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
|
PromptInputActionAddAttachments,
|
||||||
|
PromptInputActionMenu,
|
||||||
|
PromptInputActionMenuContent,
|
||||||
|
PromptInputActionMenuTrigger,
|
||||||
PromptInputBody,
|
PromptInputBody,
|
||||||
PromptInputTextarea,
|
|
||||||
PromptInputFooter,
|
PromptInputFooter,
|
||||||
|
PromptInputProvider,
|
||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
|
PromptInputTextarea,
|
||||||
|
PromptInputTools,
|
||||||
|
usePromptInputAttachments,
|
||||||
|
type PromptInputMessage,
|
||||||
} from '@/components/ai-elements/prompt-input';
|
} from '@/components/ai-elements/prompt-input';
|
||||||
import {
|
import {
|
||||||
Tool,
|
Attachment,
|
||||||
ToolHeader,
|
AttachmentHoverCard,
|
||||||
ToolContent,
|
AttachmentHoverCardContent,
|
||||||
ToolInput,
|
AttachmentHoverCardTrigger,
|
||||||
ToolOutput,
|
AttachmentInfo,
|
||||||
} from '@/components/ai-elements/tool';
|
AttachmentPreview,
|
||||||
|
AttachmentRemove,
|
||||||
|
Attachments,
|
||||||
|
getAttachmentLabel,
|
||||||
|
getMediaCategory,
|
||||||
|
type AttachmentData,
|
||||||
|
} from '@/components/ai-elements/attachments';
|
||||||
|
import {
|
||||||
|
Suggestions,
|
||||||
|
Suggestion,
|
||||||
|
} from '@/components/ai-elements/suggestion';
|
||||||
|
import {
|
||||||
|
Reasoning,
|
||||||
|
ReasoningContent,
|
||||||
|
ReasoningTrigger,
|
||||||
|
} from '@/components/ai-elements/reasoning';
|
||||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RainbowButton } from '@/components/ui/rainbow-button';
|
||||||
import {
|
import {
|
||||||
RiSparkling2Line,
|
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiExpandLeftRightLine,
|
RiSearchLine,
|
||||||
|
RiPriceTag3Line,
|
||||||
|
RiStore2Line,
|
||||||
|
RiLayoutGridLine,
|
||||||
|
RiShoppingBag3Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
|
|
||||||
|
// Feature flag. Written as a static member expression so Next inlines it at
|
||||||
|
// build time; the assistant is off unless the env var is explicitly "1".
|
||||||
|
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
|
||||||
|
|
||||||
const SUGGESTIONS = [
|
const SUGGESTIONS = [
|
||||||
'What do you sell?',
|
'What do you sell?',
|
||||||
'Show me hoodies under $100',
|
'Show me hoodies under $100',
|
||||||
'What collections are there?',
|
'What collections are there?',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Human-readable labels for the tool names exposed by /api/chat.
|
interface ToolProduct {
|
||||||
const TOOL_LABELS: Record<string, string> = {
|
handle: string;
|
||||||
|
title: string;
|
||||||
|
image: string | null;
|
||||||
|
price?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolSummary {
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
products: ToolProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOADING_LABELS: Record<string, string> = {
|
||||||
searchCatalogue: 'Searching the catalogue',
|
searchCatalogue: 'Searching the catalogue',
|
||||||
getProductDetails: 'Reading product details',
|
getProductDetails: 'Reading product details',
|
||||||
listCollections: 'Listing collections',
|
listCollections: 'Listing collections',
|
||||||
@@ -51,22 +95,227 @@ const TOOL_LABELS: Record<string, string> = {
|
|||||||
browseProducts: 'Browsing new arrivals',
|
browseProducts: 'Browsing new arrivals',
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolLabel = (type: string) => {
|
const toolName = (type: string) =>
|
||||||
const name = type.startsWith('tool-') ? type.slice(5) : type;
|
type.startsWith('tool-') ? type.slice(5) : type;
|
||||||
return TOOL_LABELS[name] ?? name;
|
|
||||||
|
const plural = (count: number, noun: string) =>
|
||||||
|
`${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
|
// Turns a finished tool result into the one-line summary plus any products
|
||||||
|
// worth previewing.
|
||||||
|
const summariseTool = (
|
||||||
|
name: string,
|
||||||
|
output: Record<string, unknown> | undefined
|
||||||
|
): ToolSummary => {
|
||||||
|
const products = (output?.products as ToolProduct[] | undefined) ?? [];
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'searchCatalogue': {
|
||||||
|
const total = (output?.totalCount as number) ?? products.length;
|
||||||
|
return {
|
||||||
|
label: `Found ${plural(total, 'product')}`,
|
||||||
|
icon: <RiSearchLine className="size-3.5" />,
|
||||||
|
products,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'getProductDetails':
|
||||||
|
return {
|
||||||
|
label: output?.found
|
||||||
|
? `Read ${output.title as string}`
|
||||||
|
: 'Product not found',
|
||||||
|
icon: <RiPriceTag3Line className="size-3.5" />,
|
||||||
|
products: output?.found
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
handle: output.handle as string,
|
||||||
|
title: output.title as string,
|
||||||
|
image: (output.image as string) ?? null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
case 'listCollections': {
|
||||||
|
const collections =
|
||||||
|
(output?.collections as Array<unknown> | undefined) ?? [];
|
||||||
|
return {
|
||||||
|
label: `Found ${plural(collections.length, 'collection')}`,
|
||||||
|
icon: <RiStore2Line className="size-3.5" />,
|
||||||
|
products: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'getCollectionProducts':
|
||||||
|
return {
|
||||||
|
label: output?.found
|
||||||
|
? `Found ${plural(products.length, 'product')} in ${output.collection}`
|
||||||
|
: 'Collection not found',
|
||||||
|
icon: <RiLayoutGridLine className="size-3.5" />,
|
||||||
|
products,
|
||||||
|
};
|
||||||
|
case 'browseProducts':
|
||||||
|
return {
|
||||||
|
label: `Browsed ${plural(products.length, 'new arrival')}`,
|
||||||
|
icon: <RiShoppingBag3Line className="size-3.5" />,
|
||||||
|
products,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: name,
|
||||||
|
icon: <RiSearchLine className="size-3.5" />,
|
||||||
|
products,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
|
||||||
|
const isInternalLink = (url: string) => {
|
||||||
|
if (url.startsWith('/')) return true;
|
||||||
|
try {
|
||||||
|
return new URL(url, window.location.origin).origin === window.location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductPreviews: React.FC<{ products: ToolProduct[] }> = ({
|
||||||
|
products,
|
||||||
|
}) => {
|
||||||
|
const withImages = products.filter((product) => product.image);
|
||||||
|
if (withImages.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Attachments variant="grid" className="ml-0 mt-2">
|
||||||
|
{withImages.slice(0, 6).map((product) => (
|
||||||
|
<Link
|
||||||
|
key={product.handle}
|
||||||
|
href={`/products/${product.handle}`}
|
||||||
|
title={product.title}
|
||||||
|
className="group/product w-20"
|
||||||
|
>
|
||||||
|
<Attachment
|
||||||
|
data={{
|
||||||
|
id: product.handle,
|
||||||
|
type: 'file',
|
||||||
|
url: product.image as string,
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
filename: product.title,
|
||||||
|
}}
|
||||||
|
className="size-20"
|
||||||
|
>
|
||||||
|
<AttachmentPreview />
|
||||||
|
</Attachment>
|
||||||
|
<span className="mt-1 line-clamp-2 block text-[11px] leading-tight text-muted-foreground group-hover/product:text-foreground">
|
||||||
|
{product.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Attachments>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AttachmentItemProps {
|
||||||
|
attachment: AttachmentData;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
() => onRemove(attachment.id),
|
||||||
|
[onRemove, attachment.id]
|
||||||
|
);
|
||||||
|
const mediaCategory = getMediaCategory(attachment);
|
||||||
|
const label = getAttachmentLabel(attachment);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttachmentHoverCard key={attachment.id}>
|
||||||
|
<AttachmentHoverCardTrigger asChild>
|
||||||
|
<Attachment data={attachment} onRemove={handleRemove}>
|
||||||
|
{/* Thumbnail swaps to the remove button on hover. */}
|
||||||
|
<div className="group relative size-5 shrink-0">
|
||||||
|
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
|
||||||
|
<AttachmentPreview />
|
||||||
|
</div>
|
||||||
|
<AttachmentRemove className="absolute inset-0" />
|
||||||
|
</div>
|
||||||
|
<AttachmentInfo />
|
||||||
|
</Attachment>
|
||||||
|
</AttachmentHoverCardTrigger>
|
||||||
|
<AttachmentHoverCardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mediaCategory === 'image' &&
|
||||||
|
attachment.type === 'file' &&
|
||||||
|
attachment.url && (
|
||||||
|
<div className="flex max-h-96 w-80 items-center justify-center overflow-hidden rounded-md border">
|
||||||
|
<img
|
||||||
|
alt={label}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
height={384}
|
||||||
|
src={attachment.url}
|
||||||
|
width={320}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1 px-0.5">
|
||||||
|
<h4 className="text-sm font-semibold leading-none">{label}</h4>
|
||||||
|
{attachment.mediaType && (
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
{attachment.mediaType}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AttachmentHoverCardContent>
|
||||||
|
</AttachmentHoverCard>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AttachmentItem.displayName = 'AttachmentItem';
|
||||||
|
|
||||||
|
// Pending uploads, shown inline above the textarea.
|
||||||
|
const PromptInputAttachmentsDisplay = () => {
|
||||||
|
const attachments = usePromptInputAttachments();
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(id: string) => attachments.remove(id),
|
||||||
|
[attachments]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attachments.files.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Attachments variant="inline" className="w-full justify-start px-2 pt-2">
|
||||||
|
{attachments.files.map((attachment) => (
|
||||||
|
<AttachmentItem
|
||||||
|
attachment={attachment}
|
||||||
|
key={attachment.id}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Attachments>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StoreAssistant: React.FC = () => {
|
const StoreAssistant: React.FC = () => {
|
||||||
|
// Returns before any hooks run — safe because the flag is a build-time
|
||||||
|
// constant and cannot change between renders.
|
||||||
|
if (!AI_ENABLED) return null;
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
// Drives the launcher's slide-in on first paint.
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
const { messages, sendMessage, status, error } = useChat({
|
const { messages, sendMessage, status, error } = useChat({
|
||||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBusy = status === 'submitted' || status === 'streaming';
|
const isBusy = status === 'submitted' || status === 'streaming';
|
||||||
|
|
||||||
|
const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${
|
||||||
|
mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0'
|
||||||
|
}`;
|
||||||
|
|
||||||
const send = (text: string) => {
|
const send = (text: string) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || isBusy) return;
|
if (!trimmed || isBusy) return;
|
||||||
@@ -74,142 +323,181 @@ const StoreAssistant: React.FC = () => {
|
|||||||
setInput('');
|
setInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attachments arrive on the submitted message, so images go out with it.
|
||||||
|
const handleSubmit = (message: PromptInputMessage) => {
|
||||||
|
const text = (message.text ?? input).trim();
|
||||||
|
const files = message.files ?? [];
|
||||||
|
if ((!text && files.length === 0) || isBusy) return;
|
||||||
|
|
||||||
|
sendMessage({ text, files });
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Launcher */}
|
{/* Popover panel, anchored above the launcher */}
|
||||||
{!open && (
|
<div
|
||||||
<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}
|
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 ${
|
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
|
||||||
expanded ? 'w-full sm:w-[36rem]' : 'w-full sm:w-96'
|
open
|
||||||
} ${open ? 'translate-x-0' : 'pointer-events-none translate-x-full'}`}
|
? 'pointer-events-auto translate-y-0 opacity-100'
|
||||||
|
: 'pointer-events-none translate-y-2 opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{ height: 'min(32rem, calc(100vh - 8rem))' }}
|
||||||
>
|
>
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between px-4">
|
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-2">
|
||||||
<div className="flex items-center gap-x-2">
|
<span className="text-sm font-medium">Store Assistant</span>
|
||||||
<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
|
<Button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
aria-label="Close assistant"
|
aria-label="Close assistant"
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
>
|
>
|
||||||
<RiCloseLine className="size-5" />
|
<RiCloseLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Conversation className="flex-1">
|
<Conversation className="flex-1">
|
||||||
<ConversationContent className="gap-4">
|
<ConversationContent className="gap-4 p-3">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<ConversationEmptyState
|
<ConversationEmptyState
|
||||||
icon={<RiSparkling2Line className="size-6" />}
|
|
||||||
title="Ask about the store"
|
title="Ask about the store"
|
||||||
description="Find products, compare options, and browse collections."
|
description="Find products, compare options, browse collections."
|
||||||
>
|
>
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
{/* w-full + wrap so chips stack in the narrow popover rather
|
||||||
|
than scrolling off the edge. */}
|
||||||
|
<Suggestions className="mt-3 w-full flex-wrap justify-center">
|
||||||
{SUGGESTIONS.map((suggestion) => (
|
{SUGGESTIONS.map((suggestion) => (
|
||||||
<Button
|
<Suggestion
|
||||||
key={suggestion}
|
key={suggestion}
|
||||||
onClick={() => send(suggestion)}
|
onClick={send}
|
||||||
variant="outline"
|
suggestion={suggestion}
|
||||||
size="sm"
|
|
||||||
className="font-normal"
|
className="font-normal"
|
||||||
>
|
/>
|
||||||
{suggestion}
|
|
||||||
</Button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Suggestions>
|
||||||
</ConversationEmptyState>
|
</ConversationEmptyState>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((message) => (
|
{messages.map((message) => {
|
||||||
|
const fileParts = message.parts.filter(
|
||||||
|
(part) => part.type === 'file'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<Message key={message.id} from={message.role}>
|
<Message key={message.id} from={message.role}>
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
{message.parts.map((part, index) => {
|
{message.parts.map((part, index) => {
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
return (
|
return (
|
||||||
<MessageResponse key={index}>
|
<MessageResponse
|
||||||
|
key={index}
|
||||||
|
linkSafety={{
|
||||||
|
enabled: true,
|
||||||
|
onLinkCheck: isInternalLink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{part.text}
|
{part.text}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls render as collapsible cards; while the model
|
if (part.type === 'reasoning') {
|
||||||
// is still working, the header shimmers instead.
|
return (
|
||||||
|
<Reasoning
|
||||||
|
key={index}
|
||||||
|
className="w-full"
|
||||||
|
isStreaming={
|
||||||
|
status === 'streaming' &&
|
||||||
|
part.state === 'streaming'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReasoningTrigger />
|
||||||
|
<ReasoningContent>{part.text}</ReasoningContent>
|
||||||
|
</Reasoning>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (part.type.startsWith('tool-')) {
|
if (part.type.startsWith('tool-')) {
|
||||||
const toolPart = part as typeof part & {
|
const toolPart = part as typeof part & {
|
||||||
state: string;
|
state: string;
|
||||||
input?: unknown;
|
output?: Record<string, unknown>;
|
||||||
output?: unknown;
|
|
||||||
errorText?: string;
|
errorText?: string;
|
||||||
};
|
};
|
||||||
const running =
|
const name = toolName(part.type);
|
||||||
|
|
||||||
|
// Shimmer while the call is in flight; a quiet summary
|
||||||
|
// line once it returns.
|
||||||
|
if (
|
||||||
toolPart.state === 'input-streaming' ||
|
toolPart.state === 'input-streaming' ||
|
||||||
toolPart.state === 'input-available';
|
toolPart.state === 'input-available'
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Shimmer key={index} className="text-xs">
|
||||||
|
{LOADING_LABELS[name] ?? name}
|
||||||
|
</Shimmer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolPart.state === 'output-error') {
|
||||||
|
return (
|
||||||
|
<p key={index} className="text-xs text-muted-foreground">
|
||||||
|
Couldn't load that.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, icon, products } = summariseTool(
|
||||||
|
name,
|
||||||
|
toolPart.output
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tool key={index} className="my-1">
|
<div key={index} className="my-1">
|
||||||
{running ? (
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-2 p-3">
|
{icon}
|
||||||
<Shimmer className="text-sm">
|
{label}
|
||||||
{toolLabel(part.type)}
|
</div>
|
||||||
</Shimmer>
|
<ProductPreviews products={products} />
|
||||||
</div>
|
</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;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Images the shopper attached, shown under their message. */}
|
||||||
|
{fileParts.length > 0 && (
|
||||||
|
<Attachments variant="grid" className="ml-0 justify-start">
|
||||||
|
{fileParts.map((part, index) => (
|
||||||
|
<Attachment
|
||||||
|
key={`${message.id}-file-${index}`}
|
||||||
|
data={{
|
||||||
|
id: `${message.id}-file-${index}`,
|
||||||
|
type: 'file',
|
||||||
|
url: part.url,
|
||||||
|
mediaType: part.mediaType,
|
||||||
|
filename: part.filename,
|
||||||
|
}}
|
||||||
|
className="size-20"
|
||||||
|
>
|
||||||
|
<AttachmentPreview />
|
||||||
|
</Attachment>
|
||||||
|
))}
|
||||||
|
</Attachments>
|
||||||
|
)}
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{status === 'submitted' && (
|
{status === 'submitted' && (
|
||||||
<Shimmer className="px-1 text-sm">Thinking</Shimmer>
|
<Shimmer className="text-xs">Thinking</Shimmer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
Something went wrong. Please try again.
|
Something went wrong. Please try again.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -217,25 +505,63 @@ const StoreAssistant: React.FC = () => {
|
|||||||
<ConversationScrollButton />
|
<ConversationScrollButton />
|
||||||
</Conversation>
|
</Conversation>
|
||||||
|
|
||||||
<div className="shrink-0 p-3">
|
<div className="shrink-0 p-2">
|
||||||
|
<PromptInputProvider>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
onSubmit={(message) => send(message.text ?? input)}
|
globalDrop
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
>
|
>
|
||||||
|
<PromptInputAttachmentsDisplay />
|
||||||
<PromptInputBody>
|
<PromptInputBody>
|
||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => setInput(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
placeholder="Ask about products, sizes, collections…"
|
placeholder="Ask about products…"
|
||||||
|
className="min-h-12"
|
||||||
/>
|
/>
|
||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
<PromptInputFooter>
|
<PromptInputFooter>
|
||||||
<div />
|
<PromptInputTools>
|
||||||
<PromptInputSubmit status={status} disabled={!input.trim()} />
|
<PromptInputActionMenu>
|
||||||
|
<PromptInputActionMenuTrigger />
|
||||||
|
<PromptInputActionMenuContent>
|
||||||
|
<PromptInputActionAddAttachments />
|
||||||
|
</PromptInputActionMenuContent>
|
||||||
|
</PromptInputActionMenu>
|
||||||
|
</PromptInputTools>
|
||||||
|
<PromptInputSubmit status={status} />
|
||||||
</PromptInputFooter>
|
</PromptInputFooter>
|
||||||
</PromptInput>
|
</PromptInput>
|
||||||
|
</PromptInputProvider>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
|
||||||
|
{/* Launcher */}
|
||||||
|
{/* Rainbow treatment only while the assistant is open; otherwise the
|
||||||
|
launcher matches the rest of the site's buttons. */}
|
||||||
|
{open ? (
|
||||||
|
<RainbowButton
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Close store assistant"
|
||||||
|
aria-expanded
|
||||||
|
size="lg"
|
||||||
|
className={launcherClasses}
|
||||||
|
>
|
||||||
|
Ask
|
||||||
|
</RainbowButton>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="Open store assistant"
|
||||||
|
aria-expanded={false}
|
||||||
|
className={`h-11 ${launcherClasses}`}
|
||||||
|
>
|
||||||
|
Ask
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const rainbowButtonVariants = cva(
|
||||||
|
cn(
|
||||||
|
"relative cursor-pointer group transition-all animate-rainbow",
|
||||||
|
"inline-flex items-center justify-center gap-2 shrink-0",
|
||||||
|
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
|
||||||
|
"text-sm font-medium whitespace-nowrap",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
|
||||||
|
outline:
|
||||||
|
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-xl px-3 text-xs",
|
||||||
|
lg: "h-11 rounded-xl px-8",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface RainbowButtonProps
|
||||||
|
extends
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof rainbowButtonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(rainbowButtonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RainbowButton.displayName = "RainbowButton"
|
||||||
|
|
||||||
|
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"@ai-sdk/react": "^4.0.47",
|
"@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",
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||||
"@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/cjk": "^1.0.3",
|
||||||
|
|||||||
@@ -1710,7 +1710,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@radix-ui/react-use-controllable-state@npm:1.2.6":
|
"@radix-ui/react-use-controllable-state@npm:1.2.6, @radix-ui/react-use-controllable-state@npm:^1.2.6":
|
||||||
version: 1.2.6
|
version: 1.2.6
|
||||||
resolution: "@radix-ui/react-use-controllable-state@npm:1.2.6"
|
resolution: "@radix-ui/react-use-controllable-state@npm:1.2.6"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4802,6 +4802,7 @@ __metadata:
|
|||||||
"@ai-sdk/react": "npm:^4.0.47"
|
"@ai-sdk/react": "npm:^4.0.47"
|
||||||
"@next/swc-wasm-web": "npm:16.2.10"
|
"@next/swc-wasm-web": "npm:16.2.10"
|
||||||
"@openrouter/ai-sdk-provider": "npm:^3.0.0"
|
"@openrouter/ai-sdk-provider": "npm:^3.0.0"
|
||||||
|
"@radix-ui/react-use-controllable-state": "npm:^1.2.6"
|
||||||
"@remixicon/react": "npm:^4.9.0"
|
"@remixicon/react": "npm:^4.9.0"
|
||||||
"@shopify/storefront-api-client": "npm:^1.0.0"
|
"@shopify/storefront-api-client": "npm:^1.0.0"
|
||||||
"@streamdown/cjk": "npm:^1.0.3"
|
"@streamdown/cjk": "npm:^1.0.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user