From 7b5353185c400e85808a6db16e6ae11cea130c9c Mon Sep 17 00:00:00 2001 From: caroluchoa Date: Mon, 12 Feb 2024 18:44:53 -0300 Subject: [PATCH] add chat --- components/minicart/vtex/Cart.tsx | 6 +- components/product/AddToCartButton/vtex.tsx | 12 +- .../ChatComponents/ChatStep.tsx | 478 ++++++++++++++++++ .../ChatComponents/FunctionCalls.tsx | 399 +++++++++++++++ .../ChatComponents/Messages.tsx | 398 +++++++++++++++ components/shop-assistant/ChatContainer.tsx | 132 +++++ components/shop-assistant/ChatContext.tsx | 31 ++ components/shop-assistant/ShopAssistant.tsx | 380 ++++++++++++++ .../autosize-textarea/AutosizeTextarea.tsx | 103 ++++ .../autosize-textarea/calculateNodeHeight.ts | 74 +++ .../autosize-textarea/forceHiddenStyles.ts | 23 + .../autosize-textarea/getSizingData.ts | 91 ++++ .../shop-assistant/autosize-textarea/hooks.ts | 82 +++ .../shop-assistant/autosize-textarea/utils.ts | 11 + .../shop-assistant/types/shop-assistant.ts | 140 +++++ components/ui/Icon.tsx | 5 +- deno.json | 4 +- fresh.gen.ts | 2 + islands/Chat.tsx | 1 + manifest.gen.ts | 162 +++--- sdk/analytics.tsx | 33 ++ sections/Chat/Chat.tsx | 1 + static/deco-logo.svg | 3 + static/sprites.svg | 4 +- tailwind.config.ts | 12 + 25 files changed, 2497 insertions(+), 90 deletions(-) create mode 100644 components/shop-assistant/ChatComponents/ChatStep.tsx create mode 100644 components/shop-assistant/ChatComponents/FunctionCalls.tsx create mode 100644 components/shop-assistant/ChatComponents/Messages.tsx create mode 100644 components/shop-assistant/ChatContainer.tsx create mode 100644 components/shop-assistant/ChatContext.tsx create mode 100644 components/shop-assistant/ShopAssistant.tsx create mode 100644 components/shop-assistant/autosize-textarea/AutosizeTextarea.tsx create mode 100644 components/shop-assistant/autosize-textarea/calculateNodeHeight.ts create mode 100644 components/shop-assistant/autosize-textarea/forceHiddenStyles.ts create mode 100644 components/shop-assistant/autosize-textarea/getSizingData.ts create mode 100644 components/shop-assistant/autosize-textarea/hooks.ts create mode 100644 components/shop-assistant/autosize-textarea/utils.ts create mode 100644 components/shop-assistant/types/shop-assistant.ts create mode 100644 islands/Chat.tsx create mode 100644 sections/Chat/Chat.tsx create mode 100644 static/deco-logo.svg diff --git a/components/minicart/vtex/Cart.tsx b/components/minicart/vtex/Cart.tsx index 9cbc5019..f1bfcaf6 100644 --- a/components/minicart/vtex/Cart.tsx +++ b/components/minicart/vtex/Cart.tsx @@ -5,7 +5,8 @@ function Cart() { const { cart, loading, updateItems, addCouponsToCart } = useCart(); const { items, totalizers } = cart.value ?? { items: [] }; const total = totalizers?.find((item) => item.id === "Items")?.value || 0; - const discounts = (totalizers?.find((item) => item.id === "Discounts")?.value || 0) * -1; + const discounts = + (totalizers?.find((item) => item.id === "Discounts")?.value || 0) * -1; const locale = cart.value?.clientPreferencesData.locale ?? "pt-BR"; const currency = cart.value?.storePreferencesData.currencyCode ?? "BRL"; const coupon = cart.value?.marketingData?.coupon ?? undefined; @@ -31,8 +32,7 @@ function Cart() { coupon={coupon} onAddCoupon={(text) => addCouponsToCart({ text })} onUpdateQuantity={(quantity, index) => - updateItems({ orderItems: [{ index, quantity }] }) - } + updateItems({ orderItems: [{ index, quantity }] })} itemToAnalyticsItem={(index) => { const item = items[index]; diff --git a/components/product/AddToCartButton/vtex.tsx b/components/product/AddToCartButton/vtex.tsx index 5f44064f..48788c1c 100644 --- a/components/product/AddToCartButton/vtex.tsx +++ b/components/product/AddToCartButton/vtex.tsx @@ -4,18 +4,24 @@ import Button, { Props as BtnProps } from "./common.tsx"; export interface Props extends Omit { seller: string; productID: string; + onClick?: () => void; } -function AddToCartButton({ seller, productID, eventParams }: Props) { +function AddToCartButton({ seller, productID, eventParams, onClick }: Props) { const { addItems } = useCart(); - const onAddItem = () => - addItems({ + const onAddItem = () => { + if (onClick) { + onClick(); + } + + return addItems({ orderItems: [{ id: productID, seller: seller, quantity: 1, }], }); + }; return + + + + + ); +} + +type FilePreviewProps = { + fileUrl: string; + resetFileInput: () => void; +}; + +function FilePreview({ fileUrl, resetFileInput }: FilePreviewProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const toggleModal = () => { + setIsModalOpen(!isModalOpen); + }; + + const closeModal = (event: MouseEvent) => { + if (event.currentTarget === event.target) { + setIsModalOpen(false); + } + }; + + return ( +
+ file preview + + {/* TODO: Use Portals to make the modal fit the whole screen */} + {isModalOpen && ( +
+
+ Enlarged file preview + +
+
+ )} +
+ ); +} diff --git a/components/shop-assistant/ChatComponents/FunctionCalls.tsx b/components/shop-assistant/ChatComponents/FunctionCalls.tsx new file mode 100644 index 00000000..da47011c --- /dev/null +++ b/components/shop-assistant/ChatComponents/FunctionCalls.tsx @@ -0,0 +1,399 @@ +import { + Content, + Ids, + Message, + MessageContentAudio, + MessageContentFile, + MessageContentText, + Product, +} from "../types/shop-assistant.ts"; +import { mapProductToAnalyticsItem } from "apps/commerce/utils/productToAnalyticsItem.ts"; +import { useOffer } from "$store/sdk/useOffer.ts"; +import AddToCartButton from "$store/islands/AddToCartButton/vtex.tsx"; +import { useSignal } from "@preact/signals"; +import { useState } from "preact/hooks"; +import Icon from "$store/components/ui/Icon.tsx"; +import { useChatContext } from "$store/components/shop-assistant/ChatContext.tsx"; +import { sendEvent, SendEventOnView } from "$store/sdk/analytics.tsx"; +import { useId } from "preact/compat"; +import { AnalyticsItem } from "apps/commerce/types.ts"; +import { mapProductCategoryToAnalyticsCategories } from "apps/commerce/utils/productToAnalyticsItem.ts"; + +export const mapProductToAnalyticsItemAssistant = ( + { + product, + price, + listPrice, + index = 0, + quantity = 1, + }: { + product: Product; + price?: number; + listPrice?: number; + index?: number; + quantity?: number; + }, +): AnalyticsItem => { + const { name, productID, inProductGroupWithID, isVariantOf, url } = product; + const categories = mapProductCategoryToAnalyticsCategories( + product.category ?? "", + ); + + return { + item_id: productID, + item_group_id: inProductGroupWithID, + quantity, + price, + index, + discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)), + item_name: isVariantOf?.name ?? name ?? "", + item_variant: name, + item_brand: product.brand?.name ?? "", + item_url: url, + ...categories, + }; +}; + +export function FunctionCalls( + { messages, assistantIds }: { messages: Message[]; assistantIds: Ids }, +) { + const isFunctionCallContent = ( + content: + | MessageContentText + | MessageContentFile + | MessageContentAudio + | Content, + ): content is Content => { + return (content as Content).response !== undefined; + }; + + const allProducts: Product[] = messages + .filter((message) => message.type === "function_calls") + .flatMap((message) => + message.content + .filter(isFunctionCallContent) + .filter( + (content) => + content.name === + "vtex/loaders/intelligentSearch/productList.ts" && + content.response.length !== 0, + ) + .flatMap((content) => content.response as Product[]) + ); + + console.log({ allProducts }); + + return ( + <> + {allProducts.length > 0 && ( +
+
+
+ +
+
+ +
+
+
+ )} + + ); +} + +function ProductShelf( + { products, assistantIds }: { products: Product[]; assistantIds: Ids }, +) { + const id = useId(); + console.log(products); + return ( +
+ {products.map((product, index) => ( +
+ + +
+ ))} +
+ ); +} + +function ProductCard( + { product, assistantIds }: { product: Product; assistantIds: Ids }, +) { + const { title, description } = extractTitleAndDescription( + product.description, + ); + const currency = product.offers.priceCurrency; + const price = product.offers.offers[0].price; + + return ( +
+ + {product.name} + +
+ +

{product.name}

+
+

+ {description} +

+
+

+ {translatePriceCurrency(currency)} {transformPrice(price, currency)} +

+ { + sendEvent({ + name: "add_to_cart", + params: { + currency: product.offers.priceCurrency, + value: product.offers.offers[0].price, + assistantId: assistantIds.assistantId, + assistantThreadID: assistantIds.threadId, + items: [mapProductToAnalyticsItem({ product })], + }, + }); + }} + /> +
+
+
+ ); +} + +function ProductCarousel( + { products, assistantIds }: { products: Product[]; assistantIds: Ids }, +) { + const id = useId(); + const [currentProductIndex, setCurrentProductIndex] = useState(0); + const product = products[currentProductIndex] as Product; + const currency = product.offers?.priceCurrency; + const price = product.offers.offers[0].price; + const [transition, setTransition] = useState(""); + + const handleNextProduct = () => { + setTransition("nextCard"); + setCurrentProductIndex(( + prevIndex, + ) => (prevIndex === products.length - 1 ? 0 : prevIndex + 1)); + }; + + const handlePrevProduct = () => { + setTransition("prevCard"); + setCurrentProductIndex(( + prevIndex, + ) => (prevIndex === 0 ? products.length - 1 : prevIndex - 1)); + }; + + return ( + <> + +
+ {products.length > 1 + ? ( + <> + + + + ) + : null} +
+ + {product.image[0].name} + +
+ +

+ {product.name} +

+
+

+ {translatePriceCurrency(currency)}{" "} + {transformPrice(price, currency)} +

+ { + sendEvent({ + name: "add_to_cart", + params: { + currency: product.offers.priceCurrency, + value: product.offers.offers[0].price, + assistantId: assistantIds.assistantId, + assistantThreadID: assistantIds.threadId, + items: [mapProductToAnalyticsItem({ product })], + }, + }); + }} + /> + +
+
+
+ + ); +} + +// Helper functions +const extractTitleAndDescription = (htmlString: string) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, "text/html"); + + const title = doc.querySelector("h1, h2, h3, h4, h5, h6")?.textContent || + ""; + + const titleElement = doc.querySelector("h1, h2, h3, h4, h5, h6"); + if (titleElement) titleElement.remove(); + const description = doc.body.textContent || ""; + + return { title, description }; +}; + +const translatePriceCurrency = (priceCurrency: string) => { + if (!priceCurrency) return ""; + switch (priceCurrency) { + case "BRL": + return "R$"; + case "USD": + return "$"; + case "EUR": + return "€"; + default: + return priceCurrency; + } +}; + +const transformPrice = (price: number, currency: string) => { + // Example: change 188.7 to 188,70 if currency is BRL, any other currency will be 188.70 + switch (currency) { + case "BRL": + return price.toFixed(2).replace(".", ","); + default: + return price.toFixed(2); + } +}; diff --git a/components/shop-assistant/ChatComponents/Messages.tsx b/components/shop-assistant/ChatComponents/Messages.tsx new file mode 100644 index 00000000..d7a91669 --- /dev/null +++ b/components/shop-assistant/ChatComponents/Messages.tsx @@ -0,0 +1,398 @@ +import { memo } from "preact/compat"; +import type { ComponentChildren } from "preact"; +import { Ref, useEffect, useRef, useState } from "preact/hooks"; +import { + AssistantMsg, + Message, + MessageContentText, + UserMsg, +} from "../types/shop-assistant.ts"; + +type MessagesProps = { + messageList: Message[]; + send: (text: string) => void; + addNewMessageToList: ({ content, type, role }: Message) => void; + updateMessageListArray: (messageList: Message[]) => void; +}; + +export function Messages( + { messageList, send, addNewMessageToList, updateMessageListArray }: + MessagesProps, +) { + const messageEl = useRef(null); + const [hasExpanded, setHasExpanded] = useState(false); + const [showLoading, setShowLoading] = useState(false); + + useEffect(() => { + if (messageList.length > 1) { + setHasExpanded(true); + } + }, [messageList]); + + useEffect(() => { + // For automatic scrolling + const messageElement = messageEl.current; + + if (messageElement) { + messageElement.scrollTop = messageElement.scrollHeight; + } + }, [messageList, showLoading]); + + useEffect(() => { + const lastMessage = messageList[messageList.length - 1]; + + // Show loading when the last message is a start_function_call or a user's message + if ( + lastMessage?.type === "start_function_call" || + lastMessage?.role === "user" + ) { + setTimeout(() => setShowLoading(true), 1000); + } else { + setShowLoading(false); + } + }, [messageList]); + + return ( + <> + +
+ {messageList.map((message, index) => ( +
+ {message.role === "assistant" + ? ( + + ) + : } +
+ ))} + +
+ + ); +} + +type BotMessageProps = { + message: Message; + send: (text: string) => void; + messageList: Message[]; + addNewMessageToList: ({ content, type, role }: Message) => void; + updateMessageListArray: (messageList: Message[]) => void; +}; + +// TODO: Refactor Content types to avoid type assertions +const BotMessage = memo( + ( + { + message, + send, + messageList, + addNewMessageToList, + updateMessageListArray, + }: BotMessageProps, + ) => { + if (message.type === "message") { + return ( + <> + {message.content.map((content, index) => ( + +
+
{(content as MessageContentText).value}
+ +
+
+ ))} + + ); + } + + return null; + }, +); + +type OptionsButtonGroupProps = { + content: MessageContentText; + send: (text: string) => void; + messageList: Message[]; + addNewMessageToList: ({ content, type, role }: Message) => void; + updateMessageListArray: (messageList: Message[]) => void; +}; + +function OptionsButtonGroup( + { + content, + send, + messageList, + addNewMessageToList, + updateMessageListArray, + }: OptionsButtonGroupProps, +) { + const sendBtnClickMessage = (option: string) => { + const msgContent: MessageContentText[] = [{ + type: "text", + value: option, + options: [], + }]; + + removeQuickReplies(); + + addNewMessageToList({ + content: msgContent, + type: "message", + role: "user", + }); + + send(option.concat(" ", getLastUserMessage(messageList))); + }; + + const getLastUserMessage = (messageList: Message[]): string => { + const lastUserMessage = messageList.reverse().find((msg) => + msg.role === "user" + ); + if (!lastUserMessage) return ""; + return (lastUserMessage?.content[0] as MessageContentText).value; + }; + + const removeQuickReplies = () => { + // TODO: refactor this + let lastAssistantMsgIndex = -1; + for (let i = messageList.length - 1; i >= 0; i--) { + if ( + messageList[i].role === "assistant" && messageList[i].type === "message" + ) { + lastAssistantMsgIndex = i; + break; + } + } + + const newMessageList: Message[] = messageList.map((message, index) => { + if (index === lastAssistantMsgIndex && message.content) { + if (message.role === "assistant" && "messageId" in message) { + // AssistantMsg + const assistantMessage = message as AssistantMsg; + return { + ...assistantMessage, // keep any other properties + content: assistantMessage.content, // No modifications + }; + } else { + // UserMsg + const userMessage = message as UserMsg; + const newContent = userMessage.content.map((content) => { + if (content.type === "text") { + // MessageContentText, remove 'options' + return { ...content, options: [] }; + } + return content; // Other types without modifications + }); + + return { + ...userMessage, + content: newContent, + }; + } + } + return message; + }); + + updateMessageListArray(newMessageList); + }; + + return ( +
+ {(content as MessageContentText).options?.length > 0 && ( +
+
Quick Replies
+
+ {(content as MessageContentText).options.map((option, index) => ( + + ))} +
+
+ )} +
+ ); +} + +function BotMessageWrapper({ children }: { children: ComponentChildren }) { + return ( +
+ {children} +
+ ); +} + +function UserMessage({ message }: { message: UserMsg }) { + const isAudioMessage = message.content.some((content) => + content.type === "audio" + ); + + return ( +
+ {message.content.map((content, index) => { + if ("value" in content) { + return
{content.value}
; + } + if (content.type === "file") { + return ( + <> + +
{content.message}
+ + ); + } + if (content.type === "audio") { + return ; + } + return null; + })} +
+ ); +} + +function TypingIndicator( + { show, messageEl }: { show: boolean; messageEl: Ref }, +) { + const [message, setMessage] = useState(""); + const [step, setStep] = useState(0); + const messageElement = messageEl.current; + + useEffect(() => { + // TODO: Refactor this to use messages from props / generate random waiting messages / typing indicator as first message (...) + if (show) { + const timeouts: number[] = []; + timeouts.push(setTimeout(() => { + setMessage("Um segundo, estou pensando... 🤔"); + setStep(1); + }, 8000)); + timeouts.push(setTimeout(() => { + setMessage("Aguarde só mais um instante... ⏳"); + setStep(2); + }, 15000)); + timeouts.push(setTimeout(() => { + setMessage( + "Só um segundinho, estou quase encontrando algo incrível! 🔍", + ); + setStep(3); + }, 23000)); + timeouts.push(setTimeout(() => { + setMessage( + "Hmm, enfrentamos um contratempo. 🌀 Faça uma nova tentativa e, caso continue com problemas, recarregue a página para recomeçarmos.", + ); + setStep(4); + }, 60000)); + + return () => { + timeouts.forEach(clearTimeout); + }; + } + }, [show]); + + useEffect(() => { + if (messageElement) messageElement.scrollTop = messageElement.scrollHeight; + }, [show, message, step]); + + useEffect(() => { + setStep(0); + }, [show]); + + return show + ? ( +
+ + {step === 0 && ( +
+ Digitando + + . + + + . + + + . + +
+ )} + {step > 0 &&
{message}
} +
+ ) + : null; +} diff --git a/components/shop-assistant/ChatContainer.tsx b/components/shop-assistant/ChatContainer.tsx new file mode 100644 index 00000000..6eecc38b --- /dev/null +++ b/components/shop-assistant/ChatContainer.tsx @@ -0,0 +1,132 @@ +import { Signal } from "@preact/signals"; +import { AssistantMsg, Content, Ids, Message } from "./types/shop-assistant.ts"; +import { useEffect, useState } from "preact/hooks"; +import { ChatStep } from "./ChatComponents/ChatStep.tsx"; +import Image from "apps/website/components/Image.tsx"; +import Icon from "$store/components/ui/Icon.tsx"; + +type ChatProps = { + messageList: Signal; + assistantIds: Signal; + addNewMessageToList: ({ content, type, role }: Message) => void; + send: (text: string) => void; + handleShowChat: () => void; + logo?: { src: string; alt: string }; + updateMessageListArray: (messageList: Message[]) => void; + updateIds: (ids: Ids) => void; +}; + +export function ChatContainer( + { + messageList, + assistantIds, + addNewMessageToList, + send, + handleShowChat, + logo, + updateMessageListArray, + updateIds, + }: ChatProps, +) { + const [shouldAnimateWidth, setShouldAnimateWidth] = useState(false); + console.log("logo", logo); + + useEffect(() => { + const localMsgList = [...messageList.value]; + console.log({ localMsgList }); + + const functionCallMsg: AssistantMsg[] = localMsgList + .filter((msg): msg is AssistantMsg => + msg.type === "function_calls" && + (msg.content as Content[]).some((content) => + content.response.length > 0 + ) + ); + + // Check if there is a multi_tool_use.parallel function call (which is an error from the openApi call) + const isMultiTool = functionCallMsg.some((msg) => { + return (msg.content as Content[]).some((content) => { + return content.name === "multi_tool_use.parallel"; + }); + }); + + setShouldAnimateWidth(!isMultiTool && functionCallMsg.length > 0); + }, [messageList.value]); + + const handleClearChat = () => { + if ( + window.confirm( + "Are you sure you want to clear the chat? This action cannot be undone.", + ) + ) { + updateMessageListArray([]); + updateIds({ threadId: "", assistantId: "" }); + } + }; + + return ( + <> + +
+
+ +
+ + +
+
+ +
+ + ); +} diff --git a/components/shop-assistant/ChatContext.tsx b/components/shop-assistant/ChatContext.tsx new file mode 100644 index 00000000..39b4c7eb --- /dev/null +++ b/components/shop-assistant/ChatContext.tsx @@ -0,0 +1,31 @@ +import { createContext } from "preact"; +import { useContext, useState } from "preact/hooks"; + +const ChatContext = createContext({ + isChatMinimized: false, + minimizeChat: (state: boolean) => {}, +}); + +interface ChatProviderProps { + children: preact.ComponentChildren; +} + +export function ChatProvider({ children }: ChatProviderProps) { + const [isChatMinimized, setIsChatMinimized] = useState(false); + + const minimizeChat = (state: boolean) => { + setIsChatMinimized(state); + }; + + return ( + + {children} + + ); +} + +export function useChatContext() { + return useContext(ChatContext); +} diff --git a/components/shop-assistant/ShopAssistant.tsx b/components/shop-assistant/ShopAssistant.tsx new file mode 100644 index 00000000..e1325ce2 --- /dev/null +++ b/components/shop-assistant/ShopAssistant.tsx @@ -0,0 +1,380 @@ +import { useSignal } from "@preact/signals"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import { ChatContainer } from "./ChatContainer.tsx"; +import { AssistantMsg, Ids, Message } from "./types/shop-assistant.ts"; +import { ImageWidget } from "apps/admin/widgets.ts"; +import Image from "apps/website/components/Image.tsx"; +import { ChatProvider, useChatContext } from "./ChatContext.tsx"; +import { useUI } from "$store/sdk/useUI.ts"; +import { sendEvent } from "$store/sdk/analytics.tsx"; + +export interface MainColors { + /** + * @format color + * @title Primary + * @default #E8E8E8 + */ + "primary": string; + /** + * @format color + * @title Secondary + * @default #FFFFFF + */ + "secondary": string; + /** + * @format color + * @title Text Color + * @default #000000 + */ + "tertiary": string; + /** + * @format color + * @title Logo Color + * @default #43db70 + */ + "logo": string; +} + +console.log("ShopAssistant.tsx"); +export interface Props { + openChat?: boolean; + mainColors?: MainColors; + logo?: { src: ImageWidget; alt: string }; +} + +function Chat({ mainColors, logo, openChat = false }: Props) { + const ws = useSignal(null); + const messageList = useSignal([]); + const assistantIds = useSignal({ threadId: "", assistantId: "" }); + const [showChat, setShowChat] = useState(false); + const { minimizeChat, isChatMinimized } = useChatContext(); + const { displayCart } = useUI(); + + useEffect(() => { + console.log({ openChat }); + if (typeof window !== "undefined") { + const isOpen = JSON.parse(sessionStorage.getItem("isOpen") ?? "false") || + false; + setShowChat(isOpen); + } else { + setShowChat(false); + } + }, []); + + useEffect(() => { + minimizeChat(displayCart.value); + }, [displayCart.value]); + + function hexToRgb(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `${r}, ${g}, ${b}`; + } + + useEffect(() => { + if (isChatMinimized) { + setShowChat(false); + } + }, [isChatMinimized]); + + useEffect(() => { + console.log({ openChat }); + setShowChat(openChat); + sessionStorage.setItem("isOpen", JSON.stringify(openChat)); + }, [openChat]); + + useEffect(() => { + loadChatSession(); + }, []); + + useEffect(() => { + if (mainColors) { + // Set the regular color variables + document.documentElement.style.setProperty( + "--primary-color-hex", + mainColors.primary, + ); + document.documentElement.style.setProperty( + "--secondary-color-hex", + mainColors.secondary, + ); + document.documentElement.style.setProperty( + "--tertiary-color-hex", + mainColors.tertiary, + ); + document.documentElement.style.setProperty( + "--logo-color-hex", + mainColors.logo, + ); + + // Set the RGB color variables + document.documentElement.style.setProperty( + "--primary-color", + hexToRgb(mainColors.primary), + ); + document.documentElement.style.setProperty( + "--secondary-color", + hexToRgb(mainColors.secondary), + ); + document.documentElement.style.setProperty( + "--opaque-white", + "rgba(255, 255, 255, 0.5)", + ); + } + }, [mainColors]); + + useEffect(() => { + const host = window.location.host; + const websocket = window.location.protocol === "https:" ? "wss" : "ws"; + // TODO: make chat name dynamic via prop + ws.value = new WebSocket( + `${websocket}://${host}/live/invoke/ai-assistants/actions/chat.ts?assistant=storefront`, + ); + + // Messages with type function_call, start_function_call or message belongs to this category of messages + const handleJSONMessage = (data: AssistantMsg) => { + addNewMessageToList({ + content: data.content, + type: data.type, + role: data.role ?? "assistant", + }); + }; + + // Welcome message belongs to this category of messages + const handlePureStringMessage = (data: string) => { + if (!hasChatHistory()) { + addNewMessageToList({ + content: [{ type: "text", value: data, options: [] }], + type: "message", + role: "assistant", + }); + } + }; + + ws.value.onmessage = (event: MessageEvent) => { + try { + if (isJSON(event.data)) { + const parsedData = JSON.parse(event.data); + console.log({ parsedData }); + if (parsedData.type === "Id") { + updateIds({ + threadId: parsedData.threadId, + assistantId: parsedData.assistantId, + }); + } else { + handleJSONMessage(parsedData); + } + } else { + handlePureStringMessage(event.data); + } + } catch (error) { + console.error("Error processing message:", error); + } + }; + }, []); + + useEffect(() => { + // TODO: Refactor + const updatedMessageList = [...messageList.value]; + + // Clear the first function_calls message if there's more than one + const functionCallMsgs = updatedMessageList.filter((msg) => + msg.type === "function_calls" + ); + if (functionCallMsgs.length > 1) { + const firstFunctionCallIndex = updatedMessageList.findIndex((msg) => + msg.type === "function_calls" + ); + if (firstFunctionCallIndex !== -1) { + updatedMessageList.splice(firstFunctionCallIndex, 1); + } + } + + // Update messageList only if there are changes + if ( + JSON.stringify(messageList.value) !== JSON.stringify(updatedMessageList) + ) { + messageList.value = updatedMessageList; + } + }, [messageList.value]); + + const isJSON = (str: string) => { + try { + JSON.parse(str); + return true; + } catch (err) { + return false; + } + }; + + const send = useCallback((text: string) => { + if (ws.value) { + ws.value.send(text); + } + }, []); + + const addNewMessageToList = (newMessage: Message): void => { + const isChatOpen = JSON.parse( + sessionStorage.getItem("isOpen") ?? "false", + ); + messageList.value = [...messageList.value, newMessage]; + storeChatSession({ + messageList: messageList.value, + isChatOpen: isChatOpen, + }); + }; + + const updateIds = (newIds: Ids): void => { + assistantIds.value = newIds; + sessionStorage.setItem("threadId", newIds.threadId); + sessionStorage.setItem("assistantId", newIds.assistantId); + }; + + const updateMessageListArray = (newMessageList: Message[]): void => { + const isChatOpen = JSON.parse( + sessionStorage.getItem("isOpen") ?? "false", + ); + messageList.value = newMessageList; + storeChatSession({ messageList: newMessageList, isChatOpen: isChatOpen }); + }; + + const storeChatSession = ( + { messageList, isChatOpen }: { + messageList: Message[]; + isChatOpen: boolean; + }, + ) => { + sessionStorage.setItem("chatHistory", JSON.stringify(messageList)); + sessionStorage.setItem("isOpen", JSON.stringify(isChatOpen)); + }; + + // TODO(@ItamarRocha): add get ids from session storage and send it to the server + const loadChatSession = () => { + const chatHistory = JSON.parse( + sessionStorage.getItem("chatHistory") ?? "[]", + ); + const isChatOpen = JSON.parse( + sessionStorage.getItem("isOpen") ?? "false", + ); + messageList.value = chatHistory; + setShowChat(isChatOpen); + }; + + const hasChatHistory = () => { + const chatHistory = JSON.parse( + sessionStorage.getItem("chatHistory") ?? "[]", + ); + return chatHistory.length > 0; + }; + + const handleClick = () => { + setShowChat(!showChat); + sendEvent({ + name: "select_promotion", + params: { + promotion_id: "chat-sales-assistant", + promotion_name: "chat-sales-assistant", + assistantId: assistantIds.value.assistantId, + assistantThreadID: assistantIds.value.threadId, + openChat: !showChat, + }, + }); + sessionStorage.setItem("isOpen", JSON.stringify(!showChat)); + }; + + return ( + <> + +
+ {showChat + ? ( +
+ +
+ ) + : ( + + )} +
+ + ); +} + +export default function ShopAssistant( + { mainColors, logo, openChat }: Props, +) { + console.log("ShopAssistant.tsx"); + return ( + + + + ); +} diff --git a/components/shop-assistant/autosize-textarea/AutosizeTextarea.tsx b/components/shop-assistant/autosize-textarea/AutosizeTextarea.tsx new file mode 100644 index 00000000..4cc65689 --- /dev/null +++ b/components/shop-assistant/autosize-textarea/AutosizeTextarea.tsx @@ -0,0 +1,103 @@ +import { JSX } from "preact"; +import calculateNodeHeight from "./calculateNodeHeight.ts"; +import getSizingData, { SizingData } from "./getSizingData.ts"; +import { useLayoutEffect, useRef } from "preact/hooks"; +import { + useComposedRef, + useFontsLoadedListener, + useWindowResizeListener, +} from "./hooks.ts"; +import { noop } from "./utils.ts"; +import { forwardRef } from "preact/compat"; + +type TextareaProps = JSX.HTMLAttributes; + +type Style = + & Omit< + NonNullable, + "maxHeight" | "minHeight" + > + & { + height?: number; + }; + +export type TextareaHeightChangeMeta = { + rowHeight: number; +}; +export interface TextareaAutosizeProps extends Omit { + maxRows?: number; + minRows?: number; + onHeightChange?: (height: number, meta: TextareaHeightChangeMeta) => void; + onChange?: (event: React.ChangeEvent) => void; + cacheMeasurements?: boolean; + style?: Style; +} + +const TextareaAutosize = forwardRef( + ({ + cacheMeasurements, + maxRows, + minRows, + onChange = noop, + onHeightChange = noop, + ...props + }, userRef) => { + if (props.style) { + if ("maxHeight" in props.style) { + throw new Error( + "Using `style.maxHeight` for is not supported. Please use `maxRows`.", + ); + } + if ("minHeight" in props.style) { + throw new Error( + "Using `style.minHeight` for is not supported. Please use `minRows`.", + ); + } + } + const isControlled = props.value !== undefined; + const libRef = useRef(null); + const ref = useComposedRef(libRef, userRef); + const heightRef = useRef(0); + const measurementsCacheRef = useRef(); + + const resizeTextarea = () => { + const node = libRef.current!; + const nodeSizingData = cacheMeasurements && measurementsCacheRef.current + ? measurementsCacheRef.current + : getSizingData(node); + + if (!nodeSizingData) { + return; + } + + measurementsCacheRef.current = nodeSizingData; + + const [height, rowHeight] = calculateNodeHeight( + nodeSizingData, + node.value || node.placeholder || "x", + minRows, + maxRows, + ); + + if (heightRef.current !== height) { + heightRef.current = height; + node.style.setProperty("height", `${height}px`, "important"); + onHeightChange(height, { rowHeight }); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + if (!isControlled) { + resizeTextarea(); + } + onChange(event); + }; + + useLayoutEffect(resizeTextarea); + useWindowResizeListener(resizeTextarea); + useFontsLoadedListener(resizeTextarea); + return