diff --git a/www/apps/api-reference/.env.sample b/www/apps/api-reference/.env.sample index a9a938212a..095df1ea60 100644 --- a/www/apps/api-reference/.env.sample +++ b/www/apps/api-reference/.env.sample @@ -8,4 +8,7 @@ NEXT_PUBLIC_ENV= NEXT_PUBLIC_BASE_URL= NEXT_PUBLIC_DOCS_URL= NEXT_PUBLIC_UI_URL= -ALGOLIA_WRITE_API_KEY= \ No newline at end of file +ALGOLIA_WRITE_API_KEY= +NEXT_PUBLIC_AI_ASSISTANT_URL= +NEXT_PUBLIC_AI_WEBSITE_ID= +NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY= \ No newline at end of file diff --git a/www/apps/api-reference/providers/index.tsx b/www/apps/api-reference/providers/index.tsx index b6e1ec2f39..3369a78c2e 100644 --- a/www/apps/api-reference/providers/index.tsx +++ b/www/apps/api-reference/providers/index.tsx @@ -1,6 +1,7 @@ "use client" import { + AiAssistantProvider, AnalyticsProvider, ColorModeProvider, MobileProvider, diff --git a/www/apps/api-reference/providers/search.tsx b/www/apps/api-reference/providers/search.tsx index ae2bad9f63..3f88dc65fa 100644 --- a/www/apps/api-reference/providers/search.tsx +++ b/www/apps/api-reference/providers/search.tsx @@ -1,6 +1,11 @@ "use client" -import { usePageLoading, SearchProvider as UiSearchProvider } from "docs-ui" +import { + usePageLoading, + SearchProvider as UiSearchProvider, + AiAssistantCommandIcon, + AiAssistantProvider, +} from "docs-ui" import getBaseUrl from "../utils/get-base-url" type SearchProviderProps = { @@ -66,6 +71,27 @@ const SearchProvider = ({ children }: SearchProviderProps) => { }, ], }} + commands={[ + { + name: "ai-assistant", + icon: , + component: ( + + ), + title: "AI Assistant", + badge: { + variant: "purple", + children: "Beta", + }, + }, + ]} > {children} diff --git a/www/apps/docs/.env.sample b/www/apps/docs/.env.sample index f1b6b25c40..95d18ab80e 100644 --- a/www/apps/docs/.env.sample +++ b/www/apps/docs/.env.sample @@ -2,4 +2,8 @@ API_URL= DOCS_ALGOLIA_INDEX_NAME= API_ALGOLIA_INDEX_NAME= ALGOLIA_API_KEY= -ALGOLIA_APP_ID= \ No newline at end of file +ALGOLIA_APP_ID= +AI_ASSISTANT_URL= +AI_API_ASSISTANT_TOKEN= +AI_WEBSITE_ID= +AI_API_ASSISTANT_RECAPTCHA_SITE_KEY= \ No newline at end of file diff --git a/www/apps/docs/docusaurus.config.js b/www/apps/docs/docusaurus.config.js index d0d3310a35..93c40a74ff 100644 --- a/www/apps/docs/docusaurus.config.js +++ b/www/apps/docs/docusaurus.config.js @@ -118,6 +118,12 @@ const config = { analytics: { apiKey: process.env.SEGMENT_API_KEY || "temp", }, + aiAssistant: { + apiUrl: process.env.AI_ASSISTANT_URL || "temp", + websiteId: process.env.AI_WEBSITE_ID || "temp", + recaptchaSiteKey: + process.env.AI_API_ASSISTANT_RECAPTCHA_SITE_KEY || "temp", + }, prism: { defaultLanguage: "js", plugins: ["line-numbers", "show-language"], diff --git a/www/apps/docs/src/providers/DocsProviders/index.tsx b/www/apps/docs/src/providers/DocsProviders/index.tsx index 71e667ae50..34a263bb46 100644 --- a/www/apps/docs/src/providers/DocsProviders/index.tsx +++ b/www/apps/docs/src/providers/DocsProviders/index.tsx @@ -1,4 +1,9 @@ -import { AnalyticsProvider, ModalProvider, NotificationProvider } from "docs-ui" +import { + AnalyticsProvider, + ColorModeProvider, + ModalProvider, + NotificationProvider, +} from "docs-ui" import React from "react" import { useThemeConfig } from "@docusaurus/theme-common" import { ThemeConfig } from "@medusajs/docs" @@ -16,13 +21,15 @@ const DocsProviders = ({ children }: DocsProvidersProps) => { return ( - - - - {children} - - - + + + + + {children} + + + + ) } diff --git a/www/apps/docs/src/providers/Search/index.tsx b/www/apps/docs/src/providers/Search/index.tsx index 44591436c7..5a7b443da8 100644 --- a/www/apps/docs/src/providers/Search/index.tsx +++ b/www/apps/docs/src/providers/Search/index.tsx @@ -1,5 +1,10 @@ import React, { useEffect, useState } from "react" -import { SearchProvider as UiSearchProvider, checkArraySameElms } from "docs-ui" +import { + AiAssistantCommandIcon, + AiAssistantProvider, + SearchProvider as UiSearchProvider, + checkArraySameElms, +} from "docs-ui" import { ThemeConfig } from "@medusajs/docs" import { useThemeConfig } from "@docusaurus/theme-common" import { useLocalPathname } from "@docusaurus/theme-common/internal" @@ -10,7 +15,8 @@ type SearchProviderProps = { const SearchProvider = ({ children }: SearchProviderProps) => { const [defaultFilters, setDefaultFilters] = useState([]) - const { algoliaConfig: algolia } = useThemeConfig() as ThemeConfig + const { algoliaConfig: algolia, aiAssistant } = + useThemeConfig() as ThemeConfig const currentPath = useLocalPathname() useEffect(() => { @@ -60,9 +66,27 @@ const SearchProvider = ({ children }: SearchProviderProps) => { ], }, ], - className: "z-[500]", }} + commands={[ + aiAssistant && { + name: "ai-assistant", + icon: , + component: ( + + ), + title: "AI Assistant", + badge: { + variant: "purple", + children: "Beta", + }, + }, + ]} initialDefaultFilters={defaultFilters} + modalClassName="z-[500]" > {children} diff --git a/www/apps/docs/src/types/index.d.ts b/www/apps/docs/src/types/index.d.ts index 9aa7cbcfd2..863bf6c163 100644 --- a/www/apps/docs/src/types/index.d.ts +++ b/www/apps/docs/src/types/index.d.ts @@ -180,6 +180,11 @@ declare module "@medusajs/docs" { analytics?: { apiKey: string } + aiAssistant?: { + apiUrl: string + websiteId: string + recaptchaSiteKey: string + } } & DocusaurusThemeConfig export declare type MedusaDocusaurusConfig = { diff --git a/www/apps/ui/.env.example b/www/apps/ui/.env.example index 43b9a852f1..e7cbf786fc 100644 --- a/www/apps/ui/.env.example +++ b/www/apps/ui/.env.example @@ -1,2 +1,11 @@ NEXT_PUBLIC_DOCS_URL= -NEXT_PUBLIC_BASE_URL= \ No newline at end of file +NEXT_PUBLIC_BASE_URL= +NEXT_PUBLIC_BASE_PATH=/ui +NEXT_PUBLIC_DOCS_ALGOLIA_INDEX_NAME= +NEXT_PUBLIC_API_ALGOLIA_INDEX_NAME= +NEXT_PUBLIC_ALGOLIA_API_KEY= +NEXT_PUBLIC_ALGOLIA_APP_ID= +NEXT_PUBLIC_SEGMENT_API_KEY= +NEXT_PUBLIC_AI_ASSISTANT_URL= +NEXT_PUBLIC_AI_WEBSITE_ID= +NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY= \ No newline at end of file diff --git a/www/apps/ui/src/components/mdx-components.tsx b/www/apps/ui/src/components/mdx-components.tsx index c9ed8ea0de..b61625dfac 100644 --- a/www/apps/ui/src/components/mdx-components.tsx +++ b/www/apps/ui/src/components/mdx-components.tsx @@ -97,12 +97,12 @@ const components = { ...props }: React.HTMLAttributes) => { return ( -
    {children} -
+ ) }, li: ({ diff --git a/www/apps/ui/src/providers/index.tsx b/www/apps/ui/src/providers/index.tsx index afb2cf2f47..fb51b9e78a 100644 --- a/www/apps/ui/src/providers/index.tsx +++ b/www/apps/ui/src/providers/index.tsx @@ -7,6 +7,7 @@ import { NavbarProvider, AnalyticsProvider, ScrollControllerProvider, + AiAssistantProvider, } from "docs-ui" import SearchProvider from "./search" import SidebarProvider from "./sidebar" diff --git a/www/apps/ui/src/providers/search.tsx b/www/apps/ui/src/providers/search.tsx index 7b477fa126..f400a45bdb 100644 --- a/www/apps/ui/src/providers/search.tsx +++ b/www/apps/ui/src/providers/search.tsx @@ -1,7 +1,13 @@ "use client" -import { SearchProvider as UiSearchProvider } from "docs-ui" +import { + AiAssistantCommandIcon, + AiAssistantProvider, + SearchProvider as UiSearchProvider, +} from "docs-ui" import { absoluteUrl } from "../lib/absolute-url" +import clsx from "clsx" +import { Sparkles } from "@medusajs/icons" type SearchProviderProps = { children: React.ReactNode @@ -61,6 +67,27 @@ const SearchProvider = ({ children }: SearchProviderProps) => { ], }} initialDefaultFilters={["ui"]} + commands={[ + { + name: "ai-assistant", + icon: , + component: ( + + ), + title: "AI Assistant", + badge: { + variant: "purple", + children: "Beta", + }, + }, + ]} > {children} diff --git a/www/apps/ui/tsconfig.json b/www/apps/ui/tsconfig.json index 9b49b1f2e4..52e0802434 100644 --- a/www/apps/ui/tsconfig.json +++ b/www/apps/ui/tsconfig.json @@ -9,7 +9,8 @@ "./.contentlayer/generated" ] }, - "jsx": "preserve" + "jsx": "preserve", + "baseUrl": "." }, "include": [ "next-env.d.ts", diff --git a/www/packages/docs-ui/package.json b/www/packages/docs-ui/package.json index 48d7ca1e30..e1cf08062d 100644 --- a/www/packages/docs-ui/package.json +++ b/www/packages/docs-ui/package.json @@ -35,6 +35,7 @@ "devDependencies": { "@types/react": "^17.0.1", "@types/react-dom": "^17.0.1", + "@types/react-google-recaptcha": "^2.1.6", "clsx": "^2.0.0", "cpy-cli": "^5.0.0", "eslint-config-docs": "*", @@ -59,10 +60,13 @@ "@medusajs/icons": "latest", "@medusajs/ui": "latest", "@octokit/request": "^8.1.1", + "@react-hook/resize-observer": "^1.2.6", "@segment/analytics-next": "^1.55.0", "algoliasearch": "^4.20.0", "prism-react-renderer": "^2.0.6", + "react-google-recaptcha": "^3.1.0", "react-instantsearch": "^7.0.3", + "react-markdown": "^8.0.7", "react-tooltip": "^5.21.3", "react-transition-group": "^4.4.5", "react-uuid": "^2.0.0" diff --git a/www/packages/docs-ui/src/components/AiAssistant/CommandIcon/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/CommandIcon/index.tsx new file mode 100644 index 0000000000..852142a610 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/CommandIcon/index.tsx @@ -0,0 +1,24 @@ +import { SparklesSolid } from "@medusajs/icons" +import clsx from "clsx" +import React from "react" + +export type AiAssistantCommandIconProps = + React.AllHTMLAttributes + +export const AiAssistantCommandIcon = ({ + className, + ...props +}: AiAssistantCommandIconProps) => { + return ( + + + + ) +} diff --git a/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/Actions/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/Actions/index.tsx new file mode 100644 index 0000000000..f02dd2a93a --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/Actions/index.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react" +import { ThreadType } from "../.." +import clsx from "clsx" +import { + Button, + type ButtonProps, + ThumbDownIcon, + ThumbUpIcon, +} from "@/components" +import { Check, SquareTwoStack } from "@medusajs/icons" +import { useCopy } from "@/hooks" +import { AiAssistantFeedbackType, useAiAssistant } from "@/providers" + +export type AiAssistantThreadItemActionsProps = { + item: ThreadType +} + +export const AiAssistantThreadItemActions = ({ + item, +}: AiAssistantThreadItemActionsProps) => { + const { isCopied, handleCopy } = useCopy(item.content) + const [feedback, setFeedback] = useState(null) + const { sendFeedback } = useAiAssistant() + + const handleFeedback = async ( + reaction: AiAssistantFeedbackType, + question_id?: string + ) => { + try { + if (!question_id || feedback) { + return + } + setFeedback(reaction) + const response = await sendFeedback(question_id, reaction) + + if (response.status !== 200) { + console.error("Error sending feedback:", response.status) + } + } catch (error) { + console.error("Error sending feedback:", error) + } + } + + return ( +
+ + {isCopied ? : } + + {(feedback === null || feedback === "upvote") && ( + handleFeedback("upvote", item.question_id)} + className={clsx(feedback === "upvote" && "!text-medusa-fg-subtle")} + > + + + )} + {(feedback === null || feedback === "downvote") && ( + handleFeedback("downvote", item.question_id)} + className={clsx(feedback === "downvote" && "!text-medusa-fg-subtle")} + > + + + )} +
+ ) +} + +const ActionButton = ({ children, className, ...props }: ButtonProps) => { + return ( + + ) +} diff --git a/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/index.tsx new file mode 100644 index 0000000000..e40663bac7 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/index.tsx @@ -0,0 +1,51 @@ +import clsx from "clsx" +import React from "react" +import { ThreadType } from ".." +import { DotsLoading, MarkdownContent, QuestionMarkIcon } from "@/components" +import { ExclamationCircle, Sparkles } from "@medusajs/icons" +import { AiAssistantThreadItemActions } from "./Actions" + +export type AiAssistantThreadItemProps = { + item: ThreadType +} + +export const AiAssistantThreadItem = ({ item }: AiAssistantThreadItemProps) => { + return ( +
+ + {item.type === "question" && } + {item.type === "answer" && } + {item.type === "error" && } + +
+ {item.type === "question" && <>{item.content}} + {item.type === "answer" && ( + <> + {!item.question_id && item.content.length === 0 && } + {item.content} + + )} + {item.type === "error" && ( + {item.content} + )} +
+ {item.type === "answer" && item.question_id && ( + + )} +
+ ) +} diff --git a/www/packages/docs-ui/src/components/AiAssistant/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/index.tsx new file mode 100644 index 0000000000..340f31ff64 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/index.tsx @@ -0,0 +1,441 @@ +"use client" + +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react" +import { + AiAssistantCommandIcon, + Badge, + Button, + InputText, + Kbd, + SearchSuggestionItem, + SearchSuggestionType, + SearchHitGroupName, + Tooltip, + Link, +} from "@/components" +import { useAiAssistant, useSearch } from "@/providers" +import { ArrowUturnLeft, XMarkMini } from "@medusajs/icons" +import clsx from "clsx" +import { useSearchNavigation } from "@/hooks" +import { AiAssistantThreadItem } from "./ThreadItem" +import useResizeObserver from "@react-hook/resize-observer" + +export type ChunkType = { + stream_end: boolean +} & ( + | { + type: "relevant_sources" + content: { + relevant_sources: RelevantSourcesType[] + } + } + | { + type: "partial_answer" + content: PartialAnswerType + } + | { + type: "identifiers" + content: IdentifierType + } + | { + type: "error" + content: ErrorType + } +) + +export type RelevantSourcesType = { + source_url: string +} + +export type PartialAnswerType = { + text: string +} + +export type IdentifierType = { + thread_id: string + question_answer_id: string +} + +export type ErrorType = { + reason: string +} + +export type ThreadType = { + type: "question" | "answer" | "error" + content: string + question_id?: string + // for some reason, items in the array get reordered + // sometimes, so this is one way to avoid it + order: number +} + +export const AiAssistant = () => { + const [question, setQuestion] = useState("") + // this helps set the `order` field of the threadtype + const [messagesCount, setMessagesCount] = useState(0) + const [thread, setThread] = useState([]) + const [answer, setAnswer] = useState("") + const [identifiers, setIdentifiers] = useState(null) + const [loading, setLoading] = useState(false) + const { getAnswer } = useAiAssistant() + const { setCommand } = useSearch() + const inputRef = useRef(null) + const contentRef = useRef(null) + + const suggestions: SearchSuggestionType[] = [ + { + title: "FAQ", + items: [ + "What is Medusa?", + "How can I create an ecommerce store with Medusa?", + "How can I build a marketplace with Medusa?", + "How can I build subscription-based purchases with Medusa?", + "How can I build digital products with Medusa?", + "What can I build with Medusa?", + "What is Medusa Admin?", + "How do I configure the database in Medusa?", + ], + }, + ] + + const handleSubmit = (selectedQuestion?: string) => { + if (!selectedQuestion?.length && !question.length) { + return + } + setLoading(true) + setAnswer("") + setThread((prevThread) => [ + ...prevThread, + { + type: "question", + content: selectedQuestion || question, + order: getNewOrder(prevThread), + }, + ]) + setMessagesCount((prev) => prev + 1) + } + + useSearchNavigation({ + getInputElm: () => inputRef.current, + focusInput: () => inputRef.current?.focus(), + handleSubmit, + }) + + const sortThread = (threadArr: ThreadType[]) => { + const sortedThread = [...threadArr] + sortedThread.sort((itemA, itemB) => { + if (itemA.order < itemB.order) { + return -1 + } + + return itemA.order < itemB.order ? 1 : 0 + }) + return sortedThread + } + + const getNewOrder = (prevThread: ThreadType[]) => { + const sortedThread = sortThread(prevThread) + + return sortedThread.length === 0 + ? messagesCount + 1 + : sortedThread[prevThread.length - 1].order + 1 + } + + const setError = (logMessage?: string) => { + if (logMessage?.length) { + console.error(`[AI ERROR]: ${logMessage}`) + } + setThread((prevThread) => [ + ...prevThread, + { + type: "error", + content: + "I'm sorry, but I'm having trouble connecting to my knowledge base. Please try again. If the issue keeps persisting, please consider reporting an issue.", + order: getNewOrder(prevThread), + }, + ]) + setMessagesCount((prev) => prev + 1) + setLoading(false) + setQuestion("") + setAnswer("") + inputRef.current?.focus() + } + + const scrollToBottom = () => { + const parent = contentRef.current?.parentElement as HTMLElement + + parent.scrollTop = parent.scrollHeight + } + + const lastAnswerIndex = useMemo(() => { + const index = thread.reverse().findIndex((item) => item.type === "answer") + return index !== -1 ? index : 0 + }, [thread]) + + const process_stream = useCallback(async (response: Response) => { + const reader = response.body?.getReader() + if (!reader) { + return + } + const decoder = new TextDecoder("utf-8") + const delimiter = "\u241E" + const delimiterBytes = new TextEncoder().encode(delimiter) + let buffer = new Uint8Array() + + const findDelimiterIndex = (arr: Uint8Array) => { + for (let i = 0; i < arr.length - delimiterBytes.length + 1; i++) { + let found = true + for (let j = 0; j < delimiterBytes.length; j++) { + if (arr[i + j] !== delimiterBytes[j]) { + found = false + break + } + } + if (found) { + return i + } + } + return -1 + } + + let result + let loop = true + while (loop) { + result = await reader.read() + if (result.done) { + loop = false + continue + } + buffer = new Uint8Array([...buffer, ...result.value]) + let delimiterIndex + while ((delimiterIndex = findDelimiterIndex(buffer)) !== -1) { + const chunkBytes = buffer.slice(0, delimiterIndex) + const chunkText = decoder.decode(chunkBytes) + buffer = buffer.slice(delimiterIndex + delimiterBytes.length) + const chunk = JSON.parse(chunkText).chunk as ChunkType + + if (chunk.type === "partial_answer") { + setAnswer((prevAnswer) => { + return prevAnswer + chunk.content.text + }) + } else if (chunk.type === "identifiers") { + setIdentifiers(chunk.content) + } else if (chunk.type === "error") { + setError(chunk.content.reason) + loop = false + return + } + } + } + + setLoading(false) + setQuestion("") + }, []) + + const fetchAnswer = useCallback(async () => { + try { + const response = await getAnswer(question, identifiers?.thread_id) + + if (response.status === 200) { + await process_stream(response) + } else { + const message = await response.text() + setError(message) + } + } catch (error: any) { + setError(JSON.stringify(error)) + } + }, [question, identifiers, process_stream]) + + useEffect(() => { + if (loading && !answer) { + void fetchAnswer() + } + }, [loading, fetchAnswer]) + + useEffect(() => { + if ( + !loading && + answer.length && + thread[lastAnswerIndex]?.content !== answer + ) { + setThread((prevThread) => [ + ...prevThread, + { + type: "answer", + content: answer, + question_id: identifiers?.question_answer_id, + order: getNewOrder(prevThread), + }, + ]) + setAnswer("") + setMessagesCount((prev) => prev + 1) + inputRef.current?.focus() + } + }, [loading, answer, thread, lastAnswerIndex, inputRef.current]) + + useResizeObserver(contentRef, () => { + if (!loading) { + return + } + + scrollToBottom() + }) + + const getThreadItems = useCallback(() => { + const sortedThread = sortThread(thread) + + return sortedThread.map((item, index) => ( + + )) + }, [thread]) + + return ( +
+
+ + setQuestion(e.target.value)} + className={clsx( + "bg-transparent border-0 focus:outline-none hover:!bg-transparent", + "shadow-none flex-1 text-medusa-fg-base", + "disabled:!bg-transparent disabled:cursor-not-allowed" + )} + placeholder="Ask me a question about Medusa..." + autoFocus={true} + passedRef={inputRef} + disabled={loading} + /> + +
+
+
+ {!thread.length && ( +
+ {suggestions.map((suggestion, index) => ( + + + {suggestion.items.map((item, itemIndex) => ( + { + setQuestion(item) + handleSubmit(item) + }} + key={itemIndex} + tabIndex={itemIndex} + > + {item} + + ))} + + ))} +
+ )} + {getThreadItems()} + {(answer.length || loading) && ( + + )} +
+
+
+ + This site is protected by reCAPTCHA and the{" "} + + Google Privacy Policy + {" "} + and ToS{" "} + apply + + } + > +
+ + Medusa AI Assistant + Beta +
+
+
+
+ {thread.length === 0 && ( + <> + + Navigate FAQ + + + + + + + )} + {thread.length > 0 && ( + + Chat is cleared on exit + + )} +
+
+ + Ask Question + + +
+
+
+
+ ) +} diff --git a/www/packages/docs-ui/src/components/Button/index.tsx b/www/packages/docs-ui/src/components/Button/index.tsx index 9a45e91e20..6977e09716 100644 --- a/www/packages/docs-ui/src/components/Button/index.tsx +++ b/www/packages/docs-ui/src/components/Button/index.tsx @@ -24,7 +24,6 @@ export const Button = ({ }: ButtonProps) => { const variantClasses = { primary: [ - "inline-flex flex-row justify-center items-center", "py-[5px] px-docs_0.75 rounded-docs_sm cursor-pointer", "bg-button-inverted bg-medusa-button-inverted dark:bg-button-inverted-dark", "hover:bg-medusa-button-inverted-hover hover:bg-no-image hover:no-underline", @@ -42,7 +41,6 @@ export const Button = ({ "select-none", ], secondary: [ - "inline-flex flex-row justify-center items-center", "py-[5px] px-docs_0.75 rounded-docs_sm cursor-pointer", "bg-button-neutral bg-medusa-button-neutral dark:bg-button-neutral-dark", "hover:bg-medusa-button-neutral-hover hover:bg-no-image hover:no-underline", @@ -65,6 +63,7 @@ export const Button = ({ return ( - -
- } - > - - -
- -
- {filterOptions.length && ( - - setFilters(Array.isArray(value) ? [...value] : [value]) - } - addSelected={(value) => setFilters((prev) => [...prev, value])} - removeSelected={(value) => - setFilters((prev) => prev.filter((v) => v !== value)) - } - showClearButton={false} - placeholder="Filters" - handleAddAll={(isAllSelected: boolean) => { - if (isAllSelected) { - setFilters(defaultFilters) - } else { - setFilters(filterOptions.map((option) => option.value)) - } - }} - /> - )} -
-
- - Navigation - - - - - -
-
- - Open Result - - -
-
-
- - ) -} diff --git a/www/packages/docs-ui/src/components/Search/Suggestions/Item/index.tsx b/www/packages/docs-ui/src/components/Search/Suggestions/Item/index.tsx new file mode 100644 index 0000000000..9ed739dbe8 --- /dev/null +++ b/www/packages/docs-ui/src/components/Search/Suggestions/Item/index.tsx @@ -0,0 +1,32 @@ +import clsx from "clsx" +import React from "react" + +export type SearchSuggestionItemType = { + onClick: () => void +} & React.AllHTMLAttributes + +export const SearchSuggestionItem = ({ + children, + onClick, + className, + ...rest +}: SearchSuggestionItemType) => { + return ( +
+ {children} +
+ ) +} diff --git a/www/packages/docs-ui/src/components/Search/Suggestions/index.tsx b/www/packages/docs-ui/src/components/Search/Suggestions/index.tsx index aeecaa3972..9c91b9e895 100644 --- a/www/packages/docs-ui/src/components/Search/Suggestions/index.tsx +++ b/www/packages/docs-ui/src/components/Search/Suggestions/index.tsx @@ -1,9 +1,11 @@ "use client" import React from "react" -import clsx from "clsx" import { useInstantSearch } from "react-instantsearch" import { SearchHitGroupName } from "../Hits/GroupName" +import { useSearch } from "@/providers" +import { SearchSuggestionItem } from "./Item" +import { Badge } from "@/components" export type SearchSuggestionType = { title: string @@ -16,35 +18,44 @@ export type SearchSuggestionsProps = { export const SearchSuggestions = ({ suggestions }: SearchSuggestionsProps) => { const { setIndexUiState } = useInstantSearch() + const { commands, setCommand } = useSearch() + return (
+ {commands.length > 0 && ( + <> + + {commands.map((command, index) => ( + setCommand(command)} + key={index} + tabIndex={index} + className="gap-docs_0.75" + > + <> + {command.icon} + {command.title} + {command.badge && } + + + ))} + + )} {suggestions.map((suggestion, index) => ( {suggestion.items.map((item, itemIndex) => ( -
setIndexUiState({ query: item, }) } key={itemIndex} - tabIndex={itemIndex} - data-hit + tabIndex={commands.length + itemIndex} > - - {item} - -
+ {item} + ))}
))} diff --git a/www/packages/docs-ui/src/components/Search/index.tsx b/www/packages/docs-ui/src/components/Search/index.tsx index 4bbc170203..2b631bf864 100644 --- a/www/packages/docs-ui/src/components/Search/index.tsx +++ b/www/packages/docs-ui/src/components/Search/index.tsx @@ -1,6 +1,215 @@ -export * from "./EmptyQueryBoundary" -export * from "./Hits" -export * from "./Modal" -export * from "./ModalOpener" -export * from "./NoResults" -export * from "./Suggestions" +"use client" + +import React, { useEffect, useMemo, useRef, useState } from "react" +import { InstantSearch, SearchBox } from "react-instantsearch" +import clsx from "clsx" +import { SearchEmptyQueryBoundary } from "./EmptyQueryBoundary" +import { SearchSuggestions, type SearchSuggestionType } from "./Suggestions" +import { AlgoliaProps, useSearch } from "@/providers" +import { checkArraySameElms } from "@/utils" +import { SearchHitsWrapper } from "./Hits" +import { Button, Kbd, SelectBadge } from "@/components" +import { MagnifyingGlass, XMark } from "@medusajs/icons" +import { useSearchNavigation, type OptionType } from "@/hooks" + +export type SearchProps = { + algolia: AlgoliaProps + isLoading?: boolean + suggestions: SearchSuggestionType[] + checkInternalPattern?: RegExp + filterOptions?: OptionType[] +} + +export const Search = ({ + algolia, + suggestions, + isLoading = false, + checkInternalPattern, + filterOptions = [], +}: SearchProps) => { + const { isOpen, setIsOpen, defaultFilters, searchClient, modalRef } = + useSearch() + const [filters, setFilters] = useState(defaultFilters) + const formattedFilters: string = useMemo(() => { + let formatted = "" + filters.forEach((filter) => { + const split = filter.split("_") + split.forEach((f) => { + if (formatted.length) { + formatted += " OR " + } + formatted += `_tags:${f}` + }) + }) + return formatted + }, [filters]) + const searchBoxRef = useRef(null) + + const focusSearchInput = () => + searchBoxRef.current?.querySelector("input")?.focus() + + useEffect(() => { + if (!checkArraySameElms(defaultFilters, filters)) { + setFilters(defaultFilters) + } + }, [defaultFilters]) + + useEffect(() => { + if (isOpen && searchBoxRef.current) { + focusSearchInput() + } else if (!isOpen) { + const focusedItem = modalRef.current?.querySelector( + ":focus" + ) as HTMLElement + if ( + focusedItem && + focusedItem === searchBoxRef.current?.querySelector("input") + ) { + // remove focus + focusedItem.blur() + } + } + }, [isOpen]) + + useSearchNavigation({ + getInputElm: () => + searchBoxRef.current?.querySelector("input") as HTMLInputElement, + focusInput: focusSearchInput, + keyboardProps: { + isLoading, + }, + }) + + return ( +
+ +
+ ( + + )} + resetIconComponent={() => ( + + )} + placeholder="Find something..." + autoFocus + formRef={searchBoxRef} + /> + +
+
+ } + > + + +
+
+
+ {filterOptions.length && ( + + setFilters(Array.isArray(value) ? [...value] : [value]) + } + addSelected={(value) => setFilters((prev) => [...prev, value])} + removeSelected={(value) => + setFilters((prev) => prev.filter((v) => v !== value)) + } + showClearButton={false} + placeholder="Filters" + handleAddAll={(isAllSelected: boolean) => { + if (isAllSelected) { + setFilters(defaultFilters) + } else { + setFilters(filterOptions.map((option) => option.value)) + } + }} + /> + )} +
+
+ + Navigation + + + + + +
+
+ + Open Result + + +
+
+
+
+ ) +} diff --git a/www/packages/docs-ui/src/components/Select/Badge/index.tsx b/www/packages/docs-ui/src/components/Select/Badge/index.tsx index ed16262919..3b77145f63 100644 --- a/www/packages/docs-ui/src/components/Select/Badge/index.tsx +++ b/www/packages/docs-ui/src/components/Select/Badge/index.tsx @@ -77,7 +77,7 @@ export const SelectBadge = ({
void +} + +export const useCopy = (text: string): useCopyReturnType => { + const [isCopied, setIsCopied] = useState(false) + const copyTimeout = useRef(0) + + const handleCopy = useCallback(() => { + copy(text) + setIsCopied(true) + copyTimeout.current = window.setTimeout(() => { + setIsCopied(false) + }, 1000) + }, [text]) + + useEffect(() => () => window.clearTimeout(copyTimeout.current), []) + + return { isCopied, handleCopy } +} diff --git a/www/packages/docs-ui/src/hooks/use-search-navigation/index.ts b/www/packages/docs-ui/src/hooks/use-search-navigation/index.ts new file mode 100644 index 0000000000..110f2cbee8 --- /dev/null +++ b/www/packages/docs-ui/src/hooks/use-search-navigation/index.ts @@ -0,0 +1,144 @@ +"use client" + +import { useCallback, useEffect, useMemo } from "react" +import { useSearch } from "@/providers" +import { findNextSibling, findPrevSibling } from "@/utils" +import { + useKeyboardShortcut, + type useKeyboardShortcutOptions, +} from "../use-keyboard-shortcut" + +export type useSearchNavigationProps = { + getInputElm: () => HTMLInputElement | null + focusInput: () => void + handleSubmit?: () => void + keyboardProps?: Partial +} + +export const useSearchNavigation = ({ + getInputElm, + focusInput, + handleSubmit, + keyboardProps, +}: useSearchNavigationProps) => { + const shortcutKeys = useMemo(() => ["ArrowUp", "ArrowDown", "Enter"], []) + const { modalRef, isOpen } = useSearch() + + const handleKeyAction = (e: KeyboardEvent) => { + if (!isOpen) { + return + } + e.preventDefault() + const focusedItem = modalRef.current?.querySelector(":focus") as HTMLElement + if (!focusedItem) { + // focus the first data-hit + const nextItem = modalRef.current?.querySelector( + "[data-hit]" + ) as HTMLElement + nextItem?.focus() + return + } + + const isHit = focusedItem.hasAttribute("data-hit") + const isInput = focusedItem.tagName.toLowerCase() === "input" + + if (!isHit && !isInput) { + // ignore if focused items aren't input/data-hit + return + } + + const lowerPressedKey = e.key.toLowerCase() + + if (lowerPressedKey === "enter") { + if (isHit) { + // trigger click event of the focused element + focusedItem.click() + } else { + handleSubmit?.() + } + return + } + + if (lowerPressedKey === "arrowup") { + // only hit items has action on arrow up + if (isHit) { + // find if there's a data-hit item before this one + const beforeItem = findPrevSibling(focusedItem, "[data-hit]") + if (!beforeItem) { + // focus the input + focusInput() + } else { + // focus the previous item + beforeItem.focus() + } + } + } else if (lowerPressedKey === "arrowdown") { + // check if item is input or hit + if (isInput) { + // go to the first data-hit item + const nextItem = modalRef.current?.querySelector( + "[data-hit]" + ) as HTMLElement + nextItem?.focus() + } else { + // handle go down for hit items + // find if there's a data-hit item after this one + const afterItem = findNextSibling(focusedItem, "[data-hit]") + if (afterItem) { + // focus the next item + afterItem.focus() + } + } + } + } + + /** Handles starting to type which focuses the input */ + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isOpen) { + return + } + // check if shortcut keys were pressed + const lowerPressedKey = e.key.toLowerCase() + const pressedShortcut = [...shortcutKeys, "Escape"].some( + (s) => s.toLowerCase() === lowerPressedKey + ) + if (pressedShortcut) { + return + } + + const focusedItem = modalRef.current?.querySelector( + ":focus" + ) as HTMLElement + const inputElm = getInputElm() + if (inputElm && focusedItem !== inputElm) { + focusInput() + } + }, + [ + shortcutKeys, + isOpen, + modalRef.current, + shortcutKeys, + getInputElm, + focusInput, + ] + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + + return () => { + window.removeEventListener("keydown", handleKeyDown) + } + }, [handleKeyDown]) + + useKeyboardShortcut({ + metakey: false, + shortcutKeys: shortcutKeys, + checkEditing: false, + isLoading: false, + action: handleKeyAction, + ...keyboardProps, + }) +} diff --git a/www/packages/docs-ui/src/providers/AiAssistant/index.tsx b/www/packages/docs-ui/src/providers/AiAssistant/index.tsx new file mode 100644 index 0000000000..79ff129fd2 --- /dev/null +++ b/www/packages/docs-ui/src/providers/AiAssistant/index.tsx @@ -0,0 +1,120 @@ +"use client" + +import React, { createContext, useContext } from "react" +import { useAnalytics } from "@/providers" +import { AiAssistant } from "@/components" +import ReCAPTCHA from "react-google-recaptcha" + +export type AiAssistantFeedbackType = "upvote" | "downvote" + +export type AiAssistantContextType = { + getAnswer: (question: string, thread_id?: string) => Promise + sendFeedback: ( + questionId: string, + reaction: AiAssistantFeedbackType + ) => Promise +} + +const AiAssistantContext = createContext(null) + +export type AiAssistantProviderProps = { + children?: React.ReactNode + apiUrl: string + recaptchaSiteKey: string + websiteId: string +} + +export const AiAssistantProvider = ({ + apiUrl, + recaptchaSiteKey, + websiteId, + children, +}: AiAssistantProviderProps) => { + const { analytics } = useAnalytics() + const recaptchaRef = React.createRef() + + const getReCaptchaToken = async () => { + if (recaptchaRef?.current) { + const recaptchaToken = await recaptchaRef.current.executeAsync() + return recaptchaToken || "" + } + return "" + } + + const sendRequest = async ( + apiPath: string, + method = "GET", + headers?: HeadersInit, + body?: BodyInit + ) => { + return await fetch(`${apiUrl}${apiPath}`, { + method, + headers: { + "X-RECAPTCHA-TOKEN": await getReCaptchaToken(), + "X-WEBSITE-ID": websiteId, + ...headers, + }, + body, + }) + } + + const getAnswer = async (question: string, threadId?: string) => { + const questionParam = encodeURI(question) + return await sendRequest( + threadId + ? `/query/v1/thread/${threadId}/stream?query=${questionParam}` + : `/query/v1/stream?query=${questionParam}` + ) + } + + const sendFeedback = async ( + questionId: string, + reaction: AiAssistantFeedbackType + ) => { + return await sendRequest( + `/query/v1/question-answer/${questionId}/feedback`, + "POST", + { + "Content-Type": "application/json", + }, + JSON.stringify({ + question_id: questionId, + reaction, + user_identifier: analytics?.user().anonymousId() || "", + }) + ) + } + + return ( + + {children} + + + console.error( + "ReCAPTCHA token not yet configured. Please reach out to the kapa team at founders@kapa.ai to complete the setup." + ) + } + className="grecaptcha-badge" + /> + + ) +} + +export const useAiAssistant = () => { + const context = useContext(AiAssistantContext) + + if (!context) { + throw new Error("useAiAssistant must be used within a AiAssistantProvider") + } + + return context +} diff --git a/www/packages/docs-ui/src/providers/Search/index.tsx b/www/packages/docs-ui/src/providers/Search/index.tsx index 3b0b8ac2b0..4ced0f770e 100644 --- a/www/packages/docs-ui/src/providers/Search/index.tsx +++ b/www/packages/docs-ui/src/providers/Search/index.tsx @@ -6,10 +6,21 @@ import React, { useEffect, useState, useMemo, + useRef, } from "react" -import { SearchModal, SearchModalProps } from "@/components" +import { BadgeProps, Modal, Search, SearchProps } from "@/components" import { checkArraySameElms } from "../../utils" import algoliasearch, { SearchClient } from "algoliasearch/lite" +import clsx from "clsx" +import { CSSTransition, SwitchTransition } from "react-transition-group" + +export type SearchCommand = { + name: string + component: React.ReactNode + icon?: React.ReactNode + title: string + badge?: BadgeProps +} export type SearchContextType = { isOpen: boolean @@ -17,6 +28,10 @@ export type SearchContextType = { defaultFilters: string[] setDefaultFilters: (value: string[]) => void searchClient: SearchClient + commands: SearchCommand[] + command: SearchCommand | null + setCommand: React.Dispatch> + modalRef: React.MutableRefObject } const SearchContext = createContext(null) @@ -32,7 +47,9 @@ export type SearchProviderProps = { children: React.ReactNode initialDefaultFilters?: string[] algolia: AlgoliaProps - searchProps: Omit + searchProps: Omit + commands?: SearchCommand[] + modalClassName?: string } export const SearchProvider = ({ @@ -40,11 +57,16 @@ export const SearchProvider = ({ initialDefaultFilters = [], searchProps, algolia, + commands = [], + modalClassName, }: SearchProviderProps) => { const [isOpen, setIsOpen] = useState(false) const [defaultFilters, setDefaultFilters] = useState( initialDefaultFilters ) + const [command, setCommand] = useState(null) + + const modalRef = useRef(null) const searchClient: SearchClient = useMemo(() => { const algoliaClient = algoliasearch(algolia.appId, algolia.apiKey) @@ -89,10 +111,53 @@ export const SearchProvider = ({ defaultFilters, setDefaultFilters, searchClient, + commands, + command, + setCommand, + modalRef, }} > {children} - + setIsOpen(false)} + passedRef={modalRef} + className={modalClassName} + > + + + <> + {command === null && ( + + )} + {command?.component} + + + + ) } diff --git a/www/packages/docs-ui/src/providers/index.ts b/www/packages/docs-ui/src/providers/index.ts index 110e6e6712..32b34fb1e4 100644 --- a/www/packages/docs-ui/src/providers/index.ts +++ b/www/packages/docs-ui/src/providers/index.ts @@ -1,3 +1,4 @@ +export * from "./AiAssistant" export * from "./Analytics" export * from "./ColorMode" export * from "./Mobile" diff --git a/www/packages/tailwind/base.tailwind.config.js b/www/packages/tailwind/base.tailwind.config.js index d921734414..e2a6531522 100644 --- a/www/packages/tailwind/base.tailwind.config.js +++ b/www/packages/tailwind/base.tailwind.config.js @@ -433,7 +433,7 @@ module.exports = { }, ], }, - keyframes: { + keyframes: ({ theme }) => ({ fadeIn: { from: { opacity: 0 }, to: { opacity: 1 }, @@ -459,6 +459,63 @@ module.exports = { transform: "scale3d(1, 1, 1)", }, }, + fadeInDown: { + from: { + opacity: "0", + transform: "translate3d(0, -100%, 0)", + }, + to: { + opacity: "1", + transform: "translate3d(0, 0, 0)", + }, + }, + fadeInLeft: { + from: { + opacity: "0", + transform: "translate3d(-100%, 0, 0)", + }, + to: { + opacity: "1", + transform: "translate3d(0, 0, 0)", + }, + }, + fadeInRight: { + from: { + opacity: "0", + transform: "translate3d(100%, 0, 0)", + }, + to: { + opacity: "1", + transform: "translate3d(0, 0, 0)", + }, + }, + fadeOutUp: { + from: { + opacity: "1", + }, + to: { + opacity: "0", + transform: "translate3d(0, -100%, 0)", + }, + }, + fadeOutLeft: { + from: { + opacity: "1", + }, + to: { + opacity: "0", + transform: "translate3d(-100%, 0, 0)", + }, + }, + fadeOutRight: { + from: { + opacity: "1", + }, + to: { + opacity: "0", + transform: "translate3d(100%, 0, 0)", + }, + }, slideInRight: { from: { transform: "translate3d(100%, 0, 0)", @@ -477,13 +534,48 @@ module.exports = { transform: "translate3d(100%, 0, 0)", }, }, - }, + slideInLeft: { + from: { + transform: "translate3d(-100%, 0, 0)", + visibility: "visible", + }, + to: { + transform: "translate3d(0, 0, 0)", + }, + }, + slideOutLeft: { + from: { + transform: "translate3d(0, 0, 0)", + }, + to: { + visibility: "hidden", + transform: "translate3d(-100%, 0, 0)", + }, + }, + pulsingDots: { + "0%": { + opacity: 1, + }, + "100%": { + opacity: 0.3, + }, + }, + }), animation: { fadeIn: "fadeIn 500ms", fadeOut: "fadeOut 500ms", + fadeInDown: "fadeInDown 500ms", + fadeInLeft: "fadeInLeft 500ms", + fadeInRight: "fadeInRight 500ms", + fadeOutUp: "fadeOutUp 500ms", + fadeOutLeft: "fadeOutLeft 500ms", + fadeOutRight: "fadeOutRight 500ms", tada: "tada 1s", slideInRight: "slideInRight 500ms", slideOutRight: "slideOutRight 500ms", + slideInLeft: "slideInLeft 500ms", + slideOutLeft: "slideOutLeft 500ms", + pulsingDots: "pulsingDots 1s alternate infinite", }, }, fontFamily: { @@ -559,39 +651,65 @@ module.exports = { "toc-dark": "url('/img/side-menu.svg')", }, }, + animationDelay: { + 0: "0ms", + 200: "200ms", + 400: "400ms", + }, plugins: [ - plugin(({ addBase, addVariant, addUtilities, addComponents, theme }) => { - addBase(presets) - addVariant("search-cancel", "&::-webkit-search-cancel-button") - addUtilities({ - ".animation-fill-forwards": { - animationFillMode: "forwards", - }, - ".animate-fast": { - animationDuration: "300ms", - }, - ".clip": { - clipPath: "inset(0)", - }, - ".no-marker": { - "&::-webkit-details-marker": { - display: "none", + plugin( + ({ + addBase, + addVariant, + addUtilities, + addComponents, + matchUtilities, + theme, + }) => { + addBase(presets) + addVariant("search-cancel", "&::-webkit-search-cancel-button") + addUtilities({ + ".animation-fill-forwards": { + animationFillMode: "forwards", }, - }, - }) - addComponents({ - ".btn-secondary-icon": { - padding: "4px !important", - }, - "btn-clear": { - backgroundColor: "transparent", - boxShadow: theme("shadow.none"), - borderWidth: 0, - borderColor: "transparent", - outlineColor: "transparent", - cursor: "pointer", - }, - }) - }), + ".animate-fast": { + animationDuration: "300ms", + }, + ".clip": { + clipPath: "inset(0)", + }, + ".no-marker": { + "&::-webkit-details-marker": { + display: "none", + }, + }, + }) + addComponents({ + ".btn-secondary-icon": { + padding: "4px !important", + }, + "btn-clear": { + backgroundColor: "transparent", + boxShadow: theme("shadow.none"), + borderWidth: 0, + borderColor: "transparent", + outlineColor: "transparent", + cursor: "pointer", + }, + ".grecaptcha-badge": { + visibility: "hidden", + }, + }) + + matchUtilities( + { + "animation-delay": (value) => ({ + animationDelay: value, + }), + }, + { values: theme("animationDelay") } + ) + } + ), ], } diff --git a/www/yarn.lock b/www/yarn.lock index 1518766083..d7ba91979b 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -3138,6 +3138,13 @@ __metadata: languageName: node linkType: hard +"@juggle/resize-observer@npm:^3.3.1": + version: 3.4.0 + resolution: "@juggle/resize-observer@npm:3.4.0" + checksum: 12930242357298c6f2ad5d4ec7cf631dfb344ca7c8c830ab7f64e6ac11eb1aae486901d8d880fd08fb1b257800c160a0da3aee1e7ed9adac0ccbb9b7c5d93347 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.4 resolution: "@leichtgewicht/ip-codec@npm:2.0.4" @@ -5102,6 +5109,37 @@ __metadata: languageName: node linkType: hard +"@react-hook/latest@npm:^1.0.2": + version: 1.0.3 + resolution: "@react-hook/latest@npm:1.0.3" + peerDependencies: + react: ">=16.8" + checksum: d6a166c21121da519a516e8089ba28a2779d37b6017732ab55476c0d354754ad215394135765254f8752a7c6661c3fb868d088769a644848602f00f8821248ed + languageName: node + linkType: hard + +"@react-hook/passive-layout-effect@npm:^1.2.0": + version: 1.2.1 + resolution: "@react-hook/passive-layout-effect@npm:1.2.1" + peerDependencies: + react: ">=16.8" + checksum: 5c9e6b3df1c91fc2b1d4f711ca96b5f8cb3f6a13a2e97dac7cce623e58d7ee57999c45db3778d0af0b2522b3a5b7463232ef21cb3ee9900437172d48f766d933 + languageName: node + linkType: hard + +"@react-hook/resize-observer@npm:^1.2.6": + version: 1.2.6 + resolution: "@react-hook/resize-observer@npm:1.2.6" + dependencies: + "@juggle/resize-observer": ^3.3.1 + "@react-hook/latest": ^1.0.2 + "@react-hook/passive-layout-effect": ^1.2.0 + peerDependencies: + react: ">=16.8" + checksum: 6ebe4ded4dc4602906c4c727871f93ea73754dd5758f90d50e5fc7382b1844324a46a4c2e0842d8ad5bf95886091ba8a0c9d3a1ef0f10bd0c9e011ecd7aeea42 + languageName: node + linkType: hard + "@react-stately/datepicker@npm:^3.5.0, @react-stately/datepicker@npm:^3.7.0": version: 3.7.0 resolution: "@react-stately/datepicker@npm:3.7.0" @@ -7252,6 +7290,13 @@ __metadata: languageName: node linkType: hard +"@types/prop-types@npm:^15.0.0": + version: 15.7.7 + resolution: "@types/prop-types@npm:15.7.7" + checksum: 26d565ebae8c28dede71547d687367ce74eeccc645fdbef2d38478fe293996be24784fa6190586ba303ccd274aa94d8a631d36a5d9b8e0c08f5647ff3244d72c + languageName: node + linkType: hard + "@types/qs@npm:*, @types/qs@npm:^6.5.3": version: 6.9.8 resolution: "@types/qs@npm:6.9.8" @@ -7284,6 +7329,15 @@ __metadata: languageName: node linkType: hard +"@types/react-google-recaptcha@npm:^2.1.6": + version: 2.1.6 + resolution: "@types/react-google-recaptcha@npm:2.1.6" + dependencies: + "@types/react": "*" + checksum: 07b0d14aaf9fb89063a2a91b80f088ab5eb99dfd76e6f24ad2deddf44eee53d620ebb93e9e5bcd0f4b1ff320c80b7bc1c5e2c7107f4fda01538323aeecf4c7b5 + languageName: node + linkType: hard + "@types/react-router-config@npm:*, @types/react-router-config@npm:^5.0.6": version: 5.0.7 resolution: "@types/react-router-config@npm:5.0.7" @@ -10181,9 +10235,11 @@ __metadata: "@medusajs/icons": latest "@medusajs/ui": latest "@octokit/request": ^8.1.1 + "@react-hook/resize-observer": ^1.2.6 "@segment/analytics-next": ^1.55.0 "@types/react": ^17.0.1 "@types/react-dom": ^17.0.1 + "@types/react-google-recaptcha": ^2.1.6 algoliasearch: ^4.20.0 clsx: ^2.0.0 cpy-cli: ^5.0.0 @@ -10192,7 +10248,9 @@ __metadata: prism-react-renderer: ^2.0.6 react: ^17.0.1 react-dom: ^17.0.1 + react-google-recaptcha: ^3.1.0 react-instantsearch: ^7.0.3 + react-markdown: ^8.0.7 react-tooltip: ^5.21.3 react-transition-group: ^4.4.5 react-uuid: ^2.0.0 @@ -12702,7 +12760,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.1.0": +"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.0": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -17092,7 +17150,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.0.0, prop-types@npm:^15.5.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -17291,6 +17349,18 @@ __metadata: languageName: node linkType: hard +"react-async-script@npm:^1.2.0": + version: 1.2.0 + resolution: "react-async-script@npm:1.2.0" + dependencies: + hoist-non-react-statics: ^3.3.0 + prop-types: ^15.5.0 + peerDependencies: + react: ">=16.4.1" + checksum: 89450912110c380abc08258ce17d2fb18d31d6b7179a74f6bc504c0761a4ca271edb671e402fa8e5ea4250b5c17fa953af80a9f1c4ebb26c9e81caee8476c903 + languageName: node + linkType: hard + "react-base16-styling@npm:^0.6.0": version: 0.6.0 resolution: "react-base16-styling@npm:0.6.0" @@ -17393,6 +17463,18 @@ __metadata: languageName: node linkType: hard +"react-google-recaptcha@npm:^3.1.0": + version: 3.1.0 + resolution: "react-google-recaptcha@npm:3.1.0" + dependencies: + prop-types: ^15.5.0 + react-async-script: ^1.2.0 + peerDependencies: + react: ">=16.4.1" + checksum: 5ecaa6b88f238defd939012cb2671b4cbda59fe03f059158994b8c5215db482412e905eae6a67d23cef220d77cfe0430ab91ce7849d476515cda72f3a8a0a746 + languageName: node + linkType: hard + "react-helmet-async@npm:*, react-helmet-async@npm:^1.3.0": version: 1.3.0 resolution: "react-helmet-async@npm:1.3.0" @@ -17455,6 +17537,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^18.0.0": + version: 18.2.0 + resolution: "react-is@npm:18.2.0" + checksum: 6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0 + languageName: node + linkType: hard + "react-json-view@npm:^1.21.3": version: 1.21.3 resolution: "react-json-view@npm:1.21.3" @@ -17489,6 +17578,32 @@ __metadata: languageName: node linkType: hard +"react-markdown@npm:^8.0.7": + version: 8.0.7 + resolution: "react-markdown@npm:8.0.7" + dependencies: + "@types/hast": ^2.0.0 + "@types/prop-types": ^15.0.0 + "@types/unist": ^2.0.0 + comma-separated-tokens: ^2.0.0 + hast-util-whitespace: ^2.0.0 + prop-types: ^15.0.0 + property-information: ^6.0.0 + react-is: ^18.0.0 + remark-parse: ^10.0.0 + remark-rehype: ^10.0.0 + space-separated-tokens: ^2.0.0 + style-to-object: ^0.4.0 + unified: ^10.0.0 + unist-util-visit: ^4.0.0 + vfile: ^5.0.0 + peerDependencies: + "@types/react": ">=16" + react: ">=16" + checksum: 016617fbd2f4c03c5ae017fe39e89202f2ff536b4921dc1a5f7283d4b9d5157f20797adda75a8c59a06787ad0bc8841e2e437915aec645ce528e0a04a6d450ac + languageName: node + linkType: hard + "react-remove-scroll-bar@npm:^2.3.3": version: 2.3.4 resolution: "react-remove-scroll-bar@npm:2.3.4" @@ -19072,7 +19187,7 @@ __metadata: languageName: node linkType: hard -"style-to-object@npm:^0.4.1": +"style-to-object@npm:^0.4.0, style-to-object@npm:^0.4.1": version: 0.4.2 resolution: "style-to-object@npm:0.4.2" dependencies: