fix: address marketplace review feedback on CI lane, templates and carousel a11y

- move the marketplace performance E2E lane out of main CI (opt-in workflow_dispatch)
- add server-rendered pagination for template search and validate page/view/sort params at the route boundary
- match template locale filtering by language prefix with an explicit English fallback
- cache the template collection fan-out (TTL + in-flight dedupe), lower per-collection limit and batch requests
- reset the forced search mode atom when the plugin catalog mounts
- clear stale search suggestions during the debounce window
- let the trending Play control override focus/hover pause reasons
- honor prefers-reduced-motion on the eagerly playing first-collection carousel
- give the Creator Center link an accessible, localized name
- replace the nested main landmark on the templates page with a div
- localize banner read-more actions and backfill marketplace.home keys across all 24 locales

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
CodingOnStar 2026-08-15 14:39:25 +08:00
parent 05acab169b
commit e7ca2735bf
43 changed files with 755 additions and 155 deletions

View File

@ -108,7 +108,6 @@ jobs:
- 'docker/docker-compose.middleware.yaml'
- 'docker/envs/middleware.env.example'
- '.github/workflows/web-e2e.yml'
- '.github/workflows/marketplace-performance-e2e.yml'
- '.github/workflows/main-ci.yml'
- '.github/actions/setup-web/**'
vdb:
@ -394,66 +393,6 @@ jobs:
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
marketplace-performance-run:
name: Run Marketplace Performance E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
uses: ./.github/workflows/marketplace-performance-e2e.yml
secrets: inherit
marketplace-performance-skip:
name: Skip Marketplace Performance E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped marketplace performance E2E
run: echo "No E2E-related changes detected; skipping marketplace performance E2E."
marketplace-performance:
name: Marketplace Performance E2E
if: ${{ always() }}
needs:
- pre_job
- check-changes
- marketplace-performance-run
- marketplace-performance-skip
runs-on: depot-ubuntu-24.04
steps:
- name: Finalize Marketplace Performance E2E status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
RUN_RESULT: ${{ needs.marketplace-performance-run.result }}
SKIP_RESULT: ${{ needs.marketplace-performance-skip.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Marketplace performance E2E was skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Marketplace performance E2E ran successfully."
exit 0
fi
echo "Marketplace performance E2E was required but finished with result: $RUN_RESULT" >&2
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "Marketplace performance E2E was skipped because no E2E-related files changed."
exit 0
fi
echo "Marketplace performance E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
style-check:
name: Style Check
needs: pre_job

View File

@ -1,7 +1,9 @@
name: Marketplace Performance E2E
# Opt-in diagnostic: single-sample timing budgets are too noisy to gate every
# PR, so this lane is only run on demand instead of from the main CI pipeline.
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read

View File

@ -4,8 +4,24 @@ import { redirect } from '@/next/navigation'
import TemplatesPage from '../page'
vi.mock('@/app/components/plugins/marketplace/templates', () => ({
EmbeddedTemplatesMarketplace: ({ category, query }: { category: string; query: string }) => (
<div>{`Templates catalog: ${category}:${query}`}</div>
EmbeddedTemplatesMarketplace: ({
category,
page,
query,
sortBy,
sortOrder,
view,
}: {
category: string
page: number
query: string
sortBy?: string
sortOrder?: string
view?: string
}) => (
<div data-testid="catalog" data-page={page} data-sort-by={sortBy} data-sort-order={sortOrder} data-view={view}>
{`Templates catalog: ${category}:${query}`}
</div>
),
}))
@ -46,6 +62,48 @@ describe('embedded templates route', () => {
expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument()
})
it('validates page, view and sort params at the route boundary', async () => {
const page = await TemplatesPage({
params: Promise.resolve({}),
searchParams: Promise.resolve({
page: '3',
q: 'agent',
sort_by: 'created_at',
sort_order: 'ASC',
view: 'search',
}),
})
render(page)
const catalog = screen.getByTestId('catalog')
expect(catalog).toHaveAttribute('data-page', '3')
expect(catalog).toHaveAttribute('data-sort-by', 'created_at')
expect(catalog).toHaveAttribute('data-sort-order', 'ASC')
expect(catalog).toHaveAttribute('data-view', 'search')
})
it('falls back to defaults for unsupported page, view and sort params', async () => {
const page = await TemplatesPage({
params: Promise.resolve({}),
searchParams: Promise.resolve({
page: '-2',
q: 'agent',
sort_by: 'garbage',
sort_order: 'sideways',
view: 'iframe',
}),
})
render(page)
const catalog = screen.getByTestId('catalog')
expect(catalog).toHaveAttribute('data-page', '1')
expect(catalog).not.toHaveAttribute('data-sort-by')
expect(catalog).not.toHaveAttribute('data-sort-order')
expect(catalog).not.toHaveAttribute('data-view')
})
it('opens template recommendations in the existing Dify import flow', async () => {
await expect(
TemplatesPage({

View File

@ -6,6 +6,7 @@ import { redirect } from '@/next/navigation'
type TemplatesPageProps = {
params: Promise<{ category?: string[] }>
searchParams: Promise<{
page?: string
q?: string
sort_by?: string
sort_order?: string
@ -14,6 +15,26 @@ type TemplatesPageProps = {
}>
}
// These values arrive from a public URL, so validate them against the
// supported enums here at the route boundary. Unknown values fall back to the
// defaults instead of reaching the Marketplace API, where e.g.
// `sort_order=garbage` fails and would surface as a false "no templates" state.
const TEMPLATE_SORT_FIELDS = new Set(['usage_count', 'created_at'])
const TEMPLATE_SORT_ORDERS = new Set(['ASC', 'DESC'])
const parseView = (value?: string) => (value === 'search' ? 'search' : undefined)
const parseSortBy = (value?: string) =>
value && TEMPLATE_SORT_FIELDS.has(value) ? value : undefined
const parseSortOrder = (value?: string) =>
value && TEMPLATE_SORT_ORDERS.has(value) ? value : undefined
const parsePage = (value?: string) => {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1
}
export default async function TemplatesPage({ params, searchParams }: TemplatesPageProps) {
const [resolvedParams, resolvedSearchParams, locale] = await Promise.all([
params,
@ -36,10 +57,11 @@ export default async function TemplatesPage({ params, searchParams }: TemplatesP
<EmbeddedTemplatesMarketplace
category={category}
locale={locale}
page={parsePage(resolvedSearchParams.page)}
query={resolvedSearchParams.q ?? ''}
sortBy={resolvedSearchParams.sort_by}
sortOrder={resolvedSearchParams.sort_order}
view={resolvedSearchParams.view}
sortBy={parseSortBy(resolvedSearchParams.sort_by)}
sortOrder={parseSortOrder(resolvedSearchParams.sort_order)}
view={parseView(resolvedSearchParams.view)}
/>
</div>
)

View File

@ -2,7 +2,7 @@ import type { PluginsSort, SearchParamsFromCollection } from '@dify/contracts/ma
import type { ActivePluginType } from './constants'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { useCallback, useEffect } from 'react'
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { marketplaceSearchParamsParsers } from './search-params'
@ -43,6 +43,21 @@ export function useMarketplaceSearchMode(activePluginTypeOverride?: ActivePlugin
return isSearchMode
}
/**
* The forced search mode lives in the app-wide Jotai store, so a "View More"
* click would otherwise leak into the next visit of the plugin catalog after
* navigating away (e.g. to /templates) and back, rendering empty-query search
* results instead of the prefetched collections. Reset it when the catalog
* route mounts; URL-owned state (q, tags, category) is not affected.
*/
export function useResetMarketplaceSearchModeOnMount() {
const setSearchMode = useSetAtom(searchModeAtom)
useEffect(() => {
setSearchMode(null)
}, [setSearchMode])
}
export function useMarketplaceMoreClick() {
const [, setQ] = useSearchPluginText()
const setSort = useSetAtom(marketplaceSortAtom)

View File

@ -5,6 +5,7 @@ import type { MarketplaceViewProps } from './view'
import { queryOptions, useQuery } from '@tanstack/react-query'
import { useLocale } from '@/context/i18n'
import { marketplaceQuery } from '@/service/client'
import { useResetMarketplaceSearchModeOnMount } from './atoms'
import { fetchPluginBanners } from './home/banners'
import { MarketplaceView } from './view'
@ -27,6 +28,7 @@ export function EmbeddedMarketplace({
variant = 'default',
...props
}: EmbeddedMarketplaceProps) {
useResetMarketplaceSearchModeOnMount()
const locale = useLocale()
const input = {
query: {

View File

@ -44,7 +44,7 @@ describe('HomeHeader', () => {
it('shows Creator Center before Guide', () => {
render(<HomeHeader isMarketplacePlatform />)
const creatorCenterLink = screen.getByRole('link', { name: 'Creator Center' })
const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })
const guideLink = screen.getByRole('link', { name: 'marketplace.home.guide' })
expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/')
@ -63,7 +63,7 @@ describe('HomeHeader', () => {
render(<HomeHeader isMarketplacePlatform />)
expect(screen.getByRole('link', { name: 'Creator Center' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
'href',
'https://creators-staging.dify.dev/',
)
@ -74,7 +74,7 @@ describe('HomeHeader', () => {
render(<HomeHeader isMarketplacePlatform />)
expect(screen.getByRole('link', { name: 'Creator Center' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
'href',
'https://creators.dify.ai/',
)

View File

@ -121,7 +121,7 @@ describe('HomeTrending', () => {
expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument()
const blogSlide = screen.getByRole('group', { name: 'Dify Updates' })
const blogLink = within(blogSlide).getByRole('link', {
name: 'Read more about Dify v1.9 new launch',
name: 'plugin.marketplace.home.trendingReadMoreAbout',
})
expect(blogLink).toHaveAttribute('href', 'https://dify.ai/blog')
expect(within(blogSlide).getAllByRole('link')).toHaveLength(1)
@ -365,6 +365,51 @@ describe('HomeTrending', () => {
})
})
it('resumes autoplay when Play is activated without moving keyboard focus', async () => {
const pause = vi.fn()
const play = vi.fn()
const progressAnimation = {
cancel: vi.fn(),
onfinish: null,
pause,
play,
} as unknown as Animation
const originalAnimate = Element.prototype.animate
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: vi.fn(() => progressAnimation),
})
const user = userEvent.setup()
render(<HomeTrending banners={banners} isMarketplacePlatform />)
const toggleButton = screen.getByRole('button', {
name: 'plugin.marketplace.home.trendingPause',
})
// Focusing the toggle adds the implicit focus pause reason, then Enter
// adds the explicit user pause.
toggleButton.focus()
await user.keyboard('{Enter}')
expect(pause).toHaveBeenCalled()
// Play must resume the rotation even though the button is still focused
// (and would normally keep the focus pause reason active).
const playsBeforePlay = play.mock.calls.length
await user.keyboard('{Enter}')
expect(play.mock.calls.length).toBeGreaterThan(playsBeforePlay)
expect(document.activeElement).toBe(toggleButton)
expect(
screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }),
).toBeInTheDocument()
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: originalAnimate,
})
})
it('renders no carousel when the API returns no banners', () => {
render(<HomeTrending banners={[]} isMarketplacePlatform />)

View File

@ -3,13 +3,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import {
MarketplaceSearchAutocomplete,
MarketplaceSearchForm,
} from '../marketplace-search-autocomplete'
const { mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
const { debounceState, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
// Most tests bypass the debounce for simplicity; the debounce-window test
// flips this on to exercise the real 300ms lag.
debounceState: { useRealDebounce: false },
mockPluginSearch: vi.fn(),
mockTemplateSearch: vi.fn(),
}))
@ -19,7 +22,8 @@ vi.mock('ahooks', async (importOriginal) => {
return {
...original,
useDebounce: <T,>(value: T) => value,
useDebounce: <T,>(value: T, options?: { wait?: number }) =>
debounceState.useRealDebounce ? original.useDebounce(value, options) : value,
}
})
@ -60,6 +64,7 @@ function Wrapper({ children }: { children: ReactNode }) {
describe('MarketplaceSearchAutocomplete', () => {
beforeEach(() => {
vi.clearAllMocks()
debounceState.useRealDebounce = false
queryClient = new QueryClient({
defaultOptions: {
queries: {
@ -219,4 +224,51 @@ describe('MarketplaceSearchAutocomplete', () => {
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Searching...')
})
it('clears suggestions while the edited value is still debouncing', async () => {
debounceState.useRealDebounce = true
mockPluginSearch.mockResolvedValue({
data: {
plugins: [
{
type: 'plugin',
org: 'langgenius',
name: 'google-search',
label: { en_US: 'Google Search' },
brief: { en_US: 'Search the web from your workflow.' },
category: 'tool',
},
],
total: 1,
},
})
const user = userEvent.setup()
const ControlledSearch = () => {
const [value, setValue] = useState('')
return (
<MarketplaceSearchAutocomplete
locale="en-US"
onValueChange={setValue}
placeholder="Search plugins"
scope="plugins"
value={value}
/>
)
}
render(<ControlledSearch />, { wrapper: Wrapper })
// Suggestions only appear once the real 300ms debounce has elapsed.
await user.type(screen.getByRole('combobox'), 'google')
expect(await screen.findByText('Google Search')).toBeInTheDocument()
// For the first 300ms after editing, the debounced term still points at
// the old query; the previous suggestions must already be gone.
await user.type(screen.getByRole('combobox'), ' drive')
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Searching...')
})
})

View File

@ -1,6 +1,7 @@
import type { HomeCatalogTab, HomeCatalogTabLabels } from './home-catalog-tabs'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from '#i18n'
import { MARKETPLACE_URL_PREFIX } from '@/config'
import Link from '@/next/link'
import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg'
@ -39,20 +40,25 @@ const getCreatorCenterUrl = (marketplaceUrlPrefix: string) => {
}
const CreatorCenter = () => {
const { t } = useTranslation('plugin')
const creatorCenterUrl = getCreatorCenterUrl(MARKETPLACE_URL_PREFIX)
const label = t(($) => $['marketplace.home.creatorCenter'])
return (
<Link
href={creatorCenterUrl}
target="_blank"
rel="noopener noreferrer"
// The visible text is hidden below the lg breakpoint, so the link needs
// an explicit accessible name to avoid becoming an icon-only mystery.
aria-label={label}
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary [html[data-theme=dark]_&]:text-text-primary [html[data-theme=dark]_&]:hover:text-text-primary',
)}
>
<span aria-hidden className="i-ri-user-star-line size-4" />
<span className="hidden system-sm-medium lg:inline">Creator Center</span>
<span className="hidden system-sm-medium lg:inline">{label}</span>
</Link>
)
}

View File

@ -231,6 +231,7 @@ function TrendingRecommendationSlide({
}
function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
const { t } = useTranslation('plugin')
const opensInNewTab = /^https?:\/\//.test(banner.content.link)
return (
@ -238,7 +239,9 @@ function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
href={banner.content.link}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={`Read more about ${banner.content.blog_title}`}
aria-label={t(($) => $['marketplace.home.trendingReadMoreAbout'], {
title: banner.content.blog_title,
})}
className="flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<div className="flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5">
@ -265,7 +268,7 @@ function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
aria-hidden
className="flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2"
>
<span>Read more</span>
<span>{t(($) => $['marketplace.home.trendingReadMore'])}</span>
<span className="i-ri-arrow-right-s-line size-4" />
</span>
</div>
@ -480,6 +483,12 @@ function TrendingNavigation({
setIsReducedMotionPaused(false)
setPauseReason('user', false)
setPauseReason('reduced-motion', false)
// Activating Play keeps the pointer and/or keyboard focus on the button
// itself, so the implicit hover/focus reasons would silently keep the
// rotation paused. An explicit Play overrides them; they re-engage on
// the next mouseenter/focusin.
setPauseReason('focus', false)
setPauseReason('hover', false)
return
}

View File

@ -121,16 +121,22 @@ export function MarketplaceSearchAutocomplete({
enabled: hasQuery && searchesTemplates,
staleTime: 60_000,
})
const pluginSuggestions = searchesPlugins
? (pluginQuery.data?.data.bundles ?? pluginQuery.data?.data.plugins ?? []).map((plugin) =>
toPluginSuggestion(plugin, locale),
)
: []
const templateSuggestions = searchesTemplates
? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion)
: []
// While the edited value is still debouncing, the queries above still hold
// the previous term's data; gate the suggestions until both agree so stale
// options are never visible or keyboard-selectable.
const isDebouncing = value.trim() !== debouncedSearch
const pluginSuggestions =
!isDebouncing && searchesPlugins
? (pluginQuery.data?.data.bundles ?? pluginQuery.data?.data.plugins ?? []).map((plugin) =>
toPluginSuggestion(plugin, locale),
)
: []
const templateSuggestions =
!isDebouncing && searchesTemplates
? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion)
: []
const suggestions = [...templateSuggestions, ...pluginSuggestions]
const isSearching = pluginQuery.isFetching || templateQuery.isFetching
const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching
const emptyText =
scope === 'templates'
? translate('newApp.noTemplateFound', { ns: 'app' })

View File

@ -232,7 +232,7 @@ describe('Marketplace Carousel', () => {
expect(autoplay.play).toHaveBeenCalledTimes(2)
reducedMotion = true
reducedMotionListener?.()
act(() => reducedMotionListener?.())
expect(autoplay.stop).toHaveBeenCalled()
Object.defineProperty(document, 'visibilityState', {
@ -248,7 +248,7 @@ describe('Marketplace Carousel', () => {
expect(autoplay.play).toHaveBeenCalledTimes(2)
reducedMotion = false
reducedMotionListener?.()
act(() => reducedMotionListener?.())
expect(autoplay.play).toHaveBeenCalledTimes(3)
triggerIntersection(intersectionObservers[0]!, 0)
@ -268,6 +268,38 @@ describe('Marketplace Carousel', () => {
expect(intersectionObservers).toHaveLength(0)
})
it('honors reduced motion for the eagerly playing first-collection carousel', () => {
let reducedMotion = true
let reducedMotionListener: (() => void) | undefined
vi.stubGlobal('matchMedia', () => ({
get matches() {
return reducedMotion
},
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addEventListener: (_event: string, listener: () => void) => {
reducedMotionListener = listener
},
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
// The production first collection renders without pauseWhenOffscreen, so
// the reduced-motion guard must work outside the viewport-managed path.
render(<Carousel pages={pages} autoPlay />)
const autoplay = mocks.autoplayInstances[0]!
expect(autoplay.stop).toHaveBeenCalled()
expect(autoplay.play).not.toHaveBeenCalled()
reducedMotion = false
act(() => reducedMotionListener?.())
expect(autoplay.play).toHaveBeenCalled()
})
it('keeps off-screen pages out of the tab order and accessibility tree', () => {
render(<Carousel pages={pages} ariaLabel="Featured tools" />)

View File

@ -168,6 +168,13 @@ const Carousel = ({
}: CarouselProps) => {
const carouselRootRef = useRef<HTMLDivElement>(null)
const [isUserPaused, setIsUserPaused] = useState(false)
// Tracked independently of pauseWhenOffscreen so every autoplay path honors
// prefers-reduced-motion, including the eagerly-playing first collection.
const [isReducedMotion, setIsReducedMotion] = useState(
() =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false),
)
const autoplay = useMemo(() => {
if (!autoPlay) return undefined
@ -264,12 +271,28 @@ const Carousel = ({
return () => carouselRoot.removeEventListener('focusin', handleFocusIn)
}, [autoplay])
// The viewport-managed effect below tracks reduced motion itself; this
// effect covers the eager autoplay path (pauseWhenOffscreen=false), which
// previously ignored the preference entirely.
useEffect(() => {
if (!autoPlay || pauseWhenOffscreen) return
const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)')
if (!reducedMotionQuery) return
const syncReducedMotion = () => setIsReducedMotion(reducedMotionQuery.matches)
syncReducedMotion()
reducedMotionQuery.addEventListener('change', syncReducedMotion)
return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion)
}, [autoPlay, pauseWhenOffscreen])
useEffect(() => {
if (!autoplay || !api || pauseWhenOffscreen) return
if (isUserPaused) autoplay.stop()
if (isUserPaused || isReducedMotion) autoplay.stop()
else autoplay.play()
}, [api, autoplay, isUserPaused, pauseWhenOffscreen])
}, [api, autoplay, isReducedMotion, isUserPaused, pauseWhenOffscreen])
useEffect(() => {
if (!pauseWhenOffscreen || !autoplay || !api) return

View File

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vite-plus/test'
import { filterTemplatesForLocale } from '../template-language'
const template = (id: string, preferredLanguages?: string[]) => ({
id,
preferred_languages: preferredLanguages,
})
const ids = (templates: { id: string }[]) => templates.map(({ id }) => id)
describe('filterTemplatesForLocale', () => {
it('keeps templates matching the requested language prefix', () => {
const templates = [
template('en', ['en-US']),
template('zh', ['zh-Hans']),
template('ja', ['ja-JP']),
]
expect(ids(filterTemplatesForLocale(templates, 'zh-Hans'))).toEqual(['zh'])
expect(ids(filterTemplatesForLocale(templates, 'en-US'))).toEqual(['en'])
})
it('matches unrelated locales instead of collapsing them into "other"', () => {
const templates = [
template('en', ['en-US']),
template('de', ['de-DE']),
template('fr', ['fr-FR']),
]
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['de'])
})
it('falls back to English templates when nothing matches the requested language', () => {
const templates = [
template('en-1', ['en-US']),
template('en-2', ['en-GB']),
template('ja', ['ja-JP']),
]
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['en-1', 'en-2'])
})
it('falls back to the unfiltered list when neither the locale nor English matches', () => {
const templates = [template('zh', ['zh-Hans']), template('ja', ['ja-JP'])]
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['zh', 'ja'])
})
it('always keeps language-agnostic templates', () => {
const templates = [
template('agnostic-none'),
template('agnostic-empty', []),
template('de', ['de-DE']),
]
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual([
'agnostic-none',
'agnostic-empty',
'de',
])
})
it('normalizes underscore locales', () => {
const templates = [template('zh', ['zh_Hans']), template('en', ['en_US'])]
expect(ids(filterTemplatesForLocale(templates, 'zh_Hans'))).toEqual(['zh'])
})
})

View File

@ -8,6 +8,7 @@ import Link from '@/next/link'
import {
getMarketplaceTemplateCollectionsAndTemplates,
searchMarketplaceTemplates,
TEMPLATE_SEARCH_PAGE_SIZE,
} from '@/service/marketplace-template-discovery'
import { fetchPluginBanners } from '../home/banners'
import HomeCatalogNavigation from '../home/home-catalog-navigation'
@ -29,6 +30,7 @@ import { filterTemplatesForLocale } from './template-language'
type EmbeddedTemplatesMarketplaceProps = {
category: TemplateCategory
locale: Locale
page?: number
query: string
sortBy?: string
sortOrder?: string
@ -103,9 +105,81 @@ function TemplateGrid({
)
}
const PAGE_LINK_CLASS =
'flex h-8 items-center justify-center rounded-lg border-[0.5px] border-divider-regular px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const PAGE_LINK_DISABLED_CLASS =
'flex h-8 cursor-not-allowed items-center justify-center rounded-lg border-[0.5px] border-divider-subtle px-3 system-sm-medium text-text-quaternary'
// Server-rendered pagination: plain links keep the search results reachable
// beyond the first page without any client-side state.
function TemplatePagination({
category,
navigationLabel,
nextLabel,
page,
pageCount,
previousLabel,
query,
sortBy,
sortOrder,
view,
}: {
category: TemplateCategory
navigationLabel: string
nextLabel: string
page: number
pageCount: number
previousLabel: string
query: string
sortBy?: string
sortOrder?: string
view?: string
}) {
if (pageCount <= 1) return null
const buildHref = (targetPage: number) => {
const searchParams = new URLSearchParams()
if (query) searchParams.set('q', query)
if (sortBy) searchParams.set('sort_by', sortBy)
if (sortOrder) searchParams.set('sort_order', sortOrder)
if (view) searchParams.set('view', view)
if (targetPage > 1) searchParams.set('page', String(targetPage))
const queryString = searchParams.toString()
const basePath = category === 'all' ? '/templates' : `/templates/${category}`
return queryString ? `${basePath}?${queryString}` : basePath
}
return (
<nav aria-label={navigationLabel} className="mt-6 flex items-center justify-center gap-3 pb-4">
{page > 1 ? (
<Link href={buildHref(page - 1)} className={PAGE_LINK_CLASS}>
{previousLabel}
</Link>
) : (
<span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}>
{previousLabel}
</span>
)}
<span aria-current="page" className="system-sm-regular text-text-tertiary">
{page} / {pageCount}
</span>
{page < pageCount ? (
<Link href={buildHref(page + 1)} className={PAGE_LINK_CLASS}>
{nextLabel}
</Link>
) : (
<span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}>
{nextLabel}
</span>
)}
</nav>
)
}
export async function EmbeddedTemplatesMarketplace({
category,
locale,
page = 1,
query,
sortBy,
sortOrder,
@ -118,6 +192,7 @@ export async function EmbeddedTemplatesMarketplace({
{ t: tApp },
{ t: tExplore },
{ t: tPluginTags },
{ t: tCommon },
collectionsResult,
searchResult,
banners,
@ -126,11 +201,13 @@ export async function EmbeddedTemplatesMarketplace({
getTranslation(locale, 'app'),
getTranslation(locale, 'explore'),
getTranslation(locale, 'pluginTags'),
getTranslation(locale, 'common'),
showCollections ? getMarketplaceTemplateCollectionsAndTemplates() : Promise.resolve(null),
showCollections
? Promise.resolve(null)
: searchMarketplaceTemplates({
category,
page,
query: normalizedQuery,
sortBy,
sortOrder,
@ -214,7 +291,9 @@ export async function EmbeddedTemplatesMarketplace({
/>
}
/>
<main
{/* The app shell already renders the main landmark; use a plain div
to avoid nested main elements. */}
<div
className={cn(
'relative flex grow flex-col bg-background-default px-8 py-2',
styles.catalogContent,
@ -243,9 +322,21 @@ export async function EmbeddedTemplatesMarketplace({
) : (
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
)}
<TemplatePagination
category={category}
navigationLabel={tCommon('pagination.pageNumber' as never)}
nextLabel={tCommon('pagination.next' as never)}
page={page}
pageCount={Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE)}
previousLabel={tCommon('pagination.previous' as never)}
query={normalizedQuery}
sortBy={sortBy}
sortOrder={sortOrder}
view={view}
/>
</>
)}
</main>
</div>
</div>
</div>
</HomeStickyStateProvider>

View File

@ -1,36 +1,33 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
type TemplateLanguageFamily = 'en' | 'ja' | 'other' | 'zh'
function getTemplateLanguageFamily(locale: string): TemplateLanguageFamily {
const normalizedLocale = locale.toLowerCase()
if (normalizedLocale.startsWith('en')) return 'en'
if (normalizedLocale.startsWith('zh')) return 'zh'
if (normalizedLocale.startsWith('ja')) return 'ja'
return 'other'
}
const getLanguagePrefix = (locale: string) => locale.toLowerCase().split(/[-_]/)[0] ?? ''
/**
* Keeps the templates matching the requested locale's language. Templates
* without language metadata are treated as language-agnostic and always kept.
* When no template matches the requested language, the list explicitly falls
* back to English templates (and finally to the unfiltered list) so locales
* such as German render real content instead of an empty state.
*/
export function filterTemplatesForLocale<
T extends Pick<MarketplaceTemplate, 'preferred_languages'>,
>(templates: T[], locale: string) {
const languageFamily = getTemplateLanguageFamily(locale)
const requestedLanguage = getLanguagePrefix(locale)
return templates.filter((template) => {
const preferredLanguages = (template.preferred_languages ?? []).map((language) =>
language.toLowerCase(),
)
const filterByLanguage = (languagePrefix: string) =>
templates.filter((template) => {
const preferredLanguages = template.preferred_languages ?? []
if (preferredLanguages.length === 0) return true
return preferredLanguages.some((language) => getLanguagePrefix(language) === languagePrefix)
})
if (languageFamily === 'other') {
return !preferredLanguages.some(
(language) =>
language.startsWith('en') || language.startsWith('zh') || language.startsWith('ja'),
)
}
const requestedMatches = filterByLanguage(requestedLanguage)
if (requestedMatches.length > 0) return requestedMatches
return preferredLanguages.some((language) => language.startsWith(languageFamily))
})
const englishMatches = requestedLanguage === 'en' ? [] : filterByLanguage('en')
if (englishMatches.length > 0) return englishMatches
return templates
}
export function getTemplateCollectionText(value: Record<string, string>, locale: string) {

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "سوق Dify",
"marketplace.discover": "اكتشف",
"marketplace.empower": "تمكين تطوير الذكاء الاصطناعي الخاص بك",
"marketplace.home.creatorCenter": "مركز المبدعين",
"marketplace.home.guide": "دليل",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "اكتشف. وسّع. ابنِ",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "إيقاف مؤقت",
"marketplace.home.trendingPlay": "تشغيل",
"marketplace.home.trendingReadMore": "اقرأ المزيد",
"marketplace.home.trendingReadMoreAbout": "اقرأ المزيد عن {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "عرض",
"marketplace.moreFrom": "المزيد من السوق",
"marketplace.noPluginFound": "لم يتم العثور على إضافة",
"marketplace.partnerTip": "تم التحقق بواسطة شريك Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marktplatz",
"marketplace.discover": "Entdecken",
"marketplace.empower": "Unterstützen Sie Ihre KI-Entwicklung",
"marketplace.home.creatorCenter": "Creator Center",
"marketplace.home.guide": "Leitfaden",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Entdecken. Erweitern. Entwickeln",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pausieren",
"marketplace.home.trendingPlay": "Abspielen",
"marketplace.home.trendingReadMore": "Mehr erfahren",
"marketplace.home.trendingReadMoreAbout": "Mehr erfahren über {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Ansehen",
"marketplace.moreFrom": "Mehr aus dem Marketplace",
"marketplace.noPluginFound": "Kein Plugin gefunden",
"marketplace.partnerTip": "Von einem Dify-Partner verifiziert",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Discover",
"marketplace.empower": "Empower your AI development",
"marketplace.home.creatorCenter": "Creator Center",
"marketplace.home.guide": "Guide",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Discover. Extend. Build",
@ -240,6 +241,8 @@
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pause",
"marketplace.home.trendingPlay": "Play",
"marketplace.home.trendingReadMore": "Read more",
"marketplace.home.trendingReadMoreAbout": "Read more about {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "View",
"marketplace.moreFrom": "More from Marketplace",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Mercado de Dify",
"marketplace.discover": "Descubrir",
"marketplace.empower": "Potencie su desarrollo de IA",
"marketplace.home.creatorCenter": "Centro de creadores",
"marketplace.home.guide": "Guía",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Descubre. Amplía. Crea",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pausar",
"marketplace.home.trendingPlay": "Reproducir",
"marketplace.home.trendingReadMore": "Leer más",
"marketplace.home.trendingReadMoreAbout": "Leer más sobre {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Ver",
"marketplace.moreFrom": "Más de Marketplace",
"marketplace.noPluginFound": "No se ha encontrado ninguna integración",
"marketplace.partnerTip": "Verificado por un socio de Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "بازار دیفی",
"marketplace.discover": "کشف",
"marketplace.empower": "توسعه هوش مصنوعی خود را توانمند کنید",
"marketplace.home.creatorCenter": "مرکز سازندگان",
"marketplace.home.guide": "راهنما",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "کشف کنید. گسترش دهید. بسازید",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "توقف",
"marketplace.home.trendingPlay": "پخش",
"marketplace.home.trendingReadMore": "ادامه مطلب",
"marketplace.home.trendingReadMoreAbout": "ادامه مطلب درباره {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "مشاهده",
"marketplace.moreFrom": "اطلاعات بیشتر از Marketplace",
"marketplace.noPluginFound": "هیچ افزونه‌ای یافت نشد",
"marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Marché Dify",
"marketplace.discover": "Découvrir",
"marketplace.empower": "Renforcez le développement de votre IA",
"marketplace.home.creatorCenter": "Centre des créateurs",
"marketplace.home.guide": "Guide",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Découvrez. Étendez. Créez",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Mettre en pause",
"marketplace.home.trendingPlay": "Lire",
"marketplace.home.trendingReadMore": "En savoir plus",
"marketplace.home.trendingReadMoreAbout": "En savoir plus sur {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Voir",
"marketplace.moreFrom": "Plus de Marketplace",
"marketplace.noPluginFound": "Aucune intégration trouvée",
"marketplace.partnerTip": "Vérifié par un partenaire Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "डिफाई मार्केटप्लेस",
"marketplace.discover": "खोजें",
"marketplace.empower": "अपने एआई विकास को सशक्त बनाएं",
"marketplace.home.creatorCenter": "क्रिएटर केंद्र",
"marketplace.home.guide": "मार्गदर्शिका",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "खोजें। विस्तार करें। बनाएँ",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "रोकें",
"marketplace.home.trendingPlay": "चलाएं",
"marketplace.home.trendingReadMore": "और पढ़ें",
"marketplace.home.trendingReadMoreAbout": "{{title}} के बारे में और पढ़ें",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "देखें",
"marketplace.moreFrom": "मार्केटप्लेस से अधिक",
"marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला",
"marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Menemukan",
"marketplace.empower": "Berdayakan pengembangan AI Anda",
"marketplace.home.creatorCenter": "Pusat Kreator",
"marketplace.home.guide": "Panduan",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Temukan. Perluas. Bangun",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Jeda",
"marketplace.home.trendingPlay": "Putar",
"marketplace.home.trendingReadMore": "Baca selengkapnya",
"marketplace.home.trendingReadMoreAbout": "Baca selengkapnya tentang {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Lihat",
"marketplace.moreFrom": "Selengkapnya dari Marketplace",
"marketplace.noPluginFound": "Tidak ada integrasi yang ditemukan",
"marketplace.partnerTip": "Diverifikasi oleh partner Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Mercato Dify",
"marketplace.discover": "Scoprire",
"marketplace.empower": "Potenzia lo sviluppo dell'intelligenza artificiale",
"marketplace.home.creatorCenter": "Centro creatori",
"marketplace.home.guide": "Guida",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Scopri. Estendi. Crea",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pausa",
"marketplace.home.trendingPlay": "Riproduci",
"marketplace.home.trendingReadMore": "Scopri di più",
"marketplace.home.trendingReadMoreAbout": "Scopri di più su {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Visualizza",
"marketplace.moreFrom": "Altro da Marketplace",
"marketplace.noPluginFound": "Nessuna integrazione trovata",
"marketplace.partnerTip": "Verificato da un partner Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify マーケットプレイス",
"marketplace.discover": "探索",
"marketplace.empower": "AI 開発をサポートする",
"marketplace.home.creatorCenter": "クリエイターセンター",
"marketplace.home.guide": "ガイド",
"marketplace.home.heroSubtitle": "Dify Marketplace で、より安全で信頼性の高いプラグインを見つけましょう。",
"marketplace.home.heroTitle": "見つける。拡張する。構築する",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "実際の利用状況に基づく人気プラグインを2週間ごとに更新。ワークスペースでの実行数によるランキングで、有料掲載や編集部による選定はありません。",
"marketplace.home.trendingEyebrow": "トレンド",
"marketplace.home.trendingPaginationLabel": "トレンドページ",
"marketplace.home.trendingPause": "一時停止",
"marketplace.home.trendingPlay": "再生",
"marketplace.home.trendingReadMore": "続きを読む",
"marketplace.home.trendingReadMoreAbout": "{{title}} の続きを読む",
"marketplace.home.trendingTitle": "みんながインストールしているプラグイン",
"marketplace.home.trendingView": "表示",
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
"marketplace.noPluginFound": "インテグレーションが見つかりません",
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify 마켓플레이스",
"marketplace.discover": "발견하다",
"marketplace.empower": "AI 개발 역량 강화",
"marketplace.home.creatorCenter": "크리에이터 센터",
"marketplace.home.guide": "가이드",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "발견하고, 확장하고, 구축하세요",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "일시정지",
"marketplace.home.trendingPlay": "재생",
"marketplace.home.trendingReadMore": "더 알아보기",
"marketplace.home.trendingReadMoreAbout": "{{title}}에 대해 더 알아보기",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "보기",
"marketplace.moreFrom": "Marketplace 에서 더 보기",
"marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.",
"marketplace.partnerTip": "Dify 파트너에 의해 확인됨",

View File

@ -227,6 +227,12 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "ຄົ້ນຫາ",
"marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ",
"marketplace.home.creatorCenter": "ສູນຜູ້ສ້າງ",
"marketplace.home.trendingPause": "ຢຸດຊົ່ວຄາວ",
"marketplace.home.trendingPlay": "ຫຼິ້ນ",
"marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ",
"marketplace.home.trendingReadMoreAbout": "ອ່ານເພີ່ມເຕີມກ່ຽວກັບ {{title}}",
"marketplace.home.trendingView": "ເບິ່ງ",
"marketplace.moreFrom": "ເພີ່ມເຕີມຈາກ Marketplace",
"marketplace.noPluginFound": "ບໍ່ພົບການເຊື່ອມຕໍ່",
"marketplace.partnerTip": "ໄດ້ຮັບການຢືນຢັນໂດຍພັດທະນາມິດຂອງ Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Discover",
"marketplace.empower": "Empower your AI development",
"marketplace.home.creatorCenter": "Creatorcentrum",
"marketplace.home.guide": "Handleiding",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Ontdek. Breid uit. Bouw",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pauzeren",
"marketplace.home.trendingPlay": "Afspelen",
"marketplace.home.trendingReadMore": "Lees meer",
"marketplace.home.trendingReadMoreAbout": "Lees meer over {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Bekijken",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noPluginFound": "Geen plugin gevonden",
"marketplace.partnerTip": "Verified by a Dify partner",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Rynek Dify",
"marketplace.discover": "Odkryć",
"marketplace.empower": "Zwiększ możliwości rozwoju sztucznej inteligencji",
"marketplace.home.creatorCenter": "Centrum twórców",
"marketplace.home.guide": "Przewodnik",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Odkrywaj. Rozszerzaj. Twórz",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Wstrzymaj",
"marketplace.home.trendingPlay": "Odtwórz",
"marketplace.home.trendingReadMore": "Czytaj więcej",
"marketplace.home.trendingReadMoreAbout": "Czytaj więcej o {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Zobacz",
"marketplace.moreFrom": "Więcej z Marketplace",
"marketplace.noPluginFound": "Nie znaleziono integracji",
"marketplace.partnerTip": "Zweryfikowane przez partnera Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Mercado Dify",
"marketplace.discover": "Descobrir",
"marketplace.empower": "Capacite seu desenvolvimento de IA",
"marketplace.home.creatorCenter": "Central do criador",
"marketplace.home.guide": "Guia",
"marketplace.home.heroSubtitle": "Crie com plugins mais seguros e confiáveis do Dify Marketplace.",
"marketplace.home.heroTitle": "Descubra. Expanda. Crie",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Destaques por uso real, atualizados a cada duas semanas. Classificados pelas execuções reais nos espaços de trabalho — sem promoção paga ou seleção editorial.",
"marketplace.home.trendingEyebrow": "Em alta agora",
"marketplace.home.trendingPaginationLabel": "Páginas em alta",
"marketplace.home.trendingPause": "Pausar",
"marketplace.home.trendingPlay": "Reproduzir",
"marketplace.home.trendingReadMore": "Leia mais",
"marketplace.home.trendingReadMoreAbout": "Leia mais sobre {{title}}",
"marketplace.home.trendingTitle": "Os plugins que todos estão instalando",
"marketplace.home.trendingView": "Ver",
"marketplace.moreFrom": "Mais do Marketplace",
"marketplace.noPluginFound": "Nenhuma integração encontrada",
"marketplace.partnerTip": "Verificado por um parceiro da Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Piața Dify",
"marketplace.discover": "Descoperi",
"marketplace.empower": "Îmbunătățește-ți dezvoltarea AI",
"marketplace.home.creatorCenter": "Centrul creatorilor",
"marketplace.home.guide": "Ghid",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Descoperă. Extinde. Construiește",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pauză",
"marketplace.home.trendingPlay": "Redare",
"marketplace.home.trendingReadMore": "Citește mai mult",
"marketplace.home.trendingReadMoreAbout": "Citește mai mult despre {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Vezi",
"marketplace.moreFrom": "Mai multe din Marketplace",
"marketplace.noPluginFound": "Nu s-a găsit niciun plugin",
"marketplace.partnerTip": "Verificat de un partener Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Торговая площадка Dify",
"marketplace.discover": "Обнаруживать",
"marketplace.empower": "Расширьте возможности разработки ИИ",
"marketplace.home.creatorCenter": "Центр авторов",
"marketplace.home.guide": "Руководство",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Открывайте. Расширяйте. Создавайте",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Пауза",
"marketplace.home.trendingPlay": "Воспроизвести",
"marketplace.home.trendingReadMore": "Читать далее",
"marketplace.home.trendingReadMoreAbout": "Подробнее о {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Открыть",
"marketplace.moreFrom": "Больше из Marketplace",
"marketplace.noPluginFound": "Плагин не найден",
"marketplace.partnerTip": "Подтверждено партнером Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Odkrijte",
"marketplace.empower": "Okrepite svoj razvoj AI",
"marketplace.home.creatorCenter": "Središče za ustvarjalce",
"marketplace.home.guide": "Vodnik",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Odkrijte. Razširite. Ustvarite",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Premor",
"marketplace.home.trendingPlay": "Predvajaj",
"marketplace.home.trendingReadMore": "Preberi več",
"marketplace.home.trendingReadMoreAbout": "Preberi več o {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Ogled",
"marketplace.moreFrom": "Več iz tržnice",
"marketplace.noPluginFound": "Nobenega vtičnika ni bilo najti.",
"marketplace.partnerTip": "Potrjeno s strani partnerja Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "ตลาด Dify",
"marketplace.discover": "ค้นพบ",
"marketplace.empower": "เพิ่มศักยภาพในการพัฒนา AI ของคุณ",
"marketplace.home.creatorCenter": "ศูนย์ครีเอเตอร์",
"marketplace.home.guide": "คู่มือ",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "ค้นพบ ขยาย และสร้าง",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "หยุดชั่วคราว",
"marketplace.home.trendingPlay": "เล่น",
"marketplace.home.trendingReadMore": "อ่านเพิ่มเติม",
"marketplace.home.trendingReadMoreAbout": "อ่านเพิ่มเติมเกี่ยวกับ {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "ดู",
"marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace",
"marketplace.noPluginFound": "ไม่พบปลั๊กอิน",
"marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Pazar Yeri",
"marketplace.discover": "Keşfet",
"marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin",
"marketplace.home.creatorCenter": "İçerik Üretici Merkezi",
"marketplace.home.guide": "Kılavuz",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Keşfet. Genişlet. Oluştur",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Duraklat",
"marketplace.home.trendingPlay": "Oynat",
"marketplace.home.trendingReadMore": "Devamını oku",
"marketplace.home.trendingReadMoreAbout": "{{title}} hakkında devamını oku",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Görüntüle",
"marketplace.moreFrom": "Pazar Yeri'nden daha fazlası",
"marketplace.noPluginFound": "Eklenti bulunamadı",
"marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Виявити",
"marketplace.empower": "Розширюйте можливості розробки штучного інтелекту",
"marketplace.home.creatorCenter": "Центр авторів",
"marketplace.home.guide": "Посібник",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Відкривайте. Розширюйте. Створюйте",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Пауза",
"marketplace.home.trendingPlay": "Відтворити",
"marketplace.home.trendingReadMore": "Читати далі",
"marketplace.home.trendingReadMoreAbout": "Дізнатися більше про {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Переглянути",
"marketplace.moreFrom": "Більше від Marketplace",
"marketplace.noPluginFound": "Плагін не знайдено",
"marketplace.partnerTip": "Перевірено партнером Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Thị trường Dify",
"marketplace.discover": "Khám phá",
"marketplace.empower": "Hỗ trợ phát triển AI của bạn",
"marketplace.home.creatorCenter": "Trung tâm nhà sáng tạo",
"marketplace.home.guide": "Hướng dẫn",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Khám phá. Mở rộng. Xây dựng",
@ -238,7 +239,12 @@
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Tạm dừng",
"marketplace.home.trendingPlay": "Phát",
"marketplace.home.trendingReadMore": "Đọc thêm",
"marketplace.home.trendingReadMoreAbout": "Đọc thêm về {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Xem",
"marketplace.moreFrom": "Các ứng dụng khác từ Marketplace",
"marketplace.noPluginFound": "Không tìm thấy plugin nào",
"marketplace.partnerTip": "Được xác nhận bởi một đối tác của Dify",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "探索",
"marketplace.empower": "助力您的 AI 开发",
"marketplace.home.creatorCenter": "创作者中心",
"marketplace.home.guide": "指南",
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的插件。",
"marketplace.home.heroTitle": "发现。扩展。构建",
@ -240,6 +241,8 @@
"marketplace.home.trendingPaginationLabel": "热门推荐页码",
"marketplace.home.trendingPause": "暂停",
"marketplace.home.trendingPlay": "播放",
"marketplace.home.trendingReadMore": "阅读更多",
"marketplace.home.trendingReadMoreAbout": "阅读更多关于 {{title}} 的内容",
"marketplace.home.trendingTitle": "大家都在安装的插件",
"marketplace.home.trendingView": "查看",
"marketplace.moreFrom": "来自 Marketplace 的更多内容",

View File

@ -227,6 +227,7 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "發現",
"marketplace.empower": "為您的 AI 開發提供支援",
"marketplace.home.creatorCenter": "創作者中心",
"marketplace.home.guide": "指南",
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的外掛程式。",
"marketplace.home.heroTitle": "探索。擴展。建構",
@ -240,6 +241,8 @@
"marketplace.home.trendingPaginationLabel": "熱門推薦頁碼",
"marketplace.home.trendingPause": "暫停",
"marketplace.home.trendingPlay": "播放",
"marketplace.home.trendingReadMore": "閱讀更多",
"marketplace.home.trendingReadMoreAbout": "閱讀更多關於 {{title}} 的內容",
"marketplace.home.trendingTitle": "大家都在安裝的外掛程式",
"marketplace.home.trendingView": "查看",
"marketplace.moreFrom": "來自 Marketplace 的更多內容",

View File

@ -1,8 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getMarketplaceTemplateCollectionsAndTemplates,
searchMarketplaceTemplates,
} from './marketplace-template-discovery'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
const mocks = vi.hoisted(() => ({
templateCollections: vi.fn(),
@ -18,12 +14,20 @@ vi.mock('./client', () => ({
},
}))
// The collections helper keeps a module-level cache, so import a fresh copy
// per test to keep them isolated.
const importDiscovery = async () => {
vi.resetModules()
return import('./marketplace-template-discovery')
}
describe('marketplace template discovery', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads each template collection and isolates a failed collection', async () => {
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
mocks.templateCollections.mockResolvedValue({
data: {
collections: [
@ -43,7 +47,7 @@ describe('marketplace template discovery', () => {
})
expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(1, {
params: { collectionName: 'featured' },
body: { limit: 100 },
body: { limit: 24 },
})
expect(result.templatesByCollection).toEqual({
featured: [{ id: 'template-1' }],
@ -51,7 +55,51 @@ describe('marketplace template discovery', () => {
})
})
it('serves collections from the cache instead of refetching every render', async () => {
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
mocks.templateCollections.mockResolvedValue({
data: {
collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }],
},
})
mocks.templateCollectionTemplates.mockResolvedValue({
data: { templates: [{ id: 'template-1' }] },
})
const [first, second] = await Promise.all([
getMarketplaceTemplateCollectionsAndTemplates(),
getMarketplaceTemplateCollectionsAndTemplates(),
])
const third = await getMarketplaceTemplateCollectionsAndTemplates()
expect(mocks.templateCollections).toHaveBeenCalledOnce()
expect(mocks.templateCollectionTemplates).toHaveBeenCalledOnce()
expect(second).toBe(first)
expect(third).toBe(first)
})
it('does not cache a failed collections fetch', async () => {
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
mocks.templateCollections.mockRejectedValueOnce(new Error('Unavailable'))
const failed = await getMarketplaceTemplateCollectionsAndTemplates()
expect(failed).toEqual({ collections: [], templatesByCollection: {} })
mocks.templateCollections.mockResolvedValue({
data: {
collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }],
},
})
mocks.templateCollectionTemplates.mockResolvedValue({
data: { templates: [{ id: 'template-1' }] },
})
const recovered = await getMarketplaceTemplateCollectionsAndTemplates()
expect(recovered.templatesByCollection).toEqual({ featured: [{ id: 'template-1' }] })
})
it('sends category searches through the Marketplace contract', async () => {
const { searchMarketplaceTemplates } = await importDiscovery()
mocks.templateSearch.mockResolvedValue({
data: {
templates: [{ id: 'template-1' }],
@ -61,12 +109,13 @@ describe('marketplace template discovery', () => {
const result = await searchMarketplaceTemplates({
category: 'marketing',
page: 2,
query: 'campaign',
})
expect(mocks.templateSearch).toHaveBeenCalledWith({
body: {
page: 1,
page: 2,
page_size: 40,
query: 'campaign',
sort_by: 'usage_count',
@ -74,6 +123,6 @@ describe('marketplace template discovery', () => {
categories: ['marketing'],
},
})
expect(result).toEqual({ templates: [{ id: 'template-1' }], total: 1 })
expect(result).toEqual({ page: 2, templates: [{ id: 'template-1' }], total: 1 })
})
})

View File

@ -9,8 +9,11 @@ export type MarketplaceTemplateCollectionsResult = {
templatesByCollection: Record<string, MarketplaceTemplate[]>
}
export const TEMPLATE_SEARCH_PAGE_SIZE = 40
type SearchMarketplaceTemplatesOptions = {
category: string
page?: number
query: string
sortBy?: string
sortOrder?: string
@ -21,41 +24,85 @@ const EMPTY_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
templatesByCollection: {},
}
export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
try {
const response = await marketplaceClient.templateCollections({
query: {
page: 1,
page_size: 100,
},
})
const collections = response.data?.collections ?? []
const entries = await Promise.all(
collections.map(async (collection) => {
try {
const collectionResponse = await marketplaceClient.templateCollectionTemplates({
params: { collectionName: collection.name },
body: { limit: 100 },
})
const COLLECTION_PREVIEW_TEMPLATE_LIMIT = 24
const COLLECTION_FETCH_BATCH_SIZE = 5
const COLLECTIONS_CACHE_TTL_MS = 5 * 60 * 1000
return [collection.name, collectionResponse.data?.templates ?? []] as const
} catch {
return [collection.name, []] as const
}
}),
let collectionsCache: {
expiresAt: number
result: MarketplaceTemplateCollectionsResult
} | null = null
let collectionsInFlight: Promise<MarketplaceTemplateCollectionsResult> | null = null
async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
const response = await marketplaceClient.templateCollections({
query: {
page: 1,
page_size: 100,
},
})
const collections = response.data?.collections ?? []
const entries: (readonly [string, MarketplaceTemplate[]])[] = []
// Bounded fan-out: fetch collection previews in small batches instead of
// firing one uncached request per collection all at once.
for (
let batchStart = 0;
batchStart < collections.length;
batchStart += COLLECTION_FETCH_BATCH_SIZE
) {
const batch = collections.slice(batchStart, batchStart + COLLECTION_FETCH_BATCH_SIZE)
entries.push(
...(await Promise.all(
batch.map(async (collection) => {
try {
const collectionResponse = await marketplaceClient.templateCollectionTemplates({
params: { collectionName: collection.name },
body: { limit: COLLECTION_PREVIEW_TEMPLATE_LIMIT },
})
return [collection.name, collectionResponse.data?.templates ?? []] as const
} catch {
return [collection.name, [] as MarketplaceTemplate[]] as const
}
}),
)),
)
return {
collections,
templatesByCollection: Object.fromEntries(entries),
}
} catch {
return EMPTY_COLLECTIONS_RESULT
}
return {
collections,
templatesByCollection: Object.fromEntries(entries),
}
}
/**
* Server-side cached view of the template collections and their previews.
* `marketplaceClient` opts out of the framework fetch cache (`no-store`), so
* without this cache every server render of /templates would fan out to up to
* 1 + N external requests. Successful results are reused for a few minutes and
* concurrent renders share a single in-flight fetch; failures are not cached.
*/
export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
if (collectionsCache && collectionsCache.expiresAt > Date.now()) return collectionsCache.result
if (collectionsInFlight) return collectionsInFlight
collectionsInFlight = fetchCollectionsAndTemplates()
.then((result) => {
collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result }
return result
})
.catch(() => EMPTY_COLLECTIONS_RESULT)
.finally(() => {
collectionsInFlight = null
})
return collectionsInFlight
}
export async function searchMarketplaceTemplates({
category,
page = 1,
query,
sortBy = 'usage_count',
sortOrder = 'DESC',
@ -63,8 +110,8 @@ export async function searchMarketplaceTemplates({
try {
const response = await marketplaceClient.templateSearch({
body: {
page: 1,
page_size: 40,
page,
page_size: TEMPLATE_SEARCH_PAGE_SIZE,
query,
sort_by: sortBy,
sort_order: sortOrder,
@ -73,11 +120,13 @@ export async function searchMarketplaceTemplates({
})
return {
page,
templates: response.data?.templates ?? [],
total: response.data?.total ?? 0,
}
} catch {
return {
page,
templates: [],
total: 0,
}