docs: added callouts in search and AI assistant (#14421)

* docs: added callouts in search and AI assistant

* added more suggestions

* fix search tests

* add mock for links
This commit is contained in:
Shahed Nasser
2025-12-30 16:57:18 +02:00
committed by GitHub
parent 1ed6e6b308
commit 499dec6d31
23 changed files with 674 additions and 7 deletions

View File

@@ -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 }) => (
<div data-testid="card" onClick={props.onClick}>{props.title}</div>
),
}))
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(<AiAssistantChatWindowCallout />)
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: () => <div>Icon</div>,
}
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
const { getByTestId } = render(<AiAssistantChatWindowCallout />)
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: () => <div>Icon</div>,
})
const { container } = render(<AiAssistantChatWindowCallout />)
expect(container.firstChild).toBeNull()
})
test("should pass correct keywords to useMedusaSuggestions", () => {
render(<AiAssistantChatWindowCallout />)
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: () => <div>Icon</div>,
}
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
const { getByTestId } = render(<AiAssistantChatWindowCallout />)
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,
},
},
})
})
})

View File

@@ -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 (
<div className="m-docs_1 flex justify-center items-center">
<Card
{...matchedCallout}
onClick={() => {
track({
event: {
event: DocsTrackingEvents.AI_ASSISTANT_CALLOUT_CLICK,
options: {
user_keywords: lastQuestion || "",
callout_title: matchedCallout.title || "",
callout_href: matchedCallout.href || "",
},
},
})
}}
/>
</div>
)
}

View File

@@ -52,6 +52,9 @@ vi.mock("@/components/AiAssistant/ThreadItem", () => ({
<div className="thread-item">ThreadItem - type: {item.type}</div>
),
}))
vi.mock("@/components/AiAssistant/ChatWindow/Callout", () => ({
AiAssistantChatWindowCallout: () => <div data-testid="callout">Callout</div>,
}))
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", () => {

View File

@@ -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 = () => {
)}
></span>
</div>
<AiAssistantChatWindowCallout />
<AiAssistantChatWindowInput chatWindowRef={chatWindowRef} />
<AiAssistantChatWindowFooter />
</div>

View File

@@ -46,6 +46,7 @@ const createMockConversation = () => {
}
},
getLatest: mockGetLatest,
getLatestCompleted: mockGetLatest,
map(callback: (item: unknown, index: number) => React.ReactNode) {
return conversationItems.map(callback)
},

View File

@@ -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(
<CardDefaultLayout onClick={handleClick} href="#">Click me</CardDefaultLayout>
)
expect(container).toBeInTheDocument()
const link = container.querySelector("a")
expect(link).toBeInTheDocument()
fireEvent.click(link!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -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}
/>
)}
</div>

View File

@@ -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
}) => (
<a
href={href}
className={className}
target={target}
rel={rel}
onClick={(e) => {
// can't perform actual navigation in tests
e.preventDefault()
onClick?.()
}}
aria-label={ariaLabel}
>
{children}
</a>
),
}))
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(
<CardLargeLayout onClick={handleClick} href="#">Click me</CardLargeLayout>
)
expect(container).toBeInTheDocument()
const linkElement = container.querySelector("a")
expect(linkElement).toBeInTheDocument()
fireEvent.click(linkElement!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -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}
/>
)}
</div>

View File

@@ -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", () => ({
</div>
),
}))
vi.mock("@/components/Link", () => ({
Link: (props: LinkProps) => <a {...props} />,
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
}) => (
<a
href={href}
className={className}
target={target}
rel={rel}
onClick={(e) => {
// can't perform actual navigation in tests
e.preventDefault()
onClick?.()
}}
aria-label={ariaLabel}
>
{children}
</a>
),
}))
vi.mock("@/components/ThemeImage", () => ({
ThemeImage: (props: ThemeImageProps) => (
@@ -26,7 +56,32 @@ vi.mock("@/components/ThemeImage", () => ({
</div>
),
}))
vi.mock("next/image", () => ({
default: ({
src,
alt,
width,
height,
className,
style,
}: {
src: string
alt?: string
width?: number
height?: number
className?: string
style?: React.CSSProperties
}) => (
<img
src={src}
alt={alt || ""}
width={width}
height={height}
className={className}
style={style}
/>
),
}))
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 = () => <div data-testid="icon">Icon</div>
@@ -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(
<CardLayoutMini onClick={onClick} href="#">Click me</CardLayoutMini>
)
expect(container).toBeInTheDocument()
const link = container.querySelector("a")
expect(link).toBeInTheDocument()
fireEvent.click(link!)
expect(onClick).toHaveBeenCalled()
})
})

View File

@@ -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 && (

View File

@@ -31,6 +31,7 @@ export type CardProps = {
highlightText?: string[]
closeable?: boolean
onClose?: () => void
onClick?: () => void
hrefProps?: Partial<LinkProps & React.AllHTMLAttributes<HTMLAnchorElement>>
cardRef?: React.Ref<HTMLDivElement>
}

View File

@@ -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 (
<Medusa
{...props}
className={clsx(className, "[&_path]:fill-medusa-fg-subtle")}
/>
)
}

