@@ -152,7 +185,12 @@ const Card = ({
{!hideCornerMark &&
}
{/* Header */}
-
+
diff --git a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx
index cc440d567f4..87fb5039af0 100644
--- a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx
+++ b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx
@@ -6,6 +6,7 @@ import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import {
useActivePluginType,
useFilterPluginTags,
+ useFilterTemplateLanguages,
useMarketplaceMoreClick,
useMarketplaceSearchMode,
useMarketplaceSort,
@@ -128,6 +129,25 @@ describe('useFilterPluginTags', () => {
})
})
+describe('useFilterTemplateLanguages', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should return empty array as default', () => {
+ const { wrapper } = createWrapper()
+ const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper })
+
+ expect(result.current[0]).toEqual([])
+ })
+
+ it('parses languages from search params', () => {
+ const { wrapper } = createWrapper('?languages=ja')
+ const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper })
+ expect(result.current[0]).toEqual(['ja'])
+ })
+})
+
describe('useMarketplaceSearchMode', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts b/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts
new file mode 100644
index 00000000000..a8a82ebe809
--- /dev/null
+++ b/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getCreatorCenterUrl,
+ PUBLIC_CREATOR_CENTER_URL,
+ rewriteMarketplaceOriginToCreators,
+} from '../creator-center-url'
+
+describe('getCreatorCenterUrl', () => {
+ it('maps the public Marketplace to the public Creator Center', () => {
+ expect(getCreatorCenterUrl('https://marketplace.dify.ai')).toBe('https://creators.dify.ai/')
+ })
+
+ it('maps marketplace.dify.dev to creators.dify.dev', () => {
+ expect(getCreatorCenterUrl('https://marketplace.dify.dev')).toBe('https://creators.dify.dev/')
+ })
+
+ it('keeps the staging suffix on the Creators host', () => {
+ expect(getCreatorCenterUrl('https://marketplace-staging.dify.dev')).toBe(
+ 'https://creators-staging.dify.dev/',
+ )
+ })
+
+ it('falls back to the public Creator Center for localhost', () => {
+ expect(getCreatorCenterUrl('http://localhost:3000')).toBe(PUBLIC_CREATOR_CENTER_URL)
+ })
+
+ it('falls back to the public Creator Center when the prefix is empty', () => {
+ expect(getCreatorCenterUrl('')).toBe(PUBLIC_CREATOR_CENTER_URL)
+ })
+
+ it('prefers the current Marketplace page over a stale configured prefix', () => {
+ expect(getCreatorCenterUrl('https://marketplace.dify.ai', 'https://marketplace.dify.dev')).toBe(
+ 'https://creators.dify.dev/',
+ )
+ })
+})
+
+describe('rewriteMarketplaceOriginToCreators', () => {
+ it('returns null for hosts that are not a Marketplace surface', () => {
+ expect(rewriteMarketplaceOriginToCreators('https://cloud.dify.ai')).toBeNull()
+ expect(rewriteMarketplaceOriginToCreators('http://localhost:3000')).toBeNull()
+ expect(rewriteMarketplaceOriginToCreators('')).toBeNull()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx
new file mode 100644
index 00000000000..5f20986e463
--- /dev/null
+++ b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx
@@ -0,0 +1,154 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type { ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockFetchPluginBanners = vi.fn()
+
+vi.mock('@/context/i18n', () => ({
+ useLocale: () => 'zh-Hans',
+}))
+
+vi.mock('../home/banners', async (importOriginal) => {
+ const original = await importOriginal
()
+
+ return {
+ ...original,
+ fetchPluginBanners: (...args: unknown[]) => mockFetchPluginBanners(...args),
+ }
+})
+
+vi.mock('../view', () => ({
+ MarketplaceView: ({
+ banners,
+ showInstallButton,
+ }: {
+ banners: PluginBanner[]
+ showInstallButton: boolean
+ }) => (
+
+
Trending banners: {banners.length}
+
{showInstallButton ? 'Install enabled' : 'Install disabled'}
+
+ ),
+}))
+
+let queryClient: QueryClient
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+describe('EmbeddedMarketplace', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ })
+ })
+
+ it('loads homepage banners on the client for the active locale', async () => {
+ mockFetchPluginBanners.mockResolvedValue([
+ {
+ id: 'banner-1',
+ title: 'Trending',
+ sort: 1,
+ language: 'zh-Hans',
+ style_type: 'blog',
+ content: {
+ blog_title: 'Dify update',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+ },
+ ] satisfies PluginBanner[])
+
+ const { EmbeddedMarketplace } = await import('../embedded')
+
+ render( , { wrapper: Wrapper })
+
+ expect(await screen.findByText('Trending banners: 1')).toBeInTheDocument()
+ expect(screen.getByText('Install enabled')).toBeInTheDocument()
+ expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans')
+ })
+
+ it('uses server-rendered homepage banners without requesting them again on hydration', async () => {
+ const initialBanners = [
+ {
+ id: 'banner-1',
+ title: 'Trending',
+ sort: 1,
+ language: 'zh-Hans',
+ style_type: 'blog',
+ content: {
+ blog_title: 'Dify update',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+ },
+ ] satisfies PluginBanner[]
+
+ const { EmbeddedMarketplace } = await import('../embedded')
+
+ render(
+ ,
+ { wrapper: Wrapper },
+ )
+
+ expect(screen.getByText('Trending banners: 1')).toBeInTheDocument()
+ expect(mockFetchPluginBanners).not.toHaveBeenCalled()
+ })
+
+ it('refetches banners when the client locale differs from the server-rendered locale', async () => {
+ const initialBanners = [
+ {
+ id: 'banner-en',
+ title: 'Trending',
+ sort: 1,
+ language: 'en-US',
+ style_type: 'blog',
+ content: {
+ blog_title: 'Dify update',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+ },
+ ] satisfies PluginBanner[]
+ mockFetchPluginBanners.mockResolvedValue([])
+
+ const { EmbeddedMarketplace } = await import('../embedded')
+
+ render(
+ ,
+ { wrapper: Wrapper },
+ )
+
+ expect(await screen.findByText('Trending banners: 0')).toBeInTheDocument()
+ expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans')
+ })
+
+ it('does not request homepage banners for the default catalog variant', async () => {
+ const { EmbeddedMarketplace } = await import('../embedded')
+
+ render( , { wrapper: Wrapper })
+
+ expect(screen.getByText('Trending banners: 0')).toBeInTheDocument()
+ expect(mockFetchPluginBanners).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx
index 46c770694b2..567f32f5b58 100644
--- a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx
+++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
+import type { Plugin } from '@/app/components/plugins/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
+import { PluginCategoryEnum } from '@/app/components/plugins/types'
const getMarketplacePluginsByCollectionId = vi.hoisted(() => vi.fn())
const getMarketplaceCollectionsAndPlugins = vi.hoisted(() => vi.fn())
@@ -149,3 +151,79 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
})
})
})
+
+const createPlugin = (pluginID: string, category: PluginCategoryEnum) =>
+ ({
+ plugin_id: pluginID,
+ type: 'plugin',
+ category,
+ }) as Plugin
+
+const createInfiniteData = (plugin: Plugin, pageSize: number) => ({
+ pages: [
+ {
+ plugins: [plugin],
+ total: 1,
+ page: 1,
+ page_size: pageSize,
+ },
+ ],
+ pageParams: [1],
+})
+
+const createWrapperWithQueryClient = (queryClient: QueryClient) =>
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+
+describe('useMarketplacePlugins', () => {
+ it('should reset local query params without removing marketplace plugin caches', async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: Infinity },
+ },
+ })
+ const toolPlugin = createPlugin('tool-plugin', PluginCategoryEnum.tool)
+ const modelPlugin = createPlugin('model-plugin', PluginCategoryEnum.model)
+ const toolParams = {
+ query: 'search',
+ category: PluginCategoryEnum.tool,
+ type: 'plugin' as const,
+ page_size: 40,
+ }
+ const modelParams = {
+ query: '',
+ category: PluginCategoryEnum.model,
+ type: 'plugin' as const,
+ page_size: 1000,
+ }
+ const toolQueryKey = ['marketplacePlugins', toolParams]
+ const modelQueryKey = ['marketplacePlugins', modelParams]
+ const toolQueryData = createInfiniteData(toolPlugin, toolParams.page_size)
+ const modelQueryData = createInfiniteData(modelPlugin, modelParams.page_size)
+
+ queryClient.setQueryData(toolQueryKey, toolQueryData)
+ queryClient.setQueryData(modelQueryKey, modelQueryData)
+
+ const { useMarketplacePlugins } = await import('../hooks')
+ const { result } = renderHook(() => useMarketplacePlugins(), {
+ wrapper: createWrapperWithQueryClient(queryClient),
+ })
+
+ act(() => {
+ result.current.queryPlugins(toolParams)
+ })
+
+ await waitFor(() => {
+ expect(result.current.plugins).toEqual([toolPlugin])
+ })
+
+ act(() => {
+ result.current.resetQueryParams()
+ })
+
+ expect(result.current.plugins).toBeUndefined()
+ expect(queryClient.getQueryData(toolQueryKey)).toEqual(toolQueryData)
+ expect(queryClient.getQueryData(modelQueryKey)).toEqual(modelQueryData)
+ })
+})
diff --git a/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx
index 50e703aae4e..1d709af215b 100644
--- a/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx
+++ b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx
@@ -17,16 +17,21 @@ vi.mock('@/utils/var', () => ({
const mockCollections = vi.fn()
const mockCollectionPlugins = vi.fn()
+const mockSearchAdvanced = vi.fn()
vi.mock('@/service/client', () => ({
marketplaceClient: {
collections: (...args: unknown[]) => mockCollections(...args),
collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args),
+ searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args),
},
marketplaceQuery: {
collections: {
queryKey: (params: unknown) => ['marketplace', 'collections', params],
},
+ searchAdvanced: {
+ queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params],
+ },
},
}))
@@ -50,6 +55,9 @@ describe('HydrateQueryClient', () => {
mockCollectionPlugins.mockResolvedValue({
data: { plugins: [] },
})
+ mockSearchAdvanced.mockResolvedValue({
+ data: { plugins: [], total: 0 },
+ })
})
it('should render children within HydrationBoundary', async () => {
@@ -119,7 +127,28 @@ describe('HydrateQueryClient', () => {
expect(mockCollections).toHaveBeenCalled()
})
- it('should not prefetch when category does not have collections (model)', async () => {
+ it('should prefetch plugin search when q is present', async () => {
+ const { HydrateQueryClient } = await import('../hydration-server')
+
+ await HydrateQueryClient({
+ searchParams: Promise.resolve({ category: 'all', q: 'openai' }),
+ children: Child
,
+ })
+
+ expect(mockCollections).not.toHaveBeenCalled()
+ expect(mockSearchAdvanced).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: { kind: 'plugins' },
+ body: expect.objectContaining({
+ page: 1,
+ query: 'openai',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should prefetch when category does not have collections (model)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
@@ -128,9 +157,10 @@ describe('HydrateQueryClient', () => {
})
expect(mockCollections).not.toHaveBeenCalled()
+ expect(mockSearchAdvanced).toHaveBeenCalled()
})
- it('should not prefetch when category does not have collections (bundle)', async () => {
+ it('should prefetch when category does not have collections (bundle)', async () => {
const { HydrateQueryClient } = await import('../hydration-server')
await HydrateQueryClient({
@@ -139,5 +169,6 @@ describe('HydrateQueryClient', () => {
})
expect(mockCollections).not.toHaveBeenCalled()
+ expect(mockSearchAdvanced).toHaveBeenCalled()
})
})
diff --git a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx
index 8b78b7bba3c..32671514e43 100644
--- a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx
+++ b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx
@@ -1,10 +1,11 @@
-import type { ReactNode } from 'react'
+import type { ComponentProps, ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider as JotaiProvider } from 'jotai'
import { describe, expect, it, vi } from 'vite-plus/test'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import PluginTypeSwitch from '../plugin-type-switch'
+import styles from '../plugin-type-switch.module.css'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
@@ -13,7 +14,7 @@ vi.mock('#i18n', async () => {
}
})
-const renderSwitch = (searchParams = '') => {
+const renderSwitch = (searchParams = '', props?: ComponentProps) => {
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
const Wrapper = ({ children }: { children: ReactNode }) => (
@@ -21,7 +22,7 @@ const renderSwitch = (searchParams = '') => {
)
- return { ...render( , { wrapper: Wrapper }), onUrlUpdate }
+ return { ...render( , { wrapper: Wrapper }), onUrlUpdate }
}
describe('PluginTypeSwitch', () => {
@@ -41,7 +42,7 @@ describe('PluginTypeSwitch', () => {
expect(screen.getByRole('button', { name: 'category.agents' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'category.triggers' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'category.extensions' })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'category.bundles' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'category.bundles' })).not.toBeInTheDocument()
})
it('updates the category in the URL when selected', async () => {
@@ -56,4 +57,28 @@ describe('PluginTypeSwitch', () => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe('model')
expect(modelsButton).toHaveAttribute('aria-pressed', 'true')
})
+
+ it('exposes the selected category and updates the URL in the home variant', async () => {
+ const user = userEvent.setup()
+ const { onUrlUpdate } = renderSwitch('?category=all', { variant: 'home' })
+ const categoryGroup = screen.getByRole('group', { name: 'allCategories' })
+
+ expect(categoryGroup).toHaveClass('w-full', 'justify-start', 'gap-1')
+ const activeCategory = screen.getByRole('button', { name: 'category.all' })
+ const inactiveCategory = screen.getByRole('button', { name: 'category.models' })
+
+ expect(activeCategory).toHaveAttribute('aria-pressed', 'true')
+ expect(activeCategory).toHaveClass(styles.homeItem!, styles.homeItemActive!)
+ expect(inactiveCategory).toHaveClass(styles.homeItem!)
+ expect(inactiveCategory).not.toHaveClass(styles.homeItemActive!)
+ expect(screen.getByRole('button', { name: 'categorySingle.datasource' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'categorySingle.agent' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'category.models' }))
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
+ const update = onUrlUpdate.mock.calls.at(-1)?.[0]
+ expect(update?.searchParams.get('category')).toBe('model')
+ expect(update?.options.scroll).toBe(false)
+ })
})
diff --git a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx
index ec93fe23bde..9cf84fed0dc 100644
--- a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx
+++ b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx
@@ -163,7 +163,7 @@ describe('useMarketplacePlugins', () => {
})
})
- it('should handle API error gracefully', async () => {
+ it('should surface API errors instead of an empty success', async () => {
mockSearchAdvanced.mockRejectedValue(new Error('Network error'))
const { useMarketplacePlugins } = await import('../query')
@@ -177,11 +177,14 @@ describe('useMarketplacePlugins', () => {
)
await waitFor(() => {
- expect(result.current.data).toBeDefined()
+ expect(result.current.isError).toBe(true)
})
- expect(result.current.data?.pages[0]!.plugins).toEqual([])
- expect(result.current.data?.pages[0]!.total).toBe(0)
+ // No synthesized page: an empty success let a backend outage render as
+ // "no plugins found", suppressed retries, and permanently disabled
+ // getNextPageParam for this key.
+ expect(result.current.data).toBeUndefined()
+ expect(result.current.error).toEqual(new Error('Network error'))
})
it('should determine next page correctly via getNextPageParam', async () => {
diff --git a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts
index 62a786e5be1..7f0654e35f0 100644
--- a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts
+++ b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts
@@ -9,6 +9,7 @@ describe('marketplace search params', () => {
)
expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('')
expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([])
+ expect(marketplaceSearchParamsParsers.languages.parseServerSide(undefined)).toEqual([])
})
it('parses supported query values with the configured parsers', () => {
@@ -23,5 +24,9 @@ describe('marketplace search params', () => {
'rag',
'search',
])
+ expect(marketplaceSearchParamsParsers.languages.parseServerSide('en,zh-Hans')).toEqual([
+ 'en',
+ 'zh-Hans',
+ ])
})
})
diff --git a/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx
new file mode 100644
index 00000000000..34e350ac773
--- /dev/null
+++ b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx
@@ -0,0 +1,65 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type { ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockFetchPluginBanners, mockGetLocaleOnServer } = vi.hoisted(() => ({
+ mockFetchPluginBanners: vi.fn(),
+ mockGetLocaleOnServer: vi.fn(),
+}))
+
+vi.mock('@/i18n-config/server', () => ({
+ getLocaleOnServer: mockGetLocaleOnServer,
+}))
+
+vi.mock('../home/banners', async (importOriginal) => {
+ const original = await importOriginal()
+
+ return {
+ ...original,
+ fetchPluginBanners: mockFetchPluginBanners,
+ }
+})
+
+vi.mock('../hydration-server', () => ({
+ HydrateQueryClient: ({ children }: { children: ReactNode }) => children,
+}))
+
+vi.mock('../view', () => ({
+ MarketplaceView: ({ banners }: { banners: PluginBanner[] }) => (
+ Server banners: {banners.length}
+ ),
+}))
+
+describe('Marketplace server entry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('prefetches localized homepage banners before rendering the standalone view', async () => {
+ mockGetLocaleOnServer.mockResolvedValue('en-US')
+ mockFetchPluginBanners.mockResolvedValue([
+ {
+ id: 'banner-1',
+ title: 'Trending',
+ sort: 1,
+ language: 'en-US',
+ style_type: 'blog',
+ content: {
+ blog_title: 'Dify update',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+ },
+ ] satisfies PluginBanner[])
+
+ const { default: Marketplace } = await import('../index')
+ const element = await Marketplace({ variant: 'home' })
+
+ render(element)
+
+ expect(screen.getByText('Server banners: 1')).toBeInTheDocument()
+ expect(mockGetLocaleOnServer).toHaveBeenCalledOnce()
+ expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx
index 03fd80dd333..a223d6524b0 100644
--- a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx
+++ b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx
@@ -1,9 +1,10 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { renderHook, waitFor } from '@testing-library/react'
+import { act, renderHook, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
+import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
vi.mock('@/config', () => ({
API_PREFIX: '/api',
@@ -116,6 +117,7 @@ describe('useMarketplaceData', () => {
expect(result.current.plugins).toBeDefined()
expect(result.current.pluginsTotal).toBeDefined()
+ expect(mockCollections).not.toHaveBeenCalled()
document.body.removeChild(container)
})
@@ -161,6 +163,35 @@ describe('useMarketplaceData', () => {
document.body.removeChild(container)
})
+ it('should use the server route category for hydrated standalone search', async () => {
+ const { useMarketplaceData } = await import('../state')
+ const { Wrapper } = createWrapper('?q=openai')
+
+ const container = document.createElement('div')
+ container.id = 'marketplace-container'
+ document.body.appendChild(container)
+
+ const { result } = renderHook(() => useMarketplaceData(PLUGIN_TYPE_SEARCH_MAP.model), {
+ wrapper: Wrapper,
+ })
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false)
+ })
+
+ expect(mockSearchAdvanced).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ category: 'model',
+ query: 'openai',
+ }),
+ }),
+ expect.any(Object),
+ )
+
+ document.body.removeChild(container)
+ })
+
it('should trigger scroll pagination via handlePageChange callback', async () => {
// Return enough data to indicate hasNextPage (40 of 200 total)
mockSearchAdvanced.mockResolvedValue({
@@ -287,4 +318,53 @@ describe('useMarketplaceData', () => {
document.body.removeChild(container)
})
+
+ // Regression: `isSearchMode` was derived from the raw URL value while the
+ // request body used the 500ms-debounced one. Keystroke #1 therefore flipped
+ // the hook into search mode with an empty query, firing a full search for ''
+ // whose generic top-plugins results rendered until the real ones replaced
+ // them — the wrong-results flash at the start of every search session.
+ it('should never issue an empty-query search when typing starts', async () => {
+ const { useMarketplaceData } = await import('../state')
+ const { useSearchPluginText } = await import('../atoms')
+ const { Wrapper } = createWrapper('?category=all')
+
+ const container = document.createElement('div')
+ container.id = 'marketplace-container'
+ document.body.appendChild(container)
+
+ const { result } = renderHook(
+ () => ({
+ data: useMarketplaceData(),
+ setSearch: useSearchPluginText()[1],
+ }),
+ { wrapper: Wrapper },
+ )
+
+ await waitFor(() => {
+ expect(result.current.data.isLoading).toBe(false)
+ })
+
+ await act(async () => {
+ await result.current.setSearch('openai')
+ })
+
+ await waitFor(
+ () => {
+ expect(mockSearchAdvanced).toHaveBeenCalled()
+ },
+ { timeout: 3000 },
+ )
+
+ expect(mockSearchAdvanced).not.toHaveBeenCalledWith(
+ expect.objectContaining({ body: expect.objectContaining({ query: '' }) }),
+ expect.anything(),
+ )
+ expect(mockSearchAdvanced).toHaveBeenCalledWith(
+ expect.objectContaining({ body: expect.objectContaining({ query: 'openai' }) }),
+ expect.anything(),
+ )
+
+ document.body.removeChild(container)
+ })
})
diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts
index ec9e0b66772..546d16e7c3f 100644
--- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts
+++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts
@@ -229,13 +229,14 @@ describe('getMarketplacePluginsByCollectionId', () => {
expect(result).toHaveLength(2)
})
- it('should handle fetch error and return empty array', async () => {
+ it('should propagate fetch errors', async () => {
mockCollectionPlugins.mockRejectedValueOnce(new Error('Network error'))
const { getMarketplacePluginsByCollectionId } = await import('../utils')
- const result = await getMarketplacePluginsByCollectionId('test-collection')
- expect(result).toEqual([])
+ await expect(getMarketplacePluginsByCollectionId('test-collection')).rejects.toThrow(
+ 'Network error',
+ )
})
it('should send an empty body when query is omitted', async () => {
@@ -299,14 +300,35 @@ describe('getMarketplaceCollectionsAndPlugins', () => {
expect(result.marketplaceCollectionPluginsMap).toBeDefined()
})
- it('should handle fetch error and return empty data', async () => {
+ it('should propagate a failing collections request', async () => {
mockCollections.mockRejectedValueOnce(new Error('Network error'))
+ const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
+
+ // Resolving an empty catalog here made a backend outage indistinguishable
+ // from "no collections", cached as a success for the whole staleTime.
+ await expect(getMarketplaceCollectionsAndPlugins()).rejects.toThrow('Network error')
+ })
+
+ it('should keep the catalog when a single collection fails', async () => {
+ mockCollections.mockResolvedValueOnce({
+ data: {
+ collections: [
+ { name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
+ { name: 'broken', label: {}, description: {}, rule: '', created_at: '', updated_at: '' },
+ ],
+ },
+ })
+ mockCollectionPlugins
+ .mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } })
+ .mockRejectedValueOnce(new Error('collection down'))
+
const { getMarketplaceCollectionsAndPlugins } = await import('../utils')
const result = await getMarketplaceCollectionsAndPlugins()
- expect(result.marketplaceCollections).toEqual([])
- expect(result.marketplaceCollectionPluginsMap).toEqual({})
+ expect(result.marketplaceCollections).toHaveLength(2)
+ expect(result.marketplaceCollectionPluginsMap.ok).toHaveLength(1)
+ expect(result.marketplaceCollectionPluginsMap.broken).toEqual([])
})
it('should append condition and type to URL when provided', async () => {
@@ -431,23 +453,15 @@ describe('getMarketplacePlugins', () => {
expect(call![0].body.category).toBe('')
})
- it('should handle API error and return empty result', async () => {
+ it('should propagate API errors instead of synthesizing an empty page', async () => {
mockSearchAdvanced.mockRejectedValueOnce(new Error('API error'))
const { getMarketplacePlugins } = await import('../utils')
- const result = await getMarketplacePlugins(
- {
- query: 'fail',
- },
- 2,
- )
- expect(result).toEqual({
- plugins: [],
- total: 0,
- page: 2,
- page_size: 40,
- })
+ // A synthesized `{ plugins: [], total: 0 }` resolved as a *success*: no
+ // isError, no retry, a cached empty result, and getNextPageParam saw
+ // total 0 and killed pagination for that key permanently.
+ await expect(getMarketplacePlugins({ query: 'fail' }, 2)).rejects.toThrow('API error')
})
it('should pass abort signal when provided', async () => {
diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts
index a2118997a96..c01990d548a 100644
--- a/web/app/components/plugins/marketplace/atoms.ts
+++ b/web/app/components/plugins/marketplace/atoms.ts
@@ -1,9 +1,10 @@
import type { PluginsSort, SearchParamsFromCollection } from '@dify/contracts/marketplace'
+import type { ActivePluginType } from './constants'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
-import { useCallback } from 'react'
-import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
-import { marketplaceSearchParamsParsers } from './search-params'
+import { useCallback, useEffect } from 'react'
+import { DEFAULT_SORT } from './constants'
+import { marketplaceSearchParamsParsers, shouldSearchMarketplacePlugins } from './search-params'
const marketplaceSortAtom = atom(DEFAULT_SORT)
export function useMarketplaceSort() {
@@ -21,6 +22,9 @@ export function useActivePluginType() {
export function useFilterPluginTags() {
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
}
+export function useFilterTemplateLanguages() {
+ return useQueryState('languages', marketplaceSearchParamsParsers.languages)
+}
/**
* Not all categories have collections, so we need to
@@ -28,19 +32,48 @@ export function useFilterPluginTags() {
*/
export const searchModeAtom = atom(null)
-export function useMarketplaceSearchMode() {
- const [searchPluginText] = useSearchPluginText()
+export function useMarketplaceSearchMode(
+ activePluginTypeOverride?: ActivePluginType,
+ // Callers that debounce the query text MUST pass the debounced value here.
+ // Deciding "are we searching?" from the raw URL value while the request body
+ // carries the debounced one flips this hook true on keystroke #1, firing a
+ // wasted empty-query search whose generic top-plugins list renders for the
+ // debounce window before the real results replace it. '' is a meaningful
+ // override, so this is `??`, not `||`.
+ searchPluginTextOverride?: string,
+) {
+ const [searchPluginTextFromUrl] = useSearchPluginText()
+ const searchPluginText = searchPluginTextOverride ?? searchPluginTextFromUrl
const [filterPluginTags] = useFilterPluginTags()
- const [activePluginType] = useActivePluginType()
+ const [activePluginTypeFromUrl] = useActivePluginType()
+ const activePluginType = activePluginTypeOverride ?? activePluginTypeFromUrl
const searchMode = useAtomValue(searchModeAtom)
const isSearchMode =
- !!searchPluginText ||
- filterPluginTags.length > 0 ||
- (searchMode ?? !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))
+ searchMode === true ||
+ shouldSearchMarketplacePlugins({
+ category: activePluginType,
+ q: searchPluginText,
+ tags: filterPluginTags,
+ })
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)
diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts
index 5db8045a547..9dda37bd3dc 100644
--- a/web/app/components/plugins/marketplace/constants.ts
+++ b/web/app/components/plugins/marketplace/constants.ts
@@ -5,6 +5,12 @@ export const DEFAULT_SORT = {
sortOrder: 'DESC',
}
+/**
+ * DOM id of the marketplace scroll container. The route components render it
+ * and the scroll/viewport observers below the marketplace tree look it up.
+ */
+export const MARKETPLACE_CONTAINER_ID = 'marketplace-container'
+
export const SCROLL_BOTTOM_THRESHOLD = 100
export const PLUGIN_TYPE_SEARCH_MAP = {
diff --git a/web/app/components/plugins/marketplace/creator-center-url.ts b/web/app/components/plugins/marketplace/creator-center-url.ts
new file mode 100644
index 00000000000..33f2d3d5566
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-center-url.ts
@@ -0,0 +1,48 @@
+import { useSyncExternalStore } from 'react'
+
+export const PUBLIC_CREATOR_CENTER_URL = 'https://creators.dify.ai/'
+
+const subscribe = () => () => {}
+
+/**
+ * marketplace.dify.ai → creators.dify.ai
+ * marketplace.dify.dev → creators.dify.dev
+ * marketplace-staging.dify.dev → creators-staging.dify.dev
+ */
+export const rewriteMarketplaceOriginToCreators = (origin: string): string | null => {
+ if (!origin) return null
+
+ try {
+ const marketplaceUrl = new URL(origin)
+ const [service, ...domain] = marketplaceUrl.hostname.split('.')
+ if (!service?.startsWith('marketplace') || domain.length === 0) return null
+
+ marketplaceUrl.hostname = [service.replace(/^marketplace/, 'creators'), ...domain].join('.')
+ marketplaceUrl.pathname = '/'
+ marketplaceUrl.search = ''
+ marketplaceUrl.hash = ''
+ return marketplaceUrl.toString()
+ } catch {
+ return null
+ }
+}
+
+export const getCreatorCenterUrl = (marketplaceUrlPrefix: string, pageOrigin?: string): string => {
+ return (
+ rewriteMarketplaceOriginToCreators(pageOrigin ?? '') ||
+ rewriteMarketplaceOriginToCreators(marketplaceUrlPrefix) ||
+ PUBLIC_CREATOR_CENTER_URL
+ )
+}
+
+/**
+ * Prefer the current page origin when this is the standalone Marketplace, so a
+ * .dev deployment cannot inherit a baked-in .ai Creator Center URL.
+ */
+export const useCreatorCenterUrl = (marketplaceUrlPrefix: string) => {
+ return useSyncExternalStore(
+ subscribe,
+ () => getCreatorCenterUrl(marketplaceUrlPrefix, window.location.origin),
+ () => getCreatorCenterUrl(marketplaceUrlPrefix),
+ )
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx
new file mode 100644
index 00000000000..e81cb64ffa3
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx
@@ -0,0 +1,50 @@
+import type { CreatorCreation } from '../model'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import CreationCard from '../creation-card'
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: () => ,
+}))
+
+const creation: CreatorCreation = {
+ id: 'plugin:dify/search',
+ kind: 'plugin',
+ title: 'Search',
+ description: 'Search the web.',
+ target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: 'search' },
+ icon: { type: 'emoji', value: '🔎' },
+ dependencyIcons: ['/one.png', '/two.png'],
+ dependencyCount: 4,
+ updatedAt: 1,
+ createdAt: 1,
+ popularity: 1,
+}
+
+describe('CreationCard', () => {
+ it('renders a host link without selecting', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute(
+ 'href',
+ '/plugin/dify/search?language=en-US',
+ )
+ expect(screen.getByText('+2')).toBeInTheDocument()
+ })
+
+ it('selects in Dify without rendering a navigation target', async () => {
+ const user = userEvent.setup()
+ const onSelect = vi.fn()
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Search' }))
+ expect(onSelect).toHaveBeenCalledOnce()
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx
new file mode 100644
index 00000000000..39306668d88
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx
@@ -0,0 +1,94 @@
+import type { CreatorCreation } from '../model'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { renderWithNuqs } from '@/test/nuqs-testing'
+import CreatorContent from '../creator-content'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ const translations: Record = {
+ 'marketplace.creatorProfile.creations': 'Creations',
+ 'marketplace.creatorProfile.sortBy': 'Sort by',
+ 'marketplace.creatorProfile.sort.updatedAt': 'Recently updated',
+ 'marketplace.creatorProfile.sort.createdAt': 'Recently created',
+ 'marketplace.creatorProfile.sort.popularity': 'Most popular',
+ 'marketplace.creatorProfile.sort.asc': 'Sort ascending',
+ 'marketplace.creatorProfile.sort.desc': 'Sort descending',
+ 'marketplace.creatorProfile.type.plugin': 'Plugin',
+ 'marketplace.creatorProfile.type.template': 'Template',
+ }
+
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => translations[key] ?? key),
+ }),
+ }
+})
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: () => ,
+}))
+
+const createCreation = (
+ id: string,
+ title: string,
+ updatedAt: number,
+ createdAt: number,
+ popularity: number,
+): CreatorCreation => ({
+ id,
+ kind: 'plugin',
+ title,
+ description: `${title} description`,
+ target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: id },
+ icon: { type: 'emoji', value: 'P' },
+ dependencyIcons: [],
+ dependencyCount: 0,
+ updatedAt,
+ createdAt,
+ popularity,
+})
+
+const creations = [
+ createCreation('alpha', 'Alpha', 2, 3, 1),
+ createCreation('bravo', 'Bravo', 3, 1, 2),
+ createCreation('charlie', 'Charlie', 1, 2, 3),
+]
+
+const cardNames = () => screen.getAllByRole('link').map((link) => link.getAttribute('aria-label'))
+
+describe('CreatorContent', () => {
+ it('writes sort into the URL and reorders the current cards', async () => {
+ const user = userEvent.setup()
+ const { onUrlUpdate } = renderWithNuqs(
+ ({ type: 'link', href: `/creation/${creation.id}` })}
+ />,
+ )
+
+ expect(cardNames()).toEqual(['Bravo', 'Alpha', 'Charlie'])
+
+ await user.click(screen.getByRole('button', { name: 'Sort by Recently updated' }))
+ const recentlyUpdatedOption = screen.getByRole('menuitemradio', {
+ name: 'Recently updated',
+ })
+ const mostPopularOption = screen.getByRole('menuitemradio', { name: 'Most popular' })
+ expect(recentlyUpdatedOption).toHaveAttribute('aria-checked', 'true')
+ expect(mostPopularOption).toHaveAttribute('aria-checked', 'false')
+
+ await user.click(mostPopularOption)
+ await waitFor(() => {
+ expect(cardNames()).toEqual(['Charlie', 'Bravo', 'Alpha'])
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity')
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Sort ascending' }))
+ await waitFor(() => {
+ expect(cardNames()).toEqual(['Alpha', 'Bravo', 'Charlie'])
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_order')).toBe('asc')
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity')
+ })
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx
new file mode 100644
index 00000000000..d2d7327502e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx
@@ -0,0 +1,83 @@
+import type { CreatorProfileViewModel } from '../model'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import CreatorSidebar from '../creator-sidebar'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => key),
+ }),
+ }
+})
+
+vi.mock('../publisher-avatar', () => ({
+ default: ({ className, size }: { className?: string; size?: number }) => (
+
+ ),
+}))
+
+const profile: CreatorProfileViewModel['profile'] = {
+ kind: 'individual',
+ displayName: 'Creator',
+ handle: 'creator',
+ avatarUrl: '',
+ backgroundUrl: '',
+ badges: [],
+ socialLinks: [
+ { platform: 'website', href: 'https://example.com/', label: 'example.com' },
+ { platform: 'x', href: 'https://x.com/creator', label: 'x.com/creator' },
+ {
+ platform: 'instagram',
+ href: 'https://instagram.com/creator',
+ label: 'instagram.com/creator',
+ },
+ {
+ platform: 'youtube',
+ href: 'https://youtube.com/creator',
+ label: 'youtube.com/creator',
+ },
+ { platform: 'figma', href: 'https://figma.com/@creator', label: 'figma.com/@creator' },
+ { platform: 'github', href: 'https://github.com/creator', label: 'github.com/creator' },
+ ],
+}
+
+describe('CreatorSidebar social links', () => {
+ it('adds a light shadow without changing the avatar geometry', () => {
+ render( )
+
+ const avatar = screen.getByTestId('publisher-avatar')
+
+ expect(avatar).toHaveClass('shadow-xs')
+ expect(avatar).toHaveClass(
+ 'absolute',
+ '-top-12',
+ '-left-2',
+ '!size-20',
+ 'border-[1.5px]',
+ 'md:-top-[68px]',
+ 'md:!size-[100px]',
+ )
+ expect(avatar).toHaveAttribute('data-size', '100')
+ })
+
+ it('renders a static platform icon at the start of every social row', () => {
+ render( )
+
+ const expectedClasses = [
+ ['example.com', 'i-ri-global-line'],
+ ['x.com/creator', 'i-ri-twitter-x-fill'],
+ ['instagram.com/creator', 'i-ri-instagram-line'],
+ ['youtube.com/creator', 'i-ri-youtube-fill'],
+ ['figma.com/@creator', 'i-ri-figma-line'],
+ ['github.com/creator', 'i-ri-github-fill'],
+ ]
+
+ for (const [name, iconClass] of expectedClasses) {
+ const link = screen.getByRole('link', { name })
+ expect(link.firstElementChild).toHaveClass(iconClass!)
+ expect(link.firstElementChild).toHaveClass('size-4')
+ }
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts
new file mode 100644
index 00000000000..c35b8ccc774
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts
@@ -0,0 +1,261 @@
+import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { loadCreatorProfile } from '../data.server'
+
+const mocks = vi.hoisted(() => ({
+ creatorDetail: vi.fn(),
+ organizationDetail: vi.fn(),
+ publisherPlugins: vi.fn(),
+ publisherTemplates: vi.fn(),
+}))
+
+vi.mock('server-only', () => ({}))
+vi.mock('@/config', () => ({ MARKETPLACE_API_PREFIX: 'https://marketplace.example/api/v1' }))
+vi.mock('@/service/client', () => ({ marketplaceClient: mocks }))
+
+const plugin = {
+ type: 'plugin',
+ org: 'dify',
+ name: 'search',
+ plugin_id: 'dify/search',
+ label: { en_US: 'Search' },
+ brief: { en_US: 'Search the web.' },
+ tags: [],
+} as unknown as MarketplacePlugin
+
+const template = {
+ id: 'template-one',
+ template_name: 'Template one',
+ overview: 'Build an app.',
+ icon: '📄',
+ icon_background: '#fff',
+ icon_file_key: '',
+ usage_count: 1,
+ categories: [],
+} as MarketplaceTemplate
+
+describe('loadCreatorProfile', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.creatorDetail.mockResolvedValue({
+ data: {
+ creator: {
+ unique_handle: 'creator',
+ display_name: 'Creator',
+ social_links: [],
+ },
+ },
+ })
+ mocks.organizationDetail.mockResolvedValue({ data: {} })
+ mocks.publisherPlugins.mockResolvedValue({ data: { plugins: [plugin] } })
+ mocks.publisherTemplates.mockResolvedValue({ data: { templates: [template] } })
+ })
+
+ it('loads individual data through all publisher contracts', async () => {
+ const loaded = await loadCreatorProfile({
+ uniqueHandle: 'creator-one',
+ locale: 'en-US',
+ })
+
+ expect(mocks.creatorDetail).toHaveBeenCalledWith({
+ params: { uniqueHandle: 'creator-one' },
+ })
+ expect(mocks.publisherPlugins).toHaveBeenCalledWith({
+ params: { uniqueHandle: 'creator-one' },
+ query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
+ })
+ expect(loaded?.viewModel.creations).toHaveLength(2)
+ expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined()
+ expect(loaded?.viewModel.profile.backgroundUrl).toBe('')
+ expect(loaded?.viewModel.profile.avatarUrl).toBe('')
+ })
+
+ it('only emits the remote background URL when the API reports an uploaded background', async () => {
+ mocks.creatorDetail.mockResolvedValue({
+ data: {
+ creator: {
+ unique_handle: 'creator-with-background',
+ display_name: 'Creator with background',
+ background_image: 'creator/background.png',
+ social_links: [],
+ },
+ },
+ })
+
+ const loaded = await loadCreatorProfile({
+ uniqueHandle: 'creator-with-background',
+ locale: 'en-US',
+ })
+
+ expect(loaded?.viewModel.profile.backgroundUrl).toBe(
+ 'https://marketplace.example/api/v1/creators/creator-with-background/background-image',
+ )
+ })
+
+ it('only emits the remote avatar URL when the API reports an uploaded avatar', async () => {
+ mocks.creatorDetail.mockResolvedValue({
+ data: {
+ creator: {
+ unique_handle: 'creator-with-avatar',
+ display_name: 'Creator with avatar',
+ avatar: 'creator/avatar.png',
+ social_links: [],
+ },
+ },
+ })
+
+ const loaded = await loadCreatorProfile({
+ uniqueHandle: 'creator-with-avatar',
+ locale: 'en-US',
+ })
+
+ expect(loaded?.viewModel.profile.avatarUrl).toBe(
+ 'https://marketplace.example/api/v1/creators/creator-with-avatar/avatar',
+ )
+ })
+
+ it('loads evanz from the Marketplace API without a development fixture branch', async () => {
+ await loadCreatorProfile({ uniqueHandle: 'evanz', locale: 'en-US' })
+
+ expect(mocks.creatorDetail).toHaveBeenCalledWith({ params: { uniqueHandle: 'evanz' } })
+ expect(mocks.publisherTemplates).toHaveBeenCalledWith({
+ params: { uniqueHandle: 'evanz' },
+ query: { page: 1, page_size: 40, sort_by: 'updated_at', sort_order: 'DESC' },
+ })
+ })
+
+ it('forwards popularity sort to each publisher API column', async () => {
+ await loadCreatorProfile({
+ uniqueHandle: 'creator-one',
+ locale: 'en-US',
+ sortBy: 'popularity',
+ sortOrder: 'asc',
+ })
+
+ expect(mocks.publisherPlugins).toHaveBeenCalledWith({
+ params: { uniqueHandle: 'creator-one' },
+ query: { page: 1, page_size: 40, sort_by: 'install_count', sort_order: 'ASC' },
+ })
+ expect(mocks.publisherTemplates).toHaveBeenCalledWith({
+ params: { uniqueHandle: 'creator-one' },
+ query: { page: 1, page_size: 40, sort_by: 'usage_count', sort_order: 'ASC' },
+ })
+ })
+
+ it('merge-sorts mixed creations after the publisher responses return', async () => {
+ mocks.publisherPlugins.mockResolvedValue({
+ data: {
+ plugins: [{ ...plugin, install_count: 2, created_at: '2026-01-01T00:00:00Z' }],
+ },
+ })
+ mocks.publisherTemplates.mockResolvedValue({
+ data: {
+ templates: [{ ...template, usage_count: 5, created_at: '2026-01-02T00:00:00Z' }],
+ },
+ })
+
+ const loaded = await loadCreatorProfile({
+ uniqueHandle: 'creator-one',
+ locale: 'en-US',
+ sortBy: 'popularity',
+ sortOrder: 'desc',
+ })
+
+ expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template', 'plugin'])
+ })
+
+ it('fetches remaining publisher pages until the reported total is loaded', async () => {
+ const extraPlugin = {
+ ...plugin,
+ name: 'extra',
+ plugin_id: 'dify/extra',
+ } as MarketplacePlugin
+ mocks.publisherPlugins
+ .mockResolvedValueOnce({
+ data: { plugins: [plugin], total: 2 },
+ })
+ .mockResolvedValueOnce({
+ data: { plugins: [extraPlugin], total: 2 },
+ })
+
+ const loaded = await loadCreatorProfile({
+ uniqueHandle: 'paged-creator',
+ locale: 'en-US',
+ })
+
+ expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(1, {
+ params: { uniqueHandle: 'paged-creator' },
+ query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
+ })
+ expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(2, {
+ params: { uniqueHandle: 'paged-creator' },
+ query: { page: 2, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
+ })
+ expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined()
+ expect(loaded?.pluginsByCreationId['plugin:dify/extra']).toBeDefined()
+ })
+
+ it('keeps successful creations when one publisher request fails', async () => {
+ mocks.publisherPlugins.mockRejectedValue(new Error('plugin request failed'))
+
+ const loaded = await loadCreatorProfile({
+ uniqueHandle: 'creator-partial',
+ locale: 'en-US',
+ })
+
+ expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template'])
+ })
+
+ it('returns null when the primary creator does not exist', async () => {
+ mocks.creatorDetail.mockResolvedValue({ data: {} })
+
+ await expect(
+ loadCreatorProfile({ uniqueHandle: 'missing-creator', locale: 'en-US' }),
+ ).resolves.toBeNull()
+ })
+
+ it('rethrows when the primary creator request fails', async () => {
+ mocks.creatorDetail.mockRejectedValue(new Error('creator request timed out'))
+
+ await expect(
+ loadCreatorProfile({ uniqueHandle: 'slow-creator', locale: 'en-US' }),
+ ).rejects.toThrow('creator request timed out')
+ })
+
+ it('rethrows when the organization request fails', async () => {
+ mocks.organizationDetail.mockRejectedValue(new Error('organization request timed out'))
+
+ await expect(
+ loadCreatorProfile({
+ uniqueHandle: 'slow-org',
+ publisherType: 'organization',
+ locale: 'en-US',
+ }),
+ ).rejects.toThrow('organization request timed out')
+ })
+
+ it('maps organizations to the shared creator profile shape', async () => {
+ mocks.organizationDetail.mockResolvedValue({
+ data: {
+ organization: {
+ id: 'org-id',
+ unique_handle: 'dify-org',
+ display_name: 'Dify Org',
+ social_links: [],
+ },
+ },
+ })
+
+ const loaded = await loadCreatorProfile({
+ uniqueHandle: 'dify-org',
+ publisherType: 'organization',
+ locale: 'en-US',
+ })
+
+ expect(mocks.organizationDetail).toHaveBeenCalledWith({ params: { id: 'dify-org' } })
+ expect(loaded?.viewModel.profile).toMatchObject({
+ kind: 'organization',
+ displayName: 'Dify Org',
+ })
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx
new file mode 100644
index 00000000000..7b9fad871c8
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx
@@ -0,0 +1,229 @@
+import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
+import type { MarketplaceSearchSelection } from '../../home/marketplace-search-autocomplete'
+import type { LoadedCreatorProfile } from '../model'
+import type { Plugin } from '@/app/components/plugins/types'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithNuqs } from '@/test/nuqs-testing'
+import DifyCreatorProfile from '../dify-profile'
+
+const mocks = vi.hoisted(() => ({
+ push: vi.fn(),
+ installedInfo: { 'dify/deep_research': { version: '0.0.1' } },
+}))
+
+const deepResearchPlugin = {
+ type: 'plugin',
+ org: 'dify',
+ name: 'deep_research',
+ plugin_id: 'dify/deep_research',
+ latest_package_identifier: 'dify/deep_research:0.0.1@test',
+ label: { 'en-US': 'Deep Research' },
+ brief: { 'en-US': 'Research the web.' },
+} as unknown as Plugin
+
+const searchPlugin = {
+ ...deepResearchPlugin,
+ name: 'search_result',
+ plugin_id: 'dify/search_result',
+ latest_package_identifier: 'dify/search_result:0.0.1@test',
+ label: { 'en-US': 'Search result' },
+} as Plugin
+
+const template: MarketplaceTemplate = {
+ id: 'template-one',
+ template_name: 'Research Template',
+ overview: 'Build a research app.',
+ icon: 'R',
+ icon_background: '#fff',
+ icon_file_key: '',
+ publisher_unique_handle: 'dify',
+ usage_count: 1,
+ categories: [],
+}
+
+const loadedProfile: LoadedCreatorProfile = {
+ viewModel: {
+ profile: {
+ kind: 'individual',
+ displayName: 'Creator',
+ handle: 'creator',
+ avatarUrl: '',
+ backgroundUrl: '',
+ badges: [],
+ socialLinks: [],
+ },
+ creations: [
+ {
+ id: 'plugin:dify/deep_research',
+ kind: 'plugin',
+ title: 'Deep Research',
+ description: 'Research the web.',
+ target: {
+ type: 'plugin',
+ pluginType: 'plugin',
+ org: 'dify',
+ name: 'deep_research',
+ },
+ icon: { type: 'emoji', value: 'R' },
+ dependencyIcons: [],
+ dependencyCount: 0,
+ updatedAt: 1,
+ createdAt: 1,
+ popularity: 1,
+ },
+ {
+ id: 'template:template-one',
+ kind: 'template',
+ title: 'Research Template',
+ description: 'Build a research app.',
+ target: {
+ type: 'template',
+ id: 'template-one',
+ publisher: 'dify',
+ templateName: 'Research Template',
+ },
+ icon: { type: 'emoji', value: 'R' },
+ dependencyIcons: [],
+ dependencyCount: 0,
+ updatedAt: 1,
+ createdAt: 1,
+ popularity: 1,
+ },
+ ],
+ },
+ pluginsByCreationId: {
+ 'plugin:dify/deep_research': deepResearchPlugin,
+ },
+ templatesByCreationId: {
+ 'template:template-one': template,
+ },
+}
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => key),
+ }),
+ }
+})
+
+vi.mock('@/next/navigation', () => ({
+ useRouter: () => ({ push: mocks.push }),
+}))
+
+vi.mock('@/app/components/main-nav/components/account-section', () => ({
+ default: () =>
,
+}))
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
+ default: () => ({ installedInfo: mocks.installedInfo }),
+}))
+
+vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
+ default: ({ manifest }: { manifest: { name: string } }) => (
+ {manifest.name}
+ ),
+}))
+
+vi.mock('../../detail-dialog', () => ({
+ default: ({
+ isInstalled,
+ onInstall,
+ plugin,
+ }: {
+ isInstalled: boolean
+ onInstall: () => void
+ plugin: { name: string }
+ }) => (
+
+ {plugin.name}
+ {isInstalled ? 'installed' : 'not installed'}
+
+ Install plugin
+
+
+ ),
+}))
+
+vi.mock('../../templates/template-detail-dialog', () => ({
+ default: ({
+ onInstall,
+ template,
+ }: {
+ onInstall: () => void
+ template: { template_name: string }
+ }) => (
+
+ {template.template_name}
+
+ Install template
+
+
+ ),
+}))
+
+vi.mock('../header', () => ({
+ default: ({
+ onSuggestionSelect,
+ }: {
+ onSuggestionSelect: (selection: MarketplaceSearchSelection) => void
+ }) => (
+ {
+ onSuggestionSelect({ kind: 'plugin', plugin: searchPlugin })
+ }}
+ >
+ Select search plugin
+
+ ),
+}))
+
+describe('DifyCreatorProfile', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('opens the existing plugin detail flow with installed state', async () => {
+ const user = userEvent.setup()
+ renderWithNuqs( )
+
+ await user.click(screen.getByRole('button', { name: 'Deep Research' }))
+
+ const dialog = screen.getByRole('dialog', { name: 'plugin-detail' })
+ expect(dialog).toHaveTextContent('deep_research')
+ expect(dialog).toHaveTextContent('installed')
+
+ await user.click(screen.getByRole('button', { name: 'Install plugin' }))
+ expect(screen.getByTestId('install-plugin')).toHaveTextContent('deep_research')
+ })
+
+ it('opens a template detail and imports it inside Dify', async () => {
+ const user = userEvent.setup()
+ renderWithNuqs( )
+
+ await user.click(screen.getByRole('button', { name: 'Research Template' }))
+ expect(screen.getByRole('dialog', { name: 'template-detail' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Install template' }))
+ expect(mocks.push).toHaveBeenCalledWith('/apps?template-id=template-one')
+ })
+
+ it('opens search results in the same plugin dialog controller', async () => {
+ const user = userEvent.setup()
+ renderWithNuqs( )
+
+ await user.click(screen.getByRole('button', { name: 'Select search plugin' }))
+
+ const dialog = screen.getByRole('dialog', { name: 'plugin-detail' })
+ expect(dialog).toHaveTextContent('search_result')
+ expect(dialog).toHaveTextContent('not installed')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx
new file mode 100644
index 00000000000..b8887a2b7c8
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import CreatorProfileHeader from '../header'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ const translations: Record = {
+ 'marketplace.home.plugins': 'Plugins',
+ 'marketplace.home.templates': 'Templates',
+ 'marketplace.creatorProfile.searchPlaceholder': 'Search plugins or templates',
+ 'mainNav.marketplace': 'Marketplace',
+ }
+
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => translations[key] ?? key),
+ }),
+ }
+})
+
+vi.mock('../../home/home-guide', () => ({
+ default: () =>
,
+}))
+
+vi.mock('../../home/marketplace-search-autocomplete', () => ({
+ MarketplaceSearchAutocomplete: () =>
,
+}))
+
+describe('CreatorProfileHeader', () => {
+ it('returns to the native Marketplace without marking a catalog tab active', () => {
+ render( )
+
+ const pluginsLink = screen.getByRole('link', { name: 'Plugins' })
+ const templatesLink = screen.getByRole('link', { name: 'Templates' })
+
+ expect(pluginsLink).toHaveAttribute('href', '/marketplace')
+ expect(pluginsLink).not.toHaveAttribute('aria-current')
+ expect(pluginsLink).not.toHaveClass('bg-state-base-active')
+ expect(templatesLink).not.toHaveAttribute('aria-current')
+ expect(templatesLink).not.toHaveClass('bg-state-base-active')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts
new file mode 100644
index 00000000000..ff8a507998c
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts
@@ -0,0 +1,195 @@
+import type {
+ MarketplaceCreator,
+ MarketplacePlugin,
+ MarketplaceTemplate,
+} from '@dify/contracts/marketplace'
+import { describe, expect, it } from 'vitest'
+import {
+ adaptCreatorProfile,
+ getStandaloneCreationHref,
+ normalizeCreatorSocialLink,
+ parseCreatorSortField,
+ parseCreatorSortOrder,
+ sortCreatorCreations,
+ toPublisherSortQuery,
+} from '../model'
+
+const creator: MarketplaceCreator = {
+ unique_handle: 'evanz',
+ display_name: 'Evan.Z',
+ social_links: ['github.com/evanz', 'javascript:alert(1)'],
+ badges: ['partner'],
+ verified: true,
+}
+
+const plugin = {
+ type: 'bundle',
+ org: 'dify',
+ name: 'research',
+ labels: { en_US: 'Research bundle' },
+ description: { en_US: 'Research reliably.' },
+ install_count: 20,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-02-01T00:00:00Z',
+} as unknown as MarketplacePlugin
+
+const template = {
+ id: 'template/one',
+ template_name: 'Research template',
+ overview: 'Start a research app.',
+ icon: '📄',
+ icon_background: '#fff',
+ icon_file_key: '',
+ publisher_unique_handle: 'dify',
+ usage_count: 10,
+ categories: [],
+ deps_plugins: ['dify/search'],
+ created_at: '2026-01-02T00:00:00Z',
+ updated_at: '2026-02-02T00:00:00Z',
+} as MarketplaceTemplate
+
+describe('creator profile model', () => {
+ it('normalizes DTOs into host-neutral creation targets and safe social links', () => {
+ const viewModel = adaptCreatorProfile({
+ creator,
+ kind: 'organization',
+ locale: 'en-US',
+ avatarUrl: '/avatar',
+ backgroundUrl: '/background',
+ plugins: [plugin],
+ templates: [template],
+ resolvePluginIcon: () => '/plugin-icon',
+ resolveTemplateIcon: () => '',
+ resolveDependencyIcon: (id) => `/dependency/${id}`,
+ })
+
+ expect(viewModel.profile.badges).toEqual(['partner', 'verified'])
+ expect(viewModel.profile.socialLinks).toEqual([
+ expect.objectContaining({ platform: 'github', href: 'https://github.com/evanz' }),
+ ])
+ expect(viewModel.creations[0]).toMatchObject({
+ title: 'Research bundle',
+ target: { type: 'plugin', pluginType: 'bundle', org: 'dify', name: 'research' },
+ })
+ expect(viewModel.creations[1]).toMatchObject({
+ target: {
+ type: 'template',
+ id: 'template/one',
+ publisher: 'dify',
+ templateName: 'Research template',
+ },
+ dependencyCount: 1,
+ })
+ })
+
+ it('builds standalone plugin, bundle, and template URLs outside the shared model', () => {
+ const viewModel = adaptCreatorProfile({
+ creator,
+ kind: 'individual',
+ locale: 'en-US',
+ avatarUrl: '',
+ backgroundUrl: '',
+ plugins: [plugin],
+ templates: [template],
+ resolvePluginIcon: () => '',
+ resolveTemplateIcon: () => '',
+ resolveDependencyIcon: () => '',
+ })
+
+ expect(getStandaloneCreationHref(viewModel.creations[0]!, 'zh-Hans')).toBe(
+ '/bundles/dify/research?language=zh-Hans',
+ )
+ expect(getStandaloneCreationHref(viewModel.creations[1]!, 'zh-Hans')).toBe(
+ '/template/dify/Research%20template?templateId=template%2Fone&creationType=templates&language=zh-Hans',
+ )
+ })
+
+ it('normalizes Unix-second, Unix-millisecond, and ISO timestamps', () => {
+ const unixSeconds = 1_767_225_600
+ const unixMilliseconds = 1_767_225_700_000
+ const viewModel = adaptCreatorProfile({
+ creator,
+ kind: 'individual',
+ locale: 'en-US',
+ avatarUrl: '',
+ backgroundUrl: '',
+ plugins: [
+ {
+ ...plugin,
+ created_at: unixSeconds,
+ version_updated_at: unixSeconds + 100,
+ },
+ ],
+ templates: [
+ {
+ ...template,
+ created_at: '2026-01-02T00:00:00Z',
+ updated_at: unixMilliseconds,
+ },
+ ],
+ resolvePluginIcon: () => '',
+ resolveTemplateIcon: () => '',
+ resolveDependencyIcon: () => '',
+ })
+
+ expect(viewModel.creations[0]).toMatchObject({
+ createdAt: unixSeconds * 1000,
+ updatedAt: (unixSeconds + 100) * 1000,
+ })
+ expect(viewModel.creations[1]).toMatchObject({
+ createdAt: Date.parse('2026-01-02T00:00:00Z'),
+ updatedAt: unixMilliseconds,
+ })
+ })
+
+ it('maps each UI sort onto the matching plugin and template API columns', () => {
+ expect(toPublisherSortQuery('updatedAt', 'desc')).toEqual({
+ plugins: { sort_by: 'version_updated_at', sort_order: 'DESC' },
+ templates: { sort_by: 'updated_at', sort_order: 'DESC' },
+ })
+ expect(toPublisherSortQuery('createdAt', 'asc')).toEqual({
+ plugins: { sort_by: 'created_at', sort_order: 'ASC' },
+ templates: { sort_by: 'created_at', sort_order: 'ASC' },
+ })
+ expect(toPublisherSortQuery('popularity', 'desc')).toEqual({
+ plugins: { sort_by: 'install_count', sort_order: 'DESC' },
+ templates: { sort_by: 'usage_count', sort_order: 'DESC' },
+ })
+ })
+
+ it('falls back to recently updated descending for unknown URL sort values', () => {
+ expect(parseCreatorSortField('garbage')).toBe('updatedAt')
+ expect(parseCreatorSortField(undefined)).toBe('updatedAt')
+ expect(parseCreatorSortOrder('sideways')).toBe('desc')
+ expect(parseCreatorSortOrder('ASC')).toBe('asc')
+ })
+
+ it('sorts all fields in both directions and preserves equal-value order', () => {
+ const creations = [
+ { id: 'first', updatedAt: 1, createdAt: 3, popularity: 2 },
+ { id: 'second', updatedAt: 1, createdAt: 2, popularity: 3 },
+ { id: 'third', updatedAt: 2, createdAt: 1, popularity: 1 },
+ ] as ReturnType['creations']
+
+ expect(sortCreatorCreations(creations, 'updatedAt', 'asc').map(({ id }) => id)).toEqual([
+ 'first',
+ 'second',
+ 'third',
+ ])
+ expect(sortCreatorCreations(creations, 'createdAt', 'desc').map(({ id }) => id)).toEqual([
+ 'first',
+ 'second',
+ 'third',
+ ])
+ expect(sortCreatorCreations(creations, 'popularity', 'desc').map(({ id }) => id)).toEqual([
+ 'second',
+ 'first',
+ 'third',
+ ])
+ })
+
+ it('rejects unsafe URL schemes', () => {
+ expect(normalizeCreatorSocialLink('data:text/html,bad')).toBeNull()
+ expect(normalizeCreatorSocialLink('mailto:test@example.com')).toBeNull()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx
new file mode 100644
index 00000000000..ae7959e16aa
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx
@@ -0,0 +1,64 @@
+import type { CreatorProfileViewModel } from '../model'
+import { render } from 'vitest-browser-react'
+import CreatorProfileView from '../view'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => key),
+ }),
+ }
+})
+
+vi.mock('../creator-sidebar', () => ({
+ default: () => ,
+}))
+
+vi.mock('../creator-content', () => ({
+ default: () => (
+
+ ),
+}))
+
+const profile: CreatorProfileViewModel = {
+ profile: {
+ kind: 'individual',
+ displayName: 'Creator',
+ handle: 'creator',
+ avatarUrl: '',
+ backgroundUrl: '',
+ badges: [],
+ socialLinks: [],
+ },
+ creations: [],
+}
+
+describe('CreatorProfileView layout', () => {
+ it('keeps the profile background behind content taller than its scrollport', async () => {
+ const screen = await render(
+
+ ({ type: 'link', href: '/' })}
+ />
+
,
+ )
+
+ const scrollport = screen.getByTestId('creator-scrollport').element()
+ const profileRoot = scrollport.firstElementChild as HTMLElement
+ const creations = screen.getByTestId('creator-creations').element()
+
+ expect(profileRoot.getBoundingClientRect().bottom).toBeGreaterThanOrEqual(
+ creations.getBoundingClientRect().bottom,
+ )
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx
new file mode 100644
index 00000000000..e6f5dec38c5
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx
@@ -0,0 +1,88 @@
+import type { CreatorProfileViewModel } from '../model'
+import { fireEvent, render } from '@testing-library/react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import CreatorProfileView from '../view'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => key),
+ }),
+ }
+})
+
+vi.mock('../creator-sidebar', () => ({
+ default: () => ,
+}))
+
+vi.mock('../creator-content', () => ({
+ default: () => ,
+}))
+
+const profile: CreatorProfileViewModel = {
+ profile: {
+ kind: 'individual',
+ displayName: 'Creator',
+ handle: 'creator',
+ avatarUrl: '/creator-avatar.png',
+ backgroundUrl: '/creator-background.png',
+ badges: [],
+ socialLinks: [],
+ },
+ creations: [],
+}
+
+describe('CreatorProfileView SSR background', () => {
+ it('includes the default background in server markup before the remote background loads', () => {
+ const markup = renderToStaticMarkup(
+ ({ type: 'link', href: '/' })}
+ />,
+ )
+
+ expect(markup).toContain('default-background.png')
+ expect(markup).toContain('src="/creator-background.png"')
+ })
+
+ it('server-renders only the default background when the profile has no background', () => {
+ const markup = renderToStaticMarkup(
+ ({ type: 'link', href: '/' })}
+ />,
+ )
+
+ expect(markup).toContain('default-background.png')
+ expect(markup).not.toContain(' {
+ const { container } = render(
+ ({ type: 'link', href: '/' })}
+ />,
+ )
+ const remoteBackground = container.querySelector(
+ 'img[src="/creator-background.png"]',
+ )!
+
+ fireEvent.error(remoteBackground)
+
+ expect(remoteBackground).toHaveAttribute('hidden')
+ expect(remoteBackground).toHaveClass('border-0')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png b/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png
new file mode 100644
index 00000000000..704fbae82e1
Binary files /dev/null and b/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png differ
diff --git a/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx b/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx
new file mode 100644
index 00000000000..72d62ba275e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import type { CreatorCreation, CreatorCreationAction } from './model'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useTranslation } from '#i18n'
+import AppIcon from '@/app/components/base/app-icon'
+import CornerMark from '@/app/components/plugins/card/base/corner-mark'
+import Link from '@/next/link'
+
+const MAX_VISIBLE_DEPENDENCIES = 7
+
+type CreationCardProps = {
+ creation: CreatorCreation
+ action: CreatorCreationAction
+}
+
+const cardClassName =
+ 'group relative flex h-[152px] min-w-0 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 text-left shadow-xs outline-hidden transition-shadow hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid'
+
+function CreationCardContent({ creation }: { creation: CreatorCreation }) {
+ const { t } = useTranslation()
+ const visibleDependencies = creation.dependencyIcons.slice(0, MAX_VISIBLE_DEPENDENCIES)
+ const remainingDependencies = Math.max(0, creation.dependencyCount - visibleDependencies.length)
+
+ return (
+ <>
+ $[`marketplace.creatorProfile.type.${creation.kind}`], { ns: 'plugin' })}
+ className={cn(
+ creation.kind === 'plugin' && '[&>div]:text-text-accent',
+ creation.kind === 'template' && '[&>div]:text-text-warning',
+ )}
+ />
+
+
+ {creation.icon.type === 'image' ? (
+
+ ) : (
+
+ )}
+
+ {creation.title}
+
+
+
+
+ {creation.description}
+
+
+
+ {visibleDependencies.map((icon) => (
+
+ ))}
+ {remainingDependencies > 0 && (
+
+ +{remainingDependencies}
+
+ )}
+
+ >
+ )
+}
+
+export default function CreationCard({ creation, action }: CreationCardProps) {
+ if (action.type === 'link') {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx
new file mode 100644
index 00000000000..8665049314f
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx
@@ -0,0 +1,156 @@
+'use client'
+
+import type {
+ CreatorCreation,
+ CreatorCreationAction,
+ CreatorSortField,
+ CreatorSortOrder,
+} from './model'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
+ DropdownMenuTrigger,
+} from '@langgenius/dify-ui/dropdown-menu'
+import { parseAsStringEnum, useQueryStates } from 'nuqs'
+import { useMemo } from 'react'
+import { useTranslation } from '#i18n'
+import CreationCard from './creation-card'
+import {
+ CREATOR_SORT_FIELDS,
+ DEFAULT_CREATOR_SORT_FIELD,
+ DEFAULT_CREATOR_SORT_ORDER,
+ sortCreatorCreations,
+} from './model'
+
+type CreatorContentProps = {
+ creations: CreatorCreation[]
+ getCreationAction: (creation: CreatorCreation) => CreatorCreationAction
+}
+
+const sortSearchOptions = { history: 'replace' as const, shallow: false, scroll: false }
+const creatorSortSearchParsers = {
+ sort_by: parseAsStringEnum([...CREATOR_SORT_FIELDS]).withDefault(
+ DEFAULT_CREATOR_SORT_FIELD,
+ ),
+ sort_order: parseAsStringEnum(['asc', 'desc']).withDefault(
+ DEFAULT_CREATOR_SORT_ORDER,
+ ),
+}
+
+export default function CreatorContent({ creations, getCreationAction }: CreatorContentProps) {
+ const { t } = useTranslation()
+ const [sort, setSort] = useQueryStates(creatorSortSearchParsers, sortSearchOptions)
+ const sortField = sort.sort_by
+ const sortOrder = sort.sort_order
+ const sortOptions: Array<{ value: CreatorSortField; label: string }> = [
+ {
+ value: 'updatedAt',
+ label: t(($) => $['marketplace.creatorProfile.sort.updatedAt'], { ns: 'plugin' }),
+ },
+ {
+ value: 'createdAt',
+ label: t(($) => $['marketplace.creatorProfile.sort.createdAt'], { ns: 'plugin' }),
+ },
+ {
+ value: 'popularity',
+ label: t(($) => $['marketplace.creatorProfile.sort.popularity'], { ns: 'plugin' }),
+ },
+ ]
+ const selectedSort = sortOptions.find((option) => option.value === sortField) ?? sortOptions[0]!
+ const sortedCreations = useMemo(
+ () => sortCreatorCreations(creations, sortField, sortOrder),
+ [creations, sortField, sortOrder],
+ )
+ const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc'
+
+ return (
+
+
+
+ {t(($) => $['marketplace.creatorProfile.creations'], { ns: 'plugin' })}
+
+
+
+
+ $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })} ${selectedSort.label}`}
+ className="flex h-8 items-center rounded-lg px-2 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
+ >
+
+ {t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })}
+
+ {selectedSort.label}
+
+
+
+
+ value={sortField}
+ onValueChange={(nextField) => {
+ void setSort({ sort_by: nextField, sort_order: sortOrder })
+ }}
+ >
+ {sortOptions.map((option) => (
+
+ key={option.value}
+ value={option.value}
+ closeOnClick
+ className="justify-between px-3 pr-2 system-md-regular text-text-primary"
+ >
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
$[`marketplace.creatorProfile.sort.${nextSortOrder}`], {
+ ns: 'plugin',
+ })}
+ title={t(($) => $[`marketplace.creatorProfile.sort.${nextSortOrder}`], {
+ ns: 'plugin',
+ })}
+ className="flex size-8 items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
+ onClick={() => {
+ void setSort({ sort_by: sortField, sort_order: nextSortOrder })
+ }}
+ >
+
+
+
+
+
+ {sortedCreations.length > 0 ? (
+
+ {sortedCreations.map((creation) => (
+
+ ))}
+
+ ) : (
+
+ {t(($) => $['marketplace.creatorProfile.empty'], { ns: 'plugin' })}
+
+ )}
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx b/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx
new file mode 100644
index 00000000000..0241983e870
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx
@@ -0,0 +1,114 @@
+'use client'
+
+import type { CreatorProfileViewModel, CreatorSocialPlatform } from './model'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useTranslation } from '#i18n'
+import Partner from '@/app/components/plugins/base/badges/partner'
+import Verified from '@/app/components/plugins/base/badges/verified'
+import PublisherAvatar from './publisher-avatar'
+
+type CreatorSidebarProps = {
+ profile: CreatorProfileViewModel['profile']
+}
+
+function SocialIcon({ platform }: { platform: CreatorSocialPlatform }) {
+ const className = 'size-4 shrink-0 text-text-tertiary'
+
+ if (platform === 'x') return
+ if (platform === 'instagram')
+ return
+ if (platform === 'youtube')
+ return
+ if (platform === 'figma') return
+ if (platform === 'github')
+ return
+
+ return
+}
+
+export default function CreatorSidebar({ profile }: CreatorSidebarProps) {
+ const { t } = useTranslation()
+ const isOrganization = profile.kind === 'organization'
+ const isPartner = profile.badges.includes('partner')
+ const isVerified = profile.badges.includes('verified')
+
+ return (
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/data.server.ts b/web/app/components/plugins/marketplace/creator-profile/data.server.ts
new file mode 100644
index 00000000000..6de6c8a4df2
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/data.server.ts
@@ -0,0 +1,201 @@
+import type {
+ MarketplaceCreator,
+ MarketplaceOrganization,
+ MarketplacePlugin,
+ MarketplaceTemplate,
+} from '@dify/contracts/marketplace'
+import type { CreatorSortField, CreatorSortOrder, LoadedCreatorProfile } from './model'
+import { cache } from 'react'
+import { MARKETPLACE_API_PREFIX } from '@/config'
+import { marketplaceClient } from '@/service/client'
+import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils'
+import {
+ adaptCreatorProfile,
+ parseCreatorSortField,
+ parseCreatorSortOrder,
+ sortCreatorCreations,
+ toPublisherSortQuery,
+} from './model'
+import 'server-only'
+
+const PAGE_SIZE = 40
+const MAX_PAGES = 5
+
+const fetchAllPublisherPages = async (
+ fetchPage: (page: number) => Promise<{ items: T[]; total?: number }>,
+) => {
+ const first = await fetchPage(1)
+ const items = [...first.items]
+ const total = first.total ?? items.length
+
+ for (let page = 2; page <= MAX_PAGES && items.length < total; page++) {
+ const next = await fetchPage(page)
+ if (next.items.length === 0) break
+ items.push(...next.items)
+ }
+
+ return items
+}
+
+const mapOrganizationToCreator = (
+ organization: MarketplaceOrganization,
+ uniqueHandle: string,
+): MarketplaceCreator => ({
+ id: organization.id || organization.name,
+ email: organization.email,
+ name: organization.name || organization.display_name || uniqueHandle,
+ display_name: organization.display_name || organization.name || uniqueHandle,
+ unique_handle: organization.unique_handle || uniqueHandle,
+ display_email: organization.display_email,
+ description: organization.description,
+ avatar: organization.avatar,
+ background_image: organization.background_image,
+ social_links: organization.social_links ?? [],
+ badges: organization.badges,
+ verified: organization.verified,
+ status: organization.status,
+ created_at: organization.created_at,
+ updated_at: organization.updated_at,
+})
+
+const getPublisher = async (uniqueHandle: string, publisherType?: string) => {
+ if (publisherType === 'organization') {
+ const response = await marketplaceClient.organizationDetail({
+ params: { id: uniqueHandle },
+ })
+ const organization = response.data?.organization
+ return organization ? mapOrganizationToCreator(organization, uniqueHandle) : undefined
+ }
+
+ const response = await marketplaceClient.creatorDetail({
+ params: { uniqueHandle },
+ })
+ return response.data?.creator
+}
+
+const getPublisherPlugins = async (
+ uniqueHandle: string,
+ sortField: CreatorSortField,
+ sortOrder: CreatorSortOrder,
+) => {
+ const { plugins } = toPublisherSortQuery(sortField, sortOrder)
+ return fetchAllPublisherPages(async (page) => {
+ const response = await marketplaceClient.publisherPlugins({
+ params: { uniqueHandle },
+ query: { page, page_size: PAGE_SIZE, ...plugins },
+ })
+ return {
+ items: response.data?.plugins ?? [],
+ total: response.data?.total,
+ }
+ })
+}
+
+const getPublisherTemplates = async (
+ uniqueHandle: string,
+ sortField: CreatorSortField,
+ sortOrder: CreatorSortOrder,
+) => {
+ const { templates } = toPublisherSortQuery(sortField, sortOrder)
+ return fetchAllPublisherPages(async (page) => {
+ const response = await marketplaceClient.publisherTemplates({
+ params: { uniqueHandle },
+ query: { page, page_size: PAGE_SIZE, ...templates },
+ })
+ return {
+ items: response.data?.templates ?? [],
+ total: response.data?.total,
+ }
+ })
+}
+
+const getTemplateIcon = (template: MarketplaceTemplate) =>
+ template.icon_file_key
+ ? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon`
+ : ''
+
+const getDependencyIcon = (pluginId: string) =>
+ `${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon`
+
+const loadCreatorProfileCached = cache(
+ async (
+ uniqueHandle: string,
+ publisherType: string | undefined,
+ locale: string,
+ sortField: CreatorSortField,
+ sortOrder: CreatorSortOrder,
+ ): Promise => {
+ const [creatorResult, pluginsResult, templatesResult] = await Promise.allSettled([
+ getPublisher(uniqueHandle, publisherType),
+ getPublisherPlugins(uniqueHandle, sortField, sortOrder),
+ getPublisherTemplates(uniqueHandle, sortField, sortOrder),
+ ])
+
+ if (creatorResult.status === 'rejected') throw creatorResult.reason
+ const creator = creatorResult.value
+ if (!creator) return null
+
+ const plugins: MarketplacePlugin[] =
+ pluginsResult.status === 'fulfilled' ? pluginsResult.value : []
+ const templates: MarketplaceTemplate[] =
+ templatesResult.status === 'fulfilled' ? templatesResult.value : []
+ const kind = publisherType === 'organization' ? 'organization' : 'individual'
+ const resource = kind === 'organization' ? 'organizations' : 'creators'
+ const encodedHandle = encodeURIComponent(uniqueHandle)
+ const backgroundUrl = creator.background_image
+ ? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/background-image`
+ : ''
+ const avatarUrl = creator.avatar
+ ? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/avatar`
+ : ''
+ const viewModel = adaptCreatorProfile({
+ creator,
+ kind,
+ locale,
+ avatarUrl,
+ backgroundUrl,
+ plugins,
+ templates,
+ resolvePluginIcon: getPluginIconInMarketplace,
+ resolveTemplateIcon: getTemplateIcon,
+ resolveDependencyIcon: getDependencyIcon,
+ })
+
+ return {
+ viewModel: {
+ ...viewModel,
+ creations: sortCreatorCreations(viewModel.creations, sortField, sortOrder),
+ },
+ pluginsByCreationId: Object.fromEntries(
+ plugins.map((plugin) => [
+ `${plugin.type}:${plugin.org}/${plugin.name}`,
+ getFormattedPlugin(plugin),
+ ]),
+ ),
+ templatesByCreationId: Object.fromEntries(
+ templates.map((template) => [`template:${template.id}`, template]),
+ ),
+ }
+ },
+)
+
+export const loadCreatorProfile = ({
+ uniqueHandle,
+ publisherType,
+ locale,
+ sortBy,
+ sortOrder,
+}: {
+ uniqueHandle: string
+ publisherType?: string
+ locale: string
+ sortBy?: string
+ sortOrder?: string
+}) =>
+ loadCreatorProfileCached(
+ uniqueHandle,
+ publisherType,
+ locale,
+ parseCreatorSortField(sortBy),
+ parseCreatorSortOrder(sortOrder),
+ )
diff --git a/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx
new file mode 100644
index 00000000000..39b8d53c8cd
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx
@@ -0,0 +1,142 @@
+'use client'
+
+import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
+import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete'
+import type { CreatorCreation, LoadedCreatorProfile } from './model'
+import type { Plugin } from '@/app/components/plugins/types'
+import { useMemo, useState } from 'react'
+import AccountSection from '@/app/components/main-nav/components/account-section'
+import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
+import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
+import { useRouter } from '@/next/navigation'
+import MarketplaceDetailDialog from '../detail-dialog'
+import TemplateDetailDialog from '../templates/template-detail-dialog'
+import { getFormattedPlugin } from '../utils'
+import CreatorProfileHeader from './header'
+import CreatorProfileView from './view'
+
+type SelectedCreation =
+ | { kind: 'plugin'; plugin: Plugin }
+ | { kind: 'template'; template: MarketplaceTemplate }
+
+type DifyCreatorProfileProps = {
+ loadedProfile: LoadedCreatorProfile
+ locale: string
+}
+
+const normalizePlugin = (plugin: Plugin): Plugin => ({
+ ...plugin,
+ label: plugin.label ?? {},
+ brief: plugin.brief ?? {},
+ description: plugin.description ?? {},
+ tags: plugin.tags ?? [],
+ badges: plugin.badges ?? null,
+})
+
+export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreatorProfileProps) {
+ const router = useRouter()
+ const [selected, setSelected] = useState(null)
+ const [pluginToInstall, setPluginToInstall] = useState(null)
+ const profilePlugins = Object.values(loadedProfile.pluginsByCreationId)
+ const pluginIds = useMemo(
+ () =>
+ Array.from(
+ new Set([
+ ...profilePlugins.map((plugin) => plugin.plugin_id),
+ ...(selected?.kind === 'plugin' ? [selected.plugin.plugin_id] : []),
+ ]),
+ ).sort(),
+ [profilePlugins, selected],
+ )
+ const { installedInfo } = useCheckInstalled({
+ pluginIds,
+ enabled: pluginIds.length > 0,
+ })
+
+ const selectCreation = (creation: CreatorCreation) => {
+ if (creation.kind === 'plugin') {
+ const plugin = loadedProfile.pluginsByCreationId[creation.id]
+ if (plugin) setSelected({ kind: 'plugin', plugin: normalizePlugin(plugin) })
+ return
+ }
+
+ const template = loadedProfile.templatesByCreationId[creation.id]
+ if (template) setSelected({ kind: 'template', template })
+ }
+
+ const selectSearchResult = (selection: MarketplaceSearchSelection) => {
+ if (selection.kind === 'plugin') {
+ setSelected({
+ kind: 'plugin',
+ plugin: normalizePlugin(getFormattedPlugin(selection.plugin)),
+ })
+ return
+ }
+ setSelected({ kind: 'template', template: selection.template })
+ }
+
+ const closeSelected = () => setSelected(null)
+ const selectedPlugin = selected?.kind === 'plugin' ? selected.plugin : null
+ const selectedTemplate = selected?.kind === 'template' ? selected.template : null
+
+ return (
+ <>
+ ({
+ type: 'select',
+ onSelect: () => selectCreation(creation),
+ })}
+ header={
+
+
+
+ }
+ />
+ }
+ />
+
+ {selectedPlugin && (
+
{
+ setPluginToInstall(selectedPlugin)
+ closeSelected()
+ }}
+ onOpenChange={(open) => {
+ if (!open) closeSelected()
+ }}
+ />
+ )}
+ {selectedTemplate && (
+ {
+ closeSelected()
+ router.push(`/apps?template-id=${encodeURIComponent(selectedTemplate.id)}`)
+ }}
+ onOpenChange={(open) => {
+ if (!open) closeSelected()
+ }}
+ />
+ )}
+ {pluginToInstall && (
+ setPluginToInstall(null)}
+ onSuccess={() => setPluginToInstall(null)}
+ />
+ )}
+ >
+ )
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/header.tsx b/web/app/components/plugins/marketplace/creator-profile/header.tsx
new file mode 100644
index 00000000000..038c7d052c4
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/header.tsx
@@ -0,0 +1,82 @@
+'use client'
+
+import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useState } from 'react'
+import { useTranslation } from '#i18n'
+import Link from '@/next/link'
+import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg'
+import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg'
+import HomeCatalogTabs from '../home/home-catalog-tabs'
+import HomeGuide from '../home/home-guide'
+import styles from '../home/home-sticky.module.css'
+import { MarketplaceSearchAutocomplete } from '../home/marketplace-search-autocomplete'
+
+type CreatorProfileHeaderProps = {
+ actions?: React.ReactNode
+ locale: string
+ onSuggestionSelect: (selection: MarketplaceSearchSelection) => void
+}
+
+export default function CreatorProfileHeader({
+ actions,
+ locale,
+ onSuggestionSelect,
+}: CreatorProfileHeaderProps) {
+ const { t } = useTranslation()
+ const [searchValue, setSearchValue] = useState('')
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ $['marketplace.creatorProfile.searchPlaceholder'], {
+ ns: 'plugin',
+ })}
+ scope="all"
+ value={searchValue}
+ />
+
+
+
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/model.ts b/web/app/components/plugins/marketplace/creator-profile/model.ts
new file mode 100644
index 00000000000..d76b770a8f1
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/model.ts
@@ -0,0 +1,319 @@
+import type {
+ MarketplaceCreator,
+ MarketplacePlugin,
+ MarketplaceTemplate,
+ MarketplaceTimestamp,
+} from '@dify/contracts/marketplace'
+import type { Plugin } from '@/app/components/plugins/types'
+
+type CreatorProfileKind = 'individual' | 'organization'
+type CreatorProfileBadge = 'partner' | 'verified'
+export type CreatorSocialPlatform = 'website' | 'x' | 'instagram' | 'youtube' | 'figma' | 'github'
+export type CreatorSortField = 'updatedAt' | 'createdAt' | 'popularity'
+export type CreatorSortOrder = 'asc' | 'desc'
+export const CREATOR_SORT_FIELDS = ['updatedAt', 'createdAt', 'popularity'] as const
+export const DEFAULT_CREATOR_SORT_FIELD: CreatorSortField = 'updatedAt'
+export const DEFAULT_CREATOR_SORT_ORDER: CreatorSortOrder = 'desc'
+
+export const parseCreatorSortField = (value?: string | null): CreatorSortField =>
+ CREATOR_SORT_FIELDS.includes(value as CreatorSortField)
+ ? (value as CreatorSortField)
+ : DEFAULT_CREATOR_SORT_FIELD
+
+export const parseCreatorSortOrder = (value?: string | null): CreatorSortOrder => {
+ const normalized = value?.toLowerCase()
+ return normalized === 'asc' || normalized === 'desc' ? normalized : DEFAULT_CREATOR_SORT_ORDER
+}
+
+export const toPublisherSortQuery = (field: CreatorSortField, order: CreatorSortOrder) => {
+ const sort_order = order === 'asc' ? 'ASC' : 'DESC'
+ return {
+ plugins: {
+ sort_by:
+ field === 'updatedAt'
+ ? 'version_updated_at'
+ : field === 'createdAt'
+ ? 'created_at'
+ : 'install_count',
+ sort_order,
+ },
+ templates: {
+ sort_by:
+ field === 'updatedAt' ? 'updated_at' : field === 'createdAt' ? 'created_at' : 'usage_count',
+ sort_order,
+ },
+ }
+}
+
+export type CreatorSocialLink = {
+ platform: CreatorSocialPlatform
+ href: string
+ label: string
+}
+
+type CreatorCreationTarget =
+ | {
+ type: 'plugin'
+ org: string
+ name: string
+ pluginType: MarketplacePlugin['type']
+ }
+ | {
+ type: 'template'
+ id: string
+ publisher: string
+ templateName: string
+ }
+
+type CreatorCreationIcon =
+ | { type: 'image'; src: string }
+ | { type: 'emoji'; value: string; background?: string }
+
+export type CreatorCreation = {
+ id: string
+ kind: 'plugin' | 'template'
+ title: string
+ description: string
+ target: CreatorCreationTarget
+ icon: CreatorCreationIcon
+ dependencyIcons: string[]
+ dependencyCount: number
+ updatedAt: number
+ createdAt: number
+ popularity: number
+}
+
+export type CreatorProfileViewModel = {
+ profile: {
+ kind: CreatorProfileKind
+ displayName: string
+ handle: string
+ description?: string
+ email?: string
+ avatarUrl: string
+ backgroundUrl: string
+ badges: CreatorProfileBadge[]
+ socialLinks: CreatorSocialLink[]
+ }
+ creations: CreatorCreation[]
+}
+
+export type LoadedCreatorProfile = {
+ viewModel: CreatorProfileViewModel
+ pluginsByCreationId: Record
+ templatesByCreationId: Record
+}
+
+export type CreatorCreationAction =
+ | { type: 'link'; href: string }
+ | { type: 'select'; onSelect: () => void }
+
+export type CreatorProfileAdapterInput = {
+ creator: MarketplaceCreator
+ kind: CreatorProfileKind
+ locale: string
+ avatarUrl: string
+ backgroundUrl: string
+ plugins: MarketplacePlugin[]
+ templates: MarketplaceTemplate[]
+ resolvePluginIcon: (plugin: MarketplacePlugin) => string
+ resolveTemplateIcon: (template: MarketplaceTemplate) => string
+ resolveDependencyIcon: (pluginId: string) => string
+}
+
+const toTimestamp = (value?: MarketplaceTimestamp | null) => {
+ if (value === undefined || value === null || value === '') return 0
+
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) return 0
+
+ // Marketplace search responses use Unix seconds, while some consumers may already
+ // provide JavaScript timestamps in milliseconds.
+ return Math.abs(value) < 1_000_000_000_000 ? value * 1000 : value
+ }
+
+ const timestamp = Date.parse(value)
+ return Number.isNaN(timestamp) ? 0 : timestamp
+}
+
+const getCreatorLocalizedText = (
+ value: Partial> | string | undefined,
+ locale: string,
+) => {
+ if (typeof value === 'string') return value
+ if (!value) return ''
+
+ const normalizedLocale = locale.replace('-', '_')
+ return (
+ value[locale] ||
+ value[normalizedLocale] ||
+ value['en-US'] ||
+ value.en_US ||
+ Object.values(value).find(Boolean) ||
+ ''
+ )
+}
+
+const getSocialPlatform = (hostname: string): CreatorSocialPlatform => {
+ if (
+ hostname === 'x.com' ||
+ hostname.endsWith('.x.com') ||
+ hostname === 'twitter.com' ||
+ hostname.endsWith('.twitter.com')
+ )
+ return 'x'
+ if (hostname === 'instagram.com' || hostname.endsWith('.instagram.com')) return 'instagram'
+ if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com') || hostname === 'youtu.be')
+ return 'youtube'
+ if (hostname === 'figma.com' || hostname.endsWith('.figma.com')) return 'figma'
+ if (hostname === 'github.com' || hostname.endsWith('.github.com')) return 'github'
+ return 'website'
+}
+
+export const normalizeCreatorSocialLink = (value: string): CreatorSocialLink | null => {
+ const trimmedValue = value.trim()
+ if (!trimmedValue) return null
+
+ const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(trimmedValue)
+ if (hasScheme && !/^https?:\/\//i.test(trimmedValue)) return null
+
+ try {
+ const url = new URL(
+ /^https?:\/\//i.test(trimmedValue) ? trimmedValue : `https://${trimmedValue}`,
+ )
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
+
+ const hostname = url.hostname.toLowerCase().replace(/^www\./, '')
+ return {
+ platform: getSocialPlatform(hostname),
+ href: url.toString(),
+ label: trimmedValue.replace(/^https?:\/\//i, '').replace(/\/$/, ''),
+ }
+ } catch {
+ return null
+ }
+}
+
+const getCreatorBadges = (creator: MarketplaceCreator) => {
+ const badges = new Set()
+ if (creator.badges?.includes('partner')) badges.add('partner')
+ if (creator.verified || creator.badges?.includes('verified')) badges.add('verified')
+ return Array.from(badges)
+}
+
+export const adaptCreatorProfile = ({
+ creator,
+ kind,
+ locale,
+ avatarUrl,
+ backgroundUrl,
+ plugins,
+ templates,
+ resolvePluginIcon,
+ resolveTemplateIcon,
+ resolveDependencyIcon,
+}: CreatorProfileAdapterInput): CreatorProfileViewModel => {
+ const pluginCreations = plugins.map((plugin): CreatorCreation => ({
+ id: `${plugin.type}:${plugin.org}/${plugin.name}`,
+ kind: 'plugin',
+ title: getCreatorLocalizedText(plugin.labels ?? plugin.label, locale) || plugin.name,
+ description:
+ getCreatorLocalizedText(
+ plugin.type === 'bundle' ? plugin.description : plugin.brief,
+ locale,
+ ) ||
+ plugin.introduction ||
+ '',
+ target: {
+ type: 'plugin',
+ org: plugin.org,
+ name: plugin.name,
+ pluginType: plugin.type,
+ },
+ icon: { type: 'image', src: resolvePluginIcon(plugin) },
+ dependencyIcons: [],
+ dependencyCount: 0,
+ updatedAt: toTimestamp(plugin.version_updated_at || plugin.updated_at),
+ createdAt: toTimestamp(plugin.created_at),
+ popularity: plugin.install_count || 0,
+ }))
+
+ const templateCreations = templates.map((template): CreatorCreation => {
+ const templateIcon = resolveTemplateIcon(template)
+ const dependencyIds = template.deps_plugins ?? []
+ const publisher =
+ template.publisher_handle ||
+ template.publisher_unique_handle ||
+ template.creator_email ||
+ 'template'
+
+ return {
+ id: `template:${template.id}`,
+ kind: 'template',
+ title: template.template_name,
+ description: template.overview || '',
+ target: {
+ type: 'template',
+ id: template.id,
+ publisher,
+ templateName: template.template_name,
+ },
+ icon: templateIcon
+ ? { type: 'image', src: templateIcon }
+ : { type: 'emoji', value: template.icon || '📄', background: template.icon_background },
+ dependencyIcons: dependencyIds.map(resolveDependencyIcon),
+ dependencyCount: dependencyIds.length,
+ updatedAt: toTimestamp(template.updated_at),
+ createdAt: toTimestamp(template.created_at),
+ popularity: template.usage_count || 0,
+ }
+ })
+
+ return {
+ profile: {
+ kind,
+ displayName: creator.display_name || creator.name || creator.unique_handle,
+ handle: creator.unique_handle,
+ description: creator.description || undefined,
+ email: creator.display_email || creator.email || undefined,
+ avatarUrl,
+ backgroundUrl,
+ badges: getCreatorBadges(creator),
+ socialLinks: (creator.social_links ?? [])
+ .map(normalizeCreatorSocialLink)
+ .filter((link): link is CreatorSocialLink => link !== null),
+ },
+ creations: [...pluginCreations, ...templateCreations],
+ }
+}
+
+export const sortCreatorCreations = (
+ creations: CreatorCreation[],
+ field: CreatorSortField,
+ order: CreatorSortOrder,
+) => {
+ const direction = order === 'asc' ? 1 : -1
+ return creations
+ .map((creation, index) => ({ creation, index }))
+ .sort((left, right) => {
+ const difference = (left.creation[field] - right.creation[field]) * direction
+ return difference || left.index - right.index
+ })
+ .map(({ creation }) => creation)
+}
+
+export const getStandaloneCreationHref = (creation: CreatorCreation, locale?: string) => {
+ const language = locale ? `language=${encodeURIComponent(locale)}` : ''
+ if (creation.target.type === 'plugin') {
+ const resource = creation.target.pluginType === 'bundle' ? 'bundles' : 'plugin'
+ const path = `/${resource}/${encodeURIComponent(creation.target.org)}/${encodeURIComponent(creation.target.name)}`
+ return language ? `${path}?${language}` : path
+ }
+
+ const params = new URLSearchParams({
+ templateId: creation.target.id,
+ creationType: 'templates',
+ })
+ if (locale) params.set('language', locale)
+ return `/template/${encodeURIComponent(creation.target.publisher)}/${encodeURIComponent(creation.target.templateName)}?${params.toString()}`
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx b/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx
new file mode 100644
index 00000000000..2776410cd51
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx
@@ -0,0 +1,75 @@
+'use client'
+
+import type { CSSProperties } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useState } from 'react'
+
+type PublisherAvatarProps = {
+ avatarUrl: string
+ name: string
+ isOrganization: boolean
+ size?: number
+ className?: string
+}
+
+// Keep in sync with Creator Center `components/ui/avatar.tsx`.
+const DEFAULT_AVATAR_BG =
+ 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%), linear-gradient(90deg, #155aef 0%, #155aef 100%)'
+
+const DEFAULT_AVATAR_LETTER_STYLE: CSSProperties = {
+ color: '#FFFFFF',
+ textShadow: '0px 0.25px 0.5px rgba(0, 0, 0, 0.20)',
+ lineHeight: '120%',
+ textTransform: 'uppercase',
+}
+
+function getFallbackTextClass(size: number) {
+ if (size <= 32) return 'text-xs'
+ if (size <= 50) return 'text-base'
+ return 'text-[40px]'
+}
+
+export default function PublisherAvatar({
+ avatarUrl,
+ name,
+ isOrganization,
+ size = 24,
+ className,
+}: PublisherAvatarProps) {
+ const [failedAvatarUrl, setFailedAvatarUrl] = useState(null)
+ const shapeClass = isOrganization ? 'rounded-md' : 'rounded-full'
+ const shouldShowImage = Boolean(avatarUrl) && failedAvatarUrl !== avatarUrl
+ const fallbackLetter = name?.[0]?.toUpperCase() || 'U'
+
+ return (
+
+ {shouldShowImage ? (
+
setFailedAvatarUrl(avatarUrl)}
+ />
+ ) : (
+
+
+ {fallbackLetter}
+
+
+ )}
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/creator-profile/view.tsx b/web/app/components/plugins/marketplace/creator-profile/view.tsx
new file mode 100644
index 00000000000..ebae19c187e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/creator-profile/view.tsx
@@ -0,0 +1,88 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import type { CreatorCreation, CreatorCreationAction, CreatorProfileViewModel } from './model'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useTranslation } from '#i18n'
+import Link from '@/next/link'
+import DefaultCreatorBackground from './assets/default-background.png'
+import CreatorContent from './creator-content'
+import CreatorSidebar from './creator-sidebar'
+
+export type CreatorProfileViewProps = {
+ profile: CreatorProfileViewModel
+ getCreationAction: (creation: CreatorCreation) => CreatorCreationAction
+ header?: ReactNode
+ homeHref: string
+ isMarketplacePlatform: boolean
+}
+
+export default function CreatorProfileView({
+ profile,
+ getCreationAction,
+ header,
+ homeHref,
+ isMarketplacePlatform,
+}: CreatorProfileViewProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ {header}
+
+ $['marketplace.creatorProfile.breadcrumbLabel'], { ns: 'plugin' })}
+ className="flex h-12 shrink-0 items-end gap-2 overflow-hidden"
+ >
+ $['marketplace.creatorProfile.home'], { ns: 'plugin' })}
+ className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
+ >
+
+
+
+ /
+
+
+ {t(($) => $['marketplace.creatorProfile.title'], { ns: 'plugin' })}
+
+
+
+
+
+ {profile.profile.backgroundUrl && (
+
{
+ event.currentTarget.hidden = true
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx
index d4dc268ab93..af8a7ea12ef 100644
--- a/web/app/components/plugins/marketplace/description/index.tsx
+++ b/web/app/components/plugins/marketplace/description/index.tsx
@@ -7,6 +7,7 @@ import { useLocale, useTranslation } from '#i18n'
import Divider from '@/app/components/base/divider'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import { SubmitRequestDropdown } from '@/app/components/plugins/plugin-page/nav-operations'
+import { MARKETPLACE_CONTAINER_ID } from '../constants'
import PluginTypeSwitch from '../plugin-type-switch'
import SearchBoxWrapper from '../search-box/search-box-wrapper'
@@ -27,7 +28,7 @@ const EXPANDED_TABS_MARGIN_TOP = 32
const Description = ({
isMarketplacePlatform = false,
marketplaceNav,
- scrollContainerId = 'marketplace-container',
+ scrollContainerId = MARKETPLACE_CONTAINER_ID,
}: DescriptionProps) => {
const { t } = useTranslation('plugin')
const { t: tCommon } = useTranslation('common')
diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..d141b88d519
--- /dev/null
+++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx
@@ -0,0 +1,125 @@
+import type { Plugin } from '@/app/components/plugins/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { ThemeProvider } from 'next-themes'
+import { describe, expect, it, vi } from 'vitest'
+import { PluginCategoryEnum } from '@/app/components/plugins/types'
+import MarketplaceDetailDialog from '../index'
+
+vi.mock('../../utils', () => ({
+ getPluginLinkInMarketplace: (
+ plugin: Plugin,
+ params: { installed: string; language: string; source?: string; theme?: string; view: string },
+ ) =>
+ `about:blank?plugin=${plugin.org}/${plugin.name}&installed=${params.installed}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`,
+}))
+
+const plugin = {
+ type: 'plugin',
+ org: 'dify',
+ name: 'plugin-a',
+ plugin_id: 'plugin-a',
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_package_identifier: 'pkg',
+ icon: 'icon.png',
+ verified: true,
+ label: { 'en-US': 'Plugin A' },
+ brief: { 'en-US': 'Brief' },
+ description: { 'en-US': 'Description' },
+ introduction: 'Intro',
+ repository: 'https://github.com/dify/plugin-a',
+ category: PluginCategoryEnum.tool,
+ install_count: 42,
+ endpoint: { settings: [] },
+ tags: [],
+ badges: [],
+ verification: { authorized_category: 'community' },
+ from: 'marketplace',
+} as Plugin
+
+describe('MarketplaceDetailDialog', () => {
+ it('renders the marketplace detail route in modal mode and closes in place', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+
+ render(
+
+
+ ,
+ )
+
+ const frame = screen.getByTitle('Plugin A · plugin.detailPanel.operation.detail')
+ expect(frame).toHaveAttribute(
+ 'src',
+ // resolvedTheme maps the "system" preference to the concrete value, so
+ // the embedded detail page receives light/dark rather than "system".
+ 'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal',
+ )
+ expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('forwards a validated install request from the embedded detail frame', () => {
+ const onInstall = vi.fn()
+
+ render(
+
+
+ ,
+ )
+
+ const frame = screen.getByTitle(
+ 'Plugin A · plugin.detailPanel.operation.detail',
+ ) as HTMLIFrameElement
+ const installRequest = {
+ type: 'dify-marketplace:install-plugin',
+ pluginUniqueIdentifier: plugin.latest_package_identifier,
+ }
+ fireEvent(
+ window,
+ new MessageEvent('message', {
+ data: installRequest,
+ origin: 'https://attacker.example',
+ source: frame.contentWindow,
+ }),
+ )
+ fireEvent(
+ window,
+ new MessageEvent('message', {
+ data: {
+ ...installRequest,
+ pluginUniqueIdentifier: 'another/plugin:1.0.0',
+ },
+ origin: 'null',
+ source: frame.contentWindow,
+ }),
+ )
+ expect(onInstall).not.toHaveBeenCalled()
+
+ fireEvent(
+ window,
+ new MessageEvent('message', {
+ data: installRequest,
+ origin: 'null',
+ source: frame.contentWindow,
+ }),
+ )
+
+ expect(onInstall).toHaveBeenCalledOnce()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/detail-dialog/frame.tsx b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx
new file mode 100644
index 00000000000..529f6ffff9c
--- /dev/null
+++ b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx
@@ -0,0 +1,133 @@
+'use client'
+
+import { cn } from '@langgenius/dify-ui/cn'
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogClose,
+ DialogPopup,
+ DialogPortal,
+ DialogTitle,
+} from '@langgenius/dify-ui/dialog'
+import { IconButton } from '@langgenius/dify-ui/icon-button'
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from '#i18n'
+
+type MarketplaceDetailDialogFrameProps = {
+ open: boolean
+ src: string
+ title: string
+ onMessage?: (data: unknown) => void
+ onOpenChange: (open: boolean) => void
+}
+
+// The iframe load event can be delayed indefinitely on a stalled connection
+// (and cross-origin load errors are not observable), so reveal the frame after
+// this timeout instead of keeping the skeleton up forever.
+const LOADING_REVEAL_TIMEOUT_MS = 15_000
+
+export default function MarketplaceDetailDialogFrame({
+ open,
+ src,
+ title,
+ onMessage,
+ onOpenChange,
+}: MarketplaceDetailDialogFrameProps) {
+ const { t } = useTranslation()
+ const iframeRef = useRef(null)
+ const closeButtonRef = useRef(null)
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ if (!open) return
+
+ const timeout = window.setTimeout(() => setIsLoading(false), LOADING_REVEAL_TIMEOUT_MS)
+ return () => window.clearTimeout(timeout)
+ }, [open, src])
+
+ useEffect(() => {
+ if (!open || !onMessage) return
+
+ const marketplaceOrigin = new URL(src, window.location.href).origin
+ const handleMessage = (event: MessageEvent) => {
+ if (event.source !== iframeRef.current?.contentWindow || event.origin !== marketplaceOrigin)
+ return
+
+ onMessage(event.data)
+ }
+
+ window.addEventListener('message', handleMessage)
+ return () => window.removeEventListener('message', handleMessage)
+ }, [onMessage, open, src])
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (!nextOpen) setIsLoading(true)
+ onOpenChange(nextOpen)
+ }
+
+ return (
+
+
+
+ {/* Keep initial focus on the visible close control: while the iframe is
+ still loading it is inert, so default focus could otherwise land on
+ an invisible cross-origin frame. */}
+
+ {title}
+
+
+
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/detail-dialog/index.tsx b/web/app/components/plugins/marketplace/detail-dialog/index.tsx
new file mode 100644
index 00000000000..8f764cdd19c
--- /dev/null
+++ b/web/app/components/plugins/marketplace/detail-dialog/index.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+import type { Plugin } from '@/app/components/plugins/types'
+import { useTheme } from 'next-themes'
+import { useCallback } from 'react'
+import { useLocale, useTranslation } from '#i18n'
+import { getPluginLinkInMarketplace } from '../utils'
+import MarketplaceDetailDialogFrame from './frame'
+
+const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-plugin'
+
+type MarketplaceDetailDialogProps = {
+ isInstalled: boolean
+ open: boolean
+ plugin: Plugin
+ onInstall: () => void
+ onOpenChange: (open: boolean) => void
+}
+
+function MarketplaceDetailDialog({
+ isInstalled,
+ open,
+ plugin,
+ onInstall,
+ onOpenChange,
+}: MarketplaceDetailDialogProps) {
+ const { t } = useTranslation()
+ const locale = useLocale()
+ // resolvedTheme maps the "system" preference to the concrete light/dark
+ // value the marketplace page expects.
+ const { resolvedTheme } = useTheme()
+ const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name
+ const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
+ const detailURL = getPluginLinkInMarketplace(plugin, {
+ installed: String(isInstalled),
+ language: locale,
+ source: globalThis.location?.origin,
+ theme: resolvedTheme,
+ view: 'modal',
+ })
+
+ const handleMessage = useCallback(
+ (data: unknown) => {
+ if (
+ typeof data !== 'object' ||
+ data === null ||
+ !('type' in data) ||
+ !('pluginUniqueIdentifier' in data) ||
+ data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE ||
+ data.pluginUniqueIdentifier !== plugin.latest_package_identifier
+ )
+ return
+
+ onInstall()
+ },
+ [onInstall, plugin.latest_package_identifier],
+ )
+
+ return (
+
+ )
+}
+
+export default MarketplaceDetailDialog
diff --git a/web/app/components/plugins/marketplace/embedded.tsx b/web/app/components/plugins/marketplace/embedded.tsx
new file mode 100644
index 00000000000..297d6504579
--- /dev/null
+++ b/web/app/components/plugins/marketplace/embedded.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type { MarketplaceViewProps } from './view'
+import { queryOptions, useQuery } from '@tanstack/react-query'
+import { useLocale } from '@/context/i18n'
+import { useResetMarketplaceSearchModeOnMount } from './atoms'
+import { fetchPluginBanners } from './home/banners'
+import { MarketplaceView } from './view'
+
+const BANNER_STALE_TIME = 1000 * 60 * 5
+
+export type EmbeddedMarketplaceProps = Omit & {
+ initialBanners?: PluginBanner[]
+ /**
+ * Locale used to fetch `initialBanners` during server rendering. `initialBanners`
+ * is only applied while the client locale still matches it, so a client-side
+ * language change refetches banners instead of seeding the new locale's cache
+ * with banners from the previous language.
+ */
+ initialLocale?: string
+}
+
+export function EmbeddedMarketplace({
+ initialBanners,
+ initialLocale,
+ variant = 'default',
+ ...props
+}: EmbeddedMarketplaceProps) {
+ useResetMarketplaceSearchModeOnMount()
+ const locale = useLocale()
+ const { data: banners = [] } = useQuery(
+ queryOptions({
+ // fetchPluginBanners returns normalized PluginBanner[] rather than the
+ // raw contract response, so it uses its own cache key instead of
+ // impersonating the generated banners.list contract query.
+ queryKey: ['marketplace-banners', locale],
+ queryFn: () => fetchPluginBanners(locale),
+ enabled: variant === 'home',
+ initialData: locale === initialLocale ? initialBanners : undefined,
+ staleTime: BANNER_STALE_TIME,
+ }),
+ )
+
+ return
+}
diff --git a/web/app/components/plugins/marketplace/filter-track-link.tsx b/web/app/components/plugins/marketplace/filter-track-link.tsx
new file mode 100644
index 00000000000..acb520df383
--- /dev/null
+++ b/web/app/components/plugins/marketplace/filter-track-link.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import type { ComponentProps } from 'react'
+import Link from '@/next/link'
+import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track'
+
+type MarketplaceFilterTrackLinkProps = ComponentProps & {
+ filterValue: string
+ filterType: 'type_tab' | 'category' | 'language'
+ selectedValues: string[]
+ selectionMode?: 'single' | 'multi'
+ trackFilter?: boolean
+}
+
+export default function MarketplaceFilterTrackLink({
+ filterValue,
+ filterType,
+ selectedValues,
+ selectionMode = 'single',
+ trackFilter = true,
+ onClick,
+ ...props
+}: MarketplaceFilterTrackLinkProps) {
+ return (
+ {
+ if (trackFilter) {
+ markMarketplaceSiteFilter({
+ filter_type: filterType,
+ selection_mode: selectionMode,
+ filter_value: filterValue,
+ selected_values: selectedValues,
+ })
+ }
+ onClick?.(event)
+ }}
+ />
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/README.md b/web/app/components/plugins/marketplace/home/README.md
new file mode 100644
index 00000000000..2284732297e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/README.md
@@ -0,0 +1,12 @@
+# Marketplace Catalog Home
+
+The redesigned Marketplace catalog shell provides the shared header, hero, search, trending, tabs, and sticky category navigation used by the Plugins and Templates pages.
+
+## Internal Modules
+
+- `marketplace/list/list-wrapper`
+- `marketplace/plugin-type-switch`
+
+## External Modules
+
+None.
diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx
new file mode 100644
index 00000000000..79571b30f3e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx
@@ -0,0 +1,32 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import { renderWithNuqs } from '@/test/nuqs-testing'
+import CatalogLanguagesFilter from '../catalog-languages-filter'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string, options?: { ns?: string }) =>
+ options?.ns ? `${options.ns}.${key}` : key,
+ ),
+ }),
+ }
+})
+
+describe('CatalogLanguagesFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('writes selected languages into the URL', async () => {
+ const user = userEvent.setup()
+ const { onUrlUpdate } = renderWithNuqs( )
+ await user.click(screen.getByRole('button', { name: 'plugin.marketplace.languages' }))
+ await user.click(screen.getByRole('checkbox', { name: '中文' }))
+ await waitFor(() => {
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages')).toBe('zh-Hans')
+ })
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx
new file mode 100644
index 00000000000..97bdd8e1ce5
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx
@@ -0,0 +1,47 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import { renderWithNuqs } from '@/test/nuqs-testing'
+import CatalogTagsFilter from '../catalog-tags-filter'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string, options?: { ns?: string }) =>
+ options?.ns ? `${options.ns}.${key}` : key,
+ ),
+ }),
+ }
+})
+
+vi.mock('@/app/components/plugins/hooks', () => ({
+ useTags: () => ({
+ tags: [
+ { name: 'agent', label: 'Agent' },
+ { name: 'rag', label: 'RAG' },
+ { name: 'search', label: 'Search' },
+ ],
+ tagsMap: {
+ agent: { name: 'agent', label: 'Agent' },
+ rag: { name: 'rag', label: 'RAG' },
+ search: { name: 'search', label: 'Search' },
+ },
+ }),
+}))
+
+describe('CatalogTagsFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('writes selected tags into the URL', async () => {
+ const user = userEvent.setup()
+ const { onUrlUpdate } = renderWithNuqs( )
+ await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
+ await user.click(screen.getByRole('checkbox', { name: 'Agent' }))
+ await waitFor(() => {
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags')).toBe('agent')
+ })
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts
new file mode 100644
index 00000000000..e1236270d32
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import {
+ EMBEDDED_MOBILE_BANNER_MEDIA,
+ MARKETPLACE_MOBILE_BANNER_MEDIA,
+ marketplaceTabletBannerMedia,
+ resolveEventAdBannerImageSrcs,
+} from '../event-ad-banner-image'
+
+describe('resolveEventAdBannerImageSrcs', () => {
+ it('uses the mobile asset on the mobile slot when one exists', () => {
+ expect(
+ resolveEventAdBannerImageSrcs({
+ desktop: '/desktop.png',
+ tablet: '/tablet.png',
+ mobile: '/mobile.png',
+ }),
+ ).toEqual({
+ desktop: '/desktop.png',
+ mobile: '/mobile.png',
+ tablet: '/tablet.png',
+ })
+ })
+
+ it('falls back to desktop on the mobile slot when mobile is missing', () => {
+ expect(
+ resolveEventAdBannerImageSrcs({
+ desktop: '/desktop.png',
+ tablet: '/tablet.png',
+ }),
+ ).toEqual({
+ desktop: '/desktop.png',
+ mobile: '/desktop.png',
+ tablet: '/tablet.png',
+ })
+ })
+
+ it('omits tablet when the banner has no tablet asset', () => {
+ expect(
+ resolveEventAdBannerImageSrcs({
+ desktop: '/desktop.png',
+ mobile: '/mobile.png',
+ }),
+ ).toEqual({
+ desktop: '/desktop.png',
+ mobile: '/mobile.png',
+ tablet: undefined,
+ })
+ })
+})
+
+describe('marketplaceTabletBannerMedia', () => {
+ it('keeps tablet out of the standalone mobile breakpoint', () => {
+ expect(MARKETPLACE_MOBILE_BANNER_MEDIA).toBe('(max-width: 879px)')
+ expect(marketplaceTabletBannerMedia(true)).toBe('(min-width: 880px) and (max-width: 1023px)')
+ })
+
+ it('keeps tablet out of the embedded mobile breakpoint', () => {
+ expect(EMBEDDED_MOBILE_BANNER_MEDIA).toBe('(max-width: 639px)')
+ expect(marketplaceTabletBannerMedia(false)).toBe('(min-width: 640px) and (max-width: 1023px)')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx
new file mode 100644
index 00000000000..3e1f3117bd4
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx
@@ -0,0 +1,41 @@
+import { render } from 'vitest-browser-react'
+import HomeCatalogNavigation from '../home-catalog-navigation'
+import HomeCatalogTabs from '../home-catalog-tabs'
+import { HomeStickyStateProvider } from '../home-sticky-state-provider'
+import styles from '../home-sticky.module.css'
+
+describe('Marketplace home catalog alignment', () => {
+ it('aligns catalog tabs and filters with the content container', async () => {
+ const screen = await render(
+
+
+
+ }
+ catalogTabs={
+
+ }
+ />
+
+
+ ,
+ )
+
+ const contentLeft = screen
+ .getByRole('region', { name: 'Catalog content' })
+ .element()
+ .getBoundingClientRect().left
+ const tabsLeft = screen.getByRole('navigation').element().getBoundingClientRect().left
+ const filtersLeft = screen.getByTestId('catalog-filter').element().getBoundingClientRect().left
+
+ expect(tabsLeft).toBeCloseTo(contentLeft)
+ expect(filtersLeft).toBeCloseTo(contentLeft)
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx
new file mode 100644
index 00000000000..25b68060190
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx
@@ -0,0 +1,241 @@
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { MARKETPLACE_CONTAINER_ID } from '../../constants'
+import HomeCatalogNavigation from '../home-catalog-navigation'
+import HomeCatalogTabs from '../home-catalog-tabs'
+import {
+ HOME_HEADER_HEIGHT_PX,
+ HOME_SEARCH_HEIGHT_PX,
+ HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX,
+} from '../home-constants'
+import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider'
+import styles from '../home-sticky.module.css'
+
+describe('Marketplace catalog tab handoff', () => {
+ it('hands off only when the in-flow tabs fully reach the sticky header', async () => {
+ await page.viewport(1200, 800)
+ const screen = await render(
+
+ }
+ catalogTabs={
+
+
+
+ }
+ />
+
+
+ ,
+ )
+
+ const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
+ const header = screen.getByTestId('catalog-header').element()
+ const navigation = screen.getByRole('region').element()
+ const categories = screen.getByTestId('catalog-categories').element()
+ const contentTabsSlot = screen.getByTestId('content-catalog-tabs').element().parentElement!
+ const contentTabsRegion = contentTabsSlot.parentElement!
+ const headerTabsSlot = screen.getByTestId('header-catalog-tabs').element().parentElement!
+ const followingContent = screen.getByTestId('following-content').element() as HTMLElement
+ const initialHeight = navigation.getBoundingClientRect().height
+ const initialCategoryOffset =
+ categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top
+ const initialHeaderSlotWidth = headerTabsSlot.getBoundingClientRect().width
+ const initialHeaderSlotHeight = headerTabsSlot.getBoundingClientRect().height
+ const initialFollowingOffset = followingContent.offsetTop
+ const initialScrollHeight = scrollContainer.scrollHeight
+ const contentPluginsLink =
+ contentTabsSlot.querySelector
('a[href="/plugins"]')!
+ const headerPluginsLink = headerTabsSlot.querySelector('a[href="/plugins"]')!
+
+ expect(initialHeaderSlotWidth).toBeGreaterThan(0)
+ expect(initialHeaderSlotHeight).toBeGreaterThan(0)
+ expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('none')
+ expect(getComputedStyle(headerTabsSlot).transitionProperty).toBe('opacity, transform')
+ expect(getComputedStyle(headerTabsSlot).transitionDuration).toBe('0.14s')
+
+ contentPluginsLink.focus()
+ expect(document.activeElement).toBe(contentPluginsLink)
+
+ const handoffScrollTop =
+ scrollContainer.scrollTop +
+ contentTabsRegion.getBoundingClientRect().bottom -
+ header.getBoundingClientRect().bottom
+ scrollContainer.scrollTop = handoffScrollTop - 1
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ )
+
+ expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
+ expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1)
+ expect(
+ contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
+ ).toBeCloseTo(1)
+ expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(contentTabsSlot).not.toHaveAttribute('inert')
+ expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(headerTabsSlot).toHaveAttribute('inert')
+ expect(document.activeElement).toBe(contentPluginsLink)
+
+ scrollContainer.scrollTop = handoffScrollTop
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await vi.waitFor(() => {
+ expect(navigation).toHaveClass(styles.catalogNavigationPinned!)
+ })
+
+ expect(scrollContainer.scrollTop).toBe(handoffScrollTop)
+ expect(navigation.getBoundingClientRect().height).toBeCloseTo(initialHeight)
+ expect(
+ categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top,
+ ).toBeCloseTo(initialCategoryOffset)
+ expect(
+ categories.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
+ ).toBeCloseTo(64)
+ expect(
+ contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
+ ).toBeCloseTo(0)
+ expect(headerTabsSlot.getBoundingClientRect().width).toBeCloseTo(initialHeaderSlotWidth)
+ expect(headerTabsSlot.getBoundingClientRect().height).toBeCloseTo(initialHeaderSlotHeight)
+ expect(followingContent.offsetTop).toBe(initialFollowingOffset)
+ expect(scrollContainer.scrollHeight).toBe(initialScrollHeight)
+ expect(getComputedStyle(contentTabsSlot).display).not.toBe('none')
+ expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('none')
+ expect(getComputedStyle(contentTabsSlot).transitionProperty).toBe('opacity, transform')
+ expect(getComputedStyle(contentTabsSlot).transitionDuration).toBe('0.14s')
+ expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(contentTabsSlot).toHaveAttribute('inert')
+ expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(headerTabsSlot).not.toHaveAttribute('inert')
+ expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('auto')
+ await vi.waitFor(
+ () => {
+ expect(getComputedStyle(contentTabsSlot).opacity).toBe('0')
+ expect(getComputedStyle(headerTabsSlot).opacity).toBe('1')
+ expect(document.activeElement).toBe(headerPluginsLink)
+ },
+ { timeout: 500 },
+ )
+
+ scrollContainer.scrollTop = handoffScrollTop - 1
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await vi.waitFor(() => {
+ expect(document.activeElement).toBe(contentPluginsLink)
+ })
+
+ expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
+ expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1)
+ expect(
+ contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom,
+ ).toBeCloseTo(1)
+ expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(contentTabsSlot).not.toHaveAttribute('inert')
+ expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(headerTabsSlot).toHaveAttribute('inert')
+ })
+
+ it('keeps the in-flow tabs active when the standalone header slot is hidden on mobile', async () => {
+ await page.viewport(879, 800)
+ const screen = await render(
+
+ }
+ catalogTabs={Content tabs
}
+ />
+
+
+ ,
+ )
+
+ const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
+ const navigation = screen.getByRole('region').element()
+ const contentTabsSlot = screen.getByTestId('mobile-content-tabs').element().parentElement!
+ const headerTabs = screen.getByTestId('mobile-header-tabs').element()
+ const headerTabsSlot = headerTabs.parentElement!
+
+ expect(getComputedStyle(headerTabs).display).toBe('none')
+
+ scrollContainer.scrollTop = 300
+ scrollContainer.dispatchEvent(new Event('scroll'))
+
+ expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
+ expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(contentTabsSlot).not.toHaveAttribute('inert')
+ expect(getComputedStyle(contentTabsSlot).opacity).toBe('1')
+ expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('auto')
+ expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(headerTabsSlot).toHaveAttribute('inert')
+ expect(
+ contentTabsSlot.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
+ ).toBeCloseTo(
+ HOME_HEADER_HEIGHT_PX +
+ HOME_SEARCH_HEIGHT_PX +
+ HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX /* .catalogTabsRegion padding-top, tucked under search padding */,
+ )
+
+ await page.viewport(880, 800)
+ await vi.waitFor(() => {
+ expect(navigation).toHaveClass(styles.catalogNavigationPinned!)
+ })
+ expect(getComputedStyle(headerTabs).display).toBe('flex')
+ expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(contentTabsSlot).toHaveAttribute('inert')
+ expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(headerTabsSlot).not.toHaveAttribute('inert')
+
+ await page.viewport(879, 800)
+ await vi.waitFor(() => {
+ expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!)
+ })
+ expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(contentTabsSlot).not.toHaveAttribute('inert')
+ expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(headerTabsSlot).toHaveAttribute('inert')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx
new file mode 100644
index 00000000000..ecb121795f3
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx
@@ -0,0 +1,320 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import HomeCatalogNavigation from '../home-catalog-navigation'
+import HomeCatalogTabs from '../home-catalog-tabs'
+import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider'
+import styles from '../home-sticky.module.css'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string, options?: { ns?: string }) =>
+ options?.ns ? `${options.ns}.${key}` : key,
+ ),
+ }),
+ }
+})
+
+vi.mock('../../plugin-type-switch', () => ({
+ default: ({ className, variant }: { className?: string; variant?: string }) => (
+
+ ),
+}))
+
+afterEach(() => {
+ document.querySelectorAll('#marketplace-container').forEach((element) => element.remove())
+})
+
+describe('HomeCatalogNavigation', () => {
+ const renderNavigation = (isMarketplacePlatform: boolean) => {
+ return render(
+
+
+
+
+ }
+ />
+ ,
+ )
+ }
+
+ it('keeps template navigation inside the Marketplace platform', () => {
+ renderNavigation(true)
+
+ const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
+
+ expect(navigationSection).toHaveClass(styles.catalogNavigation!)
+ expect(navigationSection.firstElementChild).toHaveClass('w-full')
+ expect(navigationSection.firstElementChild).not.toHaveClass('mx-auto', 'max-w-[1200px]')
+ const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
+ expect(activeTab).toHaveAttribute('aria-current', 'page')
+ expect(activeTab).toHaveAttribute('href', '/plugins')
+ expect(activeTab).toHaveClass('bg-state-base-active')
+ expect(activeTab).not.toHaveClass('text-text-accent')
+ expect(activeTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
+ expect(
+ screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
+ ).toHaveAttribute('href', '/templates')
+ expect(screen.getByTestId('plugin-type-switch')).toHaveAttribute('data-variant', 'home')
+ })
+
+ it('keeps tabs clickable and uses only the active background', () => {
+ render(
)
+
+ const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
+ const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
+
+ expect(pluginsTab).toHaveAttribute('href', '/plugins')
+ expect(pluginsTab).toHaveClass('cursor-pointer')
+ expect(pluginsTab).toHaveClass('bg-state-base-active')
+ expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
+ expect(templatesTab).toHaveAttribute('href', '/templates')
+ expect(templatesTab).toHaveClass('cursor-pointer')
+ expect(templatesTab).not.toHaveClass('bg-state-base-active')
+ })
+
+ it('leaves both catalog tabs inactive when no page is selected', () => {
+ render(
)
+
+ const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
+ const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
+
+ expect(pluginsTab).toHaveAttribute('href', '/marketplace')
+ expect(pluginsTab).not.toHaveAttribute('aria-current')
+ expect(pluginsTab).not.toHaveClass('bg-state-base-active')
+ expect(templatesTab).not.toHaveAttribute('aria-current')
+ expect(templatesTab).not.toHaveClass('bg-state-base-active')
+ })
+
+ it('marks Templates as active when rendering the Templates catalog', () => {
+ render(
)
+
+ const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
+ const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
+
+ expect(pluginsTab).not.toHaveAttribute('aria-current')
+ expect(pluginsTab).not.toHaveClass('bg-state-base-active')
+ expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
+ expect(templatesTab).toHaveAttribute('aria-current', 'page')
+ expect(templatesTab).toHaveClass('bg-state-base-active')
+ expect(templatesTab).not.toHaveClass('text-text-accent')
+ expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
+ })
+
+ it('uses request-localized labels and preserves the selected language', () => {
+ render(
+
,
+ )
+
+ expect(screen.getByRole('link', { name: '插件' })).toHaveAttribute(
+ 'href',
+ '/plugins?language=zh-Hans',
+ )
+ expect(screen.getByRole('link', { name: '模板' })).toHaveAttribute(
+ 'href',
+ '/templates?language=zh-Hans',
+ )
+ })
+
+ it('renders a supplied catalog category navigation', () => {
+ render(
+
+ }
+ catalogCategories={Template categories }
+ />
+ ,
+ )
+
+ expect(screen.getByRole('navigation', { name: 'Template categories' })).toBeInTheDocument()
+ expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument()
+ })
+
+ it('uses a short divider between the leading tag filter and categories', () => {
+ render(
+
+ }
+ catalogLeading={Tags
}
+ catalogTrailing={Languages
}
+ catalogCategories={Categories }
+ />
+ ,
+ )
+ // Categories sit in the flex-1 scroller; the row is one level up.
+ const row = screen.getByRole('navigation', { name: 'Plugin categories' }).parentElement
+ ?.parentElement
+ const divider = row?.children.item(1)
+
+ expect(row?.children.item(0)).toHaveTextContent('Tags')
+ expect(divider).toHaveAttribute('aria-hidden', 'true')
+ expect(divider).toHaveClass(
+ 'mx-1',
+ 'h-3.5',
+ 'w-px',
+ 'shrink-0',
+ 'bg-divider-regular',
+ styles.catalogLeadingDivider!,
+ )
+ expect(divider).toBeEmptyDOMElement()
+ expect(row?.children.item(2)).toHaveTextContent('Categories')
+ expect(row?.children.item(3)).toHaveTextContent('Languages')
+ expect(row).not.toHaveTextContent('·')
+ })
+
+ it('keeps Dify catalog navigation on the current origin', () => {
+ renderNavigation(false)
+
+ expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
+ 'href',
+ '/marketplace',
+ )
+ expect(
+ screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
+ ).toHaveAttribute('href', '/templates')
+ })
+
+ it('keeps both tab copies mounted while exposing only the active copy', () => {
+ const scrollContainer = document.createElement('div')
+ scrollContainer.id = 'marketplace-container'
+ document.body.appendChild(scrollContainer)
+ const containerRect = vi
+ .spyOn(scrollContainer, 'getBoundingClientRect')
+ .mockReturnValue(new DOMRect(0, -100, 100, 100))
+
+ renderNavigation(true)
+
+ const contentTabs = document.querySelector
(
+ '[data-home-catalog-tabs-slot="content"]',
+ )!
+ const catalogTabsRegion = contentTabs.parentElement!
+ const headerTabs = screen.getByTestId('header-catalog-tabs')
+ const headerTabsSlot = headerTabs.parentElement!
+ const handoffBoundaryRect = vi
+ .spyOn(catalogTabsRegion, 'getBoundingClientRect')
+ .mockReturnValue(new DOMRect(0, -7, 100, 56))
+
+ expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(headerTabsSlot).toHaveAttribute('inert')
+ expect(contentTabs).not.toHaveAttribute('aria-hidden')
+ expect(contentTabs).not.toHaveAttribute('inert')
+
+ containerRect.mockReturnValue(new DOMRect(0, 0, 100, 100))
+ handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56))
+ fireEvent.scroll(scrollContainer)
+
+ expect(headerTabs).toBeInTheDocument()
+ expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(headerTabsSlot).not.toHaveAttribute('inert')
+ expect(contentTabs).toHaveAttribute('aria-hidden', 'true')
+ expect(contentTabs).toHaveAttribute('inert')
+
+ scrollContainer.remove()
+ })
+
+ it('shows the compact navigation and header tabs after reaching the sticky header', () => {
+ const scrollContainer = document.createElement('div')
+ scrollContainer.id = 'marketplace-container'
+ document.body.appendChild(scrollContainer)
+
+ renderNavigation(true)
+
+ const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
+ const contentTabsSlot = document.querySelector(
+ '[data-home-catalog-tabs-slot="content"]',
+ )!
+ const catalogTabsRegion = contentTabsSlot.parentElement!
+ const headerTabs = screen.getByTestId('header-catalog-tabs')
+ const headerTabsSlot = headerTabs.parentElement!
+ vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100))
+ const handoffBoundaryRect = vi
+ .spyOn(catalogTabsRegion, 'getBoundingClientRect')
+ .mockReturnValue(new DOMRect(0, -7, 100, 56))
+
+ fireEvent.scroll(scrollContainer)
+ expect(headerTabs).toBeInTheDocument()
+ expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(headerTabsSlot).toHaveAttribute('inert')
+ expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(contentTabsSlot).not.toHaveAttribute('inert')
+
+ handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56))
+ fireEvent.scroll(scrollContainer)
+
+ expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!)
+ expect(contentTabsSlot).toHaveClass(styles.catalogTabsPinned!)
+ expect(headerTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(headerTabsSlot).not.toHaveAttribute('inert')
+ expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(contentTabsSlot).toHaveAttribute('inert')
+
+ handoffBoundaryRect.mockReturnValue(new DOMRect(0, -7, 100, 56))
+ fireEvent.scroll(scrollContainer)
+
+ expect(navigationSection).not.toHaveClass(styles.catalogNavigationPinned!)
+ expect(headerTabs).toBeInTheDocument()
+ expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true')
+ expect(headerTabsSlot).toHaveAttribute('inert')
+ expect(contentTabsSlot).not.toHaveAttribute('aria-hidden')
+ expect(contentTabsSlot).not.toHaveAttribute('inert')
+
+ scrollContainer.remove()
+ })
+
+ it('keeps the pinned state when compact styling moves the sticky section', () => {
+ const scrollContainer = document.createElement('div')
+ scrollContainer.id = 'marketplace-container'
+ document.body.appendChild(scrollContainer)
+
+ renderNavigation(true)
+
+ const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
+ const contentTabsSlot = document.querySelector(
+ '[data-home-catalog-tabs-slot="content"]',
+ )!
+ const catalogTabsRegion = contentTabsSlot.parentElement!
+ vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100))
+ vi.spyOn(catalogTabsRegion, 'getBoundingClientRect').mockReturnValue(
+ new DOMRect(0, -9, 100, 56),
+ )
+ vi.spyOn(navigationSection, 'getBoundingClientRect').mockReturnValue(
+ new DOMRect(0, 49, 100, 60),
+ )
+
+ fireEvent.scroll(scrollContainer)
+
+ expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!)
+ expect(screen.getByTestId('header-catalog-tabs').parentElement).not.toHaveAttribute(
+ 'aria-hidden',
+ )
+
+ scrollContainer.remove()
+ })
+
+ it('leaves browser scroll anchoring enabled because the handoff preserves geometry', () => {
+ const scrollContainer = document.createElement('div')
+ scrollContainer.id = 'marketplace-container'
+ document.body.appendChild(scrollContainer)
+
+ const { unmount } = renderNavigation(true)
+
+ expect(scrollContainer.style.overflowAnchor).toBe('')
+
+ unmount()
+ expect(scrollContainer.style.overflowAnchor).toBe('')
+
+ scrollContainer.remove()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx
new file mode 100644
index 00000000000..fe6feca9720
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx
@@ -0,0 +1,86 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import HomeGuide from '../home-guide'
+
+const mocks = vi.hoisted(() => ({
+ marketplaceUrlPrefix: 'https://marketplace.dify.ai',
+ useDocLink: vi.fn(() => (path?: string) => `https://docs.dify.ai/console${path || ''}`),
+}))
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ i18n: {
+ language: 'en-US',
+ },
+ t: withSelectorKey((key: string) => key),
+ }),
+ }
+})
+
+vi.mock('@/context/i18n', () => ({
+ defaultDocBaseUrl: 'https://docs.dify.ai',
+ useDocLink: mocks.useDocLink,
+}))
+
+vi.mock('@/config', () => ({
+ get MARKETPLACE_URL_PREFIX() {
+ return mocks.marketplaceUrlPrefix
+ },
+}))
+
+const openGuideMenu = async (isMarketplacePlatform: boolean) => {
+ const user = userEvent.setup()
+ render( )
+
+ expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /requestSubmit/ }))
+ return within(await screen.findByRole('menu'))
+}
+
+describe('HomeGuide', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
+ })
+
+ it('opens a four-option dropdown on the standalone Marketplace instead of navigating away', async () => {
+ const menu = await openGuideMenu(true)
+ const options = menu.getAllByRole('menuitem')
+
+ expect(options).toHaveLength(4)
+ expect(options[0]).toHaveAttribute(
+ 'href',
+ 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml',
+ )
+ expect(options[1]).toHaveAttribute(
+ 'href',
+ 'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin',
+ )
+ expect(options[2]).toHaveAttribute(
+ 'href',
+ 'https://docs.dify.ai/en/develop-plugin/publishing/marketplace-listing/release-overview',
+ )
+ expect(options[3]).toHaveAttribute('href', 'https://creators.dify.ai')
+ expect(mocks.useDocLink).not.toHaveBeenCalled()
+ })
+
+ it('uses Dify deployment-aware documentation links inside the console', async () => {
+ const menu = await openGuideMenu(false)
+ const options = menu.getAllByRole('menuitem')
+
+ expect(options).toHaveLength(4)
+ expect(options[1]).toHaveAttribute(
+ 'href',
+ 'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin',
+ )
+ expect(options[2]).toHaveAttribute(
+ 'href',
+ 'https://docs.dify.ai/console/develop-plugin/publishing/marketplace-listing/release-overview',
+ )
+ expect(mocks.useDocLink).toHaveBeenCalledOnce()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx
new file mode 100644
index 00000000000..f58cd24cbe5
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx
@@ -0,0 +1,146 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import HomeHeader from '../home-header'
+
+const mocks = vi.hoisted(() => ({
+ marketplaceUrlPrefix: 'https://marketplace.dify.ai',
+}))
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ i18n: {
+ language: 'en-US',
+ },
+ t: withSelectorKey((key: string) => key),
+ }),
+ }
+})
+
+vi.mock('@/context/i18n', () => ({
+ defaultDocBaseUrl: 'https://docs.dify.ai',
+}))
+
+vi.mock('@/config', () => ({
+ get MARKETPLACE_URL_PREFIX() {
+ return mocks.marketplaceUrlPrefix
+ },
+}))
+
+vi.mock('../home-sticky-state-provider', () => ({
+ HomeStickyCatalogTabs: ({ children }: { children: React.ReactNode }) => children,
+}))
+
+describe('HomeHeader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai'
+ })
+
+ it('shows Creator Center before the docs dropdown', () => {
+ render( )
+
+ const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })
+ const guideButton = screen.getByRole('button', { name: /requestSubmit/ })
+
+ expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/')
+ expect(creatorCenterLink).toHaveAttribute('target', '_blank')
+ expect(creatorCenterLink).toHaveAttribute('rel', 'noopener noreferrer')
+ expect(creatorCenterLink.parentElement?.className).toMatch(/standaloneHeaderActions/)
+ expect(creatorCenterLink.compareDocumentPosition(guideButton)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ )
+ // Creator Center must be a single interactive element, not a link-wrapped button.
+ expect(creatorCenterLink.querySelector('button')).toBeNull()
+ expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument()
+ })
+
+ it('links Creator Center to the staging Creators site in staging', () => {
+ mocks.marketplaceUrlPrefix = 'https://marketplace-staging.dify.dev'
+
+ render( )
+
+ expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
+ 'href',
+ 'https://creators-staging.dify.dev/',
+ )
+ })
+
+ it('links Creator Center to the dev Creators site on marketplace.dify.dev', () => {
+ mocks.marketplaceUrlPrefix = 'https://marketplace.dify.dev'
+
+ render( )
+
+ expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
+ 'href',
+ 'https://creators.dify.dev/',
+ )
+ })
+
+ it('falls back to the public Creator Center for a custom Marketplace origin', () => {
+ mocks.marketplaceUrlPrefix = 'http://localhost:3000'
+
+ render( )
+
+ expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
+ 'href',
+ 'https://creators.dify.ai/',
+ )
+ })
+
+ it('renders the Marketplace wordmark without a Marketplace text label', () => {
+ render( )
+
+ const brandLink = screen.getByRole('link', { name: 'Dify Marketplace' })
+ const [lightLogo, darkLogo] = brandLink.querySelectorAll('img')
+ expect(lightLogo).toHaveAttribute('src', expect.stringContaining('dify-marketplace-logo.svg'))
+ expect(darkLogo).toHaveAttribute(
+ 'src',
+ expect.stringContaining('dify-marketplace-logo-dark.svg'),
+ )
+ expect(lightLogo).toHaveAttribute('width', '141.761')
+ expect(lightLogo).toHaveAttribute('height', '16.386')
+ expect(darkLogo).toHaveAttribute('width', '141.761')
+ expect(darkLogo).toHaveAttribute('height', '16.386')
+ expect(screen.queryByText('mainNav.marketplace')).not.toBeInTheDocument()
+ })
+
+ it('selects neither catalog tab on non-catalog pages', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('link', { name: 'Plugins' })).not.toHaveAttribute('aria-current')
+ expect(screen.getByRole('link', { name: 'Templates' })).not.toHaveAttribute('aria-current')
+ })
+
+ it('shows Templates with only the active background on the Templates catalog', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('link', { name: '插件' })).not.toHaveAttribute('aria-current')
+ const templatesTab = screen.getByRole('link', { name: '模板' })
+ expect(templatesTab).toHaveAttribute('aria-current', 'page')
+ expect(templatesTab).toHaveAttribute('href', '/templates?language=zh-Hans')
+ expect(templatesTab).toHaveClass('bg-state-base-active')
+ expect(templatesTab).not.toHaveClass('text-text-accent')
+ expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx
new file mode 100644
index 00000000000..14d7ee9d0f6
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx
@@ -0,0 +1,89 @@
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { HERO_GRID_PITCH_PX, HERO_ICON_SIZE_PX } from '../home-constants'
+import HomeHero from '../home-hero'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) => key),
+ }),
+ }
+})
+
+describe('HomeHero', () => {
+ it('renders catalog-specific copy when supplied', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('heading', { name: 'Discover templates' })).toBeInTheDocument()
+ expect(screen.getByText('Start faster with ready-to-use workflows.')).toBeInTheDocument()
+ expect(screen.queryByText('marketplace.home.heroTitle')).not.toBeInTheDocument()
+ })
+
+ it('renders the six decorative hero icons as images instead of iconify masks', () => {
+ const { container } = render( )
+
+ for (const name of [
+ 'sparkling-fill',
+ 'plug-fill',
+ 'puzzle-fill',
+ 'brain-2-fill',
+ 'image-circle-ai-line',
+ 'voice-ai-fill',
+ ])
+ expect(container.querySelector(`img[src*="${name}"]`)).not.toBeNull()
+
+ expect(container.querySelector('img[src*="google"]')).toBeNull()
+ expect(container.querySelector('.i-ri-sparkling-fill')).toBeNull()
+ expect(container.querySelector('.i-custom-public-common-gmail')).toBeNull()
+ })
+
+ it('places each decorative icon flush inside a 41px grid cell', () => {
+ expect(HERO_ICON_SIZE_PX).toBe(HERO_GRID_PITCH_PX - 1)
+
+ const { container } = render( )
+ const icons = [...container.querySelectorAll('[aria-hidden] span.absolute')]
+ expect(icons).toHaveLength(6)
+
+ const plusOffset = /^calc\(50% \+ (-?\d+)px\)$/
+ const minusOffset = /^calc\(50% - (\d+)px\)$/
+
+ for (const icon of icons) {
+ const plusMatch = plusOffset.exec(icon.style.left)
+ const minusMatch = minusOffset.exec(icon.style.left)
+ const left = plusMatch
+ ? Number(plusMatch[1])
+ : minusMatch
+ ? -Number(minusMatch[1])
+ : Number.NaN
+ const top = Number.parseFloat(icon.style.top)
+
+ expect(left).not.toBeNaN()
+ expect((left - 1) % HERO_GRID_PITCH_PX === 0).toBe(true)
+ expect(top % HERO_GRID_PITCH_PX === 0).toBe(true)
+ }
+ })
+
+ it('starts vertical grid lines on the same 50% origin as the icons', () => {
+ const css = readFileSync(
+ resolve(dirname(fileURLToPath(import.meta.url)), '../home-hero.module.css'),
+ 'utf8',
+ )
+
+ expect(css).toMatch(/background-position:\s*calc\(50% \+ 0\.5px\)/)
+ expect(css).toMatch(/\.frame\s*\{\s*height:\s*163px/)
+ expect(css).toMatch(/\.glow\s*\{[\s\S]*?width:\s*555px/)
+ expect(css).toMatch(/\.glow\s*\{[\s\S]*?height:\s*245px/)
+ expect(css).toMatch(/\.glow\s*\{[\s\S]*?filter:\s*blur\(30px\)/)
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx
new file mode 100644
index 00000000000..9a0d6d8d9d8
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx
@@ -0,0 +1,211 @@
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { MARKETPLACE_CONTAINER_ID } from '../../constants'
+import { HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX } from '../home-constants'
+import HomeHeader from '../home-header'
+import HomeSearch from '../home-search'
+import { HomeShell } from '../home-shell'
+import styles from '../home-sticky.module.css'
+
+vi.mock('@/public/marketplace/dify-marketplace-logo-dark.svg', () => ({
+ default: { src: '/marketplace/dify-marketplace-logo-dark.svg' },
+}))
+
+vi.mock('@/public/marketplace/dify-marketplace-logo.svg', () => ({
+ default: { src: '/marketplace/dify-marketplace-logo.svg' },
+}))
+
+vi.mock('../home-catalog-tabs', () => ({
+ default: () => null,
+}))
+
+vi.mock('../home-creator-center', () => ({
+ default: () => null,
+}))
+
+vi.mock('../home-guide', () => ({
+ default: () => null,
+}))
+
+const nextFrame = () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ })
+
+const overlaps = (a: DOMRect, b: DOMRect) =>
+ a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
+
+const isCenterClickable = (target: Element) => {
+ const rect = target.getBoundingClientRect()
+ const node = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
+ return Boolean(node && target.contains(node))
+}
+
+const renderMarketplaceHome = () =>
+ render(
+
+
Sign in} isMarketplacePlatform />
+ }
+ hero={
}
+ isMarketplacePlatform
+ navigation={
}
+ page="plugins"
+ search={
+
+
+
+ }
+ >
+
+
+
,
+ )
+
+describe('Marketplace mobile search layout', () => {
+ it('pins the mobile search below the header without covering brand or actions', async () => {
+ await page.viewport(390, 844)
+ const screen = await renderMarketplaceHome()
+
+ const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
+ const header = screen.getByRole('banner').element()
+ const brand = screen.getByRole('link', { name: 'Dify Marketplace' }).element()
+ const signIn = screen.getByRole('button', { name: 'Sign in' }).element()
+ const searchInput = screen
+ .getByRole('textbox', { name: 'Search plugins or templates' })
+ .element()
+
+ scrollContainer.scrollTop = 400
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await nextFrame()
+
+ const headerRect = header.getBoundingClientRect()
+ const searchRect = searchInput.getBoundingClientRect()
+
+ expect(searchRect.top).toBeGreaterThanOrEqual(headerRect.bottom - 1)
+ expect(searchRect.top).toBeLessThanOrEqual(headerRect.bottom + 2)
+ expect(overlaps(searchRect, brand.getBoundingClientRect())).toBe(false)
+ expect(overlaps(searchRect, signIn.getBoundingClientRect())).toBe(false)
+ expect(isCenterClickable(brand)).toBe(true)
+ expect(isCenterClickable(signIn)).toBe(true)
+ expect(isCenterClickable(searchInput)).toBe(true)
+ })
+
+ it('keeps bottom padding under the stuck mobile search', async () => {
+ await page.viewport(390, 844)
+ const screen = await renderMarketplaceHome()
+
+ const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
+ const searchInput = screen
+ .getByRole('textbox', { name: 'Search plugins or templates' })
+ .element()
+
+ scrollContainer.scrollTop = 400
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await nextFrame()
+
+ const searchRow = document.querySelector(`.${styles.search}`)!
+ const inputRect = searchInput.getBoundingClientRect()
+ const rowRect = searchRow.getBoundingClientRect()
+
+ expect(getComputedStyle(searchRow).paddingBottom).toBe(
+ `${HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX}px`,
+ )
+ expect(rowRect.bottom - inputRect.bottom).toBeCloseTo(HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, 0)
+ })
+
+ it('keeps the desktop search in the header gap while scrolling', async () => {
+ await page.viewport(1280, 900)
+ const screen = await renderMarketplaceHome()
+
+ const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
+ const header = screen.getByRole('banner').element()
+ const searchInput = screen
+ .getByRole('textbox', { name: 'Search plugins or templates' })
+ .element()
+
+ scrollContainer.scrollTop = 400
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await nextFrame()
+
+ expect(
+ searchInput.getBoundingClientRect().top - header.getBoundingClientRect().top,
+ ).toBeCloseTo(6, 0)
+ expect(getComputedStyle(document.querySelector(`.${styles.search}`)!).paddingBottom).toBe('0px')
+ })
+
+ it('keeps a search-results search below the header when there is no hero to overlap', async () => {
+ await page.viewport(1280, 900)
+ const screen = await render(
+
+
Sign in} isMarketplacePlatform />
+ }
+ hero={null}
+ isMarketplacePlatform
+ navigation={null}
+ page="plugins"
+ search={
+
+
+
+ }
+ >
+
+
+
,
+ )
+
+ const header = screen.getByRole('banner').element()
+ const searchInput = screen
+ .getByRole('textbox', { name: 'Search plugins or templates' })
+ .element()
+
+ expect(searchInput.getBoundingClientRect().top).toBeGreaterThanOrEqual(
+ header.getBoundingClientRect().bottom - 1,
+ )
+ expect(searchInput.getBoundingClientRect().width).toBeGreaterThan(300)
+ })
+
+ it('does not jump the page when the stuck desktop search is focused or typed into', async () => {
+ await page.viewport(1280, 900)
+ const screen = await renderMarketplaceHome()
+
+ const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)!
+ const header = screen.getByRole('banner').element()
+ const searchInput = screen
+ .getByRole('textbox', { name: 'Search plugins or templates' })
+ .element()
+
+ scrollContainer.scrollTop = 400
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await nextFrame()
+
+ const scrollTopBefore = scrollContainer.scrollTop
+ const inputTopBefore = searchInput.getBoundingClientRect().top
+ expect(inputTopBefore - header.getBoundingClientRect().top).toBeCloseTo(6, 0)
+
+ const searchLocator = screen.getByRole('textbox', { name: 'Search plugins or templates' })
+ await searchLocator.click()
+ await nextFrame()
+
+ expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
+ expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
+
+ await searchLocator.fill('g')
+ await nextFrame()
+
+ expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
+ expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx
new file mode 100644
index 00000000000..b9fb2809f9a
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx
@@ -0,0 +1,275 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import HomeTrending from '../home-trending'
+import { HomeBannerSlide } from '../home-trending-slides'
+
+const createBlogBanner = (id: string, title: string, sort: number): PluginBanner => ({
+ id,
+ style_type: 'blog',
+ title,
+ sort,
+ language: 'en',
+ content: {
+ blog_title: title,
+ subtitle: 'New Agent node support',
+ description: 'Build agent workflows with the new Agent node.',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+})
+
+const blogBanner = createBlogBanner('blog', 'Dify v1.9 new launch', 0)
+const adBanner: PluginBanner = {
+ id: 'ad',
+ style_type: 'ad',
+ title: 'Partner campaign',
+ sort: 1,
+ language: 'en',
+ content: {
+ images: {
+ desktop: '/api/v1/banners/images/banners/ad.png',
+ },
+ link: 'https://partner.example.com',
+ alt_text: 'Partner campaign',
+ },
+}
+const eventBanner: PluginBanner = {
+ id: 'event',
+ style_type: 'event',
+ title: 'Launch event',
+ sort: 2,
+ language: 'en',
+ content: {
+ images: {
+ desktop: '/api/v1/banners/images/banners/event.png',
+ },
+ link: 'https://dify.ai/event',
+ alt_text: 'Launch event',
+ },
+}
+const carouselBanners = [
+ createBlogBanner('first', 'First banner', 0),
+ createBlogBanner('second', 'Second banner', 1),
+ createBlogBanner('third', 'Third banner', 2),
+]
+
+const visibleReadMore = (slide: Element) =>
+ [...slide.querySelectorAll('[aria-hidden]')].find((el) => {
+ const text = el.textContent ?? ''
+ return /Read more|trendingReadMore/.test(text) && el.getBoundingClientRect().height > 0
+ }) ?? null
+
+describe('Marketplace home trending layout', () => {
+ it('keeps standalone mobile blog banners at the stacked 357px height', async () => {
+ await page.viewport(600, 900)
+ await render(
+ ,
+ )
+
+ const blogSlide = document.querySelector('[data-testid="blog-banner"] > a')!
+
+ expect(blogSlide.getBoundingClientRect().height).toBe(357)
+ })
+
+ it('clamps standalone mobile blog subtitle to one line and description to two', async () => {
+ await page.viewport(600, 900)
+ const subtitleText =
+ 'On September 10, 2026, LangGenius K.K. will host its flagship annual conference in Tokyo.'
+ const descriptionText =
+ 'It is a full day dedicated to turning generative AI from isolated pilots into real operations. Registration is open now for the second year of the conference.'
+ const longTag = 'IF Con Tokyo 2026 Annual Conference Extra Long Label'
+ const longTitle = 'IF Con Tokyo 2026: Turn “What If” into Production'
+ const longBlog: PluginBanner = {
+ id: 'blog-long',
+ style_type: 'blog',
+ title: longTag,
+ sort: 0,
+ language: 'en',
+ content: {
+ blog_title: longTitle,
+ subtitle: subtitleText,
+ description: descriptionText,
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+ }
+ const screen = await render(
+
+
+
,
+ )
+
+ const slide = screen.getByRole('link').element()
+ const tag = screen.getByText(longTag).element()
+ const title = screen.getByRole('heading', { name: longTitle }).element()
+ const subtitle = screen.getByText(subtitleText).element()
+ const description = screen.getByText(descriptionText).element()
+ const slideBox = slide.getBoundingClientRect()
+ const titleBox = title.getBoundingClientRect()
+
+ expect(getComputedStyle(tag).whiteSpace).toBe('nowrap')
+ expect(getComputedStyle(tag).textOverflow).toBe('ellipsis')
+ expect(getComputedStyle(title).whiteSpace).toBe('normal')
+ expect(titleBox.height).toBeGreaterThan(24)
+ expect(titleBox.left - slideBox.left).toBeCloseTo(20, 0)
+ expect(slideBox.right - titleBox.right).toBeCloseTo(20, 0)
+ expect(slideBox.height).toBeGreaterThan(357)
+ expect(getComputedStyle(subtitle).whiteSpace).toBe('nowrap')
+ expect(getComputedStyle(subtitle).textOverflow).toBe('ellipsis')
+ expect(getComputedStyle(description).webkitLineClamp).toBe('2')
+ expect(description.getBoundingClientRect().height).toBeCloseTo(40, 0)
+ expect(visibleReadMore(slide)).toBeNull()
+ })
+
+ it('clamps the desktop blog tag to one line and lets the title wrap', async () => {
+ await page.viewport(1200, 900)
+ const longTag =
+ "Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents — extra-long green label"
+ const longTitle =
+ "Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and AgentsDify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents"
+ const longBlog: PluginBanner = {
+ ...createBlogBanner('blog-desktop-long', longTitle, 0),
+ title: longTag,
+ }
+ const screen = await render(
+
+
+
,
+ )
+
+ const tag = screen.getByText(longTag).element()
+ const title = screen.getByRole('heading', { name: longTitle }).element()
+ const tagBox = tag.getBoundingClientRect()
+ const titleBox = title.getBoundingClientRect()
+
+ expect(getComputedStyle(tag).whiteSpace).toBe('nowrap')
+ expect(getComputedStyle(tag).textOverflow).toBe('ellipsis')
+ expect(tagBox.height).toBeLessThanOrEqual(20)
+ expect(getComputedStyle(title).whiteSpace).toBe('normal')
+ expect(titleBox.height).toBeGreaterThan(24)
+ expect(visibleReadMore(screen.getByRole('link').element())).not.toBeNull()
+ })
+
+ it('shows the standalone mobile event poster at the 800:721 delivery ratio', async () => {
+ await page.viewport(600, 900)
+ const screen = await render(
+
+
+
,
+ )
+
+ const slide = screen.getByRole('link', { name: 'Launch event' }).element()
+ const box = slide.getBoundingClientRect()
+ const artwork = slide.querySelector('img')
+
+ expect(box.height).toBeCloseTo((box.width * 721) / 800, 1)
+ expect(artwork).not.toBeNull()
+ expect(getComputedStyle(artwork!).objectFit).toBe('contain')
+ expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
+ })
+
+ it('keeps event and ad artwork left-aligned so desktop cropping stays on the right', async () => {
+ await page.viewport(1000, 900)
+ const screen = await render(
+
+
+
+
,
+ )
+
+ for (const name of ['Partner campaign', 'Launch event']) {
+ const artwork = screen.getByRole('link', { name }).element().querySelector('img')
+
+ expect(artwork).not.toBeNull()
+ expect(getComputedStyle(artwork!).objectFit).toBe('cover')
+ expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
+ }
+ })
+
+ it('keeps blog artwork at 400px on desktop so shrinking clips the right', async () => {
+ await page.viewport(1200, 900)
+ const screen = await render(
+
+
+
,
+ )
+
+ const artwork = screen.getByRole('link').element().querySelector('img')
+
+ expect(artwork).not.toBeNull()
+ expect(artwork!.getBoundingClientRect().width).toBe(400)
+ expect(getComputedStyle(artwork!).objectFit).toBe('cover')
+ expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
+ })
+
+ it('keeps desktop event artwork at least 1200px wide so overflow clips the right', async () => {
+ await page.viewport(1000, 900)
+ const screen = await render(
+
+
+
,
+ )
+
+ const artwork = screen
+ .getByRole('link', { name: 'Launch event' })
+ .element()
+ .querySelector('img')
+
+ expect(artwork).not.toBeNull()
+ expect(artwork!.getBoundingClientRect().width).toBeGreaterThanOrEqual(1200)
+ expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%')
+ })
+
+ it('keeps the blog artwork left corners rounded when its image is cropped', async () => {
+ const screen = await render(
+
+
+
,
+ )
+
+ const artwork = screen.getByRole('link').element().querySelector('img')
+
+ expect(artwork).not.toBeNull()
+ expect(getComputedStyle(artwork!).borderTopLeftRadius).toBe('16px')
+ expect(getComputedStyle(artwork!).borderBottomLeftRadius).toBe('16px')
+ })
+
+ it('moves forwards into the first slide clone before resetting the loop', async () => {
+ const screen = await render(
+ ,
+ )
+
+ await screen.getByRole('button', { name: 'Third banner' }).click()
+ await new Promise((resolve) => setTimeout(resolve, 450))
+
+ const track = document.querySelector('[data-carousel-track]')!
+ const progress = document.querySelector('[data-carousel-progress]')!
+ const progressAnimation = progress.getAnimations()[0]
+ expect(progressAnimation).toBeDefined()
+ progressAnimation!.finish()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute(
+ 'aria-current',
+ 'true',
+ )
+ expect(track.style.transform).toContain('-300%')
+ expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument()
+
+ await expect
+ .poll(() => track.getAttribute('data-carousel-loop-phase'), { timeout: 1000 })
+ .toBe('idle')
+
+ expect(screen.getByRole('button', { name: 'First banner' }).element()).toHaveAttribute(
+ 'aria-current',
+ 'true',
+ )
+ expect(track.style.transform).toBe('translate3d(0%, 0px, 0px)')
+ expect(track.querySelector('[data-carousel-loop-clone]')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx
new file mode 100644
index 00000000000..ca2f7d81983
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx
@@ -0,0 +1,207 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import HomeTrending from '../home-trending'
+
+const createBanner = (id: string, title: string, sort: number): PluginBanner => ({
+ id,
+ style_type: 'blog',
+ title,
+ sort,
+ language: 'en',
+ content: {
+ blog_title: title,
+ subtitle: `${title} subtitle`,
+ description: `${title} description`,
+ link: `https://example.com/${id}`,
+ link_target_type: 'blog',
+ },
+})
+
+const banners = [
+ createBanner('first', 'First banner', 0),
+ createBanner('second', 'Second banner', 1),
+ createBanner('third', 'Third banner', 2),
+]
+
+const dispatchTouchPointer = (
+ target: Element,
+ type: 'pointerdown' | 'pointermove' | 'pointerup',
+ init: Pick,
+) =>
+ target.dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ isPrimary: true,
+ pointerType: 'touch',
+ ...init,
+ }),
+ )
+
+describe('Marketplace home trending mobile swipe', () => {
+ it('switches in both directions without activating a dragged link or clearing Pause', async () => {
+ await page.viewport(600, 900)
+ const screen = await render(
+
+
+
,
+ )
+
+ const firstSlideLocator = screen.getByRole('group', { name: 'First banner' })
+ const secondSlideLocator = screen.getByRole('group', {
+ name: 'Second banner',
+ includeHidden: true,
+ })
+ const firstSlide = firstSlideLocator.element()
+ const firstLink = firstSlide.querySelector('a')!
+
+ dispatchTouchPointer(firstSlide, 'pointerdown', {
+ pointerId: 1,
+ clientX: 480,
+ clientY: 160,
+ })
+ dispatchTouchPointer(firstSlide, 'pointermove', {
+ pointerId: 1,
+ clientX: 300,
+ clientY: 166,
+ })
+ await expect.element(secondSlideLocator).toBeVisible()
+ dispatchTouchPointer(firstSlide, 'pointerup', {
+ pointerId: 1,
+ clientX: 300,
+ clientY: 166,
+ })
+ const clickWasNotCanceled = firstLink.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
+ )
+
+ expect(clickWasNotCanceled).toBe(false)
+ await expect
+ .element(screen.getByRole('button', { name: 'Second banner' }))
+ .toHaveAttribute('aria-current', 'true')
+ await screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }).click()
+
+ const secondSlide = secondSlideLocator.element()
+ dispatchTouchPointer(secondSlide, 'pointerdown', {
+ pointerId: 2,
+ clientX: 260,
+ clientY: 160,
+ })
+ dispatchTouchPointer(secondSlide, 'pointermove', {
+ pointerId: 2,
+ clientX: 440,
+ clientY: 166,
+ })
+ dispatchTouchPointer(secondSlide, 'pointerup', {
+ pointerId: 2,
+ clientX: 440,
+ clientY: 166,
+ })
+
+ await expect
+ .element(screen.getByRole('button', { name: 'First banner' }))
+ .toHaveAttribute('aria-current', 'true')
+ await expect
+ .element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }))
+ .toBeInTheDocument()
+ })
+
+ it('suppresses the trailing click when a horizontal drag is pulled back before release', async () => {
+ await page.viewport(600, 900)
+ const screen = await render(
+
+
+
,
+ )
+ const firstSlide = screen.getByRole('group', { name: 'First banner' }).element()
+ const firstLink = firstSlide.querySelector('a')!
+
+ dispatchTouchPointer(firstSlide, 'pointerdown', {
+ pointerId: 1,
+ clientX: 400,
+ clientY: 160,
+ })
+ dispatchTouchPointer(firstSlide, 'pointermove', {
+ pointerId: 1,
+ clientX: 280,
+ clientY: 164,
+ })
+ dispatchTouchPointer(firstSlide, 'pointermove', {
+ pointerId: 1,
+ clientX: 396,
+ clientY: 162,
+ })
+ dispatchTouchPointer(firstSlide, 'pointerup', {
+ pointerId: 1,
+ clientX: 396,
+ clientY: 162,
+ })
+
+ expect(
+ firstLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })),
+ ).toBe(false)
+ await expect
+ .element(screen.getByRole('button', { name: 'First banner' }))
+ .toHaveAttribute('aria-current', 'true')
+ })
+
+ it('keeps vertical gestures on the current slide and ignores desktop touch input', async () => {
+ await page.viewport(600, 900)
+ let screen = await render(
+
+
+
,
+ )
+ let activeSlide = screen.getByRole('group', { name: 'First banner' }).element()
+
+ dispatchTouchPointer(activeSlide, 'pointerdown', {
+ pointerId: 1,
+ clientX: 300,
+ clientY: 120,
+ })
+ dispatchTouchPointer(activeSlide, 'pointermove', {
+ pointerId: 1,
+ clientX: 270,
+ clientY: 300,
+ })
+ dispatchTouchPointer(activeSlide, 'pointerup', {
+ pointerId: 1,
+ clientX: 270,
+ clientY: 300,
+ })
+
+ await expect
+ .element(screen.getByRole('button', { name: 'First banner' }))
+ .toHaveAttribute('aria-current', 'true')
+
+ screen.unmount()
+ await page.viewport(1000, 900)
+ screen = await render(
+
+
+
,
+ )
+ activeSlide = screen.getByRole('group', { name: 'First banner' }).element()
+
+ dispatchTouchPointer(activeSlide, 'pointerdown', {
+ pointerId: 2,
+ clientX: 480,
+ clientY: 160,
+ })
+ dispatchTouchPointer(activeSlide, 'pointermove', {
+ pointerId: 2,
+ clientX: 260,
+ clientY: 160,
+ })
+ dispatchTouchPointer(activeSlide, 'pointerup', {
+ pointerId: 2,
+ clientX: 260,
+ clientY: 160,
+ })
+
+ await expect
+ .element(screen.getByRole('button', { name: 'First banner' }))
+ .toHaveAttribute('aria-current', 'true')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx
new file mode 100644
index 00000000000..318482700f1
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx
@@ -0,0 +1,837 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { trackEvent } from '@/app/components/base/amplitude'
+import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track'
+import HomeTrending from '../home-trending'
+
+vi.mock('@/app/components/base/amplitude', () => ({
+ trackEvent: vi.fn(),
+}))
+
+vi.mock('@/utils/marketplace-site-track', () => ({
+ rememberMarketplaceSiteReferrer: vi.fn(),
+ trackMarketplaceSiteEvent: vi.fn(),
+}))
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useTranslation: (namespace: string) => ({
+ t: withSelectorKey((key: string) => `${namespace}.${key}`),
+ }),
+ }
+})
+
+vi.mock('@/app/components/plugins/base/badges/partner', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/plugins/base/badges/verified', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/config', async (importOriginal) => ({
+ ...(await importOriginal()),
+ MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com',
+}))
+
+const banners: PluginBanner[] = [
+ {
+ id: 'recommend',
+ style_type: 'recommend',
+ title: 'Trending',
+ sort: 0,
+ language: 'en',
+ content: {
+ theme_type: 'hottest',
+ heading: 'Popular plugins',
+ description: 'Chosen from real usage.',
+ cards: [
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/dropbox',
+ display_name: 'Dropbox',
+ icon_url: '/api/v1/plugins/langgenius/dropbox/icon',
+ creator: 'langgenius',
+ badges: ['partner', 'verified'],
+ link: '/plugins/langgenius/dropbox',
+ card_position: 0,
+ auto_batch_id: '11111111-1111-4111-8111-111111111111',
+ },
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/zapier',
+ display_name: 'Zapier',
+ link: '/plugins/langgenius/zapier',
+ card_position: 1,
+ },
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/notion',
+ display_name: 'Notion',
+ link: '/plugins/langgenius/notion',
+ card_position: 2,
+ },
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/slack',
+ display_name: 'Slack',
+ link: '/plugins/langgenius/slack',
+ card_position: 3,
+ },
+ ],
+ },
+ },
+ {
+ id: 'blog',
+ style_type: 'blog',
+ title: 'Dify Updates',
+ sort: 1,
+ language: 'en',
+ content: {
+ blog_title: 'Dify v1.9 new launch',
+ subtitle: 'New Agent node support',
+ description: 'Build agent workflows with the new Agent node.',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+ },
+ {
+ id: 'event',
+ style_type: 'event',
+ title: 'Duck Duck Go',
+ sort: 2,
+ language: 'en',
+ content: {
+ images: {
+ desktop: '/api/v1/banners/images/banners/duckduckgo.png',
+ mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png',
+ },
+ link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
+ alt_text: 'DuckDuckGo plugin',
+ },
+ },
+]
+
+const mockTrackEvent = vi.mocked(trackEvent)
+const mockTrackMarketplaceSiteEvent = vi.mocked(trackMarketplaceSiteEvent)
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('HomeTrending', () => {
+ it('renders and switches between the three API-backed banner layouts', async () => {
+ const user = userEvent.setup()
+
+ render( )
+
+ expect(document.querySelector('[data-home-trending-carousel-root]')?.className).toMatch(
+ /carouselRoot/,
+ )
+ expect(screen.getByRole('heading', { name: 'Popular plugins' })).toBeInTheDocument()
+ const recommendationSlide = screen.getByRole('group', { name: 'Trending' })
+ expect(
+ within(recommendationSlide)
+ .getAllByRole('link')
+ .map((link) => link.getAttribute('aria-label')),
+ ).toEqual(['Dropbox', 'Zapier', 'Notion', 'Slack'])
+
+ await user.click(screen.getByRole('button', { name: 'Dify Updates' }))
+
+ 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: 'plugin.marketplace.home.trendingReadMoreAbout',
+ })
+ expect(blogLink).toHaveAttribute('href', 'https://dify.ai/blog')
+ expect(within(blogSlide).getAllByRole('link')).toHaveLength(1)
+ expect(
+ within(blogLink).getByRole('heading', { name: 'Dify v1.9 new launch' }),
+ ).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
+
+ expect(screen.getByRole('link', { name: 'DuckDuckGo plugin' })).toHaveAttribute(
+ 'href',
+ 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
+ )
+ })
+
+ it('marks inactive standalone slides so mobile CSS can collapse mixed banner heights', () => {
+ render( )
+
+ const recommendSlide = screen.getByRole('group', { name: 'Trending' })
+ const blogSlide = document.querySelector(
+ '[aria-roledescription="slide"][aria-label="Dify Updates"]',
+ )
+ const eventSlide = document.querySelector(
+ '[aria-roledescription="slide"][aria-label="Duck Duck Go"]',
+ )
+ const eventLink = document.querySelector('a[aria-label="DuckDuckGo plugin"]')
+
+ expect(recommendSlide.className).toMatch(/slide/)
+ expect(recommendSlide.className).not.toMatch(/slideInactive/)
+ expect(blogSlide?.className).toMatch(/slideInactive/)
+ expect(eventSlide?.className).toMatch(/slideInactive/)
+ expect(recommendSlide.firstElementChild?.className).toMatch(/stackedSlide/)
+ expect(blogSlide?.firstElementChild?.className).toMatch(/stackedSlide/)
+ expect(eventLink?.className).toMatch(/imageSlide/)
+ expect(eventLink?.querySelector('source')).toHaveAttribute('media', '(max-width: 879px)')
+ expect(eventLink?.querySelector('source')?.getAttribute('srcset')).toContain(
+ 'duckduckgo-mobile.png',
+ )
+ })
+
+ it('keeps the embedded event image breakpoint at 639px', () => {
+ render( )
+
+ expect(document.querySelector('a[aria-label="DuckDuckGo plugin"] source')).toHaveAttribute(
+ 'media',
+ '(max-width: 639px)',
+ )
+ })
+
+ it('falls back to desktop on the mobile source when an event banner has no mobile asset', () => {
+ const eventWithoutMobile: PluginBanner = {
+ id: 'event-desktop-only',
+ style_type: 'event',
+ title: 'Desktop Event',
+ sort: 0,
+ language: 'en',
+ content: {
+ images: {
+ desktop: '/api/v1/banners/images/banners/event-desktop.png',
+ tablet: '/api/v1/banners/images/banners/event-tablet.png',
+ },
+ link: 'https://dify.ai/event',
+ alt_text: 'Desktop event',
+ },
+ }
+
+ render( )
+
+ const eventLink = screen.getByRole('link', { name: 'Desktop event' })
+ const sources = eventLink.querySelectorAll('source')
+
+ expect(sources[0]).toHaveAttribute('media', '(max-width: 879px)')
+ expect(sources[0]?.getAttribute('srcset')).toContain('event-desktop.png')
+ expect(sources[0]?.getAttribute('srcset')).not.toContain('event-tablet.png')
+ expect(sources[1]).toHaveAttribute('media', '(min-width: 880px) and (max-width: 1023px)')
+ expect(sources[1]?.getAttribute('srcset')).toContain('event-tablet.png')
+ expect(eventLink.querySelector('img')?.getAttribute('src')).toContain('event-desktop.png')
+ })
+
+ it('switches to the selected slide from the pagination with the keyboard', async () => {
+ const user = userEvent.setup()
+
+ render( )
+
+ const duckDuckGoButton = screen.getByRole('button', { name: 'Duck Duck Go' })
+
+ duckDuckGoButton.focus()
+ await user.keyboard('{Enter}')
+
+ expect(duckDuckGoButton).toHaveAttribute('aria-current', 'true')
+ expect(screen.getByRole('button', { name: 'Trending' })).not.toHaveAttribute('aria-current')
+ expect(screen.getByRole('group', { name: 'Duck Duck Go' })).toHaveAttribute(
+ 'aria-hidden',
+ 'false',
+ )
+ })
+
+ it('loops from the last banner to a visual clone before resetting to the first banner', () => {
+ const animations: Array<{
+ cancel: ReturnType
+ onfinish: (() => void) | null
+ pause: ReturnType
+ play: ReturnType
+ }> = []
+ const originalAnimate = Element.prototype.animate
+ Object.defineProperty(Element.prototype, 'animate', {
+ configurable: true,
+ value: vi.fn(() => {
+ const animation = {
+ cancel: vi.fn(),
+ onfinish: null,
+ pause: vi.fn(),
+ play: vi.fn(),
+ }
+ animations.push(animation)
+ return animation as unknown as Animation
+ }),
+ })
+
+ try {
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
+ const track = document.querySelector('[data-carousel-track]')!
+
+ act(() => animations.at(-1)?.onfinish?.())
+
+ expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute(
+ 'aria-current',
+ 'true',
+ )
+ expect(track).toHaveStyle({ transform: 'translate3d(-300%, 0, 0)' })
+ expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument()
+
+ fireEvent.transitionEnd(track, { propertyName: 'transform' })
+
+ expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute(
+ 'aria-current',
+ 'true',
+ )
+ expect(track).toHaveStyle({ transform: 'translate3d(-0%, 0, 0)', transition: 'none' })
+ } finally {
+ Object.defineProperty(Element.prototype, 'animate', {
+ configurable: true,
+ value: originalAnimate,
+ })
+ }
+ })
+
+ it('toggles the carousel between paused and playing states', async () => {
+ const user = userEvent.setup()
+
+ render( )
+
+ const carousel = document.querySelector('[data-home-trending-carousel-root]')!
+ const liveTrack = carousel.querySelector('[aria-live]')!
+ expect(liveTrack).toHaveAttribute('aria-live', 'off')
+
+ const pauseButton = screen.getByRole('button', {
+ name: 'plugin.marketplace.home.trendingPause',
+ })
+ expect(pauseButton).toHaveClass('bg-state-base-active')
+
+ pauseButton.focus()
+ await user.keyboard('{Enter}')
+
+ expect(liveTrack).toHaveAttribute('aria-live', 'polite')
+
+ const playButton = screen.getByRole('button', {
+ name: 'plugin.marketplace.home.trendingPlay',
+ })
+
+ playButton.focus()
+ await user.keyboard(' ')
+
+ expect(
+ screen.getByRole('button', {
+ name: 'plugin.marketplace.home.trendingPause',
+ }),
+ ).toBeInTheDocument()
+ })
+
+ it('starts with autoplay paused when reduced motion is enabled', () => {
+ const matchMedia = vi.spyOn(window, 'matchMedia').mockReturnValue({
+ matches: true,
+ media: '(prefers-reduced-motion: reduce)',
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })
+
+ render( )
+
+ expect(
+ screen.getByRole('button', {
+ name: 'plugin.marketplace.home.trendingPlay',
+ }),
+ ).toBeInTheDocument()
+
+ matchMedia.mockRestore()
+ })
+
+ it('keeps embedded autoplay paused until every pause reason is cleared', () => {
+ const pause = vi.fn()
+ const play = vi.fn()
+ const cancel = vi.fn()
+ const progressAnimation = {
+ cancel,
+ 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 intersectionObservers: {
+ callback: IntersectionObserverCallback
+ options?: IntersectionObserverInit
+ }[] = []
+ class MockIntersectionObserver {
+ disconnect = vi.fn()
+ observe = vi.fn()
+ root: Element | Document | null
+ rootMargin: string
+ takeRecords = vi.fn(() => [])
+ thresholds: readonly number[]
+ unobserve = vi.fn()
+
+ constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
+ this.root = options?.root ?? null
+ this.rootMargin = options?.rootMargin ?? '0px'
+ this.thresholds = Array.isArray(options?.threshold)
+ ? options.threshold
+ : [options?.threshold ?? 0]
+ intersectionObservers.push({ callback, options })
+ }
+ }
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+ let reducedMotion = false
+ 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(),
+ }))
+ const marketplaceContainer = document.createElement('div')
+ marketplaceContainer.id = 'marketplace-container'
+ document.body.appendChild(marketplaceContainer)
+
+ const { unmount } = render(
+ ,
+ {
+ container: marketplaceContainer,
+ },
+ )
+ const carouselRoot = marketplaceContainer.querySelector('[data-home-trending-carousel-root]')!
+ const viewportObserver = intersectionObservers.find(
+ (observer) => observer.options?.threshold === 0.25,
+ )
+ const setIntersectionRatio = (intersectionRatio: number) => {
+ act(() => {
+ viewportObserver?.callback(
+ [
+ {
+ intersectionRatio,
+ isIntersecting: intersectionRatio > 0,
+ } as IntersectionObserverEntry,
+ ],
+ {} as IntersectionObserver,
+ )
+ })
+ }
+
+ expect(pause).toHaveBeenCalled()
+
+ setIntersectionRatio(0.25)
+ expect(play).toHaveBeenCalledOnce()
+
+ fireEvent.mouseEnter(carouselRoot)
+ setIntersectionRatio(0)
+ fireEvent.mouseLeave(carouselRoot)
+ expect(play).toHaveBeenCalledOnce()
+
+ setIntersectionRatio(0.25)
+ expect(play).toHaveBeenCalledTimes(2)
+
+ const playsBeforeFocus = play.mock.calls.length
+ const focusTarget = carouselRoot.querySelector('a')!
+ fireEvent.focusIn(focusTarget)
+ setIntersectionRatio(0)
+ setIntersectionRatio(0.25)
+ expect(play).toHaveBeenCalledTimes(playsBeforeFocus)
+ fireEvent.focusOut(focusTarget, { relatedTarget: null })
+ expect(play.mock.calls.length).toBeGreaterThan(playsBeforeFocus)
+
+ // Navigation controls sit inside the pause boundary, so focusing them
+ // also stops the rotation.
+ const playsBeforeControlFocus = play.mock.calls.length
+ const paginationButton = screen.getByRole('button', { name: 'Dify Updates' })
+ fireEvent.focusIn(paginationButton)
+ setIntersectionRatio(0)
+ setIntersectionRatio(0.25)
+ expect(play).toHaveBeenCalledTimes(playsBeforeControlFocus)
+ fireEvent.focusOut(paginationButton, { relatedTarget: null })
+ expect(play.mock.calls.length).toBeGreaterThan(playsBeforeControlFocus)
+
+ const playsBeforeUserPause = play.mock.calls.length
+ fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }))
+ setIntersectionRatio(0)
+ setIntersectionRatio(0.25)
+ expect(play).toHaveBeenCalledTimes(playsBeforeUserPause)
+ fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }))
+ expect(play.mock.calls.length).toBeGreaterThan(playsBeforeUserPause)
+
+ const playsBeforeVisibilityPause = play.mock.calls.length
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'hidden',
+ })
+ fireEvent(document, new Event('visibilitychange'))
+ setIntersectionRatio(0.25)
+ expect(play).toHaveBeenCalledTimes(playsBeforeVisibilityPause)
+
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'visible',
+ })
+ fireEvent(document, new Event('visibilitychange'))
+ expect(play.mock.calls.length).toBeGreaterThan(playsBeforeVisibilityPause)
+
+ const playsBeforeReducedMotion = play.mock.calls.length
+ reducedMotion = true
+ reducedMotionListener?.()
+ setIntersectionRatio(0)
+ setIntersectionRatio(0.25)
+ expect(play).toHaveBeenCalledTimes(playsBeforeReducedMotion)
+
+ reducedMotion = false
+ reducedMotionListener?.()
+ expect(play.mock.calls.length).toBeGreaterThan(playsBeforeReducedMotion)
+
+ unmount()
+ marketplaceContainer.remove()
+ Object.defineProperty(Element.prototype, 'animate', {
+ configurable: true,
+ value: originalAnimate,
+ })
+ })
+
+ it('resumes autoplay after a pointer click on pagination without waiting for blur', 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( )
+
+ const carouselRoot = document.querySelector('[data-home-trending-carousel-root]')!
+ const paginationButton = screen.getByRole('button', { name: 'Dify Updates' })
+
+ // Pointer activation hovers and focuses the control, which normally
+ // pauses rotation until mouseleave/focusout.
+ fireEvent.mouseEnter(carouselRoot)
+ paginationButton.focus()
+ fireEvent.focusIn(paginationButton)
+
+ const playsBeforeSelect = play.mock.calls.length
+ await user.click(paginationButton)
+
+ expect(paginationButton).toHaveAttribute('aria-current', 'true')
+ expect(document.activeElement).toBe(paginationButton)
+ expect(play.mock.calls.length).toBeGreaterThan(playsBeforeSelect)
+
+ Object.defineProperty(Element.prototype, 'animate', {
+ configurable: true,
+ value: originalAnimate,
+ })
+ })
+
+ it('keeps autoplay paused when pagination is selected from the keyboard', 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( )
+
+ const paginationButton = screen.getByRole('button', { name: 'Dify Updates' })
+ paginationButton.focus()
+ fireEvent.focusIn(paginationButton)
+
+ const playsBeforeSelect = play.mock.calls.length
+ await user.keyboard('{Enter}')
+
+ expect(paginationButton).toHaveAttribute('aria-current', 'true')
+ expect(document.activeElement).toBe(paginationButton)
+ expect(play).toHaveBeenCalledTimes(playsBeforeSelect)
+
+ Object.defineProperty(Element.prototype, 'animate', {
+ configurable: true,
+ value: originalAnimate,
+ })
+ })
+
+ 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( )
+
+ 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('sends embedded cards without a delivery link to the marketplace site', () => {
+ const bannerWithMixedLinks: PluginBanner = {
+ id: 'recommend-mixed',
+ style_type: 'recommend',
+ title: 'Trending',
+ sort: 0,
+ language: 'en',
+ content: {
+ theme_type: 'hottest',
+ cards: [
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/dropbox',
+ display_name: 'Dropbox',
+ link: 'https://external.example.com/dropbox',
+ card_position: 0,
+ },
+ {
+ // The console has no local /plugin route, so a card without a
+ // delivery-provided link must open the marketplace detail page.
+ item_type: 'plugin',
+ item_id: 'langgenius/notion',
+ display_name: 'Notion',
+ link: '',
+ card_position: 1,
+ },
+ {
+ item_type: 'template',
+ item_id: 'tpl-1',
+ display_name: 'Support Bot',
+ link: '',
+ card_position: 2,
+ },
+ ],
+ },
+ }
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
+ 'href',
+ 'https://external.example.com/dropbox',
+ )
+ const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' })
+ expect(marketplaceFallbackLink.getAttribute('href')).toMatch(
+ /^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/,
+ )
+ expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank')
+ expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute(
+ 'href',
+ '/templates?tid=tpl-1',
+ )
+ })
+
+ it('renders no carousel when the API returns no banners', () => {
+ render( )
+
+ expect(
+ screen.queryByRole('region', {
+ name: 'plugin.marketplace.home.trendingTitle',
+ }),
+ ).not.toBeInTheDocument()
+ })
+
+ it('tracks recommend card clicks as item clicks without a frame click', async () => {
+ const user = userEvent.setup()
+
+ render( )
+
+ await user.click(screen.getByRole('link', { name: 'Dropbox' }))
+
+ expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_item_click', {
+ banner_id: 'recommend',
+ sort: 0,
+ page: 'templates',
+ language: 'en',
+ style_type: 'recommend',
+ item_type: 'plugin',
+ item_id: 'langgenius/dropbox',
+ card_position: 0,
+ theme_type: 'hottest',
+ auto_batch_id: '11111111-1111-4111-8111-111111111111',
+ })
+ expect(mockTrackEvent).not.toHaveBeenCalledWith('marketplace_banner_click', expect.anything())
+ })
+
+ it('tracks whole-slide blog and event links as frame clicks', async () => {
+ const user = userEvent.setup()
+
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Dify Updates' }))
+ await user.click(
+ screen.getByRole('link', { name: 'plugin.marketplace.home.trendingReadMoreAbout' }),
+ )
+
+ expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', {
+ banner_id: 'blog',
+ sort: 1,
+ page: 'plugins',
+ language: 'en',
+ style_type: 'blog',
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
+ await user.click(screen.getByRole('link', { name: 'DuckDuckGo plugin' }))
+
+ expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', {
+ banner_id: 'event',
+ sort: 2,
+ page: 'plugins',
+ language: 'en',
+ style_type: 'event',
+ })
+ })
+
+ it('does not render banner slides whose CMS link is not http(s) or relative', () => {
+ const unsafeBlog: PluginBanner = {
+ id: 'blog-unsafe',
+ style_type: 'blog',
+ title: 'Unsafe Updates',
+ sort: 0,
+ language: 'en',
+ content: {
+ blog_title: 'Unsafe launch',
+ subtitle: 'Should not be clickable',
+ description: 'Reject javascript hrefs from CMS payloads.',
+ link: 'javascript:alert(1)',
+ link_target_type: 'blog',
+ },
+ }
+
+ render( )
+
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: 'Unsafe launch' })).not.toBeInTheDocument()
+ })
+
+ it('dual-writes banner impressions to Amplitude and marketplace site tracking', () => {
+ vi.useFakeTimers()
+ const observers: Array<{ callback: IntersectionObserverCallback }> = []
+ class MockIntersectionObserver {
+ disconnect = vi.fn()
+ observe = vi.fn()
+ root: Element | Document | null = null
+ rootMargin = '0px'
+ takeRecords = () => []
+ thresholds = [0.5]
+ unobserve = vi.fn()
+
+ constructor(callback: IntersectionObserverCallback) {
+ observers.push({ callback })
+ }
+ }
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+
+ try {
+ const blogBanner = banners[1]
+ if (!blogBanner) throw new Error('Expected a blog banner fixture')
+
+ render( )
+
+ const observer = observers.at(-1)
+ if (!observer) throw new Error('Expected IntersectionObserver to be registered')
+
+ act(() => {
+ observer.callback(
+ [
+ {
+ intersectionRatio: 0.5,
+ isIntersecting: true,
+ } as IntersectionObserverEntry,
+ ],
+ {} as IntersectionObserver,
+ )
+ })
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ const properties = {
+ banner_id: 'blog',
+ sort: 1,
+ page: 'plugins',
+ language: 'en',
+ style_type: 'blog',
+ }
+ expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_impression', properties)
+ expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith(
+ 'marketplace_banner_impression',
+ properties,
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts
new file mode 100644
index 00000000000..2da3ec0be6f
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { sanitizeMarketplaceHref } from '../marketplace-href'
+
+describe('sanitizeMarketplaceHref', () => {
+ it('allows http(s) URLs and same-origin relative paths', () => {
+ expect(sanitizeMarketplaceHref('https://dify.ai/blog')).toBe('https://dify.ai/blog')
+ expect(sanitizeMarketplaceHref('http://localhost:3000/plugin/a/b')).toBe(
+ 'http://localhost:3000/plugin/a/b',
+ )
+ expect(sanitizeMarketplaceHref('/plugin/langgenius/dropbox')).toBe('/plugin/langgenius/dropbox')
+ })
+
+ it('rejects blank values and non-http schemes', () => {
+ expect(sanitizeMarketplaceHref('')).toBeNull()
+ expect(sanitizeMarketplaceHref(' ')).toBeNull()
+ expect(sanitizeMarketplaceHref('javascript:alert(1)')).toBeNull()
+ expect(sanitizeMarketplaceHref('data:text/html,bad')).toBeNull()
+ expect(sanitizeMarketplaceHref('mailto:test@example.com')).toBeNull()
+ expect(sanitizeMarketplaceHref('//evil.example')).toBeNull()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx
new file mode 100644
index 00000000000..656a2e7f8ba
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx
@@ -0,0 +1,83 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import MarketplaceLiveSearch from '../marketplace-live-search'
+
+const { mockReplace } = vi.hoisted(() => ({
+ mockReplace: vi.fn(),
+}))
+
+vi.mock('ahooks', async (importOriginal) => {
+ const original = await importOriginal()
+
+ return {
+ ...original,
+ useDebounce: (value: T) => value,
+ }
+})
+
+vi.mock('@/next/navigation', () => ({
+ useRouter: () => ({ replace: mockReplace }),
+}))
+
+describe('MarketplaceLiveSearch', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('updates the active tab result route while the user types', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ await user.type(screen.getByRole('searchbox'), 'legal')
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&language=en-US', {
+ scroll: false,
+ })
+ })
+ })
+
+ it('clears the query without leaving the active plugin tab', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ await user.clear(screen.getByRole('searchbox'))
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false })
+ })
+ })
+
+ it('preserves catalog filter params while the user types', async () => {
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ await user.type(screen.getByRole('searchbox'), 'legal')
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&languages=ja', {
+ scroll: false,
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-plugin-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-plugin-search.spec.tsx
new file mode 100644
index 00000000000..e7e17b99bf5
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-plugin-search.spec.tsx
@@ -0,0 +1,22 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it } from 'vite-plus/test'
+import { renderWithNuqs } from '@/test/nuqs-testing'
+import MarketplacePluginSearch from '../marketplace-plugin-search'
+
+describe('MarketplacePluginSearch', () => {
+ it('updates the catalog query as the user types without opening suggestions', async () => {
+ const user = userEvent.setup()
+ const { onUrlUpdate } = renderWithNuqs( )
+
+ const input = screen.getByRole('searchbox', { name: 'Search plugins' })
+ await user.type(input, 'google')
+
+ expect(input).toHaveValue('google')
+ await waitFor(() => {
+ expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google')
+ })
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx
new file mode 100644
index 00000000000..89103801459
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx
@@ -0,0 +1,303 @@
+import type { ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useAtomValue } from 'jotai'
+import { useState } from 'react'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { MARKETPLACE_CONTAINER_ID } from '../../constants'
+import HomeCatalogNavigation from '../home-catalog-navigation'
+import HomeSearch from '../home-search'
+import { homeCatalogPinnedAtom } from '../home-sticky-state'
+import { HomeStickyStateProvider } from '../home-sticky-state-provider'
+import { MarketplaceSearchAutocomplete } from '../marketplace-search-autocomplete'
+
+const { mockTemplateSearch } = vi.hoisted(() => ({
+ mockTemplateSearch: vi.fn(),
+}))
+
+vi.mock('ahooks', async (importOriginal) => {
+ const original = await importOriginal()
+
+ return {
+ ...original,
+ useDebounce: (value: T) => value,
+ }
+})
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const original = await importOriginal()
+ const { createReactI18nextMock } = await import('@/test/i18n-mock')
+
+ return {
+ ...original,
+ ...createReactI18nextMock({
+ clearSearch: 'Clear search',
+ loading: 'Loading',
+ 'marketplace.loadError': 'Failed to load. Please try again.',
+ 'marketplace.home.plugins': 'Plugins',
+ 'marketplace.home.templates': 'Templates',
+ 'marketplace.noPluginFound': 'No integration found',
+ 'marketplace.viewMore': 'View more',
+ 'newApp.noTemplateFound': 'No templates found',
+ }),
+ }
+})
+
+vi.mock('@/service/client', async (importOriginal) => {
+ const original = await importOriginal()
+
+ return {
+ ...original,
+ marketplaceQuery: {
+ searchAdvanced: {
+ queryOptions: ({ input }: { input: unknown }) => ({
+ queryKey: ['marketplace', 'plugins', input],
+ queryFn: () => ({ data: { plugins: [], total: 0 } }),
+ }),
+ },
+ templateSearch: {
+ queryOptions: ({ input }: { input: unknown }) => ({
+ queryKey: ['marketplace', 'templates', input],
+ queryFn: () => mockTemplateSearch(input),
+ }),
+ },
+ },
+ }
+})
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ gcTime: 0,
+ retry: false,
+ },
+ },
+})
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+function StickyTemplateSearch() {
+ const [value, setValue] = useState('')
+
+ return (
+
+ )
+}
+
+function PinnedHeaderState() {
+ const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
+
+ return (
+
+ )
+}
+
+describe('Marketplace search autocomplete layout', () => {
+ beforeEach(() => {
+ queryClient.clear()
+ mockTemplateSearch.mockReset()
+ mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
+ })
+
+ it('keeps the pinned catalog layout stable while the results popup opens', async () => {
+ await page.viewport(1280, 720)
+
+ const screen = await render(
+
+
+
+
+
+
+
+
+
}
+ catalogTabs={
}
+ />
+
+
+
+ ,
+ )
+
+ const scrollContainer = screen.getByTestId('marketplace-scroll-container').element()
+ scrollContainer.scrollTop = 300
+ scrollContainer.dispatchEvent(new Event('scroll'))
+ await new Promise(requestAnimationFrame)
+
+ const input = screen.getByRole('combobox', { name: 'Search templates' })
+ await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
+
+ const catalogNavigation = screen
+ .getByRole('region', { name: 'common.mainNav.marketplace' })
+ .element()
+ const scrollTopBefore = scrollContainer.scrollTop
+ const inputTopBefore = input.element().getBoundingClientRect().top
+ const navigationTopBefore = catalogNavigation.getBoundingClientRect().top
+
+ await input.fill('open')
+ await expect.element(screen.getByText('No templates found')).toBeVisible()
+
+ expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
+ await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible()
+ expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore)
+ expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore)
+ })
+
+ it('matches the reference grouped panel and compact result spacing', async () => {
+ await page.viewport(1280, 720)
+ mockTemplateSearch.mockResolvedValue({
+ data: {
+ templates: [
+ {
+ id: 'template-1',
+ template_name: 'Legal Research Agent',
+ overview: 'Research legal questions with cited sources.',
+ publisher_handle: 'dify',
+ usage_count: 120,
+ categories: ['knowledge'],
+ icon: '📄',
+ icon_background: '#FFFFFF',
+ icon_file_key: '',
+ },
+ {
+ id: 'template-2',
+ template_name: 'Contract Reviewer',
+ overview: 'Review contracts and identify risks.',
+ publisher_handle: 'dify',
+ usage_count: 80,
+ categories: ['knowledge'],
+ icon: '📄',
+ icon_background: '#FFFFFF',
+ icon_file_key: '',
+ },
+ ],
+ total: 2,
+ },
+ })
+
+ const screen = await render(
+
+
+
+
+ ,
+ )
+
+ await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal')
+ await expect.element(screen.getByText('Legal Research Agent')).toBeVisible()
+
+ const list = screen.getByRole('listbox').element()
+ const panel = list.parentElement!
+ const templateGroup = screen.getByRole('group', { name: 'Templates' }).element()
+ const firstItem = screen.getByRole('option', { name: /Legal Research Agent/ }).element()
+ const lastItem = screen.getByRole('option', { name: /Contract Reviewer/ }).element()
+ const panelStyle = getComputedStyle(panel)
+ const listStyle = getComputedStyle(list)
+ const templateGroupStyle = getComputedStyle(templateGroup)
+ const firstItemStyle = getComputedStyle(firstItem)
+ const statusRoots = screen.getByRole('status').all()
+ const trailingStatus = statusRoots.at(-1)!.element()
+
+ expect(panelStyle.width).toBe('472px')
+ expect(panelStyle.paddingTop).toBe('0px')
+ expect(panelStyle.paddingRight).toBe('0px')
+ expect(panelStyle.paddingBottom).toBe('0px')
+ expect(panelStyle.paddingLeft).toBe('0px')
+ expect(panelStyle.borderRadius).toBe('12px')
+ expect(listStyle.paddingTop).toBe('0px')
+ expect(templateGroupStyle.paddingTop).toBe('4px')
+ expect(templateGroupStyle.paddingRight).toBe('4px')
+ expect(templateGroupStyle.paddingBottom).toBe('4px')
+ expect(templateGroupStyle.paddingLeft).toBe('4px')
+ expect(firstItemStyle.paddingTop).toBe('4px')
+ expect(firstItemStyle.paddingRight).toBe('4px')
+ expect(firstItemStyle.paddingBottom).toBe('4px')
+ expect(firstItemStyle.paddingLeft).toBe('12px')
+ expect(firstItemStyle.borderRadius).toBe('8px')
+ expect(firstItemStyle.marginLeft).toBe('0px')
+ expect(firstItemStyle.marginRight).toBe('0px')
+ expect(trailingStatus.getBoundingClientRect().height).toBe(0)
+ expect(
+ panel.getBoundingClientRect().bottom - lastItem.getBoundingClientRect().bottom,
+ ).toBeCloseTo(5)
+ })
+
+ it('keeps result rows fully clickable without a persistent trailing arrow', async () => {
+ await page.viewport(390, 844)
+ mockTemplateSearch.mockResolvedValue({
+ data: {
+ templates: [
+ {
+ id: 'template-1',
+ template_name: 'Legal Research Agent',
+ overview: 'Research legal questions with cited sources.',
+ publisher_handle: 'dify',
+ usage_count: 120,
+ categories: ['knowledge'],
+ icon: '📄',
+ icon_background: '#FFFFFF',
+ icon_file_key: '',
+ },
+ ],
+ total: 1,
+ },
+ })
+
+ const screen = await render(
+
+
+
+
+ ,
+ )
+
+ await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal')
+ const result = screen.getByRole('option', { name: /Legal Research Agent/ })
+ await expect.element(result).toBeVisible()
+
+ const resultElement = result.element()
+ const resultRect = resultElement.getBoundingClientRect()
+ const label = screen.getByText('Legal Research Agent').element()
+ const labelRectBeforeHover = label.getBoundingClientRect()
+ const trailingVisuals = Array.from(
+ resultElement.querySelectorAll('[aria-hidden="true"]'),
+ ).filter((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.width > 0 && rect.left >= resultRect.right - 40
+ })
+
+ expect(trailingVisuals).toHaveLength(0)
+ expect(getComputedStyle(resultElement).cursor).toBe('pointer')
+
+ const backgroundBeforeHover = getComputedStyle(resultElement).backgroundColor
+ await result.hover()
+ const labelRectAfterHover = label.getBoundingClientRect()
+
+ expect(getComputedStyle(resultElement).backgroundColor).not.toBe(backgroundBeforeHover)
+ expect(labelRectAfterHover.left).toBeCloseTo(labelRectBeforeHover.left)
+ expect(labelRectAfterHover.width).toBeCloseTo(labelRectBeforeHover.width)
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx
new file mode 100644
index 00000000000..78609985b0b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx
@@ -0,0 +1,580 @@
+import type { ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useState } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import { MARKETPLACE_API_PREFIX } from '@/config'
+import {
+ MarketplaceSearchAutocomplete,
+ MarketplaceSearchForm,
+} from '../marketplace-search-autocomplete'
+
+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(),
+}))
+
+vi.mock('ahooks', async (importOriginal) => {
+ const original = await importOriginal()
+
+ return {
+ ...original,
+ useDebounce: (value: T, options?: { wait?: number }) =>
+ debounceState.useRealDebounce ? original.useDebounce(value, options) : value,
+ }
+})
+
+vi.mock('react-i18next', async () => {
+ const { createReactI18nextMock } = await import('@/test/i18n-mock')
+
+ return createReactI18nextMock({
+ clearSearch: 'Clear search',
+ loading: 'Loading',
+ 'marketplace.loadError': 'Failed to load. Please try again.',
+ 'marketplace.home.plugins': 'Plugins',
+ 'marketplace.home.templates': 'Templates',
+ 'marketplace.noPluginFound': 'No integration found',
+ 'marketplace.viewMore': 'View more',
+ 'newApp.noTemplateFound': 'No templates found',
+ })
+})
+
+vi.mock('@/service/client', () => ({
+ marketplaceQuery: {
+ searchAdvanced: {
+ queryOptions: ({ input }: { input: unknown }) => ({
+ queryKey: ['marketplace', 'plugins', input],
+ queryFn: () => mockPluginSearch(input),
+ }),
+ },
+ templateSearch: {
+ queryOptions: ({ input }: { input: unknown }) => ({
+ queryKey: ['marketplace', 'templates', input],
+ queryFn: () => mockTemplateSearch(input),
+ }),
+ },
+ },
+}))
+
+let queryClient: QueryClient
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+describe('MarketplaceSearchAutocomplete', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ debounceState.useRealDebounce = false
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ gcTime: 0,
+ retry: false,
+ },
+ },
+ })
+ mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
+ mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } })
+ })
+
+ it('shows template suggestions and keeps the route search form contract', async () => {
+ let resolveTemplateSearch!: (value: unknown) => void
+ const templateSearchPromise = new Promise((resolve) => {
+ resolveTemplateSearch = resolve
+ })
+ mockTemplateSearch.mockReturnValue(templateSearchPromise)
+ const templateSearchResponse = {
+ data: {
+ templates: [
+ {
+ id: 'template-1',
+ template_name: 'Legal Research Agent',
+ overview: 'Research legal questions with cited sources.',
+ publisher_handle: 'dify',
+ usage_count: 120,
+ categories: ['knowledge'],
+ icon: '📄',
+ icon_background: '#FFFFFF',
+ icon_file_key: '',
+ },
+ ],
+ total: 1,
+ },
+ }
+ const user = userEvent.setup()
+
+ const { container } = render(
+ ,
+ { wrapper: Wrapper },
+ )
+
+ await user.type(screen.getByRole('combobox'), 'legal')
+ expect(screen.queryByText('Legal Research Agent')).not.toBeInTheDocument()
+ expect(screen.getByText(/Loading/)).toBeInTheDocument()
+ expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
+ resolveTemplateSearch(templateSearchResponse)
+
+ expect(await screen.findByText('Legal Research Agent')).toBeInTheDocument()
+ expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
+ expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
+ expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument()
+ expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge')
+ expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q')
+ expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('type', 'text')
+ expect(container.querySelectorAll('button[aria-label="Clear search"]')).toHaveLength(1)
+ expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US')
+ expect(mockPluginSearch).not.toHaveBeenCalled()
+ })
+
+ it('shows plugin suggestions while preserving the controlled search owner', async () => {
+ 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 onValueChange = vi.fn()
+
+ const ControlledSearch = () => {
+ const [value, setValue] = useState('')
+
+ return (
+ {
+ onValueChange(nextValue)
+ setValue(nextValue)
+ }}
+ placeholder="Search plugins"
+ scope="plugins"
+ value={value}
+ />
+ )
+ }
+
+ render( , { wrapper: Wrapper })
+
+ await user.type(screen.getByRole('combobox'), 'google')
+
+ expect(await screen.findByText('Google Search')).toBeInTheDocument()
+ expect(screen.getByText('Search the web from your workflow.')).toBeInTheDocument()
+ expect(screen.getByRole('listbox').querySelector('img')).toHaveAttribute(
+ 'src',
+ `${MARKETPLACE_API_PREFIX}/plugins/langgenius/google-search/icon`,
+ )
+ expect(onValueChange).toHaveBeenLastCalledWith('google')
+ expect(mockTemplateSearch).not.toHaveBeenCalled()
+ })
+
+ it('groups mixed suggestions and submits the complete search from the popup', async () => {
+ mockTemplateSearch.mockResolvedValue({
+ data: {
+ templates: [
+ {
+ id: 'template-1',
+ template_name: 'Legal Research Agent',
+ overview: 'Research legal questions with cited sources.',
+ publisher_handle: 'dify',
+ usage_count: 120,
+ categories: ['knowledge'],
+ icon: '📄',
+ icon_background: '#FFFFFF',
+ icon_file_key: '',
+ },
+ ],
+ total: 1,
+ },
+ })
+ 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 handleSubmit = vi.fn((event: Event) => {
+ event.preventDefault()
+ })
+
+ const { container } = render(
+ ,
+ { wrapper: Wrapper },
+ )
+
+ container.querySelector('form')?.addEventListener('submit', handleSubmit)
+
+ await user.type(screen.getByRole('combobox'), 'search')
+
+ const templateGroup = await screen.findByRole('group', { name: 'Templates' })
+ const pluginGroup = screen.getByRole('group', { name: 'Plugins' })
+ expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument()
+ expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'View more' }))
+
+ expect(handleSubmit).toHaveBeenCalledOnce()
+ })
+
+ it('submits the route search form when a suggestion is chosen', async () => {
+ 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 handleSubmit = vi.fn((event: Event) => {
+ event.preventDefault()
+ })
+
+ const { container } = render(
+ ,
+ { wrapper: Wrapper },
+ )
+
+ container.querySelector('form')?.addEventListener('submit', handleSubmit)
+
+ await user.type(screen.getByRole('combobox'), 'google')
+ await user.click(await screen.findByText('Google Search'))
+
+ expect(handleSubmit).toHaveBeenCalledOnce()
+ })
+
+ it('keeps keyboard selection working for the highlighted suggestion', async () => {
+ 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 handleSubmit = vi.fn((event: Event) => {
+ event.preventDefault()
+ })
+
+ const { container } = render(
+ ,
+ { wrapper: Wrapper },
+ )
+
+ container.querySelector('form')?.addEventListener('submit', handleSubmit)
+
+ await user.type(screen.getByRole('combobox'), 'google')
+ expect(await screen.findByText('Google Search')).toBeInTheDocument()
+ await user.keyboard('{ArrowDown}{Enter}')
+
+ expect(handleSubmit).toHaveBeenCalledOnce()
+ })
+
+ it('hands the selected plugin back to a creator-profile owner without submitting', async () => {
+ 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 onSuggestionSelect = vi.fn()
+ const handleSubmit = vi.fn((event: Event) => {
+ event.preventDefault()
+ })
+
+ const ControlledSearch = () => {
+ const [value, setValue] = useState('')
+
+ return (
+
+ )
+ }
+
+ render( , { wrapper: Wrapper })
+
+ await user.type(screen.getByRole('combobox'), 'google')
+ await user.click(await screen.findByText('Google Search'))
+
+ expect(onSuggestionSelect).toHaveBeenCalledWith({
+ kind: 'plugin',
+ plugin: expect.objectContaining({
+ org: 'langgenius',
+ name: 'google-search',
+ }),
+ })
+ expect(handleSubmit).not.toHaveBeenCalled()
+ expect(screen.getByRole('combobox')).toHaveValue('')
+ })
+
+ it('does not offer the previous term suggestions while a new search is pending', async () => {
+ const googleResponse = {
+ 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,
+ },
+ }
+ mockPluginSearch.mockImplementation((input: { body: { query: string } }) => {
+ if (input.body.query === 'google') return Promise.resolve(googleResponse)
+ // Keep the follow-up term pending so stale suggestions would be visible
+ // if the query still returned placeholder data.
+ return new Promise(() => {})
+ })
+ const user = userEvent.setup()
+
+ const ControlledSearch = () => {
+ const [value, setValue] = useState('')
+
+ return (
+
+ )
+ }
+
+ render( , { wrapper: Wrapper })
+
+ await user.type(screen.getByRole('combobox'), 'google')
+ expect(await screen.findByText('Google Search')).toBeInTheDocument()
+
+ await user.type(screen.getByRole('combobox'), ' drive')
+
+ expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
+ expect(screen.getByText(/Loading/)).toBeInTheDocument()
+ expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
+ })
+
+ it('does not reopen after dismiss while a request is still pending', async () => {
+ let resolvePluginSearch!: (value: unknown) => void
+ mockPluginSearch.mockReturnValue(
+ new Promise((resolve) => {
+ resolvePluginSearch = resolve
+ }),
+ )
+ const user = userEvent.setup()
+ const pluginResponse = {
+ 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 ControlledSearch = () => {
+ const [value, setValue] = useState('')
+
+ return (
+ <>
+
+ Outside search
+ >
+ )
+ }
+
+ render( , { wrapper: Wrapper })
+
+ await user.type(screen.getByRole('combobox'), 'google')
+ expect(screen.getByText(/Loading/)).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Outside search' }))
+ await waitFor(() => {
+ expect(screen.getByText(/Loading/)).not.toBeVisible()
+ })
+
+ resolvePluginSearch(pluginResponse)
+
+ await waitFor(() => {
+ expect(mockPluginSearch).toHaveBeenCalled()
+ })
+ expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('keeps the empty and status roots mounted when nothing matches', async () => {
+ mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } })
+ const user = userEvent.setup()
+
+ const ControlledSearch = () => {
+ const [value, setValue] = useState('')
+
+ return (
+
+ )
+ }
+
+ render( , { wrapper: Wrapper })
+
+ await user.type(screen.getByRole('combobox'), 'zzzz')
+
+ expect(await screen.findByText('No integration found')).toBeInTheDocument()
+ expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
+ expect(screen.queryByText(/Loading/)).not.toBeInTheDocument()
+ })
+
+ 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 (
+
+ )
+ }
+
+ render( , { 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.getByText(/Loading/)).toBeInTheDocument()
+ expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx
new file mode 100644
index 00000000000..da5a92037dc
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx
@@ -0,0 +1,45 @@
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { MARKETPLACE_CONTAINER_ID } from '../../constants'
+import { preserveStickySearchScroll } from '../preserve-sticky-search-scroll'
+
+const nextFrame = () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ })
+
+describe('Sticky search scroll guard', () => {
+ it('keeps the scroll position when Chromium focuses the in-flow sticky input', async () => {
+ await page.viewport(1280, 900)
+
+ const screen = await render(
+
+
Header
+
Hero
+
+
+
+
Catalog
+
,
+ )
+
+ const container = document.getElementById(MARKETPLACE_CONTAINER_ID)!
+ const searchRoot = screen.getByTestId('search-root').element()
+ const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element()
+
+ const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container)
+ container.scrollTop = 400
+ container.dispatchEvent(new Event('scroll'))
+ await nextFrame()
+
+ const scrollTopBefore = container.scrollTop
+ HTMLInputElement.prototype.focus.call(input)
+ await nextFrame()
+
+ expect(container.scrollTop).toBe(scrollTopBefore)
+ stop()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts
new file mode 100644
index 00000000000..e32a020f211
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts
@@ -0,0 +1,137 @@
+import { act, render } from '@testing-library/react'
+import { createElement, useRef } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useBannerViewability } from '../use-banner-viewability'
+
+type ObserverRecord = {
+ callback: IntersectionObserverCallback
+ options?: IntersectionObserverInit
+}
+
+let observers: ObserverRecord[] = []
+
+class MockIntersectionObserver implements IntersectionObserver {
+ readonly root: Element | Document | null
+ readonly rootMargin: string
+ readonly scrollMargin = ''
+ readonly thresholds: readonly number[]
+ observe = vi.fn()
+ unobserve = vi.fn()
+ disconnect = vi.fn()
+ takeRecords = () => []
+
+ constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
+ this.root = options?.root ?? null
+ this.rootMargin = options?.rootMargin ?? '0px'
+ this.thresholds = Array.isArray(options?.threshold)
+ ? options.threshold
+ : [options?.threshold ?? 0]
+ observers.push({ callback, options })
+ }
+}
+
+function ViewabilityProbe({
+ enabled = true,
+ onImpression,
+}: {
+ enabled?: boolean
+ onImpression: () => void
+}) {
+ const targetRef = useRef(null)
+ useBannerViewability(targetRef, onImpression, enabled)
+ return createElement('div', { ref: targetRef, 'data-testid': 'banner-slide' })
+}
+
+function triggerIntersection(intersectionRatio: number) {
+ const observer = observers.at(-1)
+ if (!observer) throw new Error('Expected IntersectionObserver to be registered')
+
+ act(() => {
+ observer.callback(
+ [
+ {
+ intersectionRatio,
+ isIntersecting: intersectionRatio > 0,
+ } as IntersectionObserverEntry,
+ ],
+ {} as IntersectionObserver,
+ )
+ })
+}
+
+describe('useBannerViewability', () => {
+ beforeEach(() => {
+ observers = []
+ vi.useFakeTimers()
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+
+ it('records one impression after the slide stays at least 50% visible for 1000ms', () => {
+ const onImpression = vi.fn()
+ render(createElement(ViewabilityProbe, { onImpression }))
+
+ triggerIntersection(0.5)
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(onImpression).toHaveBeenCalledOnce()
+
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+ expect(onImpression).toHaveBeenCalledOnce()
+ })
+
+ it('records a second impression after the slide leaves and becomes viewable again', () => {
+ const onImpression = vi.fn()
+ render(createElement(ViewabilityProbe, { onImpression }))
+
+ triggerIntersection(0.8)
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+ expect(onImpression).toHaveBeenCalledOnce()
+
+ triggerIntersection(0)
+ triggerIntersection(0.6)
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(onImpression).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not record an impression when the slide is visible for less than 1s', () => {
+ const onImpression = vi.fn()
+ render(createElement(ViewabilityProbe, { onImpression }))
+
+ triggerIntersection(0.9)
+ act(() => {
+ vi.advanceTimersByTime(999)
+ })
+ triggerIntersection(0)
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(onImpression).not.toHaveBeenCalled()
+ })
+
+ it('does not record an impression when the visible ratio stays below 0.5', () => {
+ const onImpression = vi.fn()
+ render(createElement(ViewabilityProbe, { onImpression }))
+
+ triggerIntersection(0.49)
+ act(() => {
+ vi.advanceTimersByTime(2000)
+ })
+
+ expect(onImpression).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/assets/background.webp b/web/app/components/plugins/marketplace/home/assets/background.webp
new file mode 100644
index 00000000000..ff09b6466a6
Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/background.webp differ
diff --git a/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg b/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg
new file mode 100644
index 00000000000..c747d0dbf1d
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png b/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png
new file mode 100644
index 00000000000..12d192cef36
Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png differ
diff --git a/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg b/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg
new file mode 100644
index 00000000000..d2fa982771e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/app/components/plugins/marketplace/home/assets/plug-fill.svg b/web/app/components/plugins/marketplace/home/assets/plug-fill.svg
new file mode 100644
index 00000000000..d6c546294e6
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/assets/plug-fill.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg b/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg
new file mode 100644
index 00000000000..f9e75e09c07
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp b/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp
new file mode 100644
index 00000000000..54e94fb7de7
Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp differ
diff --git a/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg b/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg
new file mode 100644
index 00000000000..3fa7e2c52af
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg b/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg
new file mode 100644
index 00000000000..2124d153d36
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/app/components/plugins/marketplace/home/banners.spec.ts b/web/app/components/plugins/marketplace/home/banners.spec.ts
new file mode 100644
index 00000000000..6989ec912e5
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/banners.spec.ts
@@ -0,0 +1,218 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { marketplaceClient } from '@/service/client'
+import { fetchPluginBanners } from './banners'
+
+vi.mock('@/service/client', () => ({
+ marketplaceClient: {
+ banners: {
+ list: vi.fn(),
+ },
+ },
+}))
+
+const mockedListBanners = vi.mocked(marketplaceClient.banners.list)
+
+describe('fetchPluginBanners', () => {
+ beforeEach(() => {
+ mockedListBanners.mockReset()
+ })
+
+ it('normalizes every public banner style in API sort order', async () => {
+ mockedListBanners.mockResolvedValue({
+ code: 0,
+ msg: 'success',
+ data: {
+ banners: [
+ {
+ id: 'event',
+ style_type: 'event',
+ title: 'Dify Event',
+ sort: 3,
+ language: 'en',
+ content: {
+ images: {
+ desktop: '/api/v1/banners/images/banners/event.png',
+ mobile: '/api/v1/banners/images/banners/event-mobile.png',
+ },
+ link: 'https://dify.ai/events',
+ alt_text: 'Dify Event',
+ activity_id: 'event-1',
+ },
+ },
+ {
+ id: 'recommend',
+ style_type: 'recommend',
+ title: 'Trending Now',
+ sort: 1,
+ language: 'en',
+ content: {
+ theme_type: 'hottest',
+ heading: 'Popular plugins',
+ description: 'Chosen from real usage.',
+ cards: [
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/fourth',
+ display_name: 'Fourth',
+ link: '/plugins/langgenius/fourth',
+ card_position: 3,
+ },
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/first',
+ display_name: 'First',
+ icon_url: '/api/v1/plugins/langgenius/first/icon',
+ creator: 'langgenius',
+ badges: ['verified', 'partner', 'unknown'],
+ link: '/plugins/langgenius/first',
+ card_position: 0,
+ auto_batch_id: '11111111-1111-4111-8111-111111111111',
+ },
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/third',
+ display_name: 'Third',
+ link: '/plugins/langgenius/third',
+ card_position: 2,
+ },
+ {
+ item_type: 'plugin',
+ item_id: 'langgenius/second',
+ display_name: 'Second',
+ link: '/plugins/langgenius/second',
+ card_position: 1,
+ },
+ ],
+ },
+ },
+ {
+ id: 'ad',
+ style_type: 'ad',
+ title: 'Partner campaign',
+ sort: 4,
+ language: 'en',
+ content: {
+ images: {
+ desktop: '/api/v1/banners/images/banners/ad.webp',
+ },
+ link: 'https://example.com',
+ partner_id: 'partner-1',
+ campaign_id: 'campaign-1',
+ },
+ },
+ {
+ id: 'blog',
+ style_type: 'blog',
+ title: 'Dify Updates',
+ sort: 2,
+ language: 'en',
+ content: {
+ blog_title: 'Dify v1.9 new launch',
+ subtitle: 'New Agent node support',
+ description: 'Build agent workflows with the new Agent node.',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'blog',
+ },
+ },
+ {
+ id: 'unsupported',
+ style_type: 'popup',
+ title: 'Unsupported',
+ sort: 0,
+ language: 'en',
+ content: {},
+ },
+ ],
+ },
+ })
+
+ const banners = await fetchPluginBanners('en-US')
+
+ expect(mockedListBanners).toHaveBeenCalledWith({
+ query: {
+ page: 'plugins',
+ language: 'en-US',
+ },
+ })
+ expect(banners.map((banner) => banner.id)).toEqual(['recommend', 'blog', 'event', 'ad'])
+
+ const recommend = banners[0]
+ expect(recommend?.style_type).toBe('recommend')
+ if (recommend?.style_type === 'recommend') {
+ expect(recommend.content.cards.map((card) => card.display_name)).toEqual([
+ 'First',
+ 'Second',
+ 'Third',
+ 'Fourth',
+ ])
+ expect(recommend.content.cards[0]).toMatchObject({
+ creator: 'langgenius',
+ badges: ['verified', 'partner'],
+ auto_batch_id: '11111111-1111-4111-8111-111111111111',
+ })
+ }
+
+ const event = banners[2]
+ expect(event?.style_type).toBe('event')
+ if (event?.style_type === 'event') {
+ expect(event.content.images).toEqual({
+ desktop: '/api/v1/banners/images/banners/event.png',
+ mobile: '/api/v1/banners/images/banners/event-mobile.png',
+ })
+ }
+ })
+
+ it('drops malformed banners and returns no placeholders for an empty response', async () => {
+ mockedListBanners
+ .mockResolvedValueOnce({
+ data: {
+ banners: [
+ {
+ id: 'empty-recommend',
+ style_type: 'recommend',
+ title: 'Empty',
+ sort: 0,
+ language: 'en',
+ content: {
+ theme_type: 'hottest',
+ cards: [],
+ },
+ },
+ {
+ id: 'event-without-desktop',
+ style_type: 'event',
+ title: 'Broken',
+ sort: 1,
+ language: 'en',
+ content: {
+ images: {
+ mobile: '/api/v1/banners/images/banners/mobile.png',
+ },
+ link: 'https://example.com',
+ },
+ },
+ ],
+ },
+ })
+ .mockResolvedValueOnce('')
+
+ await expect(fetchPluginBanners('en-US')).resolves.toEqual([])
+ await expect(fetchPluginBanners('en-US')).resolves.toEqual([])
+ })
+
+ it('requests templates banners when fetching for the templates page', async () => {
+ mockedListBanners.mockResolvedValue({
+ data: {
+ banners: [],
+ },
+ })
+
+ await expect(fetchPluginBanners('en-US', 'templates')).resolves.toEqual([])
+ expect(mockedListBanners).toHaveBeenCalledWith({
+ query: {
+ page: 'templates',
+ language: 'en-US',
+ },
+ })
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/banners.ts b/web/app/components/plugins/marketplace/home/banners.ts
new file mode 100644
index 00000000000..1b1f24cc985
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/banners.ts
@@ -0,0 +1,154 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import { z } from 'zod'
+import { marketplaceClient } from '@/service/client'
+
+// The banner types live in @dify/contracts/marketplace so the standalone
+// marketplace and the embedded console share one definition; this module owns
+// the runtime normalization of the untyped delivery payload.
+const MAX_CARDS_PER_PAGE = 4
+
+// Mirrors the previous hand-rolled parsing: an optional field of the wrong
+// type is dropped instead of rejecting the whole banner.
+const lenientOptionalString = z.string().optional().catch(undefined)
+// Same, but an empty string also collapses to undefined (responsive image
+// variants are only useful when they actually point somewhere).
+const lenientNonEmptyString = z.string().min(1).optional().catch(undefined)
+
+const bannerBaseShape = {
+ id: z.string().min(1),
+ title: z.string().min(1),
+ sort: z.number(),
+ language: z.string().min(1),
+}
+
+const recommendCardSchema = z.object({
+ item_type: z.enum(['plugin', 'template']),
+ item_id: z.string().min(1),
+ display_name: z.string().min(1),
+ icon_url: lenientOptionalString,
+ icon: lenientOptionalString,
+ icon_background: lenientOptionalString,
+ creator: lenientOptionalString,
+ badges: z
+ .unknown()
+ .transform((value) =>
+ Array.isArray(value)
+ ? value.filter(
+ (badge): badge is 'partner' | 'verified' => badge === 'partner' || badge === 'verified',
+ )
+ : undefined,
+ )
+ // The trailing optional keeps the key optional in the inferred type and
+ // lets a missing field bypass the transform pipeline.
+ .optional(),
+ link: z.string().catch(''),
+ card_position: z.number().catch(0),
+ auto_batch_id: z.union([z.string(), z.null()]).optional().catch(undefined),
+})
+
+const recommendContentSchema = z.object({
+ theme_type: z.enum(['newest', 'hottest', 'partner']),
+ heading: lenientOptionalString,
+ subheadings: z
+ .unknown()
+ .transform((value) =>
+ Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === 'string')
+ : undefined,
+ )
+ .optional(),
+ description: lenientOptionalString,
+ cards: z
+ .array(recommendCardSchema.nullable().catch(null))
+ .catch([])
+ .transform((cards) =>
+ cards
+ .flatMap((card) => (card === null ? [] : [card]))
+ .sort((a, b) => a.card_position - b.card_position)
+ .slice(0, MAX_CARDS_PER_PAGE),
+ )
+ // A recommendation banner with no renderable card has nothing to show.
+ .refine((cards) => cards.length > 0),
+})
+
+const blogContentSchema = z.object({
+ blog_title: z.string().min(1),
+ subtitle: lenientOptionalString,
+ description: lenientOptionalString,
+ link: z.string().min(1),
+ link_target_type: z.enum(['blog', 'github']),
+})
+
+const imageContentShape = {
+ images: z.object({
+ desktop: z.string().min(1),
+ tablet: lenientNonEmptyString,
+ mobile: lenientNonEmptyString,
+ }),
+ link: z.string().min(1),
+ alt_text: lenientOptionalString,
+ activity_id: lenientOptionalString,
+}
+
+const pluginBannerSchema = z.discriminatedUnion('style_type', [
+ z.object({
+ ...bannerBaseShape,
+ style_type: z.literal('recommend'),
+ content: recommendContentSchema,
+ }),
+ z.object({
+ ...bannerBaseShape,
+ style_type: z.literal('blog'),
+ content: blogContentSchema,
+ }),
+ z.object({
+ ...bannerBaseShape,
+ style_type: z.literal('event'),
+ content: z.object(imageContentShape),
+ }),
+ z.object({
+ ...bannerBaseShape,
+ style_type: z.literal('ad'),
+ content: z.object({
+ ...imageContentShape,
+ partner_id: lenientOptionalString,
+ campaign_id: lenientOptionalString,
+ }),
+ }),
+])
+
+const bannersResponseSchema = z.object({
+ data: z.object({
+ banners: z.array(z.unknown()),
+ }),
+})
+
+const normalizePluginBanners = (response: unknown): PluginBanner[] => {
+ const parsedResponse = bannersResponseSchema.safeParse(response)
+ if (!parsedResponse.success) return []
+
+ return parsedResponse.data.data.banners
+ .flatMap((banner): PluginBanner[] => {
+ // Malformed banners are dropped individually so one bad delivery entry
+ // does not blank the whole trending section.
+ const parsedBanner = pluginBannerSchema.safeParse(banner)
+ return parsedBanner.success ? [parsedBanner.data] : []
+ })
+ .sort((a, b) => a.sort - b.sort)
+}
+
+export type MarketplaceBannerPage = 'plugins' | 'templates'
+
+export const fetchPluginBanners = async (
+ language: string,
+ page: MarketplaceBannerPage = 'plugins',
+): Promise => {
+ const response = await marketplaceClient.banners.list({
+ query: {
+ page,
+ language,
+ },
+ })
+
+ return normalizePluginBanners(response)
+}
diff --git a/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx b/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx
new file mode 100644
index 00000000000..eae77708b2e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx
@@ -0,0 +1,177 @@
+'use client'
+
+import { Button } from '@langgenius/dify-ui/button'
+import { Checkbox } from '@langgenius/dify-ui/checkbox'
+import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
+import { cn } from '@langgenius/dify-ui/cn'
+import { IconButton } from '@langgenius/dify-ui/icon-button'
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
+import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from '#i18n'
+import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track'
+import { useFilterTemplateLanguages } from '../atoms'
+import { LANGUAGE_OPTIONS } from '../templates/template-language'
+
+export default function CatalogLanguagesFilter() {
+ const { t } = useTranslation()
+ const [languages, setLanguages] = useFilterTemplateLanguages()
+ const [open, setOpen] = useState(false)
+ const [searchText, setSearchText] = useState('')
+ const triggerRef = useRef(null)
+ const shouldRestoreFocusRef = useRef(false)
+ const selectedOptions = LANGUAGE_OPTIONS.filter((option) => languages.includes(option.value))
+ const selectedNativeLabels = selectedOptions.map((option) => option.nativeLabel)
+ const selectedCount = selectedOptions.length
+ const triggerLabel = selectedNativeLabels.length
+ ? selectedNativeLabels.join(', ')
+ : t(($) => $['marketplace.languages'], { ns: 'plugin' })
+ const searchQuery = searchText.toLowerCase()
+ const filteredOptions = LANGUAGE_OPTIONS.filter(
+ (option) =>
+ option.label.toLowerCase().includes(searchQuery) ||
+ option.nativeLabel.toLowerCase().includes(searchQuery),
+ )
+
+ useEffect(() => {
+ if (selectedCount || !shouldRestoreFocusRef.current) return
+
+ shouldRestoreFocusRef.current = false
+ triggerRef.current?.focus()
+ }, [selectedCount])
+
+ const handleLanguagesChange = (next: string[]) => {
+ const addedLanguage = next.find((language) => !languages.includes(language))
+ const removedLanguage = languages.find((language) => !next.includes(language))
+ markMarketplaceSiteFilter({
+ filter_type: 'language',
+ selection_mode: 'multi',
+ filter_value: addedLanguage ?? removedLanguage ?? next.at(-1) ?? '',
+ selected_values: next,
+ })
+ // Server-rendered template results read `languages` from the URL, so this
+ // update must notify the App Router instead of only rewriting history.
+ setLanguages(next.length ? next : null, { shallow: false })
+ }
+
+ return (
+
+
+
+
+
+
+
+ {!selectedCount && (
+ {t(($) => $['marketplace.languages'], { ns: 'plugin' })}
+ )}
+ {!!selectedCount && (
+
+ {selectedNativeLabels.slice(0, 2).join(',')}
+
+ )}
+ {selectedCount > 2 && (
+ +{selectedCount - 2}
+ )}
+
+ {!selectedCount && (
+
+
+
+ )}
+
+ }
+ />
+ {!!selectedCount && (
+ $.clearSearch, {
+ ns: 'plugin',
+ label: triggerLabel,
+ })}
+ className="absolute right-1 focus-visible:ring-inset"
+ onClick={() => {
+ shouldRestoreFocusRef.current = true
+ handleLanguagesChange([])
+ }}
+ >
+
+
+ )}
+
+
+
+
+
+ $['marketplace.searchFilterLanguage'], { ns: 'plugin' }) || ''}
+ className="[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
+ value={searchText}
+ onValueChange={setSearchText}
+ placeholder={
+ t(($) => $['marketplace.searchFilterLanguage'], { ns: 'plugin' }) || ''
+ }
+ />
+
+
+
+
+
+
$['marketplace.languages'], { ns: 'plugin' })}
+ value={languages}
+ onValueChange={handleLanguagesChange}
+ className="max-h-112 overflow-y-auto p-1"
+ >
+ {filteredOptions.map((option) => (
+
+
+
+ {option.nativeLabel}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx b/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx
new file mode 100644
index 00000000000..d0ee51665e3
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx
@@ -0,0 +1,15 @@
+'use client'
+
+import { useFilterPluginTags } from '../atoms'
+import TagsFilter from '../search-box/tags-filter'
+
+export default function CatalogTagsFilter() {
+ const [tags, setTags] = useFilterPluginTags()
+ return (
+ setTags(next.length ? next : null)}
+ usedInMarketplace
+ />
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts b/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts
new file mode 100644
index 00000000000..7bc39fb631f
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts
@@ -0,0 +1,22 @@
+export const MARKETPLACE_MOBILE_BANNER_MEDIA = '(max-width: 879px)'
+export const EMBEDDED_MOBILE_BANNER_MEDIA = '(max-width: 639px)'
+
+export function marketplaceTabletBannerMedia(isMarketplacePlatform: boolean) {
+ return isMarketplacePlatform
+ ? '(min-width: 880px) and (max-width: 1023px)'
+ : '(min-width: 640px) and (max-width: 1023px)'
+}
+
+export function resolveEventAdBannerImageSrcs(images: {
+ desktop: string
+ tablet?: string
+ mobile?: string
+}) {
+ return {
+ desktop: images.desktop,
+ // Phones always get a source: the mobile asset when present, otherwise desktop.
+ // That keeps tablet from winning at mobile widths.
+ mobile: images.mobile || images.desktop,
+ tablet: images.tablet || undefined,
+ }
+}
diff --git a/web/app/components/plugins/marketplace/home/home-catalog-focus.ts b/web/app/components/plugins/marketplace/home/home-catalog-focus.ts
new file mode 100644
index 00000000000..47614c6ce8b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-catalog-focus.ts
@@ -0,0 +1,21 @@
+export type HomeCatalogTabSlot = 'content' | 'header'
+
+const getCatalogTabSlot = (slot: HomeCatalogTabSlot) =>
+ document.querySelector(`[data-home-catalog-tabs-slot="${slot}"]`)
+
+export const getFocusedCatalogTabHref = (slot: HomeCatalogTabSlot) => {
+ const slotElement = getCatalogTabSlot(slot)
+ const activeElement = document.activeElement
+ if (!slotElement || !activeElement || !slotElement.contains(activeElement)) return null
+
+ return activeElement.closest('a[href]')?.getAttribute('href') ?? null
+}
+
+export const focusCatalogTab = (slot: HomeCatalogTabSlot, href: string) => {
+ const slotElement = getCatalogTabSlot(slot)
+ const matchingLink = Array.from(
+ slotElement?.querySelectorAll('a[href]') ?? [],
+ ).find((link) => link.getAttribute('href') === href)
+
+ matchingLink?.focus({ preventScroll: true })
+}
diff --git a/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx
new file mode 100644
index 00000000000..46aa76f66fe
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx
@@ -0,0 +1,135 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useAtomValue, useSetAtom } from 'jotai'
+import { useEffect, useLayoutEffect, useRef } from 'react'
+import { useTranslation } from '#i18n'
+import { MARKETPLACE_CONTAINER_ID } from '../constants'
+import PluginTypeSwitch from '../plugin-type-switch'
+import { focusCatalogTab, getFocusedCatalogTabHref } from './home-catalog-focus'
+import { HOME_HEADER_HEIGHT_PX } from './home-constants'
+import { homeCatalogPinnedAtom } from './home-sticky-state'
+import styles from './home-sticky.module.css'
+
+type HomeCatalogNavigationProps = {
+ catalogCategories?: ReactNode
+ catalogLeading?: ReactNode
+ catalogTabs: ReactNode
+ catalogTrailing?: ReactNode
+ isMarketplacePlatform: boolean
+}
+
+function HomeCatalogNavigation({
+ catalogCategories,
+ catalogLeading,
+ catalogTabs,
+ catalogTrailing,
+ isMarketplacePlatform,
+}: HomeCatalogNavigationProps) {
+ const { t } = useTranslation()
+ const isPinned = useAtomValue(homeCatalogPinnedAtom)
+ const setIsPinned = useSetAtom(homeCatalogPinnedAtom)
+ const isPinnedRef = useRef(isPinned)
+ const pendingFocusedTabHrefRef = useRef(null)
+ const catalogTabsRegionRef = useRef(null)
+
+ useLayoutEffect(() => {
+ isPinnedRef.current = isPinned
+ const focusedTabHref = pendingFocusedTabHrefRef.current
+ if (!focusedTabHref) return
+
+ pendingFocusedTabHrefRef.current = null
+ focusCatalogTab(isPinned ? 'header' : 'content', focusedTabHref)
+ }, [isPinned])
+
+ useEffect(() => {
+ const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)
+ if (!scrollContainer) return
+ const desktopHeaderSlotQuery =
+ isMarketplacePlatform && typeof window.matchMedia === 'function'
+ ? window.matchMedia('(min-width: 880px)')
+ : null
+
+ const updatePinnedState = () => {
+ const catalogTabsRegion = catalogTabsRegionRef.current
+ if (!catalogTabsRegion) return
+
+ const containerTop = scrollContainer.getBoundingClientRect().top
+ const catalogTabsRegionBottom = catalogTabsRegion.getBoundingClientRect().bottom
+ const canUseHeaderSlot =
+ !isMarketplacePlatform || !desktopHeaderSlotQuery || desktopHeaderSlotQuery.matches
+ const nextIsPinned =
+ canUseHeaderSlot && catalogTabsRegionBottom <= containerTop + HOME_HEADER_HEIGHT_PX
+ if (nextIsPinned === isPinnedRef.current) return
+
+ pendingFocusedTabHrefRef.current = getFocusedCatalogTabHref(
+ nextIsPinned ? 'content' : 'header',
+ )
+ isPinnedRef.current = nextIsPinned
+ setIsPinned(nextIsPinned)
+ }
+
+ updatePinnedState()
+ scrollContainer.addEventListener('scroll', updatePinnedState, { passive: true })
+ desktopHeaderSlotQuery?.addEventListener('change', updatePinnedState)
+ window.addEventListener('resize', updatePinnedState)
+
+ return () => {
+ scrollContainer.removeEventListener('scroll', updatePinnedState)
+ desktopHeaderSlotQuery?.removeEventListener('change', updatePinnedState)
+ window.removeEventListener('resize', updatePinnedState)
+ }
+ }, [isMarketplacePlatform, setIsPinned])
+
+ return (
+
+
+
$['mainNav.marketplace'], { ns: 'common' })}
+ className={cn(
+ 'w-full shrink-0 bg-background-default',
+ styles.catalogNavigation,
+ isPinned && styles.catalogNavigationPinned,
+ )}
+ // Pins directly below the header, so the offset is the header height.
+ style={{ top: HOME_HEADER_HEIGHT_PX }}
+ >
+
+
+ {catalogLeading ? (
+ <>
+
{catalogLeading}
+
+ >
+ ) : null}
+
+ {catalogCategories ??
}
+
+ {catalogTrailing ?
{catalogTrailing}
: null}
+
+
+
+
+ )
+}
+
+export default HomeCatalogNavigation
diff --git a/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx
new file mode 100644
index 00000000000..30dcbc41106
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx
@@ -0,0 +1,77 @@
+import { cn } from '@langgenius/dify-ui/cn'
+import { useTranslation } from '#i18n'
+import Link from '@/next/link'
+
+export type HomeCatalogTab = 'plugins' | 'templates'
+export type HomeCatalogTabLabels = Record
+
+type HomeCatalogTabsProps = {
+ activeTab?: HomeCatalogTab | null
+ className?: string
+ isMarketplacePlatform: boolean
+ labels?: HomeCatalogTabLabels
+ language?: string
+}
+
+const HomeCatalogTabs = ({
+ activeTab = 'plugins',
+ className,
+ isMarketplacePlatform,
+ labels,
+ language,
+}: HomeCatalogTabsProps) => {
+ const { t } = useTranslation()
+ const catalogParams = language ? { language } : undefined
+ const getRelativeCatalogHref = (path: string) => {
+ const searchParams = new URLSearchParams(catalogParams)
+ const queryString = searchParams.toString()
+ return queryString ? `${path}?${queryString}` : path
+ }
+ const pluginsHref = isMarketplacePlatform
+ ? getRelativeCatalogHref('/plugins')
+ : getRelativeCatalogHref('/marketplace')
+ const templatesHref = getRelativeCatalogHref('/templates')
+ const isPluginsActive = activeTab === 'plugins'
+ const isTemplatesActive = activeTab === 'templates'
+ const pluginsLabel = labels?.plugins ?? t(($) => $['marketplace.home.plugins'], { ns: 'plugin' })
+ const templatesLabel =
+ labels?.templates ?? t(($) => $['marketplace.home.templates'], { ns: 'plugin' })
+
+ return (
+ $['mainNav.marketplace'], { ns: 'common' })}
+ className={cn('flex h-8 items-center gap-1', className)}
+ >
+
+ {pluginsLabel}
+
+
+ {templatesLabel}
+
+
+ )
+}
+
+export default HomeCatalogTabs
diff --git a/web/app/components/plugins/marketplace/home/home-constants.ts b/web/app/components/plugins/marketplace/home/home-constants.ts
new file mode 100644
index 00000000000..ed10c674f37
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-constants.ts
@@ -0,0 +1,15 @@
+/**
+ * Height of the marketplace home header in pixels. Sticky home chrome reads
+ * this so the header, search, and catalog offsets cannot drift apart.
+ */
+export const HOME_HEADER_HEIGHT_PX = 48
+
+/** Height of the home search row. HomeSearch and the mobile catalog offset both read this. */
+export const HOME_SEARCH_HEIGHT_PX = 36
+
+/** Extra sticky-chrome gap under the mobile search row (ECO-475). */
+export const HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX = 16
+
+/** 40px icon tiles + 1px divider-subtle lines in the marketplace home hero. */
+export const HERO_GRID_PITCH_PX = 41
+export const HERO_ICON_SIZE_PX = 40
diff --git a/web/app/components/plugins/marketplace/home/home-creator-center.tsx b/web/app/components/plugins/marketplace/home/home-creator-center.tsx
new file mode 100644
index 00000000000..954ca24453b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-creator-center.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+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 { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track'
+import { useCreatorCenterUrl } from '../creator-center-url'
+
+export default function HomeCreatorCenter() {
+ const { t } = useTranslation('plugin')
+ const creatorCenterUrl = useCreatorCenterUrl(MARKETPLACE_URL_PREFIX)
+ const label = t(($) => $['marketplace.home.creatorCenter'])
+
+ return (
+ {
+ trackMarketplaceSiteEvent('marketplace_creator_partner_click', {
+ click_target: 'creator_center',
+ })
+ }}
+ // 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',
+ )}
+ >
+
+ {label}
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/home-guide.tsx b/web/app/components/plugins/marketplace/home/home-guide.tsx
new file mode 100644
index 00000000000..bceac49c2df
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-guide.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import type { DocPathWithoutLang } from '@/types/doc-paths'
+import { useTranslation } from '#i18n'
+import {
+ SubmitRequestDropdown,
+ SubmitRequestDropdownMenu,
+} from '@/app/components/plugins/plugin-page/nav-operations'
+import { defaultDocBaseUrl } from '@/context/i18n'
+import { getDocLanguage } from '@/i18n-config/language'
+
+function MarketplaceGuide() {
+ const { i18n } = useTranslation()
+ const docLanguage = getDocLanguage(i18n.language)
+ const docLink = (path: DocPathWithoutLang) => `${defaultDocBaseUrl}/${docLanguage}${path}`
+
+ return
+}
+
+export default function HomeGuide({ isMarketplacePlatform }: { isMarketplacePlatform: boolean }) {
+ // Standalone Marketplace cannot call useDocLink(): it reads the console-only
+ // systemFeatures suspense query and crashes SSR. The dropdown paths have no
+ // product-specific variants, so composing the URL from the locale matches.
+ return isMarketplacePlatform ? :
+}
diff --git a/web/app/components/plugins/marketplace/home/home-header.tsx b/web/app/components/plugins/marketplace/home/home-header.tsx
new file mode 100644
index 00000000000..f608133b69c
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-header.tsx
@@ -0,0 +1,93 @@
+import type { HomeCatalogTab, HomeCatalogTabLabels } from './home-catalog-tabs'
+import { cn } from '@langgenius/dify-ui/cn'
+import Link from '@/next/link'
+import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg'
+import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg'
+import HomeCatalogTabs from './home-catalog-tabs'
+import { HOME_HEADER_HEIGHT_PX } from './home-constants'
+// HomeCreatorCenter stays in its own client module: it derives styles via
+// buttonVariants(), which cannot be invoked inside this server component.
+import HomeCreatorCenter from './home-creator-center'
+import HomeGuide from './home-guide'
+import { HomeStickyCatalogTabs } from './home-sticky-state-provider'
+import styles from './home-sticky.module.css'
+
+type HomeHeaderProps = {
+ activeTab?: HomeCatalogTab | null
+ actions?: React.ReactNode
+ catalogLabels?: HomeCatalogTabLabels
+ isMarketplacePlatform: boolean
+ language?: string
+}
+
+const HomeHeader = ({
+ activeTab = 'plugins',
+ actions,
+ catalogLabels,
+ isMarketplacePlatform,
+ language,
+}: HomeHeaderProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default HomeHeader
diff --git a/web/app/components/plugins/marketplace/home/home-hero.module.css b/web/app/components/plugins/marketplace/home/home-hero.module.css
new file mode 100644
index 00000000000..4b51646e05b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-hero.module.css
@@ -0,0 +1,74 @@
+.decorations {
+ pointer-events: none;
+ position: absolute;
+ inset: 0;
+}
+
+/* 4 grid rows (0–163) so the 40px icons at y=123 sit fully inside the hero
+ without overflowing into a scrollbar. */
+.frame {
+ height: 163px;
+}
+
+/* 40px cells + 1px divider-subtle lines, matching Figma header/Variant2.
+ Figma's vertical lines are inset 141px on a 1512px canvas (~9%) and sit
+ under a white wash, so the grid fades out toward both edges instead of
+ meeting the viewport at full strength.
+
+ The 41px tile is odd-sized, so `background-position: center` places the
+ 1px stroke on a half-pixel and leaves a 0.5px gap beside every icon.
+ +0.5px matches Figma (`left: calc(50% + 0.5px)`) so line starts sit on
+ the same pixels as `left: calc(50% + n * 41px)`. */
+.grid {
+ position: absolute;
+ inset: 0;
+ background-image:
+ linear-gradient(
+ to right,
+ transparent 20px,
+ var(--color-divider-subtle) 20px,
+ var(--color-divider-subtle) 21px,
+ transparent 21px
+ ),
+ linear-gradient(to bottom, var(--color-divider-subtle) 1px, transparent 1px);
+ background-size:
+ var(--hero-grid-pitch, 41px) 100%,
+ 100% var(--hero-grid-pitch, 41px);
+ background-position:
+ calc(50% + 0.5px) top,
+ left 40px;
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent 0%,
+ #000 12%,
+ #000 88%,
+ transparent 100%
+ );
+ mask-image: linear-gradient(to right, transparent 0%, #000 12%, #000 88%, transparent 100%);
+}
+
+/* Figma Ellipse 5 (1159:70851): 555×245 white oval at (478, 63) on the
+ 1512×257 header, layer-blur 60. Hero y is shifted −44px so the top icon
+ row sits at 0. The blur washes grid lines out under the title and search
+ while fading toward the decorative icons. */
+.glow {
+ position: absolute;
+ top: 19px;
+ left: 50%;
+ width: 555px;
+ height: 245px;
+ transform: translateX(-50%);
+ border-radius: 50%;
+ background: var(--color-background-default);
+ filter: blur(30px);
+}
+
+@media (max-width: 879px) {
+ .decorations {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .copyBlock {
+ max-width: 360px;
+ }
+}
diff --git a/web/app/components/plugins/marketplace/home/home-hero.tsx b/web/app/components/plugins/marketplace/home/home-hero.tsx
new file mode 100644
index 00000000000..3b685c340ba
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-hero.tsx
@@ -0,0 +1,100 @@
+'use client'
+
+import type { CSSProperties, ReactNode } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useTranslation } from '#i18n'
+import brain2FillIcon from './assets/brain-2-fill.svg'
+import imageCircleAiLineIcon from './assets/image-circle-ai-line.svg'
+import plugFillIcon from './assets/plug-fill.svg'
+import puzzleFillIcon from './assets/puzzle-fill.svg'
+import sparklingFillIcon from './assets/sparkling-fill.svg'
+import voiceAiFillIcon from './assets/voice-ai-fill.svg'
+import { HERO_GRID_PITCH_PX, HERO_ICON_SIZE_PX } from './home-constants'
+import styles from './home-hero.module.css'
+
+type HomeHeroProps = {
+ isMarketplacePlatform: boolean
+ subtitle?: ReactNode
+ title?: ReactNode
+}
+
+type HeroDecorationIcon = {
+ left: number
+ src: string
+ top: number
+}
+
+const heroIconSrc = (icon: { src: string } | string) => (typeof icon === 'string' ? icon : icon.src)
+
+// Positions are Figma offsets from the 1512px canvas center, with the top
+// icon row shifted to y=0 so the marks sit in HomeHero instead of the header.
+const heroDecorationIcons: HeroDecorationIcon[] = [
+ { src: heroIconSrc(sparklingFillIcon), left: -450, top: HERO_GRID_PITCH_PX },
+ { src: heroIconSrc(plugFillIcon), left: -286, top: 0 },
+ { src: heroIconSrc(puzzleFillIcon), left: -327, top: HERO_GRID_PITCH_PX * 3 },
+ { src: heroIconSrc(brain2FillIcon), left: 247, top: HERO_GRID_PITCH_PX * 2 },
+ { src: heroIconSrc(imageCircleAiLineIcon), left: 370, top: HERO_GRID_PITCH_PX * 3 },
+ { src: heroIconSrc(voiceAiFillIcon), left: 411, top: 0 },
+]
+
+const heroGridStyle = {
+ '--hero-grid-pitch': `${HERO_GRID_PITCH_PX}px`,
+} as CSSProperties
+
+const HeroDecorations = () => (
+
+
+
+ {heroDecorationIcons.map((icon) => (
+
+
+
+
+
+ ))}
+
+)
+
+const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => {
+ const { t } = useTranslation('plugin')
+
+ return (
+
+
+
+
+
+ {title ?? t(($) => $['marketplace.home.heroTitle'])}
+
+
+ {subtitle ?? t(($) => $['marketplace.home.heroSubtitle'])}
+
+
+
+
+ )
+}
+
+export default HomeHero
diff --git a/web/app/components/plugins/marketplace/home/home-search.tsx b/web/app/components/plugins/marketplace/home/home-search.tsx
new file mode 100644
index 00000000000..08d26678a7a
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-search.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useEffect, useRef } from 'react'
+import { useTranslation } from '#i18n'
+import { MARKETPLACE_CONTAINER_ID } from '../constants'
+import styles from './home-sticky.module.css'
+import MarketplacePluginSearch from './marketplace-plugin-search'
+import { preserveStickySearchScroll } from './preserve-sticky-search-scroll'
+
+type HomeSearchProps = {
+ children?: ReactNode
+ /**
+ * Registers the global Cmd/Ctrl+K focus shortcut. The embedded console
+ * already binds Mod+K to GotoAnything, so only the standalone marketplace
+ * should keep this enabled.
+ */
+ enableSearchShortcut?: boolean
+ /**
+ * Pull the search row up over the hero. Search-results (and any other
+ * page without a hero) must leave this off so the field stays below the
+ * header instead of covering the brand.
+ */
+ overlapHero?: boolean
+}
+
+const HomeSearch = ({
+ children,
+ enableSearchShortcut = true,
+ overlapHero = true,
+}: HomeSearchProps) => {
+ const searchRef = useRef(null)
+ const { t } = useTranslation('plugin')
+
+ useEffect(() => {
+ const searchRoot = searchRef.current
+ const container = document.getElementById(MARKETPLACE_CONTAINER_ID)
+ if (!searchRoot || !container) return
+ return preserveStickySearchScroll(searchRoot, container)
+ }, [])
+
+ useEffect(() => {
+ if (!enableSearchShortcut) return
+
+ const handleGlobalSearchShortcut = (event: KeyboardEvent) => {
+ if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return
+
+ event.preventDefault()
+ searchRef.current?.querySelector('input')?.focus({ preventScroll: true })
+ }
+
+ document.addEventListener('keydown', handleGlobalSearchShortcut)
+ return () => document.removeEventListener('keydown', handleGlobalSearchShortcut)
+ }, [enableSearchShortcut])
+
+ return (
+
+
+ {children ?? (
+ $['marketplace.home.searchPlaceholder'])}
+ />
+ )}
+
+
+ )
+}
+
+export default HomeSearch
diff --git a/web/app/components/plugins/marketplace/home/home-shell.tsx b/web/app/components/plugins/marketplace/home/home-shell.tsx
new file mode 100644
index 00000000000..d5a1fe2d07b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-shell.tsx
@@ -0,0 +1,77 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type { CSSProperties, ReactNode } from 'react'
+import type { MarketplaceBannerPage } from './banners'
+import { cn } from '@langgenius/dify-ui/cn'
+import {
+ HOME_HEADER_HEIGHT_PX,
+ HOME_SEARCH_HEIGHT_PX,
+ HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX,
+} from './home-constants'
+import { HomeStickyStateProvider } from './home-sticky-state-provider'
+import styles from './home-sticky.module.css'
+import HomeTrending from './home-trending'
+
+type HomeShellProps = {
+ banners: PluginBanner[]
+ children: ReactNode
+ header: ReactNode
+ hero: ReactNode
+ isMarketplacePlatform: boolean
+ navigation: ReactNode
+ page: MarketplaceBannerPage
+ search: ReactNode
+}
+
+/**
+ * Shared scaffold for the marketplace catalog homes (Plugins and Templates):
+ * sticky header, hero, floating search, the optional trending banners, and
+ * the sticky catalog navigation above the page content. Keeping the structure
+ * in one place stops the two catalog pages from drifting apart.
+ */
+export function HomeShell({
+ banners,
+ children,
+ header,
+ hero,
+ isMarketplacePlatform,
+ navigation,
+ page,
+ search,
+}: HomeShellProps) {
+ return (
+
+
+ {header}
+
+ {hero}
+ {search}
+ {banners.length > 0 && (
+ <>
+
+
+ >
+ )}
+ {navigation}
+ {children}
+
+
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx b/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx
new file mode 100644
index 00000000000..436a89707b6
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useAtomValue } from 'jotai'
+import { ScopeProvider } from 'jotai-scope'
+import { homeCatalogPinnedAtom, homeStickyScopedAtoms } from './home-sticky-state'
+import styles from './home-sticky.module.css'
+
+export function HomeStickyStateProvider({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function HomeStickyCatalogTabs({ children }: { children: ReactNode }) {
+ const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/home-sticky-state.ts b/web/app/components/plugins/marketplace/home/home-sticky-state.ts
new file mode 100644
index 00000000000..e49206393a4
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-sticky-state.ts
@@ -0,0 +1,5 @@
+import { atom } from 'jotai'
+
+export const homeCatalogPinnedAtom = atom(false)
+
+export const homeStickyScopedAtoms = [homeCatalogPinnedAtom]
diff --git a/web/app/components/plugins/marketplace/home/home-sticky.module.css b/web/app/components/plugins/marketplace/home/home-sticky.module.css
new file mode 100644
index 00000000000..e2f99827b1f
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-sticky.module.css
@@ -0,0 +1,171 @@
+/* Sticky offsets read --home-header-height and --home-search-height from
+ HomeShell (HOME_HEADER_HEIGHT_PX / HOME_SEARCH_HEIGHT_PX). Desktop catalog
+ navigation still pins with an inline top of HOME_HEADER_HEIGHT_PX. */
+
+.headerCatalogTabs {
+ display: flex;
+}
+
+.headerCatalogSlot,
+.catalogTabs {
+ transition-property: opacity, transform;
+ transition-duration: 140ms;
+ transition-timing-function: ease-out;
+ will-change: opacity, transform;
+}
+
+.headerCatalogSlot {
+ display: flex;
+ flex-shrink: 0;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(4px);
+}
+
+.headerCatalogSlotPinned {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateY(0);
+}
+
+.marketplaceLogoLight {
+ display: block;
+}
+
+.marketplaceLogoDark {
+ display: none;
+}
+
+:global(html[data-theme='dark']) .marketplaceLogoLight {
+ display: none;
+}
+
+:global(html[data-theme='dark']) .marketplaceLogoDark {
+ display: block;
+}
+
+.search {
+ position: sticky;
+ z-index: 60;
+ top: 6px;
+ height: var(--home-search-height, 36px);
+ padding-right: 356px;
+ padding-left: 356px;
+ overflow-anchor: none;
+}
+
+.searchContent {
+ max-width: 420px;
+}
+
+.catalogNavigationGroup {
+ display: contents;
+}
+
+.catalogTabsRegion {
+ padding: 24px 32px 0;
+}
+
+.catalogNavigation {
+ position: sticky;
+ z-index: 40;
+ padding: 16px 32px;
+}
+
+.catalogNavigationPinned {
+ background-color: var(--color-background-default);
+}
+
+.catalogTabs {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.catalogTabsPinned {
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-4px);
+}
+
+.catalogContent {
+ min-height: calc(100vh - 106px);
+ min-height: calc(100dvh - 106px);
+}
+
+@media (max-width: 879px) {
+ .search {
+ position: relative;
+ z-index: 0;
+ top: auto;
+ padding-right: 16px;
+ padding-left: 16px;
+ }
+
+ :global([data-marketplace-standalone]) .search {
+ position: sticky;
+ z-index: 45;
+ top: var(--home-header-height, 48px);
+ height: calc(var(--home-search-height, 36px) + var(--home-search-mobile-padding-bottom, 16px));
+ padding-right: 20px;
+ padding-bottom: var(--home-search-mobile-padding-bottom, 16px);
+ padding-left: 20px;
+ background-color: var(--color-background-default);
+ }
+
+ :global([data-marketplace-standalone]) .searchContent {
+ max-width: 360px;
+ }
+
+ :global([data-marketplace-standalone]) .headerCatalogTabs {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .standaloneHeaderActions {
+ display: none;
+ }
+
+ /* Sit under the search row's padding-bottom so that gap is not stacked
+ on top of the tabs' own padding when this group pins. */
+ :global([data-marketplace-standalone]) .catalogNavigationGroup {
+ position: sticky;
+ z-index: 40;
+ display: block;
+ top: calc(var(--home-header-height, 48px) + var(--home-search-height, 36px));
+ background-color: var(--color-background-default);
+ }
+
+ :global([data-marketplace-standalone]) .catalogTabsRegion {
+ padding-top: var(--home-search-mobile-padding-bottom, 16px);
+ padding-right: 20px;
+ padding-left: 20px;
+ }
+
+ :global([data-marketplace-standalone]) .catalogNavigation {
+ position: static;
+ padding: 16px 20px;
+ }
+
+ :global([data-marketplace-standalone]) .catalogLeading {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .catalogLeadingDivider {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .catalogContent {
+ padding-right: 20px;
+ padding-left: 20px;
+ }
+
+ :global([data-marketplace-standalone]) .bannerSpacer {
+ height: 24px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .headerCatalogSlot,
+ .catalogTabs {
+ transition-duration: 0ms;
+ }
+}
diff --git a/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx
new file mode 100644
index 00000000000..4b065bfc89b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx
@@ -0,0 +1,281 @@
+'use client'
+
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type { RefObject } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslation } from '#i18n'
+import { MARKETPLACE_CONTAINER_ID } from '../constants'
+import styles from './home-trending.module.css'
+
+const AUTOPLAY_DELAY = 5000
+const PAGINATION_DOT_SIZE = 6
+const PAGINATION_ACTIVE_WIDTH = 40
+const PAGINATION_GAP = 8
+const PAGINATION_STEP = PAGINATION_DOT_SIZE + PAGINATION_GAP
+const PAGINATION_ACTIVE_SHIFT = PAGINATION_ACTIVE_WIDTH - PAGINATION_DOT_SIZE
+
+const getPaginationItemOffset = (index: number, selectedIndex: number) =>
+ index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0)
+
+type AutoplayPauseReason =
+ | 'focus'
+ | 'hover'
+ | 'interaction'
+ | 'reduced-motion'
+ | 'user'
+ | 'viewport'
+ | 'visibility'
+
+function TrendingNavigation({
+ banners,
+ selectedIndex,
+ carouselRootRef,
+ interactionPaused,
+ pauseWhenOffscreen,
+ onSelect,
+ onNext,
+ onPausedChange,
+}: {
+ banners: PluginBanner[]
+ selectedIndex: number
+ carouselRootRef: RefObject
+ interactionPaused: boolean
+ pauseWhenOffscreen: boolean
+ onSelect: (index: number) => void
+ onNext: () => void
+ onPausedChange?: (paused: boolean) => void
+}) {
+ const { t } = useTranslation('plugin')
+ const progressRef = useRef(null)
+ const progressAnimationRef = useRef(null)
+ const pauseReasonsRef = useRef(
+ new Set(pauseWhenOffscreen ? ['viewport'] : []),
+ )
+ const [isUserPaused, setIsUserPaused] = useState(false)
+ const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false)
+ const isExplicitlyPaused = isUserPaused || isReducedMotionPaused
+ const paginationWidth =
+ PAGINATION_ACTIVE_WIDTH + Math.max(0, banners.length - 1) * PAGINATION_STEP
+
+ const setPauseReason = useCallback(
+ (reason: AutoplayPauseReason, shouldPause: boolean) => {
+ if (shouldPause) pauseReasonsRef.current.add(reason)
+ else pauseReasonsRef.current.delete(reason)
+
+ const isPaused = pauseReasonsRef.current.size > 0
+ onPausedChange?.(isPaused)
+
+ const progressAnimation = progressAnimationRef.current
+ if (!progressAnimation) return
+
+ if (isPaused) progressAnimation.pause()
+ else progressAnimation.play()
+ },
+ [onPausedChange],
+ )
+
+ useEffect(() => {
+ setPauseReason('interaction', interactionPaused)
+ }, [interactionPaused, setPauseReason])
+
+ useEffect(() => {
+ const progressElement = progressRef.current
+ if (!progressElement?.animate) return
+
+ const progressAnimation = progressElement.animate(
+ [{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }],
+ {
+ duration: AUTOPLAY_DELAY,
+ easing: 'linear',
+ fill: 'forwards',
+ },
+ )
+ progressAnimationRef.current = progressAnimation
+
+ if (pauseReasonsRef.current.size > 0) progressAnimation.pause()
+ progressAnimation.onfinish = onNext
+
+ return () => {
+ progressAnimation.onfinish = null
+ progressAnimation.cancel()
+ if (progressAnimationRef.current === progressAnimation) progressAnimationRef.current = null
+ }
+ }, [onNext, selectedIndex])
+
+ useEffect(() => {
+ const carouselRoot = carouselRootRef.current
+ if (!carouselRoot) return
+
+ const handleMouseEnter = () => setPauseReason('hover', true)
+ const handleMouseLeave = () => setPauseReason('hover', false)
+ const handleFocusIn = () => setPauseReason('focus', true)
+ const handleFocusOut = (event: FocusEvent) => {
+ if (carouselRoot.contains(event.relatedTarget as Node | null)) return
+ setPauseReason('focus', false)
+ }
+ const handleVisibilityChange = () =>
+ setPauseReason('visibility', document.visibilityState === 'hidden')
+
+ carouselRoot.addEventListener('mouseenter', handleMouseEnter)
+ carouselRoot.addEventListener('mouseleave', handleMouseLeave)
+ carouselRoot.addEventListener('focusin', handleFocusIn)
+ carouselRoot.addEventListener('focusout', handleFocusOut)
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+ handleVisibilityChange()
+
+ return () => {
+ carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
+ carouselRoot.removeEventListener('mouseleave', handleMouseLeave)
+ carouselRoot.removeEventListener('focusin', handleFocusIn)
+ carouselRoot.removeEventListener('focusout', handleFocusOut)
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }
+ }, [carouselRootRef, setPauseReason])
+
+ useEffect(() => {
+ if (!pauseWhenOffscreen) {
+ setPauseReason('viewport', false)
+ return
+ }
+
+ const carouselRoot = carouselRootRef.current
+ if (!carouselRoot) return
+
+ if (typeof IntersectionObserver === 'undefined') {
+ setPauseReason('viewport', false)
+ return
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ const isVisible = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25
+ setPauseReason('viewport', !isVisible)
+ },
+ {
+ root: document.getElementById(MARKETPLACE_CONTAINER_ID),
+ threshold: 0.25,
+ },
+ )
+
+ observer.observe(carouselRoot)
+
+ return () => observer.disconnect()
+ }, [carouselRootRef, pauseWhenOffscreen, setPauseReason])
+
+ useEffect(() => {
+ const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
+ const syncReducedMotion = () => {
+ // oxlint-disable-next-line eslint-react/set-state-in-effect -- This state mirrors an external media query.
+ setIsReducedMotionPaused(reducedMotionQuery.matches)
+ setPauseReason('reduced-motion', reducedMotionQuery.matches)
+ }
+
+ syncReducedMotion()
+ reducedMotionQuery.addEventListener('change', syncReducedMotion)
+
+ return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion)
+ }, [setPauseReason])
+
+ const clearImplicitPauseReasons = () => {
+ // Pointer activation leaves hover and/or focus on the control, which
+ // would otherwise keep rotation paused until the next mouseleave/focusout.
+ setPauseReason('focus', false)
+ setPauseReason('hover', false)
+ }
+
+ const toggleAutoplay = () => {
+ if (isExplicitlyPaused) {
+ setIsUserPaused(false)
+ setIsReducedMotionPaused(false)
+ setPauseReason('user', false)
+ setPauseReason('reduced-motion', false)
+ // An explicit Play overrides the implicit reasons; they re-engage on
+ // the next mouseenter/focusin.
+ clearImplicitPauseReasons()
+ return
+ }
+
+ setIsUserPaused(true)
+ setPauseReason('user', true)
+ }
+
+ return (
+ $['marketplace.home.trendingPaginationLabel'])}
+ className={cn(
+ styles.navigation,
+ 'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2',
+ )}
+ >
+
+
+
+
+ {banners.map((banner, index) => {
+ const isCurrent = index === selectedIndex
+
+ return (
+ {
+ if (!isCurrent) onSelect(index)
+ // Keyboard selection keeps the focus pause so rotation does
+ // not advance under the user. Pointer selection should keep
+ // timing immediately without waiting for blur.
+ if (event.detail === 0) return
+ clearImplicitPauseReasons()
+ }}
+ className={cn(
+ 'absolute top-0 left-0 z-2 h-1.5 overflow-hidden rounded-full outline-hidden transition-[transform,width,background-color] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] after:absolute after:-inset-2 hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none',
+ isCurrent ? 'bg-transparent' : 'bg-state-base-handle',
+ )}
+ style={{
+ width: isCurrent ? PAGINATION_ACTIVE_WIDTH : PAGINATION_DOT_SIZE,
+ transform: `translate3d(${getPaginationItemOffset(index, selectedIndex)}px, 0, 0)`,
+ }}
+ />
+ )
+ })}
+
+
+
+ $[
+ isExplicitlyPaused
+ ? 'marketplace.home.trendingPlay'
+ : 'marketplace.home.trendingPause'
+ ],
+ )}
+ onClick={toggleAutoplay}
+ className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-active text-text-primary outline-hidden hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
+ >
+ {isExplicitlyPaused ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+export default TrendingNavigation
diff --git a/web/app/components/plugins/marketplace/home/home-trending-slides.tsx b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx
new file mode 100644
index 00000000000..a23842ca8fc
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx
@@ -0,0 +1,503 @@
+'use client'
+
+import type {
+ BannerAd,
+ BannerBlog,
+ BannerEvent,
+ BannerRecommend,
+ BannerRecommendCard,
+ PluginBanner,
+} from '@dify/contracts/marketplace'
+import type { MarketplaceBannerPage } from './banners'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useTranslation } from '#i18n'
+import { trackEvent } from '@/app/components/base/amplitude'
+import Partner from '@/app/components/plugins/base/badges/partner'
+import Verified from '@/app/components/plugins/base/badges/verified'
+import { MARKETPLACE_API_PREFIX } from '@/config'
+import Link from '@/next/link'
+import {
+ rememberMarketplaceSiteReferrer,
+ trackMarketplaceSiteEvent,
+} from '@/utils/marketplace-site-track'
+import { getPluginLinkInMarketplace } from '../utils'
+import background from './assets/background.webp'
+import difyUpdatesArt from './assets/dify-updates-art.png'
+import {
+ EMBEDDED_MOBILE_BANNER_MEDIA,
+ MARKETPLACE_MOBILE_BANNER_MEDIA,
+ marketplaceTabletBannerMedia,
+ resolveEventAdBannerImageSrcs,
+} from './event-ad-banner-image'
+import { buildMarketplaceBannerClickProperties } from './home-trending-track'
+import styles from './home-trending.module.css'
+import { sanitizeMarketplaceHref } from './marketplace-href'
+
+const getMarketplaceAssetURL = (path?: string) => {
+ if (!path) return ''
+ if (/^https?:\/\//.test(path) || path.startsWith('/_next/')) return path
+
+ try {
+ const apiURL = new URL(MARKETPLACE_API_PREFIX)
+ if (path.startsWith('/api/')) return `${apiURL.origin}${path}`
+ return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
+ } catch {
+ return path
+ }
+}
+
+const getLocalCardHref = (card: BannerRecommendCard) => {
+ if (card.item_type === 'plugin') {
+ const [organization, pluginName] = card.item_id.split('/')
+ if (organization && pluginName)
+ return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}`
+ }
+
+ if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}`
+
+ return '/'
+}
+
+const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean) => {
+ if (isMarketplacePlatform) return getLocalCardHref(card)
+ const deliveryHref = card.link ? sanitizeMarketplaceHref(card.link) : null
+ if (deliveryHref) return deliveryHref
+
+ // The embedded console has no local plugin detail route, so a plugin card
+ // without a delivery-provided link opens the marketplace site detail page.
+ if (card.item_type === 'plugin') {
+ const [organization, pluginName] = card.item_id.split('/')
+ if (organization && pluginName)
+ return getPluginLinkInMarketplace({ org: organization, name: pluginName, type: 'plugin' })
+ }
+
+ return getLocalCardHref(card)
+}
+
+const getCardCreator = (card: BannerRecommendCard) => {
+ if (card.creator) return card.creator
+ if (card.item_type !== 'plugin') return ''
+
+ return card.item_id.split('/')[0] || ''
+}
+
+const getBannerFrameProps = (banner: PluginBanner, page: MarketplaceBannerPage) => ({
+ banner_id: banner.id,
+ sort: banner.sort,
+ page,
+ language: banner.language,
+ style_type: banner.style_type,
+})
+
+const trackMarketplaceBannerClick = (
+ banner: PluginBanner,
+ cardClick?: Parameters[1],
+) => {
+ trackMarketplaceSiteEvent(
+ 'marketplace_banner_click',
+ buildMarketplaceBannerClickProperties(banner, cardClick),
+ )
+}
+
+function TrendingCopy({
+ banner,
+ isMarketplacePlatform,
+}: {
+ banner: BannerRecommend
+ isMarketplacePlatform: boolean
+}) {
+ const { t } = useTranslation('plugin')
+ const heading = banner.content.heading || t(($) => $['marketplace.home.trendingTitle'])
+ const description =
+ banner.content.description ||
+ banner.content.subheadings?.join(' · ') ||
+ t(($) => $['marketplace.home.trendingDescription'])
+
+ return (
+
+
+
+ {banner.title}
+
+
+ {heading}
+
+
+ {description}
+
+
+
+ )
+}
+
+function TrendingCard({
+ banner,
+ card,
+ isMarketplacePlatform,
+ page,
+}: {
+ banner: BannerRecommend
+ card: BannerRecommendCard
+ isMarketplacePlatform: boolean
+ page: MarketplaceBannerPage
+}) {
+ const { t } = useTranslation('plugin')
+ const iconURL = getMarketplaceAssetURL(card.icon_url)
+ const creator = getCardCreator(card)
+ const href = getCardHref(card, isMarketplacePlatform)
+ if (!href) return null
+ const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href)
+ const isPartner = card.badges?.includes('partner')
+ const isVerified = card.badges?.includes('verified')
+
+ return (
+ {
+ trackEvent('marketplace_banner_item_click', {
+ ...getBannerFrameProps(banner, page),
+ item_type: card.item_type,
+ item_id: card.item_id,
+ card_position: card.card_position,
+ theme_type: banner.content.theme_type,
+ auto_batch_id: card.auto_batch_id ?? null,
+ })
+ rememberMarketplaceSiteReferrer(card.item_id, 'banner')
+ trackMarketplaceBannerClick(banner, {
+ item_id: card.item_id,
+ item_type: card.item_type,
+ link: href,
+ })
+ }}
+ className={cn(
+ styles.card,
+ 'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
+ )}
+ >
+
+ {iconURL ? (
+
+ ) : card.icon ? (
+
{card.icon}
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {card.display_name}
+
+ {(isPartner || isVerified) && (
+
+ {isPartner && (
+
$['marketplace.partnerTip'])} />
+ )}
+ {isVerified && (
+ $['marketplace.verifiedTip'])} />
+ )}
+
+ )}
+
+ {creator && (
+
+ {t(($) => $['marketplace.home.trendingByCreator'], { creator })}
+
+ )}
+
+
+ {t(($) => $['marketplace.home.trendingView'])}
+
+
+
+ )
+}
+
+function TrendingRecommendationSlide({
+ banner,
+ isMarketplacePlatform,
+ page,
+}: {
+ banner: BannerRecommend
+ isMarketplacePlatform: boolean
+ page: MarketplaceBannerPage
+}) {
+ return (
+
+
+
+
+
+
+
+ {banner.content.cards.map((card) => (
+
+ ))}
+
+
+
+ )
+}
+
+function BlogBannerSlide({
+ banner,
+ isMarketplacePlatform,
+ page,
+}: {
+ banner: BannerBlog
+ isMarketplacePlatform: boolean
+ page: MarketplaceBannerPage
+}) {
+ const { t } = useTranslation('plugin')
+ const href = sanitizeMarketplaceHref(banner.content.link)
+ if (!href) return null
+ const opensInNewTab = /^https?:\/\//.test(href)
+
+ return (
+ {
+ trackEvent('marketplace_banner_click', getBannerFrameProps(banner, page))
+ trackMarketplaceBannerClick(banner)
+ }}
+ aria-label={t(($) => $['marketplace.home.trendingReadMoreAbout'], {
+ title: banner.content.blog_title,
+ })}
+ className={cn(
+ 'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
+ isMarketplacePlatform && styles.stackedSlide,
+ )}
+ >
+
+
+
+
+
+ {banner.content.blog_title}
+
+
+ {banner.content.subtitle && (
+
+ {banner.content.subtitle}
+
+ )}
+ {banner.content.description && (
+
+ {banner.content.description}
+
+ )}
+
+ {t(($) => $['marketplace.home.trendingReadMore'])}
+
+
+
+
+
+
+
+
+ )
+}
+
+function ImageBannerSlide({
+ banner,
+ isMarketplacePlatform,
+ page,
+}: {
+ banner: BannerEvent | BannerAd
+ isMarketplacePlatform: boolean
+ page: MarketplaceBannerPage
+}) {
+ const href = sanitizeMarketplaceHref(banner.content.link)
+ if (!href) return null
+ const resolved = resolveEventAdBannerImageSrcs({
+ desktop: getMarketplaceAssetURL(banner.content.images.desktop),
+ tablet: getMarketplaceAssetURL(banner.content.images.tablet) || undefined,
+ mobile: getMarketplaceAssetURL(banner.content.images.mobile) || undefined,
+ })
+
+ return (
+ {
+ trackEvent('marketplace_banner_click', getBannerFrameProps(banner, page))
+ trackMarketplaceBannerClick(banner)
+ }}
+ aria-label={banner.content.alt_text || banner.title}
+ className={cn(
+ 'block h-[200px] w-full overflow-hidden rounded-2xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
+ isMarketplacePlatform && styles.imageSlide,
+ )}
+ >
+
+
+ {resolved.tablet && (
+
+ )}
+
+
+
+ )
+}
+
+export function HomeBannerSlide({
+ banner,
+ isMarketplacePlatform,
+ page,
+}: {
+ banner: PluginBanner
+ isMarketplacePlatform: boolean
+ page: MarketplaceBannerPage
+}) {
+ if (banner.style_type === 'blog')
+ return (
+
+ )
+
+ if (banner.style_type === 'event' || banner.style_type === 'ad')
+ return (
+
+ )
+
+ return (
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts b/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts
new file mode 100644
index 00000000000..9cb7d8c7261
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts
@@ -0,0 +1,135 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import { describe, expect, it } from 'vitest'
+import { buildMarketplaceBannerClickProperties } from './home-trending-track'
+
+const recommendBanner: PluginBanner = {
+ id: 'banner-recommend',
+ style_type: 'recommend',
+ title: 'Trending',
+ sort: 0,
+ language: 'en',
+ content: {
+ theme_type: 'hottest',
+ cards: [],
+ },
+}
+
+describe('buildMarketplaceBannerClickProperties', () => {
+ it('maps a recommendation card click to the site-event payload', () => {
+ expect(
+ buildMarketplaceBannerClickProperties(recommendBanner, {
+ item_id: 'langgenius/dropbox',
+ item_type: 'plugin',
+ link: '/plugin/langgenius/dropbox',
+ }),
+ ).toEqual({
+ banner_id: 'banner-recommend',
+ title: 'Trending',
+ theme_type: 'most_popular',
+ click_target: 'recommendation',
+ sort: 0,
+ language: 'en',
+ item_id: 'langgenius/dropbox',
+ item_type: 'plugin',
+ link: '/plugin/langgenius/dropbox',
+ })
+ })
+
+ it('maps newest recommendation theme to new_arrivals', () => {
+ expect(
+ buildMarketplaceBannerClickProperties(
+ {
+ ...recommendBanner,
+ content: { theme_type: 'newest', cards: [] },
+ },
+ {
+ item_id: 'tpl-1',
+ item_type: 'template',
+ link: '/templates?tid=tpl-1',
+ },
+ ),
+ ).toMatchObject({
+ theme_type: 'new_arrivals',
+ click_target: 'recommendation',
+ item_id: 'tpl-1',
+ item_type: 'template',
+ })
+ })
+
+ it('reports blog frame clicks with target_type and without card fields', () => {
+ expect(
+ buildMarketplaceBannerClickProperties({
+ id: 'banner-blog',
+ style_type: 'blog',
+ title: 'Dify Updates',
+ sort: 1,
+ language: 'zh',
+ content: {
+ blog_title: 'Launch',
+ link: 'https://dify.ai/blog',
+ link_target_type: 'github',
+ },
+ }),
+ ).toEqual({
+ banner_id: 'banner-blog',
+ title: 'Dify Updates',
+ click_target: 'blog',
+ sort: 1,
+ language: 'zh',
+ target_type: 'github',
+ link: 'https://dify.ai/blog',
+ })
+ })
+
+ it('reports event frame clicks with activity_id only', () => {
+ expect(
+ buildMarketplaceBannerClickProperties({
+ id: 'banner-event',
+ style_type: 'event',
+ title: 'Meetup',
+ sort: 2,
+ language: 'ja',
+ content: {
+ images: { desktop: '/event.png' },
+ link: 'https://dify.ai/events',
+ activity_id: 'act-1',
+ },
+ }),
+ ).toEqual({
+ banner_id: 'banner-event',
+ title: 'Meetup',
+ click_target: 'event',
+ sort: 2,
+ language: 'ja',
+ activity_id: 'act-1',
+ link: 'https://dify.ai/events',
+ })
+ })
+
+ it('reports ad frame clicks with partner and campaign ids', () => {
+ expect(
+ buildMarketplaceBannerClickProperties({
+ id: 'banner-ad',
+ style_type: 'ad',
+ title: 'Partner',
+ sort: 3,
+ language: 'en',
+ content: {
+ images: { desktop: '/ad.png' },
+ link: 'https://partner.example',
+ partner_id: 'acme',
+ campaign_id: 'spring',
+ },
+ }),
+ ).toEqual({
+ banner_id: 'banner-ad',
+ title: 'Partner',
+ click_target: 'ad',
+ sort: 3,
+ language: 'en',
+ partner_id: 'acme',
+ campaign_id: 'spring',
+ link: 'https://partner.example',
+ })
+ })
+})
diff --git a/web/app/components/plugins/marketplace/home/home-trending-track.ts b/web/app/components/plugins/marketplace/home/home-trending-track.ts
new file mode 100644
index 00000000000..65b4321c230
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-trending-track.ts
@@ -0,0 +1,58 @@
+import type { BannerRecommendCard, PluginBanner } from '@dify/contracts/marketplace'
+
+const CLICK_TARGET_BY_STYLE = {
+ recommend: 'recommendation',
+ blog: 'blog',
+ event: 'event',
+ ad: 'ad',
+} as const
+
+const THEME_TYPE_BY_BANNER = {
+ newest: 'new_arrivals',
+ hottest: 'most_popular',
+ partner: 'partner',
+} as const
+
+export type MarketplaceBannerCardClick = Pick & {
+ link: string
+}
+
+const compact = (properties: Record) => {
+ const next: Record = {}
+ for (const [key, value] of Object.entries(properties)) {
+ if (value !== undefined && value !== '') next[key] = value
+ }
+ return next
+}
+
+export const buildMarketplaceBannerClickProperties = (
+ banner: PluginBanner,
+ cardClick?: MarketplaceBannerCardClick,
+) => {
+ const clickTarget = CLICK_TARGET_BY_STYLE[banner.style_type]
+ const properties: Record = {
+ banner_id: banner.id,
+ title: banner.title,
+ click_target: clickTarget,
+ sort: banner.sort,
+ language: banner.language,
+ link: cardClick?.link ?? (banner.style_type === 'recommend' ? undefined : banner.content.link),
+ }
+
+ if (banner.style_type === 'recommend') {
+ properties.theme_type = THEME_TYPE_BY_BANNER[banner.content.theme_type]
+ properties.item_id = cardClick?.item_id
+ properties.item_type = cardClick?.item_type
+ }
+
+ if (banner.style_type === 'blog') properties.target_type = banner.content.link_target_type
+
+ if (banner.style_type === 'event') properties.activity_id = banner.content.activity_id
+
+ if (banner.style_type === 'ad') {
+ properties.partner_id = banner.content.partner_id
+ properties.campaign_id = banner.content.campaign_id
+ }
+
+ return compact(properties)
+}
diff --git a/web/app/components/plugins/marketplace/home/home-trending.module.css b/web/app/components/plugins/marketplace/home/home-trending.module.css
new file mode 100644
index 00000000000..34272cf272b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-trending.module.css
@@ -0,0 +1,315 @@
+.wrapper {
+ padding-bottom: 30px;
+}
+
+.copy {
+ flex: none;
+ width: 36.9167%;
+ height: 200px;
+}
+
+.recommendVisual {
+ flex: 1;
+ min-width: 0;
+ container-type: inline-size;
+}
+
+.recommendCards {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ overflow: hidden;
+ padding: 42px 36px;
+}
+
+.navigation {
+ top: 208px;
+ width: 100%;
+}
+
+.contentTrack {
+ transition: transform 400ms ease-out;
+}
+
+.card {
+ flex: 1 1 161px;
+ width: auto;
+ min-width: 161px;
+ max-width: 210px;
+ box-shadow: 0 8px 7.2px -6px rgb(0 0 0 / 19%);
+ scroll-snap-align: start;
+}
+
+@container (max-width: 751px) {
+ .recommendCards > .card:nth-child(n + 4) {
+ display: none;
+ }
+}
+
+@container (max-width: 578px) {
+ .recommendCards > .card:nth-child(n + 3) {
+ display: none;
+ }
+}
+
+@container (max-width: 405px) {
+ .recommendCards {
+ justify-content: flex-start;
+ overflow-x: auto;
+ scroll-snap-type: x proximity;
+ scrollbar-width: none;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .recommendCards::-webkit-scrollbar {
+ display: none;
+ }
+
+ .recommendCards > .card:nth-child(n) {
+ display: flex;
+ flex: 0 0 161px;
+ }
+}
+
+.updatesArt {
+ /* Keep the 400×200 art at design size on PC so shrinking the frame
+ clips overflow on the right instead of scaling the bitmap. */
+ width: 400px;
+ max-width: 400px;
+ flex-shrink: 0;
+ object-position: left;
+}
+
+/* Desktop: crop from the right so left-side artwork stays visible when the
+ 6:1 frame is narrower than the image. */
+.imageSlide :is(picture, img) {
+ object-position: left;
+}
+
+/* Desktop and mobile: keep the green label on one line. The title wraps. */
+.blogTag {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.blogTitle {
+ overflow-wrap: break-word;
+ white-space: normal;
+}
+
+.updatesDescription {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .contentTrack {
+ transition-duration: 0ms;
+ }
+}
+
+@media (max-width: 879px) {
+ :global([data-marketplace-standalone]) .section {
+ padding-right: 20px;
+ padding-left: 20px;
+ }
+
+ :global([data-marketplace-standalone]) .wrapper {
+ padding-bottom: 0;
+ }
+
+ :global([data-marketplace-standalone]) .copy {
+ width: 100%;
+ height: 160px;
+ }
+
+ :global([data-marketplace-standalone]) .navigation {
+ position: static;
+ top: auto;
+ width: 100%;
+ }
+
+ /* Recommend mobile: 96px app icons (Figma 1026:24938), not desktop cards. */
+ :global([data-marketplace-standalone]) .recommendBackdrop {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .recommendCards {
+ justify-content: center;
+ gap: 20px;
+ overflow: hidden;
+ padding: 36px 12px;
+ }
+
+ :global([data-marketplace-standalone]) .recommendCards > .card {
+ display: flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ width: 96px;
+ min-width: 96px;
+ max-width: 96px;
+ height: 96px;
+ padding: 0;
+ overflow: visible;
+ background: transparent;
+ border-radius: 20px;
+ box-shadow: none;
+ }
+
+ :global([data-marketplace-standalone]) .recommendCards > .card:nth-child(-n + 3) {
+ display: flex;
+ }
+
+ :global([data-marketplace-standalone]) .recommendCards > .card:nth-child(n + 4) {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .cardIcon {
+ width: 96px;
+ height: 96px;
+ border-color: var(--color-effects-icon-border);
+ border-radius: 20px;
+ box-shadow:
+ 0 0.5px 5px 0 var(--color-shadow-shadow-4),
+ 0 0.5px 2px -0.5px var(--color-shadow-shadow-4);
+ backdrop-filter: blur(5px);
+ }
+
+ :global([data-marketplace-standalone]) .cardMeta {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .copyDescription {
+ font-size: 15px;
+ letter-spacing: -0.075px;
+ }
+
+ :global([data-marketplace-standalone]) .updatesArt {
+ width: 100%;
+ max-width: none;
+ height: 197px;
+ }
+
+ :global([data-marketplace-standalone]) .carouselRoot {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ height: auto;
+ border-radius: 0;
+ }
+
+ :global([data-marketplace-standalone]) .slideViewport {
+ height: auto;
+ touch-action: pan-y pinch-zoom;
+ }
+
+ :global([data-marketplace-standalone]) .contentTrack {
+ height: auto;
+ align-items: flex-start;
+ }
+
+ :global([data-marketplace-standalone]) .slide {
+ height: auto;
+ }
+
+ /* Flex rows size to the tallest item. Collapse hidden slides so image
+ banners do not inherit the stacked blog/recommend height. */
+ :global([data-marketplace-standalone]) .slideInactive {
+ height: 0;
+ overflow: hidden;
+ }
+
+ :global([data-marketplace-standalone]) .stackedSlide {
+ display: flex;
+ flex-direction: column-reverse;
+ height: auto;
+ }
+
+ :global([data-marketplace-standalone]) .stackedVisual {
+ flex: none;
+ width: 100%;
+ height: 197px;
+ border-radius: 16px;
+ }
+
+ /* Recommend mobile: already-tinted crop, not the desktop image + mix-blend. */
+ :global([data-marketplace-standalone]) .recommendVisual {
+ border-radius: 12px;
+ background-color: var(--color-text-accent);
+ background-image: url('./assets/recommend-mobile-backdrop.webp');
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: cover;
+ }
+
+ :global([data-marketplace-standalone]) .stackedCopy {
+ flex: none;
+ height: auto;
+ min-height: 160px;
+ padding: 20px;
+ overflow: hidden;
+ }
+
+ :global([data-marketplace-standalone]) .stackedCopyMeta {
+ flex: none;
+ gap: 2px;
+ }
+
+ :global([data-marketplace-standalone]) .blogSubtitle {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ :global([data-marketplace-standalone]) .updatesDescription {
+ flex-shrink: 0;
+ width: 100%;
+ min-width: 0;
+ height: 40px;
+ }
+
+ /* Event/ad mobile: show the 800×721 poster whole (ops banner-meta), not
+ cover-cropped into the stacked 357px blog/recommend frame. */
+ :global([data-marketplace-standalone]) .imageSlide {
+ height: auto;
+ aspect-ratio: 800 / 721;
+ }
+
+ :global([data-marketplace-standalone]) .imageSlide :is(picture, img) {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ object-position: left;
+ }
+
+ :global([data-marketplace-standalone]) .readMoreDesktop {
+ display: none;
+ }
+}
+
+@media (min-width: 880px) {
+ /* Event/ad: keep the 6:1 bitmap at least 1200px wide so a narrower
+ overflow-hidden frame clips the right, not the left. */
+ .imageSlide img {
+ min-width: 1200px;
+ max-width: none;
+ }
+}
+
+@media (min-width: 1232px) {
+ .marketplaceCopy {
+ width: 443px;
+ }
+}
+
+@media (min-width: 1260px) {
+ .embeddedCopy {
+ width: 431px;
+ }
+}
diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx
new file mode 100644
index 00000000000..b67c76f6a3d
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/home-trending.tsx
@@ -0,0 +1,397 @@
+'use client'
+
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type {
+ MouseEvent as ReactMouseEvent,
+ PointerEvent as ReactPointerEvent,
+ TransitionEvent,
+} from 'react'
+import type { MarketplaceBannerPage } from './banners'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useTranslation } from '#i18n'
+import { trackEvent } from '@/app/components/base/amplitude'
+import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track'
+import TrendingNavigation from './home-trending-navigation'
+import { HomeBannerSlide } from './home-trending-slides'
+import styles from './home-trending.module.css'
+import { useBannerViewability } from './use-banner-viewability'
+
+type LoopPhase = 'idle' | 'resetting' | 'wrapping'
+type GestureAxis = 'horizontal' | 'pending' | 'vertical'
+
+type SwipeGesture = {
+ axis: GestureAxis
+ pointerId: number
+ selectedIndex: number
+ startX: number
+ startY: number
+}
+
+const MOBILE_VIEWPORT_QUERY = '(max-width: 879px)'
+const GESTURE_AXIS_THRESHOLD = 8
+const MIN_SWIPE_THRESHOLD = 40
+const MAX_SWIPE_THRESHOLD = 64
+
+function TrackedBannerSlide({
+ banner,
+ isActive,
+ isDragging,
+ isMarketplacePlatform,
+ page,
+}: {
+ banner: PluginBanner
+ isActive: boolean
+ isDragging: boolean
+ isMarketplacePlatform: boolean
+ page: MarketplaceBannerPage
+}) {
+ const slideRef = useRef(null)
+
+ useBannerViewability(
+ slideRef,
+ () => {
+ const properties = {
+ banner_id: banner.id,
+ sort: banner.sort,
+ page,
+ language: banner.language,
+ style_type: banner.style_type,
+ }
+ trackEvent('marketplace_banner_impression', properties)
+ trackMarketplaceSiteEvent('marketplace_banner_impression', properties)
+ },
+ isActive,
+ )
+
+ return (
+
+
+
+ )
+}
+
+function HomeTrending({
+ banners,
+ isMarketplacePlatform,
+ page,
+}: {
+ banners: PluginBanner[]
+ isMarketplacePlatform: boolean
+ page: MarketplaceBannerPage
+}) {
+ const { t } = useTranslation('plugin')
+ const carouselRootRef = useRef(null)
+ const swipeGestureRef = useRef(null)
+ const suppressClickRef = useRef(false)
+ const suppressClickTimerRef = useRef(null)
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const [trackIndex, setTrackIndex] = useState(0)
+ const [loopPhase, setLoopPhase] = useState('idle')
+ const [dragOffset, setDragOffset] = useState(0)
+ const [isDragging, setIsDragging] = useState(false)
+ const [isGestureActive, setIsGestureActive] = useState(false)
+ const [isRotationPaused, setIsRotationPaused] = useState(false)
+ const selectSlide = useCallback((index: number) => {
+ setLoopPhase('idle')
+ setTrackIndex(index)
+ setSelectedIndex(index)
+ }, [])
+ const selectNextSlide = useCallback(() => {
+ if (selectedIndex < banners.length - 1) {
+ const nextIndex = selectedIndex + 1
+ setTrackIndex(nextIndex)
+ setSelectedIndex(nextIndex)
+ return
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ setTrackIndex(0)
+ setSelectedIndex(0)
+ return
+ }
+
+ // Move forwards to a visual clone of the first slide. Once that
+ // transition completes, the track can snap back to the real first slide.
+ setLoopPhase('wrapping')
+ setTrackIndex(banners.length)
+ }, [banners.length, selectedIndex])
+
+ const handleTrackTransitionEnd = useCallback(
+ (event: TransitionEvent) => {
+ if (loopPhase !== 'wrapping' || event.target !== event.currentTarget) return
+
+ setLoopPhase('resetting')
+ setTrackIndex(0)
+ setSelectedIndex(0)
+ },
+ [loopPhase],
+ )
+
+ useEffect(() => {
+ if (loopPhase !== 'resetting') return
+
+ let settled = false
+ const settle = () => {
+ if (settled) return
+ settled = true
+ setLoopPhase('idle')
+ }
+
+ const frame = window.requestAnimationFrame(settle)
+ const timeout = window.setTimeout(settle, 50)
+ return () => {
+ window.cancelAnimationFrame(frame)
+ window.clearTimeout(timeout)
+ }
+ }, [loopPhase])
+
+ useEffect(
+ () => () => {
+ if (suppressClickTimerRef.current !== null) window.clearTimeout(suppressClickTimerRef.current)
+ },
+ [],
+ )
+
+ const canStartSwipe = useCallback(
+ (event: ReactPointerEvent) =>
+ isMarketplacePlatform &&
+ banners.length > 1 &&
+ loopPhase === 'idle' &&
+ event.isPrimary &&
+ event.pointerType === 'touch' &&
+ window.matchMedia(MOBILE_VIEWPORT_QUERY).matches,
+ [banners.length, isMarketplacePlatform, loopPhase],
+ )
+
+ const handlePointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (!canStartSwipe(event)) return
+
+ if (suppressClickTimerRef.current !== null) {
+ window.clearTimeout(suppressClickTimerRef.current)
+ suppressClickTimerRef.current = null
+ }
+ suppressClickRef.current = false
+ swipeGestureRef.current = {
+ axis: 'pending',
+ pointerId: event.pointerId,
+ selectedIndex,
+ startX: event.clientX,
+ startY: event.clientY,
+ }
+ setIsGestureActive(true)
+ },
+ [canStartSwipe, selectedIndex],
+ )
+
+ const handlePointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const gesture = swipeGestureRef.current
+ if (!gesture || gesture.pointerId !== event.pointerId) return
+
+ const deltaX = event.clientX - gesture.startX
+ const deltaY = event.clientY - gesture.startY
+
+ if (gesture.axis === 'pending') {
+ if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < GESTURE_AXIS_THRESHOLD) return
+
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
+ gesture.axis = 'vertical'
+ setIsGestureActive(false)
+ return
+ }
+
+ gesture.axis = 'horizontal'
+ setIsDragging(true)
+ try {
+ event.currentTarget.setPointerCapture(event.pointerId)
+ } catch {
+ // Touch pointers are implicitly captured; explicit capture is only a
+ // safeguard for browsers that retarget during a horizontal drag.
+ }
+ }
+
+ if (gesture.axis !== 'horizontal') return
+
+ const viewportWidth = event.currentTarget.getBoundingClientRect().width
+ const boundedOffset = Math.max(-viewportWidth, Math.min(viewportWidth, deltaX))
+ const isPastStart = gesture.selectedIndex === 0 && boundedOffset > 0
+ const isPastEnd = gesture.selectedIndex === banners.length - 1 && boundedOffset < 0
+ setDragOffset(isPastStart || isPastEnd ? boundedOffset * 0.35 : boundedOffset)
+ },
+ [banners.length],
+ )
+
+ const finishSwipe = useCallback(
+ (event: ReactPointerEvent, wasCanceled = false) => {
+ const gesture = swipeGestureRef.current
+ if (!gesture || gesture.pointerId !== event.pointerId) return
+
+ const deltaX = event.clientX - gesture.startX
+ const wasHorizontal = gesture.axis === 'horizontal'
+ swipeGestureRef.current = null
+ setDragOffset(0)
+ setIsDragging(false)
+ setIsGestureActive(false)
+
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId))
+ event.currentTarget.releasePointerCapture(event.pointerId)
+
+ if (!wasHorizontal) return
+
+ // Once the gesture locks to the horizontal axis, suppress the browser's
+ // trailing click even if the finger returns near its starting point.
+ suppressClickRef.current = true
+ suppressClickTimerRef.current = window.setTimeout(() => {
+ suppressClickRef.current = false
+ suppressClickTimerRef.current = null
+ }, 0)
+
+ if (wasCanceled) return
+
+ const swipeThreshold = Math.min(
+ MAX_SWIPE_THRESHOLD,
+ Math.max(MIN_SWIPE_THRESHOLD, event.currentTarget.getBoundingClientRect().width * 0.12),
+ )
+ if (Math.abs(deltaX) < swipeThreshold) return
+
+ if (deltaX < 0 && gesture.selectedIndex < banners.length - 1)
+ selectSlide(gesture.selectedIndex + 1)
+ else if (deltaX > 0 && gesture.selectedIndex > 0) selectSlide(gesture.selectedIndex - 1)
+ },
+ [banners.length, selectSlide],
+ )
+
+ const handleClickCapture = useCallback((event: ReactMouseEvent) => {
+ if (!suppressClickRef.current) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ suppressClickRef.current = false
+ if (suppressClickTimerRef.current !== null) {
+ window.clearTimeout(suppressClickTimerRef.current)
+ suppressClickTimerRef.current = null
+ }
+ }, [])
+
+ if (banners.length === 0) return null
+
+ return (
+ $['marketplace.home.trendingTitle'])}
+ className={cn(
+ 'shrink-0 bg-background-default pb-6',
+ isMarketplacePlatform ? 'px-4 min-[1232px]:px-0' : 'px-4 md:px-9',
+ isMarketplacePlatform && styles.section,
+ )}
+ >
+
+
$['marketplace.home.trendingTitle'])}
+ className={cn(
+ 'relative h-[200px] w-full rounded-2xl',
+ isMarketplacePlatform && styles.carouselRoot,
+ )}
+ data-home-trending-carousel-root
+ >
+
finishSwipe(event, true)}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={finishSwipe}
+ >
+
+ {banners.map((banner, index) => (
+
+ ))}
+ {loopPhase !== 'idle' && banners[0] && (
+
+
+
+ )}
+
+
+ {/* A single banner has nothing to rotate through, so skip the
+ pagination/autoplay controls entirely. */}
+ {banners.length > 1 && (
+
+ )}
+
+
+
+ )
+}
+
+export default HomeTrending
diff --git a/web/app/components/plugins/marketplace/home/index.tsx b/web/app/components/plugins/marketplace/home/index.tsx
new file mode 100644
index 00000000000..c555b94e66d
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/index.tsx
@@ -0,0 +1,82 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type { ActivePluginType } from '../constants'
+import type { HomeCatalogTabLabels } from './home-catalog-tabs'
+import ListWrapper from '../list/list-wrapper'
+import CatalogTagsFilter from './catalog-tags-filter'
+import HomeCatalogNavigation from './home-catalog-navigation'
+import HomeCatalogTabs from './home-catalog-tabs'
+import HomeHeader from './home-header'
+import HomeHero from './home-hero'
+import HomeSearch from './home-search'
+import { HomeShell } from './home-shell'
+import styles from './home-sticky.module.css'
+
+type MarketplaceHomeProps = {
+ actions?: React.ReactNode
+ activePluginType?: ActivePluginType
+ banners: PluginBanner[]
+ catalogCategories?: React.ReactNode
+ catalogLabels?: HomeCatalogTabLabels
+ isMarketplacePlatform: boolean
+ language?: string
+ linkToMarketplaceDetail: boolean
+ search?: React.ReactNode
+ showInstallButton: boolean
+}
+
+const MarketplaceHome = ({
+ actions,
+ activePluginType,
+ banners,
+ catalogCategories,
+ catalogLabels,
+ isMarketplacePlatform,
+ language,
+ linkToMarketplaceDetail,
+ search,
+ showInstallButton,
+}: MarketplaceHomeProps) => {
+ return (
+
+ }
+ hero={ }
+ search={{search} }
+ navigation={
+ }
+ isMarketplacePlatform={isMarketplacePlatform}
+ catalogTabs={
+
+ }
+ />
+ }
+ >
+
+
+
+
+ )
+}
+
+export default MarketplaceHome
diff --git a/web/app/components/plugins/marketplace/home/marketplace-href.ts b/web/app/components/plugins/marketplace/home/marketplace-href.ts
new file mode 100644
index 00000000000..91b992209b8
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/marketplace-href.ts
@@ -0,0 +1,15 @@
+export function sanitizeMarketplaceHref(value: string): string | null {
+ const trimmed = value.trim()
+ if (!trimmed) return null
+ if (trimmed.startsWith('/') && !trimmed.startsWith('//') && !trimmed.includes('\\')) {
+ return trimmed
+ }
+
+ try {
+ const url = new URL(trimmed)
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
+ return url.toString()
+ } catch {
+ return null
+ }
+}
diff --git a/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx
new file mode 100644
index 00000000000..32a7b9eec93
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx
@@ -0,0 +1,84 @@
+'use client'
+
+import { cn } from '@langgenius/dify-ui/cn'
+import { useDebounce } from 'ahooks'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useRouter } from '@/next/navigation'
+import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track'
+
+type MarketplaceLiveSearchProps = {
+ action: string
+ className?: string
+ language?: string
+ placeholder: string
+ preserveParams?: {
+ tags?: string[]
+ languages?: string[]
+ }
+ query: string
+}
+
+export default function MarketplaceLiveSearch({
+ action,
+ className,
+ language,
+ placeholder,
+ preserveParams,
+ query,
+}: MarketplaceLiveSearchProps) {
+ const router = useRouter()
+ const [value, setValue] = useState(query)
+ const debouncedSearch = useDebounce(value.trim(), { wait: 300 })
+ const routedSearchRef = useRef(query.trim())
+ const navigate = useCallback(
+ (nextQuery: string) => {
+ if (nextQuery) markMarketplaceSiteSearch(nextQuery)
+ const searchParams = new URLSearchParams()
+ if (nextQuery) searchParams.set('q', nextQuery)
+ if (language) searchParams.set('language', language)
+ if (preserveParams?.tags?.length) searchParams.set('tags', preserveParams.tags.join(','))
+ if (preserveParams?.languages?.length)
+ searchParams.set('languages', preserveParams.languages.join(','))
+ const queryString = searchParams.toString()
+
+ router.replace(`${action}${queryString ? `?${queryString}` : ''}`, { scroll: false })
+ },
+ [action, language, preserveParams, router],
+ )
+
+ useEffect(() => {
+ if (debouncedSearch === routedSearchRef.current) return
+
+ routedSearchRef.current = debouncedSearch
+ navigate(debouncedSearch)
+ }, [debouncedSearch, navigate])
+
+ return (
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx b/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx
new file mode 100644
index 00000000000..86e72000551
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/marketplace-plugin-search.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { useSearchPluginText } from '../atoms'
+
+type MarketplacePluginSearchProps = {
+ placeholder: string
+}
+
+export default function MarketplacePluginSearch({ placeholder }: MarketplacePluginSearchProps) {
+ const [value, setValue] = useSearchPluginText()
+
+ return (
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx
new file mode 100644
index 00000000000..8303ef0bfb8
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx
@@ -0,0 +1,437 @@
+'use client'
+
+import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
+import {
+ Autocomplete,
+ AutocompleteClear,
+ AutocompleteCollection,
+ AutocompleteEmpty,
+ AutocompleteGroup,
+ AutocompleteGroupLabel,
+ AutocompleteInput,
+ AutocompleteInputGroup,
+ AutocompleteItem,
+ AutocompleteItemText,
+ AutocompleteList,
+ AutocompletePortal,
+ AutocompletePositioner,
+ AutocompleteSeparator,
+ AutocompleteStatus,
+ useAutocompleteFilteredItems,
+} from '@langgenius/dify-ui/autocomplete'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useQuery } from '@tanstack/react-query'
+import { useDebounce } from 'ahooks'
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from '#i18n'
+import { MARKETPLACE_API_PREFIX } from '@/config'
+import { renderI18nObject } from '@/i18n-config/index'
+import { marketplaceQuery } from '@/service/client'
+import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track'
+import { getPluginIconInMarketplace } from '../utils'
+
+export type MarketplaceSearchScope = 'all' | 'plugins' | 'templates'
+
+export type MarketplaceSearchSelection =
+ | { kind: 'plugin'; plugin: MarketplacePlugin }
+ | { kind: 'template'; template: MarketplaceTemplate }
+
+type MarketplaceSuggestion = {
+ description: string
+ iconUrl?: string
+ id: string
+ kind: 'plugin' | 'template'
+ label: string
+ meta: string
+ selection: MarketplaceSearchSelection
+}
+
+type MarketplaceSuggestionGroup = {
+ id: MarketplaceSuggestion['kind']
+ items: MarketplaceSuggestion[]
+ label: string
+}
+
+type MarketplaceSearchAutocompleteProps = {
+ category?: string
+ inputName?: string
+ locale: string
+ onSuggestionSelect?: (selection: MarketplaceSearchSelection) => void
+ onValueChange: (value: string) => void
+ placeholder: string
+ scope: MarketplaceSearchScope
+ value: string
+}
+
+const getPluginText = (
+ value: MarketplacePlugin['brief'] | MarketplacePlugin['label'],
+ locale: string,
+) => {
+ if (typeof value === 'string') return value
+ return renderI18nObject((value ?? {}) as Record, locale)
+}
+
+const toTemplateSuggestion = (template: MarketplaceTemplate): MarketplaceSuggestion => ({
+ description: template.overview,
+ iconUrl: template.icon_file_key
+ ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`
+ : undefined,
+ id: `template:${template.id}`,
+ kind: 'template',
+ label: template.template_name,
+ meta: template.publisher_handle || template.publisher_unique_handle || '',
+ selection: { kind: 'template', template },
+})
+
+const toPluginSuggestion = (plugin: MarketplacePlugin, locale: string): MarketplaceSuggestion => ({
+ description: getPluginText(plugin.brief, locale),
+ iconUrl: getPluginIconInMarketplace(plugin),
+ id: `plugin:${plugin.org}/${plugin.name}`,
+ kind: 'plugin',
+ label: getPluginText(plugin.label, locale) || plugin.name,
+ meta: plugin.org,
+ selection: { kind: 'plugin', plugin },
+})
+
+type MarketplaceSuggestionListProps = {
+ onSuggestionSelect?: (selection: MarketplaceSearchSelection) => void
+ onValueChange: (value: string) => void
+ setIsOpen: (isOpen: boolean) => void
+}
+
+function MarketplaceSuggestionList({
+ onSuggestionSelect,
+ onValueChange,
+ setIsOpen,
+}: MarketplaceSuggestionListProps) {
+ const groups = useAutocompleteFilteredItems()
+
+ return (
+
+ {groups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && }
+
+ {group.label}
+
+ >
+ {(item) => (
+ {
+ onSuggestionSelect(item.selection)
+ queueMicrotask(() => {
+ onValueChange('')
+ setIsOpen(false)
+ })
+ }
+ : undefined
+ }
+ >
+
+ {item.iconUrl ? (
+ {
+ currentTarget.style.display = 'none'
+ }}
+ />
+ ) : (
+
+ )}
+
+
+
+ {item.label}
+
+ {!!item.description && (
+
+ {item.description}
+
+ )}
+ {!!item.meta && (
+
+ {item.meta}
+
+ )}
+
+
+ )}
+
+
+ ))}
+
+ )
+}
+
+export function MarketplaceSearchAutocomplete({
+ category = 'all',
+ inputName,
+ locale,
+ onSuggestionSelect,
+ onValueChange,
+ placeholder,
+ scope,
+ value,
+}: MarketplaceSearchAutocompleteProps) {
+ const { t } = useTranslation()
+ const [isOpen, setIsOpen] = useState(false)
+ const searchRootRef = useRef(null)
+ const resultsPanelRef = useRef(null)
+ const debouncedSearch = useDebounce(value.trim(), { wait: 300 })
+ const hasQuery = Boolean(debouncedSearch)
+ const searchesPlugins = scope === 'all' || scope === 'plugins'
+ const searchesTemplates = scope === 'all' || scope === 'templates'
+ const isBundleSearch = category === 'bundle'
+ const pluginQuery = useQuery({
+ ...marketplaceQuery.searchAdvanced.queryOptions({
+ input: {
+ params: { kind: isBundleSearch ? 'bundles' : 'plugins' },
+ body: {
+ page: 1,
+ page_size: 5,
+ query: debouncedSearch,
+ sort_by: 'install_count',
+ sort_order: 'DESC',
+ category: category !== 'all' && !isBundleSearch ? category : '',
+ },
+ },
+ retry: false,
+ }),
+ // No placeholderData here: showing the previous term's suggestions would
+ // leave stale items keyboard-selectable while the new request is pending.
+ enabled: hasQuery && searchesPlugins,
+ staleTime: 60_000,
+ })
+ const templateQuery = useQuery({
+ ...marketplaceQuery.templateSearch.queryOptions({
+ input: {
+ body: {
+ page: 1,
+ page_size: 5,
+ query: debouncedSearch,
+ sort_by: 'usage_count',
+ sort_order: 'DESC',
+ ...(category !== 'all' ? { categories: [category] } : {}),
+ },
+ },
+ retry: false,
+ }),
+ enabled: hasQuery && searchesTemplates,
+ staleTime: 60_000,
+ })
+ // 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 suggestionGroups: MarketplaceSuggestionGroup[] = [
+ ...(templateSuggestions.length
+ ? [
+ {
+ id: 'template' as const,
+ items: templateSuggestions,
+ label: t(($) => $['marketplace.home.templates'], { ns: 'plugin' }),
+ },
+ ]
+ : []),
+ ...(pluginSuggestions.length
+ ? [
+ {
+ id: 'plugin' as const,
+ items: pluginSuggestions,
+ label: t(($) => $['marketplace.home.plugins'], { ns: 'plugin' }),
+ },
+ ]
+ : []),
+ ]
+ const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching
+ // Keep open tied to the typing session so outside-press can dismiss during
+ // debounce/fetch. Pending, empty, and error copy live inside the popup.
+ const hasTypedQuery = Boolean(value.trim())
+ const isPopupOpen = isOpen && hasTypedQuery
+ // A failed request must not read as "nothing matched"; when every source in
+ // scope errored and nothing is displayable, surface a load failure instead.
+ const hasLoadError =
+ !isDebouncing &&
+ suggestions.length === 0 &&
+ ((searchesPlugins && pluginQuery.isError) || (searchesTemplates && templateQuery.isError))
+ const emptyText = hasLoadError
+ ? t(($) => $['marketplace.loadError'], { ns: 'plugin' })
+ : scope === 'templates'
+ ? t(($) => $['newApp.noTemplateFound'], { ns: 'app' })
+ : t(($) => $['marketplace.noPluginFound'], { ns: 'plugin' })
+
+ useEffect(() => {
+ if (!isPopupOpen) return
+
+ const handleOutsidePress = (event: MouseEvent) => {
+ const target = event.target
+ if (!(target instanceof Node)) return
+ if (searchRootRef.current?.contains(target) || resultsPanelRef.current?.contains(target))
+ return
+ setIsOpen(false)
+ }
+
+ document.addEventListener('click', handleOutsidePress)
+ return () => document.removeEventListener('click', handleOutsidePress)
+ }, [isPopupOpen])
+
+ return (
+
+
item.label}
+ items={suggestionGroups}
+ mode="list"
+ name={inputName}
+ onOpenChange={setIsOpen}
+ onValueChange={(nextValue) => {
+ onValueChange(nextValue)
+ setIsOpen(Boolean(nextValue.trim()))
+ }}
+ open={isPopupOpen}
+ openOnInputClick
+ submitOnItemClick={Boolean(inputName)}
+ value={value}
+ >
+
+
+
+ {!!value && (
+ $.clearSearch, { ns: 'plugin', label: placeholder })}
+ size="large"
+ />
+ )}
+
+
+
+
+
+
+ {!isSearching && suggestions.length === 0 ? emptyText : null}
+
+
+ {isSearching ? t(($) => $.loading, { ns: 'common' }) : null}
+
+ {Boolean(inputName) && suggestions.length > 0 && !isSearching && (
+
+ {
+ const form = searchRootRef.current?.closest('form')
+ if (form instanceof HTMLFormElement) form.requestSubmit()
+ }}
+ >
+
+ {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
+
+
+ Enter
+
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+type MarketplaceSearchFormProps = {
+ action: string
+ category?: string
+ className?: string
+ language?: string
+ locale: string
+ placeholder: string
+ query: string
+ scope: MarketplaceSearchScope
+}
+
+export function MarketplaceSearchForm({
+ action,
+ category,
+ className,
+ language,
+ locale,
+ placeholder,
+ query,
+ scope,
+}: MarketplaceSearchFormProps) {
+ const [value, setValue] = useState(query)
+
+ return (
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts
new file mode 100644
index 00000000000..cd4e3167833
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts
@@ -0,0 +1,99 @@
+const LARGE_SCROLL_JUMP_PX = 16
+
+/**
+ * Sticky search sits in document flow below the hero, then visually pins in the
+ * header. Focusing or typing in that input makes Chromium scroll the layout box
+ * into view, which unpins the search and looks like the page rolling down.
+ * Remember the scroll position and snap back when a focused search input causes
+ * a large jump.
+ */
+export function preserveStickySearchScroll(searchRoot: HTMLElement, container: HTMLElement) {
+ let stableScrollTop = container.scrollTop
+ let suppressing = false
+
+ const remember = () => {
+ if (!suppressing) stableScrollTop = container.scrollTop
+ }
+
+ const restore = () => {
+ if (container.scrollTop === stableScrollTop) return
+ suppressing = true
+ container.scrollTop = stableScrollTop
+ requestAnimationFrame(() => {
+ suppressing = false
+ })
+ }
+
+ const onScroll = () => {
+ if (suppressing) return
+ if (searchRoot.contains(document.activeElement)) {
+ if (Math.abs(container.scrollTop - stableScrollTop) > LARGE_SCROLL_JUMP_PX) {
+ restore()
+ return
+ }
+ }
+ remember()
+ }
+
+ const onPointerDown = (event: PointerEvent) => {
+ if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return
+ remember()
+ suppressing = true
+ requestAnimationFrame(() => {
+ restore()
+ suppressing = false
+ })
+ }
+
+ const onFocusIn = (event: FocusEvent) => {
+ if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return
+ restore()
+ requestAnimationFrame(restore)
+ }
+
+ const onInput = (event: Event) => {
+ if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return
+ restore()
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Tab') return
+ remember()
+ suppressing = true
+ requestAnimationFrame(() => {
+ suppressing = false
+ })
+ }
+
+ const patchInputFocus = (input: HTMLInputElement) => {
+ if (input.dataset.marketplaceSearchFocus === 'patched') return
+ input.dataset.marketplaceSearchFocus = 'patched'
+ const nativeFocus = input.focus.bind(input)
+ input.focus = (options) => nativeFocus({ ...options, preventScroll: true })
+ }
+
+ searchRoot.querySelectorAll('input').forEach((input) => {
+ patchInputFocus(input)
+ })
+ const observer = new MutationObserver(() => {
+ searchRoot.querySelectorAll('input').forEach((input) => {
+ patchInputFocus(input)
+ })
+ })
+ observer.observe(searchRoot, { childList: true, subtree: true })
+
+ container.addEventListener('scroll', onScroll, { passive: true })
+ searchRoot.addEventListener('pointerdown', onPointerDown, true)
+ searchRoot.addEventListener('focusin', onFocusIn)
+ searchRoot.addEventListener('input', onInput, true)
+ window.addEventListener('keydown', onKeyDown, true)
+
+ return () => {
+ observer.disconnect()
+ container.removeEventListener('scroll', onScroll)
+ searchRoot.removeEventListener('pointerdown', onPointerDown, true)
+ searchRoot.removeEventListener('focusin', onFocusIn)
+ searchRoot.removeEventListener('input', onInput, true)
+ window.removeEventListener('keydown', onKeyDown, true)
+ }
+}
diff --git a/web/app/components/plugins/marketplace/home/use-banner-viewability.ts b/web/app/components/plugins/marketplace/home/use-banner-viewability.ts
new file mode 100644
index 00000000000..4a2fdc5e356
--- /dev/null
+++ b/web/app/components/plugins/marketplace/home/use-banner-viewability.ts
@@ -0,0 +1,58 @@
+import type { RefObject } from 'react'
+import { useEffect, useRef } from 'react'
+
+const BANNER_VIEWABILITY_THRESHOLD = 0.5
+const BANNER_VIEWABILITY_DWELL_MS = 1000
+
+export function useBannerViewability(
+ targetRef: RefObject,
+ onImpression: () => void,
+ enabled = true,
+) {
+ const onImpressionRef = useRef(onImpression)
+ onImpressionRef.current = onImpression
+
+ useEffect(() => {
+ if (!enabled) return
+
+ const target = targetRef.current
+ if (!target || typeof IntersectionObserver === 'undefined') return
+
+ let dwellTimer: ReturnType | undefined
+ let didImpress = false
+
+ const clearDwell = () => {
+ if (dwellTimer === undefined) return
+ clearTimeout(dwellTimer)
+ dwellTimer = undefined
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ const isViewable = (entry?.intersectionRatio ?? 0) >= BANNER_VIEWABILITY_THRESHOLD
+
+ if (!isViewable) {
+ didImpress = false
+ clearDwell()
+ return
+ }
+
+ if (didImpress || dwellTimer !== undefined) return
+
+ dwellTimer = setTimeout(() => {
+ dwellTimer = undefined
+ didImpress = true
+ onImpressionRef.current()
+ }, BANNER_VIEWABILITY_DWELL_MS)
+ },
+ { threshold: BANNER_VIEWABILITY_THRESHOLD },
+ )
+
+ observer.observe(target)
+
+ return () => {
+ clearDwell()
+ observer.disconnect()
+ }
+ }, [enabled, targetRef])
+}
diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts
index 455ae83dd92..df0a0ea8a11 100644
--- a/web/app/components/plugins/marketplace/hooks.ts
+++ b/web/app/components/plugins/marketplace/hooks.ts
@@ -5,11 +5,11 @@ import type {
PluginsSearchParams,
} from '@dify/contracts/marketplace'
import type { Plugin } from '../types'
-import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { postMarketplace } from '@/service/base'
-import { SCROLL_BOTTOM_THRESHOLD } from './constants'
+import { MARKETPLACE_CONTAINER_ID, SCROLL_BOTTOM_THRESHOLD } from './constants'
import {
getFormattedPlugin,
getMarketplaceCollectionsAndPlugins,
@@ -81,7 +81,6 @@ export const useMarketplacePluginsByCollectionId = (
* @deprecated Use useMarketplacePlugins from query.ts instead
*/
export const useMarketplacePlugins = (enabled = true) => {
- const queryClient = useQueryClient()
const [queryParams, setQueryParams] = useState()
const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => {
@@ -156,12 +155,9 @@ export const useMarketplacePlugins = (enabled = true) => {
retry: false,
})
- const resetPlugins = useCallback(() => {
+ const resetQueryParams = useCallback(() => {
setQueryParams(undefined)
- queryClient.removeQueries({
- queryKey: ['marketplacePlugins'],
- })
- }, [queryClient])
+ }, [])
const handleUpdatePlugins = useCallback(
(pluginsSearchParams: PluginsSearchParams) => {
@@ -195,7 +191,7 @@ export const useMarketplacePlugins = (enabled = true) => {
return {
plugins,
total,
- resetPlugins,
+ resetQueryParams,
queryPlugins: handleUpdatePlugins,
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced,
@@ -211,24 +207,40 @@ export const useMarketplacePlugins = (enabled = true) => {
export const useMarketplaceContainerScroll = (
callback: () => void,
- scrollContainerId = 'marketplace-container',
+ scrollContainerId = MARKETPLACE_CONTAINER_ID,
) => {
- const handleScroll = useCallback(
- (e: Event) => {
- const target = e.target as HTMLDivElement
- const { scrollTop, scrollHeight, clientHeight } = target
- if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0)
- callback()
- },
- [callback],
- )
+ // The callback closes over isFetching, so its identity flips on every fetch
+ // boundary. Re-subscribing on each flip dropped the scroll events in that
+ // window; a ref keeps one listener for the container's lifetime.
+ const callbackRef = useRef(callback)
+ callbackRef.current = callback
useEffect(() => {
const container = document.getElementById(scrollContainerId)
- if (container) container.addEventListener('scroll', handleScroll)
+ if (!container) return
+
+ // scrollTop/scrollHeight/clientHeight force a synchronous layout, so
+ // measuring per scroll event janks the scroll. Worse, every threshold hit
+ // calls fetchNextPage, which defaults to cancelRefetch: true — a burst
+ // aborts and restarts the in-flight page request, and the backend counts
+ // those aborts against its search circuit breaker. One measurement per
+ // frame is both smoother and quieter on the wire.
+ let frame = 0
+ const handleScroll = () => {
+ if (frame) return
+ frame = requestAnimationFrame(() => {
+ frame = 0
+ const { scrollTop, scrollHeight, clientHeight } = container
+ if (scrollTop > 0 && scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD)
+ callbackRef.current()
+ })
+ }
+
+ container.addEventListener('scroll', handleScroll, { passive: true })
return () => {
- if (container) container.removeEventListener('scroll', handleScroll)
+ if (frame) cancelAnimationFrame(frame)
+ container.removeEventListener('scroll', handleScroll)
}
- }, [handleScroll])
+ }, [scrollContainerId])
}
diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx
index 9da59135d56..a54da0b6e29 100644
--- a/web/app/components/plugins/marketplace/hydration-server.tsx
+++ b/web/app/components/plugins/marketplace/hydration-server.tsx
@@ -5,7 +5,13 @@ import { createLoader } from 'nuqs/server'
import { getQueryClient } from '@/app/get-query-client'
import { marketplaceQuery } from '@/service/client'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
-import { marketplaceSearchParamsParsers } from './search-params'
+import { getMarketplacePluginsInfiniteQueryOptions } from './query-options'
+import {
+ getMarketplacePluginsSearchParams,
+ marketplaceSearchParamsParsers,
+ shouldSearchMarketplacePlugins,
+} from './search-params'
+import { withinServerBudget } from './server-budget'
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
@@ -17,18 +23,27 @@ async function getDehydratedState(searchParams?: Promise) {
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
const params: MarketplaceSearchParams = await loadSearchParams(searchParams)
- if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
- return
- }
-
const queryClient = getQueryClient()
- await queryClient.prefetchQuery({
- queryKey: marketplaceQuery.collections.queryKey({
- input: { query: getCollectionsParams(params.category) },
+ if (shouldSearchMarketplacePlugins(params)) {
+ await withinServerBudget(
+ queryClient.prefetchInfiniteQuery(
+ getMarketplacePluginsInfiniteQueryOptions(getMarketplacePluginsSearchParams(params)),
+ ),
+ )
+ return dehydrate(queryClient)
+ }
+
+ if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) return
+
+ await withinServerBudget(
+ queryClient.prefetchQuery({
+ queryKey: marketplaceQuery.collections.queryKey({
+ input: { query: getCollectionsParams(params.category) },
+ }),
+ queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
}),
- queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
- })
+ )
return dehydrate(queryClient)
}
diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx
index 98f1edd8de5..59cc0b5aee1 100644
--- a/web/app/components/plugins/marketplace/index.tsx
+++ b/web/app/components/plugins/marketplace/index.tsx
@@ -1,17 +1,14 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
import type { SearchParams } from 'nuqs'
-import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
-import { TanStackQueryProvider } from '@/app/query-provider'
-import Description from './description'
+import type { MarketplaceViewProps } from './view'
+import { getLocaleOnServer } from '@/i18n-config/server'
+import { fetchPluginBanners } from './home/banners'
import { HydrateQueryClient } from './hydration-server'
-import ListWrapper from './list/list-wrapper'
-import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
+import { withinServerBudget } from './server-budget'
+import { MarketplaceView } from './view'
-type MarketplaceProps = {
- showInstallButton?: boolean
- linkToMarketplaceDetail?: boolean
- pluginTypeSwitchClassName?: string
- isMarketplacePlatform?: boolean
- marketplaceNav?: React.ReactNode
+type MarketplaceProps = Omit & {
+ language?: string
/**
* Pass the search params from the request to prefetch data on the server.
*/
@@ -19,31 +16,39 @@ type MarketplaceProps = {
}
const Marketplace = async ({
- showInstallButton = false,
- linkToMarketplaceDetail = false,
- pluginTypeSwitchClassName,
- isMarketplacePlatform = false,
- marketplaceNav,
+ language,
searchParams,
+ variant = 'default',
+ ...viewProps
}: MarketplaceProps) => {
+ let trendingBanners: PluginBanner[] = []
+
+ if (variant === 'home') {
+ const locale = language ?? (await getLocaleOnServer())
+
+ // Banners are decoration on a page whose point is the catalog, so the same
+ // budget that keeps the prefetch from holding the document applies here.
+ // A late resolution just misses this render; nothing waits on it.
+ await withinServerBudget(
+ fetchPluginBanners(locale)
+ .then((banners) => {
+ trendingBanners = banners
+ })
+ .catch(() => {
+ // Keep the homepage available if Marketplace banner delivery is down.
+ }),
+ )
+ }
+
return (
-
-
-
-
- {!isMarketplacePlatform && (
-
- )}
-
-
-
-
+
+
+
)
}
diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx
index d61071aadc7..66f58412a5d 100644
--- a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx
+++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx
@@ -45,10 +45,34 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permis
useOptionalPluginInstallPermission: () => ({ canInstallPlugin: true }),
}))
+vi.mock('../../detail-dialog', () => ({
+ default: ({
+ isInstalled,
+ open,
+ onInstall,
+ onOpenChange,
+ }: {
+ isInstalled: boolean
+ open: boolean
+ onInstall: () => void
+ onOpenChange: (open: boolean) => void
+ }) =>
+ open ? (
+
+ {!isInstalled && (
+
+ install from detail
+
+ )}
+ onOpenChange(false)}>
+ close detail
+
+
+ ) : null,
+}))
+
vi.mock('../../utils', () => ({
getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`,
- getPluginLinkInMarketplace: (plugin: Plugin, params: Record) =>
- `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`,
}))
const plugin = {
@@ -91,6 +115,7 @@ describe('CardWrapper', () => {
renderCardWrapper()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ expect(document.querySelector('[data-marketplace-card="plugin-a"]')).toBeInTheDocument()
expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent')
})
@@ -107,7 +132,7 @@ describe('CardWrapper', () => {
screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }),
).toBeInTheDocument()
expect(
- screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' }),
+ screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }),
).toBeInTheDocument()
})
@@ -122,13 +147,18 @@ describe('CardWrapper', () => {
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
- it('links the detail action to the marketplace', () => {
- renderCardWrapper({ showInstallButton: true })
+ it('opens and closes marketplace detail dialog from the detail action', async () => {
+ const user = userEvent.setup()
+ renderCardWrapper({ showInstallButton: true, isInstalled: true })
- const link = screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' })
- expect(link).toHaveAttribute('href', '/marketplace/dify/plugin-a?language=en-US&theme=system')
- expect(link).toHaveAttribute('target', '_blank')
- expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }))
+ expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toHaveAttribute(
+ 'data-installed',
+ 'true',
+ )
+
+ await user.click(screen.getByRole('button', { name: 'close detail' }))
+ expect(screen.queryByRole('dialog', { name: 'marketplace detail' })).not.toBeInTheDocument()
})
it('opens and closes install modal from install action', () => {
@@ -140,4 +170,15 @@ describe('CardWrapper', () => {
fireEvent.click(screen.getByTestId('close-install-modal'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
+
+ it('opens the same install modal from the marketplace detail dialog', async () => {
+ const user = userEvent.setup()
+ renderCardWrapper({ showInstallButton: true })
+
+ await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }))
+ await user.click(screen.getByRole('button', { name: 'install from detail' }))
+
+ expect(screen.getByTestId('install-modal')).toBeInTheDocument()
+ expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument()
+ })
})
diff --git a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx
new file mode 100644
index 00000000000..1b884005871
--- /dev/null
+++ b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx
@@ -0,0 +1,371 @@
+import type { CarouselPage } from '../carousel'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import Carousel from '../carousel'
+
+const mocks = vi.hoisted(() => {
+ const listeners = new Map void>>()
+ const carouselState = {
+ scrollSnaps: [0, 1, 2, 3, 4],
+ selectedIndex: 0,
+ }
+ const api = {
+ off: vi.fn((event: string, listener: () => void) => {
+ listeners.get(event)?.delete(listener)
+ }),
+ on: vi.fn((event: string, listener: () => void) => {
+ const eventListeners = listeners.get(event) ?? new Set()
+ eventListeners.add(listener)
+ listeners.set(event, eventListeners)
+ }),
+ scrollNext: vi.fn(),
+ scrollPrev: vi.fn(),
+ scrollSnapList: vi.fn(() => carouselState.scrollSnaps),
+ scrollTo: vi.fn(),
+ selectedScrollSnap: vi.fn(() => carouselState.selectedIndex),
+ }
+ const autoplayInstances: { play: ReturnType; stop: ReturnType }[] = []
+ const autoplayOptions: Record[] = []
+
+ return {
+ api,
+ autoplayInstances,
+ autoplayOptions,
+ carouselState,
+ emit: (event: string) => listeners.get(event)?.forEach((listener) => listener()),
+ listeners,
+ }
+})
+
+vi.mock('embla-carousel-react', () => ({
+ default: () => [vi.fn(), mocks.api],
+}))
+
+vi.mock('embla-carousel-autoplay', () => ({
+ default: (options: Record) => {
+ const instance = { play: vi.fn(), stop: vi.fn() }
+ mocks.autoplayOptions.push(options)
+ mocks.autoplayInstances.push(instance)
+ return instance
+ },
+}))
+
+const pages: CarouselPage[] = Array.from({ length: 5 }, (_, index) => ({
+ id: `page-${index + 1}`,
+ content: Page content {index + 1}
,
+}))
+
+type IntersectionObserverRecord = {
+ callback: IntersectionObserverCallback
+ disconnect: ReturnType
+ observe: ReturnType
+ options?: IntersectionObserverInit
+}
+
+const intersectionObservers: IntersectionObserverRecord[] = []
+
+class MockIntersectionObserver {
+ callback: IntersectionObserverCallback
+ disconnect = vi.fn()
+ observe = vi.fn()
+ options?: IntersectionObserverInit
+ root: Element | Document | null
+ rootMargin: string
+ takeRecords = vi.fn(() => [])
+ thresholds: readonly number[]
+ unobserve = vi.fn()
+
+ constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
+ this.callback = callback
+ this.options = options
+ this.root = options?.root ?? null
+ this.rootMargin = options?.rootMargin ?? '0px'
+ this.thresholds = Array.isArray(options?.threshold)
+ ? options.threshold
+ : [options?.threshold ?? 0]
+ intersectionObservers.push(this)
+ }
+}
+
+const installIntersectionObserver = () => {
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+}
+
+const triggerIntersection = (record: IntersectionObserverRecord, intersectionRatio: number) => {
+ act(() => {
+ record.callback(
+ [
+ {
+ intersectionRatio,
+ isIntersecting: intersectionRatio > 0,
+ } as IntersectionObserverEntry,
+ ],
+ record as unknown as IntersectionObserver,
+ )
+ })
+}
+
+describe('Marketplace Carousel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.listeners.clear()
+ mocks.autoplayInstances.length = 0
+ mocks.autoplayOptions.length = 0
+ mocks.carouselState.scrollSnaps = [0, 1, 2, 3, 4]
+ mocks.carouselState.selectedIndex = 0
+ intersectionObservers.length = 0
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ callback(0)
+ return 1
+ })
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'visible',
+ })
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('keeps every slide shell while mounting only the current and adjacent pages', () => {
+ const { rerender } = render( )
+
+ expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(5)
+ expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(3)
+ expect(screen.getByText('Page content 1')).toBeInTheDocument()
+ expect(screen.getByText('Page content 2')).toBeInTheDocument()
+ expect(screen.getByText('Page content 5')).toBeInTheDocument()
+ expect(screen.queryByText('Page content 3')).not.toBeInTheDocument()
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'plugin.marketplace.carousel.goToPage:{"page":4}' }),
+ )
+
+ expect(screen.getByText('Page content 3')).toBeInTheDocument()
+ expect(screen.getByText('Page content 4')).toBeInTheDocument()
+ expect(mocks.api.scrollTo).toHaveBeenCalledWith(3)
+
+ mocks.carouselState.selectedIndex = 3
+ act(() => mocks.emit('select'))
+ mocks.carouselState.selectedIndex = 0
+ act(() => mocks.emit('select'))
+
+ expect(screen.getByText('Page content 4')).toBeInTheDocument()
+
+ rerender( )
+
+ expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(3)
+ expect(screen.getByText('Page content 3')).toBeInTheDocument()
+ })
+
+ it('keeps eager consumers fully mounted and preserves loop navigation', () => {
+ render( )
+
+ expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(5)
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollPrevious' }),
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }))
+
+ expect(mocks.api.scrollPrev).toHaveBeenCalledOnce()
+ expect(mocks.api.scrollNext).toHaveBeenCalledOnce()
+ })
+
+ it('plays managed autoplay only while the carousel is visible and motion is allowed', () => {
+ installIntersectionObserver()
+ const marketplaceContainer = document.createElement('div')
+ marketplaceContainer.id = 'marketplace-container'
+ document.body.appendChild(marketplaceContainer)
+ let reducedMotion = false
+ 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(),
+ }))
+
+ const { unmount } = render(
+ ,
+ { container: marketplaceContainer },
+ )
+ const autoplay = mocks.autoplayInstances[0]!
+ const carousel = screen.getByRole('region')
+
+ expect(mocks.autoplayOptions[0]).toMatchObject({
+ playOnInit: false,
+ stopOnInteraction: false,
+ stopOnMouseEnter: false,
+ })
+ expect(intersectionObservers[0]!.options).toEqual({
+ root: marketplaceContainer,
+ threshold: 0.25,
+ })
+ expect(autoplay.stop).toHaveBeenCalled()
+
+ triggerIntersection(intersectionObservers[0]!, 0.24)
+ triggerIntersection(intersectionObservers[0]!, 0.25)
+ expect(autoplay.play).toHaveBeenCalledOnce()
+
+ fireEvent.mouseEnter(carousel)
+ expect(autoplay.stop).toHaveBeenCalled()
+
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'hidden',
+ })
+ fireEvent(document, new Event('visibilitychange'))
+ fireEvent.mouseLeave(carousel)
+ expect(autoplay.play).toHaveBeenCalledOnce()
+
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'visible',
+ })
+ fireEvent(document, new Event('visibilitychange'))
+ expect(autoplay.play).toHaveBeenCalledTimes(2)
+
+ reducedMotion = true
+ act(() => reducedMotionListener?.())
+ expect(autoplay.stop).toHaveBeenCalled()
+
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'hidden',
+ })
+ fireEvent(document, new Event('visibilitychange'))
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ value: 'visible',
+ })
+ fireEvent(document, new Event('visibilitychange'))
+ expect(autoplay.play).toHaveBeenCalledTimes(2)
+
+ reducedMotion = false
+ act(() => reducedMotionListener?.())
+ expect(autoplay.play).toHaveBeenCalledTimes(3)
+
+ triggerIntersection(intersectionObservers[0]!, 0)
+ expect(autoplay.stop).toHaveBeenCalled()
+
+ unmount()
+ marketplaceContainer.remove()
+ })
+
+ it('preserves standalone autoplay initialization', () => {
+ render( )
+
+ expect(mocks.autoplayOptions[0]).toMatchObject({
+ playOnInit: true,
+ stopOnMouseEnter: true,
+ })
+ 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( )
+ 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( )
+
+ expect(screen.getByRole('region', { name: 'Featured tools' })).toBeInTheDocument()
+
+ const slides = document.querySelectorAll('[data-carousel-page]')
+ expect(slides[0]).toHaveAttribute('aria-roledescription', 'slide')
+ expect(slides[0]).toHaveAttribute('aria-label', '1 / 5')
+ expect(slides[0]).not.toHaveAttribute('aria-hidden', 'true')
+ expect(slides[0]).not.toHaveAttribute('inert')
+ expect(slides[1]).toHaveAttribute('aria-hidden', 'true')
+ expect(slides[1]).toHaveAttribute('inert')
+
+ mocks.carouselState.selectedIndex = 3
+ act(() => mocks.emit('select'))
+
+ expect(slides[0]).toHaveAttribute('aria-hidden', 'true')
+ expect(slides[0]).toHaveAttribute('inert')
+ expect(slides[3]).not.toHaveAttribute('aria-hidden', 'true')
+ expect(slides[3]).not.toHaveAttribute('inert')
+ })
+
+ it('stops rotation for the rest of the session once focus enters', () => {
+ render( )
+ const autoplay = mocks.autoplayInstances[0]!
+ const carousel = screen.getByRole('region')
+
+ // The controls expose only the pagination dots and the two nav arrows.
+ expect(screen.getAllByRole('button')).toHaveLength(7)
+
+ const playsBeforeFocus = autoplay.play.mock.calls.length
+ fireEvent.focusIn(carousel)
+
+ expect(autoplay.stop).toHaveBeenCalled()
+ expect(autoplay.play).toHaveBeenCalledTimes(playsBeforeFocus)
+
+ // Moving focus around does not resume rotation on its own.
+ fireEvent.focusIn(carousel)
+ expect(autoplay.play).toHaveBeenCalledTimes(playsBeforeFocus)
+ })
+
+ it('does not start managed autoplay when the carousel has only one page', () => {
+ installIntersectionObserver()
+ mocks.carouselState.scrollSnaps = [0]
+
+ render( )
+ const autoplay = mocks.autoplayInstances[0]!
+
+ triggerIntersection(intersectionObservers[0]!, 1)
+
+ expect(autoplay.play).not.toHaveBeenCalled()
+ })
+
+ // The autoplay plugin skips its own setup on single-page carousels, so an
+ // external play() call would crash inside the plugin (undefined delay list).
+ it('does not start eager autoplay when the carousel has only one page', () => {
+ mocks.carouselState.scrollSnaps = [0]
+
+ render( )
+ const autoplay = mocks.autoplayInstances[0]!
+
+ expect(autoplay.play).not.toHaveBeenCalled()
+ expect(autoplay.stop).toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx
new file mode 100644
index 00000000000..c939c9ea73a
--- /dev/null
+++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx
@@ -0,0 +1,204 @@
+import type { MarketplaceCollection } from '@dify/contracts/marketplace'
+import type { Plugin } from '@/app/components/plugins/types'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import ListWithCollection from '../list-with-collection'
+
+const mockState = vi.hoisted(() => ({
+ becomePartnerText: 'Become a Partner',
+}))
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ const translations: Record = {
+ 'marketplace.carousel.scrollPrevious': 'Previous',
+ }
+
+ return {
+ useLocale: () => 'en-US',
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) =>
+ key === 'marketplace.becomePartner'
+ ? mockState.becomePartnerText
+ : (translations[key] ?? key),
+ ),
+ }),
+ }
+})
+
+vi.mock('@/i18n-config/language', () => ({
+ getLanguage: (locale: string) => locale,
+}))
+
+vi.mock('../../atoms', () => ({
+ useMarketplaceMoreClick: () => vi.fn(),
+}))
+
+vi.mock('../card-wrapper', () => ({
+ default: ({ plugin }: { plugin: Plugin }) => {plugin.name}
,
+}))
+
+vi.mock('@/utils/marketplace-site-track', () => ({
+ trackMarketplaceSiteEvent: vi.fn(),
+}))
+
+const partnerCollection: MarketplaceCollection = {
+ name: 'partners',
+ label: { 'en-US': 'Partners' },
+ description: { 'en-US': 'Plugins verified by Dify partners.' },
+ rule: 'partners',
+ created_at: '',
+ updated_at: '',
+ searchable: false,
+ search_params: {},
+}
+
+const partnerPlugins = Array.from({ length: 9 }, (_, index) => ({
+ plugin_id: `partner-${index}`,
+ name: `Partner plugin ${index}`,
+})) as Plugin[]
+
+const renderPartnerCollection = ({
+ pluginCount = 9,
+ standalone = true,
+ width = 350,
+}: {
+ pluginCount?: number
+ standalone?: boolean
+ width?: number
+} = {}) =>
+ render(
+
+
+
,
+ )
+
+const getTextRect = (element: Element) => {
+ const range = document.createRange()
+ range.selectNodeContents(element)
+ return range.getBoundingClientRect()
+}
+
+describe('Partner collection header layout', () => {
+ beforeEach(() => {
+ mockState.becomePartnerText = 'Become a Partner'
+ })
+
+ it('keeps the mobile call to action beside the title and clear of carousel controls', async () => {
+ await page.viewport(390, 844)
+ const screen = await renderPartnerCollection()
+
+ const title = screen.getByText('Partners', { exact: true }).element()
+ const description = screen.getByText('Plugins verified by Dify partners.').element()
+ const separator = screen.getByText('|').element()
+ const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element()
+ const previousButton = screen.getByRole('button', { name: 'Previous' }).element()
+
+ const titleRect = getTextRect(title)
+ const descriptionRect = description.getBoundingClientRect()
+ const partnerLinkRect = partnerLink.getBoundingClientRect()
+ const previousButtonRect = previousButton.getBoundingClientRect()
+
+ const titleCenter = titleRect.top + titleRect.height / 2
+ const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2
+
+ expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2)
+ expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
+ expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8)
+ expect(descriptionRect.top).toBeGreaterThanOrEqual(
+ Math.max(titleRect.bottom, partnerLinkRect.bottom),
+ )
+ expect(getComputedStyle(separator).display).toBe('none')
+ })
+
+ it('keeps the mobile action 12px from the title when navigation is absent', async () => {
+ await page.viewport(390, 844)
+ const screen = await renderPartnerCollection({ pluginCount: 2 })
+
+ const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect()
+ const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element())
+ const partnerLinkRect = screen
+ .getByRole('link', { name: 'Become a Partner' })
+ .element()
+ .getBoundingClientRect()
+
+ expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
+ expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right)
+ expect(screen.getByRole('button', { name: 'Previous' }).query()).toBeNull()
+ })
+
+ it('keeps the mobile action clear of navigation at a 320px viewport', async () => {
+ await page.viewport(320, 844)
+ mockState.becomePartnerText = 'Torne-se um parceiro'
+ const screen = await renderPartnerCollection({ width: 280 })
+
+ const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element())
+ const partnerLinkRect = screen
+ .getByRole('link', { name: 'Torne-se um parceiro' })
+ .element()
+ .getBoundingClientRect()
+ const previousButtonRect = screen
+ .getByRole('button', { name: 'Previous' })
+ .element()
+ .getBoundingClientRect()
+
+ expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
+ expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8)
+ })
+
+ it('preserves the narrow embedded metadata row', async () => {
+ await page.viewport(390, 844)
+ const screen = await renderPartnerCollection({ standalone: false })
+
+ const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect()
+ const descriptionRect = screen
+ .getByText('Plugins verified by Dify partners.')
+ .element()
+ .getBoundingClientRect()
+ const partnerLinkRect = screen
+ .getByRole('link', { name: 'Become a Partner' })
+ .element()
+ .getBoundingClientRect()
+
+ expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2)
+ expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right)
+ expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none')
+ })
+
+ it('preserves the desktop title and metadata rows', async () => {
+ await page.viewport(1280, 900)
+ const screen = await render(
+
+
+
,
+ )
+
+ const titleRect = screen
+ .getByText('Partners', { exact: true })
+ .element()
+ .getBoundingClientRect()
+ const descriptionRect = screen
+ .getByText('Plugins verified by Dify partners.')
+ .element()
+ .getBoundingClientRect()
+ const separator = screen.getByText('|').element()
+ const partnerLinkRect = screen
+ .getByRole('link', { name: 'Become a Partner' })
+ .element()
+ .getBoundingClientRect()
+
+ expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom)
+ expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2)
+ expect(getComputedStyle(separator).display).not.toBe('none')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx
index b617891a833..2603f49b3ea 100644
--- a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx
+++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx
@@ -1,7 +1,7 @@
import type { MarketplaceCollection } from '@dify/contracts/marketplace'
import type { Plugin } from '@/app/components/plugins/types'
-import { fireEvent, render, screen } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import ListWithCollection from '../list-with-collection'
const mockMoreClick = vi.fn()
@@ -48,9 +48,80 @@ const pluginsMap: Record = {
empty: [],
}
+type IntersectionObserverRecord = {
+ callback: IntersectionObserverCallback
+ disconnect: ReturnType
+ observe: ReturnType
+ options?: IntersectionObserverInit
+}
+
+const intersectionObservers: IntersectionObserverRecord[] = []
+
+class MockIntersectionObserver {
+ callback: IntersectionObserverCallback
+ disconnect = vi.fn()
+ observe = vi.fn()
+ options?: IntersectionObserverInit
+ root: Element | Document | null
+ rootMargin: string
+ takeRecords = vi.fn(() => [])
+ thresholds: readonly number[]
+ unobserve = vi.fn()
+
+ constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
+ this.callback = callback
+ this.options = options
+ this.root = options?.root ?? null
+ this.rootMargin = options?.rootMargin ?? '0px'
+ this.thresholds = Array.isArray(options?.threshold)
+ ? options.threshold
+ : [options?.threshold ?? 0]
+ intersectionObservers.push(this)
+ }
+}
+
+const installIntersectionObserver = () => {
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+}
+
+const triggerIntersection = (
+ observer: IntersectionObserverRecord,
+ { intersectionRatio, isIntersecting }: { intersectionRatio: number; isIntersecting: boolean },
+) => {
+ act(() => {
+ observer.callback(
+ [{ intersectionRatio, isIntersecting } as IntersectionObserverEntry],
+ observer as unknown as IntersectionObserver,
+ )
+ })
+}
+
+const buildPerformanceFixture = () => {
+ const pluginCounts = [61, 8, 8, 8, 8, 8, 8]
+ const fixtureCollections = pluginCounts.map((_, collectionIndex) => ({
+ ...collections[0]!,
+ name: `collection-${collectionIndex}`,
+ label: { 'en-US': `Collection ${collectionIndex}` },
+ description: { 'en-US': `Description ${collectionIndex}` },
+ })) as MarketplaceCollection[]
+ const fixturePluginsMap = Object.fromEntries(
+ pluginCounts.map((pluginCount, collectionIndex) => [
+ `collection-${collectionIndex}`,
+ Array.from({ length: pluginCount }, (_, pluginIndex) => ({
+ plugin_id: `collection-${collectionIndex}-plugin-${pluginIndex}`,
+ name: `Collection ${collectionIndex} Plugin ${pluginIndex}`,
+ })) as Plugin[],
+ ]),
+ )
+
+ return { fixtureCollections, fixturePluginsMap }
+}
+
describe('ListWithCollection', () => {
beforeEach(() => {
vi.clearAllMocks()
+ intersectionObservers.length = 0
+ installIntersectionObserver()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
@@ -58,6 +129,10 @@ describe('ListWithCollection', () => {
})
})
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
it('renders only collections that contain plugins', () => {
render(
{
)
expect(screen.queryByText('plugin.marketplace.viewMore')).not.toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Scroll right' })).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }),
+ ).toBeInTheDocument()
const carousel = screen.getByRole('region')
const carouselViewport = carousel.querySelector('.overflow-hidden')
const carouselContent = carouselViewport?.firstElementChild
@@ -209,4 +286,94 @@ describe('ListWithCollection', () => {
expect(carouselViewport).toHaveClass('overflow-hidden', 'rounded-[inherit]')
expect(carouselContent).toHaveStyle({ columnGap: '12px' })
})
+
+ it('keeps the first collection eager and defers the rest until they enter the preload range', () => {
+ const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture()
+ const marketplaceContainer = document.createElement('div')
+ marketplaceContainer.id = 'marketplace-container'
+ document.body.appendChild(marketplaceContainer)
+
+ const { unmount } = render(
+ ,
+ { container: marketplaceContainer },
+ )
+
+ expect(screen.getAllByText(/Collection \d$/)).toHaveLength(7)
+ expect(document.querySelectorAll('[data-marketplace-collection]')).toHaveLength(7)
+ // The first (above-the-fold) collection renders its real cards immediately
+ // so server-rendered HTML contains first-screen content; the six remaining
+ // collections keep placeholders until they intersect.
+ expect(
+ document.querySelectorAll('[data-marketplace-collection-placeholder] > div'),
+ ).toHaveLength(48)
+ expect(screen.getAllByTestId('card-wrapper')).toHaveLength(61)
+ expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(8)
+ expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(8)
+ // Partner collections autoplay; this fixture is non-partner, so the only
+ // observers here are the collection preload observers.
+ const collectionObservers = intersectionObservers.filter(
+ (observer) => observer.options?.rootMargin === '320px 0px',
+ )
+ expect(collectionObservers).toHaveLength(6)
+ expect(collectionObservers[0]!.options).toEqual({
+ root: marketplaceContainer,
+ rootMargin: '320px 0px',
+ threshold: 0.01,
+ })
+
+ triggerIntersection(collectionObservers[0]!, {
+ intersectionRatio: 0.01,
+ isIntersecting: true,
+ })
+
+ expect(collectionObservers[0]!.disconnect).toHaveBeenCalled()
+ expect(screen.getAllByTestId('card-wrapper')).toHaveLength(69)
+
+ triggerIntersection(collectionObservers[0]!, {
+ intersectionRatio: 0,
+ isIntersecting: false,
+ })
+
+ expect(screen.getAllByTestId('card-wrapper')).toHaveLength(69)
+
+ unmount()
+ marketplaceContainer.remove()
+ })
+
+ it('mounts deferred collections after hydration when IntersectionObserver is unavailable', () => {
+ vi.stubGlobal('IntersectionObserver', undefined)
+
+ render(
+ ,
+ )
+
+ expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2)
+ expect(
+ document.querySelector('[data-marketplace-collection-placeholder]'),
+ ).not.toBeInTheDocument()
+ })
+
+ it('keeps standalone collections eager for SSR-compatible rendering', () => {
+ const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture()
+
+ render(
+ ,
+ )
+
+ expect(screen.getAllByTestId('card-wrapper')).toHaveLength(109)
+ expect(
+ intersectionObservers.some((observer) => observer.options?.rootMargin === '320px 0px'),
+ ).toBe(false)
+ })
})
diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx
new file mode 100644
index 00000000000..0714b7cd62e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx
@@ -0,0 +1,101 @@
+import type { MarketplaceCollection } from '@dify/contracts/marketplace'
+import type { Plugin } from '@/app/components/plugins/types'
+import { useState } from 'react'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import ListWrapper from '../list-wrapper'
+
+const mockMarketplaceData = vi.hoisted(() => ({
+ plugins: undefined as Plugin[] | undefined,
+ pluginsTotal: 0,
+ marketplaceCollections: [] as MarketplaceCollection[],
+ marketplaceCollectionPluginsMap: {} as Record,
+ isLoading: false,
+ isRefreshing: false,
+ isError: false,
+ refetch: vi.fn(),
+ isFetchingNextPage: false,
+ page: 1,
+}))
+
+vi.mock('#i18n', () => ({
+ useTranslation: () => ({
+ t: (_selector: unknown, options?: Record) =>
+ `${options?.num ?? 0} plugins found`,
+ }),
+}))
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: () => loading
,
+}))
+
+vi.mock('../../sort-dropdown', () => ({
+ default: () => sort
,
+}))
+
+vi.mock('../index', () => ({
+ default: () => (
+
+ Catalog result anchor
+
+ ),
+}))
+
+vi.mock('../../state', () => ({
+ useMarketplaceData: () => mockMarketplaceData,
+}))
+
+vi.mock('../../atoms', () => ({
+ useSearchPluginText: () => [''],
+}))
+
+function SearchResultsHarness() {
+ const [searchVersion, setSearchVersion] = useState(0)
+
+ return (
+
+
{
+ mockMarketplaceData.plugins = [{ plugin_id: 'plugin-1', name: 'Search result' } as Plugin]
+ mockMarketplaceData.pluginsTotal = 1
+ setSearchVersion((version) => version + 1)
+ }}
+ >
+ Type search
+
+
+
+
+ )
+}
+
+describe('Marketplace result scroll anchoring', () => {
+ beforeEach(() => {
+ mockMarketplaceData.plugins = undefined
+ mockMarketplaceData.pluginsTotal = 0
+ })
+
+ // Scroll anchoring is owned by Chromium's layout engine and cannot be
+ // represented faithfully by the happy-dom unit project.
+ it('does not move the page when the first search result header appears', async () => {
+ await page.viewport(1280, 720)
+ const screen = await render( )
+ const scrollContainer = screen.getByTestId('marketplace-scroll-container').element()
+
+ scrollContainer.scrollTop = 260
+ await new Promise(requestAnimationFrame)
+ const scrollTopBefore = scrollContainer.scrollTop
+
+ await screen.getByRole('button', { name: 'Type search' }).click()
+ await expect.element(screen.getByText('1 plugins found')).toBeVisible()
+ await new Promise(requestAnimationFrame)
+
+ expect(scrollContainer.scrollTop).toBe(scrollTopBefore)
+ })
+})
diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx
index 3b882a804d2..b011e742797 100644
--- a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx
+++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx
@@ -1,7 +1,10 @@
import type { MarketplaceCollection } from '@dify/contracts/marketplace'
+import type { ReactNode } from 'react'
import type { Plugin } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import ListWrapper from '../list-wrapper'
const mockMarketplaceData = vi.hoisted(() => ({
@@ -10,6 +13,9 @@ const mockMarketplaceData = vi.hoisted(() => ({
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record,
isLoading: false,
+ isRefreshing: false,
+ isError: false,
+ refetch: vi.fn(),
isFetchingNextPage: false,
page: 1,
}))
@@ -18,21 +24,14 @@ vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
- t: withSelectorKey((key: string, options?: { ns?: string; num?: number }) =>
- key === 'marketplace.pluginsResult' && options?.ns === 'plugin'
- ? `${options.num} plugins found`
- : options?.ns
- ? `${options.ns}.${key}`
- : key,
- ),
+ t: withSelectorKey((key: string, options?: Record) => {
+ if (key === 'marketplace.pluginsResult') return `${options?.num} plugins found`
+ return key
+ }),
}),
}
})
-vi.mock('../../state', () => ({
- useMarketplaceData: () => mockMarketplaceData,
-}))
-
vi.mock('@/app/components/base/loading', () => ({
default: ({ className }: { className?: string }) => (
@@ -51,6 +50,17 @@ vi.mock('../index', () => ({
),
}))
+vi.mock('../../state', () => ({
+ useMarketplaceData: () => mockMarketplaceData,
+}))
+
+// ListWrapper reads the raw `q` through nuqs for its analytics flush, so the
+// tree needs an adapter even though the data hook itself is mocked.
+const renderListWrapper = (ui: ReactNode) => {
+ const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams: '' })
+ return render(
{ui} )
+}
+
describe('ListWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -59,6 +69,8 @@ describe('ListWrapper', () => {
mockMarketplaceData.marketplaceCollections = []
mockMarketplaceData.marketplaceCollectionPluginsMap = {}
mockMarketplaceData.isLoading = false
+ mockMarketplaceData.isRefreshing = false
+ mockMarketplaceData.isError = false
mockMarketplaceData.isFetchingNextPage = false
mockMarketplaceData.page = 1
})
@@ -67,20 +79,35 @@ describe('ListWrapper', () => {
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
mockMarketplaceData.pluginsTotal = 1
- render(
)
+ renderListWrapper(
)
expect(screen.getByText('1 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
})
- it('shows centered loading only on initial loading page', () => {
+ it('shows centered loading on a cold start', () => {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 1
- render(
)
+ renderListWrapper(
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
- expect(screen.queryByTestId('list')).not.toBeInTheDocument()
+ })
+
+ // The reported "jitter": every debounced keystroke used to unmount the grid
+ // behind a centre-absolute spinner, collapsing the container height and
+ // jumping the scroll position.
+ it('keeps the result grid mounted while a superseded query is in flight', () => {
+ mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
+ mockMarketplaceData.pluginsTotal = 1
+ mockMarketplaceData.isRefreshing = true
+
+ renderListWrapper(
)
+
+ const list = screen.getByTestId('list')
+ expect(list).toBeInTheDocument()
+ expect(list.parentElement).toHaveAttribute('aria-busy', 'true')
+ expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
it('renders list when loading additional pages', () => {
@@ -88,7 +115,7 @@ describe('ListWrapper', () => {
mockMarketplaceData.page = 2
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
- render(
)
+ renderListWrapper(
)
expect(screen.getByTestId('list')).toBeInTheDocument()
})
@@ -97,8 +124,34 @@ describe('ListWrapper', () => {
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
mockMarketplaceData.isFetchingNextPage = true
- render(
)
+ renderListWrapper(
)
expect(screen.getAllByTestId('loading')).toHaveLength(1)
})
+
+ it('keeps the supplied layout constraint while category results are loading', () => {
+ mockMarketplaceData.isLoading = true
+ mockMarketplaceData.page = 1
+
+ const { container } = renderListWrapper(
)
+
+ expect(container.firstElementChild).toHaveClass('catalog-content-min-height')
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ // A failed search used to arrive as a successful empty page and render as
+ // "no plugins found", with nothing to retry.
+ it('offers a retry when the search failed with nothing to show', async () => {
+ const user = userEvent.setup()
+ mockMarketplaceData.isError = true
+ mockMarketplaceData.plugins = []
+
+ renderListWrapper(
)
+
+ expect(screen.queryByTestId('list')).not.toBeInTheDocument()
+ expect(screen.getByText('marketplace.loadError')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'operation.retry' }))
+ expect(mockMarketplaceData.refetch).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx
index 0e3fcd0c186..a6e989f4beb 100644
--- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx
+++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx
@@ -1,61 +1,63 @@
'use client'
import type { Plugin } from '@/app/components/plugins/types'
-import { Button, buttonVariants } from '@langgenius/dify-ui/button'
-import { cn } from '@langgenius/dify-ui/cn'
+import { Button } from '@langgenius/dify-ui/button'
import { useBoolean } from 'ahooks'
-import { useTheme } from 'next-themes'
import * as React from 'react'
import { useMemo } from 'react'
-import { useLocale, useTranslation } from '#i18n'
+import { useTranslation } from '#i18n'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { useTags } from '@/app/components/plugins/hooks'
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Link from '@/next/link'
-import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
+import { trackMarketplaceSiteCardClick } from '@/utils/marketplace-site-track'
+import MarketplaceDetailDialog from '../detail-dialog'
+import { getPluginDetailLinkInMarketplace } from '../utils'
type CardWrapperProps = {
plugin: Plugin
showInstallButton?: boolean
isInstalled?: boolean
linkToMarketplaceDetail?: boolean
+ section?: string
}
const CardWrapperComponent = ({
plugin,
showInstallButton,
isInstalled = false,
linkToMarketplaceDetail = false,
+ section = 'list',
}: CardWrapperProps) => {
const { t } = useTranslation()
- const { theme } = useTheme()
const [
isShowInstallFromMarketplace,
{ setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace },
] = useBoolean(false)
+ const [
+ isShowMarketplaceDetail,
+ { setTrue: showMarketplaceDetail, setFalse: hideMarketplaceDetail },
+ ] = useBoolean(false)
const { canInstallPlugin } = useOptionalPluginInstallPermission()
- const locale = useLocale()
const { getTagLabel } = useTags()
- // Memoize marketplace link params to prevent unnecessary re-renders
- const marketplaceLinkParams = useMemo(
- () => ({
- language: locale,
- theme,
- }),
- [locale, theme],
- )
-
// Memoize tag labels to prevent recreating array on every render
const tagLabels = useMemo(
() => plugin.tags.map((tag) => getTagLabel(tag.name)),
[plugin.tags, getTagLabel],
)
+ const handleMarketplaceDetailOpenChange = (open: boolean) => {
+ if (open) showMarketplaceDetail()
+ else hideMarketplaceDetail()
+ }
const showInstallAction = !!showInstallButton && canInstallPlugin
if (showInstallAction) {
return (
-
+
+
{isShowInstallFromMarketplace && (
+
{
+ trackMarketplaceSiteCardClick({
+ itemId,
+ itemType: 'plugin',
+ section,
+ })
+ }}
>
{card}
diff --git a/web/app/components/plugins/marketplace/list/carousel.module.css b/web/app/components/plugins/marketplace/list/carousel.module.css
new file mode 100644
index 00000000000..23978e585f4
--- /dev/null
+++ b/web/app/components/plugins/marketplace/list/carousel.module.css
@@ -0,0 +1,5 @@
+@media (max-width: 879px) {
+ :global([data-marketplace-standalone]) .pagination {
+ display: none;
+ }
+}
diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx
index 3b94c907295..e8cdc527a18 100644
--- a/web/app/components/plugins/marketplace/list/carousel.tsx
+++ b/web/app/components/plugins/marketplace/list/carousel.tsx
@@ -1,38 +1,45 @@
'use client'
/* oxlint-disable eslint-react/set-state-in-effect */
+import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import Autoplay from 'embla-carousel-autoplay'
import useEmblaCarousel from 'embla-carousel-react'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from '#i18n'
+import { MARKETPLACE_CONTAINER_ID } from '../constants'
+import styles from './carousel.module.css'
+import { CAROUSEL_PAGE_CLASS } from './collection-constants'
-type CarouselApi = ReturnType[1]
+export type CarouselPage = {
+ id: string
+ content: ReactNode
+}
type CarouselProps = {
- children: React.ReactNode
+ pages: CarouselPage[]
+ ariaLabel?: string
className?: string
showNavigation?: boolean
showPagination?: boolean
autoPlay?: boolean
autoPlayInterval?: number
+ deferMountPages?: boolean
+ pauseWhenOffscreen?: boolean
}
type NavButtonProps = {
- direction: 'left' | 'right'
- disabled: boolean
+ label: string
onClick: () => void
iconClassName: string
}
-const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonProps) => (
+const NavButton = ({ label, onClick, iconClassName }: NavButtonProps) => (
void
scrollPrev: () => void
scrollSnaps: number[]
+ scrollTo: (index: number) => void
}
const CarouselControls = ({
- api,
showPagination,
selectedIndex,
scrollNext,
scrollPrev,
scrollSnaps,
+ scrollTo,
}: CarouselControlsProps) => {
+ const { t } = useTranslation()
const paginationItems = scrollSnaps.map((snap, index) => ({
id: `${snap}-${index}`,
snap,
@@ -69,7 +77,7 @@ const CarouselControls = ({
return (
{showPagination && (
-
+
{paginationItems.map((item, index) => (
api?.scrollTo(index)}
- aria-label={`Go to page ${index + 1}`}
+ onClick={() => scrollTo(index)}
+ aria-label={t(($) => $['marketplace.carousel.goToPage'], {
+ ns: 'plugin',
+ page: index + 1,
+ })}
/>
))}
)}
$['marketplace.carousel.scrollPrevious'], { ns: 'plugin' })}
onClick={scrollPrev}
iconClassName="i-ri-arrow-left-s-line"
/>
$['marketplace.carousel.scrollNext'], { ns: 'plugin' })}
onClick={scrollNext}
iconClassName="i-ri-arrow-right-s-line"
/>
@@ -103,44 +112,106 @@ const CarouselControls = ({
)
}
+const normalizePageIndex = (index: number, pageCount: number) =>
+ ((index % pageCount) + pageCount) % pageCount
+
+const getPageWindowIds = (pages: CarouselPage[], centerIndex: number) => {
+ if (!pages.length) return []
+
+ return [-1, 0, 1].map(
+ (offset) => pages[normalizePageIndex(centerIndex + offset, pages.length)]!.id,
+ )
+}
+
const Carousel = ({
- children,
+ pages,
+ ariaLabel,
className,
showNavigation = true,
showPagination = true,
autoPlay = false,
autoPlayInterval = 5000,
+ deferMountPages = false,
+ pauseWhenOffscreen = false,
}: CarouselProps) => {
- const plugins = useMemo(() => {
- if (!autoPlay) return []
+ const carouselRootRef = useRef(null)
+ const [isFocusPaused, setIsFocusPaused] = 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
- return [
- Autoplay({
- delay: autoPlayInterval,
- stopOnInteraction: false,
- stopOnMouseEnter: true,
- }),
- ]
- }, [autoPlay, autoPlayInterval])
+ return Autoplay({
+ delay: autoPlayInterval,
+ playOnInit: !pauseWhenOffscreen,
+ stopOnInteraction: false,
+ stopOnMouseEnter: !pauseWhenOffscreen,
+ })
+ }, [autoPlay, autoPlayInterval, pauseWhenOffscreen])
+ const plugins = useMemo(() => (autoplay ? [autoplay] : []), [autoplay])
const [carouselRef, api] = useEmblaCarousel(
{ align: 'start', containScroll: 'trimSnaps', loop: true },
plugins,
)
const [selectedIndex, setSelectedIndex] = useState(0)
const [scrollSnaps, setScrollSnaps] = useState([])
+ const [mountedPageIds, setMountedPageIds] = useState(
+ () => new Set(deferMountPages ? getPageWindowIds(pages, 0) : pages.map((page) => page.id)),
+ )
+
+ const mountPageWindow = useCallback(
+ (centerIndex: number) => {
+ if (!deferMountPages || !pages.length) return
+
+ const pageIds = getPageWindowIds(pages, centerIndex)
+ setMountedPageIds((currentPageIds) => {
+ if (pageIds.every((pageId) => currentPageIds.has(pageId))) return currentPageIds
+
+ return new Set([...currentPageIds, ...pageIds])
+ })
+ },
+ [deferMountPages, pages],
+ )
+
+ const scheduleScroll = useCallback((scroll: () => void) => {
+ window.requestAnimationFrame(scroll)
+ }, [])
+
+ const scrollTo = useCallback(
+ (index: number) => {
+ mountPageWindow(index)
+ scheduleScroll(() => api?.scrollTo(index))
+ },
+ [api, mountPageWindow, scheduleScroll],
+ )
const scrollPrev = useCallback(() => {
- api?.scrollPrev()
- }, [api])
+ mountPageWindow(selectedIndex - 1)
+ scheduleScroll(() => api?.scrollPrev())
+ }, [api, mountPageWindow, scheduleScroll, selectedIndex])
const scrollNext = useCallback(() => {
- api?.scrollNext()
- }, [api])
+ mountPageWindow(selectedIndex + 1)
+ scheduleScroll(() => api?.scrollNext())
+ }, [api, mountPageWindow, scheduleScroll, selectedIndex])
+
+ useEffect(() => {
+ if (!deferMountPages) return
+
+ mountPageWindow(selectedIndex)
+ }, [deferMountPages, mountPageWindow, pages, selectedIndex])
useEffect(() => {
if (!api) return
const handleSelect = () => {
- setSelectedIndex(api.selectedScrollSnap())
+ const nextSelectedIndex = api.selectedScrollSnap()
+ setSelectedIndex(nextSelectedIndex)
setScrollSnaps(api.scrollSnapList())
+ mountPageWindow(nextSelectedIndex)
}
handleSelect()
@@ -151,23 +222,167 @@ const Carousel = ({
api.off('reInit', handleSelect)
api.off('select', handleSelect)
}
- }, [api])
+ }, [api, mountPageWindow])
+
+ useEffect(() => {
+ if (!autoplay) return
+
+ const carouselRoot = carouselRootRef.current
+ if (!carouselRoot) return
+
+ // Once keyboard or assistive-technology focus enters the carousel
+ // (including its controls), rotation stays stopped so the content no
+ // longer changes underneath the user.
+ const handleFocusIn = () => setIsFocusPaused(true)
+
+ carouselRoot.addEventListener('focusin', handleFocusIn)
+ 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
+
+ // Autoplay skips its own setup on single-page carousels, so play() would
+ // crash inside the plugin; a lone page has nothing to rotate through anyway.
+ if (scrollSnaps.length <= 1 || isFocusPaused || isReducedMotion) autoplay.stop()
+ else autoplay.play()
+ }, [api, autoplay, isFocusPaused, isReducedMotion, pauseWhenOffscreen, scrollSnaps])
+
+ useEffect(() => {
+ if (!pauseWhenOffscreen || !autoplay || !api) return
+
+ const carouselRoot = carouselRootRef.current
+ if (!carouselRoot) return
+
+ let isInViewport = false
+ let isHovered = false
+ let isDocumentVisible = document.visibilityState === 'visible'
+ const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)')
+ let isReducedMotion = reducedMotionQuery?.matches ?? false
+
+ const syncAutoplay = () => {
+ const hasMultiplePages = api.scrollSnapList().length > 1
+
+ if (
+ hasMultiplePages &&
+ isInViewport &&
+ isDocumentVisible &&
+ !isReducedMotion &&
+ !isHovered &&
+ !isFocusPaused
+ )
+ autoplay.play()
+ else autoplay.stop()
+ }
+ const handleVisibilityChange = () => {
+ isDocumentVisible = document.visibilityState === 'visible'
+ syncAutoplay()
+ }
+ const handleReducedMotionChange = () => {
+ isReducedMotion = reducedMotionQuery?.matches ?? false
+ syncAutoplay()
+ }
+ const handleMouseEnter = () => {
+ isHovered = true
+ syncAutoplay()
+ }
+ const handleMouseLeave = () => {
+ isHovered = false
+ syncAutoplay()
+ }
+
+ const observer =
+ typeof IntersectionObserver === 'undefined'
+ ? undefined
+ : new IntersectionObserver(
+ ([entry]) => {
+ isInViewport = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25
+ syncAutoplay()
+ },
+ {
+ root: document.getElementById(MARKETPLACE_CONTAINER_ID),
+ threshold: 0.25,
+ },
+ )
+
+ if (observer) observer.observe(carouselRoot)
+ else isInViewport = true
+
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+ reducedMotionQuery?.addEventListener('change', handleReducedMotionChange)
+ carouselRoot.addEventListener('mouseenter', handleMouseEnter)
+ carouselRoot.addEventListener('mouseleave', handleMouseLeave)
+ syncAutoplay()
+
+ return () => {
+ observer?.disconnect()
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ reducedMotionQuery?.removeEventListener('change', handleReducedMotionChange)
+ carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
+ carouselRoot.removeEventListener('mouseleave', handleMouseLeave)
+ autoplay.stop()
+ }
+ }, [api, autoplay, isFocusPaused, pauseWhenOffscreen])
return (
-
+
{showNavigation && (
)}
- {children}
+ {pages.map((page, index) => {
+ const isMounted = !deferMountPages || mountedPageIds.has(page.id)
+ const isCurrent = index === selectedIndex
+
+ return (
+
+ {isMounted ? page.content : null}
+
+ )
+ })}
diff --git a/web/app/components/plugins/marketplace/list/collection-constants.ts b/web/app/components/plugins/marketplace/list/collection-constants.ts
index 842d9acb785..c11acb6cd0f 100644
--- a/web/app/components/plugins/marketplace/list/collection-constants.ts
+++ b/web/app/components/plugins/marketplace/list/collection-constants.ts
@@ -1,5 +1,15 @@
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
+export const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
+
+// Collections whose header shows the "Become a Partner" call to action, as
+// named by the Marketplace API for the plugin and template catalogs.
+export const PARTNER_COLLECTION_NAMES = new Set([
+ 'partners',
+ 'partner-template',
+ 'Partner Template',
+])
+
export const CAROUSEL_PAGE_CLASS = 'w-full shrink-0'
export const CAROUSEL_PAGE_SIZE = {
diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx
index 6d6c227b56e..f6d94dddc0c 100644
--- a/web/app/components/plugins/marketplace/list/index.tsx
+++ b/web/app/components/plugins/marketplace/list/index.tsx
@@ -20,6 +20,8 @@ type ListProps = {
cardRender?: (plugin: Plugin) => React.JSX.Element | null
emptyClassName?: string
onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void
+ deferOffscreenCollections?: boolean
+ cardSection?: string
}
const List = ({
marketplaceCollections,
@@ -31,6 +33,8 @@ const List = ({
cardRender,
emptyClassName,
onCollectionMoreClick,
+ deferOffscreenCollections,
+ cardSection = 'list',
}: ListProps) => {
const { canInstallPlugin } = useOptionalPluginInstallPermission()
const pluginIds = useMemo(() => {
@@ -69,6 +73,7 @@ const List = ({
cardRender={cardRender}
onCollectionMoreClick={onCollectionMoreClick}
installedPluginIds={installedPluginIds}
+ deferOffscreenCollections={deferOffscreenCollections}
/>
)}
{plugins && !!plugins.length && (
@@ -83,6 +88,7 @@ const List = ({
showInstallButton={showInstallButton}
isInstalled={installedPluginIds.has(plugin.plugin_id)}
linkToMarketplaceDetail={linkToMarketplaceDetail}
+ section={cardSection}
/>
)
})}
diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx
index 3fcdd727251..a5c8cb12159 100644
--- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx
+++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx
@@ -3,33 +3,22 @@
import type { MarketplaceCollection, SearchParamsFromCollection } from '@dify/contracts/marketplace'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { getLanguage } from '@/i18n-config/language'
+import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track'
import { useMarketplaceMoreClick } from '../atoms'
+import { MARKETPLACE_CONTAINER_ID } from '../constants'
import { buildCarouselPages } from '../utils'
import CardWrapper from './card-wrapper'
import Carousel from './carousel'
-import {
- CAROUSEL_BREAKPOINTS,
- CAROUSEL_PAGE_CLASS,
- CAROUSEL_PAGE_SIZE,
- GRID_CLASS,
-} from './collection-constants'
+import { BECOME_PARTNER_URL, GRID_CLASS, PARTNER_COLLECTION_NAMES } from './collection-constants'
+import styles from './partner-header.module.css'
+import { useCarouselItemsPerPage } from './use-carousel-items-per-page'
-const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
-const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template'])
-
-const getViewportWidth = () =>
- typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth
-
-const getCarouselItemsPerPage = (viewportWidth: number) => {
- if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl
- if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg
- if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm
-
- return CAROUSEL_PAGE_SIZE.base
-}
+const COLLECTION_PRELOAD_MARGIN = '320px 0px'
+const COLLECTION_INTERSECTION_THRESHOLD = 0.01
+const MAX_PLACEHOLDER_CARDS = 8
type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
@@ -40,6 +29,7 @@ type ListWithCollectionProps = {
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void
installedPluginIds?: ReadonlySet
+ deferOffscreenCollections?: boolean
}
type PluginCardProps = {
@@ -48,6 +38,7 @@ type PluginCardProps = {
cardRender?: (plugin: Plugin) => React.JSX.Element | null
isInstalled?: boolean
linkToMarketplaceDetail?: boolean
+ section?: string
}
const PluginCard = ({
@@ -56,6 +47,7 @@ const PluginCard = ({
cardRender,
isInstalled,
linkToMarketplaceDetail,
+ section,
}: PluginCardProps) => {
if (cardRender) return cardRender(plugin)
@@ -65,10 +57,235 @@ const PluginCard = ({
showInstallButton={showInstallButton}
isInstalled={isInstalled}
linkToMarketplaceDetail={linkToMarketplaceDetail}
+ section={section}
/>
)
}
+type CollectionSectionProps = {
+ collection: MarketplaceCollection
+ plugins: Plugin[]
+ itemsPerPage: number
+ showInstallButton?: boolean
+ linkToMarketplaceDetail?: boolean
+ cardContainerClassName?: string
+ cardRender?: (plugin: Plugin) => React.JSX.Element | null
+ onMoreClick: (searchParams?: SearchParamsFromCollection) => void
+ installedPluginIds?: ReadonlySet
+ deferMount: boolean
+}
+
+const CollectionPlaceholder = ({
+ cardContainerClassName,
+ count,
+}: {
+ cardContainerClassName?: string
+ count: number
+}) => (
+
+ {Array.from({ length: count }, (_, index) => (
+
+ ))}
+
+)
+
+const CollectionSection = ({
+ collection,
+ plugins,
+ itemsPerPage,
+ showInstallButton,
+ linkToMarketplaceDetail,
+ cardContainerClassName,
+ cardRender,
+ onMoreClick,
+ installedPluginIds,
+ deferMount,
+}: CollectionSectionProps) => {
+ const { t } = useTranslation()
+ const locale = useLocale()
+ const sectionRef = useRef(null)
+ const [isMounted, setIsMounted] = useState(!deferMount)
+ const pages = useMemo(() => buildCarouselPages(plugins, itemsPerPage), [itemsPerPage, plugins])
+ const hasMultiplePages = pages.length > 1
+ const isPartnersCollection = PARTNER_COLLECTION_NAMES.has(collection.name)
+
+ useEffect(() => {
+ if (!deferMount || isMounted) return
+
+ const section = sectionRef.current
+ if (!section) return
+
+ if (typeof IntersectionObserver === 'undefined') {
+ // oxlint-disable-next-line eslint-react/set-state-in-effect -- This is the hydration fallback for browsers without IntersectionObserver.
+ setIsMounted(true)
+ return
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (!entry?.isIntersecting) return
+
+ setIsMounted(true)
+ observer.disconnect()
+ },
+ {
+ root: document.getElementById(MARKETPLACE_CONTAINER_ID),
+ rootMargin: COLLECTION_PRELOAD_MARGIN,
+ threshold: COLLECTION_INTERSECTION_THRESHOLD,
+ },
+ )
+
+ observer.observe(section)
+
+ return () => observer.disconnect()
+ }, [deferMount, isMounted])
+
+ const carouselPages = useMemo(
+ () =>
+ pages.map((pageItems, pageIndex) => ({
+ id: `${collection.name}-${itemsPerPage}-${pageIndex}`,
+ content: (
+
+ {pageItems.map((plugin) => (
+
+ ))}
+
+ ),
+ })),
+ [
+ cardContainerClassName,
+ cardRender,
+ collection.name,
+ installedPluginIds,
+ itemsPerPage,
+ linkToMarketplaceDetail,
+ pages,
+ showInstallButton,
+ ],
+ )
+
+ return (
+
+
+
+
+ {collection.label[getLanguage(locale)]}
+
+
+
+ {collection.searchable && !hasMultiplePages && (
+
onMoreClick(collection.search_params)}
+ >
+ {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
+
+
+ )}
+
+ {!isMounted ? (
+
+ ) : hasMultiplePages ? (
+
+ ) : (
+
+ {plugins.map((plugin) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
@@ -78,118 +295,33 @@ const ListWithCollection = ({
cardRender,
onCollectionMoreClick,
installedPluginIds,
+ deferOffscreenCollections = false,
}: ListWithCollectionProps) => {
- const { t } = useTranslation()
- const locale = useLocale()
const defaultOnMoreClick = useMarketplaceMoreClick()
const handleMoreClick = onCollectionMoreClick ?? defaultOnMoreClick
- const [viewportWidth, setViewportWidth] = useState(getViewportWidth)
- const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth])
+ const itemsPerPage = useCarouselItemsPerPage()
- useEffect(() => {
- const handleResize = () => setViewportWidth(window.innerWidth)
-
- window.addEventListener('resize', handleResize)
-
- return () => window.removeEventListener('resize', handleResize)
- }, [])
-
- return (
- <>
- {marketplaceCollections
- .filter((collection) => {
- return marketplaceCollectionPluginsMap[collection.name]?.length
- })
- .map((collection) => {
- const plugins = marketplaceCollectionPluginsMap[collection.name]!
- const pages = buildCarouselPages(plugins, itemsPerPage)
- const hasMultiplePages = pages.length > 1
- const isPartnersCollection = PARTNERS_COLLECTION_NAMES.has(collection.name)
-
- return (
-
-
-
-
- {collection.label[getLanguage(locale)]}
-
-
-
- {collection.searchable && !hasMultiplePages && (
-
handleMoreClick(collection.search_params)}
- >
- {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
-
-
- )}
-
- {hasMultiplePages ? (
-
- {pages.map((pageItems) => (
- plugin.plugin_id).join('-')}
- className={CAROUSEL_PAGE_CLASS}
- style={{ scrollSnapAlign: 'start' }}
- >
-
- {pageItems.map((plugin) => (
-
- ))}
-
-
- ))}
-
- ) : (
-
- {plugins.map((plugin) => (
-
- ))}
-
- )}
-
- )
- })}
- >
- )
+ return marketplaceCollections
+ .filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length)
+ .map((collection, index) => (
+ 0}
+ />
+ ))
}
export default ListWithCollection
diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx
index 63a1debe9db..61942e562cd 100644
--- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx
+++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx
@@ -1,15 +1,34 @@
'use client'
+import type { ActivePluginType } from '../constants'
+import { Button } from '@langgenius/dify-ui/button'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useEffect, useRef } from 'react'
import { useTranslation } from '#i18n'
import Loading from '@/app/components/base/loading'
+import {
+ flushMarketplaceSiteFilter,
+ flushMarketplaceSiteSearch,
+ markMarketplaceSiteSearch,
+} from '@/utils/marketplace-site-track'
+import { useSearchPluginText } from '../atoms'
import SortDropdown from '../sort-dropdown'
import { useMarketplaceData } from '../state'
import List from './index'
type ListWrapperProps = {
+ activePluginType?: ActivePluginType
+ className?: string
+ deferOffscreenCollections?: boolean
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
}
-const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapperProps) => {
+const ListWrapper = ({
+ activePluginType,
+ className,
+ deferOffscreenCollections,
+ showInstallButton,
+ linkToMarketplaceDetail,
+}: ListWrapperProps) => {
const { t } = useTranslation()
const {
@@ -18,17 +37,50 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper
marketplaceCollections,
marketplaceCollectionPluginsMap,
isLoading,
+ isRefreshing,
+ isError,
+ refetch,
isFetchingNextPage,
page,
- } = useMarketplaceData()
+ } = useMarketplaceData(activePluginType)
+ const [searchPluginText] = useSearchPluginText()
+ const previousSearchRef = useRef(searchPluginText)
+ const isFirstSearchRender = useRef(true)
+
+ useEffect(() => {
+ if (isFirstSearchRender.current) {
+ isFirstSearchRender.current = false
+ previousSearchRef.current = searchPluginText
+ return
+ }
+
+ if (searchPluginText && searchPluginText !== previousSearchRef.current)
+ markMarketplaceSiteSearch(searchPluginText)
+
+ previousSearchRef.current = searchPluginText
+ }, [searchPluginText])
+
+ useEffect(() => {
+ if (isLoading || isError || pluginsTotal === undefined) return
+
+ flushMarketplaceSiteSearch(pluginsTotal)
+ flushMarketplaceSiteFilter(pluginsTotal)
+ }, [isLoading, isError, pluginsTotal])
return (
{plugins && (
@@ -40,14 +92,34 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper
)}
- {(!isLoading || page > 1) && (
-
+ {isError && !plugins?.length ? (
+
+ {t(($) => $['marketplace.loadError'], { ns: 'plugin' })}
+ void refetch()}>
+ {t(($) => $['operation.retry'], { ns: 'common' })}
+
+
+ ) : (
+ // Rendered even while a superseded query is in flight: unmounting
+ // the grid collapsed the container and jumped the scroll position
+ // on every search keystroke. `isRefreshing` dims it instead.
+
+
+
)}
{isLoading && page === 1 && (
diff --git a/web/app/components/plugins/marketplace/list/partner-header.module.css b/web/app/components/plugins/marketplace/list/partner-header.module.css
new file mode 100644
index 00000000000..3124c729091
--- /dev/null
+++ b/web/app/components/plugins/marketplace/list/partner-header.module.css
@@ -0,0 +1,52 @@
+@media (max-width: 879px) {
+ :global([data-marketplace-standalone]) .partnerHeader {
+ box-sizing: border-box;
+ display: grid;
+ width: 100%;
+ grid-template-areas:
+ 'title action'
+ 'description description';
+ grid-template-columns: max-content minmax(0, 1fr);
+ align-items: center;
+ column-gap: 12px;
+ }
+
+ :global([data-marketplace-standalone]) .partnerHeaderWithNavigation {
+ padding-right: 80px;
+ }
+
+ :global([data-marketplace-standalone]) .partnerTitle {
+ grid-area: title;
+ }
+
+ :global([data-marketplace-standalone]) .partnerMetadata {
+ display: contents;
+ }
+
+ :global([data-marketplace-standalone]) .partnerDescription {
+ grid-area: description;
+ min-width: 0;
+ }
+
+ :global([data-marketplace-standalone]) .partnerSeparator {
+ display: none;
+ }
+
+ :global([data-marketplace-standalone]) .partnerAction {
+ grid-area: action;
+ justify-self: start;
+ min-width: 0;
+ max-width: 100%;
+ white-space: nowrap;
+ }
+
+ :global([data-marketplace-standalone]) .partnerActionLabel {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ :global([data-marketplace-standalone]) .partnerActionIcon {
+ flex-shrink: 0;
+ }
+}
diff --git a/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts
new file mode 100644
index 00000000000..a17b3f1bee4
--- /dev/null
+++ b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts
@@ -0,0 +1,37 @@
+'use client'
+
+import { useSyncExternalStore } from 'react'
+import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE } from './collection-constants'
+
+const subscribeToViewport = (onStoreChange: () => void) => {
+ globalThis.window?.addEventListener('resize', onStoreChange)
+
+ return () => globalThis.window?.removeEventListener('resize', onStoreChange)
+}
+
+const getViewportWidth = () => globalThis.window?.innerWidth ?? CAROUSEL_BREAKPOINTS.xl
+const getServerViewportWidth = () => CAROUSEL_BREAKPOINTS.xl
+
+function getCarouselItemsPerPage(viewportWidth: number) {
+ if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl
+ if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg
+ if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm
+
+ return CAROUSEL_PAGE_SIZE.base
+}
+
+/**
+ * Viewport-derived carousel page size. useSyncExternalStore keeps the
+ * hydration render on the server snapshot (xl) and applies the real viewport
+ * in a follow-up render, so narrow viewports do not trigger a hydration
+ * mismatch against the server-rendered markup.
+ */
+export function useCarouselItemsPerPage() {
+ const viewportWidth = useSyncExternalStore(
+ subscribeToViewport,
+ getViewportWidth,
+ getServerViewportWidth,
+ )
+
+ return getCarouselItemsPerPage(viewportWidth)
+}
diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.module.css b/web/app/components/plugins/marketplace/plugin-type-switch.module.css
new file mode 100644
index 00000000000..50f0086da79
--- /dev/null
+++ b/web/app/components/plugins/marketplace/plugin-type-switch.module.css
@@ -0,0 +1,25 @@
+.homeItem {
+ transition:
+ color 150ms ease,
+ background-color 150ms ease;
+}
+
+.homeItem:hover {
+ color: var(--color-text-secondary);
+ background-color: var(--color-state-base-hover);
+}
+
+.homeItemActive {
+ color: var(--color-saas-dify-blue-inverted);
+ background-color: var(--color-background-interaction-from-bg-2);
+}
+
+.homeItemActive:hover {
+ background-color: var(--color-state-base-hover);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .homeItem {
+ transition: none;
+ }
+}
diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx
index 6c3d6f876a5..823d5542089 100644
--- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx
+++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx
@@ -1,31 +1,25 @@
'use client'
import type { ActivePluginType } from './constants'
import { cn } from '@langgenius/dify-ui/cn'
-import {
- RiArchive2Line,
- RiBrain2Line,
- RiDatabase2Line,
- RiHammerLine,
- RiPuzzle2Line,
- RiSpeakAiLine,
-} from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { Fragment } from 'react'
import { useTranslation } from '#i18n'
-import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import PluginIcon from '@/app/components/base/icons/src/vender/plugin/Plugin'
+import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track'
import { searchModeAtom, useActivePluginType } from './atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
+import styles from './plugin-type-switch.module.css'
type PluginTypeSwitchProps = {
className?: string
- variant?: 'default' | 'hero'
+ variant?: 'default' | 'hero' | 'home'
}
const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const setSearchMode = useSetAtom(searchModeAtom)
const isHero = variant === 'hero'
+ const isHome = variant === 'home'
const iconClassName = 'mr-1.5 size-4'
const options: Array<{
@@ -38,42 +32,46 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr
text: isHero
? t(($) => $['marketplace.allPlugins'], { ns: 'plugin' })
: t(($) => $['category.all'], { ns: 'plugin' }),
- icon: isHero ? : null,
+ icon: isHero || isHome ? : null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t(($) => $['category.models'], { ns: 'plugin' }),
- icon: ,
+ icon: ,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t(($) => $['category.tools'], { ns: 'plugin' }),
- icon: ,
+ icon: ,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
- text: t(($) => $['category.datasources'], { ns: 'plugin' }),
- icon: ,
+ text: t(($) => $[isHome ? 'categorySingle.datasource' : 'category.datasources'], {
+ ns: 'plugin',
+ }),
+ icon: ,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
- text: t(($) => $['category.agents'], { ns: 'plugin' }),
- icon: ,
+ text: t(($) => $[isHome ? 'categorySingle.agent' : 'category.agents'], { ns: 'plugin' }),
+ icon: (
+
+ ),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t(($) => $['category.triggers'], { ns: 'plugin' }),
- icon: ,
+ icon: (
+
+ ),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t(($) => $['category.extensions'], { ns: 'plugin' }),
- icon: ,
- },
- {
- value: PLUGIN_TYPE_SEARCH_MAP.bundle,
- text: t(($) => $['category.bundles'], { ns: 'plugin' }),
- icon: ,
+ icon: ,
},
]
@@ -82,9 +80,15 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr
className={cn(
isHero
? 'flex shrink-0 items-center gap-1 overflow-x-auto'
- : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
+ : isHome
+ ? 'flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto'
+ : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
className,
)}
+ role="group"
+ // Labels the filter group itself; "All integrations" is already the
+ // first option's text and would read as a duplicate.
+ aria-label={t(($) => $.allCategories, { ns: 'plugin' })}
>
{options.map((option, index) => {
const isActive = activePluginType === option.value
@@ -96,17 +100,31 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr
aria-pressed={isActive}
className={cn(
'flex h-8 cursor-pointer appearance-none items-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
- isHero ? 'text-text-primary-on-surface' : 'text-text-tertiary',
+ isHero
+ ? 'text-text-primary-on-surface'
+ : isHome
+ ? cn('min-w-12 shrink-0 justify-center text-text-tertiary', styles.homeItem)
+ : 'text-text-tertiary',
!isActive &&
(isHero
? 'hover:bg-white/20'
- : 'hover:bg-state-base-hover hover:text-text-secondary'),
+ : !isHome && 'hover:bg-state-base-hover hover:text-text-secondary'),
isActive &&
(isHero
? 'border-white/95 bg-components-main-nav-nav-button-bg-active text-saas-dify-blue-inverted shadow-md backdrop-blur-[5px]'
- : 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'),
+ : isHome
+ ? styles.homeItemActive
+ : 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'),
)}
onClick={() => {
+ if (option.value !== activePluginType) {
+ markMarketplaceSiteFilter({
+ filter_type: 'type_tab',
+ selection_mode: 'single',
+ filter_value: option.value,
+ selected_values: [option.value],
+ })
+ }
handleActivePluginTypeChange(option.value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
setSearchMode(null)
diff --git a/web/app/components/plugins/marketplace/query-options.ts b/web/app/components/plugins/marketplace/query-options.ts
new file mode 100644
index 00000000000..c9a8f5ba8ac
--- /dev/null
+++ b/web/app/components/plugins/marketplace/query-options.ts
@@ -0,0 +1,35 @@
+import type { PluginsSearchParams } from '@dify/contracts/marketplace'
+import { infiniteQueryOptions, keepPreviousData } from '@tanstack/react-query'
+import { marketplaceQuery } from '@/service/client'
+import { getMarketplacePlugins } from './utils'
+
+export const getMarketplacePluginsInfiniteQueryOptions = (
+ queryParams: PluginsSearchParams | undefined,
+) =>
+ infiniteQueryOptions({
+ queryKey: marketplaceQuery.searchAdvanced.queryKey({
+ input: {
+ body: queryParams ?? { query: '' },
+ params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' },
+ },
+ }),
+ queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal),
+ getNextPageParam: (lastPage) => {
+ const nextPage = lastPage.page + 1
+ const loaded = lastPage.page * lastPage.page_size
+ return loaded < (lastPage.total || 0) ? nextPage : undefined
+ },
+ initialPageParam: 1,
+ enabled: queryParams !== undefined,
+ // Hold the previous term's results while the new query is in flight. Without
+ // this, `data` goes undefined on every keystroke that survives the debounce,
+ // the grid unmounts, the container collapses, and the scroll position jumps —
+ // the "jitter" the Marketplace search is reported for. Consumers show a
+ // quiet pending state off `isPlaceholderData` instead.
+ placeholderData: keepPreviousData,
+ // Matches the autocomplete queries. Now that the fetcher propagates
+ // failures, react-query's default of 3 retries would hold isFetching true
+ // through ~7s of backoff — indistinguishable from a hang. Failing fast and
+ // offering an explicit Retry is both honest and fewer requests to abort.
+ retry: false,
+ })
diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts
index ff966363686..17195d20efc 100644
--- a/web/app/components/plugins/marketplace/query.ts
+++ b/web/app/components/plugins/marketplace/query.ts
@@ -1,32 +1,24 @@
import type { MarketPlaceInputs, PluginsSearchParams } from '@dify/contracts/marketplace'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { marketplaceQuery } from '@/service/client'
-import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
+import { getMarketplacePluginsInfiniteQueryOptions } from './query-options'
+import { getMarketplaceCollectionsAndPlugins } from './utils'
export function useMarketplaceCollectionsAndPlugins(
collectionsParams: MarketPlaceInputs['collections']['query'],
+ enabled = true,
) {
return useQuery({
queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }),
queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }),
+ enabled,
+ // Matches the plugins query: the shared client default of 3 retries holds
+ // isFetching true for ~7s of backoff, which the catalog renders as a
+ // spinner indistinguishable from a hang.
+ retry: false,
})
}
export function useMarketplacePlugins(queryParams: PluginsSearchParams | undefined) {
- return useInfiniteQuery({
- queryKey: marketplaceQuery.searchAdvanced.queryKey({
- input: {
- body: queryParams!,
- params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' },
- },
- }),
- queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal),
- getNextPageParam: (lastPage) => {
- const nextPage = lastPage.page + 1
- const loaded = lastPage.page * lastPage.page_size
- return loaded < (lastPage.total || 0) ? nextPage : undefined
- },
- initialPageParam: 1,
- enabled: queryParams !== undefined,
- })
+ return useInfiniteQuery(getMarketplacePluginsInfiniteQueryOptions(queryParams))
}
diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx
index 0424f4396ee..e888aac2653 100644
--- a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx
+++ b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx
@@ -19,7 +19,7 @@ vi.mock('../index', () => ({
describe('SearchBoxWrapper', () => {
it('passes marketplace search state into SearchBox', () => {
- render( )
+ render( )
expect(screen.getByTestId('search-box')).toBeInTheDocument()
expect(mockSearchBox).toHaveBeenCalledWith(
@@ -31,6 +31,7 @@ describe('SearchBoxWrapper', () => {
tags: ['agent', 'rag'],
onTagsChange: mockHandleFilterPluginTagsChange,
placeholder: 'plugin.searchPlugins',
+ searchIconName: 'i-ri-search-line',
usedInMarketplace: true,
}),
)
diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx
index 2a34bcc2bf5..c117c8cd177 100644
--- a/web/app/components/plugins/marketplace/search-box/index.tsx
+++ b/web/app/components/plugins/marketplace/search-box/index.tsx
@@ -14,6 +14,7 @@ type SearchBoxProps = {
wrapperClassName?: string
inputClassName?: string
inputElementClassName?: string
+ searchIconName?: string
searchIconClassName?: string
tags: string[]
onTagsChange: (tags: string[]) => void
@@ -31,6 +32,7 @@ function SearchBox({
wrapperClassName,
inputClassName,
inputElementClassName,
+ searchIconName = 'i-ri-search-line',
searchIconClassName,
tags,
onTagsChange,
@@ -111,7 +113,7 @@ function SearchBox({
{
+ const addedTag = nextTags.find((tag) => !tags.includes(tag))
+ const removedTag = tags.find((tag) => !nextTags.includes(tag))
+ markMarketplaceSiteFilter({
+ filter_type: 'category',
+ selection_mode: 'multi',
+ filter_value: addedTag ?? removedTag ?? nextTags.at(-1) ?? '',
+ selected_values: nextTags,
+ })
+ onTagsChange(nextTags)
+ }
return (
@@ -32,7 +44,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
selectedTagsLength={selectedTagsLength}
tags={tags}
tagsMap={tagsMap}
- onTagsChange={onTagsChange}
+ onTagsChange={handleTagsChange}
/>
)}
{!usedInMarketplace && (
@@ -40,7 +52,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
selectedTagsLength={selectedTagsLength}
tags={tags}
tagsMap={tagsMap}
- onTagsChange={onTagsChange}
+ onTagsChange={handleTagsChange}
/>
)}
$.allTags, { ns: 'pluginTags' })}
value={tags}
- onValueChange={(nextTags) => onTagsChange(nextTags)}
+ onValueChange={handleTagsChange}
className="max-h-112 overflow-y-auto p-1"
>
{filteredOptions.map((option) => (
diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts
index 9538543ea40..6ddc889c1f5 100644
--- a/web/app/components/plugins/marketplace/search-params.ts
+++ b/web/app/components/plugins/marketplace/search-params.ts
@@ -1,16 +1,38 @@
+import type { PluginsSearchParams, PluginsSort } from '@dify/contracts/marketplace'
import type { inferParserType } from 'nuqs/server'
import type { ActivePluginType } from './constants'
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
-import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
+import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
+import { getMarketplaceListFilterType } from './utils'
export const marketplaceSearchParamsParsers = {
category: parseAsStringEnum(
Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[],
)
.withDefault('all')
- .withOptions({ history: 'replace', clearOnDefault: false }),
- q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
+ .withOptions({ history: 'replace', clearOnDefault: false, scroll: false }),
+ q: parseAsString.withDefault('').withOptions({ history: 'replace', scroll: false }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
+ languages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
}
export type MarketplaceSearchParams = inferParserType
+
+export const shouldSearchMarketplacePlugins = ({
+ category,
+ q,
+ tags,
+}: Pick) =>
+ Boolean(q || tags.length > 0 || !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(category))
+
+export const getMarketplacePluginsSearchParams = (
+ { category, q, tags }: Pick,
+ sort: PluginsSort = DEFAULT_SORT,
+): PluginsSearchParams => ({
+ query: q,
+ category: category === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : category,
+ tags,
+ sort_by: sort.sortBy,
+ sort_order: sort.sortOrder,
+ type: getMarketplaceListFilterType(category),
+})
diff --git a/web/app/components/plugins/marketplace/server-budget.ts b/web/app/components/plugins/marketplace/server-budget.ts
new file mode 100644
index 00000000000..39249a64441
--- /dev/null
+++ b/web/app/components/plugins/marketplace/server-budget.ts
@@ -0,0 +1,39 @@
+/**
+ * How long a server render may wait for Marketplace data before giving up on
+ * server-side rendering it.
+ *
+ * The catalog routes prefetch on the server so results land in the initial HTML
+ * (crawlers, first paint). Awaiting that prefetch to completion makes the whole
+ * RSC response hostage to the Marketplace API: with a slow upstream the browser
+ * sits on the *previous* page with no feedback, which is what "search just spins
+ * forever" looks like from the outside. Measured against a 3s-delayed API, an
+ * unbounded await pushed time-to-first-byte to ~7s.
+ *
+ * Nothing is lost when the budget expires: the client re-requests whatever is
+ * missing from the dehydrated state, and TanStack Query is configured to
+ * dehydrate still-pending queries, so in-flight work streams instead of
+ * blocking. Server rendering degrades exactly when it is too slow to be worth
+ * waiting for.
+ *
+ * Known limitation: the catalog spends this budget twice in sequence — banners
+ * in `index.tsx`, then the prefetch in `hydration-server.tsx` — so the worst
+ * case is 2x. Overlapping them means handing the started prefetch promise down
+ * instead of letting `HydrateQueryClient` own it, which is a wider change than
+ * bounding the waits.
+ */
+const SERVER_PREFETCH_BUDGET_MS = 2_500
+
+export async function withinServerBudget(work: Promise): Promise {
+ let cancelBudget = () => {}
+ try {
+ await Promise.race([
+ work,
+ new Promise((resolve) => {
+ const timer = setTimeout(resolve, SERVER_PREFETCH_BUDGET_MS)
+ cancelBudget = () => clearTimeout(timer)
+ }),
+ ])
+ } finally {
+ cancelBudget()
+ }
+}
diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts
index f9c723a6481..a2a826ff7c6 100644
--- a/web/app/components/plugins/marketplace/state.ts
+++ b/web/app/components/plugins/marketplace/state.ts
@@ -1,4 +1,5 @@
import type { PluginsSearchParams } from '@dify/contracts/marketplace'
+import type { ActivePluginType } from './constants'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import {
@@ -8,52 +9,79 @@ import {
useMarketplaceSortValue,
useSearchPluginText,
} from './atoms'
-import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
-import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
+import { getMarketplacePluginsSearchParams } from './search-params'
+import { getCollectionsParams } from './utils'
-export function useMarketplaceData() {
+export function useMarketplaceData(activePluginTypeOverride?: ActivePluginType) {
const [searchPluginTextOriginal] = useSearchPluginText()
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
const [filterPluginTags] = useFilterPluginTags()
- const [activePluginType] = useActivePluginType()
+ const [activePluginTypeFromUrl] = useActivePluginType()
+ const activePluginType = activePluginTypeOverride ?? activePluginTypeFromUrl
+ const isSearchMode = useMarketplaceSearchMode(activePluginType, searchPluginText)
const collectionsQuery = useMarketplaceCollectionsAndPlugins(
getCollectionsParams(activePluginType),
+ !isSearchMode,
)
const sort = useMarketplaceSortValue()
- const isSearchMode = useMarketplaceSearchMode()
const queryParams = useMemo((): PluginsSearchParams | undefined => {
if (!isSearchMode) return undefined
- return {
- query: searchPluginText,
- category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
- tags: filterPluginTags,
- sort_by: sort.sortBy,
- sort_order: sort.sortOrder,
- type: getMarketplaceListFilterType(activePluginType),
- }
+ return getMarketplacePluginsSearchParams(
+ {
+ q: searchPluginText,
+ category: activePluginType,
+ tags: filterPluginTags,
+ },
+ sort,
+ )
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
const pluginsQuery = useMarketplacePlugins(queryParams)
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery
const handlePageChange = useCallback(() => {
- if (hasNextPage && !isFetching) fetchNextPage()
+ if (hasNextPage && !isFetching) void fetchNextPage()
}, [fetchNextPage, hasNextPage, isFetching])
// Scroll pagination
useMarketplaceContainerScroll(handlePageChange)
+ const pages = pluginsQuery.data?.pages
+ // Meilisearch resolves ties in `install_count DESC` by internal document
+ // order, and the sync task rewrites those documents every minute, so
+ // offset-paginated pages can overlap. Without this, an overlap renders two
+ // cards with the same React key and remounts the grid.
+ const plugins = useMemo(() => {
+ if (!pages) return undefined
+ const seen = new Set()
+ return pages.flatMap((page) =>
+ page.plugins.filter((plugin) => {
+ const key = `${plugin.org}/${plugin.name}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ }),
+ )
+ }, [pages])
+
return {
marketplaceCollections: collectionsQuery.data?.marketplaceCollections,
marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap,
- plugins: pluginsQuery.data?.pages.flatMap((page) => page.plugins),
- pluginsTotal: pluginsQuery.data?.pages[0]?.total,
- page: pluginsQuery.data?.pages.length || 1,
+ plugins,
+ pluginsTotal: pages?.[0]?.total,
+ page: pages?.length || 1,
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
+ // A superseded query keeps the previous results on screen (placeholderData)
+ // or has not been issued yet (still debouncing). Both need a quiet pending
+ // affordance; unmounting the grid instead collapses layout and jumps scroll.
+ isRefreshing:
+ pluginsQuery.isPlaceholderData || searchPluginTextOriginal.trim() !== searchPluginText.trim(),
+ isError: collectionsQuery.isError || pluginsQuery.isError,
+ refetch: isSearchMode ? pluginsQuery.refetch : collectionsQuery.refetch,
isFetchingNextPage,
}
}
diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx
new file mode 100644
index 00000000000..e1869ba75f0
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx
@@ -0,0 +1,91 @@
+import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { ThemeProvider } from 'next-themes'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import TemplateCard from '../template-card'
+
+const { mockPush } = vi.hoisted(() => ({
+ mockPush: vi.fn(),
+}))
+
+vi.mock('@/next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+}))
+
+vi.mock('../../utils', () => ({
+ getTemplateLinkInMarketplace: (
+ currentTemplate: MarketplaceTemplate,
+ params: { language: string; source?: string; theme?: string; view: string },
+ ) =>
+ `about:blank?templateId=${currentTemplate.id}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`,
+}))
+
+vi.mock('@/app/components/base/app-icon', () => ({
+ default: () =>
,
+}))
+
+const template: MarketplaceTemplate = {
+ id: 'template/one',
+ template_name: 'Campaign planner',
+ overview: 'Plan a launch campaign.',
+ icon: '📄',
+ icon_background: '#fff',
+ icon_file_key: '',
+ publisher_unique_handle: 'dify',
+ usage_count: 1200,
+ categories: ['marketing'],
+ badges: ['partner'],
+}
+
+describe('TemplateCard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('opens template detail before starting the Dify import flow', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByRole('link', { name: 'Campaign planner' })).not.toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Campaign planner' }))
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(mockPush).not.toHaveBeenCalled()
+
+ const frame = screen.getByTitle(
+ 'Campaign planner · plugin.detailPanel.operation.detail',
+ ) as HTMLIFrameElement
+ const marketplaceOrigin = new URL(frame.getAttribute('src')!, window.location.href).origin
+ const installRequest = {
+ type: 'dify-marketplace:install-template',
+ templateId: template.id,
+ }
+ fireEvent(
+ window,
+ new MessageEvent('message', {
+ data: { ...installRequest, templateId: 'another-template' },
+ origin: marketplaceOrigin,
+ source: frame.contentWindow,
+ }),
+ )
+ expect(mockPush).not.toHaveBeenCalled()
+
+ fireEvent(
+ window,
+ new MessageEvent('message', {
+ data: installRequest,
+ origin: marketplaceOrigin,
+ source: frame.contentWindow,
+ }),
+ )
+ expect(mockPush).toHaveBeenCalledWith('/apps?template-id=template%2Fone')
+ expect(screen.getByText('dify')).toBeInTheDocument()
+ expect(screen.getByText('1.2k')).toBeInTheDocument()
+ expect(screen.getByLabelText('Verified by a Dify partner')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx
new file mode 100644
index 00000000000..27de0983450
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx
@@ -0,0 +1,137 @@
+import type {
+ MarketplaceTemplate,
+ MarketplaceTemplateCollection,
+} from '@dify/contracts/marketplace'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import TemplateCollectionList from '../template-collection-list'
+
+vi.mock('#i18n', async () => {
+ const { withSelectorKey } = await import('@/test/i18n-mock')
+ return {
+ useLocale: () => 'en-US',
+ useTranslation: () => ({
+ t: withSelectorKey((key: string) =>
+ key === 'marketplace.carousel.scrollPrevious' ? 'Previous' : key,
+ ),
+ }),
+ }
+})
+
+vi.mock('../template-card', () => ({
+ default: ({ template }: { template: MarketplaceTemplate }) => {template.template_name}
,
+}))
+
+const partnerCollection: MarketplaceTemplateCollection = {
+ name: 'partners',
+ label: { en_US: 'Partners' },
+ description: { en_US: 'Plugins verified by Dify partners.' },
+ searchable: false,
+ search_params: {},
+ priority: 0,
+}
+
+const partnerTemplates = Array.from({ length: 9 }, (_, index) => ({
+ id: `template-${index}`,
+ template_name: `Partner template ${index}`,
+ overview: 'Partner template',
+ icon: '📄',
+ icon_background: '#fff',
+ icon_file_key: '',
+ publisher_unique_handle: 'dify',
+ usage_count: 10,
+ categories: ['marketing'],
+})) as MarketplaceTemplate[]
+
+const renderPartnerCollection = ({
+ templateCount = 9,
+ standalone = true,
+ width = 350,
+}: {
+ templateCount?: number
+ standalone?: boolean
+ width?: number
+} = {}) =>
+ render(
+
+
+
,
+ )
+
+const getTextRect = (element: Element) => {
+ const range = document.createRange()
+ range.selectNodeContents(element)
+ return range.getBoundingClientRect()
+}
+
+describe('Template partner collection header layout', () => {
+ it('keeps the mobile call to action beside the title and clear of carousel controls', async () => {
+ await page.viewport(390, 844)
+ const screen = await renderPartnerCollection()
+
+ const title = screen.getByText('Partners', { exact: true }).element()
+ const description = screen.getByText('Plugins verified by Dify partners.').element()
+ const separator = screen.getByText('|').element()
+ const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element()
+ const previousButton = screen.getByRole('button', { name: 'Previous' }).element()
+
+ const titleRect = getTextRect(title)
+ const descriptionRect = description.getBoundingClientRect()
+ const partnerLinkRect = partnerLink.getBoundingClientRect()
+ const previousButtonRect = previousButton.getBoundingClientRect()
+ const titleCenter = titleRect.top + titleRect.height / 2
+ const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2
+
+ expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2)
+ expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0)
+ expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8)
+ expect(descriptionRect.top).toBeGreaterThanOrEqual(
+ Math.max(titleRect.bottom, partnerLinkRect.bottom),
+ )
+ expect(getComputedStyle(separator).display).toBe('none')
+ })
+
+ it('preserves the desktop title and metadata rows', async () => {
+ await page.viewport(1280, 900)
+ const screen = await render(
+
+
+
,
+ )
+
+ const titleRect = screen
+ .getByText('Partners', { exact: true })
+ .element()
+ .getBoundingClientRect()
+ const descriptionRect = screen
+ .getByText('Plugins verified by Dify partners.')
+ .element()
+ .getBoundingClientRect()
+ const partnerLinkRect = screen
+ .getByRole('link', { name: 'Become a Partner' })
+ .element()
+ .getBoundingClientRect()
+
+ expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom)
+ expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2)
+ expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none')
+ })
+})
diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx
new file mode 100644
index 00000000000..4efe7f90d92
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx
@@ -0,0 +1,98 @@
+import type {
+ MarketplaceTemplate,
+ MarketplaceTemplateCollection,
+} from '@dify/contracts/marketplace'
+import { render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import TemplateCollectionList from '../template-collection-list'
+
+vi.mock('../template-card', () => ({
+ default: ({ template }: { template: MarketplaceTemplate }) => (
+ {template.template_name}
+ ),
+}))
+
+vi.mock('@/utils/marketplace-site-track', () => ({
+ trackMarketplaceSiteEvent: vi.fn(),
+}))
+
+const partnerCollection: MarketplaceTemplateCollection = {
+ name: 'partners',
+ label: { en_US: 'Partners' },
+ description: { en_US: 'Partner templates' },
+ searchable: false,
+ search_params: {},
+ priority: 0,
+}
+
+const featuredCollection: MarketplaceTemplateCollection = {
+ name: 'featured',
+ label: { en_US: 'Featured' },
+ description: { en_US: 'Featured templates' },
+ searchable: false,
+ search_params: {},
+ priority: 1,
+}
+
+const buildTemplates = (prefix: string, count: number) =>
+ Array.from({ length: count }, (_, index) => ({
+ id: `${prefix}-${index}`,
+ template_name: `${prefix} ${index}`,
+ overview: 'Template',
+ icon: '📄',
+ icon_background: '#fff',
+ icon_file_key: '',
+ publisher_unique_handle: 'dify',
+ usage_count: 10,
+ categories: ['marketing'],
+ })) as MarketplaceTemplate[]
+
+describe('TemplateCollectionList carousel', () => {
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerWidth', {
+ configurable: true,
+ writable: true,
+ value: 1280,
+ })
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('keeps carousel navigation for non-partner collections that exceed two rows', () => {
+ render(
+ ,
+ )
+
+ expect(
+ screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }),
+ ).toBeInTheDocument()
+ expect(screen.getByRole('region', { name: 'Featured' })).toBeInTheDocument()
+ })
+
+ it('keeps carousel navigation for partner collections that exceed two rows', () => {
+ render(
+ ,
+ )
+
+ expect(
+ screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }),
+ ).toBeInTheDocument()
+ expect(screen.getByRole('region', { name: 'Partners' })).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts
new file mode 100644
index 00000000000..77d55b2e942
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vite-plus/test'
+import {
+ filterTemplatesForLocale,
+ getTemplateCollectionText,
+ parseListParam,
+ resolveTemplateSearchLanguages,
+} 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'])
+ })
+})
+
+describe('getTemplateCollectionText', () => {
+ it('uses the matching collection translation and falls back to English', () => {
+ const label = {
+ en_US: 'Featured',
+ zh_Hans: '精选',
+ zh_Hant: '精選',
+ ja_JP: '注目',
+ }
+
+ expect(getTemplateCollectionText(label, 'zh-Hant')).toBe('精選')
+ expect(getTemplateCollectionText(label, 'de-DE')).toBe('Featured')
+ })
+
+ it('falls back to the first available translation when English is missing', () => {
+ expect(getTemplateCollectionText({ ja_JP: '注目' }, 'de-DE')).toBe('注目')
+ expect(getTemplateCollectionText({}, 'de-DE')).toBe('')
+ })
+})
+
+describe('parseListParam', () => {
+ it('normalizes undefined, comma-separated, and array language values', () => {
+ expect(parseListParam(undefined)).toEqual([])
+ expect(parseListParam('en,zh-Hans')).toEqual(['en', 'zh-Hans'])
+ expect(parseListParam(['ja', ' other '])).toEqual(['ja', 'other'])
+ })
+})
+
+describe('resolveTemplateSearchLanguages', () => {
+ it('uses the explicit filter when the visitor picked languages', () => {
+ expect(resolveTemplateSearchLanguages(['ja'], 'zh-Hans')).toEqual(['ja'])
+ })
+
+ it('maps UI locales onto catalog language values when the filter is unset', () => {
+ expect(resolveTemplateSearchLanguages([], 'en-US')).toEqual(['en'])
+ expect(resolveTemplateSearchLanguages([], 'zh-Hans')).toEqual(['zh-Hans'])
+ expect(resolveTemplateSearchLanguages([], 'zh_Hans')).toEqual(['zh-Hans'])
+ expect(resolveTemplateSearchLanguages([], 'ja-JP')).toEqual(['ja'])
+ })
+
+ it('keeps unmatched locale prefixes so pagination is not mixed-language', () => {
+ expect(resolveTemplateSearchLanguages([], 'de-DE')).toEqual(['de'])
+ })
+})
diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts
new file mode 100644
index 00000000000..34993d58447
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vite-plus/test'
+import { buildTemplatesHref } from '../template-links'
+
+describe('buildTemplatesHref', () => {
+ it('appends selected languages as a comma-separated query value', () => {
+ expect(buildTemplatesHref({ category: 'all', languages: ['en', 'ja'] })).toBe(
+ '/templates?languages=en%2Cja',
+ )
+ })
+})
diff --git a/web/app/components/plugins/marketplace/templates/categories.ts b/web/app/components/plugins/marketplace/templates/categories.ts
new file mode 100644
index 00000000000..2d8d02943e9
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/categories.ts
@@ -0,0 +1,17 @@
+export const TEMPLATE_CATEGORIES = [
+ 'all',
+ 'marketing',
+ 'sales',
+ 'support',
+ 'operations',
+ 'it',
+ 'knowledge',
+ 'design',
+ 'others',
+] as const
+
+export type TemplateCategory = (typeof TEMPLATE_CATEGORIES)[number]
+
+export function isTemplateCategory(value: string | undefined): value is TemplateCategory {
+ return TEMPLATE_CATEGORIES.includes(value as TemplateCategory)
+}
diff --git a/web/app/components/plugins/marketplace/templates/index.tsx b/web/app/components/plugins/marketplace/templates/index.tsx
new file mode 100644
index 00000000000..4b40c7f32f1
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/index.tsx
@@ -0,0 +1,316 @@
+import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
+import type { TemplateCategory } from './categories'
+import type { Locale } from '@/i18n-config'
+import { cn } from '@langgenius/dify-ui/cn'
+import AccountSection from '@/app/components/main-nav/components/account-section'
+import { getTranslation } from '@/i18n-config/server'
+import { redirect } from '@/next/navigation'
+import {
+ getMarketplaceTemplateCollectionsAndTemplates,
+ searchMarketplaceTemplates,
+ TEMPLATE_SEARCH_PAGE_SIZE,
+} from '@/service/marketplace-template-discovery'
+import { fetchPluginBanners } from '../home/banners'
+import CatalogLanguagesFilter from '../home/catalog-languages-filter'
+import HomeCatalogNavigation from '../home/home-catalog-navigation'
+import HomeCatalogTabs from '../home/home-catalog-tabs'
+import HomeHeader from '../home/home-header'
+import HomeHero from '../home/home-hero'
+import HomeSearch from '../home/home-search'
+import { HomeShell } from '../home/home-shell'
+import styles from '../home/home-sticky.module.css'
+import MarketplaceLiveSearch from '../home/marketplace-live-search'
+import { GRID_CLASS } from '../list/collection-constants'
+import TemplateCard from './template-card'
+import TemplateCategoryNavigation from './template-category-navigation'
+import TemplateCollectionList from './template-collection-list'
+import {
+ filterTemplatesForLocale,
+ parseListParam,
+ resolveTemplateSearchLanguages,
+} from './template-language'
+import { buildTemplatesHref, PAGE_LINK_CLASS } from './template-links'
+import TemplatePagination from './template-pagination'
+
+type EmbeddedTemplatesMarketplaceProps = {
+ category: TemplateCategory
+ languages?: string | string[]
+ locale: Locale
+ page?: number
+ query: string
+ sortBy?: string
+ sortOrder?: string
+ view?: string
+}
+
+function EmptyState({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function TemplateGrid({
+ partnerText,
+ templates,
+}: {
+ partnerText: string
+ templates: MarketplaceTemplate[]
+}) {
+ return (
+
+ {templates.map((template) => (
+
+ ))}
+
+ )
+}
+
+// The retry link is a plain anchor on purpose: a full navigation re-runs the
+// failed (and uncached) server fetch instead of reusing the router cache.
+function LoadErrorState({
+ message,
+ retryHref,
+ retryLabel,
+}: {
+ message: string
+ retryHref: string
+ retryLabel: string
+}) {
+ return (
+
+ )
+}
+
+export async function EmbeddedTemplatesMarketplace({
+ category,
+ languages,
+ locale,
+ page = 1,
+ query,
+ sortBy,
+ sortOrder,
+ view,
+}: EmbeddedTemplatesMarketplaceProps) {
+ const normalizedQuery = query.trim()
+ const selectedLanguages = parseListParam(languages)
+ const searchLanguages = resolveTemplateSearchLanguages(selectedLanguages, locale)
+ const showCollections =
+ category === 'all' && !normalizedQuery && view !== 'search' && selectedLanguages.length === 0
+ const [
+ { t: tPlugin },
+ { t: tApp },
+ { t: tExplore },
+ { t: tPluginTags },
+ { t: tCommon },
+ collectionsResult,
+ searchResult,
+ banners,
+ ] = await Promise.all([
+ getTranslation(locale, 'plugin'),
+ 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,
+ languages: searchLanguages,
+ }),
+ fetchPluginBanners(locale, 'templates').catch(() => []),
+ ])
+ const categoryLabels = {
+ all: tPlugin(($) => $['category.all'], { ns: 'plugin' }),
+ marketing: tApp(($) => $['marketplace.template.category.marketing'], { ns: 'app' }),
+ sales: tApp(($) => $['marketplace.template.category.sales'], { ns: 'app' }),
+ support: tApp(($) => $['marketplace.template.category.support'], { ns: 'app' }),
+ operations: tApp(($) => $['marketplace.template.category.operations'], { ns: 'app' }),
+ it: tApp(($) => $['marketplace.template.category.it'], { ns: 'app' }),
+ knowledge: tApp(($) => $['marketplace.template.category.knowledge'], { ns: 'app' }),
+ design: tApp(($) => $['marketplace.template.category.design'], { ns: 'app' }),
+ others: tPluginTags(($) => $['tags.other'], { ns: 'pluginTags' }),
+ }
+ const pageCount = Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE)
+ // An out-of-range ?page= would render a misleading empty state; send the
+ // visitor to the last page that actually exists instead.
+ if (searchResult?.ok && searchResult.total > 0 && page > pageCount) {
+ redirect(
+ buildTemplatesHref({
+ category,
+ languages: selectedLanguages,
+ page: pageCount,
+ query: normalizedQuery,
+ sortBy,
+ sortOrder,
+ view,
+ }),
+ )
+ }
+
+ const templates = searchResult?.templates ?? []
+ // Collection previews have no language query, so they still need a locale
+ // pass. Search results are already paginated with `searchLanguages`.
+ const visibleTemplatesByCollection = Object.fromEntries(
+ (collectionsResult?.collections ?? []).map((collection) => [
+ collection.name,
+ filterTemplatesForLocale(
+ collectionsResult?.templatesByCollection[collection.name] ?? [],
+ locale,
+ ),
+ ]),
+ )
+ const hasVisibleCollections = (collectionsResult?.collections ?? []).some(
+ (collection) => (visibleTemplatesByCollection[collection.name]?.length ?? 0) > 0,
+ )
+ const pluginsLabel = tPlugin(($) => $['marketplace.home.plugins'], { ns: 'plugin' })
+ const templatesLabel = tPlugin(($) => $['marketplace.home.templates'], { ns: 'plugin' })
+ const partnerText = tPlugin(($) => $['marketplace.partnerTip'], { ns: 'plugin' })
+ const loadFailed = collectionsResult
+ ? !collectionsResult.ok
+ : searchResult
+ ? !searchResult.ok
+ : false
+ const currentHref = buildTemplatesHref({
+ category,
+ languages: selectedLanguages,
+ page,
+ query: normalizedQuery,
+ sortBy,
+ sortOrder,
+ view,
+ })
+ const loadErrorState = (
+ $['marketplace.loadError'], { ns: 'plugin' })}
+ retryHref={currentHref}
+ retryLabel={tCommon(($) => $['operation.retry'], { ns: 'common' })}
+ />
+ )
+
+ return (
+
+
+
+ }
+ catalogLabels={{ plugins: pluginsLabel, templates: templatesLabel }}
+ isMarketplacePlatform={false}
+ />
+ }
+ hero={
+ $['apps.description'], { ns: 'explore' })}
+ />
+ }
+ search={
+
+ $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' })}
+ preserveParams={selectedLanguages.length ? { languages: selectedLanguages } : undefined}
+ query={query}
+ />
+
+ }
+ navigation={
+
+ }
+ catalogCategories={
+ $.allCategories, { ns: 'plugin' })}
+ labels={categoryLabels}
+ languages={selectedLanguages}
+ query={query}
+ />
+ }
+ catalogTrailing={ }
+ />
+ }
+ >
+ {/* The app shell already renders the main landmark; use a plain div
+ to avoid nested main elements. */}
+
+ {loadFailed ? (
+ loadErrorState
+ ) : collectionsResult ? (
+ hasVisibleCollections ? (
+
$['marketplace.becomePartner'], {
+ ns: 'plugin',
+ })}
+ collections={collectionsResult.collections}
+ locale={locale}
+ partnerText={partnerText}
+ templatesByCollection={visibleTemplatesByCollection}
+ viewMoreText={tPlugin(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
+ />
+ ) : (
+ {tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}
+ )
+ ) : (
+ <>
+ {/* The locale filter runs after pagination, so the API total
+ does not describe what is on screen; show the number of
+ templates actually rendered on this page instead. */}
+
+ {tExplore(($) => $['apps.resultNum'], { ns: 'explore', num: templates.length })}
+
+ {templates.length > 0 ? (
+
+ ) : (
+ {tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}
+ )}
+ $['pagination.pageNumber'], { ns: 'common' })}
+ nextLabel={tCommon(($) => $['pagination.next'], { ns: 'common' })}
+ page={page}
+ pageCount={pageCount}
+ previousLabel={tCommon(($) => $['pagination.previous'], { ns: 'common' })}
+ query={normalizedQuery}
+ sortBy={sortBy}
+ sortOrder={sortOrder}
+ view={view}
+ />
+ >
+ )}
+
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/templates/template-card.tsx b/web/app/components/plugins/marketplace/templates/template-card.tsx
new file mode 100644
index 00000000000..58ee195db6f
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/template-card.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useBoolean } from 'ahooks'
+import { useCallback } from 'react'
+import AppIcon from '@/app/components/base/app-icon'
+import Partner from '@/app/components/plugins/base/badges/partner'
+import { MARKETPLACE_API_PREFIX } from '@/config'
+import { useRouter } from '@/next/navigation'
+import { formatNumberAbbreviated } from '@/utils/format'
+import { getIconFromMarketPlace } from '@/utils/get-icon'
+import TemplateDetailDialog from './template-detail-dialog'
+
+type TemplateCardProps = {
+ template: MarketplaceTemplate
+ className?: string
+ partnerText: string
+}
+
+const MAX_VISIBLE_PLUGIN_DEPENDENCIES = 7
+
+export default function TemplateCard({ template, className, partnerText }: TemplateCardProps) {
+ const router = useRouter()
+ const [isDetailOpen, { setTrue: showDetail, setFalse: hideDetail }] = useBoolean(false)
+ const publisher =
+ template.publisher_handle || template.publisher_unique_handle || template.creator_email || ''
+ const visiblePlugins = template.deps_plugins?.slice(0, MAX_VISIBLE_PLUGIN_DEPENDENCIES) ?? []
+ const remainingPluginCount = Math.max(
+ 0,
+ (template.deps_plugins?.length ?? 0) - MAX_VISIBLE_PLUGIN_DEPENDENCIES,
+ )
+ const imageUrl = template.icon_file_key
+ ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`
+ : undefined
+ const handleOpenChange = (open: boolean) => {
+ if (open) showDetail()
+ else hideDetail()
+ }
+ const handleInstall = useCallback(() => {
+ hideDetail()
+ router.push(`/apps?template-id=${encodeURIComponent(template.id)}`)
+ }, [hideDetail, router, template.id])
+
+ return (
+ <>
+
+
+
+
+
+
+ {template.template_name}
+
+ {template.badges?.includes('partner') && (
+
+ )}
+
+
+ {publisher && {publisher} }
+ {publisher && · }
+ {formatNumberAbbreviated(template.usage_count)}
+
+
+
+
+
+ {template.overview}
+
+
+
+ {visiblePlugins.map((pluginId) => (
+
+ ))}
+ {remainingPluginCount > 0 && (
+
+{remainingPluginCount}
+ )}
+
+
+
+ >
+ )
+}
diff --git a/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx b/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx
new file mode 100644
index 00000000000..dfd809ef5fb
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx
@@ -0,0 +1,56 @@
+import type { TemplateCategory } from './categories'
+import { cn } from '@langgenius/dify-ui/cn'
+import MarketplaceFilterTrackLink from '../filter-track-link'
+import pluginTypeStyles from '../plugin-type-switch.module.css'
+import { TEMPLATE_CATEGORIES } from './categories'
+
+export type TemplateCategoryLabels = Record
+
+export default function TemplateCategoryNavigation({
+ activeCategory,
+ ariaLabel,
+ labels,
+ languages,
+ query,
+}: {
+ activeCategory: TemplateCategory
+ ariaLabel: string
+ labels: TemplateCategoryLabels
+ languages: string[]
+ query: string
+}) {
+ return (
+
+ {TEMPLATE_CATEGORIES.map((category) => {
+ const searchParams = new URLSearchParams()
+ if (query) searchParams.set('q', query)
+ if (languages.length) searchParams.set('languages', languages.join(','))
+ const queryString = searchParams.toString()
+ const href = `/templates/${category}${queryString ? `?${queryString}` : ''}`
+
+ return (
+
+ {labels[category]}
+
+ )
+ })}
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/templates/template-collection-list.tsx b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx
new file mode 100644
index 00000000000..735db4f6b64
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx
@@ -0,0 +1,172 @@
+'use client'
+
+import type {
+ MarketplaceTemplate,
+ MarketplaceTemplateCollection,
+} from '@dify/contracts/marketplace'
+import { cn } from '@langgenius/dify-ui/cn'
+import Link from '@/next/link'
+import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track'
+import Carousel from '../list/carousel'
+import {
+ BECOME_PARTNER_URL,
+ GRID_CLASS,
+ PARTNER_COLLECTION_NAMES,
+} from '../list/collection-constants'
+import styles from '../list/partner-header.module.css'
+import { useCarouselItemsPerPage } from '../list/use-carousel-items-per-page'
+import TemplateCard from './template-card'
+import { getTemplateCollectionText } from './template-language'
+
+type TemplateCollectionListProps = {
+ becomePartnerText: string
+ collections: MarketplaceTemplateCollection[]
+ locale: string
+ partnerText: string
+ /**
+ * Templates per collection, already filtered for the request locale by the
+ * caller; this component only renders what it receives.
+ */
+ templatesByCollection: Record
+ viewMoreText: string
+}
+
+function getViewMoreHref(collection: MarketplaceTemplateCollection) {
+ const searchParams = new URLSearchParams({ view: 'search' })
+ const collectionSearch = collection.search_params
+
+ if (collectionSearch?.query) searchParams.set('q', collectionSearch.query)
+ if (collectionSearch?.sort_by) searchParams.set('sort_by', collectionSearch.sort_by)
+ if (collectionSearch?.sort_order) searchParams.set('sort_order', collectionSearch.sort_order)
+
+ return `/templates/all?${searchParams.toString()}`
+}
+
+export default function TemplateCollectionList({
+ becomePartnerText,
+ collections,
+ locale,
+ partnerText,
+ templatesByCollection,
+ viewMoreText,
+}: TemplateCollectionListProps) {
+ const itemsPerPage = useCarouselItemsPerPage()
+
+ return collections.map((collection) => {
+ const templates = templatesByCollection[collection.name] ?? []
+
+ if (!templates.length) return null
+
+ const isPartnerCollection = PARTNER_COLLECTION_NAMES.has(collection.name)
+ const hasMultiplePages = !collection.searchable && templates.length > itemsPerPage
+
+ return (
+
+
+
+
+ {getTemplateCollectionText(collection.label, locale)}
+
+
+
+ {collection.searchable && (
+
+ {viewMoreText}
+
+
+ )}
+
+ {collection.searchable ? (
+
+ {templates.slice(0, 4).map((template) => (
+
+ ))}
+
+ ) : (
+ {
+ const pageTemplates = templates.slice(
+ pageIndex * itemsPerPage,
+ (pageIndex + 1) * itemsPerPage,
+ )
+
+ return {
+ id: `${collection.name}-${itemsPerPage}-${pageIndex}`,
+ content: (
+
+ {pageTemplates.map((template) => (
+
+
+
+ ))}
+
+ ),
+ }
+ },
+ )}
+ ariaLabel={getTemplateCollectionText(collection.label, locale)}
+ showNavigation
+ showPagination
+ autoPlay={isPartnerCollection}
+ autoPlayInterval={5000}
+ pauseWhenOffscreen
+ />
+ )}
+
+ )
+ })
+}
diff --git a/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx b/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx
new file mode 100644
index 00000000000..1e230705e9e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
+import { useTheme } from 'next-themes'
+import { useCallback } from 'react'
+import { useLocale, useTranslation } from '#i18n'
+import MarketplaceDetailDialogFrame from '../detail-dialog/frame'
+import { getTemplateLinkInMarketplace } from '../utils'
+
+const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-template'
+
+type TemplateDetailDialogProps = {
+ open: boolean
+ template: MarketplaceTemplate
+ onInstall: () => void
+ onOpenChange: (open: boolean) => void
+}
+
+export default function TemplateDetailDialog({
+ open,
+ template,
+ onInstall,
+ onOpenChange,
+}: TemplateDetailDialogProps) {
+ const { t } = useTranslation()
+ const locale = useLocale()
+ // resolvedTheme maps the "system" preference to the concrete light/dark
+ // value the marketplace page expects.
+ const { resolvedTheme } = useTheme()
+ const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
+ const detailURL = getTemplateLinkInMarketplace(template, {
+ language: locale,
+ source: globalThis.location?.origin,
+ theme: resolvedTheme,
+ view: 'modal',
+ })
+ const handleMessage = useCallback(
+ (data: unknown) => {
+ if (
+ typeof data !== 'object' ||
+ data === null ||
+ !('type' in data) ||
+ !('templateId' in data) ||
+ data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE ||
+ data.templateId !== template.id
+ )
+ return
+
+ onInstall()
+ },
+ [onInstall, template.id],
+ )
+
+ return (
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/templates/template-language.ts b/web/app/components/plugins/marketplace/templates/template-language.ts
new file mode 100644
index 00000000000..c2d10aa2e0e
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/template-language.ts
@@ -0,0 +1,67 @@
+import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
+
+export const LANGUAGE_OPTIONS = [
+ { value: 'en', label: 'English', nativeLabel: 'English' },
+ { value: 'zh-Hans', label: 'Simplified Chinese', nativeLabel: '中文' },
+ { value: 'ja', label: 'Japanese', nativeLabel: '日本語' },
+ { value: 'other', label: 'Other', nativeLabel: 'Other' },
+] as const
+
+export function parseListParam(value?: string | string[]) {
+ if (!value) return []
+ const parts = Array.isArray(value) ? value : value.split(',')
+ return parts.map((part) => part.trim()).filter(Boolean)
+}
+
+const getLanguagePrefix = (locale: string) => locale.toLowerCase().split(/[-_]/)[0] ?? ''
+
+function getSearchLanguagesForLocale(locale: string) {
+ const requestedLanguage = getLanguagePrefix(locale)
+ if (!requestedLanguage) return ['en']
+ if (requestedLanguage === 'zh') return ['zh-Hans']
+
+ const knownOption = LANGUAGE_OPTIONS.find(
+ (option) => option.value !== 'other' && getLanguagePrefix(option.value) === requestedLanguage,
+ )
+ if (knownOption) return [knownOption.value]
+
+ return [requestedLanguage]
+}
+
+export function resolveTemplateSearchLanguages(selectedLanguages: string[], locale: string) {
+ return selectedLanguages.length > 0 ? selectedLanguages : getSearchLanguagesForLocale(locale)
+}
+
+/**
+ * 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,
+>(templates: T[], locale: string) {
+ const requestedLanguage = getLanguagePrefix(locale)
+
+ 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)
+ })
+
+ const requestedMatches = filterByLanguage(requestedLanguage)
+ if (requestedMatches.length > 0) return requestedMatches
+
+ const englishMatches = requestedLanguage === 'en' ? [] : filterByLanguage('en')
+ if (englishMatches.length > 0) return englishMatches
+
+ return templates
+}
+
+export function getTemplateCollectionText(value: Record, locale: string) {
+ const localeKey = locale.replace('-', '_')
+
+ return value[localeKey] || value.en_US || Object.values(value)[0] || ''
+}
diff --git a/web/app/components/plugins/marketplace/templates/template-links.ts b/web/app/components/plugins/marketplace/templates/template-links.ts
new file mode 100644
index 00000000000..fc0ae9b9425
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/template-links.ts
@@ -0,0 +1,37 @@
+import type { TemplateCategory } from './categories'
+
+export 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'
+export 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'
+
+export type TemplatesHrefOptions = {
+ category: TemplateCategory
+ languages?: string[]
+ page?: number
+ query?: string
+ sortBy?: string
+ sortOrder?: string
+ view?: string
+}
+
+export function buildTemplatesHref({
+ category,
+ languages,
+ page = 1,
+ query,
+ sortBy,
+ sortOrder,
+ view,
+}: TemplatesHrefOptions) {
+ 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 (languages?.length) searchParams.set('languages', languages.join(','))
+ if (page > 1) searchParams.set('page', String(page))
+ const queryString = searchParams.toString()
+ const basePath = category === 'all' ? '/templates' : `/templates/${category}`
+ return queryString ? `${basePath}?${queryString}` : basePath
+}
diff --git a/web/app/components/plugins/marketplace/templates/template-pagination.tsx b/web/app/components/plugins/marketplace/templates/template-pagination.tsx
new file mode 100644
index 00000000000..9cac5515cb1
--- /dev/null
+++ b/web/app/components/plugins/marketplace/templates/template-pagination.tsx
@@ -0,0 +1,70 @@
+import type { TemplateCategory } from './categories'
+import Link from '@/next/link'
+import { buildTemplatesHref, PAGE_LINK_CLASS, PAGE_LINK_DISABLED_CLASS } from './template-links'
+
+// Server-rendered pagination: plain links keep the search results reachable
+// beyond the first page without any client-side state.
+export default function TemplatePagination({
+ category,
+ languages,
+ navigationLabel,
+ nextLabel,
+ page,
+ pageCount,
+ previousLabel,
+ query,
+ sortBy,
+ sortOrder,
+ view,
+}: {
+ category: TemplateCategory
+ languages?: string[]
+ 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) =>
+ buildTemplatesHref({
+ category,
+ languages,
+ page: targetPage,
+ query,
+ sortBy,
+ sortOrder,
+ view,
+ })
+
+ return (
+
+ {page > 1 ? (
+
+ {previousLabel}
+
+ ) : (
+
+ {previousLabel}
+
+ )}
+
+ {page} / {pageCount}
+
+ {page < pageCount ? (
+
+ {nextLabel}
+
+ ) : (
+
+ {nextLabel}
+
+ )}
+
+ )
+}
diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts
index acc77a84ebf..7f51d4368ef 100644
--- a/web/app/components/plugins/marketplace/utils.ts
+++ b/web/app/components/plugins/marketplace/utils.ts
@@ -1,7 +1,7 @@
import type {
CollectionsAndPluginsSearchParams,
- MarketplaceCollection,
MarketplacePlugin,
+ MarketplaceTemplate,
PluginsSearchParams,
} from '@dify/contracts/marketplace'
import type { ActivePluginType } from './constants'
@@ -70,72 +70,97 @@ export const getPluginDetailLinkInMarketplace = (
return `/plugin/${org}/${name}`
}
+export const getTemplateLinkInMarketplace = (
+ template: Pick<
+ MarketplaceTemplate,
+ 'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name'
+ >,
+ params?: Record,
+) => {
+ const publisher = template.publisher_handle || template.publisher_unique_handle || 'template'
+ const path = `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}`
+
+ return getMarketplaceUrl(path, {
+ ...params,
+ templateId: template.id,
+ })
+}
+
export const getMarketplaceCategoryUrl = (
category?: string,
params?: Record,
) => {
return getMarketplaceUrl(category ? `/plugins/${category}` : '/plugins', params)
}
+// One collections response lists every catalog carousel and each needs its own
+// plugins request. Firing them all at once head-of-line blocks on the browser's
+// per-origin connection cap, so the whole catalog waits on the slowest tail
+// request — and every one of those is a request the next search has to abort.
+const COLLECTION_PLUGINS_CONCURRENCY = 4
+
export const getMarketplacePluginsByCollectionId = async (
collectionId: string,
query?: CollectionsAndPluginsSearchParams,
options?: MarketplaceFetchOptions,
) => {
- let plugins: Plugin[] = []
-
- try {
- const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins(
- {
- params: {
- collectionId,
- },
- body: query ?? {},
+ const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins(
+ {
+ params: {
+ collectionId,
},
- {
- signal: options?.signal,
- },
- )
- plugins = (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) =>
- getFormattedPlugin(plugin),
- )
- } catch {
- plugins = []
- }
+ body: query ?? {},
+ },
+ {
+ signal: options?.signal,
+ },
+ )
- return plugins
+ return (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) =>
+ getFormattedPlugin(plugin),
+ )
}
export const getMarketplaceCollectionsAndPlugins = async (
query?: CollectionsAndPluginsSearchParams,
options?: MarketplaceFetchOptions,
) => {
- let marketplaceCollections: MarketplaceCollection[] = []
- let marketplaceCollectionPluginsMap: Record = {}
- try {
- const marketplaceCollectionsDataJson = await marketplaceClient.collections(
- {
- query: {
- ...query,
- page: 1,
- page_size: 100,
- },
+ // Deliberately not wrapped in a catch: a swallowed failure resolves as an
+ // empty catalog, which react-query caches as a success for the whole
+ // staleTime and renders as "nothing here" with no retry and no error signal.
+ const marketplaceCollectionsDataJson = await marketplaceClient.collections(
+ {
+ query: {
+ ...query,
+ page: 1,
+ page_size: 100,
},
- {
- signal: options?.signal,
- },
- )
- marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || []
- await Promise.all(
- marketplaceCollections.map(async (collection: MarketplaceCollection) => {
- const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options)
+ },
+ {
+ signal: options?.signal,
+ },
+ )
+ const marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || []
+ const marketplaceCollectionPluginsMap: Record = {}
- marketplaceCollectionPluginsMap[collection.name] = plugins
- }),
- )
- } catch {
- marketplaceCollections = []
- marketplaceCollectionPluginsMap = {}
+ const pending = [...marketplaceCollections]
+ const fetchCollectionPlugins = async () => {
+ for (let collection = pending.shift(); collection; collection = pending.shift()) {
+ try {
+ marketplaceCollectionPluginsMap[collection.name] =
+ await getMarketplacePluginsByCollectionId(collection.name, query, options)
+ } catch {
+ // One empty carousel beats a blank catalog: the collection list itself
+ // loaded, so render what did arrive.
+ marketplaceCollectionPluginsMap[collection.name] = []
+ }
+ }
}
+ await Promise.all(
+ Array.from(
+ { length: Math.min(COLLECTION_PLUGINS_CONCURRENCY, pending.length) },
+ fetchCollectionPlugins,
+ ),
+ )
return {
marketplaceCollections,
@@ -159,39 +184,35 @@ export const getMarketplacePlugins = async (
const { query, sort_by, sort_order, category, tags, type, page_size = 40 } = queryParams
- try {
- const res = await marketplaceClient.searchAdvanced(
- {
- params: {
- kind: type === 'bundle' ? 'bundles' : 'plugins',
- },
- body: {
- page: pageParam,
- page_size,
- query,
- sort_by,
- sort_order,
- category: category !== 'all' ? category : '',
- tags,
- },
+ // Errors propagate on purpose. Returning a synthesized empty page here made
+ // every backend failure — and every aborted keystroke — look like a
+ // successful zero-result search: react-query never saw isError, never
+ // retried, cached the emptiness, reported total 0 to the analytics flush, and
+ // permanently killed getNextPageParam for that key.
+ const res = await marketplaceClient.searchAdvanced(
+ {
+ params: {
+ kind: type === 'bundle' ? 'bundles' : 'plugins',
},
- { signal },
- )
- const resPlugins = res.data.bundles || res.data.plugins || []
+ body: {
+ page: pageParam,
+ page_size,
+ query,
+ sort_by,
+ sort_order,
+ category: category !== 'all' ? category : '',
+ tags,
+ },
+ },
+ { signal },
+ )
+ const resPlugins = res.data.bundles || res.data.plugins || []
- return {
- plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)),
- total: res.data.total,
- page: pageParam,
- page_size,
- }
- } catch {
- return {
- plugins: [],
- total: 0,
- page: pageParam,
- page_size,
- }
+ return {
+ plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)),
+ total: res.data.total,
+ page: pageParam,
+ page_size,
}
}
diff --git a/web/app/components/plugins/marketplace/view.tsx b/web/app/components/plugins/marketplace/view.tsx
new file mode 100644
index 00000000000..4737daf3801
--- /dev/null
+++ b/web/app/components/plugins/marketplace/view.tsx
@@ -0,0 +1,75 @@
+import type { PluginBanner } from '@dify/contracts/marketplace'
+import type { ActivePluginType } from './constants'
+import type { HomeCatalogTabLabels } from './home/home-catalog-tabs'
+import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
+import Description from './description'
+import MarketplaceHome from './home'
+import ListWrapper from './list/list-wrapper'
+import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
+
+type MarketplaceVariant = 'default' | 'home'
+
+export type MarketplaceViewProps = {
+ banners: PluginBanner[]
+ showInstallButton?: boolean
+ linkToMarketplaceDetail?: boolean
+ pluginTypeSwitchClassName?: string
+ isMarketplacePlatform?: boolean
+ marketplaceNav?: React.ReactNode
+ variant?: MarketplaceVariant
+ homeHeaderActions?: React.ReactNode
+ homeCatalogLabels?: HomeCatalogTabLabels
+ homeCatalogCategories?: React.ReactNode
+ homeActivePluginType?: ActivePluginType
+ homeSearch?: React.ReactNode
+ language?: string
+}
+
+export function MarketplaceView({
+ banners,
+ showInstallButton = false,
+ linkToMarketplaceDetail = false,
+ pluginTypeSwitchClassName,
+ isMarketplacePlatform = false,
+ marketplaceNav,
+ variant = 'default',
+ homeHeaderActions,
+ homeCatalogLabels,
+ homeCatalogCategories,
+ homeActivePluginType,
+ homeSearch,
+ language,
+}: MarketplaceViewProps) {
+ return (
+
+ {variant === 'home' ? (
+
+ ) : (
+ <>
+
+ {!isMarketplacePlatform && (
+
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx
index 22f5a212cab..094898ea58e 100644
--- a/web/app/components/plugins/plugin-detail-panel/index.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/index.tsx
@@ -4,7 +4,6 @@ import type { PluginDetail } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
- DrawerBackdrop,
DrawerContent,
DrawerPopup,
DrawerPortal,
@@ -68,18 +67,18 @@ const PluginDetailPanel: FC = ({
return (
{
if (!open) onHide()
}}
>
-
-
+
diff --git a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx
index 95215e021de..b988542d401 100644
--- a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx
@@ -32,13 +32,13 @@ vi.mock('../../hooks', () => ({
}),
}))
-const mockCurrentPluginID = vi.fn((): string | undefined => undefined)
-const mockSetCurrentPluginID = vi.fn()
+const mockSelectedItem = vi.fn((): { type: 'plugin'; id: string } | undefined => undefined)
+const mockSetSelectedItem = vi.fn()
vi.mock('../../plugin-page/context', () => ({
usePluginPageContext: (selector: (v: Record) => unknown) => {
const context = {
- currentPluginID: mockCurrentPluginID(),
- setCurrentPluginID: mockSetCurrentPluginID,
+ selectedItem: mockSelectedItem(),
+ setSelectedItem: mockSetSelectedItem,
}
return selector(context)
},
@@ -174,7 +174,7 @@ describe('PluginItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme.mockReturnValue('light')
- mockCurrentPluginID.mockReturnValue(undefined)
+ mockSelectedItem.mockReturnValue(undefined)
mockEnableMarketplace.mockReturnValue(true)
mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo('1.0.0'))
mockGetValueFromI18nObject.mockImplementation((obj: Record) => obj?.en_US || '')
@@ -588,7 +588,7 @@ describe('PluginItem', () => {
// ==================== User Interactions Tests ====================
describe('User Interactions', () => {
- it('should call setCurrentPluginID when plugin is clicked', () => {
+ it('should select the plugin when its card is clicked', () => {
// Arrange
const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
@@ -598,12 +598,15 @@ describe('PluginItem', () => {
fireEvent.click(pluginContainer)
// Assert
- expect(mockSetCurrentPluginID).toHaveBeenCalledWith('test-plugin-id')
+ expect(mockSetSelectedItem).toHaveBeenCalledWith({
+ type: 'plugin',
+ id: 'test-plugin-id',
+ })
})
it('should highlight selected plugin', () => {
// Arrange
- mockCurrentPluginID.mockReturnValue('test-plugin-id')
+ mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'test-plugin-id' })
const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
// Act
@@ -611,12 +614,14 @@ describe('PluginItem', () => {
// Assert
const pluginContainer = container.firstChild as HTMLElement
- expect(pluginContainer).toHaveClass('border-components-option-card-option-selected-border')
+ expect(pluginContainer).toHaveClass(
+ 'after:inset-ring-components-option-card-option-selected-border',
+ )
})
it('should not highlight unselected plugin', () => {
// Arrange
- mockCurrentPluginID.mockReturnValue('other-plugin-id')
+ mockSelectedItem.mockReturnValue({ type: 'plugin', id: 'other-plugin-id' })
const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
// Act
@@ -625,7 +630,7 @@ describe('PluginItem', () => {
// Assert
const pluginContainer = container.firstChild as HTMLElement
expect(pluginContainer).not.toHaveClass(
- 'border-components-option-card-option-selected-border',
+ 'after:inset-ring-components-option-card-option-selected-border',
)
})
@@ -638,8 +643,8 @@ describe('PluginItem', () => {
const actionArea = screen.getByTestId('plugin-action').parentElement
fireEvent.click(actionArea!)
- // Assert - setCurrentPluginID should not be called
- expect(mockSetCurrentPluginID).not.toHaveBeenCalled()
+ // Assert - selecting the plugin should not be triggered
+ expect(mockSetSelectedItem).not.toHaveBeenCalled()
})
it('should only reveal actions on card hover or focus', () => {
@@ -651,9 +656,18 @@ describe('PluginItem', () => {
// Assert
expect(screen.getByTestId('plugin-action').parentElement).toHaveClass(
+ 'absolute',
+ 'top-1/2',
+ 'right-0',
+ '-translate-y-1/2',
+ 'pointer-events-none',
'opacity-0',
+ 'group-hover/plugin-item:pointer-events-auto',
'group-hover/plugin-item:opacity-100',
- 'focus-within:opacity-100',
+ 'group-focus-within/plugin-item:pointer-events-auto',
+ 'group-focus-within/plugin-item:opacity-100',
+ '[@media(hover:none)]:pointer-events-auto',
+ '[@media(hover:none)]:opacity-100',
)
})
})
diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx
index 05d0ed90da5..db723f6cfed 100644
--- a/web/app/components/plugins/plugin-item/index.tsx
+++ b/web/app/components/plugins/plugin-item/index.tsx
@@ -47,8 +47,10 @@ const PluginItem: FC = ({
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
- const currentPluginID = usePluginPageContext((v) => v.currentPluginID)
- const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
+ const selectedPluginID = usePluginPageContext((v) =>
+ v.selectedItem?.type === 'plugin' ? v.selectedItem.id : undefined,
+ )
+ const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem)
const { refreshPluginList } = useRefreshPluginList()
const {
@@ -118,19 +120,20 @@ const PluginItem: FC = ({
return (
{
- setCurrentPluginID(plugin.plugin_id)
+ setSelectedItem({ type: 'plugin', id: plugin.plugin_id })
}}
>
@@ -186,10 +189,14 @@ const PluginItem: FC
= ({
}
/>
-
-
+
+
e.stopPropagation()}
>
= ({
-
+
{/* Organization & Name */}
{
- const currentPluginID = usePluginPageContext((v) => v.currentPluginID)
- const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
+ const selectedItem = usePluginPageContext((v) => v.selectedItem)
+ const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem)
const options = usePluginPageContext((v) => v.options)
return (
- {currentPluginID ?? 'none'}
+
+ {selectedItem ? `${selectedItem.type}:${selectedItem.id}` : 'none'}
+
{options.length}
- setCurrentPluginID('plugin-1')}>select plugin
+ setSelectedItem({ type: 'builtinTool', id: 'builtin-1' })}>
+ select builtin tool
+
+ setSelectedItem({ type: 'plugin', id: 'plugin-1' })}>
+ select plugin
+
)
}
@@ -62,7 +70,9 @@ describe('PluginPageContextProvider', () => {
expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('1')
})
- it('keeps the query-state tab and updates the current plugin id', () => {
+ it('keeps the query-state tab and replaces the selected item', async () => {
+ const user = userEvent.setup()
+
renderWithProviders(
@@ -70,9 +80,17 @@ describe('PluginPageContextProvider', () => {
{ enableMarketplace: true, searchParams: '?tab=discover' },
)
- fireEvent.click(screen.getByText('select plugin'))
+ await user.click(screen.getByRole('button', { name: 'select builtin tool' }))
- expect(screen.getByRole('status', { name: 'Current plugin' })).toHaveTextContent('plugin-1')
+ expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent(
+ 'builtinTool:builtin-1',
+ )
+
+ await user.click(screen.getByRole('button', { name: 'select plugin' }))
+
+ expect(screen.getByRole('status', { name: 'Selected item' })).toHaveTextContent(
+ 'plugin:plugin-1',
+ )
expect(screen.getByRole('status', { name: 'Available tabs' })).toHaveTextContent('2')
})
})
diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx
index 9f8a18453a1..c4b14947809 100644
--- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx
+++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx
@@ -1,6 +1,8 @@
import type { PluginDetail } from '../../types'
+import type { PluginPageSelection } from '../context'
import type { Collection } from '@/app/components/tools/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import {
getStepByStepTourTargetSelector,
@@ -15,14 +17,18 @@ const mockState = vi.hoisted(() => ({
tags: [] as string[],
searchQuery: '',
},
- currentPluginID: undefined as string | undefined,
+ selectedItem: undefined as PluginPageSelection | undefined,
}))
+const mockContextSubscribers = vi.hoisted(() => new Set<() => void>())
const mockSystemFeatures = vi.hoisted(() => ({
enableMarketplace: true,
}))
const mockSetFilters = vi.fn()
-const mockSetCurrentPluginID = vi.fn()
+const mockSetSelectedItem = vi.fn((item?: PluginPageSelection) => {
+ mockState.selectedItem = item
+ mockContextSubscribers.forEach((subscriber) => subscriber())
+})
const mockLoadNextPage = vi.fn()
const mockInvalidateInstalledPluginList = vi.fn()
const mockRemoveFilteredInstalledPluginPageOnUnmount = vi.fn()
@@ -55,22 +61,40 @@ vi.mock('../../hooks', () => ({
}),
}))
-vi.mock('../context', () => ({
- usePluginPageContext: (
- selector: (value: {
- filters: typeof mockState.filters
- setFilters: typeof mockSetFilters
- currentPluginID: string | undefined
- setCurrentPluginID: typeof mockSetCurrentPluginID
- }) => unknown,
- ) =>
- selector({
- filters: mockState.filters,
- setFilters: mockSetFilters,
- currentPluginID: mockState.currentPluginID,
- setCurrentPluginID: mockSetCurrentPluginID,
- }),
-}))
+vi.mock('../context', async () => {
+ const { useSyncExternalStore } = await import('react')
+
+ return {
+ usePluginPageContext: (
+ selector: (value: {
+ filters: typeof mockState.filters
+ setFilters: typeof mockSetFilters
+ selectedItem: PluginPageSelection | undefined
+ setSelectedItem: typeof mockSetSelectedItem
+ }) => unknown,
+ ) =>
+ useSyncExternalStore(
+ (subscriber) => {
+ mockContextSubscribers.add(subscriber)
+ return () => mockContextSubscribers.delete(subscriber)
+ },
+ () =>
+ selector({
+ filters: mockState.filters,
+ setFilters: mockSetFilters,
+ selectedItem: mockState.selectedItem,
+ setSelectedItem: mockSetSelectedItem,
+ }),
+ () =>
+ selector({
+ filters: mockState.filters,
+ setFilters: mockSetFilters,
+ selectedItem: mockState.selectedItem,
+ setSelectedItem: mockSetSelectedItem,
+ }),
+ ),
+ }
+})
vi.mock('../filter-management', () => ({
default: ({
@@ -140,13 +164,19 @@ vi.mock('../list', () => ({
}) => (
{pluginList.map((plugin, index) => (
-
mockSetSelectedItem({ type: 'plugin', id: plugin.plugin_id })}
>
{plugin.plugin_id}
-
+
))}
{children}
@@ -250,13 +280,14 @@ vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({
detail?: PluginDetail
onHide: () => void
onUpdate: () => void
- }) => (
-
- {detail?.plugin_id ?? 'none'}
- hide detail
- refresh detail
-
- ),
+ }) =>
+ detail ? (
+
+ {detail.plugin_id}
+ hide detail
+ refresh detail
+
+ ) : null,
}))
const createPlugin = (
@@ -324,7 +355,7 @@ describe('PluginsPanel', () => {
},
)
mockState.filters = { categories: [], tags: [], searchQuery: '' }
- mockState.currentPluginID = undefined
+ mockState.selectedItem = undefined
mockUseInstalledPluginList.mockReturnValue({
data: { plugins: [] },
isLoading: false,
@@ -544,6 +575,43 @@ describe('PluginsPanel', () => {
expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument()
})
+ it('replaces the builtin tool detail when an installed plugin is selected', async () => {
+ vi.useRealTimers()
+ const user = userEvent.setup()
+ mockPluginListWithLatestVersion.mockReturnValue([
+ createPlugin('tool-plugin', 'Tool Plugin', [], PluginCategoryEnum.tool),
+ ])
+ mockUseInstalledPluginList.mockReturnValue({
+ data: {
+ plugins: [],
+ builtin_tools: [createBuiltinTool('builtin-tool', 'Builtin Tool')],
+ },
+ isLoading: false,
+ isFetching: false,
+ isLastPage: true,
+ loadNextPage: mockLoadNextPage,
+ })
+
+ render( )
+
+ const builtinToolCard = screen.getByRole('button', { name: 'builtin-tool' })
+ const pluginCard = screen.getByRole('button', { name: 'tool-plugin' })
+
+ await user.click(builtinToolCard)
+
+ expect(builtinToolCard).toHaveAttribute('aria-pressed', 'true')
+ expect(pluginCard).toHaveAttribute('aria-pressed', 'false')
+ expect(screen.getByTestId('builtin-tool-detail')).toHaveTextContent('builtin-tool')
+ expect(screen.queryByTestId('plugin-detail-panel')).not.toBeInTheDocument()
+
+ await user.click(pluginCard)
+
+ expect(pluginCard).toHaveAttribute('aria-pressed', 'true')
+ expect(builtinToolCard).toHaveAttribute('aria-pressed', 'false')
+ expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('tool-plugin')
+ expect(screen.queryByTestId('builtin-tool-detail')).not.toBeInTheDocument()
+ })
+
it('filters builtin tools with the tool integrations search query', () => {
mockState.filters.searchQuery = 'alpha'
mockUseInstalledPluginList.mockReturnValue({
@@ -898,7 +966,7 @@ describe('PluginsPanel', () => {
})
it('renders the empty state and keeps the current plugin detail in sync', () => {
- mockState.currentPluginID = 'beta-tool'
+ mockState.selectedItem = { type: 'plugin', id: 'beta-tool' }
mockState.filters.searchQuery = 'missing'
mockPluginListWithLatestVersion.mockReturnValue([createPlugin('beta-tool', 'Beta Tool')])
@@ -907,10 +975,10 @@ describe('PluginsPanel', () => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool')
- fireEvent.click(screen.getByText('hide detail'))
fireEvent.click(screen.getByText('refresh detail'))
+ fireEvent.click(screen.getByText('hide detail'))
- expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined)
+ expect(mockSetSelectedItem).toHaveBeenCalledWith(undefined)
expect(mockInvalidateInstalledPluginList).toHaveBeenCalled()
})
})
diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx
index 457ca4386a8..2985e8c68ea 100644
--- a/web/app/components/plugins/plugin-page/context-provider.tsx
+++ b/web/app/components/plugins/plugin-page/context-provider.tsx
@@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
-import type { PluginPageTab } from './context'
+import type { PluginPageSelection, PluginPageTab } from './context'
import type { FilterState } from './filter-management'
import { useSuspenseQuery } from '@tanstack/react-query'
import { parseAsStringEnum, useQueryState } from 'nuqs'
@@ -38,7 +38,7 @@ export const PluginPageContextProvider = ({
searchQuery: '',
},
)
- const [currentPluginID, setCurrentPluginID] = useState()
+ const [selectedItem, setSelectedItem] = useState()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@@ -56,8 +56,8 @@ export const PluginPageContextProvider = ({
- currentPluginID: string | undefined
- setCurrentPluginID: (pluginID?: string) => void
+ selectedItem: PluginPageSelection | undefined
+ setSelectedItem: (item?: PluginPageSelection) => void
filters: FilterState
setFilters: (filter: FilterState) => void
activeTab: PluginPageTab
@@ -26,8 +30,8 @@ const emptyContainerRef: RefObject = { current: null }
export const PluginPageContext = createContext({
containerRef: emptyContainerRef,
- currentPluginID: undefined,
- setCurrentPluginID: noop,
+ selectedItem: undefined,
+ setSelectedItem: noop,
filters: {
categories: [],
tags: [],
diff --git a/web/app/components/plugins/plugin-page/nav-operations.tsx b/web/app/components/plugins/plugin-page/nav-operations.tsx
index 3624317de29..2c4ded2e8d8 100644
--- a/web/app/components/plugins/plugin-page/nav-operations.tsx
+++ b/web/app/components/plugins/plugin-page/nav-operations.tsx
@@ -75,10 +75,18 @@ type SubmitRequestDropdownProps = {
dividerAfterFirst?: boolean
}
-export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) {
+type SubmitRequestDropdownMenuProps = SubmitRequestDropdownProps & {
+ docLink: (path: DocPathWithoutLang) => string
+}
+
+// Presentational dropdown. Callers that cannot use useDocLink() — standalone
+// Marketplace SSR — pass a locale-composed docLink instead.
+export function SubmitRequestDropdownMenu({
+ dividerAfterFirst,
+ docLink,
+}: SubmitRequestDropdownMenuProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
- const docLink = useDocLink()
const options = getOptions(docLink)
return (
@@ -112,3 +120,8 @@ export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdo
)
}
+
+export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) {
+ const docLink = useDocLink()
+ return
+}
diff --git a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx
index 1031fbf45e4..3d0f46513f9 100644
--- a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx
+++ b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx
@@ -43,8 +43,8 @@ type PluginsPanelResultsProps = {
isLastPage: boolean
keywords: string
loadNextPage: () => void
+ onSelectBuiltinTool: (id: string) => void
scrollAreaLabel?: string
- setCurrentBuiltinToolID: (id: string) => void
showCategoryEmptyState: boolean
tagFilterValue: string[]
}
@@ -71,8 +71,8 @@ const PluginsPanelResults = ({
isLastPage,
keywords,
loadNextPage,
+ onSelectBuiltinTool,
scrollAreaLabel,
- setCurrentBuiltinToolID,
showCategoryEmptyState,
tagFilterValue,
}: PluginsPanelResultsProps) => {
@@ -152,7 +152,7 @@ const PluginsPanelResults = ({
data-step-by-step-tour-target={
filteredList.length === 0 && index === 0 ? firstBuiltinToolTarget : undefined
}
- onClick={() => setCurrentBuiltinToolID(collection.id)}
+ onClick={() => onSelectBuiltinTool(collection.id)}
>
v.currentPluginID)
- const setCurrentPluginID = usePluginPageContext((v) => v.setCurrentPluginID)
- const [currentBuiltinToolID, setCurrentBuiltinToolID] = useState()
+ const selectedItem = usePluginPageContext((v) => v.selectedItem)
+ const setSelectedItem = usePluginPageContext((v) => v.setSelectedItem)
const containerRef = useRef(null)
const { run: handleFilterChange } = useDebounceFn(
@@ -186,18 +185,17 @@ const PluginsPanel = ({
sourceCount: categoryList.length + builtinTools.length,
})
- const currentPluginDetail = useMemo(() => {
- const detail = pluginListWithLatestVersion.find(
- (plugin) => plugin.plugin_id === currentPluginID,
- )
- return detail
- }, [currentPluginID, pluginListWithLatestVersion])
+ const currentPluginID = selectedItem?.type === 'plugin' ? selectedItem.id : undefined
+ const currentBuiltinToolID = selectedItem?.type === 'builtinTool' ? selectedItem.id : undefined
+ const currentPluginDetail = useMemo(
+ () => pluginListWithLatestVersion.find((plugin) => plugin.plugin_id === currentPluginID),
+ [currentPluginID, pluginListWithLatestVersion],
+ )
const currentBuiltinTool = useMemo(() => {
return filteredBuiltinTools.find((collection) => collection.id === currentBuiltinToolID)
}, [currentBuiltinToolID, filteredBuiltinTools])
- const handleHide = () => setCurrentPluginID(undefined)
- const handleBuiltinToolHide = () => setCurrentBuiltinToolID(undefined)
+ const handleDetailHide = () => setSelectedItem(undefined)
const hasToolMarketplacePanel = enableMarketplace && isToolIntegrationPage
const categoryMarketplace =
enableMarketplace && hasEmbeddedMarketplace ? fixedCategory : undefined
@@ -284,7 +282,7 @@ const PluginsPanel = ({
keywords={filters.searchQuery}
loadNextPage={loadNextPage}
scrollAreaLabel={scrollAreaLabel}
- setCurrentBuiltinToolID={setCurrentBuiltinToolID}
+ onSelectBuiltinTool={(id) => setSelectedItem({ type: 'builtinTool', id })}
tagFilterValue={filters.tags}
canDeletePlugin={canDeletePlugin}
canUpdatePlugin={canUpdatePlugin}
@@ -327,14 +325,14 @@ const PluginsPanel = ({
onUpdate={() => {
invalidateInstalledPluginList(fixedCategory)
}}
- onHide={handleHide}
+ onHide={handleDetailHide}
canDeletePlugin={canDeletePlugin}
canUpdatePlugin={canUpdatePlugin}
/>
{currentBuiltinTool && !currentBuiltinTool.plugin_id && (
)}
diff --git a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts
index 4bc90a1b9bb..f1d734768ee 100644
--- a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts
+++ b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts
@@ -14,7 +14,7 @@ import { useMarketplace } from '../hooks'
const mockQueryMarketplaceCollectionsAndPlugins = vi.fn()
const mockQueryPlugins = vi.fn()
const mockQueryPluginsWithDebounced = vi.fn()
-const mockResetPlugins = vi.fn()
+const mockResetQueryParams = vi.fn()
const mockFetchNextPage = vi.fn()
const mockUseMarketplaceCollectionsAndPlugins = vi.fn()
@@ -70,7 +70,7 @@ const setupHookMocks = (overrides?: {
})
mockUseMarketplacePlugins.mockReturnValue({
plugins: overrides?.plugins,
- resetPlugins: mockResetPlugins,
+ resetQueryParams: mockResetQueryParams,
queryPlugins: mockQueryPlugins,
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
isLoading: overrides?.isPluginsLoading ?? false,
@@ -125,7 +125,7 @@ describe('useMarketplace', () => {
})
expect(mockQueryPluginsWithDebounced).not.toHaveBeenCalled()
expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled()
- expect(mockResetPlugins).not.toHaveBeenCalled()
+ expect(mockResetQueryParams).not.toHaveBeenCalled()
})
it('should query plugins immediately when only tags are provided', async () => {
@@ -163,7 +163,7 @@ describe('useMarketplace', () => {
type: 'plugin',
})
})
- expect(mockResetPlugins).toHaveBeenCalledTimes(1)
+ expect(mockResetQueryParams).toHaveBeenCalledTimes(1)
})
})
diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts
index 1b692200c0b..2985e97dbc4 100644
--- a/web/app/components/tools/marketplace/hooks.ts
+++ b/web/app/components/tools/marketplace/hooks.ts
@@ -29,13 +29,13 @@ export const useMarketplace = (
} = useMarketplaceCollectionsAndPlugins()
const {
plugins,
- resetPlugins,
+ resetQueryParams,
queryPlugins,
isLoading: isPluginsLoading,
fetchNextPage,
hasNextPage,
page: pluginsPage,
- } = useMarketplacePlugins()
+ } = useMarketplacePlugins(enabled)
const searchPluginTextRef = useRef(searchPluginText)
const filterPluginTagsRef = useRef(filterPluginTags)
@@ -72,7 +72,7 @@ export const useMarketplace = (
exclude,
type: 'plugin',
})
- resetPlugins()
+ resetQueryParams()
}
}
}, [
@@ -80,7 +80,7 @@ export const useMarketplace = (
filterPluginTags,
queryPlugins,
queryMarketplaceCollectionsAndPlugins,
- resetPlugins,
+ resetQueryParams,
exclude,
enabled,
isSuccess,
diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx
index d7a61199c20..77bd5373d67 100644
--- a/web/app/components/workflow/__tests__/custom-edge.spec.tsx
+++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx
@@ -1,9 +1,11 @@
import type { ReactNode } from 'react'
-import { render, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { Position } from 'reactflow'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import CustomEdge from '../custom-edge'
import { BlockEnum, NodeRunningStatus } from '../types'
+import { renderWorkflowComponent } from './workflow-test-env'
const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
@@ -38,6 +40,9 @@ vi.mock('reactflow', () => ({
Right: 'right',
Left: 'left',
},
+ useStoreApi: () => ({
+ getState: () => ({ getNodes: () => [] }),
+ }),
}))
vi.mock('../hooks/use-available-blocks', async (importOriginal) => {
@@ -81,8 +86,10 @@ describe('CustomEdge', () => {
})
})
- it('should render a gradient edge and its real insert-node trigger', () => {
- render(
+ it('should render a gradient edge and hide the start tab from its insert-node selector', async () => {
+ const user = userEvent.setup()
+
+ renderWorkflowComponent(
{
opacity: '0.7',
zIndex: '1001',
})
+
+ await user.click(addBlockTrigger)
+
+ expect(screen.queryByRole('tab', { name: 'workflow.tabs.start' })).not.toBeInTheDocument()
})
it('should prefer the running stroke color when the edge is selected', () => {
- render(
+ renderWorkflowComponent(
{
})
it('should use the fail-branch running color while the connected node is hovering', () => {
- render(
+ renderWorkflowComponent(
{
})
it('should fall back to the default edge color when no highlight state is active', () => {
- render(
+ renderWorkflowComponent(
{
})
describe('inContainer filtering', () => {
- it('should exclude Iteration, Loop, End, DataSource, KnowledgeBase, HumanInput when inContainer=true', () => {
+ it('should allow HumanInput while excluding unsupported blocks when inContainer=true', () => {
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM, true), {
hooksStoreProps,
})
@@ -155,7 +155,7 @@ describe('useAvailableBlocks', () => {
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.End)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.DataSource)
expect(result.current.availableNextBlocks).not.toContain(BlockEnum.KnowledgeBase)
- expect(result.current.availableNextBlocks).not.toContain(BlockEnum.HumanInput)
+ expect(result.current.availableNextBlocks).toContain(BlockEnum.HumanInput)
})
it('should exclude LoopEnd when not in container', () => {
diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts
index 0eab5ad8af2..2d2353b6971 100644
--- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts
@@ -1183,15 +1183,14 @@ describe('useNodesInteractions', () => {
)
})
- // Nested container paste restrictions should stay aligned with available block filtering.
- describe('nested container paste restrictions', () => {
+ // Nested container paste behavior should stay aligned with available block filtering.
+ describe('nested container paste behavior', () => {
const disallowedNestedPasteNodeTypes = [
BlockEnum.End,
BlockEnum.Iteration,
BlockEnum.Loop,
BlockEnum.DataSource,
BlockEnum.KnowledgeBase,
- BlockEnum.HumanInput,
]
const createNodeMeta = (type: BlockEnum) => ({
@@ -1205,7 +1204,7 @@ describe('useNodesInteractions', () => {
},
})
- const runDisallowedPasteScenario = async (
+ const pasteNodeIntoContainer = async (
containerType: BlockEnum.Iteration | BlockEnum.Loop,
nodeType: BlockEnum,
) => {
@@ -1263,23 +1262,48 @@ describe('useNodesInteractions', () => {
const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[]
- expect(pastedNodes).toHaveLength(1)
- expect(pastedNodes[0]?.id).toBe(containerId)
- expect(pastedNodes[0]?.data._children).toEqual([])
- expect(
- pastedNodes.some((node) => node.data.type === nodeType && node.parentId === containerId),
- ).toBe(false)
+ return { containerId, pastedNodes }
}
it.each(disallowedNestedPasteNodeTypes)(
'should not paste %s into an iteration container',
async (nodeType) => {
- await runDisallowedPasteScenario(BlockEnum.Iteration, nodeType)
+ const { containerId, pastedNodes } = await pasteNodeIntoContainer(
+ BlockEnum.Iteration,
+ nodeType,
+ )
+
+ expect(pastedNodes).toHaveLength(1)
+ expect(pastedNodes[0]?.id).toBe(containerId)
+ expect(pastedNodes[0]?.data._children).toEqual([])
},
)
- it('should not paste human-input into a loop container', async () => {
- await runDisallowedPasteScenario(BlockEnum.Loop, BlockEnum.HumanInput)
- })
+ it.each([BlockEnum.Iteration, BlockEnum.Loop] as const)(
+ 'should paste human-input into a %s container',
+ async (containerType) => {
+ const { containerId, pastedNodes } = await pasteNodeIntoContainer(
+ containerType,
+ BlockEnum.HumanInput,
+ )
+ const container = pastedNodes.find((node) => node.id === containerId)
+ const pastedHumanInput = pastedNodes.find(
+ (node) => node.data.type === BlockEnum.HumanInput && node.parentId === containerId,
+ )
+ const isIteration = containerType === BlockEnum.Iteration
+
+ expect(pastedHumanInput).toBeDefined()
+ expect(pastedHumanInput?.data).toMatchObject({
+ isInIteration: isIteration,
+ iteration_id: isIteration ? containerId : undefined,
+ isInLoop: !isIteration,
+ loop_id: isIteration ? undefined : containerId,
+ })
+ expect(container?.data._children).toContainEqual({
+ nodeId: pastedHumanInput?.id,
+ nodeType: BlockEnum.HumanInput,
+ })
+ },
+ )
})
})
diff --git a/web/app/components/workflow/hooks/use-available-blocks.ts b/web/app/components/workflow/hooks/use-available-blocks.ts
index 675a36be49a..6ebb1933f28 100644
--- a/web/app/components/workflow/hooks/use-available-blocks.ts
+++ b/web/app/components/workflow/hooks/use-available-blocks.ts
@@ -11,8 +11,7 @@ const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => {
nodeType === BlockEnum.Loop ||
nodeType === BlockEnum.End ||
nodeType === BlockEnum.DataSource ||
- nodeType === BlockEnum.KnowledgeBase ||
- nodeType === BlockEnum.HumanInput)
+ nodeType === BlockEnum.KnowledgeBase)
)
return false
diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts
index 4b2ea7bf6a8..6d38ddf853f 100644
--- a/web/app/components/workflow/hooks/use-nodes-interactions.ts
+++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts
@@ -1786,7 +1786,6 @@ export const useNodesInteractions = () => {
BlockEnum.Loop,
BlockEnum.DataSource,
BlockEnum.KnowledgeBase,
- BlockEnum.HumanInput,
]
// Same-canvas copy keeps the source container selected, so only treat a
// selected container as the paste target when it is not part of the clipboard.
diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx
index 490f656f284..b659664ddfb 100644
--- a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx
+++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { NodeSourceHandle, NodeTargetHandle } from '../node-handle'
@@ -210,6 +211,16 @@ describe('node-handle', () => {
// Target-side tests cover selector visibility, connection locking, and status rendering.
describe('NodeTargetHandle', () => {
+ it('should show the start tab when adding a node before the target node', async () => {
+ const user = userEvent.setup()
+
+ renderTargetHandle()
+
+ await user.click(screen.getByTestId('handle-target-handle'))
+
+ expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toBeInTheDocument()
+ })
+
it('should toggle the target add trigger', () => {
renderTargetHandle()
@@ -260,6 +271,16 @@ describe('node-handle', () => {
// Source-side tests cover selector opening paths, previous-node selection, and status styling.
describe('NodeSourceHandle', () => {
+ it('should show the start tab when adding a node after the source node', async () => {
+ const user = userEvent.setup()
+
+ renderSourceHandle()
+
+ await user.click(screen.getByTestId('handle-source-handle'))
+
+ expect(screen.getByRole('tab', { name: 'workflow.tabs.start' })).toBeInTheDocument()
+ })
+
it('should toggle the source add trigger', () => {
renderSourceHandle()
diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx
index 955e5eb2f87..30a22ab512c 100644
--- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx
+++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx
@@ -79,6 +79,7 @@ export const NodeTargetHandle = memo(
'z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!',
'after:absolute after:top-1 after:left-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
+ open && 'scale-125',
data._runningStatus === NodeRunningStatus.Succeeded &&
'after:bg-workflow-link-line-success-handle',
data._runningStatus === NodeRunningStatus.Failed &&
@@ -106,6 +107,7 @@ export const NodeTargetHandle = memo(
nextNodeTargetHandle: handleId,
}}
placement="left"
+ showStartTab
triggerClassName={`
absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150
${nodeSelectorClassName}
@@ -206,6 +208,7 @@ export const NodeSourceHandle = memo(
'group/handle z-1 size-4! rounded-none! border-none! bg-transparent! outline-hidden!',
'after:absolute after:top-1 after:right-1.5 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
+ open && 'scale-125',
data._runningStatus === NodeRunningStatus.Succeeded &&
'after:bg-workflow-link-line-success-handle',
data._runningStatus === NodeRunningStatus.Failed &&
@@ -252,6 +255,7 @@ export const NodeSourceHandle = memo(
data-popup-open:opacity-100
`}
availableBlocksTypes={availableNextBlocks}
+ showStartTab
/>
)}
diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx
index 4cface45094..d53e1150749 100644
--- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx
+++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx
@@ -263,30 +263,11 @@ vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({
onSaved,
}: {
open: boolean
- onSaved: (binding: {
- agent_id?: string | null
- binding_type: 'inline_agent' | 'roster_agent'
- current_snapshot_id?: string | null
- id: string
- node_id: string
- workflow_id: string
- }) => void
+ onSaved: (agentId: string) => void
}) =>
open ? (
-
- onSaved({
- id: 'binding-1',
- binding_type: 'roster_agent',
- agent_id: 'saved-roster-agent',
- current_snapshot_id: 'saved-snapshot',
- workflow_id: 'workflow-1',
- node_id: 'agent-node',
- })
- }
- >
+ onSaved('saved-roster-agent')}>
Save inline agent to roster
diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx
index 97a0ff5323c..2e6a40f8560 100644
--- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx
+++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx
@@ -1,5 +1,5 @@
import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen'
-import { render, screen, within } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FlowType } from '@/types/common'
import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog'
@@ -112,7 +112,6 @@ const renderDialog = (agent: AgentComposerAgentResponse = inlineAgent) => {
{
{
}),
)
})
+
+ it('keeps one source snapshot while open and uses the latest agent after reopening', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+ const onSaved = vi.fn()
+ const updatedInlineAgent = {
+ ...inlineAgent,
+ description: 'Updated source description.',
+ icon: '🦊',
+ icon_background: '#FFEDD5',
+ role: 'Updated source role',
+ }
+ const { rerender } = render(
+ ,
+ )
+
+ rerender(
+ ,
+ )
+
+ let dialog = screen.getByRole('dialog', {
+ name: 'agentV2.roster.saveToRosterDialog.title',
+ })
+ expect(
+ within(dialog).getByRole('textbox', {
+ name: 'agentV2.roster.createForm.roleLabel common.label.optional',
+ }),
+ ).toHaveValue('Tender Analyst')
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: 'agentV2.roster.saveToRosterForm.changeIcon',
+ }),
+ )
+ expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument()
+
+ rerender(
+ ,
+ )
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ rerender(
+ ,
+ )
+ dialog = screen.getByRole('dialog', {
+ name: 'agentV2.roster.saveToRosterDialog.title',
+ })
+ expect(
+ within(dialog).getByRole('textbox', {
+ name: 'agentV2.roster.createForm.roleLabel common.label.optional',
+ }),
+ ).toHaveValue('Updated source role')
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: 'agentV2.roster.saveToRosterForm.changeIcon',
+ }),
+ )
+ expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument()
+ })
+
+ it('returns only the saved roster agent id after a successful save', async () => {
+ const user = userEvent.setup()
+ const { onOpenChange, onSaved } = renderDialog()
+
+ const dialog = screen.getByRole('dialog', {
+ name: 'agentV2.roster.saveToRosterDialog.title',
+ })
+ await user.type(
+ within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }),
+ 'Roster Tender Agent',
+ )
+ await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
+
+ const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1]
+ mutationOptions.onSuccess({
+ binding: {
+ agent_id: 'roster-agent-1',
+ binding_type: 'roster_agent',
+ },
+ })
+
+ expect(onSaved).toHaveBeenCalledWith('roster-agent-1')
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ expect(toastMock.success).not.toHaveBeenCalled()
+ })
})
diff --git a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx
index 318d9be15cb..69f4f72b89d 100644
--- a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx
+++ b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx
@@ -1,9 +1,9 @@
'use client'
import type {
AgentComposerAgentResponse,
- AgentComposerBindingResponse,
WorkflowAgentComposerResponse,
} from '@dify/contracts/api/console/apps/types.gen'
+import type { Ref } from 'react'
import type {
AgentFormValues,
AgentIconSelection,
@@ -18,34 +18,100 @@ import {
} from '@langgenius/dify-ui/dialog'
import { Form } from '@langgenius/dify-ui/form'
import { IconButton } from '@langgenius/dify-ui/icon-button'
-import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIconPicker from '@/app/components/base/app-icon-picker'
-import {
- createAgentIconSelection,
- defaultAgentIcon,
-} from '@/features/agent-v2/roster/components/agent-form'
+import { createAgentIconSelection } from '@/features/agent-v2/roster/components/agent-form'
import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields'
import { consoleQuery } from '@/service/client'
import { FlowType } from '@/types/common'
type SaveInlineAgentToRosterDialogProps = {
- flowId?: string
- flowType?: FlowType
- formKey: number
- initialAgent?: AgentComposerAgentResponse | null
+ flowId: string
+ flowType: FlowType.appFlow | FlowType.snippet
+ initialAgent: AgentComposerAgentResponse
nodeId: string
open: boolean
onOpenChange: (open: boolean) => void
- onSaved: (binding: AgentComposerBindingResponse) => void
+ onSaved: (agentId: string) => void
+}
+
+type SaveInlineAgentToRosterFormSessionProps = {
+ initialAgent: AgentComposerAgentResponse
+ nameInputRef: Ref
+ pending: boolean
+ onCancel: () => void
+ onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void
+}
+
+function SaveInlineAgentToRosterFormSession({
+ initialAgent,
+ nameInputRef,
+ pending,
+ onCancel,
+ onSubmit,
+}: SaveInlineAgentToRosterFormSessionProps) {
+ const { t } = useTranslation('agentV2')
+ const { t: tCommon } = useTranslation('common')
+ const [initialValues] = useState(() => ({
+ fields: {
+ description: initialAgent.description ?? '',
+ name: '',
+ role: initialAgent.role ?? '',
+ } satisfies AgentFormValues,
+ icon: createAgentIconSelection(initialAgent),
+ }))
+ const [agentIcon, setAgentIcon] = useState(initialValues.icon)
+ const [iconPickerOpen, setIconPickerOpen] = useState(false)
+
+ return (
+ <>
+
+
+ {t(($) => $['roster.saveToRosterDialog.title'])}
+
+
+ {t(($) => $['roster.saveToRosterDialog.description'])}
+
+
+
+
+ >
+ )
}
export function SaveInlineAgentToRosterDialog({
flowId,
flowType,
- formKey,
initialAgent,
nodeId,
open,
@@ -53,14 +119,7 @@ export function SaveInlineAgentToRosterDialog({
onSaved,
}: SaveInlineAgentToRosterDialogProps) {
const { t } = useTranslation('agentV2')
- const { t: tCommon } = useTranslation('common')
- const [name, setName] = useState('')
- const [description, setDescription] = useState(initialAgent?.description ?? '')
- const [role, setRole] = useState(initialAgent?.role ?? '')
- const [iconPickerOpen, setIconPickerOpen] = useState(false)
- const [agentIcon, setAgentIcon] = useState(() =>
- initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon,
- )
+ const nameInputRef = useRef(null)
const appSaveToRosterMutation = useMutation(
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(),
)
@@ -69,33 +128,20 @@ export function SaveInlineAgentToRosterDialog({
)
const isSavingToRoster =
appSaveToRosterMutation.isPending || snippetSaveToRosterMutation.isPending
-
const handleOpenChange = (nextOpen: boolean) => {
- if (nextOpen) {
- setName('')
- setDescription(initialAgent?.description ?? '')
- setRole(initialAgent?.role ?? '')
- setAgentIcon(initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon)
- } else {
- setIconPickerOpen(false)
- }
+ if (!nextOpen && isSavingToRoster) return
onOpenChange(nextOpen)
}
- const handleSubmit = (formValues: AgentFormValues) => {
+ const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => {
if (isSavingToRoster) return
- if (!flowId) return
-
- const trimmedName = formValues.name?.trim() ?? ''
- const trimmedRole = formValues.role?.trim() ?? ''
-
const body = {
variant: 'workflow' as const,
save_strategy: 'save_to_roster' as const,
- new_agent_name: trimmedName,
- description: formValues.description?.trim() ?? '',
- role: trimmedRole,
+ new_agent_name: formValues.name.trim(),
+ description: formValues.description.trim(),
+ role: formValues.role.trim(),
icon_type: agentIcon.type,
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
@@ -105,9 +151,8 @@ export function SaveInlineAgentToRosterDialog({
const binding = composerState.binding
if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return
- toast.success(t(($) => $['roster.saveToRosterSuccess']))
- onSaved(binding)
- handleOpenChange(false)
+ onSaved(binding.agent_id)
+ onOpenChange(false)
},
}
@@ -142,75 +187,31 @@ export function SaveInlineAgentToRosterDialog({
return (
<>
-
+
$['operation.close'], { ns: 'common' })}
size="lg"
- className="absolute inset-e-6 top-6"
+ className="absolute inset-e-5 top-5"
>
}
/>
-
-
- {t(($) => $['roster.saveToRosterDialog.title'])}
-
-
- {t(($) => $['roster.saveToRosterDialog.description'])}
-
-
-
+ onOpenChange(false)}
+ onSubmit={handleSubmit}
+ />
- {
- setAgentIcon(icon)
- }}
- />
>
)
}
diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx
index ee5c6b9e6b5..1c12d645377 100644
--- a/web/app/components/workflow/nodes/agent-v2/panel.tsx
+++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx
@@ -131,7 +131,6 @@ export function AgentV2Panel({ id, data }: NodePanelProps) {
requestKey: number
} | null>(null)
const [isOutputVariablesCollapsed, setIsOutputVariablesCollapsed] = useState(true)
- const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0)
const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const openInlineAgentPanelNodeId = useStore((state) => state.openInlineAgentPanelNodeId)
const setOpenInlineAgentPanelNodeId = useStore((state) => state.setOpenInlineAgentPanelNodeId)
@@ -186,7 +185,12 @@ export function AgentV2Panel({ id, data }: NodePanelProps) {
const isAgentBindingPending =
isInlineAgentPending || isInlineAgentWaitingForCreation || isCreatingInlineAgent
const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent'
- const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent
+ const saveToRosterTarget =
+ configsMap?.flowId &&
+ (configsMap.flowType === FlowType.appFlow || configsMap.flowType === FlowType.snippet)
+ ? { flowId: configsMap.flowId, flowType: configsMap.flowType }
+ : null
+ const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent && !!saveToRosterTarget
const inlineComposerStateForPanel = inlineAgentQuery.data
const displayedAgent =
rosterAgentQuery.data ??
@@ -378,14 +382,11 @@ export function AgentV2Panel({ id, data }: NodePanelProps) {
])
const handleSaveInlineToRosterOpen = useCallback(() => {
- setSaveToRosterSessionKey((key) => key + 1)
setIsSaveToRosterDialogOpen(true)
}, [])
const handleInlineSavedToRoster = useCallback(
- (binding: AgentComposerBindingResponse) => {
- if (binding.binding_type !== 'roster_agent' || !binding.agent_id) return
-
+ (agentId: string) => {
setOpenInlineAgentPanelNodeId(undefined)
setIsInlineAgentPanelOpenedFromTrigger(false)
setIsRosterAgentPanelOpen(true)
@@ -395,7 +396,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps) {
delete draft._openInlineAgentPanel
draft.agent_binding = {
binding_type: 'roster_agent',
- agent_id: binding.agent_id!,
+ agent_id: agentId,
}
})
inputsRef.current = newInputs
@@ -698,17 +699,17 @@ export function AgentV2Panel({ id, data }: NodePanelProps) {
onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined}
onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined}
/>
-
+ {saveToRosterTarget && inlineAgent && (
+
+ )}
{
).toEqual({ kind: 'absolute', href: redirectUrl })
})
+ it('should reject a Marketplace origin that is not a trusted Dify login target', () => {
+ const searchParams = new URLSearchParams({
+ redirect_url: 'http://localhost:3001/plugin/langgenius/openai?tab=reviews#rating',
+ })
+
+ expect(
+ resolvePostLoginRedirect(
+ searchParams as unknown as Parameters
[0],
+ ),
+ ).toEqual({ kind: 'internal', href: '/' })
+ })
+
it('should use the default target instead of a stored device target when the query target is invalid', () => {
setPostLoginRedirect('/device?user_code=ABCD')
const searchParams = new URLSearchParams({ redirect_url: 'https://google.com' })
@@ -109,4 +121,15 @@ describe('post-login redirect utilities', () => {
expect(resolvePostLoginRedirect()).toEqual({ kind: 'internal', href: '/' })
})
+
+ it('should preserve every Marketplace OAuth authorize parameter across signin', () => {
+ setPostLoginRedirect(
+ '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code',
+ )
+
+ expect(resolvePostLoginRedirect()).toEqual({
+ kind: 'internal',
+ href: '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code',
+ })
+ })
})
diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts
index 8d9991a51f0..fc4ecaaca43 100644
--- a/web/app/signin/utils/post-login-redirect.ts
+++ b/web/app/signin/utils/post-login-redirect.ts
@@ -7,7 +7,13 @@ const DEVICE_TTL_MS = 15 * 60 * 1000
const ALLOWED: Record> = {
'/device': new Set(['user_code', 'sso_verified']),
- '/account/oauth/authorize': new Set(['client_id', 'scope', 'state', 'redirect_uri']),
+ '/account/oauth/authorize': new Set([
+ 'client_id',
+ 'redirect_uri',
+ 'response_type',
+ 'scope',
+ 'state',
+ ]),
}
function validateDeviceRedirect(target: string): string | null {
diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx
index 9e0e66bbbb1..3287a77cd5a 100644
--- a/web/context/__tests__/console-bootstrap.spec.tsx
+++ b/web/context/__tests__/console-bootstrap.spec.tsx
@@ -186,6 +186,8 @@ vi.mock('@/app/components/base/amplitude/use-amplitude-initialized', () => ({
vi.mock('@/app/components/base/amplitude/registration-tracking', () => ({
flushRegistrationSuccess: vi.fn(),
+ subscribeRegistrationSuccess: () => () => {},
+ getRegistrationSuccessSnapshot: () => 0,
}))
vi.mock('@/app/components/base/zendesk/utils', () => ({
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
index d1fcfb76586..a04a731b2e8 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
@@ -471,7 +471,7 @@ function AgentVersionRestoreBar({
diff --git a/web/features/agent-v2/agent-detail/sidebar-actions.tsx b/web/features/agent-v2/agent-detail/sidebar-actions.tsx
index 03385bfda6e..4e04ed08d54 100644
--- a/web/features/agent-v2/agent-detail/sidebar-actions.tsx
+++ b/web/features/agent-v2/agent-detail/sidebar-actions.tsx
@@ -1,6 +1,7 @@
'use client'
import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
+import type { AgentFormSource } from '@/features/agent-v2/roster/components/agent-form'
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,50 +18,22 @@ import { DuplicateAgentDialog } from '@/features/agent-v2/roster/components/dupl
import { EditAgentDialog } from '@/features/agent-v2/roster/components/edit-agent-dialog'
import { useRouter } from '@/next/navigation'
-type AgentDetailSidebarActionAgent = Pick<
- AgentAppPartial,
- | 'app_id'
- | 'description'
- | 'icon'
- | 'icon_background'
- | 'icon_type'
- | 'icon_url'
- | 'id'
- | 'mode'
- | 'name'
- | 'role'
->
+type AgentDetailSidebarActionAgent = AgentFormSource & Pick
export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebarActionAgent }) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { t: tApp } = useTranslation('app')
const [isEditOpen, setIsEditOpen] = useState(false)
- const [editSessionKey, setEditSessionKey] = useState(0)
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false)
- const [duplicateSessionKey, setDuplicateSessionKey] = useState(0)
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
const { exportAppDsl, isExporting } = useExportAppDsl()
const router = useRouter()
- const dialogAgent: AgentAppPartial = {
- description: agent.description,
- icon: agent.icon,
- icon_background: agent.icon_background,
- icon_type: agent.icon_type,
- icon_url: agent.icon_url,
- id: agent.id,
- mode: agent.mode,
- name: agent.name,
- role: agent.role,
- }
-
const handleEditOpen = () => {
- setEditSessionKey((key) => key + 1)
setIsEditOpen(true)
}
const handleDuplicateOpen = () => {
- setDuplicateSessionKey((key) => key + 1)
setIsDuplicateOpen(true)
}
@@ -112,15 +85,9 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar
-
+
diff --git a/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts b/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts
new file mode 100644
index 00000000000..d083bc288ae
--- /dev/null
+++ b/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts
@@ -0,0 +1,17 @@
+import { createAgentIconSelection } from '../agent-form'
+
+describe('createAgentIconSelection', () => {
+ it('uses the resolved image URL while preserving the uploaded file id', () => {
+ expect(
+ createAgentIconSelection({
+ icon: 'uploaded-file-id',
+ icon_type: 'image',
+ icon_url: 'https://example.com/resolved-agent-icon.png',
+ }),
+ ).toEqual({
+ type: 'image',
+ fileId: 'uploaded-file-id',
+ url: 'https://example.com/resolved-agent-icon.png',
+ })
+ })
+})
diff --git a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx
index 7423faf5c41..4890cc5a745 100644
--- a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx
+++ b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx
@@ -112,7 +112,7 @@ describe('CreateAgentDialog', () => {
mutationOptions.onSuccess({ id: 'agent-1' })
})
- expect(toastMock.success).toHaveBeenCalledWith('agentV2.roster.createSuccess')
+ expect(toastMock.success).not.toHaveBeenCalled()
expect(trackCreateAppMock).toHaveBeenCalledWith({
source: 'studio_blank',
appMode: 'agent-v2',
@@ -141,6 +141,44 @@ describe('CreateAgentDialog', () => {
expect(mutationMock.mutate).not.toHaveBeenCalled()
})
+ it('focuses the name field when opened and resets native form values after closing', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ const trigger = screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ })
+ await user.click(trigger)
+
+ let dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' })
+ const nameInput = within(dialog).getByRole('textbox', {
+ name: 'agentV2.roster.createForm.nameLabel',
+ })
+ const descriptionInput = within(dialog).getByRole('textbox', {
+ name: /agentV2\.roster\.createForm\.descriptionLabel/,
+ })
+ expect(nameInput).toHaveFocus()
+ expect(descriptionInput).toHaveAttribute('maxlength', '400')
+
+ await user.type(nameInput, 'Temporary Agent')
+ await user.type(descriptionInput, 'Temporary description')
+ await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('dialog', { name: 'agentV2.roster.createDialog.title' }),
+ ).not.toBeInTheDocument()
+ })
+
+ await user.click(trigger)
+ dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' })
+ expect(
+ within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }),
+ ).toHaveValue('')
+ expect(
+ within(dialog).getByRole('textbox', {
+ name: /agentV2\.roster\.createForm\.descriptionLabel/,
+ }),
+ ).toHaveValue('')
+ })
+
it('marks role and description as optional', async () => {
const user = userEvent.setup()
render( )
diff --git a/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx
new file mode 100644
index 00000000000..6087ddb4e97
--- /dev/null
+++ b/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx
@@ -0,0 +1,151 @@
+import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { DuplicateAgentDialog } from '../duplicate-agent-dialog'
+
+const queryDataMock = vi.hoisted(() => vi.fn())
+const mutationMock = vi.hoisted(() => ({
+ isPending: false,
+ mutate: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: () => mutationMock,
+ useQueryClient: () => ({
+ getQueryData: queryDataMock,
+ }),
+}))
+
+vi.mock('@/app/components/base/app-icon-picker', () => ({
+ __esModule: true,
+ default: ({
+ initialEmoji,
+ open,
+ }: {
+ initialEmoji?: { icon: string; background: string }
+ open: boolean
+ }) => (open ? {`${initialEmoji?.icon}:${initialEmoji?.background}`} : null),
+}))
+
+vi.mock('@/service/client', () => ({
+ consoleQuery: {
+ agent: {
+ byAgentId: {
+ copy: {
+ post: {
+ mutationOptions: vi.fn(() => ({})),
+ },
+ },
+ get: {
+ queryKey: vi.fn(() => ['agent']),
+ },
+ },
+ },
+ },
+}))
+
+const createAgent = (overrides: Partial = {}): AgentAppPartial => ({
+ description: 'Original description',
+ icon: '🧸',
+ icon_background: '#F5F3FF',
+ icon_type: 'emoji',
+ icon_url: null,
+ id: 'agent-1',
+ mode: 'agent',
+ name: 'Research Agent',
+ role: 'Research Assistant',
+ ...overrides,
+})
+
+describe('DuplicateAgentDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mutationMock.isPending = false
+ queryDataMock.mockReturnValue(undefined)
+ })
+
+ it('keeps one form snapshot while open and creates a new session after closing', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+ const updatedAgent = createAgent({
+ icon: '🦊',
+ icon_background: '#FFEDD5',
+ name: 'Updated Agent',
+ role: 'Updated Role',
+ })
+ const { rerender } = render(
+ ,
+ )
+
+ rerender( )
+
+ let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' })
+ expect(
+ within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }),
+ ).toHaveValue('Research Agent copy')
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: /agentV2\.roster\.duplicateForm\.changeIcon.*Research Agent/,
+ }),
+ )
+ expect(screen.getByText('🧸:#F5F3FF')).toBeInTheDocument()
+
+ rerender( )
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ rerender( )
+ dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' })
+ expect(
+ within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }),
+ ).toHaveValue('Updated Agent copy')
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: /agentV2\.roster\.duplicateForm\.changeIcon.*Updated Agent/,
+ }),
+ )
+ expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument()
+ })
+
+ it('starts a new form session when the agent identity changes', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+ const secondAgent = createAgent({
+ description: 'Second description',
+ id: 'agent-2',
+ name: 'Second Agent',
+ role: 'Second Role',
+ })
+ const { rerender } = render(
+ ,
+ )
+
+ rerender( )
+
+ const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' })
+ expect(
+ within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }),
+ ).toHaveValue('Second Agent copy')
+ await user.click(within(dialog).getByRole('button', { name: 'common.operation.duplicate' }))
+
+ expect(mutationMock.mutate).toHaveBeenCalledWith(
+ {
+ params: {
+ agent_id: 'agent-2',
+ },
+ body: {
+ name: 'Second Agent copy',
+ description: 'Second description',
+ role: 'Second Role',
+ icon_type: 'emoji',
+ icon: '🧸',
+ icon_background: '#F5F3FF',
+ },
+ },
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ }),
+ )
+ })
+})
diff --git a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx
index 8cee962675c..632e1fce60a 100644
--- a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx
+++ b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx
@@ -1,5 +1,5 @@
import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
-import { render, screen, within } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EditAgentDialog } from '../edit-agent-dialog'
@@ -27,19 +27,24 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
vi.mock('@/app/components/base/app-icon-picker', () => ({
__esModule: true,
default: ({
+ initialEmoji,
onSelect,
open,
}: {
+ initialEmoji?: { icon: string; background: string }
onSelect: (payload: { type: 'emoji'; icon: string; background: string }) => void
open: boolean
}) =>
open ? (
- onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
- >
- Select brain icon
-
+
+ {`${initialEmoji?.icon}:${initialEmoji?.background}`}
+ onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
+ >
+ Select brain icon
+
+
) : null,
}))
@@ -71,9 +76,9 @@ const createAgent = (overrides: Partial = {}): AgentAppPartial
const renderDialog = (agent = createAgent()) => {
const onOpenChange = vi.fn()
- render( )
+ const renderResult = render( )
- return { onOpenChange }
+ return { ...renderResult, onOpenChange }
}
describe('EditAgentDialog', () => {
@@ -154,12 +159,35 @@ describe('EditAgentDialog', () => {
expect(mutationOptions).not.toHaveProperty('onError')
})
+ it('closes without a redundant success toast after updating', async () => {
+ const user = userEvent.setup()
+ const { onOpenChange } = renderDialog()
+
+ const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
+ const roleInput = within(dialog).getByRole('textbox', {
+ name: /agentV2\.roster\.createForm\.roleLabel/,
+ })
+ await user.clear(roleInput)
+ await user.type(roleInput, 'Market Analyst')
+ await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
+
+ const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1]
+ mutationOptions.onSuccess()
+
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ expect(toastMock.success).not.toHaveBeenCalled()
+ })
+
it('submits selected icon fields when the roster icon changes', async () => {
const user = userEvent.setup()
renderDialog()
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
- await user.click(within(dialog).getByRole('button', { name: /agentV2\.roster\.editAgent/ }))
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: 'agentV2.roster.createForm.changeIcon',
+ }),
+ )
await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' }))
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
@@ -185,6 +213,159 @@ describe('EditAgentDialog', () => {
expect(mutationOptions).not.toHaveProperty('onError')
})
+ it('keeps the original form snapshot when the agent source changes while open', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+ const agent = createAgent()
+ const { rerender } = render( )
+
+ let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
+ expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
+
+ rerender(
+ ,
+ )
+
+ dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
+ expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
+ expect(
+ within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }),
+ ).toHaveValue('Research Agent')
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: 'agentV2.roster.createForm.changeIcon',
+ }),
+ )
+ expect(screen.getByText('🧸:#F5F3FF')).toBeInTheDocument()
+ })
+
+ it('keeps a user-selected icon when the agent source changes during the session', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+
+ const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: 'agentV2.roster.createForm.changeIcon',
+ }),
+ )
+ await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' }))
+
+ rerender(
+ ,
+ )
+
+ expect(screen.getByText('🧠:#E0F2FE')).toBeInTheDocument()
+ expect(
+ within(screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })).getByRole(
+ 'button',
+ { name: 'common.operation.save' },
+ ),
+ ).not.toBeDisabled()
+ })
+
+ it('creates a fresh form session from the latest agent after closing', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+ const agent = createAgent()
+ const { rerender } = render( )
+
+ const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
+ await user.click(
+ within(dialog).getByRole('button', {
+ name: 'agentV2.roster.createForm.changeIcon',
+ }),
+ )
+ await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' }))
+
+ rerender( )
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ rerender(
+ ,
+ )
+ const reopenedDialog = screen.getByRole('dialog', {
+ name: 'agentV2.roster.editDialog.title',
+ })
+ await user.click(
+ within(reopenedDialog).getByRole('button', {
+ name: 'agentV2.roster.createForm.changeIcon',
+ }),
+ )
+
+ expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument()
+ })
+
+ it('starts a new form session when the agent identity changes', async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+ const { rerender } = render(
+ ,
+ )
+
+ rerender(
+ ,
+ )
+
+ const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
+ const nameInput = within(dialog).getByRole('textbox', {
+ name: 'agentV2.roster.createForm.nameLabel',
+ })
+ expect(nameInput).toHaveValue('Second Agent')
+ expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
+
+ await user.clear(nameInput)
+ await user.type(nameInput, 'Renamed Second Agent')
+ await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
+
+ expect(mutationMock.mutate).toHaveBeenCalledWith(
+ {
+ params: {
+ agent_id: 'agent-2',
+ },
+ body: {
+ name: 'Renamed Second Agent',
+ description: 'Second description',
+ role: 'Second Role',
+ icon_type: 'emoji',
+ icon: '🦊',
+ icon_background: '#FFEDD5',
+ },
+ },
+ expect.objectContaining({
+ onSuccess: expect.any(Function),
+ }),
+ )
+ })
+
it('shows a field error when saving with an empty name', async () => {
const user = userEvent.setup()
renderDialog()
diff --git a/web/features/agent-v2/roster/components/agent-form-fields.tsx b/web/features/agent-v2/roster/components/agent-form-fields.tsx
index ff1b5caaf03..7203f173256 100644
--- a/web/features/agent-v2/roster/components/agent-form-fields.tsx
+++ b/web/features/agent-v2/roster/components/agent-form-fields.tsx
@@ -1,4 +1,5 @@
-import type { AgentIconSelection } from './agent-form'
+import type { Ref } from 'react'
+import type { AgentFormValues, AgentIconSelection } from './agent-form'
import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
@@ -6,34 +7,26 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
type AgentFormFieldsProps = {
- description: string
+ defaultValues: AgentFormValues
icon: AgentIconSelection
iconAriaLabel: string
- name: string
- onDescriptionChange: (description: string) => void
onIconClick: () => void
- onNameChange: (name: string) => void
- onRoleChange: (role: string) => void
- role: string
+ ref: Ref
}
export function AgentFormFields({
- description,
+ defaultValues,
icon,
iconAriaLabel,
- name,
- onDescriptionChange,
onIconClick,
- onNameChange,
- onRoleChange,
- role,
+ ref,
}: AgentFormFieldsProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
return (
-
-
+
+
-
+
@@ -106,9 +94,9 @@ export function AgentFormFields({
diff --git a/web/features/agent-v2/roster/components/agent-form.ts b/web/features/agent-v2/roster/components/agent-form.ts
index f726759d5dd..89c140fe4f6 100644
--- a/web/features/agent-v2/roster/components/agent-form.ts
+++ b/web/features/agent-v2/roster/components/agent-form.ts
@@ -1,11 +1,20 @@
+import type {
+ AgentAppCreatePayload,
+ AgentAppPartial,
+} from '@dify/contracts/api/console/agent/types.gen'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
+type AgentFormField = 'description' | 'name' | 'role'
+
export type AgentFormValues = {
- description?: string
- name?: string
- role?: string
+ [Field in AgentFormField]-?: NonNullable
}
+export type AgentFormSource = Pick<
+ AgentAppPartial,
+ 'description' | 'icon' | 'icon_background' | 'icon_type' | 'icon_url' | 'id' | 'name' | 'role'
+>
+
export type AgentIconSelection =
| AppIconSelection
| {
@@ -24,6 +33,7 @@ type AgentIconSource = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
+ icon_url?: string | null
}
export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelection => {
@@ -31,7 +41,7 @@ export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelec
return {
type: 'image',
fileId: agent.icon,
- url: agent.icon,
+ url: agent.icon_url ?? agent.icon,
}
}
diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx
index 64184d7b599..68847ce4c94 100644
--- a/web/features/agent-v2/roster/components/agent-roster-list.tsx
+++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx
@@ -205,8 +205,6 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const nameId = useId()
const descriptionId = useId()
const [activeDialog, setActiveDialog] = useState<'delete' | 'duplicate' | 'edit' | null>(null)
- const [editSessionKey, setEditSessionKey] = useState(0)
- const [duplicateSessionKey, setDuplicateSessionKey] = useState(0)
const { exportAppDsl, isExporting } = useExportAppDsl()
const updatedAt =
agent.updated_at != null
@@ -220,16 +218,19 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const hasPublishedReferences = publishedReferences.length > 0
const isDraft = agent.active_config_is_published !== true
const parsedIconType = zAgentIconType.safeParse(agent.icon_type).data
- const imageUrl = parsedIconType === 'image' || parsedIconType === 'link' ? agent.icon : undefined
+ const imageUrl =
+ parsedIconType === 'image'
+ ? (agent.icon_url ?? agent.icon)
+ : parsedIconType === 'link'
+ ? agent.icon
+ : undefined
const iconType = parsedIconType === 'link' ? 'image' : parsedIconType
const handleEditOpen = () => {
- setEditSessionKey((key) => key + 1)
setActiveDialog('edit')
}
const handleDuplicateOpen = () => {
- setDuplicateSessionKey((key) => key + 1)
setActiveDialog('duplicate')
}
@@ -370,13 +371,11 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
void
}
-export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) {
+type CreateAgentFormSessionProps = {
+ nameInputRef: Ref
+ pending: boolean
+ onCancel: () => void
+ onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void
+}
+
+const createAgentDefaultValues = {
+ description: '',
+ name: '',
+ role: '',
+} satisfies AgentFormValues
+
+function CreateAgentFormSession({
+ nameInputRef,
+ pending,
+ onCancel,
+ onSubmit,
+}: CreateAgentFormSessionProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
+ const [agentIcon, setAgentIcon] = useState(defaultAgentIcon)
+ const [iconPickerOpen, setIconPickerOpen] = useState(false)
+
+ return (
+ <>
+
+
+ {t(($) => $['roster.createDialog.title'])}
+
+
+ {t(($) => $['roster.createDialog.description'])}
+
+
+
+
+ >
+ )
+}
+
+export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) {
+ const { t } = useTranslation('agentV2')
const router = useRouter()
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
- const [formKey, setFormKey] = useState(0)
- const [name, setName] = useState('')
- const [description, setDescription] = useState('')
- const [role, setRole] = useState('')
- const [iconPickerOpen, setIconPickerOpen] = useState(false)
- const [agentIcon, setAgentIcon] = useState(defaultAgentIcon)
+ const nameInputRef = useRef(null)
const createAgentMutation = useMutation(consoleQuery.agent.post.mutationOptions())
- const resetForm = () => {
- setFormKey((key) => key + 1)
- setName('')
- setDescription('')
- setRole('')
- setAgentIcon(defaultAgentIcon)
- setIconPickerOpen(false)
+ const setDialogOpen = (nextOpen: boolean) => {
+ if (open === undefined) setUncontrolledOpen(nextOpen)
+ onOpenChange?.(nextOpen)
}
const handleOpenChange = (nextOpen: boolean) => {
- if (open === undefined) setUncontrolledOpen(nextOpen)
- onOpenChange?.(nextOpen)
- if (!nextOpen) resetForm()
+ if (!nextOpen && createAgentMutation.isPending) return
+ setDialogOpen(nextOpen)
}
- const handleSubmit = (formValues: AgentFormValues) => {
- const trimmedName = formValues.name?.trim() ?? ''
- const trimmedRole = formValues.role?.trim() ?? ''
+ const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => {
if (createAgentMutation.isPending) return
const body = {
- name: trimmedName,
- description: formValues.description?.trim() ?? '',
- role: trimmedRole,
+ name: formValues.name.trim(),
+ description: formValues.description.trim(),
+ role: formValues.role.trim(),
icon_type: agentIcon.type,
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
@@ -83,8 +138,7 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps
appMode: 'agent-v2',
agentScope: AgentScope.Global,
})
- toast.success(t(($) => $['roster.createSuccess']))
- handleOpenChange(false)
+ setDialogOpen(false)
router.push(getAgentDetailPath(createdAgent.id, 'configure'))
},
},
@@ -104,73 +158,30 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps
{t(($) => $['roster.createAgent'])}
)}
-
+
$['operation.close'], { ns: 'common' })}
size="lg"
- className="absolute inset-e-6 top-6"
+ className="absolute inset-e-5 top-5"
>
}
/>
-
-
- {t(($) => $['roster.createDialog.title'])}
-
-
- {t(($) => $['roster.createDialog.description'])}
-
-
-
+ setDialogOpen(false)}
+ onSubmit={handleSubmit}
+ />
-
>
)
}
diff --git a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx
index 779adcd1fd1..4f8ba601227 100644
--- a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx
+++ b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx
@@ -1,9 +1,7 @@
'use client'
-import type {
- AgentAppCopyPayload,
- AgentAppPartial,
-} from '@dify/contracts/api/console/agent/types.gen'
-import type { AgentFormValues, AgentIconSelection } from './agent-form'
+import type { AgentAppCopyPayload } from '@dify/contracts/api/console/agent/types.gen'
+import type { Ref } from 'react'
+import type { AgentFormSource, AgentFormValues, AgentIconSelection } from './agent-form'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@@ -12,37 +10,110 @@ import {
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
-import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { IconButton } from '@langgenius/dify-ui/icon-button'
-import { Input } from '@langgenius/dify-ui/input'
-import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { consoleQuery } from '@/service/client'
import { createAgentIconSelection } from './agent-form'
+import { AgentFormFields } from './agent-form-fields'
type DuplicateAgentDialogProps = {
- agent: AgentAppPartial
+ agent: AgentFormSource
open: boolean
onOpenChange: (open: boolean) => void
}
+type DuplicateAgentFormSessionProps = {
+ agent: AgentFormSource
+ nameInputRef: Ref
+ pending: boolean
+ onCancel: () => void
+ onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void
+}
+
const getDefaultCopyName = (name: string) => {
const suffix = ' copy'
return `${name.slice(0, 255 - suffix.length)}${suffix}`
}
-export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) {
+function DuplicateAgentFormSession({
+ agent,
+ nameInputRef,
+ pending,
+ onCancel,
+ onSubmit,
+}: DuplicateAgentFormSessionProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
+ const [initialValues] = useState(() => ({
+ fields: {
+ description: agent.description ?? '',
+ name: getDefaultCopyName(agent.name),
+ role: agent.role ?? '',
+ } satisfies AgentFormValues,
+ icon: createAgentIconSelection(agent),
+ sourceName: agent.name,
+ }))
+ const [agentIcon, setAgentIcon] = useState(initialValues.icon)
+ const [iconPickerOpen, setIconPickerOpen] = useState(false)
+
+ return (
+ <>
+
+
+ {t(($) => $['roster.duplicateDialog.title'])}
+
+
+ {t(($) => $['roster.duplicateDialog.description'], {
+ name: initialValues.sourceName,
+ })}
+
+
+
+
+ >
+ )
+}
+
+export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) {
+ const { t } = useTranslation('agentV2')
const queryClient = useQueryClient()
const latestAgent =
- queryClient.getQueryData(
+ queryClient.getQueryData(
consoleQuery.agent.byAgentId.get.queryKey({
input: {
params: {
@@ -51,30 +122,22 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge
},
}),
) ?? agent
- const [name, setName] = useState(() => getDefaultCopyName(latestAgent.name))
- const [description, setDescription] = useState(latestAgent.description ?? '')
- const [role, setRole] = useState(latestAgent.role ?? '')
- const [iconPickerOpen, setIconPickerOpen] = useState(false)
- const [agentIcon, setAgentIcon] = useState(() =>
- createAgentIconSelection(latestAgent),
- )
+ const nameInputRef = useRef(null)
const duplicateAgentMutation = useMutation(
consoleQuery.agent.byAgentId.copy.post.mutationOptions(),
)
const handleOpenChange = (nextOpen: boolean) => {
- if (!nextOpen) setIconPickerOpen(false)
+ if (!nextOpen && duplicateAgentMutation.isPending) return
onOpenChange(nextOpen)
}
- const handleSubmit = (formValues: AgentFormValues) => {
+ const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => {
if (duplicateAgentMutation.isPending) return
- const trimmedName = formValues.name?.trim() ?? ''
- const trimmedRole = formValues.role?.trim() ?? ''
const body: AgentAppCopyPayload = {
- name: trimmedName,
- description: formValues.description?.trim() ?? '',
- role: trimmedRole,
+ name: formValues.name.trim(),
+ description: formValues.description.trim(),
+ role: formValues.role.trim(),
icon_type: agentIcon.type,
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
@@ -90,7 +153,7 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge
{
onSuccess: () => {
toast.success(t(($) => $['roster.duplicateSuccess']))
- handleOpenChange(false)
+ onOpenChange(false)
},
},
)
@@ -99,140 +162,32 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge
return (
<>
-
+
$['operation.close'], { ns: 'common' })}
size="lg"
- className="absolute inset-e-6 top-6"
+ className="absolute inset-e-5 top-5"
>
}
/>
-
-
- {t(($) => $['roster.duplicateDialog.title'])}
-
-
- {t(($) => $['roster.duplicateDialog.description'], { name: latestAgent.name })}
-
-
-
+ onOpenChange(false)}
+ onSubmit={handleSubmit}
+ />
-
>
)
}
diff --git a/web/features/agent-v2/roster/components/edit-agent-dialog.tsx b/web/features/agent-v2/roster/components/edit-agent-dialog.tsx
index e1b586d75c7..a35cf69b820 100644
--- a/web/features/agent-v2/roster/components/edit-agent-dialog.tsx
+++ b/web/features/agent-v2/roster/components/edit-agent-dialog.tsx
@@ -1,9 +1,7 @@
'use client'
-import type {
- AgentAppPartial,
- AgentAppUpdatePayload,
-} from '@dify/contracts/api/console/agent/types.gen'
-import type { AgentFormValues, AgentIconSelection } from './agent-form'
+import type { AgentAppUpdatePayload } from '@dify/contracts/api/console/agent/types.gen'
+import type { ChangeEventHandler, Ref } from 'react'
+import type { AgentFormSource, AgentFormValues, AgentIconSelection } from './agent-form'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@@ -14,9 +12,8 @@ import {
} from '@langgenius/dify-ui/dialog'
import { Form } from '@langgenius/dify-ui/form'
import { IconButton } from '@langgenius/dify-ui/icon-button'
-import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { consoleQuery } from '@/service/client'
@@ -24,11 +21,19 @@ import { createAgentIconSelection, getAgentIconKey } from './agent-form'
import { AgentFormFields } from './agent-form-fields'
type EditAgentDialogProps = {
- agent: AgentAppPartial
+ agent: AgentFormSource
open: boolean
onOpenChange: (open: boolean) => void
}
+type EditAgentFormSessionProps = {
+ agent: AgentFormSource
+ nameInputRef: Ref
+ pending: boolean
+ onCancel: () => void
+ onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void
+}
+
const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection) => {
if (icon.type === 'emoji') {
body.icon_type = icon.type
@@ -42,133 +47,78 @@ const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection)
body.icon_background = undefined
}
-export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) {
+function EditAgentFormSession({
+ agent,
+ nameInputRef,
+ pending,
+ onCancel,
+ onSubmit,
+}: EditAgentFormSessionProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
- const [name, setName] = useState(agent.name)
- const [description, setDescription] = useState(agent.description ?? '')
- const [role, setRole] = useState(agent.role ?? '')
+ const [initialValues] = useState(() => ({
+ fields: {
+ description: agent.description ?? '',
+ name: agent.name,
+ role: agent.role ?? '',
+ } satisfies AgentFormValues,
+ icon: createAgentIconSelection(agent),
+ }))
+ const [agentIcon, setAgentIcon] = useState(initialValues.icon)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
- const [agentIcon, setAgentIcon] = useState(() =>
- createAgentIconSelection(agent),
- )
- const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions())
+ const [hasTextChanges, setHasTextChanges] = useState(false)
+ const hasIconChanges = getAgentIconKey(agentIcon) !== getAgentIconKey(initialValues.icon)
+ const hasChanges = hasTextChanges || hasIconChanges
- const handleOpenChange = (nextOpen: boolean) => {
- if (!nextOpen) setIconPickerOpen(false)
- onOpenChange(nextOpen)
- }
-
- const handleSubmit = (formValues: AgentFormValues) => {
- const trimmedName = formValues.name?.trim() ?? ''
- const trimmedDescription = formValues.description?.trim() ?? ''
- const trimmedRole = formValues.role?.trim() ?? ''
- const hasIconChanges =
- getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent))
- const hasFormChanges =
- trimmedName !== agent.name.trim() ||
- trimmedDescription !== (agent.description?.trim() ?? '') ||
- trimmedRole !== (agent.role?.trim() ?? '') ||
- hasIconChanges
-
- if (updateAgentMutation.isPending) return
-
- if (!hasFormChanges) return
-
- const body: AgentAppUpdatePayload = {
- name: trimmedName,
- description: trimmedDescription,
- // Keep sending the trimmed role even when empty: omitting the field
- // preserves the current backing-agent role, while "" intentionally clears it.
- role: trimmedRole,
- }
-
- applyIconPayload(body, agentIcon)
-
- updateAgentMutation.mutate(
- {
- params: {
- agent_id: agent.id,
- },
- body,
- },
- {
- onSuccess: () => {
- toast.success(t(($) => $['roster.updateSuccess']))
- handleOpenChange(false)
- },
- },
+ const handleFormChange: ChangeEventHandler = (event) => {
+ const formValues = new FormData(event.currentTarget)
+ setHasTextChanges(
+ String(formValues.get('name') ?? '').trim() !== initialValues.fields.name.trim() ||
+ String(formValues.get('description') ?? '').trim() !==
+ initialValues.fields.description.trim() ||
+ String(formValues.get('role') ?? '').trim() !== initialValues.fields.role.trim(),
)
}
- const trimmedName = name.trim()
- const trimmedDescription = description.trim()
- const trimmedRole = role.trim()
- const hasIconChanges =
- getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent))
- const hasChanges =
- trimmedName !== agent.name.trim() ||
- trimmedDescription !== (agent.description?.trim() ?? '') ||
- trimmedRole !== (agent.role?.trim() ?? '') ||
- hasIconChanges
-
return (
<>
-
-
- $['operation.close'], { ns: 'common' })}
- size="lg"
- className="absolute inset-e-6 top-6"
- >
-
-
- }
- />
-
-
- {t(($) => $['roster.editDialog.title'])}
-
-
- {t(($) => $['roster.editDialog.description'])}
-
-
- className="min-h-0 flex-1" onFormSubmit={handleSubmit}>
- $['roster.editAgent'], { name: agent.name })}
- name={name}
- role={role}
- onDescriptionChange={setDescription}
- onIconClick={() => setIconPickerOpen(true)}
- onNameChange={setName}
- onRoleChange={setRole}
- />
-
- handleOpenChange(false)}
- disabled={updateAgentMutation.isPending}
- >
- {tCommon(($) => $['operation.cancel'])}
-
-
- {tCommon(($) => $['operation.save'])}
-
-
-
-
-
+
+
+ {t(($) => $['roster.editDialog.title'])}
+
+
+ {t(($) => $['roster.editDialog.description'])}
+
+
+
+ className="flex min-h-0 flex-1 flex-col"
+ onChange={handleFormChange}
+ onFormSubmit={(formValues) => {
+ if (hasChanges) onSubmit(formValues, agentIcon)
+ }}
+ >
+ $['roster.createForm.changeIcon'])}
+ onIconClick={() => setIconPickerOpen(true)}
+ />
+
+
+ {tCommon(($) => $['operation.cancel'])}
+
+
+ {tCommon(($) => $['operation.save'])}
+
+
+
)
}
+
+export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) {
+ const { t } = useTranslation('agentV2')
+ const nameInputRef = useRef(null)
+ const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions())
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (!nextOpen && updateAgentMutation.isPending) return
+ onOpenChange(nextOpen)
+ }
+
+ const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => {
+ if (updateAgentMutation.isPending) return
+
+ const body: AgentAppUpdatePayload = {
+ name: formValues.name.trim(),
+ description: formValues.description.trim(),
+ // Keep sending the trimmed role even when empty: omitting the field
+ // preserves the current backing-agent role, while "" intentionally clears it.
+ role: formValues.role.trim(),
+ }
+
+ applyIconPayload(body, agentIcon)
+
+ updateAgentMutation.mutate(
+ {
+ params: {
+ agent_id: agent.id,
+ },
+ body,
+ },
+ {
+ onSuccess: () => {
+ onOpenChange(false)
+ },
+ },
+ )
+ }
+
+ return (
+ <>
+
+
+ $['operation.close'], { ns: 'common' })}
+ size="lg"
+ className="absolute inset-e-5 top-5"
+ >
+
+
+ }
+ />
+ onOpenChange(false)}
+ onSubmit={handleSubmit}
+ />
+
+
+ >
+ )
+}
diff --git a/web/features/home/continue-work/__tests__/item.spec.tsx b/web/features/home/continue-work/__tests__/item.spec.tsx
index ee61d10d6ce..37b74a4b944 100644
--- a/web/features/home/continue-work/__tests__/item.spec.tsx
+++ b/web/features/home/continue-work/__tests__/item.spec.tsx
@@ -168,10 +168,15 @@ describe('ContinueWorkItem', () => {
)
})
- it('should fall back to access point when RBAC is disabled for an access-config-only app', () => {
- renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), {
- rbac_enabled: false,
- })
+ it('should fall back to access point when RBAC is disabled for an access-config app with access point permission', () => {
+ renderItem(
+ createApp({
+ permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPoint],
+ }),
+ {
+ rbac_enabled: false,
+ },
+ )
expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute(
'href',
diff --git a/web/global.d.ts b/web/global.d.ts
index 5a68838920c..5d1e51bce0a 100644
--- a/web/global.d.ts
+++ b/web/global.d.ts
@@ -19,5 +19,18 @@ declare global {
interface Window {
gtag?: Gtag
dataLayer?: unknown[]
+ __marketplaceTracking__?: {
+ track: (eventName: string, properties?: Record) => void
+ rememberReferrer: (itemId: string, section: 'banner' | 'search' | 'list' | 'direct') => void
+ markSearch: (query: string) => void
+ flushSearch: (resultCount: number) => void
+ markFilter: (filter: {
+ filter_type: 'type_tab' | 'category' | 'language'
+ selection_mode: 'single' | 'multi'
+ filter_value: string
+ selected_values: string[]
+ }) => void
+ flushFilter: (resultCount: number) => void
+ }
}
}
diff --git a/web/hooks/use-import-dsl.spec.tsx b/web/hooks/use-import-dsl.spec.tsx
index 1f6e95205f9..d8aff462318 100644
--- a/web/hooks/use-import-dsl.spec.tsx
+++ b/web/hooks/use-import-dsl.spec.tsx
@@ -1,4 +1,4 @@
-import { act, waitFor } from '@testing-library/react'
+import { act, render, screen, waitFor } from '@testing-library/react'
import { DSLImportMode, DSLImportStatus } from '@/models/app'
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
@@ -93,6 +93,46 @@ describe('useImportDSL', () => {
mockResolveImportedAppRedirectionTarget.mockImplementation(async (target) => target)
})
+ it('should show response warnings when an import completes with warnings', async () => {
+ const completedResponse = {
+ id: 'import-1',
+ status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
+ app_id: 'app-1',
+ app_mode: AppModeEnum.WORKFLOW,
+ permission_keys: [],
+ warnings: [
+ {
+ code: 'agent_tool_authorization_required',
+ path: 'agent_packages.agent_1.soul.tools.dify_tools.0',
+ message: "Agent tool 'jina_search' requires authorization.",
+ details: { tool_name: 'jina_search' },
+ },
+ ],
+ }
+ mockImportDSL.mockResolvedValue(completedResponse)
+ mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
+
+ const { result } = renderHookWithConsoleQuery(() => useImportDSL())
+
+ await act(async () => {
+ await result.current.handleImportDSL(
+ {
+ mode: DSLImportMode.YAML_CONTENT,
+ yaml_content: 'app: demo',
+ },
+ { skipRedirectOnSuccess: true },
+ )
+ })
+
+ expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', {
+ description: expect.anything(),
+ })
+ const warningDescription = toastMocks.warning.mock.calls[0]![1].description
+ render(<>{warningDescription}>)
+ expect(screen.getByText("Agent tool 'jina_search' requires authorization.")).toBeInTheDocument()
+ expect(screen.queryByText('app.newApp.appCreateDSLWarning')).not.toBeInTheDocument()
+ })
+
it('should complete a confirmed import that returns warnings', async () => {
let resolvePluginCheck: (() => void) | undefined
const pendingResponse = {
@@ -163,8 +203,12 @@ describe('useImportDSL', () => {
expect(onSuccess).toHaveBeenCalledWith(completedResponse)
expect(onFailed).not.toHaveBeenCalled()
expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', {
- description: 'app.newApp.appCreateDSLWarning',
+ description: expect.anything(),
})
+ const warningDescription = toastMocks.warning.mock.calls[0]![1].description
+ render(<>{warningDescription}>)
+ expect(screen.getByText('Agent file was not included.')).toBeInTheDocument()
+ expect(screen.queryByText('app.newApp.appCreateDSLWarning')).not.toBeInTheDocument()
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({
id: 'app-1',
diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts
index dda857efdb5..81e32d46a91 100644
--- a/web/hooks/use-import-dsl.ts
+++ b/web/hooks/use-import-dsl.ts
@@ -3,8 +3,9 @@ import type { AppIconType } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
-import { useCallback, useRef, useState } from 'react'
+import { createElement, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import DSLImportWarningDescription from '@/app/components/app/create-from-dsl-modal/dsl-import-warning-description'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
@@ -80,7 +81,10 @@ export const useImportDSL = () => {
)
const description =
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
- ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
+ ? createElement(DSLImportWarningDescription, {
+ warnings: response.warnings,
+ fallback: t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }),
+ })
: undefined
if (status === DSLImportStatus.COMPLETED) toast.success(message)
@@ -162,7 +166,10 @@ export const useImportDSL = () => {
)
const description =
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
- ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
+ ? createElement(DSLImportWarningDescription, {
+ warnings: response.warnings,
+ fallback: t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }),
+ })
: undefined
if (status === DSLImportStatus.COMPLETED) toast.success(message)
diff --git a/web/i18n-config/__tests__/plural-selector.spec.ts b/web/i18n-config/__tests__/plural-selector.spec.ts
index 9e98128f67e..ceeed3aeaa4 100644
--- a/web/i18n-config/__tests__/plural-selector.spec.ts
+++ b/web/i18n-config/__tests__/plural-selector.spec.ts
@@ -1,6 +1,6 @@
import type { SelectorParam } from 'i18next'
import { createInstance } from 'i18next'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vite-plus/test'
import agentV2 from '../../i18n/en-US/agent-v-2.json'
import skill from '../../i18n/en-US/skill.json'
import { getInitOptions } from '../settings'
diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json
index a60ccd28834..7e0d59840d0 100644
--- a/web/i18n/ar-TN/common.json
+++ b/web/i18n/ar-TN/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "تنتهي في {{count}} أيام",
"license.unlimited": "غير محدود",
"loading": "جارٍ التحميل",
+ "mainNav.help.creatorCenter": "مركز المبدعين",
"mainNav.help.docs": "الوثائق",
"mainNav.help.learnDify": "تعلّم Dify",
"mainNav.help.openMenu": "فتح قائمة المساعدة",
@@ -669,6 +670,7 @@
"userProfile.about": "حول",
"userProfile.compliance": "الامتثال",
"userProfile.contactUs": "اتصل بنا",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "دعم البريد الإلكتروني",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "عرض المستندات",
diff --git a/web/i18n/ar-TN/permission-keys.json b/web/i18n/ar-TN/permission-keys.json
index a7f129455c5..18321e3b376 100644
--- a/web/i18n/ar-TN/permission-keys.json
+++ b/web/i18n/ar-TN/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "إدارة إعدادات امتداد API",
"app.access_config": "تكوين أذونات الوصول إلى التطبيق",
"app.acl.access_config": "عرض أذونات الوصول وإدارتها",
+ "app.acl.access_point_manage": "عرض نقاط الوصول وإدارتها",
"app.acl.delete": "حذف التطبيق",
"app.acl.deploy": "نشر التطبيق",
"app.acl.edit": "تعديل معلومات التطبيق وتنسيقه",
diff --git a/web/i18n/ar-TN/plugin.json b/web/i18n/ar-TN/plugin.json
index 4d6627fbcbf..a7c37b5f10d 100644
--- a/web/i18n/ar-TN/plugin.json
+++ b/web/i18n/ar-TN/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "جميع الإضافات",
"marketplace.and": "و",
"marketplace.becomePartner": "كن شريكًا",
+ "marketplace.carousel.goToPage": "الانتقال إلى الصفحة {{page}}",
+ "marketplace.carousel.scrollNext": "الصفحة التالية",
+ "marketplace.carousel.scrollPrevious": "الصفحة السابقة",
+ "marketplace.creatorProfile.breadcrumbLabel": "مسار التنقل",
+ "marketplace.creatorProfile.creations": "الأعمال",
+ "marketplace.creatorProfile.empty": "لا توجد أعمال بعد.",
+ "marketplace.creatorProfile.home": "الصفحة الرئيسية للسوق",
+ "marketplace.creatorProfile.onTheWeb": "على الويب",
+ "marketplace.creatorProfile.organization": "منظمة",
+ "marketplace.creatorProfile.searchPlaceholder": "ابحث عن الإضافات والقوالب",
+ "marketplace.creatorProfile.sort.asc": "ترتيب تصاعدي",
+ "marketplace.creatorProfile.sort.createdAt": "الأحدث إنشاءً",
+ "marketplace.creatorProfile.sort.desc": "ترتيب تنازلي",
+ "marketplace.creatorProfile.sort.popularity": "الشعبية",
+ "marketplace.creatorProfile.sort.updatedAt": "الأحدث تحديثًا",
+ "marketplace.creatorProfile.sortBy": "ترتيب حسب",
+ "marketplace.creatorProfile.title": "ملف المنشئ",
+ "marketplace.creatorProfile.type.plugin": "إضافة",
+ "marketplace.creatorProfile.type.template": "قالب",
"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": "اكتشف. وسّع. ابنِ",
+ "marketplace.home.plugins": "المكونات الإضافية",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "قوالب",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "فشل التحميل. يرجى المحاولة مرة أخرى.",
"marketplace.moreFrom": "المزيد من السوق",
"marketplace.noPluginFound": "لم يتم العثور على إضافة",
"marketplace.partnerTip": "تم التحقق بواسطة شريك Dify",
"marketplace.pluginsHeroSubtitle": "استخدم الإضافات التي بناها المجتمع لتعزيز تطوير الذكاء الاصطناعي الخاص بك.",
"marketplace.pluginsHeroTitle": "اكتشف. وسّع. ابنِ.",
"marketplace.pluginsResult": "{{num}} نتائج",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "فرز حسب",
"marketplace.sortOption.firstReleased": "صدر لأول مرة",
"marketplace.sortOption.mostPopular": "الأكثر شيوعًا",
diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json
index 52978625901..39d56391d07 100644
--- a/web/i18n/de-DE/common.json
+++ b/web/i18n/de-DE/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Läuft in {{count}} Tagen ab",
"license.unlimited": "Unbegrenzt",
"loading": "Wird geladen",
+ "mainNav.help.creatorCenter": "Creator Center",
"mainNav.help.docs": "Dokumentation",
"mainNav.help.learnDify": "Dify kennenlernen",
"mainNav.help.openMenu": "Hilfemenü öffnen",
@@ -669,6 +670,7 @@
"userProfile.about": "Über",
"userProfile.compliance": "Einhaltung",
"userProfile.contactUs": "Kontaktieren Sie uns",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "E-Mail-Support",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Hilfe",
diff --git a/web/i18n/de-DE/permission-keys.json b/web/i18n/de-DE/permission-keys.json
index fb32d92c699..2d546c0e082 100644
--- a/web/i18n/de-DE/permission-keys.json
+++ b/web/i18n/de-DE/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API-Erweiterungskonfiguration verwalten",
"app.access_config": "App-Zugriffsberechtigungen konfigurieren",
"app.acl.access_config": "Zugriffsberechtigungen anzeigen und verwalten",
+ "app.acl.access_point_manage": "Zugangspunkte anzeigen und verwalten",
"app.acl.delete": "App löschen",
"app.acl.deploy": "App bereitstellen",
"app.acl.edit": "App-Informationen bearbeiten und App orchestrieren",
diff --git a/web/i18n/de-DE/plugin.json b/web/i18n/de-DE/plugin.json
index e58db219428..4c3d321beff 100644
--- a/web/i18n/de-DE/plugin.json
+++ b/web/i18n/de-DE/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Alle Plugins",
"marketplace.and": "und",
"marketplace.becomePartner": "Partner werden",
+ "marketplace.carousel.goToPage": "Zu Seite {{page}} wechseln",
+ "marketplace.carousel.scrollNext": "Nächste Seite",
+ "marketplace.carousel.scrollPrevious": "Vorherige Seite",
+ "marketplace.creatorProfile.breadcrumbLabel": "Brotkrümelnavigation",
+ "marketplace.creatorProfile.creations": "Kreationen",
+ "marketplace.creatorProfile.empty": "Noch keine Kreationen.",
+ "marketplace.creatorProfile.home": "Marketplace-Startseite",
+ "marketplace.creatorProfile.onTheWeb": "Im Web",
+ "marketplace.creatorProfile.organization": "Organisation",
+ "marketplace.creatorProfile.searchPlaceholder": "Plugins und Vorlagen suchen",
+ "marketplace.creatorProfile.sort.asc": "Aufsteigend sortieren",
+ "marketplace.creatorProfile.sort.createdAt": "Zuletzt erstellt",
+ "marketplace.creatorProfile.sort.desc": "Absteigend sortieren",
+ "marketplace.creatorProfile.sort.popularity": "Beliebtheit",
+ "marketplace.creatorProfile.sort.updatedAt": "Zuletzt aktualisiert",
+ "marketplace.creatorProfile.sortBy": "Sortieren nach",
+ "marketplace.creatorProfile.title": "Creator-Profil",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Vorlage",
"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",
+ "marketplace.home.plugins": "Plugins",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Vorlagen",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Laden fehlgeschlagen. Bitte versuchen Sie es erneut.",
"marketplace.moreFrom": "Mehr aus dem Marketplace",
"marketplace.noPluginFound": "Kein Plugin gefunden",
"marketplace.partnerTip": "Von einem Dify-Partner verifiziert",
"marketplace.pluginsHeroSubtitle": "Nutzen Sie von der Community erstellte Plugins, um Ihre KI-Entwicklung voranzutreiben.",
"marketplace.pluginsHeroTitle": "Entdecken. Erweitern. Entwickeln.",
"marketplace.pluginsResult": "{{num}} Ergebnisse",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Sortieren nach",
"marketplace.sortOption.firstReleased": "Zuerst veröffentlicht",
"marketplace.sortOption.mostPopular": "Beliebteste",
diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json
index 0fc4518c58f..dad47fe359e 100644
--- a/web/i18n/en-US/common.json
+++ b/web/i18n/en-US/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Expiring in {{count}} days",
"license.unlimited": "Unlimited",
"loading": "Loading",
+ "mainNav.help.creatorCenter": "Creator Center",
"mainNav.help.docs": "Documentation",
"mainNav.help.learnDify": "Learn Dify",
"mainNav.help.openMenu": "Open help menu",
@@ -669,6 +670,7 @@
"userProfile.about": "About",
"userProfile.compliance": "Compliance",
"userProfile.contactUs": "Contact Us",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Email Support",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "View Docs",
diff --git a/web/i18n/en-US/permission-keys.json b/web/i18n/en-US/permission-keys.json
index 2344caa11e1..e69f68bb0b3 100644
--- a/web/i18n/en-US/permission-keys.json
+++ b/web/i18n/en-US/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Manage API extension configuration",
"app.access_config": "Configure app access permissions",
"app.acl.access_config": "View and manage access permissions",
+ "app.acl.access_point_manage": "View and manage access points",
"app.acl.delete": "Delete app",
"app.acl.deploy": "Deploy app",
"app.acl.edit": "Edit app information and orchestrate app",
diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json
index ab7f1cc304a..18abf4dd904 100644
--- a/web/i18n/en-US/plugin.json
+++ b/web/i18n/en-US/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "All integrations",
"marketplace.and": "and",
"marketplace.becomePartner": "Become a Partner",
+ "marketplace.carousel.goToPage": "Go to page {{page}}",
+ "marketplace.carousel.scrollNext": "Next page",
+ "marketplace.carousel.scrollPrevious": "Previous page",
+ "marketplace.creatorProfile.breadcrumbLabel": "Breadcrumb",
+ "marketplace.creatorProfile.creations": "Creations",
+ "marketplace.creatorProfile.empty": "No creations yet.",
+ "marketplace.creatorProfile.home": "Marketplace home",
+ "marketplace.creatorProfile.onTheWeb": "On the web",
+ "marketplace.creatorProfile.organization": "Organization",
+ "marketplace.creatorProfile.searchPlaceholder": "Search plugins and templates",
+ "marketplace.creatorProfile.sort.asc": "Sort ascending",
+ "marketplace.creatorProfile.sort.createdAt": "Recently created",
+ "marketplace.creatorProfile.sort.desc": "Sort descending",
+ "marketplace.creatorProfile.sort.popularity": "Popularity",
+ "marketplace.creatorProfile.sort.updatedAt": "Recently updated",
+ "marketplace.creatorProfile.sortBy": "Sort by",
+ "marketplace.creatorProfile.title": "Creator Profile",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Template",
"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",
+ "marketplace.home.plugins": "Plugins",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Templates",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Failed to load. Please try again.",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noPluginFound": "No integration found",
"marketplace.partnerTip": "Verified by a Dify partner",
"marketplace.pluginsHeroSubtitle": "Use community-built integrations to power your AI development.",
"marketplace.pluginsHeroTitle": "Discover. Extend. Build.",
"marketplace.pluginsResult": "{{num}} results",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Sort by",
"marketplace.sortOption.firstReleased": "First Released",
"marketplace.sortOption.mostPopular": "Most Popular",
diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json
index 5dc1d0bacfe..b5b2c5c8617 100644
--- a/web/i18n/es-ES/common.json
+++ b/web/i18n/es-ES/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Caducando en {{count}} días",
"license.unlimited": "Ilimitado",
"loading": "Cargando",
+ "mainNav.help.creatorCenter": "Centro de creadores",
"mainNav.help.docs": "Documentación",
"mainNav.help.learnDify": "Aprende Dify",
"mainNav.help.openMenu": "Abrir menú de ayuda",
@@ -669,6 +670,7 @@
"userProfile.about": "Acerca de",
"userProfile.compliance": "Cumplimiento",
"userProfile.contactUs": "Contáctenos",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Soporte de Correo Electrónico",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Ayuda",
diff --git a/web/i18n/es-ES/permission-keys.json b/web/i18n/es-ES/permission-keys.json
index af3336ceea7..d7d5e9d57c8 100644
--- a/web/i18n/es-ES/permission-keys.json
+++ b/web/i18n/es-ES/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gestionar la configuración de la extensión de API",
"app.access_config": "Configurar los permisos de acceso de la app",
"app.acl.access_config": "Ver y gestionar los permisos de acceso",
+ "app.acl.access_point_manage": "Ver y gestionar los puntos de acceso",
"app.acl.delete": "Eliminar app",
"app.acl.deploy": "Desplegar la app",
"app.acl.edit": "Editar la información y orquestar la app",
diff --git a/web/i18n/es-ES/plugin.json b/web/i18n/es-ES/plugin.json
index 7fe09f475fb..e57b989c6a1 100644
--- a/web/i18n/es-ES/plugin.json
+++ b/web/i18n/es-ES/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Todas las integraciones",
"marketplace.and": "y",
"marketplace.becomePartner": "Conviértete en socio",
+ "marketplace.carousel.goToPage": "Ir a la página {{page}}",
+ "marketplace.carousel.scrollNext": "Página siguiente",
+ "marketplace.carousel.scrollPrevious": "Página anterior",
+ "marketplace.creatorProfile.breadcrumbLabel": "Ruta de navegación",
+ "marketplace.creatorProfile.creations": "Creaciones",
+ "marketplace.creatorProfile.empty": "Aún no hay creaciones.",
+ "marketplace.creatorProfile.home": "Inicio del Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "En la web",
+ "marketplace.creatorProfile.organization": "Organización",
+ "marketplace.creatorProfile.searchPlaceholder": "Buscar plugins y plantillas",
+ "marketplace.creatorProfile.sort.asc": "Orden ascendente",
+ "marketplace.creatorProfile.sort.createdAt": "Recién creado",
+ "marketplace.creatorProfile.sort.desc": "Orden descendente",
+ "marketplace.creatorProfile.sort.popularity": "Popularidad",
+ "marketplace.creatorProfile.sort.updatedAt": "Recién actualizado",
+ "marketplace.creatorProfile.sortBy": "Ordenar por",
+ "marketplace.creatorProfile.title": "Perfil del creador",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Plantilla",
"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",
+ "marketplace.home.plugins": "Plugins",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Plantillas",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Error al cargar. Inténtalo de nuevo.",
"marketplace.moreFrom": "Más de Marketplace",
"marketplace.noPluginFound": "No se ha encontrado ninguna integración",
"marketplace.partnerTip": "Verificado por un socio de Dify",
"marketplace.pluginsHeroSubtitle": "Usa integraciones creadas por la comunidad para potenciar tu desarrollo de IA.",
"marketplace.pluginsHeroTitle": "Descubre. Amplía. Crea.",
"marketplace.pluginsResult": "{{num}} resultados",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Ordenar por",
"marketplace.sortOption.firstReleased": "Lanzado por primera vez",
"marketplace.sortOption.mostPopular": "Lo más popular",
diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json
index 4ed2f54264d..35a5f7e8c30 100644
--- a/web/i18n/fa-IR/common.json
+++ b/web/i18n/fa-IR/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "انقضا در {{count}} روز",
"license.unlimited": "نامحدود",
"loading": "در حال بارگذاری",
+ "mainNav.help.creatorCenter": "مرکز سازندگان",
"mainNav.help.docs": "مستندات",
"mainNav.help.learnDify": "یادگیری Dify",
"mainNav.help.openMenu": "باز کردن منوی راهنما",
@@ -669,6 +670,7 @@
"userProfile.about": "درباره",
"userProfile.compliance": "انطباق",
"userProfile.contactUs": "با ما تماس بگیرید",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "پشتیبانی ایمیل",
"userProfile.github": "گیتهاب",
"userProfile.helpCenter": "راهنما",
diff --git a/web/i18n/fa-IR/permission-keys.json b/web/i18n/fa-IR/permission-keys.json
index 372374ed3b8..5e739bed336 100644
--- a/web/i18n/fa-IR/permission-keys.json
+++ b/web/i18n/fa-IR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "مدیریت پیکربندی افزونه API",
"app.access_config": "پیکربندی مجوزهای دسترسی برنامه",
"app.acl.access_config": "مشاهده و مدیریت مجوزهای دسترسی",
+ "app.acl.access_point_manage": "مشاهده و مدیریت نقاط دسترسی",
"app.acl.delete": "حذف برنامه",
"app.acl.deploy": "استقرار برنامه",
"app.acl.edit": "ویرایش اطلاعات برنامه و هماهنگسازی برنامه",
diff --git a/web/i18n/fa-IR/plugin.json b/web/i18n/fa-IR/plugin.json
index bb374e97333..d1adac64969 100644
--- a/web/i18n/fa-IR/plugin.json
+++ b/web/i18n/fa-IR/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "همه افزونهها",
"marketplace.and": "و",
"marketplace.becomePartner": "شریک شوید",
+ "marketplace.carousel.goToPage": "رفتن به صفحه {{page}}",
+ "marketplace.carousel.scrollNext": "صفحه بعدی",
+ "marketplace.carousel.scrollPrevious": "صفحه قبلی",
+ "marketplace.creatorProfile.breadcrumbLabel": "مسیر راهنما",
+ "marketplace.creatorProfile.creations": "آثار",
+ "marketplace.creatorProfile.empty": "هنوز اثری وجود ندارد.",
+ "marketplace.creatorProfile.home": "خانه Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "در وب",
+ "marketplace.creatorProfile.organization": "سازمان",
+ "marketplace.creatorProfile.searchPlaceholder": "جستجوی افزونه و قالب",
+ "marketplace.creatorProfile.sort.asc": "مرتبسازی صعودی",
+ "marketplace.creatorProfile.sort.createdAt": "تازهساخته",
+ "marketplace.creatorProfile.sort.desc": "مرتبسازی نزولی",
+ "marketplace.creatorProfile.sort.popularity": "محبوبیت",
+ "marketplace.creatorProfile.sort.updatedAt": "تازهبهروزرسانی",
+ "marketplace.creatorProfile.sortBy": "مرتبسازی بر اساس",
+ "marketplace.creatorProfile.title": "نمایه سازنده",
+ "marketplace.creatorProfile.type.plugin": "افزونه",
+ "marketplace.creatorProfile.type.template": "قالب",
"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": "کشف کنید. گسترش دهید. بسازید",
+ "marketplace.home.plugins": "افزونهها",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "الگوها",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "بارگیری ناموفق بود. لطفاً دوباره تلاش کنید.",
"marketplace.moreFrom": "اطلاعات بیشتر از Marketplace",
"marketplace.noPluginFound": "هیچ افزونهای یافت نشد",
"marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی",
"marketplace.pluginsHeroSubtitle": "از افزونههای ساختهشده توسط جامعه برای تقویت توسعه هوش مصنوعی خود استفاده کنید.",
"marketplace.pluginsHeroTitle": "کشف کنید. گسترش دهید. بسازید.",
"marketplace.pluginsResult": "نتایج {{num}}",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "شهر سیاه",
"marketplace.sortOption.firstReleased": "اولین منتشر شد",
"marketplace.sortOption.mostPopular": "محبوب ترین",
diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json
index b0f8d11a4e3..c3f8593b602 100644
--- a/web/i18n/fr-FR/common.json
+++ b/web/i18n/fr-FR/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Expirant dans {{count}} jours",
"license.unlimited": "Illimité",
"loading": "Chargement",
+ "mainNav.help.creatorCenter": "Centre des créateurs",
"mainNav.help.docs": "Documentation",
"mainNav.help.learnDify": "Apprendre Dify",
"mainNav.help.openMenu": "Ouvrir le menu d’aide",
@@ -669,6 +670,7 @@
"userProfile.about": "À propos",
"userProfile.compliance": "Conformité",
"userProfile.contactUs": "Contactez-nous",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Support par courriel",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Aide",
diff --git a/web/i18n/fr-FR/permission-keys.json b/web/i18n/fr-FR/permission-keys.json
index 074da133fae..c547052586d 100644
--- a/web/i18n/fr-FR/permission-keys.json
+++ b/web/i18n/fr-FR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gérer la configuration de l'extension API",
"app.access_config": "Configurer les autorisations d'accès à l'application",
"app.acl.access_config": "Afficher et gérer les autorisations d'accès",
+ "app.acl.access_point_manage": "Afficher et gérer les points d’accès",
"app.acl.delete": "Supprimer l'application",
"app.acl.deploy": "Déployer l'application",
"app.acl.edit": "Modifier les informations et orchestrer l'application",
diff --git a/web/i18n/fr-FR/plugin.json b/web/i18n/fr-FR/plugin.json
index 7ce28a6030e..8cc5485fdff 100644
--- a/web/i18n/fr-FR/plugin.json
+++ b/web/i18n/fr-FR/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Toutes les intégrations",
"marketplace.and": "et",
"marketplace.becomePartner": "Devenir partenaire",
+ "marketplace.carousel.goToPage": "Aller à la page {{page}}",
+ "marketplace.carousel.scrollNext": "Page suivante",
+ "marketplace.carousel.scrollPrevious": "Page précédente",
+ "marketplace.creatorProfile.breadcrumbLabel": "Fil d'Ariane",
+ "marketplace.creatorProfile.creations": "Créations",
+ "marketplace.creatorProfile.empty": "Aucune création pour le moment.",
+ "marketplace.creatorProfile.home": "Accueil du Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "Sur le web",
+ "marketplace.creatorProfile.organization": "Organisation",
+ "marketplace.creatorProfile.searchPlaceholder": "Rechercher des plugins et des modèles",
+ "marketplace.creatorProfile.sort.asc": "Trier par ordre croissant",
+ "marketplace.creatorProfile.sort.createdAt": "Récemment créé",
+ "marketplace.creatorProfile.sort.desc": "Trier par ordre décroissant",
+ "marketplace.creatorProfile.sort.popularity": "Popularité",
+ "marketplace.creatorProfile.sort.updatedAt": "Récemment mis à jour",
+ "marketplace.creatorProfile.sortBy": "Trier par",
+ "marketplace.creatorProfile.title": "Profil du créateur",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Modèle",
"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",
+ "marketplace.home.plugins": "Plugins",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Modèles",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Échec du chargement. Veuillez réessayer.",
"marketplace.moreFrom": "Plus de Marketplace",
"marketplace.noPluginFound": "Aucune intégration trouvée",
"marketplace.partnerTip": "Vérifié par un partenaire Dify",
"marketplace.pluginsHeroSubtitle": "Utilisez des intégrations créées par la communauté pour propulser votre développement de l’IA.",
"marketplace.pluginsHeroTitle": "Découvrir. Étendre. Construire.",
"marketplace.pluginsResult": "{{num}} résultats",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Ville noire",
"marketplace.sortOption.firstReleased": "Première sortie",
"marketplace.sortOption.mostPopular": "Les plus populaires",
diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json
index cc501813156..d06b91847f9 100644
--- a/web/i18n/hi-IN/common.json
+++ b/web/i18n/hi-IN/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "{{count}} दिनों में समाप्त हो रहा है",
"license.unlimited": "असीमित",
"loading": "लोड हो रहा है",
+ "mainNav.help.creatorCenter": "क्रिएटर केंद्र",
"mainNav.help.docs": "दस्तावेज़",
"mainNav.help.learnDify": "Dify सीखें",
"mainNav.help.openMenu": "सहायता मेनू खोलें",
@@ -669,6 +670,7 @@
"userProfile.about": "के बारे में",
"userProfile.compliance": "अनुपालन",
"userProfile.contactUs": "संपर्क करें",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "सहायता",
"userProfile.github": "गिटहब",
"userProfile.helpCenter": "सहायता",
diff --git a/web/i18n/hi-IN/permission-keys.json b/web/i18n/hi-IN/permission-keys.json
index 314cbfba84b..0779d870342 100644
--- a/web/i18n/hi-IN/permission-keys.json
+++ b/web/i18n/hi-IN/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें",
"app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें",
"app.acl.access_config": "एक्सेस अनुमतियाँ देखें और प्रबंधित करें",
+ "app.acl.access_point_manage": "एक्सेस पॉइंट देखें और प्रबंधित करें",
"app.acl.delete": "ऐप हटाएं",
"app.acl.deploy": "ऐप डिप्लॉय करें",
"app.acl.edit": "ऐप की जानकारी संपादित करें और ऐप को ऑर्केस्ट्रेट करें",
diff --git a/web/i18n/hi-IN/plugin.json b/web/i18n/hi-IN/plugin.json
index af6bc3d6229..4ec874c1712 100644
--- a/web/i18n/hi-IN/plugin.json
+++ b/web/i18n/hi-IN/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "सभी इंटीग्रेशन",
"marketplace.and": "और",
"marketplace.becomePartner": "भागीदार बनें",
+ "marketplace.carousel.goToPage": "पृष्ठ {{page}} पर जाएं",
+ "marketplace.carousel.scrollNext": "अगला पृष्ठ",
+ "marketplace.carousel.scrollPrevious": "पिछला पृष्ठ",
+ "marketplace.creatorProfile.breadcrumbLabel": "ब्रेडक्रम्ब",
+ "marketplace.creatorProfile.creations": "रचनाएँ",
+ "marketplace.creatorProfile.empty": "अभी कोई रचना नहीं।",
+ "marketplace.creatorProfile.home": "Marketplace होम",
+ "marketplace.creatorProfile.onTheWeb": "वेब पर",
+ "marketplace.creatorProfile.organization": "संगठन",
+ "marketplace.creatorProfile.searchPlaceholder": "प्लगिन और टेम्पलेट खोजें",
+ "marketplace.creatorProfile.sort.asc": "बढ़ते क्रम में",
+ "marketplace.creatorProfile.sort.createdAt": "हाल ही में बनाया गया",
+ "marketplace.creatorProfile.sort.desc": "घटते क्रम में",
+ "marketplace.creatorProfile.sort.popularity": "लोकप्रियता",
+ "marketplace.creatorProfile.sort.updatedAt": "हाल ही में अपडेट किया गया",
+ "marketplace.creatorProfile.sortBy": "क्रमबद्ध करें",
+ "marketplace.creatorProfile.title": "क्रिएटर प्रोफ़ाइल",
+ "marketplace.creatorProfile.type.plugin": "प्लगिन",
+ "marketplace.creatorProfile.type.template": "टेम्पलेट",
"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": "खोजें। विस्तार करें। बनाएँ",
+ "marketplace.home.plugins": "एकीकरण",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "टेम्पलेट",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "लोड नहीं हो सका। कृपया पुनः प्रयास करें।",
"marketplace.moreFrom": "मार्केटप्लेस से अधिक",
"marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला",
"marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित",
"marketplace.pluginsHeroSubtitle": "अपने एआई विकास को सशक्त बनाने के लिए समुदाय द्वारा निर्मित इंटीग्रेशन का उपयोग करें।",
"marketplace.pluginsHeroTitle": "खोजें। विस्तार करें। निर्माण करें।",
"marketplace.pluginsResult": "{{num}} परिणाम",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "काला शहर",
"marketplace.sortOption.firstReleased": "पहली बार जारी किया गया",
"marketplace.sortOption.mostPopular": "सबसे लोकप्रिय",
diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json
index 9842b0e73ca..d6bd34f39c7 100644
--- a/web/i18n/id-ID/common.json
+++ b/web/i18n/id-ID/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Kedaluwarsa dalam {{count}} hari",
"license.unlimited": "Unlimited",
"loading": "Memuat",
+ "mainNav.help.creatorCenter": "Pusat Kreator",
"mainNav.help.docs": "Dokumentasi",
"mainNav.help.learnDify": "Pelajari Dify",
"mainNav.help.openMenu": "Buka menu bantuan",
@@ -669,6 +670,7 @@
"userProfile.about": "Tentang",
"userProfile.compliance": "Kepatuhan",
"userProfile.contactUs": "Hubungi Kami",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Dukungan Email",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Docs",
diff --git a/web/i18n/id-ID/permission-keys.json b/web/i18n/id-ID/permission-keys.json
index 4b04ea96f00..349bff75538 100644
--- a/web/i18n/id-ID/permission-keys.json
+++ b/web/i18n/id-ID/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Kelola konfigurasi ekstensi API",
"app.access_config": "Konfigurasikan izin akses aplikasi",
"app.acl.access_config": "Lihat dan kelola izin akses",
+ "app.acl.access_point_manage": "Lihat dan kelola titik akses",
"app.acl.delete": "Hapus aplikasi",
"app.acl.deploy": "Deploy aplikasi",
"app.acl.edit": "Edit informasi aplikasi dan orkestrasikan aplikasi",
diff --git a/web/i18n/id-ID/plugin.json b/web/i18n/id-ID/plugin.json
index c924915092c..9c917d11807 100644
--- a/web/i18n/id-ID/plugin.json
+++ b/web/i18n/id-ID/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Semua integrasi",
"marketplace.and": "dan",
"marketplace.becomePartner": "Menjadi Partner",
+ "marketplace.carousel.goToPage": "Buka halaman {{page}}",
+ "marketplace.carousel.scrollNext": "Halaman berikutnya",
+ "marketplace.carousel.scrollPrevious": "Halaman sebelumnya",
+ "marketplace.creatorProfile.breadcrumbLabel": "Jalur navigasi",
+ "marketplace.creatorProfile.creations": "Karya",
+ "marketplace.creatorProfile.empty": "Belum ada karya.",
+ "marketplace.creatorProfile.home": "Beranda Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "Di web",
+ "marketplace.creatorProfile.organization": "Organisasi",
+ "marketplace.creatorProfile.searchPlaceholder": "Cari plugin dan template",
+ "marketplace.creatorProfile.sort.asc": "Urutkan menaik",
+ "marketplace.creatorProfile.sort.createdAt": "Baru dibuat",
+ "marketplace.creatorProfile.sort.desc": "Urutkan menurun",
+ "marketplace.creatorProfile.sort.popularity": "Popularitas",
+ "marketplace.creatorProfile.sort.updatedAt": "Baru diperbarui",
+ "marketplace.creatorProfile.sortBy": "Urutkan berdasarkan",
+ "marketplace.creatorProfile.title": "Profil kreator",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Template",
"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",
+ "marketplace.home.plugins": "Plugin",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Templat",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Gagal memuat. Silakan coba lagi.",
"marketplace.moreFrom": "Selengkapnya dari Marketplace",
"marketplace.noPluginFound": "Tidak ada integrasi yang ditemukan",
"marketplace.partnerTip": "Diverifikasi oleh partner Dify",
"marketplace.pluginsHeroSubtitle": "Gunakan integrasi buatan komunitas untuk mendukung pengembangan AI Anda.",
"marketplace.pluginsHeroTitle": "Temukan. Perluas. Bangun.",
"marketplace.pluginsResult": "hasil {{num}}",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Urutkan berdasarkan",
"marketplace.sortOption.firstReleased": "Pertama Dirilis",
"marketplace.sortOption.mostPopular": "Paling Populer",
diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json
index 9d39d111543..46657b8eae8 100644
--- a/web/i18n/it-IT/common.json
+++ b/web/i18n/it-IT/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Scadenza tra {{count}} giorni",
"license.unlimited": "Illimitato",
"loading": "Caricamento",
+ "mainNav.help.creatorCenter": "Centro creatori",
"mainNav.help.docs": "Documentazione",
"mainNav.help.learnDify": "Impara Dify",
"mainNav.help.openMenu": "Apri menu di aiuto",
@@ -669,6 +670,7 @@
"userProfile.about": "Informazioni",
"userProfile.compliance": "Conformità",
"userProfile.contactUs": "Contattaci",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Supporto Email",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Aiuto",
diff --git a/web/i18n/it-IT/permission-keys.json b/web/i18n/it-IT/permission-keys.json
index 899adce084b..b5f3ebf8094 100644
--- a/web/i18n/it-IT/permission-keys.json
+++ b/web/i18n/it-IT/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gestisci la configurazione delle estensioni API",
"app.access_config": "Configura i permessi di accesso all'app",
"app.acl.access_config": "Visualizza e gestisci i permessi di accesso",
+ "app.acl.access_point_manage": "Visualizza e gestisci i punti di accesso",
"app.acl.delete": "Elimina app",
"app.acl.deploy": "Distribuisci app",
"app.acl.edit": "Modifica le informazioni e orchestra l'app",
diff --git a/web/i18n/it-IT/plugin.json b/web/i18n/it-IT/plugin.json
index 95acde561a4..f3f2ff975a1 100644
--- a/web/i18n/it-IT/plugin.json
+++ b/web/i18n/it-IT/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Tutte le integrazioni",
"marketplace.and": "e",
"marketplace.becomePartner": "Diventa un partner",
+ "marketplace.carousel.goToPage": "Vai alla pagina {{page}}",
+ "marketplace.carousel.scrollNext": "Pagina successiva",
+ "marketplace.carousel.scrollPrevious": "Pagina precedente",
+ "marketplace.creatorProfile.breadcrumbLabel": "Percorso di navigazione",
+ "marketplace.creatorProfile.creations": "Creazioni",
+ "marketplace.creatorProfile.empty": "Nessuna creazione al momento.",
+ "marketplace.creatorProfile.home": "Home del Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "Sul web",
+ "marketplace.creatorProfile.organization": "Organizzazione",
+ "marketplace.creatorProfile.searchPlaceholder": "Cerca plugin e modelli",
+ "marketplace.creatorProfile.sort.asc": "Ordine crescente",
+ "marketplace.creatorProfile.sort.createdAt": "Creati di recente",
+ "marketplace.creatorProfile.sort.desc": "Ordine decrescente",
+ "marketplace.creatorProfile.sort.popularity": "Popolarità",
+ "marketplace.creatorProfile.sort.updatedAt": "Aggiornati di recente",
+ "marketplace.creatorProfile.sortBy": "Ordina per",
+ "marketplace.creatorProfile.title": "Profilo del creator",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Modello",
"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",
+ "marketplace.home.plugins": "Plugin",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Modelli",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Caricamento non riuscito. Riprova.",
"marketplace.moreFrom": "Altro da Marketplace",
"marketplace.noPluginFound": "Nessuna integrazione trovata",
"marketplace.partnerTip": "Verificato da un partner Dify",
"marketplace.pluginsHeroSubtitle": "Usa integrazioni create dalla community per potenziare lo sviluppo della tua IA.",
"marketplace.pluginsHeroTitle": "Scopri. Estendi. Costruisci.",
"marketplace.pluginsResult": "{{num}} risultati",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Ordina per",
"marketplace.sortOption.firstReleased": "Prima pubblicazione",
"marketplace.sortOption.mostPopular": "I più popolari",
diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json
index b0c2886fb9e..9ea1499d8e7 100644
--- a/web/i18n/ja-JP/common.json
+++ b/web/i18n/ja-JP/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "有効期限 {{count}} 日",
"license.unlimited": "無制限",
"loading": "読み込み中",
+ "mainNav.help.creatorCenter": "クリエイターセンター",
"mainNav.help.docs": "ドキュメント",
"mainNav.help.learnDify": "Difyを学ぶ",
"mainNav.help.openMenu": "ヘルプメニューを開く",
@@ -669,6 +670,7 @@
"userProfile.about": "Dify について",
"userProfile.compliance": "コンプライアンス",
"userProfile.contactUs": "お問い合わせ",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "サポート",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "ドキュメントを見る",
diff --git a/web/i18n/ja-JP/permission-keys.json b/web/i18n/ja-JP/permission-keys.json
index 1b0c567f0e8..53033e2dc10 100644
--- a/web/i18n/ja-JP/permission-keys.json
+++ b/web/i18n/ja-JP/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API拡張設定を管理",
"app.access_config": "アプリアクセス権限を設定",
"app.acl.access_config": "アクセス権限の表示と管理",
+ "app.acl.access_point_manage": "アクセスポイントの表示と管理",
"app.acl.delete": "アプリを削除",
"app.acl.deploy": "アプリをデプロイ",
"app.acl.edit": "アプリ情報の編集とアプリのオーケストレーション",
diff --git a/web/i18n/ja-JP/plugin.json b/web/i18n/ja-JP/plugin.json
index da8d706f5a4..2b32e6b9708 100644
--- a/web/i18n/ja-JP/plugin.json
+++ b/web/i18n/ja-JP/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "すべてのインテグレーション",
"marketplace.and": "と",
"marketplace.becomePartner": "パートナーになる",
+ "marketplace.carousel.goToPage": "{{page}}ページへ移動",
+ "marketplace.carousel.scrollNext": "次のページ",
+ "marketplace.carousel.scrollPrevious": "前のページ",
+ "marketplace.creatorProfile.breadcrumbLabel": "パンくずリスト",
+ "marketplace.creatorProfile.creations": "作品",
+ "marketplace.creatorProfile.empty": "作品はまだありません。",
+ "marketplace.creatorProfile.home": "Marketplace ホーム",
+ "marketplace.creatorProfile.onTheWeb": "ウェブサイト",
+ "marketplace.creatorProfile.organization": "組織",
+ "marketplace.creatorProfile.searchPlaceholder": "プラグインとテンプレートを検索",
+ "marketplace.creatorProfile.sort.asc": "昇順に並べ替え",
+ "marketplace.creatorProfile.sort.createdAt": "作成日時",
+ "marketplace.creatorProfile.sort.desc": "降順に並べ替え",
+ "marketplace.creatorProfile.sort.popularity": "人気順",
+ "marketplace.creatorProfile.sort.updatedAt": "更新日時",
+ "marketplace.creatorProfile.sortBy": "並び順",
+ "marketplace.creatorProfile.title": "クリエイタープロフィール",
+ "marketplace.creatorProfile.type.plugin": "プラグイン",
+ "marketplace.creatorProfile.type.template": "テンプレート",
"marketplace.difyMarketplace": "Dify マーケットプレイス",
"marketplace.discover": "探索",
"marketplace.empower": "AI 開発をサポートする",
+ "marketplace.home.creatorCenter": "クリエイターセンター",
+ "marketplace.home.guide": "ガイド",
+ "marketplace.home.heroSubtitle": "Dify Marketplace で、より安全で信頼性の高いプラグインを見つけましょう。",
+ "marketplace.home.heroTitle": "見つける。拡張する。構築する",
+ "marketplace.home.plugins": "プラグイン",
+ "marketplace.home.searchPlaceholder": "プラグインまたはテンプレートを検索",
+ "marketplace.home.templates": "テンプレート",
+ "marketplace.home.trendingByCreator": "{{creator}} 作成",
+ "marketplace.home.trendingDescription": "実際の利用状況に基づく人気プラグインを2週間ごとに更新。ワークスペースでの実行数によるランキングで、有料掲載や編集部による選定はありません。",
+ "marketplace.home.trendingPaginationLabel": "トレンドページ",
+ "marketplace.home.trendingPause": "一時停止",
+ "marketplace.home.trendingPlay": "再生",
+ "marketplace.home.trendingReadMore": "続きを読む",
+ "marketplace.home.trendingReadMoreAbout": "{{title}} の続きを読む",
+ "marketplace.home.trendingTitle": "みんながインストールしているプラグイン",
+ "marketplace.home.trendingView": "表示",
+ "marketplace.languages": "言語フィルタ",
+ "marketplace.loadError": "読み込みに失敗しました。もう一度お試しください。",
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
"marketplace.noPluginFound": "インテグレーションが見つかりません",
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",
"marketplace.pluginsHeroSubtitle": "コミュニティ製のインテグレーションを活用して、AI 開発を強化しましょう。",
"marketplace.pluginsHeroTitle": "発見する。拡張する。構築する。",
"marketplace.pluginsResult": "{{num}} 件の結果",
+ "marketplace.searchFilterLanguage": "言語を検索",
"marketplace.sortBy": "並べ替え",
"marketplace.sortOption.firstReleased": "リリース順",
"marketplace.sortOption.mostPopular": "人気順",
diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json
index a39e9ed2b6d..8b6330fd9a7 100644
--- a/web/i18n/ko-KR/common.json
+++ b/web/i18n/ko-KR/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "{{count}}일 후에 만료",
"license.unlimited": "무제한",
"loading": "로딩 중",
+ "mainNav.help.creatorCenter": "크리에이터 센터",
"mainNav.help.docs": "문서",
"mainNav.help.learnDify": "Dify 배우기",
"mainNav.help.openMenu": "도움말 메뉴 열기",
@@ -669,6 +670,7 @@
"userProfile.about": "Dify 소개",
"userProfile.compliance": "컴플라이언스",
"userProfile.contactUs": "문의하기",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "이메일 지원",
"userProfile.github": "깃허브",
"userProfile.helpCenter": "도움말 센터",
diff --git a/web/i18n/ko-KR/permission-keys.json b/web/i18n/ko-KR/permission-keys.json
index 3a9981a602a..45517f6acb9 100644
--- a/web/i18n/ko-KR/permission-keys.json
+++ b/web/i18n/ko-KR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API 확장 구성 관리",
"app.access_config": "앱 접근 권한 구성",
"app.acl.access_config": "접근 권한 보기 및 관리",
+ "app.acl.access_point_manage": "액세스 지점 보기 및 관리",
"app.acl.delete": "앱 삭제",
"app.acl.deploy": "앱 배포",
"app.acl.edit": "앱 정보 편집 및 앱 오케스트레이션",
diff --git a/web/i18n/ko-KR/plugin.json b/web/i18n/ko-KR/plugin.json
index c0f9b145f9d..3ce92d3cf39 100644
--- a/web/i18n/ko-KR/plugin.json
+++ b/web/i18n/ko-KR/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "모든 플러그인",
"marketplace.and": "그리고",
"marketplace.becomePartner": "파트너 되기",
+ "marketplace.carousel.goToPage": "{{page}}페이지로 이동",
+ "marketplace.carousel.scrollNext": "다음 페이지",
+ "marketplace.carousel.scrollPrevious": "이전 페이지",
+ "marketplace.creatorProfile.breadcrumbLabel": "탐색 경로",
+ "marketplace.creatorProfile.creations": "작품",
+ "marketplace.creatorProfile.empty": "아직 작품이 없습니다.",
+ "marketplace.creatorProfile.home": "Marketplace 홈",
+ "marketplace.creatorProfile.onTheWeb": "웹에서",
+ "marketplace.creatorProfile.organization": "조직",
+ "marketplace.creatorProfile.searchPlaceholder": "플러그인 및 템플릿 검색",
+ "marketplace.creatorProfile.sort.asc": "오름차순 정렬",
+ "marketplace.creatorProfile.sort.createdAt": "최근 생성",
+ "marketplace.creatorProfile.sort.desc": "내림차순 정렬",
+ "marketplace.creatorProfile.sort.popularity": "인기순",
+ "marketplace.creatorProfile.sort.updatedAt": "최근 업데이트",
+ "marketplace.creatorProfile.sortBy": "정렬 기준",
+ "marketplace.creatorProfile.title": "크리에이터 프로필",
+ "marketplace.creatorProfile.type.plugin": "플러그인",
+ "marketplace.creatorProfile.type.template": "템플릿",
"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": "발견하고, 확장하고, 구축하세요",
+ "marketplace.home.plugins": "플러그인",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "템플릿",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "불러오지 못했습니다. 다시 시도해 주세요.",
"marketplace.moreFrom": "Marketplace 에서 더 보기",
"marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.",
"marketplace.partnerTip": "Dify 파트너에 의해 확인됨",
"marketplace.pluginsHeroSubtitle": "커뮤니티에서 제작한 플러그인을 사용하여 AI 개발을 강화하세요.",
"marketplace.pluginsHeroTitle": "발견하고. 확장하고. 구축하세요.",
"marketplace.pluginsResult": "{{num}} 결과",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "정렬",
"marketplace.sortOption.firstReleased": "첫 출시",
"marketplace.sortOption.mostPopular": "가장 인기 있는",
diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json
index 536eba81f79..2b4a75d9f9c 100644
--- a/web/i18n/lo-LA/common.json
+++ b/web/i18n/lo-LA/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "ຈະໝົດອາຍຸໃນອີກ {{count}} ມື້",
"license.unlimited": "ບໍ່ຈຳກັດ",
"loading": "ກຳລັງໂຫຼດ",
+ "mainNav.help.creatorCenter": "ສູນຜູ້ສ້າງ",
"mainNav.help.docs": "ເອກະສານປະກອບ",
"mainNav.help.learnDify": "ຮຽນຮູ້ Dify",
"mainNav.help.openMenu": "ເປີດເມນູຊ່ວຍເຫຼືອ",
@@ -669,6 +670,7 @@
"userProfile.about": "ກ່ຽວກັບ",
"userProfile.compliance": "ການປະຕິບັດຕາມກົດລະບຽບ",
"userProfile.contactUs": "ຕິດຕໍ່ພວກເຮົາ",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "ການຊ່ວຍເຫຼືອຜ່ານອີເມວ",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "ເບິ່ງເອກະສານ",
diff --git a/web/i18n/lo-LA/permission-keys.json b/web/i18n/lo-LA/permission-keys.json
index bfba51cc7e2..e04ef1f59ec 100644
--- a/web/i18n/lo-LA/permission-keys.json
+++ b/web/i18n/lo-LA/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "ຈັດການການຕັ້ງຄ່າ API extension",
"app.access_config": "ຕັ້ງຄ່າສິດການເຂົ້າເຖິງແອັບ",
"app.acl.access_config": "ເບິ່ງ ແລະ ຈັດການສິດການເຂົ້າເຖິງ",
+ "app.acl.access_point_manage": "ເບິ່ງ ແລະ ຈັດການຈຸດເຂົ້າເຖິງ",
"app.acl.delete": "ລຶບແອັບ",
"app.acl.deploy": "ຕິດຕັ້ງແອັບ",
"app.acl.edit": "ແກ້ໄຂຂໍ້ມູນແອັບ ແລະ ຈັດການລະບົບແອັບ",
diff --git a/web/i18n/lo-LA/plugin.json b/web/i18n/lo-LA/plugin.json
index 3d8c671471f..4e03771d312 100644
--- a/web/i18n/lo-LA/plugin.json
+++ b/web/i18n/lo-LA/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "ການເຊື່ອມຕໍ່ທັງໝົດ",
"marketplace.and": "ແລະ",
"marketplace.becomePartner": "ເຂົ້າຮ່ວມເປັນພັດທະນາມິດ",
+ "marketplace.carousel.goToPage": "ໄປທີ່ໜ້າ {{page}}",
+ "marketplace.carousel.scrollNext": "ໜ້າຕໍ່ໄປ",
+ "marketplace.carousel.scrollPrevious": "ໜ້າກ່ອນໜ້າ",
+ "marketplace.creatorProfile.breadcrumbLabel": "ເສັ້ນທາງນຳທາງ",
+ "marketplace.creatorProfile.creations": "ຜົນງານ",
+ "marketplace.creatorProfile.empty": "ຍັງບໍ່ມີຜົນງານ.",
+ "marketplace.creatorProfile.home": "ໜ້າຫຼັກ Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "ເທິງເວັບ",
+ "marketplace.creatorProfile.organization": "ອົງກອນ",
+ "marketplace.creatorProfile.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ",
+ "marketplace.creatorProfile.sort.asc": "ຮຽງໜ້ອຍໄປຫຼາຍ",
+ "marketplace.creatorProfile.sort.createdAt": "ສ້າງລ່າສຸດ",
+ "marketplace.creatorProfile.sort.desc": "ຮຽງຫຼາຍໄປຫາໜ້ອຍ",
+ "marketplace.creatorProfile.sort.popularity": "ຄວາມນິຍົມ",
+ "marketplace.creatorProfile.sort.updatedAt": "ອັບເດດລ່າສຸດ",
+ "marketplace.creatorProfile.sortBy": "ຮຽງຕາມ",
+ "marketplace.creatorProfile.title": "ໂປຣໄຟລ໌ຜູ້ສ້າງ",
+ "marketplace.creatorProfile.type.plugin": "ປລັກອິນ",
+ "marketplace.creatorProfile.type.template": "ແມ່ແບບ",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "ຄົ້ນຫາ",
"marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ",
+ "marketplace.home.creatorCenter": "ສູນຜູ້ສ້າງ",
+ "marketplace.home.guide": "ຄູ່ມື",
+ "marketplace.home.heroSubtitle": "ສ້າງດ້ວຍປລັກອິນທີ່ປອດໄພ ແລະ ເຊື່ອຖືໄດ້ຫຼາຍຂຶ້ນຈາກ Dify Marketplace.",
+ "marketplace.home.heroTitle": "ຄົ້ນຫາ. ຕໍ່ຍອດ. ສ້າງສັນ",
+ "marketplace.home.plugins": "ປລັກອິນ",
+ "marketplace.home.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ຫຼື ແມ່ແບບ",
+ "marketplace.home.templates": "ແມ່ແບບ",
+ "marketplace.home.trendingByCreator": "ໂດຍ {{creator}}",
+ "marketplace.home.trendingDescription": "ຄັດເລືອກຈາກການນຳໃຊ້ຕົວຈິງ, ອັບເດດທຸກໆສອງອາທິດ. ຈັດອັນດັບຕາມການເອີ້ນໃຊ້ຕົວຈິງໃນທົ່ວທຸກ workspace — ບໍ່ມີການຈ່າຍເງິນເພື່ອໂຄສະນາ ຫຼື ການຄັດເລືອກໂດຍທີມງານ.",
+ "marketplace.home.trendingPaginationLabel": "ໜ້າກຳລັງນິຍົມ",
+ "marketplace.home.trendingPause": "ຢຸດຊົ່ວຄາວ",
+ "marketplace.home.trendingPlay": "ຫຼິ້ນ",
+ "marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ",
+ "marketplace.home.trendingReadMoreAbout": "ອ່ານເພີ່ມເຕີມກ່ຽວກັບ {{title}}",
+ "marketplace.home.trendingTitle": "ປລັກອິນທີ່ທຸກຄົນກຳລັງຕິດຕັ້ງ",
+ "marketplace.home.trendingView": "ເບິ່ງ",
+ "marketplace.languages": "Filter by Languages",
+ "marketplace.loadError": "ໂຫຼດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ.",
"marketplace.moreFrom": "ເພີ່ມເຕີມຈາກ Marketplace",
"marketplace.noPluginFound": "ບໍ່ພົບການເຊື່ອມຕໍ່",
"marketplace.partnerTip": "ໄດ້ຮັບການຢືນຢັນໂດຍພັດທະນາມິດຂອງ Dify",
"marketplace.pluginsHeroSubtitle": "ນຳໃຊ້ການເຊື່ອມຕໍ່ທີ່ສ້າງໂດຍຊຸມຊົນເພື່ອຂັບເຄື່ອນການພັດທະນາ AI ຂອງທ່ານ.",
"marketplace.pluginsHeroTitle": "ຄົ້ນຫາ. ຕໍ່ຍອດ. ສ້າງສັນ.",
"marketplace.pluginsResult": "{{num}} ຜົນລາຍການ",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "ຈັດລຽງໂດຍ",
"marketplace.sortOption.firstReleased": "ປ່ອຍທຳອິດ",
"marketplace.sortOption.mostPopular": "ໄດ້ຮັບຄວາມນິຍົມສູງສຸດ",
diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json
index ada2ed37284..7dd7503027b 100644
--- a/web/i18n/nl-NL/common.json
+++ b/web/i18n/nl-NL/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Expiring in {{count}} days",
"license.unlimited": "Unlimited",
"loading": "Loading",
+ "mainNav.help.creatorCenter": "Creatorcentrum",
"mainNav.help.docs": "Documentatie",
"mainNav.help.learnDify": "Leer Dify kennen",
"mainNav.help.openMenu": "Helpmenu openen",
@@ -669,6 +670,7 @@
"userProfile.about": "About",
"userProfile.compliance": "Compliance",
"userProfile.contactUs": "Contact Us",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Email Support",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "View Docs",
diff --git a/web/i18n/nl-NL/permission-keys.json b/web/i18n/nl-NL/permission-keys.json
index 94fdf9d182c..8266b38ae22 100644
--- a/web/i18n/nl-NL/permission-keys.json
+++ b/web/i18n/nl-NL/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API-extensieconfiguratie beheren",
"app.access_config": "Toegangsrechten voor app configureren",
"app.acl.access_config": "Toegangsrechten bekijken en beheren",
+ "app.acl.access_point_manage": "Toegangspunten bekijken en beheren",
"app.acl.delete": "App verwijderen",
"app.acl.deploy": "App implementeren",
"app.acl.edit": "App-informatie bewerken en app orkestreren",
diff --git a/web/i18n/nl-NL/plugin.json b/web/i18n/nl-NL/plugin.json
index e31111c1f15..ac5a406588a 100644
--- a/web/i18n/nl-NL/plugin.json
+++ b/web/i18n/nl-NL/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Alle plugins",
"marketplace.and": "and",
"marketplace.becomePartner": "Word partner",
+ "marketplace.carousel.goToPage": "Ga naar pagina {{page}}",
+ "marketplace.carousel.scrollNext": "Volgende pagina",
+ "marketplace.carousel.scrollPrevious": "Vorige pagina",
+ "marketplace.creatorProfile.breadcrumbLabel": "Broodkruimelnavigatie",
+ "marketplace.creatorProfile.creations": "Creaties",
+ "marketplace.creatorProfile.empty": "Nog geen creaties.",
+ "marketplace.creatorProfile.home": "Marketplace-startpagina",
+ "marketplace.creatorProfile.onTheWeb": "Op het web",
+ "marketplace.creatorProfile.organization": "Organisatie",
+ "marketplace.creatorProfile.searchPlaceholder": "Zoek plugins en sjablonen",
+ "marketplace.creatorProfile.sort.asc": "Oplopend sorteren",
+ "marketplace.creatorProfile.sort.createdAt": "Recent gemaakt",
+ "marketplace.creatorProfile.sort.desc": "Aflopend sorteren",
+ "marketplace.creatorProfile.sort.popularity": "Populariteit",
+ "marketplace.creatorProfile.sort.updatedAt": "Recent bijgewerkt",
+ "marketplace.creatorProfile.sortBy": "Sorteren op",
+ "marketplace.creatorProfile.title": "Makerprofiel",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Sjabloon",
"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",
+ "marketplace.home.plugins": "Plugins",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Sjablonen",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Laden mislukt. Probeer het opnieuw.",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noPluginFound": "Geen plugin gevonden",
"marketplace.partnerTip": "Verified by a Dify partner",
"marketplace.pluginsHeroSubtitle": "Gebruik door de community gebouwde plugins om je AI-ontwikkeling te versterken.",
"marketplace.pluginsHeroTitle": "Ontdek. Breid uit. Bouw.",
"marketplace.pluginsResult": "{{num}} results",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Sort by",
"marketplace.sortOption.firstReleased": "First Released",
"marketplace.sortOption.mostPopular": "Most Popular",
diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json
index 9f0b2616413..93bd23986c7 100644
--- a/web/i18n/pl-PL/common.json
+++ b/web/i18n/pl-PL/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Wygasa za {{count}} dni",
"license.unlimited": "Nieograniczony",
"loading": "Ładowanie",
+ "mainNav.help.creatorCenter": "Centrum twórców",
"mainNav.help.docs": "Dokumentacja",
"mainNav.help.learnDify": "Poznaj Dify",
"mainNav.help.openMenu": "Otwórz menu pomocy",
@@ -669,6 +670,7 @@
"userProfile.about": "O",
"userProfile.compliance": "Zgodność",
"userProfile.contactUs": "Skontaktuj się z nami",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Wsparcie e-mail",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Pomoc",
diff --git a/web/i18n/pl-PL/permission-keys.json b/web/i18n/pl-PL/permission-keys.json
index 55619735f16..392f470914e 100644
--- a/web/i18n/pl-PL/permission-keys.json
+++ b/web/i18n/pl-PL/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Zarządzaj konfiguracją rozszerzenia API",
"app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji",
"app.acl.access_config": "Wyświetlaj uprawnienia dostępu i zarządzaj nimi",
+ "app.acl.access_point_manage": "Wyświetlaj punkty dostępu i zarządzaj nimi",
"app.acl.delete": "Usuń aplikację",
"app.acl.deploy": "Wdróż aplikację",
"app.acl.edit": "Edytuj informacje o aplikacji i orkiestruj aplikację",
diff --git a/web/i18n/pl-PL/plugin.json b/web/i18n/pl-PL/plugin.json
index bac26740194..31f7d70b715 100644
--- a/web/i18n/pl-PL/plugin.json
+++ b/web/i18n/pl-PL/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Wszystkie integracje",
"marketplace.and": "i",
"marketplace.becomePartner": "Zostań partnerem",
+ "marketplace.carousel.goToPage": "Przejdź do strony {{page}}",
+ "marketplace.carousel.scrollNext": "Następna strona",
+ "marketplace.carousel.scrollPrevious": "Poprzednia strona",
+ "marketplace.creatorProfile.breadcrumbLabel": "Ścieżka nawigacji",
+ "marketplace.creatorProfile.creations": "Twórczość",
+ "marketplace.creatorProfile.empty": "Brak prac.",
+ "marketplace.creatorProfile.home": "Strona główna Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "W sieci",
+ "marketplace.creatorProfile.organization": "Organizacja",
+ "marketplace.creatorProfile.searchPlaceholder": "Szukaj wtyczek i szablonów",
+ "marketplace.creatorProfile.sort.asc": "Sortuj rosnąco",
+ "marketplace.creatorProfile.sort.createdAt": "Ostatnio utworzone",
+ "marketplace.creatorProfile.sort.desc": "Sortuj malejąco",
+ "marketplace.creatorProfile.sort.popularity": "Popularność",
+ "marketplace.creatorProfile.sort.updatedAt": "Ostatnio zaktualizowane",
+ "marketplace.creatorProfile.sortBy": "Sortuj według",
+ "marketplace.creatorProfile.title": "Profil twórcy",
+ "marketplace.creatorProfile.type.plugin": "Wtyczka",
+ "marketplace.creatorProfile.type.template": "Szablon",
"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",
+ "marketplace.home.plugins": "Integracje",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Szablony",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Nie udało się załadować. Spróbuj ponownie.",
"marketplace.moreFrom": "Więcej z Marketplace",
"marketplace.noPluginFound": "Nie znaleziono integracji",
"marketplace.partnerTip": "Zweryfikowane przez partnera Dify",
"marketplace.pluginsHeroSubtitle": "Korzystaj z integracji tworzonych przez społeczność, aby wspierać rozwój swojej sztucznej inteligencji.",
"marketplace.pluginsHeroTitle": "Odkrywaj. Rozszerzaj. Twórz.",
"marketplace.pluginsResult": "{{num}} wyniki",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Czarne miasto",
"marketplace.sortOption.firstReleased": "Po raz pierwszy wydany",
"marketplace.sortOption.mostPopular": "Najpopularniejsze",
diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json
index e5b19972671..3ac10ee2acb 100644
--- a/web/i18n/pt-BR/common.json
+++ b/web/i18n/pt-BR/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Expirando em {{count}} dias",
"license.unlimited": "Ilimitado",
"loading": "Carregando",
+ "mainNav.help.creatorCenter": "Central do criador",
"mainNav.help.docs": "Documentação",
"mainNav.help.learnDify": "Aprenda Dify",
"mainNav.help.openMenu": "Abrir menu de ajuda",
@@ -669,6 +670,7 @@
"userProfile.about": "Sobre",
"userProfile.compliance": "Conformidade",
"userProfile.contactUs": "Contate-Nos",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Suporte por e-mail",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Ajuda",
diff --git a/web/i18n/pt-BR/permission-keys.json b/web/i18n/pt-BR/permission-keys.json
index 32dda95b517..36dd03d5719 100644
--- a/web/i18n/pt-BR/permission-keys.json
+++ b/web/i18n/pt-BR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gerenciar configuração de extensão de API",
"app.access_config": "Configurar permissões de acesso ao aplicativo",
"app.acl.access_config": "Visualizar e gerenciar permissões de acesso",
+ "app.acl.access_point_manage": "Visualizar e gerenciar pontos de acesso",
"app.acl.delete": "Excluir aplicativo",
"app.acl.deploy": "Implantar aplicativo",
"app.acl.edit": "Editar informações e orquestrar o aplicativo",
diff --git a/web/i18n/pt-BR/plugin.json b/web/i18n/pt-BR/plugin.json
index 5ea2f9b4f44..da9710427db 100644
--- a/web/i18n/pt-BR/plugin.json
+++ b/web/i18n/pt-BR/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Todas as integrações",
"marketplace.and": "e",
"marketplace.becomePartner": "Torne-se um parceiro",
+ "marketplace.carousel.goToPage": "Ir para a página {{page}}",
+ "marketplace.carousel.scrollNext": "Próxima página",
+ "marketplace.carousel.scrollPrevious": "Página anterior",
+ "marketplace.creatorProfile.breadcrumbLabel": "Navegação estrutural",
+ "marketplace.creatorProfile.creations": "Criações",
+ "marketplace.creatorProfile.empty": "Nenhuma criação ainda.",
+ "marketplace.creatorProfile.home": "Página inicial do Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "Na web",
+ "marketplace.creatorProfile.organization": "Organização",
+ "marketplace.creatorProfile.searchPlaceholder": "Pesquisar plugins e modelos",
+ "marketplace.creatorProfile.sort.asc": "Ordenar crescente",
+ "marketplace.creatorProfile.sort.createdAt": "Criado recentemente",
+ "marketplace.creatorProfile.sort.desc": "Ordenar decrescente",
+ "marketplace.creatorProfile.sort.popularity": "Popularidade",
+ "marketplace.creatorProfile.sort.updatedAt": "Atualizado recentemente",
+ "marketplace.creatorProfile.sortBy": "Ordenar por",
+ "marketplace.creatorProfile.title": "Perfil do criador",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Modelo",
"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",
+ "marketplace.home.plugins": "Plugins",
+ "marketplace.home.searchPlaceholder": "Buscar plugins ou modelos",
+ "marketplace.home.templates": "Modelos",
+ "marketplace.home.trendingByCreator": "por {{creator}}",
+ "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.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.languages": "Idiomas",
+ "marketplace.loadError": "Falha ao carregar. Tente novamente.",
"marketplace.moreFrom": "Mais do Marketplace",
"marketplace.noPluginFound": "Nenhuma integração encontrada",
"marketplace.partnerTip": "Verificado por um parceiro da Dify",
"marketplace.pluginsHeroSubtitle": "Use integrações criadas pela comunidade para impulsionar seu desenvolvimento de IA.",
"marketplace.pluginsHeroTitle": "Descubra. Estenda. Construa.",
"marketplace.pluginsResult": "{{num}} resultados",
+ "marketplace.searchFilterLanguage": "Pesquisar idioma",
"marketplace.sortBy": "Ordenar por",
"marketplace.sortOption.firstReleased": "Lançado pela primeira vez",
"marketplace.sortOption.mostPopular": "Mais popular",
diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json
index c44adecf3cb..bd635f885ad 100644
--- a/web/i18n/ro-RO/common.json
+++ b/web/i18n/ro-RO/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Expiră în {{count}} zile",
"license.unlimited": "Nelimitat",
"loading": "Se încarcă",
+ "mainNav.help.creatorCenter": "Centrul creatorilor",
"mainNav.help.docs": "Documentație",
"mainNav.help.learnDify": "Învață Dify",
"mainNav.help.openMenu": "Deschide meniul de ajutor",
@@ -669,6 +670,7 @@
"userProfile.about": "Despre",
"userProfile.compliance": "Conformitate",
"userProfile.contactUs": "Contactați-ne",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Suport prin email",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Ajutor",
diff --git a/web/i18n/ro-RO/permission-keys.json b/web/i18n/ro-RO/permission-keys.json
index 2610e185492..73225f7cd60 100644
--- a/web/i18n/ro-RO/permission-keys.json
+++ b/web/i18n/ro-RO/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Gestionează configurația extensiei API",
"app.access_config": "Configurează permisiunile de acces ale aplicației",
"app.acl.access_config": "Vizualizează și gestionează permisiunile de acces",
+ "app.acl.access_point_manage": "Vizualizează și gestionează punctele de acces",
"app.acl.delete": "Șterge aplicația",
"app.acl.deploy": "Implementează aplicația",
"app.acl.edit": "Editează informațiile aplicației și orchestrează aplicația",
diff --git a/web/i18n/ro-RO/plugin.json b/web/i18n/ro-RO/plugin.json
index 9f0820d4395..a02da38423e 100644
--- a/web/i18n/ro-RO/plugin.json
+++ b/web/i18n/ro-RO/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Toate pluginurile",
"marketplace.and": "și",
"marketplace.becomePartner": "Deveniți partener",
+ "marketplace.carousel.goToPage": "Mergi la pagina {{page}}",
+ "marketplace.carousel.scrollNext": "Pagina următoare",
+ "marketplace.carousel.scrollPrevious": "Pagina anterioară",
+ "marketplace.creatorProfile.breadcrumbLabel": "Fir de navigare",
+ "marketplace.creatorProfile.creations": "Creații",
+ "marketplace.creatorProfile.empty": "Nicio creație încă.",
+ "marketplace.creatorProfile.home": "Pagina principală Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "Pe web",
+ "marketplace.creatorProfile.organization": "Organizație",
+ "marketplace.creatorProfile.searchPlaceholder": "Caută pluginuri și șabloane",
+ "marketplace.creatorProfile.sort.asc": "Sortare crescătoare",
+ "marketplace.creatorProfile.sort.createdAt": "Create recent",
+ "marketplace.creatorProfile.sort.desc": "Sortare descrescătoare",
+ "marketplace.creatorProfile.sort.popularity": "Popularitate",
+ "marketplace.creatorProfile.sort.updatedAt": "Actualizate recent",
+ "marketplace.creatorProfile.sortBy": "Sortează după",
+ "marketplace.creatorProfile.title": "Profilul creatorului",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Șablon",
"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",
+ "marketplace.home.plugins": "Plugin-uri",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Șabloane",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Încărcarea a eșuat. Încercați din nou.",
"marketplace.moreFrom": "Mai multe din Marketplace",
"marketplace.noPluginFound": "Nu s-a găsit niciun plugin",
"marketplace.partnerTip": "Verificat de un partener Dify",
"marketplace.pluginsHeroSubtitle": "Folosiți pluginuri create de comunitate pentru a vă alimenta dezvoltarea AI.",
"marketplace.pluginsHeroTitle": "Descoperă. Extinde. Construiește.",
"marketplace.pluginsResult": "{{num}} rezultate",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Sortează după",
"marketplace.sortOption.firstReleased": "Prima lansare",
"marketplace.sortOption.mostPopular": "Cele mai populare",
diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json
index 06832cdc80f..0e8da4c9539 100644
--- a/web/i18n/ru-RU/common.json
+++ b/web/i18n/ru-RU/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Срок действия истекает через {{count}} дней",
"license.unlimited": "Неограниченный",
"loading": "Загрузка",
+ "mainNav.help.creatorCenter": "Центр авторов",
"mainNav.help.docs": "Документация",
"mainNav.help.learnDify": "Изучить Dify",
"mainNav.help.openMenu": "Открыть меню помощи",
@@ -669,6 +670,7 @@
"userProfile.about": "О нас",
"userProfile.compliance": "Соблюдение",
"userProfile.contactUs": "Свяжитесь с нами",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Поддержка по электронной почте",
"userProfile.github": "ГитХаб",
"userProfile.helpCenter": "Помощь",
diff --git a/web/i18n/ru-RU/permission-keys.json b/web/i18n/ru-RU/permission-keys.json
index 574f0e96add..bd986d65e95 100644
--- a/web/i18n/ru-RU/permission-keys.json
+++ b/web/i18n/ru-RU/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Управление конфигурацией API-расширений",
"app.access_config": "Настройка прав доступа к приложению",
"app.acl.access_config": "Просмотр и управление правами доступа",
+ "app.acl.access_point_manage": "Просмотр и управление точками доступа",
"app.acl.delete": "Удаление приложения",
"app.acl.deploy": "Развертывание приложения",
"app.acl.edit": "Редактирование информации о приложении и оркестрация приложения",
diff --git a/web/i18n/ru-RU/plugin.json b/web/i18n/ru-RU/plugin.json
index cc2b798f782..3b886c0ff4b 100644
--- a/web/i18n/ru-RU/plugin.json
+++ b/web/i18n/ru-RU/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Все плагины",
"marketplace.and": "и",
"marketplace.becomePartner": "Стать партнёром",
+ "marketplace.carousel.goToPage": "Перейти на страницу {{page}}",
+ "marketplace.carousel.scrollNext": "Следующая страница",
+ "marketplace.carousel.scrollPrevious": "Предыдущая страница",
+ "marketplace.creatorProfile.breadcrumbLabel": "Навигационная цепочка",
+ "marketplace.creatorProfile.creations": "Работы",
+ "marketplace.creatorProfile.empty": "Пока нет работ.",
+ "marketplace.creatorProfile.home": "Главная Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "В интернете",
+ "marketplace.creatorProfile.organization": "Организация",
+ "marketplace.creatorProfile.searchPlaceholder": "Поиск плагинов и шаблонов",
+ "marketplace.creatorProfile.sort.asc": "По возрастанию",
+ "marketplace.creatorProfile.sort.createdAt": "Недавно создано",
+ "marketplace.creatorProfile.sort.desc": "По убыванию",
+ "marketplace.creatorProfile.sort.popularity": "Популярность",
+ "marketplace.creatorProfile.sort.updatedAt": "Недавно обновлено",
+ "marketplace.creatorProfile.sortBy": "Сортировать",
+ "marketplace.creatorProfile.title": "Профиль автора",
+ "marketplace.creatorProfile.type.plugin": "Плагин",
+ "marketplace.creatorProfile.type.template": "Шаблон",
"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": "Открывайте. Расширяйте. Создавайте",
+ "marketplace.home.plugins": "Интеграции",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Шаблоны",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Не удалось загрузить. Повторите попытку.",
"marketplace.moreFrom": "Больше из Marketplace",
"marketplace.noPluginFound": "Плагин не найден",
"marketplace.partnerTip": "Подтверждено партнером Dify",
"marketplace.pluginsHeroSubtitle": "Используйте плагины, созданные сообществом, чтобы ускорить разработку ИИ.",
"marketplace.pluginsHeroTitle": "Открывайте. Расширяйте. Создавайте.",
"marketplace.pluginsResult": "Результаты {{num}}",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Черный город",
"marketplace.sortOption.firstReleased": "Впервые выпущен",
"marketplace.sortOption.mostPopular": "Самые популярные",
diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json
index 5979a3e6743..ce710e7eb2a 100644
--- a/web/i18n/sl-SI/common.json
+++ b/web/i18n/sl-SI/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Poteče v {{count}} dneh",
"license.unlimited": "Brez omejitev",
"loading": "Nalaganje",
+ "mainNav.help.creatorCenter": "Središče za ustvarjalce",
"mainNav.help.docs": "Dokumentacija",
"mainNav.help.learnDify": "Spoznajte Dify",
"mainNav.help.openMenu": "Odpri meni pomoči",
@@ -669,6 +670,7 @@
"userProfile.about": "O nas",
"userProfile.compliance": "Skladnost",
"userProfile.contactUs": "Kontaktirajte nas",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Podpora po e-pošti",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Pomoč",
diff --git a/web/i18n/sl-SI/permission-keys.json b/web/i18n/sl-SI/permission-keys.json
index 544c6f92a8d..4a49494a2c2 100644
--- a/web/i18n/sl-SI/permission-keys.json
+++ b/web/i18n/sl-SI/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Upravljanje konfiguracije razširitve API",
"app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije",
"app.acl.access_config": "Ogled in upravljanje dovoljenj za dostop",
+ "app.acl.access_point_manage": "Ogled in upravljanje dostopnih točk",
"app.acl.delete": "Izbriši aplikacijo",
"app.acl.deploy": "Uvedi aplikacijo",
"app.acl.edit": "Uredi podatke o aplikaciji in orkestriraj aplikacijo",
diff --git a/web/i18n/sl-SI/plugin.json b/web/i18n/sl-SI/plugin.json
index 854abf2439d..959425d379f 100644
--- a/web/i18n/sl-SI/plugin.json
+++ b/web/i18n/sl-SI/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Vsi vtičniki",
"marketplace.and": "in",
"marketplace.becomePartner": "Postanite partner",
+ "marketplace.carousel.goToPage": "Pojdi na stran {{page}}",
+ "marketplace.carousel.scrollNext": "Naslednja stran",
+ "marketplace.carousel.scrollPrevious": "Prejšnja stran",
+ "marketplace.creatorProfile.breadcrumbLabel": "Drobtinice",
+ "marketplace.creatorProfile.creations": "Stvaritve",
+ "marketplace.creatorProfile.empty": "Še ni stvaritev.",
+ "marketplace.creatorProfile.home": "Domov Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "Na spletu",
+ "marketplace.creatorProfile.organization": "Organizacija",
+ "marketplace.creatorProfile.searchPlaceholder": "Iskanje vtičnikov in predlog",
+ "marketplace.creatorProfile.sort.asc": "Razvrsti naraščajoče",
+ "marketplace.creatorProfile.sort.createdAt": "Nedavno ustvarjeno",
+ "marketplace.creatorProfile.sort.desc": "Razvrsti padajoče",
+ "marketplace.creatorProfile.sort.popularity": "Priljubljenost",
+ "marketplace.creatorProfile.sort.updatedAt": "Nedavno posodobljeno",
+ "marketplace.creatorProfile.sortBy": "Razvrsti po",
+ "marketplace.creatorProfile.title": "Profil ustvarjalca",
+ "marketplace.creatorProfile.type.plugin": "Vtičnik",
+ "marketplace.creatorProfile.type.template": "Predloga",
"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",
+ "marketplace.home.plugins": "Integracije",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Predloge",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Nalaganje ni uspelo. Poskusite znova.",
"marketplace.moreFrom": "Več iz tržnice",
"marketplace.noPluginFound": "Nobenega vtičnika ni bilo najti.",
"marketplace.partnerTip": "Potrjeno s strani partnerja Dify",
"marketplace.pluginsHeroSubtitle": "Uporabite vtičnike, ki jih je ustvarila skupnost, za pospešitev vašega razvoja AI.",
"marketplace.pluginsHeroTitle": "Odkrijte. Razširite. Gradite.",
"marketplace.pluginsResult": "{{num}} rezultati",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Razvrsti po",
"marketplace.sortOption.firstReleased": "Prvič izdan",
"marketplace.sortOption.mostPopular": "Najbolj priljubljeno",
diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json
index e85c29ff2eb..b389ffda54c 100644
--- a/web/i18n/th-TH/common.json
+++ b/web/i18n/th-TH/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "หมดอายุใน {{count}} วัน",
"license.unlimited": "ไม่มีขีดจำกัด",
"loading": "กำลังโหลด",
+ "mainNav.help.creatorCenter": "ศูนย์ครีเอเตอร์",
"mainNav.help.docs": "เอกสาร",
"mainNav.help.learnDify": "เรียนรู้ Dify",
"mainNav.help.openMenu": "เปิดเมนูช่วยเหลือ",
@@ -669,6 +670,7 @@
"userProfile.about": "ประมาณ",
"userProfile.compliance": "การปฏิบัติตามข้อกำหนด",
"userProfile.contactUs": "ติดต่อเรา",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "การสนับสนุนทางอีเมล",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "วิธีใช้",
diff --git a/web/i18n/th-TH/permission-keys.json b/web/i18n/th-TH/permission-keys.json
index b7b9854abf0..0ad4047ef26 100644
--- a/web/i18n/th-TH/permission-keys.json
+++ b/web/i18n/th-TH/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API",
"app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป",
"app.acl.access_config": "ดูและจัดการสิทธิ์การเข้าถึง",
+ "app.acl.access_point_manage": "ดูและจัดการจุดเข้าถึง",
"app.acl.delete": "ลบแอป",
"app.acl.deploy": "ปรับใช้แอป",
"app.acl.edit": "แก้ไขข้อมูลแอปและจัดวางแอป",
diff --git a/web/i18n/th-TH/plugin.json b/web/i18n/th-TH/plugin.json
index 0961caf3e0e..67b64c4ded7 100644
--- a/web/i18n/th-TH/plugin.json
+++ b/web/i18n/th-TH/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "ปลั๊กอินทั้งหมด",
"marketplace.and": "และ",
"marketplace.becomePartner": "เป็นพันธมิตร",
+ "marketplace.carousel.goToPage": "ไปที่หน้า {{page}}",
+ "marketplace.carousel.scrollNext": "หน้าถัดไป",
+ "marketplace.carousel.scrollPrevious": "หน้าก่อนหน้า",
+ "marketplace.creatorProfile.breadcrumbLabel": "เส้นทางนำทาง",
+ "marketplace.creatorProfile.creations": "ผลงาน",
+ "marketplace.creatorProfile.empty": "ยังไม่มีผลงาน",
+ "marketplace.creatorProfile.home": "หน้าแรก Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "บนเว็บ",
+ "marketplace.creatorProfile.organization": "องค์กร",
+ "marketplace.creatorProfile.searchPlaceholder": "ค้นหาปลั๊กอินและเทมเพลต",
+ "marketplace.creatorProfile.sort.asc": "เรียงจากน้อยไปมาก",
+ "marketplace.creatorProfile.sort.createdAt": "สร้างล่าสุด",
+ "marketplace.creatorProfile.sort.desc": "เรียงจากมากไปน้อย",
+ "marketplace.creatorProfile.sort.popularity": "ความนิยม",
+ "marketplace.creatorProfile.sort.updatedAt": "อัปเดตล่าสุด",
+ "marketplace.creatorProfile.sortBy": "เรียงตาม",
+ "marketplace.creatorProfile.title": "โปรไฟล์ครีเอเตอร์",
+ "marketplace.creatorProfile.type.plugin": "ปลั๊กอิน",
+ "marketplace.creatorProfile.type.template": "เทมเพลต",
"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": "ค้นพบ ขยาย และสร้าง",
+ "marketplace.home.plugins": "ปลั๊กอิน",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "เทมเพลต",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "โหลดไม่สำเร็จ โปรดลองอีกครั้ง",
"marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace",
"marketplace.noPluginFound": "ไม่พบปลั๊กอิน",
"marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify",
"marketplace.pluginsHeroSubtitle": "ใช้ปลั๊กอินที่สร้างโดยชุมชนเพื่อเสริมพลังการพัฒนา AI ของคุณ",
"marketplace.pluginsHeroTitle": "ค้นพบ ขยาย สร้าง",
"marketplace.pluginsResult": "{{num}} ผลลัพธ์",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "เมืองสีดํา",
"marketplace.sortOption.firstReleased": "เปิดตัวครั้งแรก",
"marketplace.sortOption.mostPopular": "แห่ง",
diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json
index bfd30dc5a00..5854f0b0662 100644
--- a/web/i18n/tr-TR/common.json
+++ b/web/i18n/tr-TR/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "{{count}} gün içinde sona eriyor",
"license.unlimited": "Sınırsız",
"loading": "Yükleniyor",
+ "mainNav.help.creatorCenter": "İçerik Üretici Merkezi",
"mainNav.help.docs": "Belgeler",
"mainNav.help.learnDify": "Dify’ı öğrenin",
"mainNav.help.openMenu": "Yardım menüsünü aç",
@@ -669,6 +670,7 @@
"userProfile.about": "Hakkında",
"userProfile.compliance": "Uygunluk",
"userProfile.contactUs": "Bize Ulaşın",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "E-posta Desteği",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Yardım",
diff --git a/web/i18n/tr-TR/permission-keys.json b/web/i18n/tr-TR/permission-keys.json
index 36ba8ec9709..781d9d00d38 100644
--- a/web/i18n/tr-TR/permission-keys.json
+++ b/web/i18n/tr-TR/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "API uzantısı yapılandırmasını yönet",
"app.access_config": "Uygulama erişim izinlerini yapılandır",
"app.acl.access_config": "Erişim izinlerini görüntüle ve yönet",
+ "app.acl.access_point_manage": "Erişim noktalarını görüntüle ve yönet",
"app.acl.delete": "Uygulamayı sil",
"app.acl.deploy": "Uygulamayı dağıt",
"app.acl.edit": "Uygulama bilgilerini düzenle ve uygulamayı orkestre et",
diff --git a/web/i18n/tr-TR/plugin.json b/web/i18n/tr-TR/plugin.json
index 3054c665e5e..2718093eb6e 100644
--- a/web/i18n/tr-TR/plugin.json
+++ b/web/i18n/tr-TR/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Tüm eklentiler",
"marketplace.and": "ve",
"marketplace.becomePartner": "Partner Olun",
+ "marketplace.carousel.goToPage": "{{page}}. sayfaya git",
+ "marketplace.carousel.scrollNext": "Sonraki sayfa",
+ "marketplace.carousel.scrollPrevious": "Önceki sayfa",
+ "marketplace.creatorProfile.breadcrumbLabel": "Sayfa yolu",
+ "marketplace.creatorProfile.creations": "Çalışmalar",
+ "marketplace.creatorProfile.empty": "Henüz çalışma yok.",
+ "marketplace.creatorProfile.home": "Marketplace ana sayfası",
+ "marketplace.creatorProfile.onTheWeb": "Web'de",
+ "marketplace.creatorProfile.organization": "Organizasyon",
+ "marketplace.creatorProfile.searchPlaceholder": "Eklenti ve şablon ara",
+ "marketplace.creatorProfile.sort.asc": "Artan sırala",
+ "marketplace.creatorProfile.sort.createdAt": "Son oluşturulan",
+ "marketplace.creatorProfile.sort.desc": "Azalan sırala",
+ "marketplace.creatorProfile.sort.popularity": "Popülerlik",
+ "marketplace.creatorProfile.sort.updatedAt": "Son güncellenen",
+ "marketplace.creatorProfile.sortBy": "Sırala",
+ "marketplace.creatorProfile.title": "Üretici profili",
+ "marketplace.creatorProfile.type.plugin": "Eklenti",
+ "marketplace.creatorProfile.type.template": "Şablon",
"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",
+ "marketplace.home.plugins": "Eklentiler",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Şablonlar",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Yüklenemedi. Lütfen tekrar deneyin.",
"marketplace.moreFrom": "Pazar Yeri'nden daha fazlası",
"marketplace.noPluginFound": "Eklenti bulunamadı",
"marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı",
"marketplace.pluginsHeroSubtitle": "Yapay zeka geliştirmenizi güçlendirmek için topluluk tarafından oluşturulan eklentileri kullanın.",
"marketplace.pluginsHeroTitle": "Keşfet. Genişlet. Oluştur.",
"marketplace.pluginsResult": "{{num}} sonuç",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Sırala",
"marketplace.sortOption.firstReleased": "İlk Çıkanlar",
"marketplace.sortOption.mostPopular": "En popüler",
diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json
index 011449c67ab..113648dc0a8 100644
--- a/web/i18n/uk-UA/common.json
+++ b/web/i18n/uk-UA/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Термін дії закінчується за {{count}} днів",
"license.unlimited": "Безмежний",
"loading": "Завантаження",
+ "mainNav.help.creatorCenter": "Центр авторів",
"mainNav.help.docs": "Документація",
"mainNav.help.learnDify": "Вивчити Dify",
"mainNav.help.openMenu": "Відкрити меню довідки",
@@ -669,6 +670,7 @@
"userProfile.about": "Про нас",
"userProfile.compliance": "Відповідність",
"userProfile.contactUs": "Зв’яжіться з нами",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Підтримка по електронній пошті",
"userProfile.github": "Гітхаб",
"userProfile.helpCenter": "Довідковий центр",
diff --git a/web/i18n/uk-UA/permission-keys.json b/web/i18n/uk-UA/permission-keys.json
index 861c83a4367..8cfd28d2548 100644
--- a/web/i18n/uk-UA/permission-keys.json
+++ b/web/i18n/uk-UA/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Керування конфігурацією розширення API",
"app.access_config": "Налаштування дозволів доступу до застосунку",
"app.acl.access_config": "Переглядати дозволи доступу та керувати ними",
+ "app.acl.access_point_manage": "Переглядати точки доступу та керувати ними",
"app.acl.delete": "Видалити застосунок",
"app.acl.deploy": "Розгорнути застосунок",
"app.acl.edit": "Редагувати інформацію про застосунок та оркеструвати застосунок",
diff --git a/web/i18n/uk-UA/plugin.json b/web/i18n/uk-UA/plugin.json
index 630ece992cf..80dff6cdd78 100644
--- a/web/i18n/uk-UA/plugin.json
+++ b/web/i18n/uk-UA/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Всі плагіни",
"marketplace.and": "і",
"marketplace.becomePartner": "Стати партнером",
+ "marketplace.carousel.goToPage": "Перейти на сторінку {{page}}",
+ "marketplace.carousel.scrollNext": "Наступна сторінка",
+ "marketplace.carousel.scrollPrevious": "Попередня сторінка",
+ "marketplace.creatorProfile.breadcrumbLabel": "Навігаційний ланцюжок",
+ "marketplace.creatorProfile.creations": "Роботи",
+ "marketplace.creatorProfile.empty": "Поки немає робіт.",
+ "marketplace.creatorProfile.home": "Головна Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "В інтернеті",
+ "marketplace.creatorProfile.organization": "Організація",
+ "marketplace.creatorProfile.searchPlaceholder": "Пошук плагінів і шаблонів",
+ "marketplace.creatorProfile.sort.asc": "За зростанням",
+ "marketplace.creatorProfile.sort.createdAt": "Нещодавно створено",
+ "marketplace.creatorProfile.sort.desc": "За спаданням",
+ "marketplace.creatorProfile.sort.popularity": "Популярність",
+ "marketplace.creatorProfile.sort.updatedAt": "Нещодавно оновлено",
+ "marketplace.creatorProfile.sortBy": "Сортувати",
+ "marketplace.creatorProfile.title": "Профіль автора",
+ "marketplace.creatorProfile.type.plugin": "Плагін",
+ "marketplace.creatorProfile.type.template": "Шаблон",
"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": "Відкривайте. Розширюйте. Створюйте",
+ "marketplace.home.plugins": "Інтеграції",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Шаблони",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Не вдалося завантажити. Спробуйте ще раз.",
"marketplace.moreFrom": "Більше від Marketplace",
"marketplace.noPluginFound": "Плагін не знайдено",
"marketplace.partnerTip": "Перевірено партнером Dify",
"marketplace.pluginsHeroSubtitle": "Використовуйте створені спільнотою плагіни для розвитку вашої розробки штучного інтелекту.",
"marketplace.pluginsHeroTitle": "Відкривайте. Розширюйте. Створюйте.",
"marketplace.pluginsResult": "Результати {{num}}",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Чорне місто",
"marketplace.sortOption.firstReleased": "Перший реліз",
"marketplace.sortOption.mostPopular": "Найпопулярніших",
diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json
index 267387c547e..60ecb59c3e6 100644
--- a/web/i18n/vi-VN/common.json
+++ b/web/i18n/vi-VN/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "Hết hạn sau {{count}} ngày",
"license.unlimited": "Vô hạn",
"loading": "Đang tải",
+ "mainNav.help.creatorCenter": "Trung tâm nhà sáng tạo",
"mainNav.help.docs": "Tài liệu",
"mainNav.help.learnDify": "Tìm hiểu Dify",
"mainNav.help.openMenu": "Mở menu trợ giúp",
@@ -669,6 +670,7 @@
"userProfile.about": "Về chúng tôi",
"userProfile.compliance": "Tuân thủ",
"userProfile.contactUs": "Liên hệ với chúng tôi",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "Hỗ trợ qua Email",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "Trung tâm trợ giúp",
diff --git a/web/i18n/vi-VN/permission-keys.json b/web/i18n/vi-VN/permission-keys.json
index 1e6662a9304..2290d6362e0 100644
--- a/web/i18n/vi-VN/permission-keys.json
+++ b/web/i18n/vi-VN/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "Quản lý cấu hình phần mở rộng API",
"app.access_config": "Cấu hình quyền truy cập ứng dụng",
"app.acl.access_config": "Xem và quản lý quyền truy cập",
+ "app.acl.access_point_manage": "Xem và quản lý điểm truy cập",
"app.acl.delete": "Xóa ứng dụng",
"app.acl.deploy": "Triển khai ứng dụng",
"app.acl.edit": "Chỉnh sửa thông tin và điều phối ứng dụng",
diff --git a/web/i18n/vi-VN/plugin.json b/web/i18n/vi-VN/plugin.json
index 0c53d8f25c2..1967395535d 100644
--- a/web/i18n/vi-VN/plugin.json
+++ b/web/i18n/vi-VN/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "Tất cả plugin",
"marketplace.and": "và",
"marketplace.becomePartner": "Trở thành đối tác",
+ "marketplace.carousel.goToPage": "Đi tới trang {{page}}",
+ "marketplace.carousel.scrollNext": "Trang sau",
+ "marketplace.carousel.scrollPrevious": "Trang trước",
+ "marketplace.creatorProfile.breadcrumbLabel": "Đường dẫn điều hướng",
+ "marketplace.creatorProfile.creations": "Tác phẩm",
+ "marketplace.creatorProfile.empty": "Chưa có tác phẩm nào.",
+ "marketplace.creatorProfile.home": "Trang chủ Marketplace",
+ "marketplace.creatorProfile.onTheWeb": "Trên web",
+ "marketplace.creatorProfile.organization": "Tổ chức",
+ "marketplace.creatorProfile.searchPlaceholder": "Tìm plugin và mẫu",
+ "marketplace.creatorProfile.sort.asc": "Sắp xếp tăng dần",
+ "marketplace.creatorProfile.sort.createdAt": "Tạo gần đây",
+ "marketplace.creatorProfile.sort.desc": "Sắp xếp giảm dần",
+ "marketplace.creatorProfile.sort.popularity": "Phổ biến",
+ "marketplace.creatorProfile.sort.updatedAt": "Cập nhật gần đây",
+ "marketplace.creatorProfile.sortBy": "Sắp xếp theo",
+ "marketplace.creatorProfile.title": "Hồ sơ nhà sáng tạo",
+ "marketplace.creatorProfile.type.plugin": "Plugin",
+ "marketplace.creatorProfile.type.template": "Mẫu",
"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",
+ "marketplace.home.plugins": "Plugin",
+ "marketplace.home.searchPlaceholder": "Search plugins or templates",
+ "marketplace.home.templates": "Mẫu",
+ "marketplace.home.trendingByCreator": "by {{creator}}",
+ "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.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.languages": "Filter by Languages",
+ "marketplace.loadError": "Tải không thành công. Vui lòng thử lại.",
"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",
"marketplace.pluginsHeroSubtitle": "Sử dụng các plugin do cộng đồng xây dựng để hỗ trợ phát triển AI của bạn.",
"marketplace.pluginsHeroTitle": "Khám phá. Mở rộng. Xây dựng.",
"marketplace.pluginsResult": "{{num}} kết quả",
+ "marketplace.searchFilterLanguage": "Search language",
"marketplace.sortBy": "Thành phố đen",
"marketplace.sortOption.firstReleased": "Phát hành lần đầu tiên",
"marketplace.sortOption.mostPopular": "Phổ biến nhất",
diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json
index 33307528ef9..1c1e69b59cb 100644
--- a/web/i18n/zh-Hans/common.json
+++ b/web/i18n/zh-Hans/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "许可证还有 {{count}} 天到期",
"license.unlimited": "无限制",
"loading": "加载中",
+ "mainNav.help.creatorCenter": "创作者中心",
"mainNav.help.docs": "文档",
"mainNav.help.learnDify": "了解 Dify",
"mainNav.help.openMenu": "打开帮助菜单",
@@ -669,6 +670,7 @@
"userProfile.about": "关于",
"userProfile.compliance": "合规",
"userProfile.contactUs": "联系我们",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "邮件支持",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "查看帮助文档",
diff --git a/web/i18n/zh-Hans/permission-keys.json b/web/i18n/zh-Hans/permission-keys.json
index 91d9afb2cc8..db8184ee533 100644
--- a/web/i18n/zh-Hans/permission-keys.json
+++ b/web/i18n/zh-Hans/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "管理API扩展",
"app.access_config": "配置应用访问权限",
"app.acl.access_config": "查看与管理访问权限",
+ "app.acl.access_point_manage": "查看与管理访问点",
"app.acl.delete": "删除应用",
"app.acl.deploy": "部署应用",
"app.acl.edit": "编辑应用信息与编排应用",
diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json
index 184b60d344f..efae41eb2a8 100644
--- a/web/i18n/zh-Hans/plugin.json
+++ b/web/i18n/zh-Hans/plugin.json
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "所有集成",
"marketplace.and": "和",
"marketplace.becomePartner": "成为合作伙伴",
+ "marketplace.carousel.goToPage": "转到第 {{page}} 页",
+ "marketplace.carousel.scrollNext": "下一页",
+ "marketplace.carousel.scrollPrevious": "上一页",
+ "marketplace.creatorProfile.breadcrumbLabel": "面包屑导航",
+ "marketplace.creatorProfile.creations": "作品",
+ "marketplace.creatorProfile.empty": "暂无作品。",
+ "marketplace.creatorProfile.home": "Marketplace 首页",
+ "marketplace.creatorProfile.onTheWeb": "社交主页",
+ "marketplace.creatorProfile.organization": "组织",
+ "marketplace.creatorProfile.searchPlaceholder": "搜索插件和模板",
+ "marketplace.creatorProfile.sort.asc": "升序排列",
+ "marketplace.creatorProfile.sort.createdAt": "创建时间",
+ "marketplace.creatorProfile.sort.desc": "降序排列",
+ "marketplace.creatorProfile.sort.popularity": "热度",
+ "marketplace.creatorProfile.sort.updatedAt": "更新时间",
+ "marketplace.creatorProfile.sortBy": "排序",
+ "marketplace.creatorProfile.title": "创作者主页",
+ "marketplace.creatorProfile.type.plugin": "插件",
+ "marketplace.creatorProfile.type.template": "模板",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "探索",
"marketplace.empower": "助力您的 AI 开发",
+ "marketplace.home.creatorCenter": "创作者中心",
+ "marketplace.home.guide": "指南",
+ "marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的插件。",
+ "marketplace.home.heroTitle": "发现。扩展。构建",
+ "marketplace.home.plugins": "插件",
+ "marketplace.home.searchPlaceholder": "搜索插件或模板",
+ "marketplace.home.templates": "模板",
+ "marketplace.home.trendingByCreator": "由 {{creator}} 发布",
+ "marketplace.home.trendingDescription": "基于真实使用情况选出的热门插件,每两周更新一次。榜单按各工作区的实际运行次数排序,不含付费推广或编辑推荐。",
+ "marketplace.home.trendingPaginationLabel": "热门推荐页码",
+ "marketplace.home.trendingPause": "暂停",
+ "marketplace.home.trendingPlay": "播放",
+ "marketplace.home.trendingReadMore": "阅读更多",
+ "marketplace.home.trendingReadMoreAbout": "阅读更多关于 {{title}} 的内容",
+ "marketplace.home.trendingTitle": "大家都在安装的插件",
+ "marketplace.home.trendingView": "查看",
+ "marketplace.languages": "按语言筛选",
+ "marketplace.loadError": "加载失败,请重试。",
"marketplace.moreFrom": "来自 Marketplace 的更多内容",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
"marketplace.pluginsHeroSubtitle": "使用社区构建的集成助力您的 AI 开发。",
"marketplace.pluginsHeroTitle": "探索 · 扩展 · 构建",
"marketplace.pluginsResult": "{{num}} 个插件结果",
+ "marketplace.searchFilterLanguage": "搜索语言",
"marketplace.sortBy": "排序方式",
"marketplace.sortOption.firstReleased": "首次发布",
"marketplace.sortOption.mostPopular": "最受欢迎",
diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json
index 43113137c09..5805edb6382 100644
--- a/web/i18n/zh-Hant/common.json
+++ b/web/i18n/zh-Hant/common.json
@@ -197,6 +197,7 @@
"license.expiring_plural": "將在 {{count}} 天后過期",
"license.unlimited": "無限制",
"loading": "載入中",
+ "mainNav.help.creatorCenter": "創作者中心",
"mainNav.help.docs": "文件",
"mainNav.help.learnDify": "學習 Dify",
"mainNav.help.openMenu": "開啟幫助選單",
@@ -669,6 +670,7 @@
"userProfile.about": "關於",
"userProfile.compliance": "合規",
"userProfile.contactUs": "聯絡我們",
+ "userProfile.discord": "Discord",
"userProfile.emailSupport": "電子郵件支援",
"userProfile.github": "GitHub",
"userProfile.helpCenter": "查看幫助文件",
diff --git a/web/i18n/zh-Hant/permission-keys.json b/web/i18n/zh-Hant/permission-keys.json
index 43350a59ada..8a74b37f086 100644
--- a/web/i18n/zh-Hant/permission-keys.json
+++ b/web/i18n/zh-Hant/permission-keys.json
@@ -3,6 +3,7 @@
"api_extension.manage": "管理API擴充配置",
"app.access_config": "配置應用訪問權限",
"app.acl.access_config": "檢視與管理存取權限",
+ "app.acl.access_point_manage": "檢視與管理存取點",
"app.acl.delete": "刪除應用",
"app.acl.deploy": "部署應用",
"app.acl.edit": "編輯應用資訊與編排應用",
diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json
index 61d44151540..e22a867d9e8 100644
--- a/web/i18n/zh-Hant/plugin.json
+++ b/web/i18n/zh-Hant/plugin.json
@@ -84,19 +84,19 @@
"autoUpdate.upgradeMode.partial": "僅選擇",
"autoUpdate.upgradeModePlaceholder.exclude": "選定的插件將不會自動更新",
"autoUpdate.upgradeModePlaceholder.partial": "只有選定的插件會自動更新。目前未選定任何插件,因此不會自動更新任何插件。",
- "category.agents": "代理策略",
- "category.all": "都",
- "category.bundles": "束",
+ "category.agents": "Agent 策略",
+ "category.all": "全部",
+ "category.bundles": "整合包",
"category.datasources": "資料來源",
- "category.extensions": "擴展",
+ "category.extensions": "擴充功能",
"category.models": "模型",
"category.tools": "工具",
- "category.triggers": "觸發因素",
- "categorySingle.agent": "代理策略",
- "categorySingle.bundle": "捆",
+ "category.triggers": "觸發器",
+ "categorySingle.agent": "Agent 策略",
+ "categorySingle.bundle": "整合包",
"categorySingle.datasource": "資料來源",
- "categorySingle.extension": "外延",
- "categorySingle.model": "型",
+ "categorySingle.extension": "擴充功能",
+ "categorySingle.model": "模型",
"categorySingle.tool": "工具",
"categorySingle.trigger": "觸發器",
"clearSearch": "清空{{label}}",
@@ -226,15 +226,53 @@
"marketplace.allPlugins": "所有集成",
"marketplace.and": "和",
"marketplace.becomePartner": "成為合作夥伴",
+ "marketplace.carousel.goToPage": "轉到第 {{page}} 頁",
+ "marketplace.carousel.scrollNext": "下一頁",
+ "marketplace.carousel.scrollPrevious": "上一頁",
+ "marketplace.creatorProfile.breadcrumbLabel": "麵包屑導航",
+ "marketplace.creatorProfile.creations": "作品",
+ "marketplace.creatorProfile.empty": "尚無作品。",
+ "marketplace.creatorProfile.home": "Marketplace 首頁",
+ "marketplace.creatorProfile.onTheWeb": "社交主頁",
+ "marketplace.creatorProfile.organization": "組織",
+ "marketplace.creatorProfile.searchPlaceholder": "搜尋外掛和模板",
+ "marketplace.creatorProfile.sort.asc": "升序排列",
+ "marketplace.creatorProfile.sort.createdAt": "建立時間",
+ "marketplace.creatorProfile.sort.desc": "降序排列",
+ "marketplace.creatorProfile.sort.popularity": "熱度",
+ "marketplace.creatorProfile.sort.updatedAt": "更新時間",
+ "marketplace.creatorProfile.sortBy": "排序",
+ "marketplace.creatorProfile.title": "創作者主頁",
+ "marketplace.creatorProfile.type.plugin": "外掛",
+ "marketplace.creatorProfile.type.template": "模板",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "發現",
"marketplace.empower": "為您的 AI 開發提供支援",
+ "marketplace.home.creatorCenter": "創作者中心",
+ "marketplace.home.guide": "指南",
+ "marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的外掛程式。",
+ "marketplace.home.heroTitle": "探索。擴展。建構",
+ "marketplace.home.plugins": "外掛",
+ "marketplace.home.searchPlaceholder": "搜尋外掛程式或範本",
+ "marketplace.home.templates": "範本",
+ "marketplace.home.trendingByCreator": "由 {{creator}} 發布",
+ "marketplace.home.trendingDescription": "根據真實使用情況選出的熱門外掛程式,每兩週更新一次。榜單按各工作區的實際執行次數排序,不含付費推廣或編輯推薦。",
+ "marketplace.home.trendingPaginationLabel": "熱門推薦頁碼",
+ "marketplace.home.trendingPause": "暫停",
+ "marketplace.home.trendingPlay": "播放",
+ "marketplace.home.trendingReadMore": "閱讀更多",
+ "marketplace.home.trendingReadMoreAbout": "閱讀更多關於 {{title}} 的內容",
+ "marketplace.home.trendingTitle": "大家都在安裝的外掛程式",
+ "marketplace.home.trendingView": "查看",
+ "marketplace.languages": "按語言篩選",
+ "marketplace.loadError": "載入失敗,請重試。",
"marketplace.moreFrom": "來自 Marketplace 的更多內容",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "由 Dify 合作夥伴驗證",
"marketplace.pluginsHeroSubtitle": "使用社群構建的集成來助力您的 AI 開發。",
"marketplace.pluginsHeroTitle": "發現。擴展。構建。",
"marketplace.pluginsResult": "{{num}} 個結果",
+ "marketplace.searchFilterLanguage": "搜尋語言",
"marketplace.sortBy": "排序方式",
"marketplace.sortOption.firstReleased": "首次發佈",
"marketplace.sortOption.mostPopular": "最受歡迎",
diff --git a/web/proxy.ts b/web/proxy.ts
index da637a724ac..1f7248efd53 100644
--- a/web/proxy.ts
+++ b/web/proxy.ts
@@ -18,15 +18,35 @@ const EMBEDDABLE_PATH_SEGMENTS = [
'/workflow',
]
const NON_EMBEDDABLE_PATH_SEGMENTS = ['/device']
-const FRAME_ANCESTORS_NONE = "frame-ancestors 'none';"
+const FRAME_ANCESTORS_NONE = "'none'"
const LEGACY_EDUCATION_ACTION = 'getEducationVerify'
+const getHttpOrigin = (value: string | undefined) => {
+ if (!value) return ''
+
+ try {
+ const url = new URL(value)
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : ''
+ } catch {
+ return ''
+ }
+}
+
const matchesPathSegment = (pathname: string, segments: string[]) =>
segments.some((segment) => pathname === segment || pathname.startsWith(`${segment}/`))
export const canEmbedPath = (pathname: string) =>
matchesPathSegment(pathname, EMBEDDABLE_PATH_SEGMENTS)
+const appendFrameAncestors = (response: NextResponse, frameOrigin: string) => {
+ const existingCsp = response.headers.get('Content-Security-Policy')
+ if (existingCsp?.includes('frame-ancestors')) return
+ response.headers.set(
+ 'Content-Security-Policy',
+ `${existingCsp ? `${existingCsp} ` : ''}frame-ancestors ${frameOrigin};`,
+ )
+}
+
const wrapResponseWithFrameProtection = (response: NextResponse, pathname: string) => {
// Published app routes are intentionally embeddable; all other routes default to clickjacking protection.
const preventEmbedding =
@@ -35,13 +55,7 @@ const wrapResponseWithFrameProtection = (response: NextResponse, pathname: strin
if (preventEmbedding) {
response.headers.set('X-Frame-Options', 'DENY')
- const contentSecurityPolicy = response.headers.get('Content-Security-Policy')
- response.headers.set(
- 'Content-Security-Policy',
- contentSecurityPolicy
- ? `${contentSecurityPolicy} ${FRAME_ANCESTORS_NONE}`
- : FRAME_ANCESTORS_NONE,
- )
+ appendFrameAncestors(response, FRAME_ANCESTORS_NONE)
}
return response
@@ -80,6 +94,8 @@ export function proxy(request: NextRequest) {
? ' https://challenges.cloudflare.com'
: ''
const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}${turnstileOrigin}`
+ const marketplaceFrameOrigin = getHttpOrigin(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX)
+ const marketplaceFrameSrc = marketplaceFrameOrigin ? ` ${marketplaceFrameOrigin}` : ''
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const csp = `'nonce-${nonce}'`
@@ -92,6 +108,7 @@ export function proxy(request: NextRequest) {
style-src 'self' 'unsafe-inline' ${scheme_source} ${whiteList};
worker-src 'self' ${scheme_source} ${csp} ${whiteList};
media-src 'self' ${scheme_source} ${csp} ${whiteList};
+ frame-src 'self' ${scheme_source} ${whiteList}${marketplaceFrameSrc};
img-src * data: blob:;
font-src 'self';
object-src 'none';
diff --git a/web/public/marketplace/dify-marketplace-logo-dark.svg b/web/public/marketplace/dify-marketplace-logo-dark.svg
new file mode 100644
index 00000000000..377525a94a4
--- /dev/null
+++ b/web/public/marketplace/dify-marketplace-logo-dark.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/public/marketplace/dify-marketplace-logo.svg b/web/public/marketplace/dify-marketplace-logo.svg
new file mode 100644
index 00000000000..bff6718c2af
--- /dev/null
+++ b/web/public/marketplace/dify-marketplace-logo.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/service/__tests__/base-request.spec.ts b/web/service/__tests__/base-request.spec.ts
index c324a83cb55..0da529bc10c 100644
--- a/web/service/__tests__/base-request.spec.ts
+++ b/web/service/__tests__/base-request.spec.ts
@@ -1,4 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import {
+ discardRegistrationSessionState,
+ OAUTH_REGISTRATION_GA_SENT_KEY,
+ REGISTRATION_SUCCESS_STORAGE_KEY,
+} from '@/app/components/base/amplitude/registration-session-state'
// oxlint-disable-next-line no-restricted-imports -- This spec directly tests the legacy request owner.
import { request } from '../base'
@@ -51,6 +56,21 @@ const createUnauthorizedResponse = () =>
},
)
+const createForcedLogoutResponse = () =>
+ new Response(
+ JSON.stringify({
+ code: 'unauthorized_and_force_logout',
+ message: 'This account session is no longer valid.',
+ status: 401,
+ }),
+ {
+ status: 401,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ )
+
type ClientRequestOptions = {
response: Response
refreshError?: Error
@@ -80,9 +100,11 @@ describe('request 401 handling', () => {
writable: true,
configurable: true,
})
+ window.sessionStorage.clear()
})
afterEach(() => {
+ discardRegistrationSessionState()
Object.defineProperty(globalThis, 'location', {
value: originalLocation,
writable: true,
@@ -103,21 +125,42 @@ describe('request 401 handling', () => {
it('should preserve the current URL when a 401 response cannot be parsed', async () => {
const response = new Response('not-json', { status: 401 })
arrangeClientRequest({ response })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker')
await expect(request('/account/profile')).rejects.toBe(response)
expect(globalThis.location.href).toBe(
`https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`,
)
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
expect(mocks.refreshAccessTokenOrReLogin).not.toHaveBeenCalled()
})
+ it('clears account A registration state before a forced reload so account B starts clean', async () => {
+ const response = createForcedLogoutResponse()
+ arrangeClientRequest({ response })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker')
+ window.sessionStorage.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true')
+ const reload = vi.fn(() => {
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
+ expect(window.sessionStorage.getItem(OAUTH_REGISTRATION_GA_SENT_KEY)).toBeNull()
+ })
+ globalThis.location.reload = reload
+
+ await expect(request('/account/profile')).rejects.toBe(response)
+
+ expect(reload).toHaveBeenCalledOnce()
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-b-marker')
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('account-b-marker')
+ })
+
it('should preserve the current URL when token refresh fails', async () => {
const response = createUnauthorizedResponse()
arrangeClientRequest({
response,
refreshError: new Error('refresh failed'),
})
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'account-a-marker')
await expect(request('/account/profile')).rejects.toBe(response)
@@ -125,5 +168,16 @@ describe('request 401 handling', () => {
expect(globalThis.location.href).toBe(
`https://example.com/app/signin?redirect_url=${encodeURIComponent('/app/apps?category=agent#recent')}`,
)
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
+ })
+
+ it('does not clear console registration state for a public-app 401 redirect', async () => {
+ const response = createUnauthorizedResponse()
+ arrangeClientRequest({ response })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'console-marker')
+
+ await expect(request('/account/profile', {}, { isPublicAPI: true })).rejects.toBe(response)
+
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBe('console-marker')
})
})
diff --git a/web/service/base.ts b/web/service/base.ts
index d5ece4cd7d4..ea2435fdb6b 100644
--- a/web/service/base.ts
+++ b/web/service/base.ts
@@ -30,6 +30,7 @@ import type {
} from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import Cookies from 'js-cookie'
+import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state'
import {
API_PREFIX,
CSRF_COOKIE_NAME,
@@ -197,6 +198,14 @@ export type IOtherOptions = {
onDataSourceNodeError?: IOnDataSourceNodeError
}
+const discardRegistrationStateForConsoleAuthBoundary = ({
+ isMarketplaceAPI,
+ isPublicAPI,
+}: IOtherOptions) => {
+ if (isMarketplaceAPI || isPublicAPI) return
+ discardRegistrationSessionState()
+}
+
function jumpTo(url: string) {
if (!url || !isClient) return
const targetPath = new URL(url, window.location.origin).pathname
@@ -1008,6 +1017,7 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe
const [parseErr, errRespData] = await asyncRunSafe(errResp.json())
if (parseErr) {
+ discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch)
window.location.href = buildSigninUrlWithRedirect()
return Promise.reject(err)
}
@@ -1025,6 +1035,7 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe
}
if (code === 'unauthorized_and_force_logout') {
// Cookies will be cleared by the backend
+ discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch)
window.location.reload()
return Promise.reject(err)
}
@@ -1053,6 +1064,7 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe
// there. Redirecting to /signin loses the user_code context and
// the post-login flow lands on /apps instead of returning here.
if (window.location.pathname === `${basePath}/device`) return Promise.reject(err)
+ discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch)
if (window.location.pathname !== `${basePath}/signin`) {
jumpTo(buildSigninUrlWithRedirect())
return Promise.reject(err)
diff --git a/web/service/client.ts b/web/service/client.ts
index 4aa5001b371..14ad3c465f7 100644
--- a/web/service/client.ts
+++ b/web/service/client.ts
@@ -36,6 +36,23 @@ function getMarketplaceHeaders() {
})
}
+// 15s deadline so a stalled Marketplace fetch can error/retry.
+const MARKETPLACE_REQUEST_TIMEOUT_MS = 15_000
+
+// Combine the caller's abort with the deadline; AbortSignal.any is too new.
+function withRequestDeadline(callerSignal: AbortSignal | null | undefined): AbortSignal {
+ const deadline = AbortSignal.timeout(MARKETPLACE_REQUEST_TIMEOUT_MS)
+ if (!callerSignal) return deadline
+ if (callerSignal.aborted) return callerSignal
+
+ const controller = new AbortController()
+ callerSignal.addEventListener('abort', () => controller.abort(callerSignal.reason), {
+ once: true,
+ })
+ deadline.addEventListener('abort', () => controller.abort(deadline.reason), { once: true })
+ return controller.signal
+}
+
function isURL(path: string) {
try {
// oxlint-disable-next-line no-new
@@ -99,9 +116,11 @@ const marketplaceLink = new OpenAPILink(marketplaceRouterContract, {
url: MARKETPLACE_API_PREFIX,
headers: () => getMarketplaceHeaders(),
fetch: (request, init) => {
+ const requestInit = init as RequestInit | undefined
return globalThis.fetch(request, {
- ...init,
+ ...requestInit,
cache: 'no-store',
+ signal: withRequestDeadline(requestInit?.signal ?? request.signal),
})
},
interceptors: [
diff --git a/web/service/common.spec.ts b/web/service/common.spec.ts
index ef678cb69f9..cfbf09ad331 100644
--- a/web/service/common.spec.ts
+++ b/web/service/common.spec.ts
@@ -1,5 +1,14 @@
+import type { ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook } from '@testing-library/react'
+import { createElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import {
+ OAUTH_REGISTRATION_GA_SENT_KEY,
+ REGISTRATION_SUCCESS_STORAGE_KEY,
+} from '@/app/components/base/amplitude/registration-session-state'
import { emailLoginWithCode, sendEMailLoginCode } from './common'
+import { useLogout } from './use-common'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
@@ -68,3 +77,29 @@ describe('emailLoginWithCode', () => {
})
})
})
+
+describe('useLogout', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ window.sessionStorage.clear()
+ })
+
+ it('discards registration delivery state after a successful logout', async () => {
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(['account-profile'], { id: 'previous-user' })
+ window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, 'pending-marker')
+ window.sessionStorage.setItem(OAUTH_REGISTRATION_GA_SENT_KEY, 'true')
+ mocks.post.mockResolvedValueOnce({ result: 'success' })
+ const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children)
+ const { result } = renderHook(() => useLogout(), { wrapper })
+
+ await act(async () => {
+ await result.current.mutateAsync()
+ })
+
+ expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
+ expect(window.sessionStorage.getItem(OAUTH_REGISTRATION_GA_SENT_KEY)).toBeNull()
+ expect(queryClient.getQueryData(['account-profile'])).toBeUndefined()
+ })
+})
diff --git a/web/service/marketplace-template-discovery.spec.ts b/web/service/marketplace-template-discovery.spec.ts
new file mode 100644
index 00000000000..7a29322e601
--- /dev/null
+++ b/web/service/marketplace-template-discovery.spec.ts
@@ -0,0 +1,141 @@
+import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+
+const mocks = vi.hoisted(() => ({
+ templateCollections: vi.fn(),
+ templateCollectionTemplates: vi.fn(),
+ templateSearch: vi.fn(),
+}))
+
+vi.mock('./client', () => ({
+ marketplaceClient: {
+ templateCollections: (...args: unknown[]) => mocks.templateCollections(...args),
+ templateCollectionTemplates: (...args: unknown[]) => mocks.templateCollectionTemplates(...args),
+ templateSearch: (...args: unknown[]) => mocks.templateSearch(...args),
+ },
+}))
+
+// 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: [
+ { name: 'featured', label: {}, description: {}, priority: 1 },
+ { name: 'partners', label: {}, description: {}, priority: 2 },
+ ],
+ },
+ })
+ mocks.templateCollectionTemplates
+ .mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } })
+ .mockRejectedValueOnce(new Error('Unavailable'))
+
+ const result = await getMarketplaceTemplateCollectionsAndTemplates()
+
+ expect(mocks.templateCollections).toHaveBeenCalledWith({
+ query: { page: 1, page_size: 100 },
+ })
+ expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(1, {
+ params: { collectionName: 'featured' },
+ body: { limit: 24 },
+ })
+ expect(result.templatesByCollection).toEqual({
+ featured: [{ id: 'template-1' }],
+ partners: [],
+ })
+ })
+
+ 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: {}, ok: false })
+
+ 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.ok).toBe(true)
+ 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' }],
+ total: 1,
+ },
+ })
+
+ const result = await searchMarketplaceTemplates({
+ category: 'marketing',
+ page: 2,
+ query: 'campaign',
+ })
+
+ expect(mocks.templateSearch).toHaveBeenCalledWith({
+ body: {
+ page: 2,
+ page_size: 40,
+ query: 'campaign',
+ sort_by: 'usage_count',
+ sort_order: 'DESC',
+ categories: ['marketing'],
+ },
+ })
+ expect(result).toEqual({ ok: true, page: 2, templates: [{ id: 'template-1' }], total: 1 })
+ })
+
+ it('marks a failed template search instead of reporting an empty result', async () => {
+ const { searchMarketplaceTemplates } = await importDiscovery()
+ mocks.templateSearch.mockRejectedValueOnce(new Error('Unavailable'))
+
+ const result = await searchMarketplaceTemplates({
+ category: 'all',
+ query: 'campaign',
+ })
+
+ expect(result).toEqual({ ok: false, page: 1, templates: [], total: 0 })
+ })
+})
diff --git a/web/service/marketplace-template-discovery.ts b/web/service/marketplace-template-discovery.ts
new file mode 100644
index 00000000000..9cbb4f5fcf7
--- /dev/null
+++ b/web/service/marketplace-template-discovery.ts
@@ -0,0 +1,148 @@
+import type {
+ MarketplaceTemplate,
+ MarketplaceTemplateCollection,
+} from '@dify/contracts/marketplace'
+import { marketplaceClient } from './client'
+
+export type MarketplaceTemplateCollectionsResult = {
+ collections: MarketplaceTemplateCollection[]
+ templatesByCollection: Record
+ /**
+ * False when the Marketplace API request failed, so the UI can render an
+ * error state instead of claiming the catalog is empty.
+ */
+ ok: boolean
+}
+
+export const TEMPLATE_SEARCH_PAGE_SIZE = 40
+
+type SearchMarketplaceTemplatesOptions = {
+ category: string
+ languages?: string[]
+ page?: number
+ query: string
+ sortBy?: string
+ sortOrder?: string
+}
+
+const FAILED_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
+ collections: [],
+ templatesByCollection: {},
+ ok: false,
+}
+
+const COLLECTION_PREVIEW_TEMPLATE_LIMIT = 24
+const COLLECTION_FETCH_BATCH_SIZE = 5
+const COLLECTIONS_CACHE_TTL_MS = 5 * 60 * 1000
+
+let collectionsCache: {
+ expiresAt: number
+ result: MarketplaceTemplateCollectionsResult
+} | null = null
+let collectionsInFlight: Promise | null = null
+
+async function fetchCollectionsAndTemplates(): Promise {
+ 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),
+ ok: true,
+ }
+}
+
+/**
+ * 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 {
+ 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(() => FAILED_COLLECTIONS_RESULT)
+ .finally(() => {
+ collectionsInFlight = null
+ })
+
+ return collectionsInFlight
+}
+
+export async function searchMarketplaceTemplates({
+ category,
+ languages,
+ page = 1,
+ query,
+ sortBy = 'usage_count',
+ sortOrder = 'DESC',
+}: SearchMarketplaceTemplatesOptions) {
+ try {
+ const response = await marketplaceClient.templateSearch({
+ body: {
+ page,
+ page_size: TEMPLATE_SEARCH_PAGE_SIZE,
+ query,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ ...(category === 'all' ? {} : { categories: [category] }),
+ ...(languages?.length ? { languages } : {}),
+ },
+ })
+
+ return {
+ ok: true,
+ page,
+ templates: response.data?.templates ?? [],
+ total: response.data?.total ?? 0,
+ }
+ } catch {
+ // Marked as failed so callers can distinguish an API outage from a
+ // genuinely empty search result.
+ return {
+ ok: false,
+ page,
+ templates: [],
+ total: 0,
+ }
+ }
+}
diff --git a/web/service/use-common.ts b/web/service/use-common.ts
index 95564c5fbea..524a38bfd0f 100644
--- a/web/service/use-common.ts
+++ b/web/service/use-common.ts
@@ -17,6 +17,7 @@ import type {
} from '@/models/common'
import type { RETRIEVE_METHOD } from '@/types/app'
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state'
// oxlint-disable-next-line no-restricted-imports
import { get, post } from './base'
import { consoleQuery } from './client'
@@ -162,6 +163,7 @@ export const useLogout = () => {
mutationKey: [NAME_SPACE, 'logout'],
mutationFn: () => post('/logout'),
onSuccess: () => {
+ discardRegistrationSessionState()
// Drop all cached queries so the post-logout /signin probe doesn't read
// the previous user's profile (the userProfile queryKey is shared with
// the (commonLayout) tree, which keeps observing it during React's
diff --git a/web/types/assets.d.ts b/web/types/assets.d.ts
index 6afed58b48d..fbdbcc6e762 100644
--- a/web/types/assets.d.ts
+++ b/web/types/assets.d.ts
@@ -24,3 +24,8 @@ declare module '*.gif' {
const value: any
export default value
}
+
+declare module '*.webp' {
+ const value: any
+ export default value
+}
diff --git a/web/utils/__tests__/marketplace-site-track.spec.ts b/web/utils/__tests__/marketplace-site-track.spec.ts
new file mode 100644
index 00000000000..3ed02ccebff
--- /dev/null
+++ b/web/utils/__tests__/marketplace-site-track.spec.ts
@@ -0,0 +1,66 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ markMarketplaceSiteFilter,
+ markMarketplaceSiteSearch,
+ trackMarketplaceSiteCardClick,
+ trackMarketplaceSiteEvent,
+} from '../marketplace-site-track'
+
+describe('marketplace site track bridge', () => {
+ afterEach(() => {
+ document.body.removeAttribute('data-is-marketplace')
+ delete window.__marketplaceTracking__
+ })
+
+ it('does not forward events outside the standalone marketplace', () => {
+ const track = vi.fn()
+ window.__marketplaceTracking__ = { track } as never
+
+ trackMarketplaceSiteEvent('marketplace_card_click', { click_target: 'card' })
+
+ expect(track).not.toHaveBeenCalled()
+ })
+
+ it('forwards events and card clicks on the standalone marketplace', () => {
+ const track = vi.fn()
+ const rememberReferrer = vi.fn()
+ document.body.setAttribute('data-is-marketplace', '')
+ window.__marketplaceTracking__ = {
+ track,
+ rememberReferrer,
+ markSearch: vi.fn(),
+ flushSearch: vi.fn(),
+ markFilter: vi.fn(),
+ flushFilter: vi.fn(),
+ }
+
+ trackMarketplaceSiteEvent('marketplace_creator_partner_click', {
+ click_target: 'creator_center',
+ })
+ trackMarketplaceSiteCardClick({
+ itemId: 'org/name',
+ itemType: 'plugin',
+ section: 'partners',
+ })
+ markMarketplaceSiteSearch('openai')
+ markMarketplaceSiteFilter({
+ filter_type: 'type_tab',
+ selection_mode: 'single',
+ filter_value: 'tool',
+ selected_values: ['tool'],
+ })
+
+ expect(track).toHaveBeenNthCalledWith(1, 'marketplace_creator_partner_click', {
+ click_target: 'creator_center',
+ })
+ expect(rememberReferrer).toHaveBeenCalledWith('org/name', 'list')
+ expect(track).toHaveBeenNthCalledWith(2, 'marketplace_card_click', {
+ click_target: 'card',
+ item_id: 'org/name',
+ item_type: 'plugin',
+ section: 'partners',
+ })
+ })
+})
diff --git a/web/utils/marketplace-site-track.ts b/web/utils/marketplace-site-track.ts
new file mode 100644
index 00000000000..f5bbdfe8e0a
--- /dev/null
+++ b/web/utils/marketplace-site-track.ts
@@ -0,0 +1,74 @@
+type MarketplaceSiteReferrerSection = 'banner' | 'search' | 'list' | 'direct'
+
+type MarketplaceSiteFilter = {
+ filter_type: 'type_tab' | 'category' | 'language'
+ selection_mode: 'single' | 'multi'
+ filter_value: string
+ selected_values: string[]
+}
+
+const isMarketplaceSite = () =>
+ typeof globalThis.document !== 'undefined' &&
+ globalThis.document.body?.hasAttribute('data-is-marketplace')
+
+const marketplaceTracking = () => globalThis.window.__marketplaceTracking__
+
+export const trackMarketplaceSiteEvent = (
+ eventName: string,
+ properties?: Record,
+) => {
+ if (!isMarketplaceSite()) return
+
+ marketplaceTracking()?.track(eventName, properties)
+}
+
+export const rememberMarketplaceSiteReferrer = (
+ itemId: string,
+ section: MarketplaceSiteReferrerSection,
+) => {
+ if (!isMarketplaceSite()) return
+
+ marketplaceTracking()?.rememberReferrer(itemId, section)
+}
+
+export const markMarketplaceSiteSearch = (query: string) => {
+ if (!isMarketplaceSite()) return
+
+ marketplaceTracking()?.markSearch(query)
+}
+
+export const flushMarketplaceSiteSearch = (resultCount: number) => {
+ if (!isMarketplaceSite()) return
+
+ marketplaceTracking()?.flushSearch(resultCount)
+}
+
+export const markMarketplaceSiteFilter = (filter: MarketplaceSiteFilter) => {
+ if (!isMarketplaceSite()) return
+
+ marketplaceTracking()?.markFilter(filter)
+}
+
+export const flushMarketplaceSiteFilter = (resultCount: number) => {
+ if (!isMarketplaceSite()) return
+
+ marketplaceTracking()?.flushFilter(resultCount)
+}
+
+export const trackMarketplaceSiteCardClick = ({
+ itemId,
+ itemType,
+ section,
+}: {
+ itemId: string
+ itemType: 'plugin' | 'template'
+ section: string
+}) => {
+ rememberMarketplaceSiteReferrer(itemId, section === 'search' ? 'search' : 'list')
+ trackMarketplaceSiteEvent('marketplace_card_click', {
+ click_target: 'card',
+ item_id: itemId,
+ item_type: itemType,
+ section,
+ })
+}
diff --git a/web/utils/var.spec.ts b/web/utils/var.spec.ts
index c871eea6762..67bc8334cde 100644
--- a/web/utils/var.spec.ts
+++ b/web/utils/var.spec.ts
@@ -219,6 +219,18 @@ describe('Variable Utilities', () => {
expect(url).not.toContain('source=https%253A%252F%252Fexample.com')
})
+ it('should let params replace the default source without duplicating it', () => {
+ const url = getMarketplaceUrl(
+ '/plugins',
+ { source: 'http://localhost:3001', language: 'en-US' },
+ { source: 'http://localhost:3000' },
+ )
+ const searchParams = new URL(url, 'https://marketplace.dify.ai').searchParams
+
+ expect(searchParams.getAll('source')).toEqual(['http://localhost:3001'])
+ expect(searchParams.get('language')).toBe('en-US')
+ })
+
it('should not access window during server render', () => {
const originalWindow = window
vi.stubGlobal('window', undefined)
diff --git a/web/utils/var.ts b/web/utils/var.ts
index 0a6a1a586b1..acccd7211ce 100644
--- a/web/utils/var.ts
+++ b/web/utils/var.ts
@@ -171,7 +171,7 @@ export function getMarketplaceUrl(
if (params) {
Object.keys(params).forEach((key) => {
const value = params[key]
- if (value !== undefined && value !== null) searchParams.append(key, value)
+ if (value !== undefined && value !== null) searchParams.set(key, value)
})
}