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:
@@ -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=
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AiAssistantProvider,
|
||||
AnalyticsProvider,
|
||||
ColorModeProvider,
|
||||
MobileProvider,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=
|
||||
@@ -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"],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
www/apps/docs/src/types/index.d.ts
vendored
5
www/apps/docs/src/types/index.d.ts
vendored
@@ -180,6 +180,11 @@ declare module "@medusajs/docs" {
|
||||
analytics?: {
|
||||
apiKey: string
|
||||
}
|
||||
aiAssistant?: {
|
||||
apiUrl: string
|
||||
websiteId: string
|
||||
recaptchaSiteKey: string
|
||||
}
|
||||
} & DocusaurusThemeConfig
|
||||
|
||||
export declare type MedusaDocusaurusConfig = {
|
||||
|
||||
@@ -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=
|
||||
@@ -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: ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NavbarProvider,
|
||||
AnalyticsProvider,
|
||||
ScrollControllerProvider,
|
||||
AiAssistantProvider,
|
||||
} from "docs-ui"
|
||||
import SearchProvider from "./search"
|
||||
import SidebarProvider from "./sidebar"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"./.contentlayer/generated"
|
||||
]
|
||||
},
|
||||
"jsx": "preserve"
|
||||
"jsx": "preserve",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
441
www/packages/docs-ui/src/components/AiAssistant/index.tsx
Normal file
441
www/packages/docs-ui/src/components/AiAssistant/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
23
www/packages/docs-ui/src/components/Icons/ThumbUp/index.tsx
Normal file
23
www/packages/docs-ui/src/components/Icons/ThumbUp/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
www/packages/docs-ui/src/components/Icons/index.tsx
Normal file
3
www/packages/docs-ui/src/components/Icons/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./QuestionMark"
|
||||
export * from "./ThumbDown"
|
||||
export * from "./ThumbUp"
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
16
www/packages/docs-ui/src/components/Loading/Dots/index.tsx
Normal file
16
www/packages/docs-ui/src/components/Loading/Dots/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
26
www/packages/docs-ui/src/hooks/use-copy/index.tsx
Normal file
26
www/packages/docs-ui/src/hooks/use-copy/index.tsx
Normal 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 }
|
||||
}
|
||||
144
www/packages/docs-ui/src/hooks/use-search-navigation/index.ts
Normal file
144
www/packages/docs-ui/src/hooks/use-search-navigation/index.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
120
www/packages/docs-ui/src/providers/AiAssistant/index.tsx
Normal file
120
www/packages/docs-ui/src/providers/AiAssistant/index.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./AiAssistant"
|
||||
export * from "./Analytics"
|
||||
export * from "./ColorMode"
|
||||
export * from "./Mobile"
|
||||
|
||||
@@ -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") }
|
||||
)
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
121
www/yarn.lock
121
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:
|
||||
|
||||
Reference in New Issue
Block a user