View File

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

View File

@@ -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 }) => (
<div data-testid="card" onClick={props.onClick}>{props.title}</div>
),
}))
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(<SearchCallout />)
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: () => <div>Icon</div>,
}
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
const { getByTestId } = render(<SearchCallout />)
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(<SearchCallout />)
expect(container.firstChild).toBeNull()
})
test("should call useMedusaSuggestions with the correct query", () => {
render(<SearchCallout />)
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: () => <div>Icon</div>,
}
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
const { getByTestId } = render(<SearchCallout />)
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,
},
},
})
})
})

View File

@@ -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 (
<div className="m-docs_1 flex justify-center items-center">
<Card
{...matchedCallout}
onClick={() => {
track({
event: {
event: DocsTrackingEvents.SEARCH_CALLOUT_CLICK,
options: {
user_keywords: query,
callout_title: matchedCallout.title,
callout_href: matchedCallout.href,
},
},
})
}}
/>
</div>
)
}

View File

@@ -109,6 +109,9 @@ vi.mock("@/components/Search/Footer", () => ({
vi.mock("@/components/Loading/Spinner", () => ({
SpinnerLoading: () => <div data-testid="spinner-loading">Loading</div>,
}))
vi.mock("@/components/Search/Callout", () => ({
SearchCallout: () => <div data-testid="search-callout">Callout</div>,
}))
import { Search } from "../../Search"
@@ -209,6 +212,15 @@ describe("rendering", () => {
expect(footer).toBeInTheDocument()
})
test("renders SearchCallout", () => {
const suggestions: SearchSuggestionType[] = []
const { container } = render(
<Search algolia={defaultAlgoliaProps} suggestions={suggestions} />
)
const callout = container.querySelector("[data-testid='search-callout']")
expect(callout).toBeInTheDocument()
})
test("passes checkInternalPattern to SearchHitsWrapper", () => {
const suggestions: SearchSuggestionType[] = []
const pattern = /\/docs\//

View File

@@ -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 = ({
<SearchEmptyQueryBoundary
fallback={<SearchSuggestions suggestions={suggestions} />}
>
<SearchCallout />
<SearchHitsWrapper
configureProps={{}}
checkInternalPattern={checkInternalPattern}

View File

@@ -510,4 +510,6 @@ export enum DocsTrackingEvents {
SURVEY_API = "survey_api-ref",
CODE_BLOCK_COPY = "code_block_copy",
AI_ASSISTANT_START_CHAT = "ai_assistant_start_chat",
AI_ASSISTANT_CALLOUT_CLICK = "ai_assistant_callout_click",
SEARCH_CALLOUT_CLICK = "search_callout_click",
}

View File

@@ -11,6 +11,7 @@ export * from "./use-current-learning-path"
export * from "./use-is-external-link"
export * from "./use-mutation-observer"
export * from "./use-keyboard-shortcut"
export * from "./use-medusa-suggestions"
export * from "./use-page-scroll-manager"
export * from "./use-request-runner"
export * from "./use-scroll-utils"

View File

@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, test, vi } from "vitest"
import { cleanup, render } from "@testing-library/react"
// mock components
vi.mock("@/componets/Card", () => ({
Card: (props: { title: string }) => <div data-testid="card">{props.title}</div>,
}))
import { useMedusaSuggestions } from "../index"
const TestComponent = ({ keywords }: { keywords: string | string[] }) => {
const suggestion = useMedusaSuggestions({ keywords })
return suggestion ? (
<div data-testid="suggestion">{suggestion.title}</div>
) : null
}
beforeEach(() => {
vi.clearAllMocks()
cleanup()
})
describe("keywords", () => {
test("should handle empty keyword string", () => {
const { container } = render(<TestComponent keywords="" />)
expect(container.firstChild).toBeNull()
})
test("should handle empty keyword array", () => {
const { container } = render(<TestComponent keywords={[]} />)
expect(container.firstChild).toBeNull()
})
test("should return null for no matches", () => {
const { container } = render(<TestComponent keywords="nonexistentkeyword" />)
expect(container.firstChild).toBeNull()
})
test("should return suggestion for matching keyword string", () => {
const { getByTestId } = render(<TestComponent keywords="railway" />)
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(
<TestComponent keywords={["nonexistent", "railway", "anothernonexistent"]} />
)
const suggestion = getByTestId("suggestion")
expect(suggestion).toBeInTheDocument()
expect(suggestion).toHaveTextContent("Deploy to Cloud")
})
test("should be case insensitive and ignore punctuation", () => {
const { getByTestId } = render(<TestComponent keywords="Railway!" />)
const suggestion = getByTestId("suggestion")
expect(suggestion).toBeInTheDocument()
expect(suggestion).toHaveTextContent("Deploy to Cloud")
})
})

View File

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

View File

@@ -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<string, CardProps>
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],
])