docs: add AI Assistant (#5249)

* added components

* added ai assistant button

* change styling

* improve AI assistant

* change to a drawer

* added command support into search

* add AiAssistant to all projects

* remove usage of Text component

* added error handling

* use recaptcha

* fix new configurations

* fix background color

* change suggested questions
This commit is contained in:
Shahed Nasser
2023-10-05 11:10:44 +03:00
committed by GitHub
parent b6bea74914
commit b3f75d8f21
45 changed files with 1864 additions and 437 deletions

View File

@@ -8,4 +8,7 @@ NEXT_PUBLIC_ENV=
NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_DOCS_URL=
NEXT_PUBLIC_UI_URL=
ALGOLIA_WRITE_API_KEY=
ALGOLIA_WRITE_API_KEY=
NEXT_PUBLIC_AI_ASSISTANT_URL=
NEXT_PUBLIC_AI_WEBSITE_ID=
NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY=

View File

@@ -1,6 +1,7 @@
"use client"
import {
AiAssistantProvider,
AnalyticsProvider,
ColorModeProvider,
MobileProvider,

View File

@@ -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: <AiAssistantCommandIcon />,
component: (
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
/>
),
title: "AI Assistant",
badge: {
variant: "purple",
children: "Beta",
},
},
]}
>
{children}
</UiSearchProvider>

View File

@@ -2,4 +2,8 @@ API_URL=
DOCS_ALGOLIA_INDEX_NAME=
API_ALGOLIA_INDEX_NAME=
ALGOLIA_API_KEY=
ALGOLIA_APP_ID=
ALGOLIA_APP_ID=
AI_ASSISTANT_URL=
AI_API_ASSISTANT_TOKEN=
AI_WEBSITE_ID=
AI_API_ASSISTANT_RECAPTCHA_SITE_KEY=

View File

@@ -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"],

View File

@@ -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 (
<AnalyticsProvider writeKey={apiKey}>
<ModalProvider>
<SearchProvider>
<LearningPathProvider>
<NotificationProvider>{children}</NotificationProvider>
</LearningPathProvider>
</SearchProvider>
</ModalProvider>
<ColorModeProvider>
<ModalProvider>
<SearchProvider>
<LearningPathProvider>
<NotificationProvider>{children}</NotificationProvider>
</LearningPathProvider>
</SearchProvider>
</ModalProvider>
</ColorModeProvider>
</AnalyticsProvider>
)
}

View File

@@ -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<string[]>([])
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: <AiAssistantCommandIcon />,
component: (
<AiAssistantProvider
apiUrl={aiAssistant.apiUrl}
websiteId={aiAssistant.websiteId}
recaptchaSiteKey={aiAssistant.recaptchaSiteKey}
/>
),
title: "AI Assistant",
badge: {
variant: "purple",
children: "Beta",
},
},
]}
initialDefaultFilters={defaultFilters}
modalClassName="z-[500]"
>
{children}
</UiSearchProvider>

View File

@@ -180,6 +180,11 @@ declare module "@medusajs/docs" {
analytics?: {
apiKey: string
}
aiAssistant?: {
apiUrl: string
websiteId: string
recaptchaSiteKey: string
}
} & DocusaurusThemeConfig
export declare type MedusaDocusaurusConfig = {

View File

@@ -1,2 +1,11 @@
NEXT_PUBLIC_DOCS_URL=
NEXT_PUBLIC_BASE_URL=
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=

View File

@@ -97,12 +97,12 @@ const components = {
...props
}: React.HTMLAttributes<HTMLOListElement>) => {
return (
<ul
<ol
{...props}
className={clsx("list-decimal px-docs_1 mb-docs_1.5", className)}
>
{children}
</ul>
</ol>
)
},
li: ({

View File

@@ -7,6 +7,7 @@ import {
NavbarProvider,
AnalyticsProvider,
ScrollControllerProvider,
AiAssistantProvider,
} from "docs-ui"
import SearchProvider from "./search"
import SidebarProvider from "./sidebar"

View File

@@ -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: <AiAssistantCommandIcon />,
component: (
<AiAssistantProvider
apiUrl={process.env.NEXT_PUBLIC_AI_ASSISTANT_URL || "temp"}
websiteId={process.env.NEXT_PUBLIC_AI_WEBSITE_ID || "temp"}
recaptchaSiteKey={
process.env.NEXT_PUBLIC_AI_API_ASSISTANT_RECAPTCHA_SITE_KEY ||
"temp"
}
/>
),
title: "AI Assistant",
badge: {
variant: "purple",
children: "Beta",
},
},
]}
>
{children}
</UiSearchProvider>

View File

