diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/__tests__/index.test.tsx
new file mode 100644
index 0000000000..a74277cfbd
--- /dev/null
+++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/__tests__/index.test.tsx
@@ -0,0 +1,119 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { cleanup, fireEvent, render } from "@testing-library/react"
+import * as AiAssistantMocks from "../../../__mocks__"
+
+// mock functions
+const mockUseMedusaSuggestions = vi.fn((options) => null as unknown)
+const mockTrack = vi.fn()
+
+// mock components and hooks
+vi.mock("@/providers/AiAssistant", () => ({
+ useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(),
+}))
+vi.mock("@kapaai/react-sdk", () => ({
+ useChat: () => AiAssistantMocks.mockUseChat(),
+}))
+vi.mock("@/hooks/use-medusa-suggestions", () => ({
+ useMedusaSuggestions: (options: unknown) => mockUseMedusaSuggestions(options),
+}))
+vi.mock("@/components/Card", () => ({
+ Card: (props: { title: string, onClick: () => void }) => (
+
{props.title}
+ ),
+}))
+vi.mock("@/providers/Analytics", () => ({
+ useAnalytics: () => ({
+ track: mockTrack,
+ }),
+}))
+
+import { AiAssistantChatWindowCallout } from "../index"
+import { DocsTrackingEvents } from "../../../../../constants"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ cleanup()
+})
+
+describe("render", () => {
+ test("should not render when there is no matched suggestion", () => {
+ mockUseMedusaSuggestions.mockReturnValueOnce(null)
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test("should render when there is a matched suggestion", () => {
+ const mockCardProps = {
+ title: "Test Card",
+ text: "This is a test card.",
+ href: "https://example.com",
+ icon: () => Icon
,
+ }
+ mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
+
+ const { getByTestId } = render()
+
+ expect(getByTestId("card")).toBeInTheDocument()
+ expect(getByTestId("card")).toHaveTextContent("Test Card")
+ })
+
+ test("should not render when loading is true", () => {
+ AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({
+ ...AiAssistantMocks.defaultUseAiAssistantReturn,
+ loading: true,
+ })
+
+ mockUseMedusaSuggestions.mockReturnValueOnce({
+ title: "Test Card",
+ text: "This is a test card.",
+ href: "https://example.com",
+ icon: () => Icon
,
+ })
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test("should pass correct keywords to useMedusaSuggestions", () => {
+ render()
+
+ expect(mockUseMedusaSuggestions).toHaveBeenCalledWith({
+ keywords: AiAssistantMocks.mockConversation.getLatestCompleted()?.question || "",
+ })
+ })
+})
+
+describe("interactions", () => {
+ test("should track event on card click", () => {
+ const mockCardProps = {
+ title: "Test Card",
+ text: "This is a test card.",
+ href: "https://example.com",
+ icon: () => Icon
,
+ }
+ mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
+
+ const { getByTestId } = render()
+
+ const cardElement = getByTestId("card")
+ expect(cardElement).toBeInTheDocument()
+
+ // Simulate click
+ fireEvent.click(cardElement!)
+
+ expect(mockTrack).toHaveBeenCalledWith({
+ event: {
+ event: DocsTrackingEvents.AI_ASSISTANT_CALLOUT_CLICK,
+ options: {
+ user_keywords: AiAssistantMocks.mockConversation.getLatestCompleted()?.question || "",
+ callout_title: mockCardProps.title,
+ callout_href: mockCardProps.href,
+ },
+ },
+ })
+ })
+})
\ No newline at end of file
diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/index.tsx
new file mode 100644
index 0000000000..ac14544d5d
--- /dev/null
+++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/index.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import React from "react"
+import { Card } from "../../../Card"
+import { useChat } from "@kapaai/react-sdk"
+import { useAiAssistant } from "../../../../providers/AiAssistant"
+import { useMedusaSuggestions } from "../../../../hooks/use-medusa-suggestions"
+import { useAnalytics } from "../../../../providers/Analytics"
+import { DocsTrackingEvents } from "../../../../constants"
+
+export const AiAssistantChatWindowCallout = () => {
+ const { conversation } = useChat()
+ const { loading } = useAiAssistant()
+ const { track } = useAnalytics()
+
+ const lastQuestion = conversation.getLatestCompleted()?.question
+
+ const matchedCallout = useMedusaSuggestions({
+ keywords: lastQuestion || "",
+ })
+
+ if (loading || !matchedCallout) {
+ return null
+ }
+
+ return (
+
+ {
+ track({
+ event: {
+ event: DocsTrackingEvents.AI_ASSISTANT_CALLOUT_CLICK,
+ options: {
+ user_keywords: lastQuestion || "",
+ callout_title: matchedCallout.title || "",
+ callout_href: matchedCallout.href || "",
+ },
+ },
+ })
+ }}
+ />
+
+ )
+}
diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx
index 9174924fc1..0743f05f10 100644
--- a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx
+++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx
@@ -52,6 +52,9 @@ vi.mock("@/components/AiAssistant/ThreadItem", () => ({
ThreadItem - type: {item.type}
),
}))
+vi.mock("@/components/AiAssistant/ChatWindow/Callout", () => ({
+ AiAssistantChatWindowCallout: () => Callout
,
+}))
import { AiAssistantChatWindow } from "../../ChatWindow"
@@ -81,6 +84,7 @@ describe("rendering", () => {
expect(container).toHaveTextContent("Header")
expect(container).toHaveTextContent("Input")
expect(container).toHaveTextContent("Footer")
+ expect(container).toHaveTextContent("Callout")
})
test("chat is hidden when chatOpened is false", () => {
diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx
index 62405a9487..2ca82dfe26 100644
--- a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx
+++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx
@@ -17,6 +17,7 @@ import { AiAssistantChatWindowInput } from "./Input"
import { useKeyboardShortcut } from "../../../hooks/use-keyboard-shortcut"
import { AiAssistantChatWindowFooter } from "./Footer"
import { useChat } from "@kapaai/react-sdk"
+import { AiAssistantChatWindowCallout } from "./Callout"
const DEFAULT_HEIGHT = "calc(100% - 8px)"
@@ -204,6 +205,7 @@ export const AiAssistantChatWindow = () => {
)}
>
+
diff --git a/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx
index a4612b8ea6..b72ef6b393 100644
--- a/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx
+++ b/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx
@@ -46,6 +46,7 @@ const createMockConversation = () => {
}
},
getLatest: mockGetLatest,
+ getLatestCompleted: mockGetLatest,
map(callback: (item: unknown, index: number) => React.ReactNode) {
return conversationItems.map(callback)
},
diff --git a/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx
index 13226e67c1..7383976a32 100644
--- a/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx
+++ b/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx
@@ -1,6 +1,6 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
-import { render } from "@testing-library/react"
+import { fireEvent, render } from "@testing-library/react"
import { CardDefaultLayout } from "../index"
import { IconProps } from "@medusajs/icons/dist/types"
import { LinkProps } from "../../../../Link"
@@ -108,6 +108,8 @@ describe("rendering", () => {
const linkElement = container.querySelector("a")
expect(linkElement).toBeInTheDocument()
expect(linkElement).toHaveAttribute("href", href)
+ expect(linkElement).toHaveAttribute("target", "_blank")
+ expect(linkElement).toHaveAttribute("rel", "noopener noreferrer")
const arrowUpRightOnBoxElement = container.querySelector(
"[data-testid='external-icon']"
)
@@ -126,6 +128,8 @@ describe("rendering", () => {
const linkElement = container.querySelector("a")
expect(linkElement).toBeInTheDocument()
expect(linkElement).toHaveAttribute("href", href)
+ expect(linkElement).not.toHaveAttribute("target")
+ expect(linkElement).not.toHaveAttribute("rel")
const internalIconElement = container.querySelector(
"[data-testid='internal-icon']"
)
@@ -213,3 +217,17 @@ describe("highlight text", () => {
expect(highlightTextElement).not.toBeInTheDocument()
})
})
+
+describe("interaction", () => {
+ test("onClick is called when card's link is clicked", () => {
+ const handleClick = vi.fn()
+ const { container } = render(
+ Click me
+ )
+ expect(container).toBeInTheDocument()
+ const link = container.querySelector("a")
+ expect(link).toBeInTheDocument()
+ fireEvent.click(link!)
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+})
\ No newline at end of file
diff --git a/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx b/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx
index 147f0d6e79..47b8e9263c 100644
--- a/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx
+++ b/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx
@@ -20,6 +20,7 @@ export const CardDefaultLayout = ({
badge,
rightIcon: RightIconComponent,
highlightText = [],
+ onClick,
}: CardProps) => {
const isExternal = useIsExternalLink({ href })
@@ -113,6 +114,10 @@ export const CardDefaultLayout = ({
href={href}
className="absolute left-0 top-0 h-full w-full rounded"
prefetch={false}
+ rel={isExternal ? "noopener noreferrer" : undefined}
+ target={isExternal ? "_blank" : undefined}
+ aria-label={title}
+ onClick={onClick}
/>
)}
diff --git a/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx
index 1fa4afe2b2..b4a9f40c09 100644
--- a/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx
+++ b/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx
@@ -1,8 +1,44 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
-import { render } from "@testing-library/react"
+import { fireEvent, render } from "@testing-library/react"
import { CardLargeLayout } from "../index"
+// mock components
+vi.mock("next/link", () => ({
+ default: ({
+ href,
+ children,
+ className,
+ target,
+ rel,
+ onClick,
+ "aria-label": ariaLabel,
+ }: {
+ href: string
+ children: React.ReactNode
+ className?: string
+ target?: string
+ rel?: string
+ onClick?: () => void
+ "aria-label"?: string
+ }) => (
+ {
+ // can't perform actual navigation in tests
+ e.preventDefault()
+ onClick?.()
+ }}
+ aria-label={ariaLabel}
+ >
+ {children}
+
+ ),
+}))
+
describe("rendering", () => {
test("renders card large layout with title", () => {
const title = "Title"
@@ -33,6 +69,8 @@ describe("rendering", () => {
const linkElement = container.querySelector("a")
expect(linkElement).toBeInTheDocument()
expect(linkElement).toHaveAttribute("href", href)
+ expect(linkElement).toHaveAttribute("rel", "noopener noreferrer")
+ expect(linkElement).toHaveAttribute("target", "_blank")
const arrowUpRightOnBoxElement = container.querySelector(
"[data-testid='external-icon']"
)
@@ -51,6 +89,8 @@ describe("rendering", () => {
const linkElement = container.querySelector("a")
expect(linkElement).toBeInTheDocument()
expect(linkElement).toHaveAttribute("href", href)
+ expect(linkElement).not.toHaveAttribute("target")
+ expect(linkElement).not.toHaveAttribute("rel")
const internalIconElement = container.querySelector(
"[data-testid='internal-icon']"
)
@@ -79,3 +119,17 @@ describe("rendering", () => {
expect(imageElement).toBeInTheDocument()
})
})
+
+describe("interaction", () => {
+ test("calls onClick when card with link is clicked", () => {
+ const handleClick = vi.fn()
+ const { container } = render(
+ Click me
+ )
+ expect(container).toBeInTheDocument()
+ const linkElement = container.querySelector("a")
+ expect(linkElement).toBeInTheDocument()
+ fireEvent.click(linkElement!)
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+})
\ No newline at end of file
diff --git a/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx b/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx
index d935fd51ed..5ffd193239 100644
--- a/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx
+++ b/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx
@@ -12,6 +12,7 @@ export const CardLargeLayout = ({
icon,
href,
className,
+ onClick,
}: CardProps) => {
const isExternal = useIsExternalLink({ href })
const IconComponent = icon
@@ -76,6 +77,10 @@ export const CardLargeLayout = ({
href={href}
className="absolute left-0 top-0 h-full w-full rounded"
prefetch={false}
+ rel={isExternal ? "noopener noreferrer" : undefined}
+ target={isExternal ? "_blank" : undefined}
+ aria-label={title}
+ onClick={onClick}
/>
)}
diff --git a/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx
index 878dd0f1c3..bce8a15bb9 100644
--- a/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx
+++ b/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx
@@ -1,8 +1,7 @@
import React from "react"
-import { describe, expect, test, vi } from "vitest"
+import { describe, expect, test, vi } from "vitest"
import { fireEvent, render } from "@testing-library/react"
import { BorderedIconProps } from "../../../../BorderedIcon"
-import { LinkProps } from "../../../../Link"
// mock data
const exampleDataImageUrl =
@@ -16,8 +15,39 @@ vi.mock("@/components/BorderedIcon", () => ({
),
}))
-vi.mock("@/components/Link", () => ({
- Link: (props: LinkProps) => ,
+vi.mock("next/link", () => ({
+ default: ({
+ href,
+ children,
+ className,
+ target,
+ rel,
+ onClick,
+ "aria-label": ariaLabel,
+ }: {
+ href: string
+ children: React.ReactNode
+ className?: string
+ target?: string
+ rel?: string
+ onClick?: () => void
+ "aria-label"?: string
+ }) => (
+ {
+ // can't perform actual navigation in tests
+ e.preventDefault()
+ onClick?.()
+ }}
+ aria-label={ariaLabel}
+ >
+ {children}
+
+ ),
}))
vi.mock("@/components/ThemeImage", () => ({
ThemeImage: (props: ThemeImageProps) => (
@@ -26,7 +56,32 @@ vi.mock("@/components/ThemeImage", () => ({
),
}))
-
+vi.mock("next/image", () => ({
+ default: ({
+ src,
+ alt,
+ width,
+ height,
+ className,
+ style,
+ }: {
+ src: string
+ alt?: string
+ width?: number
+ height?: number
+ className?: string
+ style?: React.CSSProperties
+ }) => (
+
+ ),
+}))
import { CardLayoutMini } from "../../Mini"
import { ThemeImageProps } from "../../../../ThemeImage"
@@ -60,6 +115,8 @@ describe("rendering", () => {
const linkElement = container.querySelector("a")
expect(linkElement).toBeInTheDocument()
expect(linkElement).toHaveAttribute("href", href)
+ expect(linkElement).toHaveAttribute("rel", "noopener noreferrer")
+ expect(linkElement).toHaveAttribute("target", "_blank")
})
test("renders card mini layout with internal href", () => {
const href = "/example"
@@ -70,6 +127,8 @@ describe("rendering", () => {
const linkElement = container.querySelector("a")
expect(linkElement).toBeInTheDocument()
expect(linkElement).toHaveAttribute("href", href)
+ expect(linkElement).not.toHaveAttribute("target")
+ expect(linkElement).not.toHaveAttribute("rel")
})
test("renders card mini layout with icon", () => {
const icon = () => Icon
@@ -202,3 +261,17 @@ describe("rendering", () => {
expect(themeImageElement).toHaveClass(iconClassName)
})
})
+
+describe("interaction", () => {
+ test("calls onClick when card mini layout with link is clicked", () => {
+ const onClick = vi.fn()
+ const { container } = render(
+ Click me
+ )
+ expect(container).toBeInTheDocument()
+ const link = container.querySelector("a")
+ expect(link).toBeInTheDocument()
+ fireEvent.click(link!)
+ expect(onClick).toHaveBeenCalled()
+ })
+})
\ No newline at end of file
diff --git a/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx b/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx
index 8f64366279..c2fa03fed7 100644
--- a/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx
+++ b/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx
@@ -25,6 +25,7 @@ export const CardLayoutMini = ({
imageDimensions = { width: 45, height: 36 },
iconClassName,
cardRef,
+ onClick,
}: CardProps) => {
const isExternal = useIsExternalLink({ href })
@@ -120,6 +121,10 @@ export const CardLayoutMini = ({
className="absolute left-0 top-0 w-full h-full z-[1]"
prefetch={false}
{...hrefProps}
+ rel={isExternal ? "noopener noreferrer" : undefined}
+ target={isExternal ? "_blank" : undefined}
+ aria-label={title}
+ onClick={onClick}
/>
)}
{closeable && (
diff --git a/www/packages/docs-ui/src/components/Card/index.tsx b/www/packages/docs-ui/src/components/Card/index.tsx
index 791c322ec3..546a307a12 100644
--- a/www/packages/docs-ui/src/components/Card/index.tsx
+++ b/www/packages/docs-ui/src/components/Card/index.tsx
@@ -31,6 +31,7 @@ export type CardProps = {
highlightText?: string[]
closeable?: boolean
onClose?: () => void
+ onClick?: () => void
hrefProps?: Partial>
cardRef?: React.Ref
}
diff --git a/www/packages/docs-ui/src/components/Icons/ColoredMedusa/index.tsx b/www/packages/docs-ui/src/components/Icons/ColoredMedusa/index.tsx
new file mode 100644
index 0000000000..90a674e759
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Icons/ColoredMedusa/index.tsx
@@ -0,0 +1,13 @@
+import { Medusa } from "@medusajs/icons"
+import { IconProps } from "@medusajs/icons/dist/types"
+import clsx from "clsx"
+import React from "react"
+
+export const ColoredMedusaIcon = ({ className, ...props }: IconProps) => {
+ return (
+
+ )
+}
diff --git a/www/packages/docs-ui/src/components/Icons/index.tsx b/www/packages/docs-ui/src/components/Icons/index.tsx
index 9009740c8a..d500452ac4 100644
--- a/www/packages/docs-ui/src/components/Icons/index.tsx
+++ b/www/packages/docs-ui/src/components/Icons/index.tsx
@@ -4,6 +4,7 @@ export * from "./CalendarRefresh"
export * from "./ChefHat"
export * from "./CircleDottedLine"
export * from "./CloudSolid"
+export * from "./ColoredMedusa"
export * from "./DecisionProcess"
export * from "./Erp"
export * from "./ImageBinary"
diff --git a/www/packages/docs-ui/src/components/Search/Callout/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Search/Callout/__tests__/index.test.tsx
new file mode 100644
index 0000000000..abe6a4a2af
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Search/Callout/__tests__/index.test.tsx
@@ -0,0 +1,115 @@
+import React from "react"
+import { beforeEach, describe, expect, test, vi } from "vitest"
+import { cleanup, fireEvent, render } from "@testing-library/react"
+
+// mock functions
+const mockUseMedusaSuggestions = vi.fn((options) => null as unknown)
+const mockUseInstantSearch = vi.fn(() => ({
+ results: {
+ query: "test",
+ },
+}))
+const mockTrack = vi.fn()
+
+// mock components and hooks
+vi.mock("@/hooks/use-medusa-suggestions", () => ({
+ useMedusaSuggestions: (options: unknown) => mockUseMedusaSuggestions(options),
+}))
+vi.mock("@/components/Card", () => ({
+ Card: (props: { title: string, onClick: () => void }) => (
+ {props.title}
+ ),
+}))
+vi.mock("react-instantsearch", () => ({
+ useInstantSearch: () => mockUseInstantSearch(),
+}))
+vi.mock("@/providers/Analytics", () => ({
+ useAnalytics: () => ({
+ track: mockTrack,
+ }),
+}))
+
+import { SearchCallout } from "../index"
+import { DocsTrackingEvents } from "../../../../constants"
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ cleanup()
+})
+
+describe("render", () => {
+ test("should not render when there is no matched suggestion", () => {
+ mockUseMedusaSuggestions.mockReturnValueOnce(null)
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test("should render when there is a matched suggestion", () => {
+ const mockCardProps = {
+ title: "Test Card",
+ text: "This is a test card.",
+ href: "https://example.com",
+ icon: () => Icon
,
+ }
+ mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
+
+ const { getByTestId } = render()
+
+ expect(getByTestId("card")).toBeInTheDocument()
+ expect(getByTestId("card")).toHaveTextContent("Test Card")
+ })
+
+ test("should not render when there is no query", () => {
+ mockUseInstantSearch.mockReturnValueOnce({
+ results: {
+ query: "",
+ },
+ })
+ mockUseMedusaSuggestions.mockReturnValueOnce(null)
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test("should call useMedusaSuggestions with the correct query", () => {
+ render()
+
+ expect(mockUseMedusaSuggestions).toHaveBeenCalledWith({
+ keywords: "test",
+ })
+ })
+})
+
+describe("interaction", () => {
+ test("should track click event when card is clicked", () => {
+ const mockCardProps = {
+ title: "Test Card",
+ text: "This is a test card.",
+ href: "https://example.com",
+ icon: () => Icon
,
+ }
+ mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
+
+ const { getByTestId } = render()
+
+ const cardElement = getByTestId("card")
+ expect(cardElement).toBeInTheDocument()
+
+ // Simulate click
+ fireEvent.click(cardElement!)
+
+ expect(mockTrack).toHaveBeenCalledWith({
+ event: {
+ event: DocsTrackingEvents.SEARCH_CALLOUT_CLICK,
+ options: {
+ user_keywords: "test",
+ callout_title: mockCardProps.title,
+ callout_href: mockCardProps.href,
+ },
+ },
+ })
+ })
+})
\ No newline at end of file
diff --git a/www/packages/docs-ui/src/components/Search/Callout/index.tsx b/www/packages/docs-ui/src/components/Search/Callout/index.tsx
new file mode 100644
index 0000000000..29c9c28f49
--- /dev/null
+++ b/www/packages/docs-ui/src/components/Search/Callout/index.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import React from "react"
+import { useInstantSearch } from "react-instantsearch"
+import { useMedusaSuggestions } from "../../../hooks/use-medusa-suggestions"
+import { Card } from "../../Card"
+import { useAnalytics } from "../../../providers/Analytics"
+import { DocsTrackingEvents } from "../../../constants"
+
+export const SearchCallout = () => {
+ const { results } = useInstantSearch()
+ const query = results?.query || ""
+ const matchedCallout = useMedusaSuggestions({ keywords: query })
+ const { track } = useAnalytics()
+
+ if (!matchedCallout) {
+ return null
+ }
+
+ return (
+
+ {
+ track({
+ event: {
+ event: DocsTrackingEvents.SEARCH_CALLOUT_CLICK,
+ options: {
+ user_keywords: query,
+ callout_title: matchedCallout.title,
+ callout_href: matchedCallout.href,
+ },
+ },
+ })
+ }}
+ />
+
+ )
+}
diff --git a/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx
index 5e006e0384..b1245091d0 100644
--- a/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx
+++ b/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx
@@ -109,6 +109,9 @@ vi.mock("@/components/Search/Footer", () => ({
vi.mock("@/components/Loading/Spinner", () => ({
SpinnerLoading: () => Loading
,
}))
+vi.mock("@/components/Search/Callout", () => ({
+ SearchCallout: () => Callout
,
+}))
import { Search } from "../../Search"
@@ -209,6 +212,15 @@ describe("rendering", () => {
expect(footer).toBeInTheDocument()
})
+ test("renders SearchCallout", () => {
+ const suggestions: SearchSuggestionType[] = []
+ const { container } = render(
+
+ )
+ const callout = container.querySelector("[data-testid='search-callout']")
+ expect(callout).toBeInTheDocument()
+ })
+
test("passes checkInternalPattern to SearchHitsWrapper", () => {
const suggestions: SearchSuggestionType[] = []
const pattern = /\/docs\//
diff --git a/www/packages/docs-ui/src/components/Search/index.tsx b/www/packages/docs-ui/src/components/Search/index.tsx
index 5d8d055918..bebf26fcd5 100644
--- a/www/packages/docs-ui/src/components/Search/index.tsx
+++ b/www/packages/docs-ui/src/components/Search/index.tsx
@@ -12,6 +12,7 @@ import { useSearchNavigation } from "@/hooks/use-search-navigation"
import { OptionType } from "@/hooks/use-select"
import { SearchFooter } from "./Footer"
import { SearchFilters } from "./Filters"
+import { SearchCallout } from "./Callout"
export type SearchProps = {
algolia: AlgoliaProps
@@ -112,6 +113,7 @@ export const Search = ({
}
>
+
({
+ Card: (props: { title: string }) => {props.title}
,
+}))
+
+import { useMedusaSuggestions } from "../index"
+
+const TestComponent = ({ keywords }: { keywords: string | string[] }) => {
+ const suggestion = useMedusaSuggestions({ keywords })
+ return suggestion ? (
+ {suggestion.title}
+ ) : null
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ cleanup()
+})
+
+describe("keywords", () => {
+ test("should handle empty keyword string", () => {
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test("should handle empty keyword array", () => {
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test("should return null for no matches", () => {
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ test("should return suggestion for matching keyword string", () => {
+ const { getByTestId } = render()
+
+ const suggestion = getByTestId("suggestion")
+
+ expect(suggestion).toBeInTheDocument()
+ expect(suggestion).toHaveTextContent("Deploy to Cloud")
+ })
+
+ test("should return suggestion for matching keyword in array", () => {
+ const { getByTestId } = render(
+
+ )
+
+ const suggestion = getByTestId("suggestion")
+
+ expect(suggestion).toBeInTheDocument()
+ expect(suggestion).toHaveTextContent("Deploy to Cloud")
+ })
+
+ test("should be case insensitive and ignore punctuation", () => {
+ const { getByTestId } = render()
+
+ const suggestion = getByTestId("suggestion")
+
+ expect(suggestion).toBeInTheDocument()
+ expect(suggestion).toHaveTextContent("Deploy to Cloud")
+ })
+})
\ No newline at end of file
diff --git a/www/packages/docs-ui/src/hooks/use-medusa-suggestions/index.tsx b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/index.tsx
new file mode 100644
index 0000000000..28133d9a4e
--- /dev/null
+++ b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/index.tsx
@@ -0,0 +1,34 @@
+import { CardProps } from "../../components/Card"
+import { medusaSuggestions } from "./suggestions"
+
+const PUNCTIONATION = /[.,?;!]/g
+
+type UseSuggestionsProps = {
+ keywords: string | string[]
+}
+
+export const useMedusaSuggestions = ({ keywords }: UseSuggestionsProps) => {
+ if (!keywords.length) {
+ return null
+ }
+
+ const keywordsArray = Array.isArray(keywords)
+ ? keywords.map(formatWord)
+ : keywords.split(" ").map(formatWord)
+
+ let matchedSuggestion: CardProps | null = null
+
+ keywordsArray.some((word) => {
+ if (medusaSuggestions.has(word)) {
+ matchedSuggestion = medusaSuggestions.get(word) || null
+ }
+
+ return matchedSuggestion !== null
+ })
+
+ return matchedSuggestion as CardProps | null
+}
+
+const formatWord = (word: string) => {
+ return word.toLowerCase().trim().replace(PUNCTIONATION, "")
+}
diff --git a/www/packages/docs-ui/src/hooks/use-medusa-suggestions/suggestions.ts b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/suggestions.ts
new file mode 100644
index 0000000000..a12f8bdd38
--- /dev/null
+++ b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/suggestions.ts
@@ -0,0 +1,45 @@
+import { ColoredMedusaIcon } from "../../components/Icons/ColoredMedusa"
+import { CardProps } from "../../components/Card"
+
+const CLOUD_SUGGESTION: CardProps = {
+ title: "Deploy to Cloud",
+ text: "Deploy and manage production-ready Medusa applications with zero-configuration deployments, automatic scaling, and GitHub integration, and more.",
+ href: "https://cloud.medusajs.com/signup",
+ icon: ColoredMedusaIcon,
+}
+
+const CLOUD_MAIL_SUGGESTION: CardProps = {
+ title: "Deploy to Cloud",
+ text: "Deploy to Cloud with email sending support out-of-the-box.",
+ href: "https://cloud.medusajs.com/signup",
+ icon: ColoredMedusaIcon,
+}
+
+const CLOUD_S3_SUGGESTION: CardProps = {
+ title: "Deploy to Cloud",
+ text: "Deploy to Cloud with S3 storage support out-of-the-box.",
+ href: "https://cloud.medusajs.com/signup",
+ icon: ColoredMedusaIcon,
+}
+
+const CLOUD_CACHE_SUGGESTION: CardProps = {
+ title: "Deploy to Cloud",
+ text: "Deploy to Cloud with caching support out-of-the-box.",
+ href: "https://cloud.medusajs.com/signup",
+ icon: ColoredMedusaIcon,
+}
+
+type Suggestions = Map
+
+export const medusaSuggestions: Suggestions = new Map([
+ ["railway", CLOUD_SUGGESTION],
+ ["heroku", CLOUD_SUGGESTION],
+ ["aws", CLOUD_SUGGESTION],
+ ["coolify", CLOUD_SUGGESTION],
+ ["resend", CLOUD_MAIL_SUGGESTION],
+ ["sendgrid", CLOUD_MAIL_SUGGESTION],
+ ["s3", CLOUD_S3_SUGGESTION],
+ ["minio", CLOUD_S3_SUGGESTION],
+ ["cache", CLOUD_CACHE_SUGGESTION],
+ ["caching", CLOUD_CACHE_SUGGESTION],
+])