'use client'; import React, { memo, useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton, } from '@/components/ai-elements/conversation'; import { Message, MessageContent, MessageResponse, } from '@/components/ai-elements/message'; import { PromptInput, PromptInputActionAddAttachments, PromptInputActionMenu, PromptInputActionMenuContent, PromptInputActionMenuTrigger, PromptInputBody, PromptInputFooter, PromptInputProvider, PromptInputSubmit, PromptInputTextarea, PromptInputTools, usePromptInputAttachments, type PromptInputMessage, } from '@/components/ai-elements/prompt-input'; import { Attachment, AttachmentHoverCard, AttachmentHoverCardContent, AttachmentHoverCardTrigger, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments, getAttachmentLabel, getMediaCategory, type AttachmentData, } from '@/components/ai-elements/attachments'; import { Suggestions, Suggestion, } from '@/components/ai-elements/suggestion'; import { Reasoning, ReasoningContent, ReasoningTrigger, } from '@/components/ai-elements/reasoning'; import { Shimmer } from '@/components/ai-elements/shimmer'; import { Button } from '@/components/ui/button'; import { RainbowButton } from '@/components/ui/rainbow-button'; import { RiCloseLine, RiSearchLine, RiPriceTag3Line, RiStore2Line, RiLayoutGridLine, RiShoppingBag3Line, } from '@remixicon/react'; // Feature flag. Written as a static member expression so Next inlines it at // build time; the assistant is off unless the env var is explicitly "1". const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1'; const SUGGESTIONS = [ 'What do you sell?', 'Show me hoodies under $100', 'What collections are there?', ]; interface ToolProduct { handle: string; title: string; image: string | null; price?: string; } interface ToolSummary { label: string; icon: React.ReactNode; products: ToolProduct[]; } const LOADING_LABELS: Record = { searchCatalogue: 'Searching the catalogue', getProductDetails: 'Reading product details', listCollections: 'Listing collections', getCollectionProducts: 'Browsing a collection', browseProducts: 'Browsing new arrivals', }; const toolName = (type: string) => type.startsWith('tool-') ? type.slice(5) : type; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; // Turns a finished tool result into the one-line summary plus any products // worth previewing. const summariseTool = ( name: string, output: Record | 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: , products, }; } case 'getProductDetails': return { label: output?.found ? `Read ${output.title as string}` : 'Product not found', icon: , 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 | undefined) ?? []; return { label: `Found ${plural(collections.length, 'collection')}`, icon: , products: [], }; } case 'getCollectionProducts': return { label: output?.found ? `Found ${plural(products.length, 'product')} in ${output.collection}` : 'Collection not found', icon: , products, }; case 'browseProducts': return { label: `Browsed ${plural(products.length, 'new arrival')}`, icon: , products, }; default: return { label: name, icon: , 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 ( {withImages.slice(0, 6).map((product) => ( {product.title} ))} ); }; 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 ( {/* Thumbnail swaps to the remove button on hover. */}
{mediaCategory === 'image' && attachment.type === 'file' && attachment.url && (
{label}
)}

{label}

{attachment.mediaType && (

{attachment.mediaType}

)}
); }); 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.files.map((attachment) => ( ))} ); }; const StoreAssistant: React.FC = () => { // Returns before any hooks run — safe because the flag is a build-time // constant and cannot change between renders. if (!AI_ENABLED) return null; const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); const [input, setInput] = useState(''); // Drives the launcher's slide-in on first paint. useEffect(() => setMounted(true), []); const { messages, sendMessage, status, error } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); const isBusy = status === 'submitted' || status === 'streaming'; const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${ mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0' }`; const send = (text: string) => { const trimmed = text.trim(); if (!trimmed || isBusy) return; sendMessage({ text: trimmed }); setInput(''); }; // Attachments arrive on the submitted message, so images go out with it. const handleSubmit = (message: PromptInputMessage) => { const text = (message.text ?? input).trim(); const files = message.files ?? []; if ((!text && files.length === 0) || isBusy) return; sendMessage({ text, files }); setInput(''); }; return ( <> {/* Popover panel, anchored above the launcher */}
Store Assistant
{messages.length === 0 && ( {/* w-full + wrap so chips stack in the narrow popover rather than scrolling off the edge. */} {SUGGESTIONS.map((suggestion) => ( ))} )} {messages.map((message) => { const fileParts = message.parts.filter( (part) => part.type === 'file' ); return ( {message.parts.map((part, index) => { if (part.type === 'text') { return ( {part.text} ); } if (part.type === 'reasoning') { return ( {part.text} ); } if (part.type.startsWith('tool-')) { const toolPart = part as typeof part & { state: string; output?: Record; errorText?: string; }; const name = toolName(part.type); // Shimmer while the call is in flight; a quiet summary // line once it returns. if ( toolPart.state === 'input-streaming' || toolPart.state === 'input-available' ) { return ( {LOADING_LABELS[name] ?? name} ); } if (toolPart.state === 'output-error') { return (

Couldn't load that.

); } const { label, icon, products } = summariseTool( name, toolPart.output ); return (
{icon} {label}
); } return null; })} {/* Images the shopper attached, shown under their message. */} {fileParts.length > 0 && ( {fileParts.map((part, index) => ( ))} )}
); })} {status === 'submitted' && ( Thinking )} {error && (

Something went wrong. Please try again.

)}
setInput(event.target.value)} placeholder="Ask about products…" className="min-h-12" />
{/* Launcher */} {/* Rainbow treatment only while the assistant is open; otherwise the launcher matches the rest of the site's buttons. */} {open ? ( setOpen(false)} aria-label="Close store assistant" aria-expanded size="lg" className={launcherClasses} > Ask ) : ( )} ); }; export default StoreAssistant;