@@ -9,7 +9,8 @@
"./.contentlayer/generated"
]
},
"jsx": "preserve"
"jsx": "preserve",
"baseUrl": "."
},
"include": [
"next-env.d.ts",

View File

@@ -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"

View File

@@ -0,0 +1,24 @@
import { SparklesSolid } from "@medusajs/icons"
import clsx from "clsx"
import React from "react"
export type AiAssistantCommandIconProps =
React.AllHTMLAttributes<HTMLSpanElement>
export const AiAssistantCommandIcon = ({
className,
...props
}: AiAssistantCommandIconProps) => {
return (
<span
className={clsx(
"bg-button-inverted bg-medusa-button-inverted dark:bg-button-inverted-dark",
"rounded-md p-[2px] text-medusa-fg-on-inverted flex",
className
)}
{...props}
>
<SparklesSolid />
</span>
)
}

View File

@@ -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<AiAssistantFeedbackType | null>(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 (
<div
className={clsx(
"hidden md:flex gap-docs_0.25",
"text-medusa-fg-muted",
"sticky top-docs_1"
)}
>
<ActionButton onClick={handleCopy}>
{isCopied ? <Check /> : <SquareTwoStack />}
</ActionButton>
{(feedback === null || feedback === "upvote") && (
<ActionButton
onClick={async () => handleFeedback("upvote", item.question_id)}
className={clsx(feedback === "upvote" && "!text-medusa-fg-subtle")}
>
<ThumbUpIcon />
</ActionButton>
)}
{(feedback === null || feedback === "downvote") && (
<ActionButton
onClick={async () => handleFeedback("downvote", item.question_id)}
className={clsx(feedback === "downvote" && "!text-medusa-fg-subtle")}
>
<ThumbDownIcon />
</ActionButton>
)}
</div>
)
}
const ActionButton = ({ children, className, ...props }: ButtonProps) => {
return (
<Button
variant="clear"
className={clsx(
"text-medusa-fg-muted hover:text-medusa-fg-subtle",
"hover:bg-medusa-bg-subtle-hover",
"p-docs_0.125 rounded-docs_sm",
className
)}
{...props}
>
{children}
</Button>
)
}

View File

@@ -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 (
<div
className={clsx(
"p-docs_1 flex justify-start gap-docs_1 items-start",
"text-medusa-fg-subtle border-solid border-0 border-b",
"border-medusa-border-base text-medium relative",
item.type === "question" && "bg-medusa-bg-base",
item.type === "answer" && "bg-medusa-bg-subtle"
)}
>
<span
className={clsx(
"border border-solid border-medusa-border-base",
"rounded-docs_sm p-docs_0.125 bg-medusa-bg-component",
"text-medusa-fg-muted flex"
)}
>
{item.type === "question" && <QuestionMarkIcon />}
{item.type === "answer" && <Sparkles />}
{item.type === "error" && <ExclamationCircle />}
</span>
<div className="md:max-w-[calc(100%-134px)] md:w-[calc(100%-134px)]">
{item.type === "question" && <>{item.content}</>}
{item.type === "answer" && (
<>
{!item.question_id && item.content.length === 0 && <DotsLoading />}
<MarkdownContent>{item.content}</MarkdownContent>
</>
)}
{item.type === "error" && (
<span className="text-medusa-fg-error">{item.content}</span>
)}
</div>
{item.type === "answer" && item.question_id && (
<AiAssistantThreadItemActions item={item} />
)}
</div>
)
}

View File

