Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sa): ai commerce #94

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions starters/shopify-algolia/app/(ai-browse)/_components/ai-chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client"

import * as React from "react"
import { ShoppingBag, ShoppingCart } from "lucide-react"
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "components/ui/sidebar"
import { ScrollArea } from "components/ui/scroll-area"
import { Textarea } from "components/ui/textarea"
import { toast } from "sonner"
import { cn } from "utils/cn"
import { MessageList } from "components/ui/message-list"
import { useAiCommerce } from "./ai-commerce-context"
import { Suggestions } from "./chat-suggestions"

export function AiCommerceSidebar() {
const { handleSubmit, setInput, messages, isLoading, input } = useAiCommerce()
const textareaRef = React.useRef<HTMLTextAreaElement>(null)

const handleSubmitForm = (e: React.FormEvent<HTMLFormElement>) => {
handleSubmit(e)
}

const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value)
}

const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`
}
}

React.useEffect(() => {
if (textareaRef.current) {
adjustHeight()
}
}, [])

React.useEffect(() => {
if (textareaRef.current) {
const domValue = textareaRef.current.value
const finalValue = domValue || ""
setInput(finalValue)
adjustHeight()
}
// Only run once after hydration
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return (
<Sidebar variant="inset">
<SidebarHeader className="border-b border-border px-4 py-2">
<SidebarMenu>
<SidebarMenuItem>
<div className="flex w-full items-center justify-center">
<span className="font-semibold">AI-Commerce</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<ScrollArea className="px-4">
<div className="flex flex-col space-y-4">
<MessageList
messages={messages}
showTimeStamps={false}
isTyping={isLoading && messages[messages.length - 1].role === "user" && messages.length > 0}
messageOptions={{ animation: "scale", showTimeStamp: true }}
/>
</div>
</ScrollArea>
</SidebarContent>
<SidebarFooter className="p-4">
<form>
<div className="flex items-center space-x-2">
<Textarea
ref={textareaRef}
placeholder="Send a message..."
value={input}
onChange={handleInput}
className={cn("max-h-[calc(50dvh)] min-h-[24px] resize-none overflow-hidden rounded-xl bg-muted text-base")}
rows={3}
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()

if (isLoading) {
toast.error("Please wait for the model to finish its response!")
} else {
handleSubmitForm(event as unknown as React.FormEvent<HTMLFormElement>)
}
}
}}
/>
</div>
<Suggestions />
</form>
<SidebarMenu className="mt-4">
<SidebarMenuItem>
<SidebarMenuButton className="w-full justify-start text-sidebar-foreground transition-all hover:scale-105 hover:bg-sidebar-accent hover:text-secondary-foreground">
<ShoppingCart className="mr-2 h-4 w-4" />
<span>View Cart</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton className="w-full justify-start text-sidebar-foreground transition-all hover:scale-105 hover:bg-secondary hover:text-secondary-foreground">
<ShoppingBag className="mr-2 h-4 w-4" />
<span>Checkout</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client"

import * as React from "react"
import type { ChatRequestOptions, CreateMessage, Message } from "ai"
import { useChat } from "ai/react"

interface AiCommerceContextType {
messages: Message[]
append: (message: Message | CreateMessage, chatRequestOptions?: ChatRequestOptions) => Promise<string | null | undefined>
input: string
isLoading: boolean
handleSubmit: (e?: { preventDefault: () => void }) => void
setInput: (value: string) => void
}

const AiCommerceContext = React.createContext<AiCommerceContextType | undefined>(undefined)

export function useAiCommerce() {
const context = React.useContext(AiCommerceContext)
if (!context) {
throw new Error("useAiCommerce must be used within an AiCommerceProvider")
}
return context
}

interface AiCommerceProviderProps {
children: React.ReactNode
}

export function AiCommerceProvider({ children }: AiCommerceProviderProps) {
const { append, messages, input, handleSubmit, setInput, isLoading } = useChat({
api: "/api/search",
maxSteps: 10,
})

const value = React.useMemo(
() => ({
messages,
input,
isLoading,
handleSubmit,
setInput,
append,
}),
[messages, input, isLoading, handleSubmit, setInput]
)

return <AiCommerceContext.Provider value={value}>{children}</AiCommerceContext.Provider>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
import { useChat } from "ai/react"
import { toast } from "sonner"
import { useAiCommerce } from "./ai-commerce-context"
import { PromptSuggestion, PromptSuggestions } from "components/ui/prompt-suggestions"

const INIT_SUGGESTIONS = ["Show me electronics under 250$", "Show me best-selling lips", "What are current products on sale?", "I'm looking for sportswear"]

export function Suggestions() {
const { isLoading, append, messages } = useAiCommerce()
const [currentSuggestions, setCurrentSuggestions] = useState<string[]>(INIT_SUGGESTIONS)
const { data: streamingData, append: appendSuggestions } = useChat({
api: "/api/suggestions",
})

useEffect(() => {
if (!streamingData) return

const newSuggestions = streamingData.filter((message: any) => message.type === "suggestion" && message.content.content).map((message: any) => message.content.content)

if (newSuggestions.length > 0) {
// Start from an empty array and take up to 5 new suggestions
setCurrentSuggestions(newSuggestions.slice(newSuggestions.length - 5))
}
}, [streamingData])

const handleClick = (suggestion: string) => {
if (isLoading) {
toast.error("Please wait for the model to finish its response!")
return
}

append({ role: "user", content: suggestion })
appendSuggestions({
role: "user",
content: messages
.filter((message) => message.role === "user")
.map((message) => message.content)
.join(""),
})
// Clear current suggestions after clicking
setCurrentSuggestions([])
}

return (
<PromptSuggestions className="my-4">
{currentSuggestions.map((suggestion, index) => (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} transition={{ delay: 0.1 * index }} key={`suggestion-${index}`}>
<PromptSuggestion disabled={isLoading} value={suggestion} onClick={() => handleClick(suggestion)}>
{suggestion}
</PromptSuggestion>
</motion.div>
))}
</PromptSuggestions>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Metadata } from "next"
import { CategoryView } from "components/category/category-view"

export const revalidate = 86400
export const dynamic = "force-static"

interface CategoryPageProps {
params: Promise<{ slug: string; page: string }>
}

export async function generateMetadata(props: CategoryPageProps): Promise<Metadata> {
const params = await props.params
return {
title: `${params.slug} | Enterprise Commerce`,
description: "In excepteur elit mollit in.",
}
}

export async function generateStaticParams() {
return []
}

export default async function CategoryPage(props: CategoryPageProps) {
const params = await props.params
return <CategoryView searchParams={{ page: params.page }} params={params} basePath="ai" />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Metadata } from "next"
import { isDemoMode } from "utils/demo-utils"
import { getCategories } from "lib/algolia"
import { CategoryView } from "components/category/category-view"

export const revalidate = 86400
export const dynamic = "force-static"

interface CategoryPageProps {
params: Promise<{ slug: string }>
}

export async function generateMetadata(props: CategoryPageProps): Promise<Metadata> {
const params = await props.params
return {
title: `${params.slug} | Enterprise Commerce`,
description: "In excepteur elit mollit in.",
}
}

export async function generateStaticParams() {
if (isDemoMode()) return []

const { hits } = await getCategories({
hitsPerPage: 50,
attributesToRetrieve: ["handle"],
})

return hits.map(({ handle }) => ({ slug: handle }))
}

export default async function CategoryPage(props: CategoryPageProps) {
const params = await props.params
return <CategoryView params={params} basePath="ai" />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Metadata } from "next"
import { SearchParamsType } from "types"
import { CategoryView } from "components/category/category-view"

export const runtime = "nodejs"

export const revalidate = 86400

interface ProductListingPageProps {
searchParams: Promise<SearchParamsType>
params: Promise<{ slug: string }>
}

export async function generateMetadata(props: ProductListingPageProps): Promise<Metadata> {
const params = await props.params
return {
title: `${params.slug} | Enterprise Commerce`,
description: "In excepteur elit mollit in.",
}
}

export default async function ProductListingPage(props: ProductListingPageProps) {
const params = await props.params
const searchParams = await props.searchParams
return <CategoryView params={params} searchParams={searchParams} basePath="ai" />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PageSkeleton } from "components/product/page-skeleton"

export default function Loading() {
return <PageSkeleton />
}
Loading
Loading