@@ -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<ThreadType[]>([])
const [answer, setAnswer] = useState("")
const [identifiers, setIdentifiers] = useState<IdentifierType | null>(null)
const [loading, setLoading] = useState(false)
const { getAnswer } = useAiAssistant()
const { setCommand } = useSearch()
const inputRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(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) => (
<AiAssistantThreadItem item={item} key={index} />
))
}, [thread])
return (
<div className="h-full">
<div
className={clsx(
"flex gap-docs_1 px-docs_1 py-docs_0.75",
"h-[57px] w-full md:rounded-t-docs_xl relative border-0 border-solid",
"border-b border-medusa-border-base relative"
)}
>
<Button
variant="clear"
onClick={() => setCommand(null)}
className="text-medusa-fg-subtle p-[5px]"
>
<ArrowUturnLeft />
</Button>
<InputText
value={question}
onChange={(e) => 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}
/>
<Button
variant="clear"
onClick={() => {
setQuestion("")
inputRef.current?.focus()
}}
className={clsx(
"text-medusa-fg-subtle p-[5px]",
"absolute top-docs_0.75 right-docs_1",
"hover:bg-medusa-bg-base-hover rounded-docs_sm",
question.length === 0 && "hidden"
)}
>
<XMarkMini />
</Button>
</div>
<div className="h-[calc(100%-120px)] md:h-[calc(100%-114px)] lg:max-h-[calc(100%-114px)] lg:min-h-[calc(100%-114px)] overflow-auto">
<div ref={contentRef}>
{!thread.length && (
<div className="mx-docs_0.5">
{suggestions.map((suggestion, index) => (
<React.Fragment key={index}>
<SearchHitGroupName name={suggestion.title} />
{suggestion.items.map((item, itemIndex) => (
<SearchSuggestionItem
onClick={() => {
setQuestion(item)
handleSubmit(item)
}}
key={itemIndex}
tabIndex={itemIndex}
>
{item}
</SearchSuggestionItem>
))}
</React.Fragment>
))}
</div>
)}
{getThreadItems()}
{(answer.length || loading) && (
<AiAssistantThreadItem
item={{
type: "answer",
content: answer,
order: 0,
}}
/>
)}
</div>
</div>
<div
className={clsx(
"py-docs_0.75 flex items-center justify-between px-docs_1",
"border-0 border-solid",
"border-medusa-border-base border-t",
"bg-medusa-bg-base h-[57px]"
)}
>
<Tooltip
tooltipChildren={
<>
This site is protected by reCAPTCHA and the{" "}
<Link href="https://policies.google.com/privacy">
Google Privacy Policy
</Link>{" "}
and <Link href="https://policies.google.com/terms">ToS</Link>{" "}
apply
</>
}
>
<div
className={clsx(
"flex items-center gap-docs_0.75 text-compact-small-plus"
)}
>
<AiAssistantCommandIcon />
<span className="text-medusa-fg-subtle">Medusa AI Assistant</span>
<Badge variant="purple">Beta</Badge>
</div>
</Tooltip>
<div className="hidden items-center gap-docs_1 md:flex">
<div className="flex items-center gap-docs_0.5">
{thread.length === 0 && (
<>
<span
className={clsx(
"text-medusa-fg-subtle",
"text-compact-x-small"
)}
>
Navigate FAQ
</span>
<span className="gap-docs_0.25 flex">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
</>
)}
{thread.length > 0 && (
<span
className={clsx("text-medusa-fg-muted", "text-compact-x-small")}
>
Chat is cleared on exit
</span>
)}
</div>
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Ask Question
</span>
<Kbd></Kbd>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<button
className={clsx(
"inline-flex flex-row justify-center items-center gap-[6px]",
variant === "primary" && variantClasses.primary,
variant === "secondary" && variantClasses.secondary,
variant === "clear" && variantClasses.clear,

View File

@@ -22,6 +22,10 @@ export const CodeBlock = ({
}: CodeBlockProps) => {
const { colorMode } = useColorMode()
if (!source.length) {
return <></>
}
return (
<div
className={clsx(
@@ -54,8 +58,8 @@ export const CodeBlock = ({
<pre
style={{ ...style, fontStretch: "100%" }}
className={clsx(
"xs:max-w-[90%] relative !mt-0 break-words bg-transparent !outline-none",
"overflow-auto break-words rounded-docs_DEFAULT",
"xs:max-w-[90%] relative !my-0 break-words bg-transparent !outline-none",
"overflow-auto break-words rounded-docs_DEFAULT p-0",
preClassName
)}
>

View File

@@ -18,7 +18,16 @@ export const CodeMdx = ({ className, children }: CodeMdxProps) => {
const match = /language-(\w+)/.exec(className || "")
if (match) {
return <CodeBlock source={children as string} lang={match[1]} />
return (
<CodeBlock
source={
Array.isArray(children)
? (children[0] as string)
: (children as string)
}
lang={match[1]}
/>
)
}
return <InlineCode>{children}</InlineCode>

View File

@@ -1,9 +1,9 @@
"use client"
import React, { useState, useEffect, useRef, useCallback } from "react"
import copy from "copy-text-to-clipboard"
import React from "react"
import clsx from "clsx"
import { Tooltip } from "@/components/Tooltip"
import { Tooltip } from "@/components"
import { useCopy } from "../../hooks"
export type CopyButtonProps = {
text: string
@@ -18,18 +18,7 @@ export const CopyButton = ({
children,
className,
}: CopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false)
const copyTimeout = useRef<number>(0)
const handleCopy = useCallback(() => {
copy(text)
setIsCopied(true)
copyTimeout.current = window.setTimeout(() => {
setIsCopied(false)
}, 1000)
}, [text])
useEffect(() => () => window.clearTimeout(copyTimeout.current), [])
const { isCopied, handleCopy } = useCopy(text)
return (
<Tooltip

View File

@@ -0,0 +1,23 @@
import React from "react"
import { IconProps } from "@medusajs/icons/dist/types"
export const QuestionMarkIcon = (props: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M7.43951 5.51484C8.61051 4.48984 10.5105 4.48984 11.6815 5.51484C12.8535 6.53984 12.8535 8.20184 11.6815 9.22684C11.4785 9.40584 11.2515 9.55284 11.0115 9.66884C10.2665 10.0298 9.56151 10.6678 9.56151 11.4958V12.2458M9.56051 15.2458H9.56851V15.2538H9.56051V15.2458Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { IconProps } from "@medusajs/icons/dist/types"
import React from "react"
export const ThumbDownIcon = (props: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6.25001 12.5H8.12501M14.8117 4.375C14.8208 4.41667 14.835 4.45833 14.855 4.49833C15.3475 5.49833 15.625 6.62333 15.625 7.8125C15.6268 9.00847 15.3413 10.1874 14.7925 11.25M14.8117 4.375C14.7483 4.07083 14.9642 3.75 15.2908 3.75H16.0475C16.7883 3.75 17.475 4.18167 17.6908 4.89C17.9733 5.815 18.125 6.79583 18.125 7.8125C18.125 9.10667 17.8792 10.3425 17.4325 11.4775C17.1775 12.1225 16.5275 12.5 15.8333 12.5H14.9558C14.5625 12.5 14.335 12.0367 14.5392 11.7C14.6285 11.553 14.7136 11.4029 14.7925 11.25M14.8117 4.375H13.7333C13.3303 4.37525 12.9299 4.31053 12.5475 4.18333L9.95251 3.31667C9.5701 3.18947 9.16969 3.12475 8.76668 3.125H5.42001C4.90501 3.125 4.40584 3.33083 4.08251 3.7325C2.6508 5.50731 1.87157 7.71971 1.87501 10C1.87501 10.3617 1.89418 10.7192 1.93168 11.0708C2.02251 11.9217 2.78834 12.5 3.64334 12.5H6.24835C6.76335 12.5 7.07418 13.1033 6.85251 13.5683C6.45441 14.4062 6.24856 15.3224 6.25001 16.25C6.25001 16.7473 6.44756 17.2242 6.79919 17.5758C7.15082 17.9275 7.62773 18.125 8.12501 18.125C8.29077 18.125 8.44974 18.0592 8.56695 17.9419C8.68416 17.8247 8.75001 17.6658 8.75001 17.5V16.9725C8.75001 16.495 8.84168 16.0225 9.01834 15.5792C9.27168 14.9458 9.79335 14.4708 10.3958 14.15C11.3208 13.6564 12.1326 12.9752 12.7792 12.15C13.1942 11.6217 13.8008 11.25 14.4725 11.25H14.7925"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { IconProps } from "@medusajs/icons/dist/types"
import React from "react"
export const ThumbUpIcon = (props: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5.5275 8.75C6.19917 8.75 6.805 8.37833 7.22 7.85C7.86688 7.02468 8.67893 6.34349 9.60417 5.85C10.2067 5.53 10.7292 5.05333 10.9817 4.42083C11.159 3.97771 11.2501 3.50479 11.25 3.0275V2.5C11.25 2.33424 11.3159 2.17527 11.4331 2.05806C11.5503 1.94085 11.7092 1.875 11.875 1.875C12.3723 1.875 12.8492 2.07254 13.2008 2.42417C13.5525 2.77581 13.75 3.25272 13.75 3.75C13.75 4.71 13.5333 5.61917 13.1475 6.43167C12.9258 6.89667 13.2367 7.5 13.7517 7.5H16.3567C17.2117 7.5 17.9775 8.07833 18.0683 8.92917C18.1058 9.28083 18.125 9.6375 18.125 10C18.1284 12.2803 17.3492 14.4927 15.9175 16.2675C15.5942 16.6692 15.095 16.875 14.58 16.875H11.2333C10.8308 16.875 10.43 16.81 10.0475 16.6833L7.4525 15.8167C7.07009 15.6895 6.66968 15.6248 6.26667 15.625H4.92M4.92 15.625C4.98917 15.7958 5.06417 15.9625 5.145 16.1267C5.30917 16.46 5.08 16.875 4.70917 16.875H3.9525C3.21167 16.875 2.525 16.4433 2.30917 15.735C2.02054 14.7877 1.87422 13.8028 1.875 12.8125C1.875 11.5183 2.12084 10.2825 2.5675 9.1475C2.8225 8.5025 3.4725 8.125 4.16667 8.125H5.04417C5.4375 8.125 5.665 8.58833 5.46084 8.925C4.74908 10.0966 4.37369 11.4416 4.37584 12.8125C4.37584 13.8075 4.56917 14.7567 4.92084 15.625H4.92ZM11.875 7.5H13.75"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -0,0 +1,3 @@
export * from "./QuestionMark"
export * from "./ThumbDown"
export * from "./ThumbUp"

View File

@@ -4,6 +4,7 @@ import clsx from "clsx"
export type InputTextProps = {
className?: string
addGroupStyling?: boolean
passedRef?: React.Ref<HTMLInputElement>
} & React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
@@ -12,6 +13,7 @@ export type InputTextProps = {
export const InputText = ({
addGroupStyling = false,
className,
passedRef,
...props
}: InputTextProps) => {
return (
@@ -32,6 +34,7 @@ export const InputText = ({
"text-compact-medium font-base",
className
)}
ref={passedRef}
/>
)
}

View File

@@ -0,0 +1,16 @@
import clsx from "clsx"
import React from "react"
export type DotsLoadingProps = {
className?: string
}
export const DotsLoading = ({ className }: DotsLoadingProps) => {
return (
<span className={clsx("text-medium text-medusa-fg-subtle", className)}>
<span className="animate-pulsingDots">.</span>
<span className="animate-pulsingDots animation-delay-[500ms]">.</span>
<span className="animate-pulsingDots animation-delay-[1000ms]">.</span>
</span>
)
}

View File

@@ -0,0 +1,86 @@
import React from "react"
import ReactMarkdown from "react-markdown"
import { ReactMarkdownOptions } from "react-markdown/lib/react-markdown"
import { CodeMdx, Details, Kbd, Link } from "@/components"
import clsx from "clsx"
export type MarkdownContentProps = ReactMarkdownOptions
export const MarkdownContent = ({ children }: MarkdownContentProps) => {
return (
<ReactMarkdown
components={{
code: CodeMdx,
pre: ({
className,
children,
...props
}: React.HTMLAttributes<HTMLPreElement>) => {
return (
<pre className={clsx("p-0 bg-transparent", className)} {...props}>
{children}
</pre>
)
},
kbd: Kbd,
details: Details,
a: Link,
ul: ({
className,
children,
...props
}: React.HTMLAttributes<HTMLUListElement>) => {
return (
<ul
{...props}
className={clsx("list-disc px-docs_1 mb-docs_1.5", className)}
>
{children}
</ul>
)
},
ol: ({
className,
children,
...props
}: React.HTMLAttributes<HTMLOListElement>) => {
return (
<ol
{...props}
className={clsx("list-decimal px-docs_1 mb-docs_1.5", className)}
>
{children}
</ol>
)
},
li: ({
className,
children,
...props
}: React.HTMLAttributes<HTMLLIElement>) => {
return (
<li className={clsx("text-medusa-fg-subtle", className)} {...props}>
<span>{children}</span>
</li>
)
},
p: ({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) => {
return (
<p
className={clsx(
"text-medusa-fg-subtle [&:not(:last-child)]:mb-docs_1.5 last:!mb-0",
className
)}
{...props}
/>
)
},
}}
>
{children}
</ReactMarkdown>
)
}

View File

@@ -5,7 +5,7 @@ import { Button } from "@/components"
import { XMark } from "@medusajs/icons"
export type ModalHeaderProps = {
title?: string
title?: React.ReactNode
}
export const ModalHeader = ({ title }: ModalHeaderProps) => {

View File

@@ -11,7 +11,7 @@ import { Ref } from "@/types"
export type ModalProps = {
className?: string
title?: string
title?: React.ReactNode
actions?: ButtonProps[]
modalContainerClassName?: string
contentClassName?: string
@@ -19,7 +19,8 @@ export type ModalProps = {
open?: boolean
footerContent?: React.ReactNode
passedRef?: Ref<HTMLDialogElement>
} & Omit<React.ComponentProps<"dialog">, "ref">
headerClassName?: string
} & Omit<React.ComponentProps<"dialog">, "ref" | "title">
export const Modal = ({
className,

View File

@@ -1,331 +0,0 @@
"use client"
import React, { useCallback, 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 { findNextSibling, findPrevSibling } from "@/utils"
import { Button, Kbd, Modal, SelectBadge } from "@/components"
import { MagnifyingGlass, XMark } from "@medusajs/icons"
import { useKeyboardShortcut, type OptionType } from "@/hooks"
export type SearchModalProps = {
algolia: AlgoliaProps
isLoading?: boolean
suggestions: SearchSuggestionType[]
checkInternalPattern?: RegExp
filterOptions?: OptionType[]
className?: string
}
export const SearchModal = ({
algolia,
suggestions,
isLoading = false,
checkInternalPattern,
filterOptions = [],
className,
}: SearchModalProps) => {
const modalRef = useRef<HTMLDialogElement | null>(null)
const { isOpen, setIsOpen, defaultFilters, searchClient } = useSearch()
const [filters, setFilters] = useState<string[]>(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<HTMLFormElement>(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])
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()
}
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
focusSearchInput()
} 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()
}
}
}
}
const shortcutKeys = useMemo(() => ["ArrowUp", "ArrowDown", "Enter"], [])
useKeyboardShortcut({
metakey: false,
shortcutKeys,
action: handleKeyAction,
checkEditing: false,
preventDefault: false,
isLoading,
})
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 searchInput = searchBoxRef.current?.querySelector(
"input"
) as HTMLInputElement
if (searchInput && focusedItem !== searchInput) {
searchInput.focus()
}
},
[shortcutKeys, isOpen]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
}, [handleKeyDown])
return (
<Modal
contentClassName={clsx(
"!p-0 overflow-hidden relative h-full",
"rounded-none md:rounded-docs_lg flex flex-col justify-between"
)}
modalContainerClassName="w-screen h-screen !rounded-none md:!rounded-docs_lg"
open={isOpen}
onClose={() => setIsOpen(false)}
passedRef={modalRef}
className={className}
>
<InstantSearch
indexName={algolia.mainIndexName}
searchClient={searchClient}
>
<div className={clsx("bg-medusa-bg-base flex")}>
<SearchBox
classNames={{
root: clsx(
"h-[56px] w-full md:rounded-t-docs_xl relative border-0 border-solid",
"border-b border-medusa-border-base",
"bg-transparent"
),
form: clsx("h-full md:rounded-t-docs_xl bg-transparent"),
input: clsx(
"w-full h-full pl-docs_3 text-medusa-fg-base",
"placeholder:text-medusa-fg-muted",
"md:rounded-t-docs_xl text-compact-medium bg-transparent",
"appearance-none search-cancel:hidden border-0 active:outline-none focus:outline-none"
),
submit: clsx("absolute top-[18px] left-docs_1 btn-clear p-0"),
reset: clsx(
"absolute top-docs_0.75 right-docs_1 hover:bg-medusa-bg-base-hover",
"p-[5px] md:rounded-docs_DEFAULT btn-clear"
),
loadingIndicator: clsx("absolute top-[18px] right-docs_1"),
}}
submitIconComponent={() => (
<MagnifyingGlass className="text-medusa-fg-muted" />
)}
resetIconComponent={() => (
<XMark className="hidden md:block text-medusa-fg-subtle" />
)}
placeholder="Find something..."
autoFocus
formRef={searchBoxRef}
/>
<Button
variant="clear"
className={clsx(
"bg-medusa-bg-base block md:hidden",
"border-0 border-solid",
"border-medusa-border-base border-b",
"pr-docs_1"
)}
onClick={() => setIsOpen(false)}
>
<XMark className="text-medusa-fg-muted" />
</Button>
</div>
<div className="mx-docs_0.5 h-[calc(100%-120px)] md:h-[332px] md:flex-initial lg:max-h-[332px] lg:min-h-[332px]">
<SearchEmptyQueryBoundary
fallback={<SearchSuggestions suggestions={suggestions} />}
>
<SearchHitsWrapper
configureProps={{
filters: formattedFilters,
attributesToSnippet: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
attributesToHighlight: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
}}
indices={algolia.indices}
checkInternalPattern={checkInternalPattern}
/>
</SearchEmptyQueryBoundary>
</div>
</InstantSearch>
<div
className={clsx(
"py-docs_0.75 flex items-center justify-between px-docs_1",
"border-0 border-solid",
"border-medusa-border-base border-t",
"bg-medusa-bg-base"
)}
>
{filterOptions.length && (
<SelectBadge
multiple
options={filterOptions}
value={filters}
setSelected={(value) =>
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))
}
}}
/>
)}
<div className="hidden items-center gap-docs_1 md:flex">
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Navigation
</span>
<span className="gap-docs_0.25 flex">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
</div>
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Open Result
</span>
<Kbd></Kbd>
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,32 @@
import clsx from "clsx"
import React from "react"
export type SearchSuggestionItemType = {
onClick: () => void
} & React.AllHTMLAttributes<HTMLDivElement>
export const SearchSuggestionItem = ({
children,
onClick,
className,
...rest
}: SearchSuggestionItemType) => {
return (
<div
className={clsx(
"flex items-center",
"cursor-pointer rounded-docs_sm p-docs_0.5",
"hover:bg-medusa-bg-base-hover",
"focus:bg-medusa-bg-base-hover",
"focus:outline-none last:mb-docs_1",
"text-medusa-fg-base text-compact-small",
className
)}
onClick={onClick}
data-hit
{...rest}
>
{children}
</div>
)
}

View File

@@ -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 (
<div className="h-full overflow-auto">
{commands.length > 0 && (
<>
<SearchHitGroupName name={"Commands"} />
{commands.map((command, index) => (
<SearchSuggestionItem
onClick={() => setCommand(command)}
key={index}
tabIndex={index}
className="gap-docs_0.75"
>
<>
{command.icon}
<span>{command.title}</span>
{command.badge && <Badge {...command.badge} />}
</>
</SearchSuggestionItem>
))}
</>
)}
{suggestions.map((suggestion, index) => (
<React.Fragment key={index}>
<SearchHitGroupName name={suggestion.title} />
{suggestion.items.map((item, itemIndex) => (
<div
className={clsx(
"flex items-center justify-between",
"cursor-pointer rounded-docs_sm p-docs_0.5",
"hover:bg-medusa-bg-base-hover",
"focus:bg-medusa-bg-base-hover",
"focus:outline-none last:mb-docs_1"
)}
<SearchSuggestionItem
onClick={() =>
setIndexUiState({
query: item,
})
}
key={itemIndex}
tabIndex={itemIndex}
data-hit
tabIndex={commands.length + itemIndex}
>
<span
className={clsx("text-medusa-fg-base", "text-compact-small")}
>
{item}
</span>
</div>
{item}
</SearchSuggestionItem>
))}
</React.Fragment>
))}

View File

@@ -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<string[]>(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<HTMLFormElement>(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 (
<div className="h-full">
<InstantSearch
indexName={algolia.mainIndexName}
searchClient={searchClient}
>
<div className={clsx("bg-medusa-bg-base flex")}>
<SearchBox
classNames={{
root: clsx(
"h-[57px] w-full md:rounded-t-docs_xl relative border-0 border-solid",
"border-b border-medusa-border-base",
"bg-transparent"
),
form: clsx("h-full md:rounded-t-docs_xl bg-transparent"),
input: clsx(
"w-full h-full pl-docs_3 text-medusa-fg-base",
"placeholder:text-medusa-fg-muted",
"md:rounded-t-docs_xl text-compact-medium bg-transparent",
"appearance-none search-cancel:hidden border-0 active:outline-none focus:outline-none"
),
submit: clsx("absolute top-[18px] left-docs_1 btn-clear p-0"),
reset: clsx(
"absolute top-docs_0.75 right-docs_1 hover:bg-medusa-bg-base-hover",
"p-[5px] md:rounded-docs_DEFAULT btn-clear"
),
loadingIndicator: clsx("absolute top-[18px] right-docs_1"),
}}
submitIconComponent={() => (
<MagnifyingGlass className="text-medusa-fg-muted" />
)}
resetIconComponent={() => (
<XMark className="hidden md:block text-medusa-fg-subtle" />
)}
placeholder="Find something..."
autoFocus
formRef={searchBoxRef}
/>
<Button
variant="clear"
className={clsx(
"bg-medusa-bg-base block md:hidden",
"border-0 border-solid",
"border-medusa-border-base border-b",
"pr-docs_1"
)}
onClick={() => setIsOpen(false)}
>
<XMark className="text-medusa-fg-muted" />
</Button>
</div>
<div className="mx-docs_0.5 md:flex-initial h-[calc(100%-120px)] md:h-[calc(100%-114px)] lg:max-h-[calc(100%-114px)] lg:min-h-[calc(100%-114px)]">
<SearchEmptyQueryBoundary
fallback={<SearchSuggestions suggestions={suggestions} />}
>
<SearchHitsWrapper
configureProps={{
filters: formattedFilters,
attributesToSnippet: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
attributesToHighlight: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
}}
indices={algolia.indices}
checkInternalPattern={checkInternalPattern}
/>
</SearchEmptyQueryBoundary>
</div>
</InstantSearch>
<div
className={clsx(
"py-docs_0.75 flex items-center justify-between px-docs_1",
"border-0 border-solid h-[57px]",
"border-medusa-border-base border-t",
"bg-medusa-bg-base"
)}
>
{filterOptions.length && (
<SelectBadge
multiple
options={filterOptions}
value={filters}
setSelected={(value) =>
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))
}
}}
/>
)}
<div className="hidden items-center gap-docs_1 md:flex">
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Navigation
</span>
<span className="gap-docs_0.25 flex">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
</div>
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Open Result
</span>
<Kbd></Kbd>
</div>
</div>
</div>
</div>
)
}

View File

@@ -77,7 +77,7 @@ export const SelectBadge = ({
<div className={clsx("relative", className)}>
<div
className={clsx(
"border-medusa-border-base rounded-sm border border-solid",
"border-medusa-border-base rounded-docs_sm border border-solid",
"hover:bg-medusa-bg-subtle-hover",
"py-docs_0.25 h-fit cursor-pointer px-docs_0.5",
"flex items-center gap-[6px] whitespace-nowrap",

View File

@@ -1,3 +1,5 @@
export * from "./AiAssistant"
export * from "./AiAssistant/CommandIcon"
export * from "./Badge"
export * from "./Bordered"
export * from "./BorderedIcon"
@@ -11,13 +13,16 @@ export * from "./Details"
export * from "./Details/Summary"
export * from "./Feedback"
export * from "./Feedback/Solutions"
export * from "./Icons"
export * from "./InlineCode"
export * from "./Input/Text"
export * from "./Kbd"
export * from "./Label"
export * from "./Link"
export * from "./Loading"
export * from "./Loading/Dots"
export * from "./Loading/Spinner"
export * from "./MarkdownContent"
export * from "./Modal"
export * from "./Modal/Header"
export * from "./Modal/Footer"
@@ -35,6 +40,13 @@ export * from "./Notification/Item"
export * from "./Notification/Item/Layout/Default"
export * from "./Rating"
export * from "./Search"
export * from "./Search/EmptyQueryBoundary"
export * from "./Search/Hits"
export * from "./Search/Hits/GroupName"
export * from "./Search/ModalOpener"
export * from "./Search/NoResults"
export * from "./Search/Suggestions"
export * from "./Search/Suggestions/Item"
export * from "./Select"
export * from "./Sidebar"
export * from "./Sidebar/Item"

View File

@@ -1,4 +1,6 @@
export * from "./use-copy"
export * from "./use-keyboard-shortcut"
export * from "./use-scroll-utils"
export * from "./use-search-navigation"
export * from "./use-select"
export * from "./use-tabs"

View File

@@ -0,0 +1,26 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import copy from "copy-text-to-clipboard"
type useCopyReturnType = {
isCopied: boolean
handleCopy: () => void
}
export const useCopy = (text: string): useCopyReturnType => {
const [isCopied, setIsCopied] = useState(false)
const copyTimeout = useRef<number>(0)
const handleCopy = useCallback(() => {
copy(text)
setIsCopied(true)
copyTimeout.current = window.setTimeout(() => {
setIsCopied(false)
}, 1000)
}, [text])
useEffect(() => () => window.clearTimeout(copyTimeout.current), [])
return { isCopied, handleCopy }
}

View File

@@ -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<useKeyboardShortcutOptions>
}
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,
})
}

View File

@@ -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<Response>
sendFeedback: (
questionId: string,
reaction: AiAssistantFeedbackType
) => Promise<Response>
}
const AiAssistantContext = createContext<AiAssistantContextType | null>(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<ReCAPTCHA>()
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 (
<AiAssistantContext.Provider
value={{
getAnswer,
sendFeedback,
}}
>
{children}
<AiAssistant />
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={recaptchaSiteKey}
onErrored={() =>
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"
/>
</AiAssistantContext.Provider>
)
}
export const useAiAssistant = () => {
const context = useContext(AiAssistantContext)
if (!context) {
throw new Error("useAiAssistant must be used within a AiAssistantProvider")
}
return context
}

View File

@@ -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<React.SetStateAction<SearchCommand | null>>
modalRef: React.MutableRefObject<HTMLDialogElement | null>
}
const SearchContext = createContext<SearchContextType | null>(null)
@@ -32,7 +47,9 @@ export type SearchProviderProps = {
children: React.ReactNode
initialDefaultFilters?: string[]
algolia: AlgoliaProps
searchProps: Omit<SearchModalProps, "algolia">
searchProps: Omit<SearchProps, "algolia">
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<string[]>(
initialDefaultFilters
)
const [command, setCommand] = useState<SearchCommand | null>(null)
const modalRef = useRef<HTMLDialogElement | null>(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}
<SearchModal {...searchProps} algolia={algolia} />
<Modal
contentClassName={clsx(
"!p-0 overflow-hidden relative h-full",
"rounded-none md:rounded-docs_lg flex flex-col justify-between"
)}
modalContainerClassName={clsx(
"!rounded-none md:!rounded-docs_lg",
"md:!h-[480px] h-screen",
"md:!w-[640px] w-screen",
"bg-medusa-bg-base"
)}
open={isOpen}
onClose={() => setIsOpen(false)}
passedRef={modalRef}
className={modalClassName}
>
<SwitchTransition>
<CSSTransition
classNames={{
enter:
command === null
? "animate-fadeInLeft animate-fast"
: "animate-fadeInRight animate-fast",
exit:
command === null
? "animate-fadeOutLeft animate-fast"
: "animate-fadeOutRight animate-fast",
}}
timeout={300}
key={command?.name || "search"}
>
<>
{command === null && (
<Search {...searchProps} algolia={algolia} />
)}
{command?.component}
</>
</CSSTransition>
</SwitchTransition>
</Modal>
</SearchContext.Provider>
)
}

View File

@@ -1,3 +1,4 @@
export * from "./AiAssistant"
export * from "./Analytics"
export * from "./ColorMode"
export * from "./Mobile"

View File

@@ -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") }
)
}
),
],
}

View File

@@ -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: