diff --git a/knip.config.ts b/knip.config.ts index 2914f398f8c..3931cfd7a2f 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -15,6 +15,10 @@ const config: KnipConfig = { 'tsslint.config.ts', 'dev-proxy.config.ts', 'plugins/eslint/index.js', + // Public surface consumed by the standalone Marketplace host. + // The `!` suffix keeps these entries in `knip --production`. + 'app/components/plugins/marketplace/standalone/server.ts!', + 'app/components/plugins/marketplace/standalone/client.ts!', ], project: [ '**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,css,mdx}!', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 47aa90acc9a..3c3e2bcf88e 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1131,7 +1131,7 @@ }, "web/app/components/base/icons/src/vender/plugin/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 3 + "count": 2 } }, "web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts": { @@ -2565,23 +2565,15 @@ }, "web/app/components/plugins/marketplace/hooks.ts": { "@tanstack/query/prefer-query-options": { - "count": 4 + "count": 3 }, "no-restricted-imports": { "count": 1 } }, - "web/app/components/plugins/marketplace/list/list-with-collection.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/plugins/marketplace/query.ts": { "@tanstack/query/prefer-query-options": { - "count": 2 + "count": 1 } }, "web/app/components/plugins/plugin-auth/authorized/index.tsx": { diff --git a/packages/contracts/marketplace.ts b/packages/contracts/marketplace.ts index 459f3d778e6..06353129a8f 100644 --- a/packages/contracts/marketplace.ts +++ b/packages/contracts/marketplace.ts @@ -22,6 +22,10 @@ export type MarketplaceCollection = { search_params?: SearchParamsFromCollection } +export type MarketplaceTimestamp = string | number +export type MarketplaceCreatorStatus = 'pending' | 'active' | 'inactive' | 'deleted' +export type MarketplaceOrganizationStatus = 'active' | 'inactive' | 'deleted' + export type PluginsSearchParams = { query: string page?: number @@ -44,6 +48,7 @@ export type CollectionsAndPluginsSearchParams = { condition?: string exclude?: string[] type?: 'plugin' | 'bundle' + limit?: number } export type MarketplaceTemplate = { @@ -53,9 +58,65 @@ export type MarketplaceTemplate = { icon: string icon_background: string icon_file_key: string - publisher_unique_handle: string + publisher_unique_handle?: string + publisher_handle?: string + publisher_type?: string + creator_email?: string usage_count: number categories: string[] + deps_plugins?: string[] + preferred_languages?: string[] + badges?: string[] + created_at?: MarketplaceTimestamp + updated_at?: MarketplaceTimestamp +} + +export type MarketplaceCreator = { + id?: string + email?: string + name?: string + display_name?: string + unique_handle: string + display_email?: string + description?: string + avatar?: string + background_image?: string + social_links?: string[] + badges?: string[] + verified?: boolean + status?: MarketplaceCreatorStatus + public?: boolean + plugin_count?: number + template_count?: number + created_at?: string + updated_at?: string +} + +export type MarketplaceOrganization = { + id?: string + email?: string + name?: string + display_name?: string + unique_handle?: string + display_email?: string + description?: string + avatar?: string + background_image?: string + social_links?: string[] + badges?: string[] + verified?: boolean + status?: MarketplaceOrganizationStatus + created_at?: string + updated_at?: string +} + +export type MarketplaceTemplateCollection = { + name: string + description: Record + label: Record + searchable?: boolean + search_params?: SearchParamsFromCollection + priority: number } export type MarketplacePluginCategory = @@ -109,6 +170,9 @@ export type MarketplacePlugin = { authorized_category: 'langgenius' | 'partner' | 'community' } from: MarketplacePluginDependencySource + created_at?: MarketplaceTimestamp + updated_at?: MarketplaceTimestamp + version_updated_at?: MarketplaceTimestamp | null } export type PluginInfoFromMarketPlace = { @@ -154,8 +218,151 @@ export type TemplateDetailResponse = { data: MarketplaceTemplate } +export type TemplateCollectionsResponse = { + data?: { + collections?: MarketplaceTemplateCollection[] + total?: number + } +} + +export type TemplateCollectionTemplatesResponse = { + data?: { + templates?: MarketplaceTemplate[] + total?: number + } +} + +export type TemplateSearchResponse = { + data?: { + templates?: MarketplaceTemplate[] + total?: number + } +} + export type DownloadPluginResponse = Blob +export type CreatorDetailResponse = { + code?: number + data?: { + creator?: MarketplaceCreator + } + msg?: string +} + +export type OrganizationDetailResponse = { + code?: number + data?: { + organization?: MarketplaceOrganization + } + msg?: string +} + +export type PublisherPluginsResponse = { + code?: number + data?: { + plugins?: MarketplacePlugin[] + total?: number + } + msg?: string +} + +export type PublisherTemplatesResponse = { + code?: number + data?: { + templates?: MarketplaceTemplate[] + total?: number + } + msg?: string +} + +// Banner payload shapes shared by the standalone marketplace and the embedded +// console. The banners endpoint output stays `unknown` in the contract because +// the delivery format is normalized and runtime-validated in +// `web/app/components/plugins/marketplace/home/banners.ts`. +export type BannerBase = { + id: string + title: string + sort: number + language: string +} + +export type BannerRecommendCard = { + item_type: 'plugin' | 'template' + item_id: string + display_name: string + icon_url?: string + icon?: string + icon_background?: string + creator?: string + badges?: Array<'partner' | 'verified'> + link: string + card_position: number + auto_batch_id?: string | null +} + +export type BannerRecommend = BannerBase & { + style_type: 'recommend' + content: { + theme_type: 'newest' | 'hottest' | 'partner' + heading?: string + subheadings?: string[] + description?: string + cards: BannerRecommendCard[] + } +} + +export type BannerBlog = BannerBase & { + style_type: 'blog' + content: { + blog_title: string + subtitle?: string + description?: string + link: string + link_target_type: 'blog' | 'github' + } +} + +export type BannerImageContent = { + images: { + desktop: string + tablet?: string + mobile?: string + } + link: string + alt_text?: string + activity_id?: string +} + +export type BannerEvent = BannerBase & { + style_type: 'event' + content: BannerImageContent +} + +export type BannerAd = BannerBase & { + style_type: 'ad' + content: BannerImageContent & { + partner_id?: string + campaign_id?: string + } +} + +export type PluginBanner = BannerRecommend | BannerBlog | BannerEvent | BannerAd + +const bannerListContract = base + .route({ + path: '/banners', + method: 'GET', + }) + .input( + type<{ + query: { + page: 'plugins' | 'templates' + language: string + } + }>(), + ) + .output(type()) + const collectionsContract = base .route({ path: '/collections', @@ -212,6 +419,58 @@ const templateDetailContract = base ) .output(type()) +const templateCollectionsContract = base + .route({ + path: '/template-collections', + method: 'GET', + }) + .input( + type<{ + query?: { + page?: number + page_size?: number + } + }>(), + ) + .output(type()) + +const templateCollectionTemplatesContract = base + .route({ + path: '/template-collections/{collectionName}/templates', + method: 'POST', + }) + .input( + type<{ + params: { + collectionName: string + } + body?: { + limit?: number + } + }>(), + ) + .output(type()) + +const templateSearchContract = base + .route({ + path: '/templates/search/advanced', + method: 'POST', + }) + .input( + type<{ + body: { + page: number + page_size: number + query: string + sort_by: string + sort_order: string + categories?: string[] + languages?: string[] + } + }>(), + ) + .output(type()) + const downloadPluginContract = base .route({ path: '/plugins/{organization}/{pluginName}/{version}/download', @@ -228,12 +487,90 @@ const downloadPluginContract = base ) .output(type()) +const creatorDetailContract = base + .route({ + path: '/creators/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + }>(), + ) + .output(type()) + +const organizationDetailContract = base + .route({ + path: '/organizations/{id}', + method: 'GET', + }) + .input( + type<{ + params: { + id: string + } + }>(), + ) + .output(type()) + +const publisherPluginsContract = base + .route({ + path: '/plugins/publisher/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + query: { + page: number + page_size: number + sort_by?: string + sort_order?: string + } + }>(), + ) + .output(type()) + +const publisherTemplatesContract = base + .route({ + path: '/templates/publisher/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + query: { + page: number + page_size: number + sort_by?: string + sort_order?: string + } + }>(), + ) + .output(type()) + export const marketplaceRouterContract = { + banners: { + list: bannerListContract, + }, collections: collectionsContract, collectionPlugins: collectionPluginsContract, searchAdvanced: searchAdvancedContract, + templateCollections: templateCollectionsContract, + templateCollectionTemplates: templateCollectionTemplatesContract, templateDetail: templateDetailContract, + templateSearch: templateSearchContract, downloadPlugin: downloadPluginContract, + creatorDetail: creatorDetailContract, + organizationDetail: organizationDetailContract, + publisherPlugins: publisherPluginsContract, + publisherTemplates: publisherTemplatesContract, } export type MarketPlaceInputs = InferContractRouterInputs diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index f985644c633..bd454010a1b 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -363,7 +363,9 @@ export const FormDialog: Story = { await userEvent.click(canvas.getByRole('button', { name: 'Configure API extension' })) - await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus() + await waitFor(async () => { + await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus() + }) }, } diff --git a/packages/iconify-collections/assets/public/common/gmail.svg b/packages/iconify-collections/assets/public/common/gmail.svg new file mode 100644 index 00000000000..1e5afcbf624 --- /dev/null +++ b/packages/iconify-collections/assets/public/common/gmail.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index 510de02889d..2e6fbd864c8 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-public", - "lastModified": 1785332090, + "lastModified": 1786856617, "icons": { "agent-building-blocks": { "body": "" @@ -71,7 +71,8 @@ "height": 24 }, "common-d": { - "body": "" + "body": "", + "height": 16 }, "common-diagonal-dividing-line": { "body": "", @@ -89,7 +90,8 @@ "height": 24 }, "common-enter-key": { - "body": "" + "body": "", + "height": 16 }, "common-firecrawl": { "body": "", @@ -106,6 +108,11 @@ "width": 18, "height": 18 }, + "common-gmail": { + "body": "", + "width": 24, + "height": 24 + }, "common-google-drive": { "body": "", "width": 24, @@ -127,10 +134,12 @@ "height": 12 }, "common-lock": { - "body": "" + "body": "", + "height": 16 }, "common-message-chat-square": { - "body": "" + "body": "", + "height": 16 }, "common-multi-path-retrieval": { "body": "", @@ -158,7 +167,8 @@ "height": 14 }, "common-sparkles-soft-accent": { - "body": "" + "body": "", + "height": 16 }, "education-triangle": { "body": "", diff --git a/packages/iconify-collections/custom-public/info.json b/packages/iconify-collections/custom-public/info.json index d283dc036f5..09bd7c5e800 100644 --- a/packages/iconify-collections/custom-public/info.json +++ b/packages/iconify-collections/custom-public/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-public", "name": "Dify Custom Public", - "total": 150, + "total": 151, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index 4daa46430b9..d92c4e2fd1e 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -43,24 +43,6 @@ "body": "", "width": 17 }, - "app-publisher-deploying-chevron": { - "body": "", - "width": 8.27613, - "height": 5.08087 - }, - "deploy-code-block": { - "body": "" - }, - "deploy-line-5": { - "body": "", - "width": 12, - "height": 39 - }, - "deploy-rocket": { - "body": "", - "width": 14, - "height": 14 - }, "features-citations": { "body": "", "width": 24, @@ -1610,6 +1592,24 @@ "body": "", "width": 16, "height": 16 + }, + "app-publisher-deploying-chevron": { + "body": "", + "width": 8.27613, + "height": 5.08087 + }, + "deploy-code-block": { + "body": "" + }, + "deploy-line-5": { + "body": "", + "width": 12, + "height": 39 + }, + "deploy-rocket": { + "body": "", + "width": 14, + "height": 14 } } } diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index 097f2c15865..6c61adcc7c4 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 346, + "total": 350, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/__tests__/proxy-frame-options.spec.ts b/web/__tests__/proxy-frame-options.spec.ts index 54385b10f13..315898f77da 100644 --- a/web/__tests__/proxy-frame-options.spec.ts +++ b/web/__tests__/proxy-frame-options.spec.ts @@ -4,6 +4,7 @@ import { canEmbedPath, proxy } from '@/proxy' const mockEnv = vi.hoisted(() => ({ NEXT_PUBLIC_ALLOW_EMBED: false, NEXT_PUBLIC_CSP_WHITELIST: 'https://example.com', + NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: '', NEXT_PUBLIC_TURNSTILE_SITE_KEY: '', })) @@ -24,6 +25,7 @@ const createRequest = (url: string) => { describe('proxy frame options', () => { afterEach(() => { mockEnv.NEXT_PUBLIC_ALLOW_EMBED = false + mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = '' mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = '' vi.unstubAllEnvs() }) @@ -86,6 +88,36 @@ describe('proxy frame options', () => { expect(response.headers.get('x-frame-options')).toBe('DENY') expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") }) + + it('should deny framing for the Marketplace OAuth authorize route', () => { + const response = proxy( + createRequest('https://cloud.dify.ai/account/oauth/authorize?client_id=marketplace-client'), + ) + + expect(response.headers.get('x-frame-options')).toBe('DENY') + expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") + }) + + it('should allow framing Marketplace pages when a Marketplace origin is configured', () => { + vi.stubEnv('NODE_ENV', 'production') + mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = 'https://marketplace.dify.ai' + + const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + + expect(response.headers.get('content-security-policy') ?? '').toMatch( + /frame-src[^;]*https:\/\/marketplace\.dify\.ai/, + ) + }) + + it('should not add a Marketplace frame origin when the prefix is unset', () => { + vi.stubEnv('NODE_ENV', 'production') + + const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + const contentSecurityPolicy = response.headers.get('content-security-policy') ?? '' + + expect(contentSecurityPolicy).toContain('frame-src') + expect(contentSecurityPolicy).not.toContain('https://marketplace.dify.ai') + }) }) describe('proxy CookieYes consent logging', () => { diff --git a/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx b/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx new file mode 100644 index 00000000000..f89e3633885 --- /dev/null +++ b/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx @@ -0,0 +1,33 @@ +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' + +vi.mock('../document-title', () => ({ + default: () => marketplace document title, +})) + +describe('marketplace route layout', () => { + it('stays a server module so Flight can stream the marketplace page', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../layout.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/^['"]use client['"]/) + }) + + it('renders marketplace children and the document title island', async () => { + const { default: MarketplaceLayout } = await import('../layout') + + render( + +

marketplace page

+
, + ) + + expect(screen.getByText('marketplace document title')).toBeInTheDocument() + expect(screen.getByText('marketplace page')).toBeInTheDocument() + }) +}) diff --git a/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx new file mode 100644 index 00000000000..8f2d5e1526d --- /dev/null +++ b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' +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' + +vi.mock('@/app/components/plugins/marketplace/marketplace-install-permission-provider', () => ({ + default: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/plugins/marketplace/embedded', () => ({ + EmbeddedMarketplace: () =>

Embedded marketplace home

, +})) + +describe('embedded marketplace home route', () => { + it('does not stream async server children that Flight would double-resolve', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/const MarketplacePage = async/) + expect(source).not.toContain('HydrateQueryClient') + expect(source).not.toContain('AccountSection') + expect(source).not.toContain('homeHeaderActions') + }) + + it('renders the client marketplace home inside the install-permission provider', async () => { + const { default: MarketplacePage } = await import('../page') + render() + + const permission = screen.getByRole('region', { name: 'install permission' }) + + expect(permission).toContainElement(screen.getByText('Embedded marketplace home')) + }) +}) diff --git a/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx new file mode 100644 index 00000000000..1c9c5615aa6 --- /dev/null +++ b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx @@ -0,0 +1,51 @@ +import { loadCreatorProfile } from '@/app/components/plugins/marketplace/creator-profile/data.server' +import DifyCreatorProfile from '@/app/components/plugins/marketplace/creator-profile/dify-profile' +import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' +import { getLocaleOnServer } from '@/i18n-config/server' +import { notFound } from '@/next/navigation' + +type CreatorPageSearchParams = { + publisher_type?: string + sort_by?: string + sort_order?: string +} + +type CreatorProfilePageProps = { + params: Promise<{ uniqueHandle: string }> + searchParams: Promise +} + +// Sync route: async pages under this client shell Flight-double-resolve. +export default function CreatorProfilePage(props: CreatorProfilePageProps) { + return ( +
+ +
+ ) +} + +async function CreatorProfileContent({ params, searchParams }: CreatorProfilePageProps) { + const [{ uniqueHandle }, query, locale] = await Promise.all([ + params, + searchParams, + getLocaleOnServer(), + ]) + const loadedProfile = await loadCreatorProfile({ + uniqueHandle, + publisherType: query.publisher_type, + locale, + sortBy: query.sort_by, + sortOrder: query.sort_order, + }) + + if (!loadedProfile) notFound() + + return ( + + + + ) +} diff --git a/web/app/(commonLayout)/marketplace/document-title.tsx b/web/app/(commonLayout)/marketplace/document-title.tsx new file mode 100644 index 00000000000..d053ab9cb3c --- /dev/null +++ b/web/app/(commonLayout)/marketplace/document-title.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import useDocumentTitle from '@/hooks/use-document-title' + +const MarketplaceDocumentTitle = () => { + const { t } = useTranslation() + useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' })) + return null +} + +export default MarketplaceDocumentTitle diff --git a/web/app/(commonLayout)/marketplace/layout.tsx b/web/app/(commonLayout)/marketplace/layout.tsx index 5a40a6a3a62..7bf5569a955 100644 --- a/web/app/(commonLayout)/marketplace/layout.tsx +++ b/web/app/(commonLayout)/marketplace/layout.tsx @@ -1,12 +1,12 @@ -'use client' - import type { PropsWithChildren } from 'react' -import { useTranslation } from 'react-i18next' -import useDocumentTitle from '@/hooks/use-document-title' +import MarketplaceDocumentTitle from './document-title' +// Server layout: a client layout here Flight-double-resolves the page. export default function MarketplaceLayout({ children }: PropsWithChildren) { - const { t } = useTranslation() - useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' })) - - return children + return ( + <> + + {children} + + ) } diff --git a/web/app/(commonLayout)/marketplace/page.tsx b/web/app/(commonLayout)/marketplace/page.tsx index 9f56a38d4a9..a6594a8048c 100644 --- a/web/app/(commonLayout)/marketplace/page.tsx +++ b/web/app/(commonLayout)/marketplace/page.tsx @@ -1,19 +1,16 @@ -import type { SearchParams } from 'nuqs' -import Marketplace from '@/app/components/plugins/marketplace' +import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants' +import { EmbeddedMarketplace } from '@/app/components/plugins/marketplace/embedded' import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' -type MarketplacePageProps = { - searchParams?: Promise -} - -const MarketplacePage = ({ searchParams }: MarketplacePageProps) => { +// Sync route: async pages under this client shell Flight-double-resolve. +const MarketplacePage = () => { return (
- +
) diff --git a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx new file mode 100644 index 00000000000..6c86517898b --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx @@ -0,0 +1,151 @@ +import type { FunctionComponent, ReactElement } from 'react' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { redirect } from '@/next/navigation' +import TemplatesPage from '../page' + +type TemplatesPageProps = Parameters[0] + +const resolveTemplatesPage = async (props: TemplatesPageProps) => { + const tree = TemplatesPage(props) as ReactElement<{ + children: ReactElement + className: string + id: string + }> + const child = tree.props.children + const content = await (child.type as FunctionComponent)(child.props) + return createElement(tree.type, tree.props, content) +} + +vi.mock('@/app/components/plugins/marketplace/templates', () => ({ + EmbeddedTemplatesMarketplace: ({ + category, + page, + query, + sortBy, + sortOrder, + view, + }: { + category: string + page: number + query: string + sortBy?: string + sortOrder?: string + view?: string + }) => ( +
+ {`Templates catalog: ${category}:${query}`} +
+ ), +})) + +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: () => Promise.resolve('en-US'), +})) + +vi.mock('@/next/navigation', () => ({ + redirect: vi.fn((path: string) => { + throw new Error(`redirect:${path}`) + }), +})) + +describe('embedded templates route', () => { + it('does not stream async server children that Flight would double-resolve', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/export default async function TemplatesPage/) + expect(TemplatesPage.constructor.name).not.toBe('AsyncFunction') + }) + + it('renders the templates catalog at /templates', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ q: 'agent' }), + }) + + render(page) + + expect(screen.getByText('Templates catalog: all:agent')).toBeInTheDocument() + expect(screen.getByText('Templates catalog: all:agent').parentElement).toHaveAttribute( + 'id', + 'marketplace-container', + ) + }) + + it('passes a supported path category to the templates catalog', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({ category: ['marketing'] }), + searchParams: Promise.resolve({}), + }) + + render(page) + + expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument() + }) + + it('validates page, view and sort params at the route boundary', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ + page: '3', + q: 'agent', + sort_by: 'created_at', + sort_order: 'ASC', + view: 'search', + }), + }) + + render(page) + + const catalog = screen.getByTestId('catalog') + expect(catalog).toHaveAttribute('data-page', '3') + expect(catalog).toHaveAttribute('data-sort-by', 'created_at') + expect(catalog).toHaveAttribute('data-sort-order', 'ASC') + expect(catalog).toHaveAttribute('data-view', 'search') + }) + + it('falls back to defaults for unsupported page, view and sort params', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ + page: '-2', + q: 'agent', + sort_by: 'garbage', + sort_order: 'sideways', + view: 'iframe', + }), + }) + + render(page) + + const catalog = screen.getByTestId('catalog') + expect(catalog).toHaveAttribute('data-page', '1') + expect(catalog).not.toHaveAttribute('data-sort-by') + expect(catalog).not.toHaveAttribute('data-sort-order') + expect(catalog).not.toHaveAttribute('data-view') + }) + + it('opens template recommendations in the existing Dify import flow', async () => { + await expect( + resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ tid: 'template/one' }), + }), + ).rejects.toThrow('redirect:/apps?template-id=template%2Fone') + + expect(redirect).toHaveBeenCalledWith('/apps?template-id=template%2Fone') + }) +}) diff --git a/web/app/(commonLayout)/templates/[[...category]]/page.tsx b/web/app/(commonLayout)/templates/[[...category]]/page.tsx new file mode 100644 index 00000000000..4d31ad4a2e0 --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/page.tsx @@ -0,0 +1,78 @@ +import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants' +import { EmbeddedTemplatesMarketplace } from '@/app/components/plugins/marketplace/templates' +import { isTemplateCategory } from '@/app/components/plugins/marketplace/templates/categories' +import { getLocaleOnServer } from '@/i18n-config/server' +import { redirect } from '@/next/navigation' + +type TemplatesPageProps = { + params: Promise<{ category?: string[] }> + searchParams: Promise<{ + languages?: string | string[] + page?: string + q?: string + sort_by?: string + sort_order?: string + tid?: string + view?: string + }> +} + +// These values arrive from a public URL, so validate them against the +// supported enums here at the route boundary. Unknown values fall back to the +// defaults instead of reaching the Marketplace API, where e.g. +// `sort_order=garbage` fails and would surface as a false "no templates" state. +const TEMPLATE_SORT_FIELDS = new Set(['usage_count', 'created_at']) +const TEMPLATE_SORT_ORDERS = new Set(['ASC', 'DESC']) + +const parseView = (value?: string) => (value === 'search' ? 'search' : undefined) + +const parseSortBy = (value?: string) => + value && TEMPLATE_SORT_FIELDS.has(value) ? value : undefined + +const parseSortOrder = (value?: string) => + value && TEMPLATE_SORT_ORDERS.has(value) ? value : undefined + +const parsePage = (value?: string) => { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1 +} + +// Sync route: async pages under this client shell Flight-double-resolve. +export default function TemplatesPage(props: TemplatesPageProps) { + return ( +
+ +
+ ) +} + +async function TemplatesPageContent({ params, searchParams }: TemplatesPageProps) { + const [resolvedParams, resolvedSearchParams, locale] = await Promise.all([ + params, + searchParams, + getLocaleOnServer(), + ]) + + if (resolvedSearchParams.tid) { + redirect(`/apps?template-id=${encodeURIComponent(resolvedSearchParams.tid)}`) + } + + const requestedCategory = resolvedParams.category?.[0] + const category = isTemplateCategory(requestedCategory) ? requestedCategory : 'all' + + return ( + + ) +} diff --git a/web/app/account/oauth/authorize/__tests__/page.spec.tsx b/web/app/account/oauth/authorize/__tests__/page.spec.tsx index a06d4499e46..31bba9a90e8 100644 --- a/web/app/account/oauth/authorize/__tests__/page.spec.tsx +++ b/web/app/account/oauth/authorize/__tests__/page.spec.tsx @@ -127,6 +127,24 @@ describe('OAuthAuthorize', () => { ) }) + it('preserves an encoded redirect URI when requesting the OAuth app', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?next=%2Fplugins', + state: 'state-1', + }) + + renderPage() + + expect((await screen.findAllByText('Test OAuth App')).length).toBeGreaterThan(0) + const providerRequest = findRequest('/oauth/provider') + const providerTransportRequest = providerRequest?.[2]?.request as Request + await expect(providerTransportRequest.clone().json()).resolves.toEqual({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?next=%2Fplugins', + }) + }) + it('silently authorizes an app flagged with auto_authorize without rendering consent', async () => { mocks.searchParams = new URLSearchParams({ client_id: 'marketplace-client', @@ -177,6 +195,56 @@ describe('OAuthAuthorize', () => { expect(findRequest('/oauth/provider/authorize')).toBeUndefined() }) + it('does not auto-authorize with incomplete OAuth parameters', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'marketplace-client', + }) + mockProviderResponses({ autoAuthorize: true }) + + renderPage() + + expect(await screen.findByText('oauth.error.invalidParams')).toBeInTheDocument() + expect(findRequest('/oauth/provider')).toBeUndefined() + expect(findRequest('/oauth/provider/authorize')).toBeUndefined() + }) + + it('retries app info loading and resumes auto-authorization', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'marketplace-client', + redirect_uri: 'https://api.marketplace.example.com/api/v1/auth/callback/dify', + response_type: 'code', + state: 'marketplace-state', + }) + let providerAttempts = 0 + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider/authorize')) return jsonResponse({ code: 'oauth-code' }) + if (url.endsWith('/oauth/provider')) { + providerAttempts += 1 + if (providerAttempts === 1) throw new Error('Failed to load OAuth app') + return jsonResponse({ + app_icon: '', + app_label: { en_US: 'Test OAuth App' }, + auto_authorize: true, + scope: '', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + const user = userEvent.setup() + renderPage() + + expect(await screen.findByText('oauth.error.authAppInfoFetchFailed')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + await waitFor(() => expect(findRequest('/oauth/provider/authorize')).toBeDefined()) + await waitFor(() => + expect(globalThis.location.href).toBe( + 'https://api.marketplace.example.com/api/v1/auth/callback/dify?code=oauth-code&state=marketplace-state', + ), + ) + }) + it('falls back to manual confirmation when silent authorization fails', async () => { mocks.searchParams = new URLSearchParams({ client_id: 'marketplace-client', @@ -215,4 +283,38 @@ describe('OAuthAuthorize', () => { ), ) }) + + it('renders an unknown OAuth scope without crashing', async () => { + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider')) { + return jsonResponse({ + app_icon: '', + app_label: { en_US: 'Test OAuth App' }, + scope: 'read:custom_profile', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + renderPage() + + expect(await screen.findByText('read:custom_profile')).toBeInTheDocument() + }) + + it('supports OAuth app labels that use a hyphenated locale key', async () => { + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider')) { + return jsonResponse({ + app_icon: '', + app_label: { 'en-US': 'Hyphenated OAuth App' }, + scope: '', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + renderPage() + + expect((await screen.findAllByText('Hyphenated OAuth App')).length).toBeGreaterThan(0) + }) }) diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 28d54aa82f0..a8924817e71 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -13,7 +13,6 @@ import { } from '@remixicon/react' import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import * as React from 'react' -import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -57,10 +56,10 @@ export default function OAuthAuthorize() { const router = useRouter() const language = useLanguage() const searchParams = useSearchParams() - const client_id = decodeURIComponent(searchParams.get('client_id') || '') - const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '') + const clientId = searchParams.get('client_id') || '' + const redirectUri = searchParams.get('redirect_uri') || '' const state = searchParams.get('state') - const hasOAuthParams = Boolean(client_id && redirect_uri) + const hasOAuthParams = Boolean(clientId && redirectUri) // Probe user profile. 401 stays as `error` (legitimate "not logged in" state), // other errors throw to the nearest error.tsx; jumpTo same-pathname guard in // service/base.ts prevents a redirect loop here. @@ -77,10 +76,14 @@ export default function OAuthAuthorize() { const { data: authAppInfo, isLoading: isOAuthLoading, - isError, + isFetching: isOAuthFetching, + isError: isOAuthError, + refetch: refetchOAuthApp, } = useQuery( consoleQuery.oauth.provider.post.queryOptions({ - input: hasOAuthParams ? { body: { client_id, redirect_uri } } : skipToken, + input: hasOAuthParams + ? { body: { client_id: clientId, redirect_uri: redirectUri } } + : skipToken, context: { silent: true }, }), ) @@ -91,17 +94,17 @@ export default function OAuthAuthorize() { const { isAutoAuthorizing } = useSilentAuthorize({ authAppInfo, authorize, - clientId: client_id, + clientId, hasOAuthParams, isLoggedIn, isProfileLoading, - redirectUri: redirect_uri, + redirectUri, searchParams, state, }) - const hasNotifiedRef = useRef(false) - const localizedAppLabel = authAppInfo?.app_label[language] - const englishAppLabel = authAppInfo?.app_label.en_US + const localizedAppLabel = + authAppInfo?.app_label[language] ?? authAppInfo?.app_label[language.replace('_', '-')] + const englishAppLabel = authAppInfo?.app_label.en_US ?? authAppInfo?.app_label['en-US'] const appLabel = (typeof localizedAppLabel === 'string' && localizedAppLabel) || (typeof englishAppLabel === 'string' && englishAppLabel) || @@ -112,7 +115,6 @@ export default function OAuthAuthorize() { : t(($) => $.connect, { ns: 'oauth' }), ) - const isLoading = isOAuthLoading || isProfileLoading const onLoginSwitchClick = async () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`) @@ -124,30 +126,39 @@ export default function OAuthAuthorize() { } const onAuthorize = async () => { - if (!client_id || !redirect_uri) return + if (!clientId || !redirectUri) return try { - const { code } = await authorize({ body: { client_id } }) - globalThis.location.href = buildOAuthCallbackUrl(redirect_uri, code, state) + const { code } = await authorize({ body: { client_id: clientId } }) + globalThis.location.href = buildOAuthCallbackUrl(redirectUri, code, state) } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error) toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${message}`) } } - useEffect(() => { - const invalidParams = !client_id || !redirect_uri - if ((invalidParams || isError) && !hasNotifiedRef.current) { - hasNotifiedRef.current = true - toast.error( - invalidParams - ? t(($) => $['error.invalidParams'], { ns: 'oauth' }) - : t(($) => $['error.authAppInfoFetchFailed'], { ns: 'oauth' }), - { timeout: 0 }, - ) - } - }, [client_id, redirect_uri, isError]) + if (!hasOAuthParams || isOAuthError) { + return ( +
+
+ {t(($) => $[hasOAuthParams ? 'error.authAppInfoFetchFailed' : 'error.invalidParams'], { + ns: 'oauth', + })} +
+ {isOAuthError && ( + + )} +
+ ) + } - if (isLoading || isAutoAuthorizing) { + if (isProfileLoading || isOAuthLoading || isAutoAuthorizing) { return (
@@ -203,18 +214,15 @@ export default function OAuthAuthorize() { .split(/\s+/) .filter(Boolean) .map((scope: string) => { - const Icon = SCOPE_INFO_MAP[scope] + const scopeInfo = SCOPE_INFO_MAP[scope] + const ScopeIcon = scopeInfo?.icon ?? RiAccountCircleLine return (
- {Icon ? ( - - ) : ( - - )} - {Icon!.label} + + {scopeInfo?.label ?? scope}
) })} @@ -238,7 +246,7 @@ export default function OAuthAuthorize() { size="large" className="w-full" onClick={onAuthorize} - disabled={!client_id || !redirect_uri || isError || authorizing} + disabled={!clientId || !redirectUri || isOAuthError || authorizing} loading={authorizing} > {t(($) => $.continue, { ns: 'oauth' })} diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.json b/web/app/components/base/icons/src/vender/plugin/Trigger.json deleted file mode 100644 index 4ed8923e6cb..00000000000 --- a/web/app/components/base/icons/src/vender/plugin/Trigger.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "icon": { - "type": "element", - "isRootNode": true, - "name": "svg", - "attributes": { - "width": "16", - "height": "16", - "viewBox": "0 0 16 16", - "fill": "none", - "xmlns": "http://www.w3.org/2000/svg" - }, - "children": [ - { - "type": "element", - "name": "path", - "attributes": { - "fill-rule": "evenodd", - "clip-rule": "evenodd", - "d": "M7.1499 6.35213L7.25146 6.38208L14.2248 9.03898L14.3172 9.08195C14.7224 9.30788 14.778 9.87906 14.424 10.179L14.342 10.2389L11.8172 11.817L10.2391 14.3417C9.96271 14.7839 9.32424 14.751 9.08219 14.317L9.03923 14.2245L6.38232 7.25122C6.18829 6.74188 6.64437 6.24196 7.1499 6.35213ZM9.81201 12.5084L10.7671 10.981L10.8114 10.9185C10.8589 10.8589 10.9163 10.8075 10.9813 10.7668L12.5086 9.81177L8.15251 8.15226L9.81201 12.5084Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M5.2124 10.3977L3.56266 12.0474L2.61995 11.1047L4.26969 9.455L5.2124 10.3977Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M3.66683 7.99992H1.3335V6.66659H3.66683V7.99992Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M5.2124 4.2688L4.26969 5.21151L2.61995 3.56177L3.56266 2.61906L5.2124 4.2688Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M12.0477 3.56177L10.3979 5.21151L9.45524 4.2688L11.105 2.61906L12.0477 3.56177Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M8.00016 3.66659H6.66683V1.33325H8.00016V3.66659Z", - "fill": "currentColor" - }, - "children": [] - } - ] - }, - "name": "Trigger" -} diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx deleted file mode 100644 index 0db03c59836..00000000000 --- a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATE BY script -// DON NOT EDIT IT MANUALLY - -import type { IconData } from '@/app/components/base/icons/IconBase' -import * as React from 'react' -import IconBase from '@/app/components/base/icons/IconBase' -import data from './Trigger.json' - -const Icon = ({ - ref, - ...props -}: React.SVGProps & { - ref?: React.RefObject> -}) => - -Icon.displayName = 'Trigger' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/plugin/index.ts b/web/app/components/base/icons/src/vender/plugin/index.ts index b345526eb77..943c7641161 100644 --- a/web/app/components/base/icons/src/vender/plugin/index.ts +++ b/web/app/components/base/icons/src/vender/plugin/index.ts @@ -1,3 +1,2 @@ export { default as BoxSparkleFill } from './BoxSparkleFill' export { default as LeftCorner } from './LeftCorner' -export { default as Trigger } from './Trigger' diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts index 8e0dc0675d8..cae824f953e 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts @@ -25,7 +25,7 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ describe('useMarketplaceAllPlugins', () => { const mockQueryPlugins = vi.fn() const mockQueryPluginsWithDebounced = vi.fn() - const mockResetPlugins = vi.fn() + const mockResetQueryParams = vi.fn() const mockCancelQueryPluginsWithDebounced = vi.fn() const mockFetchNextPage = vi.fn() @@ -35,7 +35,7 @@ describe('useMarketplaceAllPlugins', () => { ({ plugins: [], total: 0, - resetPlugins: mockResetPlugins, + resetQueryParams: mockResetQueryParams, queryPlugins: mockQueryPlugins, queryPluginsWithDebounced: mockQueryPluginsWithDebounced, cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced, diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 222077a41dc..737bcf1aa4d 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -268,12 +268,14 @@ export const useMarketplaceAllPlugins = ( queryPlugins, queryPluginsWithDebounced, cancelQueryPluginsWithDebounced = () => {}, + resetQueryParams = () => {}, isLoading: isPluginsLoading, } = useMarketplacePlugins(enabled) useEffect(() => { if (!enabled) { cancelQueryPluginsWithDebounced() + resetQueryParams() return } @@ -302,6 +304,7 @@ export const useMarketplaceAllPlugins = ( enabled, queryPlugins, queryPluginsWithDebounced, + resetQueryParams, searchText, exclude, ]) diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 9408839897d..e5c22d4edbb 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -979,14 +979,17 @@ describe('MainNav', () => { ) }) - it('marks marketplace active on marketplace routes', () => { - mockPathname = '/marketplace' + it.each(['/marketplace', '/plugins', '/templates', '/templates/marketing'])( + 'marks marketplace active on route %s', + (pathname) => { + mockPathname = pathname - renderMainNav() + renderMainNav() - const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) - expect(marketplaceLink).toHaveClass(activeGradientMaskClassName) - }) + const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) + expect(marketplaceLink).toHaveClass(activeGradientMaskClassName) + }, + ) it('marks roster active on roster routes', () => { mockPathname = '/agents' @@ -1184,7 +1187,8 @@ describe('MainNav', () => { 'common.mainNav.help.learnDify', 'common.mainNav.help.stepByStepTour', 'common.userProfile.compliance', - 'Discord', + 'common.userProfile.discord', + 'common.mainNav.help.creatorCenter', 'common.userProfile.github', 'common.userProfile.about', ] @@ -1195,6 +1199,23 @@ describe('MainNav', () => { }) }) + it('opens Creator Center from the help menu above GitHub', async () => { + renderMainNav() + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + + const creatorCenter = await screen.findByRole('menuitem', { + name: 'common.mainNav.help.creatorCenter', + }) + const github = screen.getByRole('menuitem', { name: /common\.userProfile\.github/ }) + + expect(creatorCenter).toHaveAttribute('href', 'https://creators.dify.ai/') + expect(creatorCenter).toHaveAttribute('target', '_blank') + expect(creatorCenter).toHaveAttribute('rel', 'noopener noreferrer') + expect(creatorCenter.compareDocumentPosition(github)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(creatorCenter.querySelector('.i-ri-user-star-line')).toBeTruthy() + }) + it('opens About from its real Help menu owner and restores focus when closed', async () => { const user = userEvent.setup() mockConsoleState.current = { @@ -1267,7 +1288,7 @@ describe('MainNav', () => { fireEvent.click(contactUsItem) await waitFor(() => { - expect(screen.queryByText('Discord')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.discord')).not.toBeInTheDocument() }) expect(mockSetShowPricingModal).toHaveBeenCalled() }) diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx index 1645b32a690..c2833868fea 100644 --- a/web/app/components/main-nav/__tests__/layout.spec.tsx +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import type { Mock } from 'vite-plus/test' +import { useSuspenseQuery } from '@tanstack/react-query' import { fireEvent, screen } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' @@ -10,6 +11,7 @@ import MainNavLayout from '../layout' const mockConsoleState = vi.hoisted(() => ({ current: { isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, }, })) @@ -22,6 +24,14 @@ vi.mock('@/app/components/header/header-wrapper', () => ({
{children}
), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: vi.fn(), + } +}) + vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => mockConsoleState.current) @@ -55,7 +65,13 @@ describe('MainNavLayout', () => { ;(usePathname as Mock).mockReturnValue('/apps') mockConsoleState.current = { isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, } + ;(useSuspenseQuery as Mock).mockReturnValue({ + data: { + enable_app_deploy: true, + }, + }) ;(isAgentV2Enabled as Mock).mockReturnValue(true) }) @@ -205,29 +221,64 @@ describe('MainNavLayout', () => { expect(screen.getByTestId('main-nav')).toBeInTheDocument() }) - it.each(['/datasets/create', '/datasets/new/create', '/datasets/dataset-1/documents/create'])( - 'keeps the global main nav on collection and creation route %s', - (pathname) => { - ;(usePathname as Mock).mockReturnValue(pathname) + it.each([ + '/datasets/create', + '/datasets/new/create', + '/datasets/dataset-1/documents/create', + '/deployments/create', + ])('keeps the global main nav on collection and creation route %s', (pathname) => { + ;(usePathname as Mock).mockReturnValue(pathname) - render( - Detail sidebar}> -
content
-
, - ) + render( + Detail sidebar}> +
content
+
, + ) - expect(screen.getByTestId('main-nav')).toBeInTheDocument() - expect( - screen.queryByRole('complementary', { name: 'Detail sidebar' }), - ).not.toBeInTheDocument() + expect(screen.getByTestId('main-nav')).toBeInTheDocument() + expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument() + }) + + it.each([ + { + label: 'agent detail route for dataset operators', + pathname: '/agents/agent-1/configure', + consoleState: { + isCurrentWorkspaceDatasetOperator: true, + isCurrentWorkspaceEditor: true, + }, + systemFeatures: { + enable_app_deploy: true, + }, }, - ) - - it('keeps the global main nav on agent detail routes for dataset operators', () => { - ;(usePathname as Mock).mockReturnValue('/agents/agent-1/configure') - mockConsoleState.current = { - isCurrentWorkspaceDatasetOperator: true, - } + { + label: 'deployment detail route for non-editor workspaces', + pathname: '/deployments/app-instance-1/overview', + consoleState: { + isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: false, + }, + systemFeatures: { + enable_app_deploy: true, + }, + }, + { + label: 'deployment detail route when deployment is disabled', + pathname: '/deployments/app-instance-1/overview', + consoleState: { + isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, + }, + systemFeatures: { + enable_app_deploy: false, + }, + }, + ])('keeps the global main nav on $label', ({ pathname, consoleState, systemFeatures }) => { + ;(usePathname as Mock).mockReturnValue(pathname) + mockConsoleState.current = consoleState + ;(useSuspenseQuery as Mock).mockReturnValue({ + data: systemFeatures, + }) render( Detail sidebar}> diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx index dc6ba29391a..2d56e31c559 100644 --- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx @@ -117,12 +117,18 @@ describe('SupportMenu', () => { renderSupportMenu() expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() + expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument() expect( screen .getByText('common.userProfile.contactUs') - .compareDocumentPosition(screen.getByText('Discord')), + .compareDocumentPosition(screen.getByText('common.userProfile.discord')), ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getByRole('menuitem', { name: 'common.userProfile.discord' })).toHaveClass( + 'mx-0', + 'px-3', + ) fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' })) @@ -177,7 +183,7 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() }) it('keeps Zendesk contact us for Cloud sandbox plan with support email and Zendesk configured', () => { @@ -229,7 +235,7 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() }) it('renders email support when Zendesk is not configured for a dedicated support channel', () => { @@ -245,12 +251,12 @@ describe('SupportMenu', () => { ).toHaveAttribute('href', 'mailto:support@example.com') }) - it('has the correct Discord link', () => { + it('has the Discord link and no Forum entry', () => { renderSupportMenu() - expect(screen.getByRole('menuitem', { name: 'Discord' })).toHaveAttribute( - 'href', - 'https://discord.gg/5AEfbxcd9k', - ) + const discordLink = screen.getByText('common.userProfile.discord').closest('a') + expect(discordLink).toHaveAttribute('href', 'https://discord.gg/5AEfbxcd9k') + expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index e6851679659..f7d26d8a3a1 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -29,6 +29,7 @@ import { MenuItemContent, } from '@/app/components/header/account-dropdown/menu-item-content' import GithubStar from '@/app/components/header/github-star' +import { useCreatorCenterUrl } from '@/app/components/plugins/marketplace/creator-center-url' import { trackStepByStepTourEvent } from '@/app/components/step-by-step-tour/analytics' import { disableStepByStepTourForCurrentWorkspaceAtom, @@ -38,6 +39,7 @@ import { stepByStepTourStateUpdatingAtom, } from '@/app/components/step-by-step-tour/state' import { useSetStepByStepTourShellMode } from '@/app/components/step-by-step-tour/storage' +import { MARKETPLACE_URL_PREFIX } from '@/config' import { getLangGeniusVersionInfo } from '@/context/app-context-normalizers' import { useDocLink } from '@/context/i18n' import { @@ -91,6 +93,7 @@ const MenuSwitchIndicator = ({ checked }: { checked: boolean }) => ( const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: HelpMenuProps) => { const { t } = useTranslation() const docLink = useDocLink() + const creatorCenterUrl = useCreatorCenterUrl(MARKETPLACE_URL_PREFIX) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: profileMeta } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -252,6 +255,18 @@ const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: He + + $['mainNav.help.creatorCenter'], { ns: 'common' })} + trailing={} + /> + $['userProfile.discord'], { ns: 'common' })} trailing={} /> diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index 78c5958ca4b..c54c9af346d 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -43,7 +43,7 @@ export const MAIN_NAV_ROUTES = [ key: 'home', href: '/', labelKey: 'mainNav.home', - active: (path: string) => path === '/', + active: (path: string) => path === '/' || path === '/explore/apps', icon: 'i-custom-vender-main-nav-home-v2', activeIcon: 'i-custom-vender-main-nav-home-v2-active', visibility: VISIBLE_TO_ALL, @@ -103,7 +103,9 @@ export const MAIN_NAV_ROUTES = [ href: '/marketplace', labelKey: 'mainNav.marketplace', active: (path: string) => - isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'), + isPathUnderRoute(path, '/marketplace') || + isPathUnderRoute(path, '/plugins') || + isPathUnderRoute(path, '/templates'), icon: 'i-custom-vender-main-nav-marketplace-v2', activeIcon: 'i-custom-vender-main-nav-marketplace-v2-active', visibility: VISIBLE_TO_ALL, diff --git a/web/app/components/plugins/base/badges/partner.tsx b/web/app/components/plugins/base/badges/partner.tsx index 6d97d3b4898..41663ffee80 100644 --- a/web/app/components/plugins/base/badges/partner.tsx +++ b/web/app/components/plugins/base/badges/partner.tsx @@ -1,3 +1,5 @@ +'use client' + import type { FC } from 'react' import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark' import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight' diff --git a/web/app/components/plugins/card/__tests__/index.spec.tsx b/web/app/components/plugins/card/__tests__/index.spec.tsx new file mode 100644 index 00000000000..1a237cb9c90 --- /dev/null +++ b/web/app/components/plugins/card/__tests__/index.spec.tsx @@ -0,0 +1,108 @@ +import type { CardPayload } from '../index' +import { render } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { PluginCategoryEnum } from '../../types' +import Card from '../index' + +vi.mock('jotai', () => ({ + useAtomValue: vi.fn(), +})) + +vi.mock('@/context/workspace-state', () => ({ + currentWorkspaceIdAtom: Symbol('currentWorkspaceIdAtom'), +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string | ((dict: Record) => string), options?: { ns?: string }) => { + if (typeof key === 'string') return key + + // Independent Marketplace does not load the tools namespace, so + // tools.author falls back to the key name "author". + const dict: Record = + options?.ns === 'tools' + ? { author: 'author' } + : { + 'marketplace.by': 'by', + 'marketplace.partnerTip': 'Verified by a Dify partner', + 'marketplace.verifiedTip': 'Verified by Dify', + install: '{{num}} installs', + } + return key(dict) + }, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record) => value['en-US'] ?? '', +})) + +vi.mock('../../hooks', () => ({ + useCategories: () => ({ + categoriesMap: { + tool: { label: 'Tool' }, + }, + }), +})) + +const marketplacePlugin = { + badges: [], + brief: { 'en-US': 'Marketplace plugin description' }, + category: PluginCategoryEnum.tool, + description: { 'en-US': 'Marketplace plugin description' }, + endpoint: { settings: [] }, + from: 'marketplace', + icon: 'icon.png', + install_count: 0, + introduction: '', + label: { 'en-US': 'Marketplace plugin' }, + latest_package_identifier: 'langgenius/demo-plugin:1.0.0', + latest_version: '1.0.0', + name: 'demo-plugin', + org: 'langgenius', + plugin_id: 'langgenius/demo-plugin', + repository: '', + tags: [], + type: 'plugin', + verified: false, + verification: { authorized_category: 'langgenius' }, + version: '1.0.0', +} satisfies CardPayload + +describe('Plugin card workspace boundary', () => { + it('renders Marketplace variant icons without reading Dify workspace state', () => { + vi.mocked(useAtomValue).mockImplementation(() => { + throw new Error('Dify workspace state must not be read') + }) + + const payloadWithoutSource = { + ...marketplacePlugin, + from: undefined, + } as unknown as CardPayload + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute( + 'src', + `${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`, + ) + expect(useAtomValue).not.toHaveBeenCalled() + }) + + it('labels the marketplace author as by, not the tools.author key fallback', () => { + const { container } = render() + + expect(container).toHaveTextContent('by') + expect(container).toHaveTextContent('langgenius') + expect(container).not.toHaveTextContent('author') + }) +}) diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index f5dc18cb55b..4a5ebaa6d9f 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -42,6 +42,37 @@ type Props = Readonly<{ variant?: 'default' | 'marketplace' }> +type CardIconProps = { + icon: CardPayload['icon'] + installFailed?: boolean + installed?: boolean + marketplace?: boolean + plugin: Pick +} + +const WorkspaceCardIcon = ({ icon, installFailed, installed, plugin }: CardIconProps) => { + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) + const iconSrc = getPluginCardIconUrl(plugin, icon, currentWorkspaceId) + + return +} + +const CardIcon = ({ icon, installFailed, installed, marketplace, plugin }: CardIconProps) => { + if (marketplace || plugin.from === 'marketplace') { + const iconSrc = getPluginCardIconUrl({ ...plugin, from: 'marketplace' }, icon, '') + return + } + + return ( + + ) +} + const Card = ({ className, payload, @@ -60,15 +91,11 @@ const Card = ({ const locale = useGetLanguage() const { t } = useTranslation() const { categoriesMap } = useCategories(true) - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload const badges = payload.badges ?? [] const { theme } = useTheme() - const iconSrc = getPluginCardIconUrl( - { from, name, org, type }, - theme === Theme.dark && icon_dark ? icon_dark : icon, - currentWorkspaceId, - ) + const activeIcon = theme === Theme.dark && icon_dark ? icon_dark : icon + const pluginIdentity = { from, name, org, type } const getLocalizedText = (obj: Record | undefined) => obj ? renderI18nObject(obj, locale) : '' const isPartner = badges.includes('partner') @@ -92,7 +119,13 @@ const Card = ({
{!hideCornerMark && }
- +
@@ -116,7 +149,7 @@ const Card = ({ {org && (
- {t(($) => $.author, { ns: 'tools' })} + {t(($) => $['marketplace.by'], { ns: 'plugin' })} {org}
@@ -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<typeof import('../home/banners')>() + + return { + ...original, + fetchPluginBanners: (...args: unknown[]) => mockFetchPluginBanners(...args), + } +}) + +vi.mock('../view', () => ({ + MarketplaceView: ({ + banners, + showInstallButton, + }: { + banners: PluginBanner[] + showInstallButton: boolean + }) => ( + <div> + <p>Trending banners: {banners.length}</p> + <p>{showInstallButton ? 'Install enabled' : 'Install disabled'}</p> + </div> + ), +})) + +let queryClient: QueryClient + +function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +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(<EmbeddedMarketplace showInstallButton variant="home" />, { 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( + <EmbeddedMarketplace + initialBanners={initialBanners} + initialLocale="zh-Hans" + showInstallButton + variant="home" + />, + { 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( + <EmbeddedMarketplace + initialBanners={initialBanners} + initialLocale="en-US" + showInstallButton + variant="home" + />, + { 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(<EmbeddedMarketplace variant="default" />, { 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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + } + +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..80c0a37463a 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 () => { @@ -104,7 +112,7 @@ describe('HydrateQueryClient', () => { expect(state.queries[0]?.queryKey).toEqual([ 'marketplace', 'collections', - { input: { query: {} } }, + { input: { query: { limit: 20 } } }, ]) }) @@ -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: <div>Child</div>, + }) + + 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,42 @@ describe('HydrateQueryClient', () => { }) expect(mockCollections).not.toHaveBeenCalled() + expect(mockSearchAdvanced).toHaveBeenCalled() + }) + + it('should keep the catalog shell when collections prefetch fails', async () => { + mockCollections.mockRejectedValue(new Error('collections unavailable')) + const { HydrateQueryClient } = await import('../hydration-server') + + const element = await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'all' }), + children: <div>Child</div>, + }) + + const renderClient = new QueryClient() + const { getByText } = render( + <QueryClientProvider client={renderClient}> + {element as React.ReactElement} + </QueryClientProvider>, + ) + expect(getByText('Child')).toBeInTheDocument() + }) + + it('should keep the catalog shell when plugin search prefetch fails', async () => { + mockSearchAdvanced.mockRejectedValue(new Error('search unavailable')) + const { HydrateQueryClient } = await import('../hydration-server') + + const element = await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'all', q: 'openai' }), + children: <div>Child</div>, + }) + + const renderClient = new QueryClient() + const { getByText } = render( + <QueryClientProvider client={renderClient}> + {element as React.ReactElement} + </QueryClientProvider>, + ) + expect(getByText('Child')).toBeInTheDocument() }) }) 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<typeof PluginTypeSwitch>) => { const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) const Wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> @@ -21,7 +22,7 @@ const renderSwitch = (searchParams = '') => { </JotaiProvider> ) - return { ...render(<PluginTypeSwitch />, { wrapper: Wrapper }), onUrlUpdate } + return { ...render(<PluginTypeSwitch {...props} />, { 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..cbb2ed377f1 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx @@ -0,0 +1,95 @@ +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, 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<typeof import('../home/banners')>() + + return { + ...original, + fetchPluginBanners: mockFetchPluginBanners, + } +}) + +vi.mock('../hydration-server', () => ({ + HydrateQueryClient: ({ children }: { children: ReactNode }) => children, +})) + +vi.mock('../prefetch-marketplace-dehydrated-state', () => ({ + prefetchMarketplaceDehydratedState: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../view', () => ({ + MarketplaceView: ({ banners }: { banners: PluginBanner[] }) => ( + <p>Server banners: {banners.length}</p> + ), +})) + +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(<QueryClientProvider client={new QueryClient()}>{element}</QueryClientProvider>) + + expect(screen.getByText('Server banners: 1')).toBeInTheDocument() + expect(mockGetLocaleOnServer).toHaveBeenCalledOnce() + expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US') + }) + + it('starts catalog prefetch without waiting for banners to finish', async () => { + const { prefetchMarketplaceDehydratedState } = + await import('../prefetch-marketplace-dehydrated-state') + let resolveBanners: (banners: PluginBanner[]) => void = () => {} + mockGetLocaleOnServer.mockResolvedValue('en-US') + mockFetchPluginBanners.mockImplementation( + () => + new Promise<PluginBanner[]>((resolve) => { + resolveBanners = resolve + }), + ) + vi.mocked(prefetchMarketplaceDehydratedState).mockResolvedValue(undefined) + + const { default: Marketplace } = await import('../index') + const renderPromise = Marketplace({ variant: 'home', searchParams: Promise.resolve({}) }) + + await vi.waitFor(() => { + expect(prefetchMarketplaceDehydratedState).toHaveBeenCalled() + }) + expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US') + + resolveBanners([]) + await renderPromise + }) +}) 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..a51a208fc96 100644 --- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -138,6 +138,34 @@ describe('getPluginDetailLinkInMarketplace', () => { }) }) +describe('getTemplateDetailLinkInMarketplace', () => { + it('should return the local template detail link', async () => { + const { getTemplateDetailLinkInMarketplace } = await import('../utils') + + expect( + getTemplateDetailLinkInMarketplace({ + id: 'template-1', + template_name: 'Legal Research Agent', + publisher_handle: 'dify', + publisher_unique_handle: 'dify-unique', + }), + ).toBe('/template/dify/Legal%20Research%20Agent?templateId=template-1') + }) + + it('should fall back to the unique publisher handle', async () => { + const { getTemplateDetailLinkInMarketplace } = await import('../utils') + + expect( + getTemplateDetailLinkInMarketplace({ + id: 'template-2', + template_name: 'Inbox', + publisher_handle: '', + publisher_unique_handle: 'langgenius', + }), + ).toBe('/template/langgenius/Inbox?templateId=template-2') + }) +}) + describe('getMarketplaceListCondition', () => { it('should return category condition for tool', async () => { const { getMarketplaceListCondition } = await import('../utils') @@ -229,21 +257,23 @@ 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 () => { + it('should send the warmed preview limit when query is omitted', async () => { mockCollectionPlugins.mockResolvedValueOnce({ data: { plugins: [] }, }) - const { getMarketplacePluginsByCollectionId } = await import('../utils') + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplacePluginsByCollectionId } = + await import('../utils') await getMarketplacePluginsByCollectionId('test-collection') expect(mockCollectionPlugins).toHaveBeenCalledWith( @@ -251,7 +281,7 @@ describe('getMarketplacePluginsByCollectionId', () => { params: { collectionId: 'test-collection', }, - body: {}, + body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT }, }, expect.objectContaining({ signal: undefined, @@ -289,7 +319,8 @@ describe('getMarketplaceCollectionsAndPlugins', () => { mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } }) mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } }) - const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } = + await import('../utils') const result = await getMarketplaceCollectionsAndPlugins({ condition: 'category=tool', type: 'plugin', @@ -297,16 +328,104 @@ describe('getMarketplaceCollectionsAndPlugins', () => { expect(result.marketplaceCollections).toBeDefined() expect(result.marketplaceCollectionPluginsMap).toBeDefined() + expect(mockCollectionPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + params: { collectionId: 'collection1' }, + body: { + condition: 'category=tool', + type: 'plugin', + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, + }, + }), + expect.any(Object), + ) }) - it('should handle fetch error and return empty data', async () => { + it('posts the warmed preview limit when the catalog has no extra filters', async () => { + mockCollections.mockResolvedValueOnce({ + data: { + collections: [ + { + name: 'featured', + label: {}, + description: {}, + rule: '', + created_at: '', + updated_at: '', + }, + ], + }, + }) + mockCollectionPlugins.mockResolvedValue({ data: { plugins: [] } }) + + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } = + await import('../utils') + await getMarketplaceCollectionsAndPlugins() + + expect(mockCollectionPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + params: { collectionId: 'featured' }, + body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT }, + }), + expect.any(Object), + ) + }) + + 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('propagates cancellation instead of resolving empty carousels', async () => { + const controller = new AbortController() + mockCollections.mockResolvedValueOnce({ + data: { + collections: [ + { name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + { name: 'slow', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ], + }, + }) + mockCollectionPlugins + .mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } }) + .mockImplementationOnce(async () => { + controller.abort() + const error = new Error('Aborted') + error.name = 'AbortError' + throw error + }) + + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + + await expect( + getMarketplaceCollectionsAndPlugins({}, { signal: controller.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }) }) it('should append condition and type to URL when provided', async () => { @@ -327,22 +446,74 @@ describe('getMarketplaceCollectionsAndPlugins', () => { }) describe('getCollectionsParams', () => { - it('should return empty object for all category', async () => { - const { getCollectionsParams } = await import('../utils') - expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({}) + it('should return the warmed preview limit for all category', async () => { + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils') + expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({ + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, + }) + expect(COLLECTION_PREVIEW_PLUGIN_LIMIT).toBe(20) }) - it('should return category, condition, and type for tool category', async () => { - const { getCollectionsParams } = await import('../utils') + it('should return category, condition, type, and preview limit for tool category', async () => { + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils') const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool) expect(result).toEqual({ category: PluginCategoryEnum.tool, condition: 'category=tool', type: 'plugin', + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, }) }) }) +describe('toListPlugin', () => { + it('keeps card fields and drops list-unused payload', async () => { + const { toListPlugin } = await import('../utils') + const plugin = { + ...createMockPlugin({ + introduction: 'A very long readme that must not enter the catalog RSC payload', + }), + resource: { memory: 256 }, + plugins: { tools: ['x'] }, + tool: { identity: { name: 'search' } }, + model: { provider: 'openai' }, + agent_strategy: { features: ['a'] }, + data_sources: { items: [] }, + triggers: { events: [] }, + privacy_policy: 'https://example.com/privacy', + privacy_options: 'all', + readme_meta: { available_languages: ['en_US'] }, + endpoint: { settings: [{ name: 'api_key' }] }, + } as unknown as Plugin + + const listed = toListPlugin(plugin) + + expect(listed.org).toBe('test-org') + expect(listed.name).toBe('test-plugin') + expect(listed.plugin_id).toBe('plugin-1') + expect(listed.label).toEqual({ 'en-US': 'Test Plugin' }) + expect(listed.brief).toEqual({ 'en-US': 'Test plugin brief' }) + expect(listed.badges).toEqual([]) + expect(listed.verification).toEqual({ authorized_category: 'community' }) + expect(listed.install_count).toBe(1000) + expect(listed.category).toBe(PluginCategoryEnum.tool) + expect(listed.tags).toEqual([{ name: 'search' }]) + expect(listed.type).toBe('plugin') + expect(listed.introduction).toBe('') + expect(listed.endpoint).toEqual({ settings: [] }) + expect(listed).not.toHaveProperty('resource') + expect(listed).not.toHaveProperty('plugins') + expect(listed).not.toHaveProperty('tool') + expect(listed).not.toHaveProperty('model') + expect(listed).not.toHaveProperty('agent_strategy') + expect(listed).not.toHaveProperty('data_sources') + expect(listed).not.toHaveProperty('triggers') + expect(listed).not.toHaveProperty('privacy_policy') + expect(listed).not.toHaveProperty('privacy_options') + expect(listed).not.toHaveProperty('readme_meta') + }) +}) + describe('getMarketplacePlugins', () => { beforeEach(() => { vi.clearAllMocks() @@ -431,23 +602,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<PluginsSort>(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<true | null>(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: () => <span data-testid="creation-icon" />, +})) + +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( + <CreationCard + creation={creation} + action={{ type: 'link', href: '/plugin/dify/search?language=en-US' }} + />, + ) + + 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(<CreationCard creation={creation} action={{ type: 'select', onSelect }} />) + + 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..b34d152ec5b --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx @@ -0,0 +1,163 @@ +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' + +const publisherMocks = vi.hoisted(() => ({ + fetchPublisherPluginPage: vi.fn(), + fetchPublisherTemplatePage: vi.fn(), +})) + +vi.mock('../publisher', async (importOriginal) => { + const actual = await importOriginal<typeof import('../publisher')>() + return { + ...actual, + fetchPublisherPluginPage: publisherMocks.fetchPublisherPluginPage, + fetchPublisherTemplatePage: publisherMocks.fetchPublisherTemplatePage, + } +}) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + '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', + 'marketplace.creatorProfile.loadMore': 'Load more', + 'marketplace.creatorProfile.loadMoreFailed': "Couldn't load more creations.", + } + + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => translations[key] ?? key), + }), + } +}) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <span aria-hidden />, +})) + +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', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes sort into the URL and reorders the current cards', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs( + <CreatorContent + creations={creations} + getCreationAction={(creation) => ({ 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') + }) + }) + + it('loads the next publisher pages when more creations exist', async () => { + const user = userEvent.setup() + publisherMocks.fetchPublisherPluginPage.mockResolvedValue({ + items: [ + { + type: 'plugin', + org: 'dify', + name: 'delta', + labels: { 'en-US': 'Delta' }, + brief: { 'en-US': 'Delta plugin' }, + install_count: 4, + created_at: '2026-01-04T00:00:00Z', + updated_at: '2026-02-04T00:00:00Z', + }, + ], + hasMore: false, + }) + publisherMocks.fetchPublisherTemplatePage.mockResolvedValue({ items: [], hasMore: false }) + + renderWithNuqs( + <CreatorContent + creations={creations} + locale="en-US" + inventory={{ + uniqueHandle: 'scarlettmao', + pluginHasMore: true, + templateHasMore: false, + pluginNextPage: 2, + templateNextPage: 2, + }} + getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })} + />, + ) + + await user.click(screen.getByRole('button', { name: 'Load more' })) + + await waitFor(() => { + expect(cardNames()).toContain('Delta') + }) + expect(publisherMocks.fetchPublisherPluginPage).toHaveBeenCalledWith({ + uniqueHandle: 'scarlettmao', + page: 2, + sortField: 'updatedAt', + sortOrder: 'desc', + }) + expect(publisherMocks.fetchPublisherTemplatePage).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument() + }) +}) 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 }) => ( + <div data-testid="publisher-avatar" data-size={size} className={className} /> + ), +})) + +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(<CreatorSidebar profile={profile} />) + + 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(<CreatorSidebar profile={profile} />) + + 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..8aac42bfb92 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts @@ -0,0 +1,276 @@ +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('loads only the first publisher page and reports remaining inventory', async () => { + mocks.publisherPlugins.mockResolvedValue({ + data: { + plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })), + total: 90, + }, + }) + mocks.publisherTemplates.mockResolvedValue({ + data: { templates: [template], total: 1 }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'paged-creator', + locale: 'en-US', + }) + + expect(mocks.publisherPlugins).toHaveBeenCalledOnce() + expect(mocks.publisherPlugins).toHaveBeenCalledWith({ + params: { uniqueHandle: 'paged-creator' }, + query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' }, + }) + expect(loaded?.inventory).toMatchObject({ + uniqueHandle: 'paged-creator', + pluginHasMore: true, + templateHasMore: false, + pluginNextPage: 2, + }) + expect(loaded?.viewModel.creations).toHaveLength(41) + }) + + it('does not treat a full first page as the complete inventory when total is missing', async () => { + mocks.publisherPlugins.mockResolvedValue({ + data: { + plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })), + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'uncounted-creator', + locale: 'en-US', + }) + + expect(mocks.publisherPlugins).toHaveBeenCalledOnce() + expect(loaded?.inventory.pluginHasMore).toBe(true) + }) + + 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..1eb2c318b94 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx @@ -0,0 +1,213 @@ +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, + }, + inventory: { + uniqueHandle: 'creator', + pluginHasMore: false, + templateHasMore: false, + pluginNextPage: 2, + templateNextPage: 2, + }, +} + +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/base/app-icon', () => ({ + default: () => <span aria-hidden />, +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: () => ({ installedInfo: mocks.installedInfo }), +})) + +vi.mock('../../detail-dialog', () => ({ + default: ({ isInstalled, plugin }: { isInstalled: boolean; plugin: { name: string } }) => ( + <div role="dialog" aria-label="plugin-detail"> + <span>{plugin.name}</span> + <span>{isInstalled ? 'installed' : 'not installed'}</span> + </div> + ), +})) + +vi.mock('../../templates/template-detail-dialog', () => ({ + default: ({ + onInstall, + template, + }: { + onInstall: () => void + template: { template_name: string } + }) => ( + <div role="dialog" aria-label="template-detail"> + <span>{template.template_name}</span> + <button type="button" onClick={onInstall}> + Install template + </button> + </div> + ), +})) + +vi.mock('../header', () => ({ + default: ({ + onSuggestionSelect, + }: { + onSuggestionSelect: (selection: MarketplaceSearchSelection) => void + }) => ( + <button + type="button" + onClick={() => { + onSuggestionSelect({ kind: 'plugin', plugin: searchPlugin }) + }} + > + Select search plugin + </button> + ), +})) + +describe('DifyCreatorProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the existing plugin detail flow with installed state', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + 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') + expect(screen.queryByTestId('install-plugin')).not.toBeInTheDocument() + }) + + it('opens a template detail and imports it inside Dify', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + 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(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + 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..e29f19fde0c --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx @@ -0,0 +1,44 @@ +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<string, string> = { + '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: () => <div data-testid="marketplace-guide" />, +})) + +vi.mock('../../home/marketplace-search-autocomplete', () => ({ + MarketplaceSearchAutocomplete: () => <div data-testid="marketplace-search" />, +})) + +describe('CreatorProfileHeader', () => { + it('returns to the native Marketplace without marking a catalog tab active', () => { + render(<CreatorProfileHeader locale="en-US" onSuggestionSelect={vi.fn()} />) + + 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') + expect(screen.getByTestId('marketplace-guide')).toBeInTheDocument() + expect(screen.queryByTestId('account-section')).not.toBeInTheDocument() + }) +}) 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..99696799f8c --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts @@ -0,0 +1,224 @@ +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<typeof adaptCreatorProfile>['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() + }) + + it('ignores non-string social links and template dependencies instead of throwing', () => { + expect(normalizeCreatorSocialLink({ href: 'https://x.com/x' })).toBeNull() + expect(normalizeCreatorSocialLink(null)).toBeNull() + + const viewModel = adaptCreatorProfile({ + creator: { + ...creator, + social_links: [{ href: 'https://x.com/x' }, 'github.com/evanz'] as unknown as string[], + }, + kind: 'individual', + locale: 'en-US', + avatarUrl: '/avatar', + backgroundUrl: '/background', + plugins: [], + templates: [ + { + ...template, + deps_plugins: [null, 'dify/search', ''] as unknown as string[], + }, + ], + resolvePluginIcon: () => '/plugin-icon', + resolveTemplateIcon: () => '', + resolveDependencyIcon: (id) => `/dependency/${id}`, + }) + + expect(viewModel.profile.socialLinks).toEqual([expect.objectContaining({ platform: 'github' })]) + expect(viewModel.creations[0]?.dependencyIcons).toEqual(['/dependency/dify/search']) + }) +}) 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: () => <aside>Creator sidebar</aside>, +})) + +vi.mock('../creator-content', () => ({ + default: () => ( + <section data-testid="creator-creations" style={{ height: 640, flexShrink: 0 }}> + Creator content + </section> + ), +})) + +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( + <div + data-testid="creator-scrollport" + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + /> + </div>, + ) + + 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: () => <aside>Creator sidebar</aside>, +})) + +vi.mock('../creator-content', () => ({ + default: () => <section>Creator content</section>, +})) + +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( + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ 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( + <CreatorProfileView + profile={{ + ...profile, + profile: { ...profile.profile, backgroundUrl: '' }, + }} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + + expect(markup).toContain('default-background.png') + expect(markup).not.toContain('<img') + expect(markup).toContain('border-0') + }) + + it('hides a stale remote image after a loading failure', () => { + const { container } = render( + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + const remoteBackground = container.querySelector<HTMLImageElement>( + '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 ( + <> + <CornerMark + text={t(($) => $[`marketplace.creatorProfile.type.${creation.kind}`], { ns: 'plugin' })} + className={cn( + creation.kind === 'plugin' && '[&>div]:text-text-accent', + creation.kind === 'template' && '[&>div]:text-text-warning', + )} + /> + + <div className="flex min-w-0 shrink-0 items-center gap-3 px-4 pt-4 pr-20 pb-2"> + {creation.icon.type === 'image' ? ( + <AppIcon size="large" iconType="image" imageUrl={creation.icon.src} /> + ) : ( + <AppIcon + size="large" + iconType="emoji" + icon={creation.icon.value} + background={creation.icon.background} + /> + )} + <h3 className="min-w-0 flex-1 truncate system-md-medium text-text-primary"> + {creation.title} + </h3> + </div> + + <p className="mx-4 line-clamp-2 min-h-8 system-xs-regular text-text-secondary"> + {creation.description} + </p> + + <div className="mt-auto flex min-h-7 items-center gap-1 overflow-hidden px-4 py-1"> + {visibleDependencies.map((icon) => ( + <img + key={icon} + alt="" + aria-hidden + src={icon} + className="size-6 shrink-0 rounded-md border-[0.5px] border-effects-icon-border object-cover" + /> + ))} + {remainingDependencies > 0 && ( + <span className="shrink-0 system-xs-regular text-text-tertiary"> + +{remainingDependencies} + </span> + )} + </div> + </> + ) +} + +export default function CreationCard({ creation, action }: CreationCardProps) { + if (action.type === 'link') { + return ( + <Link href={action.href} aria-label={creation.title} className={cardClassName}> + <CreationCardContent creation={creation} /> + </Link> + ) + } + + return ( + <button + type="button" + aria-label={creation.title} + className={cardClassName} + onClick={action.onSelect} + > + <CreationCardContent creation={creation} /> + </button> + ) +} 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..bc3a890fad2 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx @@ -0,0 +1,261 @@ +'use client' + +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { + CreatorCreation, + CreatorCreationAction, + CreatorInventory, + CreatorSortField, + CreatorSortOrder, +} from './model' +import type { Plugin } from '@/app/components/plugins/types' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { parseAsStringEnum, useQueryStates } from 'nuqs' +import { useMemo, useState } 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' +import { fetchPublisherPluginPage, fetchPublisherTemplatePage, toCreatorRecords } from './publisher' + +type CreatorContentProps = { + creations: CreatorCreation[] + getCreationAction: (creation: CreatorCreation) => CreatorCreationAction + inventory?: CreatorInventory + locale?: string + onRecordsLoaded?: (records: { + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> + }) => void +} + +const sortSearchOptions = { history: 'replace' as const, shallow: false, scroll: false } +const creatorSortSearchParsers = { + sort_by: parseAsStringEnum<CreatorSortField>([...CREATOR_SORT_FIELDS]).withDefault( + DEFAULT_CREATOR_SORT_FIELD, + ), + sort_order: parseAsStringEnum<CreatorSortOrder>(['asc', 'desc']).withDefault( + DEFAULT_CREATOR_SORT_ORDER, + ), +} + +export default function CreatorContent({ + creations, + getCreationAction, + inventory, + locale = 'en-US', + onRecordsLoaded, +}: CreatorContentProps) { + const { t } = useTranslation() + const [sort, setSort] = useQueryStates(creatorSortSearchParsers, sortSearchOptions) + const sortField = sort.sort_by + const sortOrder = sort.sort_order + const [sourceCreations, setSourceCreations] = useState(creations) + const [loadedCreations, setLoadedCreations] = useState(creations) + const [pluginHasMore, setPluginHasMore] = useState(inventory?.pluginHasMore ?? false) + const [templateHasMore, setTemplateHasMore] = useState(inventory?.templateHasMore ?? false) + const [pluginNextPage, setPluginNextPage] = useState(inventory?.pluginNextPage ?? 2) + const [templateNextPage, setTemplateNextPage] = useState(inventory?.templateNextPage ?? 2) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [loadMoreFailed, setLoadMoreFailed] = useState(false) + if (creations !== sourceCreations) { + setSourceCreations(creations) + setLoadedCreations(creations) + setPluginHasMore(inventory?.pluginHasMore ?? false) + setTemplateHasMore(inventory?.templateHasMore ?? false) + setPluginNextPage(inventory?.pluginNextPage ?? 2) + setTemplateNextPage(inventory?.templateNextPage ?? 2) + setLoadMoreFailed(false) + } + 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(loadedCreations, sortField, sortOrder), + [loadedCreations, sortField, sortOrder], + ) + const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc' + const hasMore = pluginHasMore || templateHasMore + const uniqueHandle = inventory?.uniqueHandle + + const loadMore = async () => { + if (!uniqueHandle || isLoadingMore || !hasMore) return + + setIsLoadingMore(true) + setLoadMoreFailed(false) + try { + const [pluginPage, templatePage] = await Promise.all([ + pluginHasMore + ? fetchPublisherPluginPage({ + uniqueHandle, + page: pluginNextPage, + sortField, + sortOrder, + }) + : Promise.resolve({ items: [] as MarketplacePlugin[], hasMore: false }), + templateHasMore + ? fetchPublisherTemplatePage({ + uniqueHandle, + page: templateNextPage, + sortField, + sortOrder, + }) + : Promise.resolve({ items: [] as MarketplaceTemplate[], hasMore: false }), + ]) + const records = toCreatorRecords({ + locale, + plugins: pluginPage.items, + templates: templatePage.items, + }) + setLoadedCreations((current) => { + const seen = new Set(current.map((creation) => creation.id)) + return [...current, ...records.creations.filter((creation) => !seen.has(creation.id))] + }) + if (pluginHasMore) { + setPluginHasMore(pluginPage.hasMore) + setPluginNextPage((page) => page + 1) + } + if (templateHasMore) { + setTemplateHasMore(templatePage.hasMore) + setTemplateNextPage((page) => page + 1) + } + onRecordsLoaded?.(records) + } catch { + setLoadMoreFailed(true) + } finally { + setIsLoadingMore(false) + } + } + + return ( + <section + aria-labelledby="creator-creations-title" + className="flex min-w-0 flex-1 flex-col items-start pt-6" + > + <div className="flex w-full flex-wrap items-center justify-between gap-2"> + <h2 id="creator-creations-title" className="system-xl-semibold text-text-primary"> + {t(($) => $['marketplace.creatorProfile.creations'], { ns: 'plugin' })} + </h2> + + <div className="flex h-8 items-center"> + <DropdownMenu> + <DropdownMenuTrigger + aria-label={`${t(($) => $['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" + > + <span className="mr-1 system-sm-regular text-text-tertiary"> + {t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })} + </span> + <span className="system-sm-medium text-text-secondary">{selectedSort.label}</span> + <span aria-hidden className="ml-1 i-ri-arrow-down-s-line size-4 text-text-tertiary" /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + className="min-w-[176px] p-1" + > + <DropdownMenuRadioGroup<CreatorSortField> + value={sortField} + onValueChange={(nextField) => { + void setSort({ sort_by: nextField, sort_order: sortOrder }) + }} + > + {sortOptions.map((option) => ( + <DropdownMenuRadioItem<CreatorSortField> + key={option.value} + value={option.value} + closeOnClick + className="justify-between px-3 pr-2 system-md-regular text-text-primary" + > + {option.label} + <DropdownMenuRadioItemIndicator className="ml-2" /> + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + + <div className="mx-1 h-4 w-px bg-divider-regular" /> + <button + type="button" + aria-label={t(($) => $[`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 }) + }} + > + <span + aria-hidden + className={sortOrder === 'desc' ? 'i-ri-sort-desc size-4' : 'i-ri-sort-asc size-4'} + /> + </button> + </div> + </div> + + {sortedCreations.length > 0 ? ( + <div className="grid w-full grid-cols-1 gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3"> + {sortedCreations.map((creation) => ( + <CreationCard + key={creation.id} + creation={creation} + action={getCreationAction(creation)} + /> + ))} + </div> + ) : ( + <div className="w-full py-12 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['marketplace.creatorProfile.empty'], { ns: 'plugin' })} + </div> + )} + + {hasMore && ( + <div className="flex w-full flex-col items-center gap-2 pt-6"> + <button + type="button" + aria-busy={isLoadingMore || undefined} + disabled={isLoadingMore} + className="flex h-8 items-center rounded-lg 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 disabled:opacity-50" + onClick={() => { + void loadMore() + }} + > + {t(($) => $['marketplace.creatorProfile.loadMore'], { ns: 'plugin' })} + </button> + {loadMoreFailed && ( + <p className="system-xs-regular text-text-destructive"> + {t(($) => $['marketplace.creatorProfile.loadMoreFailed'], { ns: 'plugin' })} + </p> + )} + </div> + )} + </section> + ) +} 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 <span aria-hidden className={cn(className, 'i-ri-twitter-x-fill')} /> + if (platform === 'instagram') + return <span aria-hidden className={cn(className, 'i-ri-instagram-line')} /> + if (platform === 'youtube') + return <span aria-hidden className={cn(className, 'i-ri-youtube-fill')} /> + if (platform === 'figma') return <span aria-hidden className={cn(className, 'i-ri-figma-line')} /> + if (platform === 'github') + return <span aria-hidden className={cn(className, 'i-ri-github-fill')} /> + + return <span aria-hidden className={cn(className, 'i-ri-global-line')} /> +} + +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 ( + <aside className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12"> + <PublisherAvatar + avatarUrl={profile.avatarUrl} + name={profile.displayName} + isOrganization={isOrganization} + size={100} + className={cn( + 'absolute -top-12 -left-2 z-10 !size-20 border-[1.5px] border-components-panel-bg bg-background-default-dodge shadow-xs md:-top-[68px] md:!size-[100px]', + isOrganization && 'rounded-[10px]', + )} + /> + + <div className="flex flex-col gap-1"> + <div className="flex flex-wrap items-center gap-1"> + <h1 className="title-2xl-semi-bold text-text-primary">{profile.displayName}</h1> + {isOrganization && ( + <span className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary uppercase"> + {t(($) => $['marketplace.creatorProfile.organization'], { ns: 'plugin' })} + </span> + )} + {isPartner && ( + <Partner + className="size-[18px] shrink-0" + text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })} + /> + )} + {isVerified && ( + <Verified + className="size-[18px] shrink-0" + text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })} + /> + )} + </div> + <span className="system-sm-regular text-text-tertiary">@{profile.handle}</span> + </div> + + {profile.description && ( + <p className="system-sm-regular whitespace-pre-wrap text-text-secondary"> + {profile.description} + </p> + )} + + {profile.email && ( + <a + href={`mailto:${profile.email}`} + className="flex min-w-0 items-center gap-1.5 py-1 system-sm-regular text-text-secondary outline-hidden hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span aria-hidden className="i-ri-mail-line size-4 shrink-0 text-text-tertiary" /> + <span className="truncate">{profile.email}</span> + </a> + )} + + {profile.socialLinks.length > 0 && ( + <div className="flex flex-col gap-2 py-1"> + <div className="flex w-full items-center gap-2"> + <span className="shrink-0 system-xs-medium text-text-tertiary uppercase"> + {t(($) => $['marketplace.creatorProfile.onTheWeb'], { ns: 'plugin' })} + </span> + <div className="h-px min-w-0 flex-1 bg-gradient-to-r from-divider-regular to-transparent" /> + </div> + <div className="flex flex-col gap-2"> + {profile.socialLinks.map((link) => ( + <a + key={link.href} + href={link.href} + target="_blank" + rel="noopener noreferrer" + className="flex min-w-0 items-center gap-1.5 system-sm-regular text-text-secondary outline-hidden transition-colors hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <SocialIcon platform={link.platform} /> + <span className="truncate">{link.label}</span> + </a> + ))} + </div> + </div> + )} + </aside> + ) +} 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..38910290da6 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/data.server.ts @@ -0,0 +1,140 @@ +import type { MarketplaceCreator, MarketplaceOrganization } 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 { getPluginIconInMarketplace } from '../utils' +import { + adaptCreatorProfile, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, +} from './model' +import { + fetchPublisherPluginPage, + fetchPublisherTemplatePage, + getDependencyIcon, + getTemplateIcon, + toCreatorRecords, +} from './publisher' +import 'server-only' + +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 loadCreatorProfileCached = cache( + async ( + uniqueHandle: string, + publisherType: string | undefined, + locale: string, + sortField: CreatorSortField, + sortOrder: CreatorSortOrder, + ): Promise<LoadedCreatorProfile | null> => { + const [creatorResult, pluginsResult, templatesResult] = await Promise.allSettled([ + getPublisher(uniqueHandle, publisherType), + fetchPublisherPluginPage({ uniqueHandle, page: 1, sortField, sortOrder }), + fetchPublisherTemplatePage({ uniqueHandle, page: 1, sortField, sortOrder }), + ]) + + if (creatorResult.status === 'rejected') throw creatorResult.reason + const creator = creatorResult.value + if (!creator) return null + + const plugins = pluginsResult.status === 'fulfilled' ? pluginsResult.value.items : [] + const templates = templatesResult.status === 'fulfilled' ? templatesResult.value.items : [] + const pluginPage = pluginsResult.status === 'fulfilled' ? pluginsResult.value : undefined + const templatePage = templatesResult.status === 'fulfilled' ? templatesResult.value : undefined + 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, + }) + const records = toCreatorRecords({ locale, plugins, templates }) + + return { + viewModel: { + ...viewModel, + creations: sortCreatorCreations(viewModel.creations, sortField, sortOrder), + }, + pluginsByCreationId: records.pluginsByCreationId, + templatesByCreationId: records.templatesByCreationId, + inventory: { + uniqueHandle, + pluginHasMore: pluginPage?.hasMore ?? false, + templateHasMore: templatePage?.hasMore ?? false, + pluginNextPage: 2, + templateNextPage: 2, + }, + } + }, +) + +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..8ae4997db63 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx @@ -0,0 +1,137 @@ +'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 useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' +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<SelectedCreation | null>(null) + const [sourceProfile, setSourceProfile] = useState(loadedProfile) + const [pluginsByCreationId, setPluginsByCreationId] = useState(loadedProfile.pluginsByCreationId) + const [templatesByCreationId, setTemplatesByCreationId] = useState( + loadedProfile.templatesByCreationId, + ) + if (loadedProfile !== sourceProfile) { + setSourceProfile(loadedProfile) + setPluginsByCreationId(loadedProfile.pluginsByCreationId) + setTemplatesByCreationId(loadedProfile.templatesByCreationId) + } + + const profilePlugins = Object.values(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 = pluginsByCreationId[creation.id] + if (plugin) setSelected({ kind: 'plugin', plugin: normalizePlugin(plugin) }) + return + } + + const template = 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 ( + <> + <CreatorProfileView + profile={loadedProfile.viewModel} + homeHref="/marketplace" + isMarketplacePlatform + inventory={loadedProfile.inventory} + locale={locale} + onRecordsLoaded={(records) => { + setPluginsByCreationId((current) => ({ ...current, ...records.pluginsByCreationId })) + setTemplatesByCreationId((current) => ({ + ...current, + ...records.templatesByCreationId, + })) + }} + getCreationAction={(creation) => ({ + type: 'select', + onSelect: () => selectCreation(creation), + })} + header={<CreatorProfileHeader locale={locale} onSuggestionSelect={selectSearchResult} />} + /> + + {selectedPlugin && ( + <MarketplaceDetailDialog + isInstalled={Boolean(installedInfo?.[selectedPlugin.plugin_id])} + open + plugin={selectedPlugin} + onOpenChange={(open) => { + if (!open) closeSelected() + }} + /> + )} + {selectedTemplate && ( + <TemplateDetailDialog + open + template={selectedTemplate} + onInstall={() => { + closeSelected() + router.push(`/apps?template-id=${encodeURIComponent(selectedTemplate.id)}`) + }} + onOpenChange={(open) => { + if (!open) closeSelected() + }} + /> + )} + </> + ) +} 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 ( + <header className="sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-4 border-b border-divider-regular bg-background-default px-4 md:px-6"> + <div className="flex min-w-0 flex-1 items-center gap-4"> + <Link + href="/marketplace" + aria-label="Dify Marketplace" + className="flex h-full w-[141.933px] shrink-0 items-center" + > + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoLight, + )} + src={MarketplaceLogo.src} + /> + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoDark, + )} + src={MarketplaceLogoDark.src} + /> + </Link> + <div className="hidden md:block"> + <HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} /> + </div> + </div> + + <div className="hidden w-80 shrink-0 md:block"> + <MarketplaceSearchAutocomplete + locale={locale} + onSuggestionSelect={onSuggestionSelect} + onValueChange={setSearchValue} + placeholder={t(($) => $['marketplace.creatorProfile.searchPlaceholder'], { + ns: 'plugin', + })} + scope="all" + value={searchValue} + /> + </div> + + <div className="flex min-w-0 flex-1 items-center justify-end gap-2.5"> + <div className="hidden md:block"> + <HomeGuide isMarketplacePlatform={false} /> + </div> + {actions} + </div> + </header> + ) +} 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..9814dcec192 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/model.ts @@ -0,0 +1,361 @@ +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 CreatorInventory = { + uniqueHandle: string + pluginHasMore: boolean + templateHasMore: boolean + pluginNextPage: number + templateNextPage: number +} + +export type LoadedCreatorProfile = { + viewModel: CreatorProfileViewModel + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> + inventory: CreatorInventory +} + +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 firstLocalizedString = (value: object, keys: string[]) => { + for (const key of keys) { + const entry = (value as Record<string, unknown>)[key] + if (typeof entry === 'string' && entry) return entry + } + return ( + Object.values(value).find((entry): entry is string => typeof entry === 'string' && !!entry) ?? + '' + ) +} + +const getCreatorLocalizedText = ( + value: Partial<Record<string, string>> | string | undefined, + locale: string, +) => { + if (typeof value === 'string') return value + if (!value || typeof value !== 'object') return '' + + const normalizedLocale = locale.replace('-', '_') + return firstLocalizedString(value, [locale, normalizedLocale, 'en-US', 'en_US']) +} + +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: unknown): CreatorSocialLink | null => { + if (typeof value !== 'string') return 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<CreatorProfileBadge>() + if (creator.badges?.includes('partner')) badges.add('partner') + if (creator.verified || creator.badges?.includes('verified')) badges.add('verified') + return Array.from(badges) +} + +export const adaptCreations = ({ + locale, + plugins, + templates, + resolvePluginIcon, + resolveTemplateIcon, + resolveDependencyIcon, +}: Pick< + CreatorProfileAdapterInput, + | 'locale' + | 'plugins' + | 'templates' + | 'resolvePluginIcon' + | 'resolveTemplateIcon' + | 'resolveDependencyIcon' +>): CreatorCreation[] => { + 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 ?? []).filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ) + 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 [...pluginCreations, ...templateCreations] +} + +export const adaptCreatorProfile = ({ + creator, + kind, + locale, + avatarUrl, + backgroundUrl, + plugins, + templates, + resolvePluginIcon, + resolveTemplateIcon, + resolveDependencyIcon, +}: CreatorProfileAdapterInput): CreatorProfileViewModel => { + 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: adaptCreations({ + locale, + plugins, + templates, + resolvePluginIcon, + resolveTemplateIcon, + resolveDependencyIcon, + }), + } +} + +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<string | null>(null) + const shapeClass = isOrganization ? 'rounded-md' : 'rounded-full' + const shouldShowImage = Boolean(avatarUrl) && failedAvatarUrl !== avatarUrl + const fallbackLetter = name?.[0]?.toUpperCase() || 'U' + + return ( + <div + style={{ width: size, height: size }} + className={cn( + 'relative shrink-0 overflow-hidden border-[0.5px] border-divider-regular', + shapeClass, + className, + )} + > + {shouldShowImage ? ( + <img + src={avatarUrl} + alt={name} + className={cn('size-full object-cover', shapeClass)} + onError={() => setFailedAvatarUrl(avatarUrl)} + /> + ) : ( + <div + className={cn('flex size-full items-center justify-center', shapeClass)} + style={{ background: DEFAULT_AVATAR_BG }} + > + <span + className={cn(getFallbackTextClass(size), 'font-semibold')} + style={DEFAULT_AVATAR_LETTER_STYLE} + > + {fallbackLetter} + </span> + </div> + )} + </div> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/publisher.ts b/web/app/components/plugins/marketplace/creator-profile/publisher.ts new file mode 100644 index 00000000000..624a30c2234 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/publisher.ts @@ -0,0 +1,102 @@ +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { CreatorCreation, CreatorSortField, CreatorSortOrder } from './model' +import type { Plugin } from '@/app/components/plugins/types' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { marketplaceClient } from '@/service/client' +import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils' +import { adaptCreations, toPublisherSortQuery } from './model' + +const CREATOR_PAGE_SIZE = 40 + +export type PublisherPage<T> = { + items: T[] + total?: number + hasMore: boolean +} + +const publisherPageHasMore = (page: number, itemCount: number, total?: number) => + typeof total === 'number' ? page * CREATOR_PAGE_SIZE < total : itemCount === CREATOR_PAGE_SIZE + +export const getTemplateIcon = (template: MarketplaceTemplate) => + template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon` + : '' + +export const getDependencyIcon = (pluginId: string) => { + if (!pluginId.includes('/')) return '' + return `${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon` +} + +export async function fetchPublisherPluginPage({ + uniqueHandle, + page, + sortField, + sortOrder, +}: { + uniqueHandle: string + page: number + sortField: CreatorSortField + sortOrder: CreatorSortOrder +}): Promise<PublisherPage<MarketplacePlugin>> { + const { plugins } = toPublisherSortQuery(sortField, sortOrder) + const response = await marketplaceClient.publisherPlugins({ + params: { uniqueHandle }, + query: { page, page_size: CREATOR_PAGE_SIZE, ...plugins }, + }) + const items = response.data?.plugins ?? [] + const total = response.data?.total + return { items, total, hasMore: publisherPageHasMore(page, items.length, total) } +} + +export async function fetchPublisherTemplatePage({ + uniqueHandle, + page, + sortField, + sortOrder, +}: { + uniqueHandle: string + page: number + sortField: CreatorSortField + sortOrder: CreatorSortOrder +}): Promise<PublisherPage<MarketplaceTemplate>> { + const { templates } = toPublisherSortQuery(sortField, sortOrder) + const response = await marketplaceClient.publisherTemplates({ + params: { uniqueHandle }, + query: { page, page_size: CREATOR_PAGE_SIZE, ...templates }, + }) + const items = response.data?.templates ?? [] + const total = response.data?.total + return { items, total, hasMore: publisherPageHasMore(page, items.length, total) } +} + +export const toCreatorRecords = ({ + locale, + plugins, + templates, +}: { + locale: string + plugins: MarketplacePlugin[] + templates: MarketplaceTemplate[] +}): { + creations: CreatorCreation[] + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> +} => ({ + creations: adaptCreations({ + locale, + plugins, + templates, + resolvePluginIcon: getPluginIconInMarketplace, + resolveTemplateIcon: getTemplateIcon, + resolveDependencyIcon: getDependencyIcon, + }), + pluginsByCreationId: Object.fromEntries( + plugins.map((plugin) => [ + `${plugin.type}:${plugin.org}/${plugin.name}`, + getFormattedPlugin(plugin), + ]), + ), + templatesByCreationId: Object.fromEntries( + templates.map((template) => [`template:${template.id}`, template]), + ), +}) 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..fec3ac7e8d8 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/view.tsx @@ -0,0 +1,110 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { ReactNode } from 'react' +import type { + CreatorCreation, + CreatorCreationAction, + CreatorInventory, + CreatorProfileViewModel, +} from './model' +import type { Plugin } from '@/app/components/plugins/types' +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 + inventory?: CreatorInventory + locale?: string + onRecordsLoaded?: (records: { + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> + }) => void +} + +export default function CreatorProfileView({ + profile, + getCreationAction, + header, + homeHref, + isMarketplacePlatform, + inventory, + locale, + onRecordsLoaded, +}: CreatorProfileViewProps) { + const { t } = useTranslation() + + return ( + <div className="flex min-h-full shrink-0 flex-col bg-background-default"> + {header} + <main + className={cn( + 'flex w-full flex-1 flex-col px-4', + isMarketplacePlatform ? 'md:px-6' : 'md:px-9', + )} + > + <nav + aria-label={t(($) => $['marketplace.creatorProfile.breadcrumbLabel'], { ns: 'plugin' })} + className="flex h-12 shrink-0 items-end gap-2 overflow-hidden" + > + <Link + href={homeHref} + aria-label={t(($) => $['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" + > + <span aria-hidden className="i-ri-home-4-line size-4" /> + </Link> + <span aria-hidden className="pb-0.5 system-md-regular text-text-quaternary"> + / + </span> + <span className="pb-0.5 system-md-regular text-text-primary"> + {t(($) => $['marketplace.creatorProfile.title'], { ns: 'plugin' })} + </span> + </nav> + + <div className="w-full pt-5 pb-8"> + <div + className="relative h-40 w-full overflow-hidden rounded-xl border-0 bg-cover bg-center bg-no-repeat md:h-60" + style={{ backgroundImage: `url("${DefaultCreatorBackground.src}")` }} + > + {profile.profile.backgroundUrl && ( + <img + alt="" + aria-hidden + src={profile.profile.backgroundUrl} + className="size-full border-0 object-cover object-center" + onError={(event) => { + event.currentTarget.hidden = true + }} + /> + )} + </div> + + <div + className={cn( + 'grid min-w-0 grid-cols-1 gap-8 md:grid-cols-[234px_minmax(0,1fr)]', + isMarketplacePlatform ? 'md:pl-4' : 'md:pl-9', + )} + > + <CreatorSidebar profile={profile.profile} /> + <CreatorContent + creations={profile.creations} + getCreationAction={getCreationAction} + inventory={inventory} + locale={locale} + onRecordsLoaded={onRecordsLoaded} + /> + </div> + </div> + </main> + </div> + ) +} 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..47f2727ac08 --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx @@ -0,0 +1,251 @@ +import type { Plugin } from '@/app/components/plugins/types' +import { fireEvent, render, screen, waitFor } 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 { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import MarketplaceDetailDialog from '../index' + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), +})) + +vi.mock('../../utils', () => ({ + getPluginLinkInMarketplace: ( + plugin: Plugin, + params: { + canInstall?: string + 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}&canInstall=${params.canInstall}`, +})) + +vi.mock('../use-silent-install', () => ({ + useSilentMarketplaceInstall: () => ({ install: mocks.install }), +})) + +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', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.install.mockResolvedValue({ status: 'success' }) + }) + + it('renders the marketplace detail route in modal mode and closes in place', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog open isInstalled plugin={plugin} onOpenChange={onOpenChange} /> + </ThemeProvider>, + ) + + 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&canInstall=true', + ) + expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('installs from the embedded detail frame without opening a confirmation dialog', async () => { + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog open isInstalled={false} plugin={plugin} onOpenChange={vi.fn()} /> + </ThemeProvider>, + ) + + const frame = screen.getByTitle( + 'Plugin A · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage') + 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(mocks.install).not.toHaveBeenCalled() + + fireEvent( + window, + new MessageEvent('message', { + data: installRequest, + origin: 'null', + source: frame.contentWindow, + }), + ) + + expect(mocks.install).toHaveBeenCalledOnce() + expect(mocks.install).toHaveBeenCalledWith(plugin) + expect(screen.queryByRole('dialog', { name: 'plugin.installModal.installPlugin' })).toBeNull() + + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith( + { + type: 'dify-marketplace:install-plugin-status', + pluginUniqueIdentifier: plugin.latest_package_identifier, + status: 'success', + }, + 'null', + ) + }) + }) + + it('does not install when the workspace lacks plugin.install', async () => { + render( + <PluginInstallPermissionProvider canInstallPlugin={false}> + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog + open + isInstalled={false} + plugin={plugin} + onOpenChange={vi.fn()} + /> + </ThemeProvider> + </PluginInstallPermissionProvider>, + ) + + const frame = screen.getByTitle( + 'Plugin A · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + expect(frame).toHaveAttribute( + 'src', + 'about:blank?plugin=dify/plugin-a&installed=false&language=en-US&source=http://localhost:3000&theme=light&view=modal&canInstall=false', + ) + const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage') + fireEvent( + window, + new MessageEvent('message', { + data: { + type: 'dify-marketplace:install-plugin', + pluginUniqueIdentifier: plugin.latest_package_identifier, + }, + origin: 'null', + source: frame.contentWindow, + }), + ) + + expect(mocks.install).not.toHaveBeenCalled() + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith( + { + type: 'dify-marketplace:install-plugin-status', + pluginUniqueIdentifier: plugin.latest_package_identifier, + status: 'failed', + }, + 'null', + ) + }) + }) + + it('ignores a late install result after the timeout has already settled', async () => { + vi.useFakeTimers() + let finishInstall: ((result: { status: 'success' }) => void) | undefined + mocks.install.mockImplementation( + () => + new Promise((resolve) => { + finishInstall = resolve + }), + ) + + try { + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog + open + isInstalled={false} + plugin={plugin} + onOpenChange={vi.fn()} + /> + </ThemeProvider>, + ) + + const frame = screen.getByTitle( + 'Plugin A · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage') + fireEvent( + window, + new MessageEvent('message', { + data: { + type: 'dify-marketplace:install-plugin', + pluginUniqueIdentifier: plugin.latest_package_identifier, + }, + origin: 'null', + source: frame.contentWindow, + }), + ) + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + expect(postMessage).toHaveBeenCalledWith( + { + type: 'dify-marketplace:install-plugin-status', + pluginUniqueIdentifier: plugin.latest_package_identifier, + status: 'timeout', + }, + 'null', + ) + + finishInstall?.({ status: 'success' }) + await Promise.resolve() + await vi.runAllTimersAsync() + + expect(postMessage).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts b/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts new file mode 100644 index 00000000000..b3bcfb00a8a --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts @@ -0,0 +1,110 @@ +import type { Plugin } from '@/app/components/plugins/types' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types' +import { useSilentMarketplaceInstall } from '../use-silent-install' + +const mockInstallPackageFromMarketPlace = vi.fn() +const mockRefreshPluginList = vi.fn() +const mockCheckTaskStatus = vi.fn() + +vi.mock('@/service/use-plugins', () => ({ + useInstallPackageFromMarketPlace: () => ({ + mutateAsync: mockInstallPackageFromMarketPlace, + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: mockRefreshPluginList }), +})) + +vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({ + default: () => ({ + check: mockCheckTaskStatus, + stop: vi.fn(), + }), +})) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'plugin-a', + plugin_id: 'dify/plugin-a', + latest_package_identifier: 'dify/plugin-a:1.0.0@pkg', + category: PluginCategoryEnum.tool, +} as Plugin + +describe('useSilentMarketplaceInstall', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: true, + task_id: 'task-1', + }) + mockCheckTaskStatus.mockResolvedValue({ status: TaskStatus.success }) + }) + + it('installs immediately when the marketplace package is already fully installed', async () => { + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + await expect(result.current.install(plugin)).resolves.toEqual({ status: 'success' }) + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith(plugin.latest_package_identifier) + expect(mockCheckTaskStatus).not.toHaveBeenCalled() + expect(mockRefreshPluginList).toHaveBeenCalledWith(plugin) + }) + + it('waits for the install task instead of showing a confirmation step', async () => { + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: false, + task_id: 'task-2', + }) + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + await expect(result.current.install(plugin)).resolves.toEqual({ status: 'success' }) + expect(mockCheckTaskStatus).toHaveBeenCalledWith({ + taskId: 'task-2', + pluginUniqueIdentifier: plugin.latest_package_identifier, + }) + expect(mockRefreshPluginList).toHaveBeenCalledWith(plugin) + }) + + it('returns the task error when installation fails', async () => { + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: false, + task_id: 'task-3', + }) + mockCheckTaskStatus.mockResolvedValue({ + status: TaskStatus.failed, + error: 'Package not found', + }) + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + await expect(result.current.install(plugin)).resolves.toEqual({ + status: 'failed', + error: 'Package not found', + }) + expect(mockRefreshPluginList).not.toHaveBeenCalled() + }) + + it('reuses an in-flight install instead of starting a second package request', async () => { + let resolveInstall: ((value: { all_installed: boolean; task_id: string }) => void) | undefined + mockInstallPackageFromMarketPlace.mockImplementation( + () => + new Promise((resolve) => { + resolveInstall = resolve + }), + ) + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + const first = result.current.install(plugin) + const second = result.current.install(plugin) + + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveInstall?.({ all_installed: true, task_id: 'task-4' }) + await expect(first).resolves.toEqual({ status: 'success' }) + await expect(second).resolves.toEqual({ status: 'success' }) + }) + }) +}) 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..27969d2ac78 --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx @@ -0,0 +1,137 @@ +'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 ReplyToMarketplaceFrame = (data: unknown) => void + +type MarketplaceDetailDialogFrameProps = { + open: boolean + src: string + title: string + onMessage?: (data: unknown, reply: ReplyToMarketplaceFrame) => 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<HTMLIFrameElement>(null) + const closeButtonRef = useRef<HTMLButtonElement>(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, (payload) => { + iframeRef.current?.contentWindow?.postMessage(payload, marketplaceOrigin) + }) + } + + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [onMessage, open, src]) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setIsLoading(true) + onOpenChange(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogPortal> + <DialogBackdrop /> + {/* 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. */} + <DialogPopup + initialFocus={closeButtonRef} + className="fixed top-1/2 left-1/2 h-[min(800px,calc(100dvh-48px))] w-[min(1200px,calc(100vw-48px))] -translate-x-1/2 -translate-y-1/2 overflow-hidden border-0 p-0 shadow-xl" + > + <DialogTitle className="sr-only">{title}</DialogTitle> + <div + aria-hidden + className={cn( + 'absolute inset-0 bg-background-default transition-opacity', + isLoading ? 'opacity-100' : 'pointer-events-none opacity-0', + )} + > + <div className="flex h-[52px] items-center px-6"> + <div className="h-4 w-40 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + </div> + <div className="mx-auto flex w-full max-w-[1000px] gap-8 px-12 py-8"> + <div className="flex flex-1 flex-col gap-4"> + <div className="h-16 w-2/3 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-4 w-full animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-4 w-5/6 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + <div className="mt-8 h-72 w-full animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + </div> + <div className="hidden w-60 flex-col gap-4 lg:flex"> + <div className="h-24 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-52 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + </div> + </div> + </div> + <iframe + ref={iframeRef} + // While loading, remove the invisible frame from focus, pointer, + // and accessibility interaction until its content is presentable. + inert={isLoading} + className={cn( + 'size-full border-0 bg-background-default transition-opacity', + isLoading ? 'pointer-events-none opacity-0' : 'opacity-100', + )} + onLoad={() => setIsLoading(false)} + referrerPolicy="strict-origin-when-cross-origin" + src={src} + title={title} + /> + <DialogClose + render={ + <IconButton + ref={closeButtonRef} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + size="sm" + className="absolute top-5 right-5 z-10 size-8 rounded-lg" + > + <span aria-hidden className="i-ri-close-line size-4" /> + </IconButton> + } + /> + </DialogPopup> + </DialogPortal> + </Dialog> + ) +} 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..b491f5660cd --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/index.tsx @@ -0,0 +1,161 @@ +'use client' + +import type { Plugin } from '@/app/components/plugins/types' +import { useTheme } from 'next-themes' +import { useCallback, useEffect, useRef } from 'react' +import { useLocale, useTranslation } from '#i18n' +import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission' +import { getPluginLinkInMarketplace } from '../utils' +import MarketplaceDetailDialogFrame from './frame' +import { useSilentMarketplaceInstall } from './use-silent-install' + +const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-plugin' +const MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE = 'dify-marketplace:install-plugin-status' +const SILENT_INSTALL_TIMEOUT_MS = 5 * 60 * 1000 + +type MarketplaceDetailDialogProps = { + isInstalled: boolean + open: boolean + plugin: Plugin + onOpenChange: (open: boolean) => void +} + +const isInstallRequest = (data: unknown, pluginUniqueIdentifier: string) => { + return ( + typeof data === 'object' && + data !== null && + 'type' in data && + 'pluginUniqueIdentifier' in data && + data.type === MARKETPLACE_INSTALL_MESSAGE_TYPE && + data.pluginUniqueIdentifier === pluginUniqueIdentifier + ) +} + +function OpenMarketplaceDetailDialog({ + canInstallPlugin, + onOpenChange, + plugin, + src, + title, +}: { + canInstallPlugin: boolean + onOpenChange: (open: boolean) => void + plugin: Plugin + src: string + title: string +}) { + const { install } = useSilentMarketplaceInstall() + const timeoutIdsRef = useRef(new Set<number>()) + + useEffect( + () => () => { + timeoutIdsRef.current.forEach((id) => window.clearTimeout(id)) + timeoutIdsRef.current.clear() + }, + [], + ) + + const handleMessage = useCallback( + (data: unknown, reply: (payload: unknown) => void) => { + if (!isInstallRequest(data, plugin.latest_package_identifier)) return + + const uniqueIdentifier = plugin.latest_package_identifier + if (!canInstallPlugin) { + reply({ + type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE, + pluginUniqueIdentifier: uniqueIdentifier, + status: 'failed', + }) + return + } + + let settled = false + const settle = (payload: Record<string, unknown>) => { + if (settled) return + settled = true + reply(payload) + } + + const timeoutId = window.setTimeout(() => { + timeoutIdsRef.current.delete(timeoutId) + settle({ + type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE, + pluginUniqueIdentifier: uniqueIdentifier, + status: 'timeout', + }) + }, SILENT_INSTALL_TIMEOUT_MS) + timeoutIdsRef.current.add(timeoutId) + + void install(plugin).then((result) => { + window.clearTimeout(timeoutId) + timeoutIdsRef.current.delete(timeoutId) + settle({ + type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE, + pluginUniqueIdentifier: uniqueIdentifier, + ...result, + }) + }) + }, + [canInstallPlugin, install, plugin], + ) + + return ( + <MarketplaceDetailDialogFrame + open + src={src} + title={title} + onMessage={handleMessage} + onOpenChange={onOpenChange} + /> + ) +} + +function MarketplaceDetailDialog({ + isInstalled, + open, + plugin, + onOpenChange, +}: MarketplaceDetailDialogProps) { + const { t } = useTranslation() + const locale = useLocale() + const { canInstallPlugin } = useOptionalPluginInstallPermission() + // 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 installedForSrcRef = useRef(isInstalled) + if (!open) installedForSrcRef.current = isInstalled + const detailURL = getPluginLinkInMarketplace(plugin, { + canInstall: String(canInstallPlugin), + installed: String(installedForSrcRef.current), + language: locale, + source: globalThis.location?.origin, + theme: resolvedTheme, + view: 'modal', + }) + const title = `${pluginLabel} · ${detailLabel}` + + if (!open) { + return ( + <MarketplaceDetailDialogFrame + open={false} + src={detailURL} + title={title} + onOpenChange={onOpenChange} + /> + ) + } + + return ( + <OpenMarketplaceDetailDialog + canInstallPlugin={canInstallPlugin} + plugin={plugin} + src={detailURL} + title={title} + onOpenChange={onOpenChange} + /> + ) +} + +export default MarketplaceDetailDialog diff --git a/web/app/components/plugins/marketplace/detail-dialog/use-silent-install.ts b/web/app/components/plugins/marketplace/detail-dialog/use-silent-install.ts new file mode 100644 index 00000000000..9cbfa9a0a8c --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/use-silent-install.ts @@ -0,0 +1,64 @@ +'use client' + +import type { Plugin } from '@/app/components/plugins/types' +import { useCallback } from 'react' +import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' +import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' +import { TaskStatus } from '@/app/components/plugins/types' +import { useInstallPackageFromMarketPlace } from '@/service/use-plugins' + +export type SilentMarketplaceInstallResult = + | { status: 'failed'; error?: string } + | { status: 'success' } + +const inFlightInstalls = new Map<string, Promise<SilentMarketplaceInstallResult>>() + +const toErrorMessage = (error: unknown) => { + if (typeof error === 'string' && error) return error + if (error instanceof Error && error.message) return error.message + return undefined +} + +export const useSilentMarketplaceInstall = () => { + const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace() + const { refreshPluginList } = useRefreshPluginList() + + const install = useCallback( + (plugin: Plugin) => { + const uniqueIdentifier = plugin.latest_package_identifier + const inFlight = inFlightInstalls.get(uniqueIdentifier) + if (inFlight) return inFlight + + const pending = (async (): Promise<SilentMarketplaceInstallResult> => { + try { + const response = await installPackageFromMarketPlace(uniqueIdentifier) + if (response.all_installed) { + refreshPluginList(plugin) + return { status: 'success' } + } + if (!response.task_id) return { status: 'failed' } + + const { check } = checkTaskStatus() + const { status, error } = await check({ + taskId: response.task_id, + pluginUniqueIdentifier: uniqueIdentifier, + }) + if (status === TaskStatus.failed) return { status: 'failed', error } + + refreshPluginList(plugin) + return { status: 'success' } + } catch (error) { + return { status: 'failed', error: toErrorMessage(error) } + } + })().finally(() => { + inFlightInstalls.delete(uniqueIdentifier) + }) + + inFlightInstalls.set(uniqueIdentifier, pending) + return pending + }, + [installPackageFromMarketPlace, refreshPluginList], + ) + + return { install } +} 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<MarketplaceViewProps, 'banners'> & { + 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 <MarketplaceView {...props} banners={banners} variant={variant} /> +} 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<typeof Link> & { + 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 ( + <Link + {...props} + onClick={(event) => { + 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(<CatalogLanguagesFilter />) + 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(<CatalogTagsFilter />) + 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__/embedded-marketplace-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx new file mode 100644 index 00000000000..ba7d2be04dc --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx @@ -0,0 +1,243 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { screen, waitFor, within } 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 EmbeddedMarketplaceSearch from '../embedded-marketplace-search' + +const { debounceState, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ + debounceState: { useRealDebounce: false }, + mockPluginSearch: vi.fn(), + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T, options?: { wait?: number }) => + debounceState.useRealDebounce ? original.useDebounce(value, options) : value, + } +}) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.home.searchPlaceholder': 'Search plugins or templates', + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.loadError': 'Failed to load. Please try again.', + 'marketplace.noPluginFound': 'No integration found', + 'newApp.noTemplateFound': 'No templates found', + clearSearch: 'Clear search', + loading: 'Loading', + } + + return { + useLocale: () => 'en-US', + useTranslation: () => ({ + t: withSelectorKey((key: string) => translations[key] ?? key), + }), + } +}) + +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), + }), + }, + }, +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: () => ({ installedInfo: {} }), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('../../detail-dialog', () => ({ + default: ({ plugin }: { plugin: { name: string } }) => ( + <div role="dialog" aria-label="plugin-detail"> + {plugin.name} + </div> + ), +})) + +vi.mock('../../templates/template-detail-dialog', () => ({ + default: ({ template }: { template: { template_name: string } }) => ( + <div role="dialog" aria-label="template-detail"> + {template.template_name} + </div> + ), +})) + +let queryClient: QueryClient + +const renderSearch = () => { + const { onUrlUpdate } = renderWithNuqs( + <QueryClientProvider client={queryClient}> + <EmbeddedMarketplaceSearch /> + </QueryClientProvider>, + ) + + return { onUrlUpdate } +} + +describe('EmbeddedMarketplaceSearch', () => { + 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 mixed plugin and template suggestions in the in-app search 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 { onUrlUpdate } = renderSearch() + + 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() + expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument() + expect(onUrlUpdate).not.toHaveBeenCalled() + }) + + it('opens plugin and template details from the popup without filtering the catalog', 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', + plugin_id: 'langgenius/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 { onUrlUpdate } = renderSearch() + + await user.type(screen.getByRole('combobox'), 'search') + await user.click(await screen.findByText('Google Search')) + + expect(screen.getByRole('dialog', { name: 'plugin-detail' })).toHaveTextContent('google-search') + expect(onUrlUpdate).not.toHaveBeenCalled() + + await user.type(screen.getByRole('combobox'), 'search') + await user.click(await screen.findByText('Legal Research Agent')) + + expect(screen.getByRole('dialog', { name: 'template-detail' })).toHaveTextContent( + 'Legal Research Agent', + ) + expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument() + }) + + it('filters the current catalog when Enter is pressed instead of opening a result', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + plugin_id: 'langgenius/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 { onUrlUpdate } = renderSearch() + + await user.type(screen.getByRole('combobox'), 'google') + await user.hover(await screen.findByRole('option', { name: /Google Search/ })) + await user.keyboard('{Enter}') + + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google') + }) + expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument() + }) +}) 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( + <HomeStickyStateProvider> + <div className="w-[1200px]" data-marketplace-standalone> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={ + <div data-testid="catalog-filter" role="group" aria-label="Categories" /> + } + catalogTabs={ + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + } + /> + <div className={`px-8 ${styles.catalogContent}`}> + <div role="region" aria-label="Catalog content" className="h-10" /> + </div> + </div> + </HomeStickyStateProvider>, + ) + + 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( + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <div + data-testid="catalog-header" + style={{ + position: 'sticky', + top: 0, + zIndex: 50, + display: 'flex', + height: 48, + flexShrink: 0, + alignItems: 'center', + background: 'white', + }} + > + <HomeStickyCatalogTabs> + <div className={styles.headerCatalogTabs} data-testid="header-catalog-tabs"> + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + </div> + </HomeStickyCatalogTabs> + </div> + <div style={{ height: 220, flexShrink: 0 }} /> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div data-testid="catalog-categories">Categories</div>} + catalogTabs={ + <div data-testid="content-catalog-tabs"> + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + </div> + } + /> + <div data-testid="following-content" style={{ height: 640, flexShrink: 0 }} /> + </div> + </HomeStickyStateProvider>, + ) + + 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<HTMLAnchorElement>('a[href="/plugins"]')! + const headerPluginsLink = headerTabsSlot.querySelector<HTMLAnchorElement>('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<void>((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( + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <div style={{ display: 'flex', height: 48, flexShrink: 0 }}> + <HomeStickyCatalogTabs> + <div className={styles.headerCatalogTabs} data-testid="mobile-header-tabs"> + Header tabs + </div> + </HomeStickyCatalogTabs> + </div> + <div style={{ height: 220, flexShrink: 0 }} /> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div>Categories</div>} + catalogTabs={<div data-testid="mobile-content-tabs">Content tabs</div>} + /> + <div style={{ height: 640, flexShrink: 0 }} /> + </div> + </HomeStickyStateProvider>, + ) + + 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 }) => ( + <div data-testid="plugin-type-switch" className={className} data-variant={variant} /> + ), +})) + +afterEach(() => { + document.querySelectorAll('#marketplace-container').forEach((element) => element.remove()) +}) + +describe('HomeCatalogNavigation', () => { + const renderNavigation = (isMarketplacePlatform: boolean) => { + return render( + <HomeStickyStateProvider> + <HomeStickyCatalogTabs> + <div data-testid="header-catalog-tabs" /> + </HomeStickyCatalogTabs> + <HomeCatalogNavigation + isMarketplacePlatform={isMarketplacePlatform} + catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />} + /> + </HomeStickyStateProvider>, + ) + } + + 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(<HomeCatalogTabs isMarketplacePlatform />) + + 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(<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />) + + 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(<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />) + + 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( + <HomeCatalogTabs + isMarketplacePlatform + labels={{ + plugins: '插件', + templates: '模板', + }} + language="zh-Hans" + />, + ) + + 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( + <HomeStickyStateProvider> + <HomeCatalogNavigation + isMarketplacePlatform + catalogTabs={<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />} + catalogCategories={<nav aria-label="Template categories">Template categories</nav>} + /> + </HomeStickyStateProvider>, + ) + + 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( + <HomeStickyStateProvider> + <HomeCatalogNavigation + isMarketplacePlatform + catalogTabs={<HomeCatalogTabs isMarketplacePlatform />} + catalogLeading={<div>Tags</div>} + catalogTrailing={<div>Languages</div>} + catalogCategories={<nav aria-label="Plugin categories">Categories</nav>} + /> + </HomeStickyStateProvider>, + ) + // 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<HTMLElement>( + '[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<HTMLElement>( + '[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<HTMLElement>( + '[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..c86382c4464 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx @@ -0,0 +1,129 @@ +import { TooltipProvider } from '@langgenius/dify-ui/tooltip' +import { render, screen, waitFor, 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 GUIDE_BUTTON_NAME = /marketplace\.home\.guide/ + +const renderGuide = (isMarketplacePlatform: boolean) => + render( + <TooltipProvider delay={0} closeDelay={0}> + <HomeGuide isMarketplacePlatform={isMarketplacePlatform} /> + </TooltipProvider>, + ) + +const openGuideMenu = async (isMarketplacePlatform: boolean) => { + const user = userEvent.setup() + renderGuide(isMarketplacePlatform) + + expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: GUIDE_BUTTON_NAME })) + 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() + }) + + it('labels the in-app Guide icon and shows a matching tooltip on hover and focus', async () => { + const user = userEvent.setup() + renderGuide(false) + + const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME }) + expect(trigger).toHaveAccessibleName(/marketplace\.home\.guide/) + + await user.hover(trigger) + expect(await screen.findByRole('tooltip')).toHaveTextContent(/marketplace\.home\.guide/) + + await user.unhover(trigger) + await waitFor(() => { + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) + + await user.tab() + expect(trigger).toHaveFocus() + expect(await screen.findByRole('tooltip')).toHaveTextContent(/marketplace\.home\.guide/) + }) + + it('keeps the Guide dropdown available after the tooltip is shown', async () => { + const user = userEvent.setup() + renderGuide(false) + + const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME }) + await user.hover(trigger) + expect(await screen.findByRole('tooltip')).toBeInTheDocument() + + await user.click(trigger) + expect(await screen.findByRole('menu')).toBeInTheDocument() + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) +}) 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..f7c3f4182cd --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx @@ -0,0 +1,155 @@ +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', + useDocLink: () => (path?: string) => `https://docs.dify.ai/console${path || ''}`, +})) + +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('keeps Creator Center and docs in the in-app header without an account action', () => { + render(<HomeHeader isMarketplacePlatform={false} />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /marketplace\.home\.guide/ })).toBeInTheDocument() + expect(screen.queryByTestId('account-section')).not.toBeInTheDocument() + }) + + it('shows Creator Center before the docs dropdown', () => { + render(<HomeHeader isMarketplacePlatform />) + + const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' }) + const guideButton = screen.getByRole('button', { name: /marketplace\.home\.guide/ }) + + 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(<HomeHeader isMarketplacePlatform />) + + 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(<HomeHeader isMarketplacePlatform />) + + 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(<HomeHeader isMarketplacePlatform />) + + 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(<HomeHeader isMarketplacePlatform />) + + 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( + <HomeHeader + activeTab={null} + catalogLabels={{ + plugins: 'Plugins', + templates: 'Templates', + }} + isMarketplacePlatform + />, + ) + + 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( + <HomeHeader + activeTab="templates" + catalogLabels={{ + plugins: '插件', + templates: '模板', + }} + isMarketplacePlatform + language="zh-Hans" + />, + ) + + 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( + <HomeHero + isMarketplacePlatform + title="Discover templates" + subtitle="Start faster with ready-to-use workflows." + />, + ) + + 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(<HomeHero isMarketplacePlatform />) + + 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(<HomeHero isMarketplacePlatform />) + const icons = [...container.querySelectorAll<HTMLElement>('[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<void>((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( + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <HomeShell + banners={[]} + header={ + <HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform /> + } + hero={<div aria-hidden style={{ height: 180, flexShrink: 0 }} />} + isMarketplacePlatform + navigation={<div aria-hidden style={{ height: 80, flexShrink: 0 }} />} + page="plugins" + search={ + <HomeSearch enableSearchShortcut={false}> + <input + aria-label="Search plugins or templates" + style={{ display: 'block', height: 36, width: '100%' }} + /> + </HomeSearch> + } + > + <div aria-hidden style={{ height: 640, flexShrink: 0 }} /> + </HomeShell> + </div>, + ) + +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( + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <HomeShell + banners={[]} + header={ + <HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform /> + } + hero={null} + isMarketplacePlatform + navigation={null} + page="plugins" + search={ + <HomeSearch enableSearchShortcut={false} overlapHero={false}> + <input + aria-label="Search plugins or templates" + style={{ display: 'block', height: 36, width: '100%' }} + /> + </HomeSearch> + } + > + <div aria-hidden style={{ height: 640, flexShrink: 0 }} /> + </HomeShell> + </div>, + ) + + 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..dee188a39ed --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx @@ -0,0 +1,268 @@ +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( + <div data-marketplace-standalone className="w-[560px]"> + <div data-testid="blog-banner"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div> + </div>, + ) + + const blogSlide = document.querySelector<HTMLElement>('[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( + <div data-marketplace-standalone className="w-[360px]"> + <HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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( + <div className="w-[1100px]"> + <HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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( + <div data-marketplace-standalone className="w-[360px]"> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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( + <div data-marketplace-standalone className="w-[960px]"> + <HomeBannerSlide banner={adBanner} isMarketplacePlatform page="plugins" /> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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( + <div className="w-[900px]"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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( + <div data-marketplace-standalone className="w-[900px]"> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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( + <div className="w-[600px]"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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('wraps from the last banner back to the first visible slide', async () => { + const screen = await render( + <HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />, + ) + + await screen.getByRole('button', { name: 'Third banner' }).click() + expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute( + 'aria-current', + 'true', + ) + + await expect + .poll( + () => + screen + .getByRole('button', { name: 'First banner' }) + .element() + .getAttribute('aria-current'), + { timeout: 8000 }, + ) + .toBe('true') + + expect(screen.getByRole('group', { name: 'First banner' }).element()).not.toHaveAttribute( + 'inert', + ) + }) +}) 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<PointerEventInit, 'clientX' | 'clientY' | 'pointerId'>, +) => + 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( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + + 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<HTMLAnchorElement>('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( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + const firstSlide = screen.getByRole('group', { name: 'First banner' }).element() + const firstLink = firstSlide.querySelector<HTMLAnchorElement>('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( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + 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( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + 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..6500bab8f73 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -0,0 +1,911 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { act, fireEvent, render, screen, waitFor, 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: () => <span data-testid="partner-badge" />, +})) + +vi.mock('@/app/components/plugins/base/badges/verified', () => ({ + default: () => <span data-testid="verified-badge" />, +})) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/config')>()), + 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(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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(<HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />) + + 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(<HomeTrending banners={[eventWithoutMobile]} isMarketplacePlatform page="plugins" />) + + 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(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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<typeof vi.fn> + onfinish: (() => void) | null + pause: ReturnType<typeof vi.fn> + play: ReturnType<typeof vi.fn> + }> = [] + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => { + const animation = { + cancel: vi.fn(), + finished: Promise.resolve(), + onfinish: null, + pause: vi.fn(), + play: vi.fn(), + } + animations.push(animation) + return animation as unknown as Animation + }), + }) + + try { + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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('does not reject when the autoplay animation is canceled on unmount', async () => { + let rejectFinished: (reason: unknown) => void = () => {} + const finished = new Promise<Animation>((_resolve, reject) => { + rejectFinished = reject + }) + const progressAnimation = { + cancel: vi.fn(() => { + rejectFinished( + Object.assign(new Error('The animation was canceled.'), { name: 'AbortError' }), + ) + }), + onfinish: null, + pause: vi.fn(), + play: vi.fn(), + finished, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + + try { + const { unmount } = render( + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" />, + ) + + unmount() + await act(async () => { + await Promise.resolve() + }) + + expect(progressAnimation.cancel).toHaveBeenCalled() + } 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(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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, + finished: Promise.resolve(), + 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( + <HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />, + { + 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(), + finished: Promise.resolve(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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(), + finished: Promise.resolve(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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(), + finished: Promise.resolve(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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( + <HomeTrending + banners={[bannerWithMixedLinks]} + isMarketplacePlatform={false} + page="plugins" + />, + ) + + 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('clamps the active slide when a refetch shrinks the banner list', async () => { + const { rerender } = render( + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute( + 'aria-current', + 'true', + ) + + rerender(<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />) + + await waitFor(() => { + expect(screen.getByRole('group', { name: 'Trending' })).not.toHaveAttribute('inert') + }) + expect(screen.queryByRole('button', { name: 'Duck Duck Go' })).not.toBeInTheDocument() + }) + + it('renders no carousel when the API returns no banners', () => { + render(<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />) + + 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(<HomeTrending banners={banners} isMarketplacePlatform page="templates" />) + + 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()) + expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( + 'marketplace_banner_click', + expect.objectContaining({ + click_target: 'recommendation', + item_id: 'langgenius/dropbox', + item_type: 'plugin', + item_name: 'Dropbox', + }), + ) + }) + + it('tracks whole-slide blog and event links as frame clicks', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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(<HomeTrending banners={[unsafeBlog]} isMarketplacePlatform page="plugins" />) + + 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(<HomeTrending banners={[blogBanner]} isMarketplacePlatform page="plugins" />) + + 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<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(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( + <MarketplaceLiveSearch + action="/templates/knowledge" + language="en-US" + placeholder="Search templates" + query="" + />, + ) + + 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( + <MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />, + ) + + 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( + <MarketplaceLiveSearch + action="/templates/knowledge" + placeholder="Search templates" + query="" + preserveParams={{ languages: ['ja'] }} + />, + ) + + 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-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..d0b047063b9 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx @@ -0,0 +1,306 @@ +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<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T) => value, + } +}) + +vi.mock('react-i18next', async (importOriginal) => { + const original = await importOriginal<typeof import('react-i18next')>() + 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', + 'newApp.noTemplateFound': 'No templates found', + }), + } +}) + +vi.mock('@/service/client', async (importOriginal) => { + const original = await importOriginal<typeof import('@/service/client')>() + + 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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +function StickyTemplateSearch() { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search templates" + scope="templates" + value={value} + /> + ) +} + +function PinnedHeaderState() { + const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom) + + return ( + <header className="sticky top-0 z-50 flex h-12 items-center bg-background-default"> + <span>Dify Marketplace</span> + {isCatalogPinned && ( + <div role="tablist" aria-label="Header catalog tabs"> + Plugins and templates + </div> + )} + </header> + ) +} + +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( + <Wrapper> + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + data-testid="marketplace-scroll-container" + style={{ height: 360, width: 1200, overflowY: 'auto' }} + > + <PinnedHeaderState /> + <div style={{ height: 180 }} aria-hidden /> + <HomeSearch enableSearchShortcut={false}> + <StickyTemplateSearch /> + </HomeSearch> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div role="group" aria-label="Template categories" />} + catalogTabs={<div role="tablist" aria-label="Catalog tabs" />} + /> + <main aria-label="Template catalog" style={{ height: 900 }} /> + </div> + </HomeStickyStateProvider> + </Wrapper>, + ) + + 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( + <Wrapper> + <div className="w-[420px]"> + <StickyTemplateSearch /> + </div> + </Wrapper>, + ) + + await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal') + await expect.element(screen.getByText('Legal Research Agent')).toBeVisible() + + const input = screen.getByRole('combobox', { name: 'Search templates' }).element() + const searchBox = input.parentElement! + 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( + Math.abs(panel.getBoundingClientRect().width - searchBox.getBoundingClientRect().width), + ).toBeLessThanOrEqual(16) + 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( + <Wrapper> + <div className="w-full px-4"> + <StickyTemplateSearch /> + </div> + </Wrapper>, + ) + + 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<HTMLElement>('[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..6c2a020a9a5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -0,0 +1,710 @@ +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 { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} from '../marketplace-search-autocomplete' + +const { debounceState, mockAssign, 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 }, + mockAssign: vi.fn(), + mockPluginSearch: vi.fn(), + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(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', + '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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +describe('MarketplaceSearchAutocomplete', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAssign.mockReset() + vi.spyOn(window.location, 'assign').mockImplementation(mockAssign) + debounceState.useRealDebounce = false + queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, + }) + mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } }) + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + 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( + <MarketplaceSearchForm + action="/templates/knowledge" + category="knowledge" + language="en-US" + locale="en-US" + placeholder="Search all templates..." + query="" + scope="templates" + />, + { 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() + + await user.click(screen.getByText('Legal Research Agent')) + expect(mockAssign).toHaveBeenCalledWith( + '/template/dify/Legal%20Research%20Agent?templateId=template-1', + ) + + 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 ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={(nextValue) => { + onValueChange(nextValue) + setValue(nextValue) + }} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { 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 opens the selected result instead of viewing more', 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( + <MarketplaceSearchForm + action="/" + locale="en-US" + placeholder="Search plugins or templates" + query="" + scope="all" + />, + { 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() + expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument() + + await user.click(screen.getByText('Google Search')) + + expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() + }) + + it('submits the typed query on Enter without selecting a hovered suggestion', 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( + <MarketplaceSearchForm + action="/search/all" + locale="en-US" + placeholder="Search plugins or templates" + query="" + scope="all" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'search') + await user.hover(await screen.findByRole('option', { name: /Legal Research Agent/ })) + await user.keyboard('{Enter}') + + expect(handleSubmit).toHaveBeenCalledOnce() + expect(screen.getByRole('combobox')).toHaveValue('search') + }) + + it('opens plugin detail 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( + <MarketplaceSearchForm + action="/plugins" + locale="en-US" + placeholder="Search plugins" + query="" + scope="plugins" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'google') + await user.click(await screen.findByText('Google Search')) + + expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() + }) + + it('selects a suggestion without submitting when the parent handles the result', 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 ( + <form> + <MarketplaceSearchAutocomplete + inputName="q" + locale="en-US" + onSuggestionSelect={onSuggestionSelect} + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + </form> + ) + } + + const { container } = render(<ControlledSearch />, { wrapper: Wrapper }) + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'google') + await user.click(await screen.findByText('Google Search')) + + expect(onSuggestionSelect).toHaveBeenCalledOnce() + expect(onSuggestionSelect.mock.calls[0]?.[0]).toMatchObject({ + kind: 'plugin', + plugin: { name: 'google-search' }, + }) + expect(handleSubmit).not.toHaveBeenCalled() + }) + + 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( + <MarketplaceSearchForm + action="/plugins" + locale="en-US" + placeholder="Search plugins" + query="" + scope="plugins" + />, + { 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(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() + }) + + 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 ( + <form + onSubmit={(event) => { + handleSubmit(event.nativeEvent) + }} + > + <MarketplaceSearchAutocomplete + locale="en-US" + onSuggestionSelect={onSuggestionSelect} + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + </form> + ) + } + + render(<ControlledSearch />, { 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 ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { 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 ( + <> + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + <button type="button">Outside search</button> + </> + ) + } + + render(<ControlledSearch />, { 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 ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { 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 ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + // Suggestions only appear once the real 300ms debounce has elapsed. + await user.type(screen.getByRole('combobox'), 'google') + expect(await screen.findByText('Google Search')).toBeInTheDocument() + + // For the first 300ms after editing, the debounced term still points at + // the old query; the previous suggestions must already be gone. + await user.type(screen.getByRole('combobox'), ' drive') + + expect(screen.queryByText('Google Search')).not.toBeInTheDocument() + expect(screen.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..c6006159b0c --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx @@ -0,0 +1,116 @@ +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<void>((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + +const SearchPage = ({ popup }: { popup?: boolean }) => ( + <> + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <div style={{ height: 48, flexShrink: 0 }}>Header</div> + <div style={{ height: 180, flexShrink: 0 }}>Hero</div> + <div + data-testid="search-root" + style={{ position: 'sticky', top: 6, height: 36, marginTop: -36 }} + > + <input aria-label="Search plugins or templates" style={{ height: 36, width: '100%' }} /> + </div> + <div style={{ height: 900, flexShrink: 0 }}>Catalog</div> + </div> + {popup ? ( + <div + data-testid="search-popup" + style={{ + height: 80, + overflowY: 'auto', + position: 'fixed', + top: 50, + left: 100, + width: 200, + }} + > + Short + </div> + ) : null} + </> +) + +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(<SearchPage />) + 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() + }) + + it('keeps visitor-initiated scroll after typing in the sticky search', async () => { + await page.viewport(1280, 900) + + const screen = await render(<SearchPage />) + 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() + + input.focus() + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' })) + await nextFrame() + + expect(container.scrollTop).toBe(400) + + container.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true })) + container.scrollTop = 520 + container.dispatchEvent(new Event('scroll')) + await nextFrame() + + expect(container.scrollTop).toBe(520) + stop() + }) + + it('scrolls the page when the visitor wheels over a portaled popup that cannot scroll', async () => { + await page.viewport(1280, 900) + + const screen = await render(<SearchPage popup />) + 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 popup = screen.getByTestId('search-popup').element() + + const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container) + container.scrollTop = 400 + container.dispatchEvent(new Event('scroll')) + await nextFrame() + + input.focus() + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' })) + await nextFrame() + + popup.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })) + await nextFrame() + + expect(container.scrollTop).toBe(520) + 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<HTMLDivElement>(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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="brain-2-fill"> +<path id="Vector" d="M8.5 2C6.567 2 5 3.567 5 5.5C5 5.68016 5.01364 5.85714 5.03993 6.02997C3.32436 6.25523 2 7.72295 2 9.5C2 10.4793 2.40223 11.3647 3.05051 12C2.40223 12.6353 2 13.5207 2 14.5C2 15.9018 2.82359 17.1104 4.01353 17.6693C4.00457 17.7785 4 17.8888 4 18C4 20.2091 5.79086 22 8 22C9.19469 22 10.2671 21.4762 11 20.6458V3.05051C10.3647 2.40223 9.47934 2 8.5 2ZM13 3.05051V20.6458C13.7329 21.4762 14.8053 22 16 22C18.2091 22 20 20.2091 20 18C20 17.8888 19.9954 17.7785 19.9865 17.6693C21.1764 17.1104 22 15.9018 22 14.5C22 13.5207 21.5978 12.6353 20.9495 12C21.5978 11.3647 22 10.4793 22 9.5C22 7.72295 20.6756 6.25523 18.9601 6.02997C18.9864 5.85714 19 5.68016 19 5.5C19 3.567 17.433 2 15.5 2C14.5207 2 13.6353 2.40223 13 3.05051Z" fill="#0033FF"/> +</g> +</svg> 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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="image-circle-ai-line"> +<path id="Vector" d="M20.4668 8.69379L20.7134 8.12811C21.1529 7.11947 21.9445 6.31641 22.9323 5.87708L23.6919 5.53922C24.1027 5.35653 24.1027 4.75881 23.6919 4.57612L22.9748 4.25714C21.9616 3.80651 21.1558 2.97373 20.7238 1.93083L20.4706 1.31953C20.2942 0.893489 19.7058 0.893489 19.5293 1.31953L19.2761 1.93083C18.8442 2.97373 18.0384 3.80651 17.0252 4.25714L16.308 4.57612C15.8973 4.75881 15.8973 5.35653 16.308 5.53922L17.0677 5.87708C18.0555 6.31641 18.8471 7.11947 19.2866 8.12811L19.5331 8.69379C19.7136 9.10792 20.2864 9.10792 20.4668 8.69379ZM12 4C7.58172 4 4 7.58172 4 12C4 14.4636 5.11358 16.6671 6.86484 18.1346L14.2925 10.707C14.683 10.3164 15.3162 10.3164 15.7067 10.707L19.5761 14.5764C19.5773 14.5729 19.5785 14.5693 19.5797 14.5658C19.8522 13.7604 20 12.8975 20 12C20 11.6765 19.9809 11.3579 19.9437 11.0452L21.9298 10.8094C21.9762 11.2002 22 11.5975 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C12.8614 2 13.6987 2.10914 14.4983 2.31487L14 4.25179C13.3618 4.0876 12.6919 4 12 4ZM10.813 19.9125C11.2 19.9701 11.5962 19.9998 11.9996 19.9998C14.7613 19.9998 17.1992 18.6003 18.6379 16.4666L14.9996 12.8283L8.58927 19.2386L8.59334 19.2405C9.28476 19.5664 10.0304 19.7961 10.813 19.9125ZM11 10C11 11.1046 10.1046 12 9 12C7.89543 12 7 11.1046 7 10C7 8.89543 7.89543 8 9 8C10.1046 8 11 8.89543 11 10Z" fill="#FF4405"/> +</g> +</svg> 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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="plug-fill"> +<path id="Vector" d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V10H20V14C20 16.2091 18.2091 18 16 18H13ZM16 6H19C19.5523 6 20 6.44772 20 7V9H4V7C4 6.44772 4.44772 6 5 6H8V2H10V6H14V2H16V6ZM12 14.5C12.5523 14.5 13 14.0523 13 13.5C13 12.9477 12.5523 12.5 12 12.5C11.4477 12.5 11 12.9477 11 13.5C11 14.0523 11.4477 14.5 12 14.5Z" fill="#0E9384"/> +</g> +</svg> 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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="IconPuzzleFill"> +<path id="Vector" d="M9.5 4V3.5C9.5 2.11929 10.6193 1 12 1C13.3807 1 14.5 2.11929 14.5 3.5V4H20C20.5523 4 21 4.44772 21 5V9C21 9.27614 20.7761 9.5 20.5 9.5C19.1193 9.5 18 10.6193 18 12C18 13.3807 19.1193 14.5 20.5 14.5C20.7761 14.5 21 14.7239 21 15V19C21 19.5523 20.5523 20 20 20H4C3.44772 20 3 19.5523 3 19V5C3 4.44772 3.44772 4 4 4H9.5Z" fill="#0BA5EC"/> +</g> +</svg> 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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="sparkling-fill"> +<path id="Vector" d="M14 4.4375C15.3462 4.4375 16.4375 3.34619 16.4375 2H17.5625C17.5625 3.34619 18.6538 4.4375 20 4.4375V5.5625C18.6538 5.5625 17.5625 6.65381 17.5625 8H16.4375C16.4375 6.65381 15.3462 5.5625 14 5.5625V4.4375ZM1 11C4.31371 11 7 8.31371 7 5H9C9 8.31371 11.6863 11 15 11V13C11.6863 13 9 15.6863 9 19H7C7 15.6863 4.31371 13 1 13V11ZM17.25 14C17.25 15.7949 15.7949 17.25 14 17.25V18.75C15.7949 18.75 17.25 20.2051 17.25 22H18.75C18.75 20.2051 20.2051 18.75 22 18.75V17.25C20.2051 17.25 18.75 15.7949 18.75 14H17.25Z" fill="#7839EE"/> +</g> +</svg> 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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="voice-ai-fill"> +<path id="Vector" d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM8.5 6H6.5V18H8.5V6ZM4 10H2V14H4V10ZM13 2H11V22H13V2ZM17.5 8H15.5V18H17.5V8ZM22 10H20V14H22V10Z" fill="#0BA5EC"/> +</g> +</svg> 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<PluginBanner[]> => { + 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<HTMLButtonElement>(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 ( + <Popover open={open} onOpenChange={setOpen}> + <div className="relative inline-flex h-8 shrink-0 items-center"> + <PopoverTrigger + render={ + <Button + ref={triggerRef} + variant="ghost" + size="medium" + aria-label={triggerLabel} + className={cn( + 'h-8 justify-start px-2 py-1 text-text-tertiary focus-visible:ring-inset', + !!selectedCount && + 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg pr-8 shadow-xs shadow-shadow-shadow-3', + !selectedCount && 'data-popup-open:bg-state-base-hover', + )} + > + <span className="py-0.5"> + <span + aria-hidden + className={cn( + 'i-ri-global-line block size-4', + !!selectedCount && 'text-text-secondary', + )} + /> + </span> + <span className="flex items-center gap-x-1 py-1 system-sm-medium"> + {!selectedCount && ( + <span>{t(($) => $['marketplace.languages'], { ns: 'plugin' })}</span> + )} + {!!selectedCount && ( + <span className="text-text-secondary"> + {selectedNativeLabels.slice(0, 2).join(',')} + </span> + )} + {selectedCount > 2 && ( + <span className="system-xs-medium text-text-tertiary">+{selectedCount - 2}</span> + )} + </span> + {!selectedCount && ( + <span className="py-0.5"> + <span + aria-hidden + className="i-ri-arrow-down-s-line block size-4 text-text-tertiary" + /> + </span> + )} + </Button> + } + /> + {!!selectedCount && ( + <IconButton + variant="ghost" + size="md" + aria-label={t(($) => $.clearSearch, { + ns: 'plugin', + label: triggerLabel, + })} + className="absolute right-1 focus-visible:ring-inset" + onClick={() => { + shouldRestoreFocusRef.current = true + handleLanguagesChange([]) + }} + > + <span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" /> + </IconButton> + )} + </div> + <PopoverContent + placement="bottom-end" + sideOffset={4} + alignOffset={-6} + className="border-none bg-transparent shadow-none" + > + <div className="w-60 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> + <div className="p-2 pb-1"> + <InputGroup> + <InputGroupInput + type="search" + name="language-query" + autoComplete="off" + enterKeyHint="search" + aria-label={t(($) => $['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' }) || '' + } + /> + <InputGroupAddon className="ps-1.75 pe-0.75"> + <span + aria-hidden + className="i-ri-search-line size-4 text-components-input-text-placeholder" + /> + </InputGroupAddon> + </InputGroup> + </div> + <CheckboxGroup + aria-label={t(($) => $['marketplace.languages'], { ns: 'plugin' })} + value={languages} + onValueChange={handleLanguagesChange} + className="max-h-112 overflow-y-auto p-1" + > + {filteredOptions.map((option) => ( + <label + key={option.value} + className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" + > + <Checkbox className="mr-1" value={option.value} /> + <div className="px-1 system-sm-medium text-text-secondary"> + {option.nativeLabel} + </div> + </label> + ))} + </CheckboxGroup> + </div> + </PopoverContent> + </Popover> + ) +} 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 ( + <TagsFilter + tags={tags} + onTagsChange={(next) => setTags(next.length ? next : null)} + usedInMarketplace + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/embedded-marketplace-search.tsx b/web/app/components/plugins/marketplace/home/embedded-marketplace-search.tsx new file mode 100644 index 00000000000..4a91b69912e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/embedded-marketplace-search.tsx @@ -0,0 +1,101 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { MarketplaceSearchSelection } from './marketplace-search-autocomplete' +import type { Plugin } from '@/app/components/plugins/types' +import { useCallback, useState } from 'react' +import { useLocale, useTranslation } from '#i18n' +import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' +import { useRouter } from '@/next/navigation' +import { useSearchPluginText } from '../atoms' +import MarketplaceDetailDialog from '../detail-dialog' +import TemplateDetailDialog from '../templates/template-detail-dialog' +import { getFormattedPlugin } from '../utils' +import { MarketplaceSearchAutocomplete } from './marketplace-search-autocomplete' + +const normalizePlugin = (plugin: Plugin): Plugin => ({ + ...plugin, + plugin_id: plugin.plugin_id || `${plugin.org}/${plugin.name}`, + label: plugin.label ?? {}, + brief: plugin.brief ?? {}, + description: plugin.description ?? {}, + tags: plugin.tags ?? [], + badges: plugin.badges ?? null, +}) + +export default function EmbeddedMarketplaceSearch() { + const { t } = useTranslation() + const locale = useLocale() + const router = useRouter() + const [query, setQuery] = useSearchPluginText() + const [value, setValue] = useState(query ?? '') + const [valueQuery, setValueQuery] = useState(query) + if (query !== valueQuery) { + setValueQuery(query) + setValue(query ?? '') + } + const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null) + const [selectedTemplate, setSelectedTemplate] = useState<MarketplaceTemplate | null>(null) + const { installedInfo } = useCheckInstalled({ + pluginIds: selectedPlugin ? [selectedPlugin.plugin_id] : [], + enabled: Boolean(selectedPlugin), + }) + + const handleSuggestionSelect = useCallback((selection: MarketplaceSearchSelection) => { + if (selection.kind === 'plugin') { + setSelectedTemplate(null) + setSelectedPlugin(normalizePlugin(getFormattedPlugin(selection.plugin))) + return + } + + setSelectedPlugin(null) + setSelectedTemplate(selection.template) + }, []) + + return ( + <> + <form + className="relative w-full shrink-0" + onSubmit={(event) => { + event.preventDefault() + void setQuery(value.trim() || null) + }} + > + <MarketplaceSearchAutocomplete + key={query ?? ''} + inputName="q" + locale={locale} + onSuggestionSelect={handleSuggestionSelect} + onValueChange={setValue} + placeholder={t(($) => $['marketplace.home.searchPlaceholder'], { ns: 'plugin' })} + scope="all" + value={value} + /> + </form> + {selectedPlugin && ( + <MarketplaceDetailDialog + isInstalled={Boolean(installedInfo?.[selectedPlugin.plugin_id])} + open + plugin={selectedPlugin} + onOpenChange={(open) => { + if (!open) setSelectedPlugin(null) + }} + /> + )} + {selectedTemplate && ( + <TemplateDetailDialog + open + template={selectedTemplate} + onInstall={() => { + const templateId = selectedTemplate.id + setSelectedTemplate(null) + router.push(`/apps?template-id=${encodeURIComponent(templateId)}`) + }} + onOpenChange={(open) => { + if (!open) setSelectedTemplate(null) + }} + /> + )} + </> + ) +} 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<HTMLElement>(`[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<HTMLAnchorElement>('a[href]')?.getAttribute('href') ?? null +} + +export const focusCatalogTab = (slot: HomeCatalogTabSlot, href: string) => { + const slotElement = getCatalogTabSlot(slot) + const matchingLink = Array.from( + slotElement?.querySelectorAll<HTMLAnchorElement>('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<string | null>(null) + const catalogTabsRegionRef = useRef<HTMLDivElement>(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 ( + <div className={styles.catalogNavigationGroup}> + <div + ref={catalogTabsRegionRef} + className={cn('w-full shrink-0 bg-background-default', styles.catalogTabsRegion)} + > + <div + aria-hidden={isPinned ? true : undefined} + className={cn(styles.catalogTabs, isPinned && styles.catalogTabsPinned)} + data-home-catalog-tabs-slot="content" + inert={isPinned ? true : undefined} + > + {catalogTabs} + </div> + </div> + <section + aria-label={t(($) => $['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 }} + > + <div className="w-full"> + <div className="flex w-full items-center gap-2"> + {catalogLeading ? ( + <> + <div className={cn('shrink-0', styles.catalogLeading)}>{catalogLeading}</div> + <div + aria-hidden + className={cn( + 'mx-1 h-3.5 w-px shrink-0 bg-divider-regular', + styles.catalogLeadingDivider, + )} + /> + </> + ) : null} + <div className="min-w-0 flex-1 scrollbar-none overflow-x-auto"> + {catalogCategories ?? <PluginTypeSwitch className={undefined} variant="home" />} + </div> + {catalogTrailing ? <div className="shrink-0">{catalogTrailing}</div> : null} + </div> + </div> + </section> + </div> + ) +} + +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<HomeCatalogTab, string> + +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 ( + <nav + aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })} + className={cn('flex h-8 items-center gap-1', className)} + > + <Link + href={pluginsHref} + aria-label={pluginsLabel} + aria-current={isPluginsActive ? 'page' : undefined} + className={cn( + 'flex h-8 cursor-pointer items-start rounded-lg px-[9px] pt-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isPluginsActive ? 'body-sm-medium' : 'body-sm-regular', + isPluginsActive + ? 'bg-state-base-active text-text-primary' + : 'text-text-tertiary hover:bg-state-base-hover', + )} + > + {pluginsLabel} + </Link> + <Link + href={templatesHref} + aria-label={templatesLabel} + aria-current={isTemplatesActive ? 'page' : undefined} + className={cn( + 'relative flex h-8 cursor-pointer items-center rounded-[10px] p-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isTemplatesActive ? 'body-sm-medium' : 'body-sm-regular', + isTemplatesActive + ? 'bg-state-base-active text-text-primary' + : 'text-text-tertiary hover:bg-state-base-hover', + )} + > + {templatesLabel} + </Link> + </nav> + ) +} + +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 ( + <Link + href={creatorCenterUrl} + target="_blank" + rel="noopener noreferrer" + onClick={() => { + 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', + )} + > + <span aria-hidden className="i-ri-user-star-line size-4" /> + <span className="hidden system-sm-medium lg:inline">{label}</span> + </Link> + ) +} 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 <SubmitRequestDropdownMenu dividerAfterFirst docLink={docLink} /> +} + +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 ? <MarketplaceGuide /> : <SubmitRequestDropdown dividerAfterFirst /> +} 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 ( + <header + className="sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 py-1.5 md:px-9" + style={{ height: HOME_HEADER_HEIGHT_PX }} + > + <div className="flex min-w-0 flex-1 items-center gap-4"> + <Link + // In the embedded console "/" leaves the marketplace entirely, so + // the brand mark points back at the marketplace home instead. + href={isMarketplacePlatform ? '/' : '/marketplace'} + aria-label="Dify Marketplace" + className="flex h-full w-[141.933px] shrink-0 items-center" + > + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoLight, + )} + height="16.386" + src={MarketplaceLogo.src} + width="141.761" + /> + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoDark, + )} + height="16.386" + src={MarketplaceLogoDark.src} + width="141.761" + /> + </Link> + <HomeStickyCatalogTabs> + <HomeCatalogTabs + activeTab={activeTab} + className={styles.headerCatalogTabs} + isMarketplacePlatform={isMarketplacePlatform} + labels={catalogLabels} + language={language} + /> + </HomeStickyCatalogTabs> + </div> + + <div className="flex h-full min-w-0 flex-1 items-center justify-end gap-2.5"> + <div + className={cn( + 'flex min-w-0 items-center gap-2.5', + isMarketplacePlatform && styles.standaloneHeaderActions, + )} + > + <HomeCreatorCenter /> + <HomeGuide isMarketplacePlatform={isMarketplacePlatform} /> + </div> + {actions} + </div> + </header> + ) +} + +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 = () => ( + <div aria-hidden className={styles.decorations} style={heroGridStyle}> + <div className={styles.grid} /> + <div className={styles.glow} /> + {heroDecorationIcons.map((icon) => ( + <span + key={icon.src} + className="absolute flex items-center justify-center overflow-hidden bg-state-accent-hover" + style={{ + height: HERO_ICON_SIZE_PX, + left: `calc(50% + ${icon.left}px)`, + top: icon.top, + width: HERO_ICON_SIZE_PX, + }} + > + <span className="relative size-[24px] overflow-hidden"> + <img alt="" aria-hidden className="size-full" height={24} src={icon.src} width={24} /> + </span> + </span> + ))} + </div> +) + +const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => { + const { t } = useTranslation('plugin') + + return ( + <section + className={cn( + 'relative flex shrink-0 justify-center overflow-hidden bg-background-default px-4', + !isMarketplacePlatform && 'pt-6', + )} + > + <HeroDecorations /> + <div + className={cn('relative flex w-full max-w-[726px] flex-col items-center', styles.frame)} + style={{ paddingTop: HERO_GRID_PITCH_PX }} + > + <div + className={cn('flex w-full flex-col items-center gap-2 text-center', styles.copyBlock)} + > + <h1 + className="text-[28px] leading-[1.2] font-medium tracking-[-0.56px] text-text-primary" + style={{ fontFamily: "var(--font-family-brand, 'Söhne', var(--font-sans))" }} + > + {title ?? t(($) => $['marketplace.home.heroTitle'])} + </h1> + <p className="w-full text-[13px] leading-4 font-light tracking-[-0.065px] text-text-tertiary"> + {subtitle ?? t(($) => $['marketplace.home.heroSubtitle'])} + </p> + </div> + </div> + </section> + ) +} + +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..14990bc5418 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-search.tsx @@ -0,0 +1,73 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useRef } from 'react' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import EmbeddedMarketplaceSearch from './embedded-marketplace-search' +import styles from './home-sticky.module.css' +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<HTMLDivElement>(null) + + 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 ( + <div + className={cn( + 'pointer-events-none flex shrink-0 justify-center', + overlapHero && '-mt-9', + styles.search, + )} + > + <div + ref={searchRef} + className={cn('pointer-events-auto relative w-full', styles.searchContent)} + > + {children ?? <EmbeddedMarketplaceSearch />} + </div> + </div> + ) +} + +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 ( + <HomeStickyStateProvider> + <div + className="flex min-h-full w-full shrink-0 flex-col bg-background-default" + data-marketplace-standalone={isMarketplacePlatform ? '' : undefined} + style={ + { + '--home-header-height': `${HOME_HEADER_HEIGHT_PX}px`, + '--home-search-height': `${HOME_SEARCH_HEIGHT_PX}px`, + '--home-search-mobile-padding-bottom': `${HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX}px`, + } as CSSProperties + } + > + {header} + <div className="relative flex w-full flex-col"> + {hero} + {search} + {banners.length > 0 && ( + <> + <div + aria-hidden="true" + className={cn('h-12 shrink-0', isMarketplacePlatform && styles.bannerSpacer)} + /> + <HomeTrending + banners={banners} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + </> + )} + {navigation} + {children} + </div> + </div> + </HomeStickyStateProvider> + ) +} 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 ( + <ScopeProvider atoms={homeStickyScopedAtoms} name="MarketplaceHomeSticky"> + {children} + </ScopeProvider> + ) +} + +export function HomeStickyCatalogTabs({ children }: { children: ReactNode }) { + const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom) + + return ( + <div + aria-hidden={!isCatalogPinned ? true : undefined} + className={cn(styles.headerCatalogSlot, isCatalogPinned && styles.headerCatalogSlotPinned)} + data-home-catalog-tabs-slot="header" + inert={!isCatalogPinned ? true : undefined} + > + {children} + </div> + ) +} 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..5ef8d53041d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx @@ -0,0 +1,283 @@ +'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<HTMLDivElement | null> + interactionPaused: boolean + pauseWhenOffscreen: boolean + onSelect: (index: number) => void + onNext: () => void + onPausedChange?: (paused: boolean) => void +}) { + const { t } = useTranslation('plugin') + const progressRef = useRef<HTMLSpanElement>(null) + const progressAnimationRef = useRef<Animation | null>(null) + const pauseReasonsRef = useRef( + new Set<AutoplayPauseReason>(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 + // cancel() rejects `finished` with AbortError; keep that from becoming unhandled. + void progressAnimation.finished.catch(() => {}) + + 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 ( + <div + role="group" + aria-label={t(($) => $['marketplace.home.trendingPaginationLabel'])} + className={cn( + styles.navigation, + 'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2', + )} + > + <div className="relative h-1.5 shrink-0" style={{ width: paginationWidth }}> + <span + aria-hidden + className="pointer-events-none absolute top-0 left-0 z-1 flex h-1.5 w-10 items-center overflow-hidden rounded-full bg-state-base-handle transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none" + style={{ + transform: `translate3d(${selectedIndex * PAGINATION_STEP}px, 0, 0)`, + }} + > + <span + key={selectedIndex} + ref={progressRef} + data-carousel-progress + className="h-full w-full rounded-full bg-text-accent" + style={{ transform: 'scaleX(0)', transformOrigin: 'left center' }} + /> + </span> + {banners.map((banner, index) => { + const isCurrent = index === selectedIndex + + return ( + <button + key={banner.id} + type="button" + aria-label={banner.title} + aria-current={isCurrent ? 'true' : undefined} + onClick={(event) => { + 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)`, + }} + /> + ) + })} + </div> + <div className="min-w-0 flex-1" /> + <button + type="button" + aria-label={t( + ($) => + $[ + 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 ? ( + <span aria-hidden className="i-ri-play-large-fill size-2 opacity-30" /> + ) : ( + <span aria-hidden className="i-ri-pause-large-fill size-2 opacity-30" /> + )} + </button> + </div> + ) +} + +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..0510bdb6749 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx @@ -0,0 +1,504 @@ +'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<typeof buildMarketplaceBannerClickProperties>[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 ( + <div + className={cn( + styles.copy, + 'flex min-w-0 flex-col items-start overflow-hidden p-5', + isMarketplacePlatform ? styles.marketplaceCopy : styles.embeddedCopy, + )} + > + <div className="flex w-full flex-col items-start gap-2 overflow-hidden"> + <p className="shrink-0 rounded-sm bg-state-accent-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-accent"> + {banner.title} + </p> + <h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary"> + {heading} + </h2> + <p + className={cn( + styles.copyDescription, + 'w-full text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary', + )} + > + {description} + </p> + </div> + </div> + ) +} + +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 ( + <Link + href={href} + target={opensInNewTab ? '_blank' : undefined} + rel={opensInNewTab ? 'noopener noreferrer' : undefined} + aria-label={card.display_name} + onClick={() => { + 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, + display_name: card.display_name, + 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', + )} + > + <div + className={cn( + styles.cardIcon, + 'flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge', + )} + style={{ + backgroundColor: !iconURL ? card.icon_background : undefined, + }} + > + {iconURL ? ( + <img + src={iconURL} + width={40} + height={40} + alt="" + aria-hidden + className="size-full object-cover" + /> + ) : card.icon ? ( + <span className="text-xl leading-none">{card.icon}</span> + ) : ( + <span aria-hidden="true" className="i-ri-image-line size-5 text-text-quaternary" /> + )} + </div> + + <div className={cn(styles.cardMeta, 'flex w-full items-end gap-1')}> + <div className="flex min-w-0 flex-1 flex-col items-start gap-[3px]"> + <div className="flex w-full min-w-0 items-center gap-[3px]"> + <h3 className="min-w-0 truncate text-sm leading-[normal] font-medium text-text-primary"> + {card.display_name} + </h3> + {(isPartner || isVerified) && ( + <div className="flex shrink-0 items-start gap-[3.5px]"> + {isPartner && ( + <Partner className="size-3.5" text={t(($) => $['marketplace.partnerTip'])} /> + )} + {isVerified && ( + <Verified className="size-3.5" text={t(($) => $['marketplace.verifiedTip'])} /> + )} + </div> + )} + </div> + {creator && ( + <p className="w-full truncate text-xs leading-[normal] font-normal text-text-tertiary"> + {t(($) => $['marketplace.home.trendingByCreator'], { creator })} + </p> + )} + </div> + <span className="shrink-0 rounded-full bg-background-section-burn px-1.5 py-[3px] text-[10px] leading-3 font-normal text-text-primary"> + {t(($) => $['marketplace.home.trendingView'])} + </span> + </div> + </Link> + ) +} + +function TrendingRecommendationSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + return ( + <div + className={cn( + 'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body', + isMarketplacePlatform && styles.stackedSlide, + )} + > + <TrendingCopy banner={banner} isMarketplacePlatform={isMarketplacePlatform} /> + <div + className={cn( + styles.recommendVisual, + 'relative h-[200px] shrink-0 overflow-hidden rounded-xl bg-background-body', + isMarketplacePlatform && styles.stackedVisual, + )} + > + <img + src={background.src} + width={1600} + height={900} + alt="" + aria-hidden + className={cn( + styles.recommendBackdrop, + 'absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80', + )} + /> + <div + aria-hidden + className={cn( + styles.recommendBackdrop, + 'absolute inset-0 bg-text-accent mix-blend-color', + )} + /> + + <div className={cn(styles.recommendCards, 'relative z-10 h-full items-center')}> + {banner.content.cards.map((card) => ( + <TrendingCard + key={`${card.item_type}:${card.item_id}`} + banner={banner} + card={card} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ))} + </div> + </div> + </div> + ) +} + +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 ( + <Link + href={href} + target={opensInNewTab ? '_blank' : undefined} + rel={opensInNewTab ? 'noopener noreferrer' : undefined} + onClick={() => { + 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, + )} + > + <div + className={cn( + 'flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5', + isMarketplacePlatform && styles.stackedCopy, + )} + > + <div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2"> + <div className="flex w-full min-w-0 items-center"> + <p + className={cn( + 'max-w-full min-w-0 rounded-sm bg-state-success-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-success', + isMarketplacePlatform && styles.blogTag, + )} + > + {banner.title} + </p> + </div> + <div className="flex min-h-0 w-full max-w-[800px] flex-1 flex-col items-start gap-3"> + <h2 + className={cn( + 'w-full min-w-0 shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary', + isMarketplacePlatform && styles.blogTitle, + )} + > + {banner.content.blog_title} + </h2> + <div + className={cn( + 'flex min-h-0 w-full flex-1 flex-col items-start gap-2', + isMarketplacePlatform && styles.stackedCopyMeta, + )} + > + {banner.content.subtitle && ( + <p + className={cn( + 'w-full min-w-0 shrink-0 text-[15px] leading-[18px] font-normal tracking-[-0.3px] text-text-primary', + isMarketplacePlatform && styles.blogSubtitle, + )} + > + {banner.content.subtitle} + </p> + )} + {banner.content.description && ( + <p + className={cn( + styles.updatesDescription, + 'min-h-0 w-full min-w-0 overflow-hidden text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary', + )} + > + {banner.content.description} + </p> + )} + <span + aria-hidden + className={cn( + 'flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2', + isMarketplacePlatform && styles.readMoreDesktop, + )} + > + <span>{t(($) => $['marketplace.home.trendingReadMore'])}</span> + <span className="i-ri-arrow-right-s-line size-4" /> + </span> + </div> + </div> + </div> + </div> + <img + src={difyUpdatesArt.src} + width={400} + height={200} + alt="" + aria-hidden + className={cn( + styles.updatesArt, + isMarketplacePlatform && styles.stackedVisual, + 'h-[200px] shrink-0 rounded-2xl object-cover object-left', + )} + /> + </Link> + ) +} + +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 ( + <Link + href={href} + target="_blank" + rel="noopener noreferrer" + onClick={() => { + 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, + )} + > + <picture className="block size-full"> + <source + media={ + isMarketplacePlatform ? MARKETPLACE_MOBILE_BANNER_MEDIA : EMBEDDED_MOBILE_BANNER_MEDIA + } + srcSet={resolved.mobile} + /> + {resolved.tablet && ( + <source + media={marketplaceTabletBannerMedia(isMarketplacePlatform)} + srcSet={resolved.tablet} + /> + )} + <img + src={resolved.desktop} + width={1200} + height={200} + alt="" + aria-hidden + className="size-full object-cover object-left" + /> + </picture> + </Link> + ) +} + +export function HomeBannerSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: PluginBanner + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + if (banner.style_type === 'blog') + return ( + <BlogBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + ) + + if (banner.style_type === 'event' || banner.style_type === 'ad') + return ( + <ImageBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + ) + + return ( + <TrendingRecommendationSlide + banner={banner} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ) +} 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..276b1f6f1e1 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts @@ -0,0 +1,139 @@ +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', + display_name: 'Dropbox', + 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', + item_name: 'Dropbox', + 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', + display_name: 'Support Bot', + link: '/templates?tid=tpl-1', + }, + ), + ).toMatchObject({ + theme_type: 'new_arrivals', + click_target: 'recommendation', + item_id: 'tpl-1', + item_type: 'template', + item_name: 'Support Bot', + }) + }) + + 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..c141fd1b59d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-track.ts @@ -0,0 +1,62 @@ +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< + BannerRecommendCard, + 'item_id' | 'item_type' | 'display_name' +> & { + link: string +} + +const compact = (properties: Record<string, unknown>) => { + const next: Record<string, unknown> = {} + 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<string, unknown> = { + 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 + properties.item_name = cardClick?.display_name + } + + 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..5ea89ce5aaa --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -0,0 +1,403 @@ +'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<HTMLDivElement>(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 ( + <div + ref={slideRef} + role="group" + aria-roledescription="slide" + aria-label={banner.title} + aria-hidden={!isActive} + inert={!isActive} + className={cn( + 'h-full min-w-0 shrink-0 grow-0 basis-full', + isMarketplacePlatform && styles.slide, + isMarketplacePlatform && !isActive && !isDragging && styles.slideInactive, + )} + > + <HomeBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + </div> + ) +} + +function HomeTrending({ + banners, + isMarketplacePlatform, + page, +}: { + banners: PluginBanner[] + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const { t } = useTranslation('plugin') + const carouselRootRef = useRef<HTMLDivElement>(null) + const swipeGestureRef = useRef<SwipeGesture | null>(null) + const suppressClickRef = useRef(false) + const suppressClickTimerRef = useRef<number | null>(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const [trackIndex, setTrackIndex] = useState(0) + const [loopPhase, setLoopPhase] = useState<LoopPhase>('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 lastIndex = Math.max(0, banners.length - 1) + if (selectedIndex > lastIndex) { + setLoopPhase('idle') + setSelectedIndex(lastIndex) + setTrackIndex(lastIndex) + } + 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<HTMLDivElement>) => { + 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<HTMLDivElement>) => + 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<HTMLDivElement>) => { + 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<HTMLDivElement>) => { + 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<HTMLDivElement>, 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<HTMLDivElement>) => { + 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 ( + <section + aria-label={t(($) => $['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, + )} + > + <div + className={cn( + styles.wrapper, + 'mx-auto w-full', + isMarketplacePlatform ? 'max-w-[1200px]' : 'max-w-[1188px]', + )} + > + <div + // The pause boundary covers the whole carousel region, so hovering + // or focusing the navigation controls also stops the rotation. + ref={carouselRootRef} + role="region" + aria-roledescription="carousel" + aria-label={t(($) => $['marketplace.home.trendingTitle'])} + className={cn( + 'relative h-[200px] w-full rounded-2xl', + isMarketplacePlatform && styles.carouselRoot, + )} + data-home-trending-carousel-root + > + <div + className={cn( + 'h-full overflow-hidden rounded-2xl', + isMarketplacePlatform && styles.slideViewport, + )} + onClickCapture={handleClickCapture} + onPointerCancel={(event) => finishSwipe(event, true)} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={finishSwipe} + > + <div + // Keep automatic rotation silent for screen readers; announce + // the current slide only once rotation is paused or user-driven. + aria-live={isRotationPaused ? 'polite' : 'off'} + className={cn(styles.contentTrack, 'flex h-full')} + data-carousel-track + data-carousel-loop-phase={loopPhase} + onTransitionEnd={handleTrackTransitionEnd} + style={{ + transform: + dragOffset === 0 + ? `translate3d(-${trackIndex * 100}%, 0, 0)` + : `translate3d(calc(-${trackIndex * 100}% + ${dragOffset}px), 0, 0)`, + transition: loopPhase === 'resetting' || isDragging ? 'none' : undefined, + }} + > + {banners.map((banner, index) => ( + <TrackedBannerSlide + key={banner.id} + banner={banner} + isActive={index === selectedIndex} + isDragging={isDragging} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ))} + {loopPhase !== 'idle' && banners[0] && ( + <div + aria-hidden + inert + data-carousel-loop-clone + className={cn( + 'h-full min-w-0 shrink-0 grow-0 basis-full', + isMarketplacePlatform && styles.slide, + )} + > + <HomeBannerSlide + banner={banners[0]} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + </div> + )} + </div> + </div> + {/* A single banner has nothing to rotate through, so skip the + pagination/autoplay controls entirely. */} + {banners.length > 1 && ( + <TrendingNavigation + banners={banners} + selectedIndex={selectedIndex} + carouselRootRef={carouselRootRef} + pauseWhenOffscreen={!isMarketplacePlatform} + onSelect={selectSlide} + onNext={selectNextSlide} + onPausedChange={setIsRotationPaused} + interactionPaused={isGestureActive} + /> + )} + </div> + </div> + </section> + ) +} + +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 ( + <HomeShell + banners={banners} + isMarketplacePlatform={isMarketplacePlatform} + page="plugins" + header={ + <HomeHeader + actions={actions} + catalogLabels={catalogLabels} + isMarketplacePlatform={isMarketplacePlatform} + language={language} + /> + } + hero={<HomeHero isMarketplacePlatform={isMarketplacePlatform} />} + search={<HomeSearch enableSearchShortcut={isMarketplacePlatform}>{search}</HomeSearch>} + navigation={ + <HomeCatalogNavigation + catalogCategories={catalogCategories} + catalogLeading={<CatalogTagsFilter />} + isMarketplacePlatform={isMarketplacePlatform} + catalogTabs={ + <HomeCatalogTabs + isMarketplacePlatform={isMarketplacePlatform} + labels={catalogLabels} + language={language} + /> + } + /> + } + > + <div className="contents [&>div]:bg-background-default!"> + <ListWrapper + activePluginType={activePluginType} + className={styles.catalogContent} + deferOffscreenCollections={!isMarketplacePlatform} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + /> + </div> + </HomeShell> + ) +} + +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 ( + <form + action={action} + className={cn('relative shrink-0', className)} + onSubmit={(event) => { + event.preventDefault() + const nextQuery = value.trim() + routedSearchRef.current = nextQuery + navigate(nextQuery) + }} + > + <span + aria-hidden + className="pointer-events-none absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" + /> + <input + type="search" + name="q" + autoComplete="off" + aria-label={placeholder} + value={value} + onChange={(event) => setValue(event.target.value)} + placeholder={placeholder} + className="h-9 w-full rounded-[10px] border border-transparent bg-components-input-bg-normal py-2 pr-3 pl-9 text-sm text-text-primary outline-none placeholder:text-text-quaternary hover:border-components-input-border-hover focus:border-components-input-border-active" + /> + {language && <input type="hidden" name="language" value={language} />} + </form> + ) +} 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..96aadc24f8d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -0,0 +1,432 @@ +'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 { + getPluginDetailLinkInMarketplace, + getPluginIconInMarketplace, + getTemplateDetailLinkInMarketplace, +} from '../utils' + +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<string, string>, 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 }, +}) + +function MarketplaceSuggestionList({ + onSelect, +}: { + onSelect: (selection: MarketplaceSearchSelection) => void +}) { + const groups = useAutocompleteFilteredItems<MarketplaceSuggestionGroup>() + + return ( + <AutocompleteList className="max-h-none overflow-visible p-0 data-empty:p-0"> + {groups.map((group, groupIndex) => ( + <AutocompleteGroup key={group.id} items={group.items} className="p-1"> + {groupIndex > 0 && <AutocompleteSeparator className="-mx-1 mb-1" />} + <AutocompleteGroupLabel className="px-3 pt-3 pb-2 system-xs-semibold-uppercase text-text-primary"> + {group.label} + </AutocompleteGroupLabel> + <AutocompleteCollection<MarketplaceSuggestion>> + {(item) => ( + <AutocompleteItem + key={item.id} + value={item} + className="mx-0 items-start gap-1 rounded-lg py-1 pr-1 pl-3 hover:bg-state-base-hover data-highlighted:bg-state-base-hover" + onClick={() => onSelect(item.selection)} + > + <span className="flex shrink-0 items-start py-1"> + {item.iconUrl ? ( + <img + alt="" + className={cn( + 'shrink-0 object-contain', + item.kind === 'template' + ? 'size-8 rounded-lg border-[0.5px] border-divider-regular' + : 'size-7 rounded-lg', + )} + src={item.iconUrl} + onError={({ currentTarget }) => { + currentTarget.style.display = 'none' + }} + /> + ) : ( + <span + aria-hidden + className={cn( + 'flex shrink-0 items-center justify-center text-text-tertiary', + item.kind === 'template' + ? 'i-ri-layout-grid-line size-8 rounded-lg border-[0.5px] border-divider-regular text-base' + : 'i-ri-puzzle-2-line size-7 rounded-lg text-base', + )} + /> + )} + </span> + <span className="flex min-w-0 flex-1 flex-col gap-0.5 p-1"> + <AutocompleteItemText className="px-0 system-md-medium text-text-primary"> + {item.label} + </AutocompleteItemText> + {!!item.description && ( + <span className="line-clamp-2 system-xs-regular text-text-tertiary"> + {item.description} + </span> + )} + {!!item.meta && ( + <span className="truncate pt-1 system-xs-regular text-text-tertiary"> + {item.meta} + </span> + )} + </span> + </AutocompleteItem> + )} + </AutocompleteCollection> + </AutocompleteGroup> + ))} + </AutocompleteList> + ) +} + +export function MarketplaceSearchAutocomplete({ + category = 'all', + inputName, + locale, + onSuggestionSelect, + onValueChange, + placeholder, + scope, + value, +}: MarketplaceSearchAutocompleteProps) { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const searchRootRef = useRef<HTMLDivElement>(null) + const resultsPanelRef = useRef<HTMLDivElement>(null) + const keyboardHighlightedRef = useRef(false) + + const submitSearchForm = () => { + const form = searchRootRef.current?.closest('form') + if (form instanceof HTMLFormElement) form.requestSubmit() + } + const openSuggestion = (selection: MarketplaceSearchSelection) => { + if (onSuggestionSelect) { + onSuggestionSelect(selection) + queueMicrotask(() => { + onValueChange('') + setIsOpen(false) + }) + return + } + + const href = + selection.kind === 'plugin' + ? getPluginDetailLinkInMarketplace(selection.plugin) + : getTemplateDetailLinkInMarketplace(selection.template) + setIsOpen(false) + window.location.assign(href) + } + 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 ( + <div ref={searchRootRef} className="relative"> + <Autocomplete + filter={null} + itemToStringValue={(item) => item.label} + items={suggestionGroups} + mode="list" + name={inputName} + onOpenChange={setIsOpen} + onValueChange={(nextValue) => { + onValueChange(nextValue) + setIsOpen(Boolean(nextValue.trim())) + }} + open={isPopupOpen} + openOnInputClick + submitOnItemClick={false} + value={value} + onItemHighlighted={(item, details) => { + keyboardHighlightedRef.current = Boolean(item) && details.reason === 'keyboard' + }} + > + <AutocompleteInputGroup size="large"> + <span + aria-hidden + className="ml-3 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <AutocompleteInput + aria-label={placeholder} + className="px-2 text-sm" + placeholder={placeholder} + size="large" + type="text" + onKeyDownCapture={(event) => { + if (event.key !== 'Enter' || !inputName) return + if (keyboardHighlightedRef.current) return + event.preventDefault() + event.stopPropagation() + if (value.trim()) submitSearchForm() + }} + /> + {!!value && ( + <AutocompleteClear + aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })} + size="large" + /> + )} + </AutocompleteInputGroup> + <AutocompletePortal hidden={!isPopupOpen}> + <AutocompletePositioner anchor={searchRootRef} sideOffset={8}> + <div + ref={resultsPanelRef} + className="max-h-[min(20rem,var(--available-height))] w-(--anchor-width) max-w-(--available-width) overflow-y-auto overscroll-contain rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl outline-hidden backdrop-blur-sm" + aria-busy={isSearching || undefined} + > + <MarketplaceSuggestionList onSelect={openSuggestion} /> + <AutocompleteEmpty> + {!isSearching && suggestions.length === 0 ? emptyText : null} + </AutocompleteEmpty> + <AutocompleteStatus className="empty:h-0 empty:p-0"> + {isSearching ? t(($) => $.loading, { ns: 'common' }) : null} + </AutocompleteStatus> + </div> + </AutocompletePositioner> + </AutocompletePortal> + </Autocomplete> + </div> + ) +} + +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 ( + <form + action={action} + className={cn('relative shrink-0', className)} + onSubmit={() => { + markMarketplaceSiteSearch(value) + }} + > + <MarketplaceSearchAutocomplete + category={category} + inputName="q" + locale={locale} + onValueChange={setValue} + placeholder={placeholder} + scope={scope} + value={value} + /> + {language && <input type="hidden" name="language" value={language} />} + </form> + ) +} 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..4c144f70af3 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts @@ -0,0 +1,147 @@ +const LARGE_SCROLL_JUMP_PX = 16 + +const canScrollY = (element: Element, deltaY: number) => { + const { overflowY } = getComputedStyle(element) + if (overflowY !== 'auto' && overflowY !== 'scroll') return false + const maxScrollTop = element.scrollHeight - element.clientHeight + if (maxScrollTop <= 0) return false + if (deltaY > 0) return element.scrollTop < maxScrollTop - 1 + if (deltaY < 0) return element.scrollTop > 1 + return false +} + +/** + * 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. Visitor-initiated movement (wheel, touch, scrollbar) must still + * scroll the page while the field is focused. The suggestions popup is portaled + * onto `document.body`, so leftover wheel delta is forwarded to the page + * scroller when the popup cannot consume it. + */ +export function preserveStickySearchScroll(searchRoot: HTMLElement, container: HTMLElement) { + let stableScrollTop = container.scrollTop + let suppressing = false + let visitorScrolling = false + + const remember = () => { + if (!suppressing) stableScrollTop = container.scrollTop + } + + const restore = () => { + if (container.scrollTop === stableScrollTop) return + suppressing = true + container.scrollTop = stableScrollTop + requestAnimationFrame(() => { + suppressing = false + }) + } + + const markVisitorScroll = () => { + visitorScrolling = true + } + + const onWindowWheel = (event: WheelEvent) => { + if (!searchRoot.contains(document.activeElement) || event.deltaY === 0) return + markVisitorScroll() + if (!(event.target instanceof Node) || container.contains(event.target)) return + + let node: Element | null = + event.target instanceof Element ? event.target : event.target.parentElement + while (node && node !== document.documentElement) { + if (node === container) return + if (canScrollY(node, event.deltaY)) return + node = node.parentElement + } + + event.preventDefault() + container.scrollTop += event.deltaY + } + + const onScroll = () => { + if (suppressing) return + if (searchRoot.contains(document.activeElement) && !visitorScrolling) { + 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 + visitorScrolling = false + remember() + suppressing = true + requestAnimationFrame(() => { + restore() + suppressing = false + }) + } + + const onFocusIn = (event: FocusEvent) => { + if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return + visitorScrolling = false + 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 }) + + const onContainerPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node) || searchRoot.contains(event.target)) return + markVisitorScroll() + } + + container.addEventListener('scroll', onScroll, { passive: true }) + container.addEventListener('touchmove', markVisitorScroll, { passive: true }) + container.addEventListener('pointerdown', onContainerPointerDown) + searchRoot.addEventListener('pointerdown', onPointerDown, true) + searchRoot.addEventListener('focusin', onFocusIn) + searchRoot.addEventListener('input', onInput, true) + window.addEventListener('wheel', onWindowWheel, { passive: false }) + window.addEventListener('keydown', onKeyDown, true) + + return () => { + observer.disconnect() + container.removeEventListener('scroll', onScroll) + container.removeEventListener('touchmove', markVisitorScroll) + container.removeEventListener('pointerdown', onContainerPointerDown) + searchRoot.removeEventListener('pointerdown', onPointerDown, true) + searchRoot.removeEventListener('focusin', onFocusIn) + searchRoot.removeEventListener('input', onInput, true) + window.removeEventListener('wheel', onWindowWheel) + 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<Element | null>, + 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<typeof setTimeout> | 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<PluginsSearchParams>() 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..6fc23a6efbf 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -1,44 +1,20 @@ +import type { DehydratedState } from '@tanstack/react-query' import type { SearchParams } from 'nuqs/server' -import type { MarketplaceSearchParams } from './search-params' -import { dehydrate, HydrationBoundary } from '@tanstack/react-query' -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 { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' - -// The server side logic should move to marketplace's codebase so that we can get rid of Next.js - -async function getDehydratedState(searchParams?: Promise<SearchParams>) { - if (!searchParams) { - return - } - 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) }, - }), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), - }) - return dehydrate(queryClient) -} +import { HydrationBoundary } from '@tanstack/react-query' +import { prefetchMarketplaceDehydratedState } from './prefetch-marketplace-dehydrated-state' export async function HydrateQueryClient({ searchParams, + prefetchedState, children, }: { searchParams: Promise<SearchParams> | undefined + prefetchedState?: DehydratedState children: React.ReactNode }) { - const dehydratedState = await getDehydratedState(searchParams) + const dehydratedState = + prefetchedState === undefined + ? await prefetchMarketplaceDehydratedState(searchParams) + : prefetchedState return <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary> } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 98f1edd8de5..92baa6f3380 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,17 +1,15 @@ +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 { prefetchMarketplaceDehydratedState } from './prefetch-marketplace-dehydrated-state' +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<MarketplaceViewProps, 'banners'> & { + language?: string /** * Pass the search params from the request to prefetch data on the server. */ @@ -19,31 +17,54 @@ type MarketplaceProps = { } const Marketplace = async ({ - showInstallButton = false, - linkToMarketplaceDetail = false, - pluginTypeSwitchClassName, - isMarketplacePlatform = false, - marketplaceNav, + language, searchParams, + variant = 'default', + ...viewProps }: MarketplaceProps) => { - return ( - <TanStackQueryProvider> - <HydrateQueryClient searchParams={searchParams}> - <PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}> - <Description - isMarketplacePlatform={isMarketplacePlatform} - marketplaceNav={marketplaceNav} - /> - {!isMarketplacePlatform && ( - <StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} /> - )} - <ListWrapper - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - /> - </PluginInstallPermissionProviderGuard> + let trendingBanners: PluginBanner[] = [] + + if (variant === 'home') { + const locale = language ?? (await getLocaleOnServer()) + const prefetch = prefetchMarketplaceDehydratedState(searchParams) + + // Banners are decoration on a page whose point is the catalog. Overlap + // them with the catalog prefetch so the document waits at most one budget. + // A late banner resolution just misses this render; nothing waits on it. + await withinServerBudget( + Promise.all([ + fetchPluginBanners(locale) + .then((banners) => { + trendingBanners = banners + }) + .catch(() => { + // Keep the homepage available if Marketplace banner delivery is down. + }), + prefetch, + ]), + ) + + return ( + <HydrateQueryClient searchParams={undefined} prefetchedState={await prefetch}> + <MarketplaceView + {...viewProps} + banners={trendingBanners} + language={language} + variant={variant} + /> </HydrateQueryClient> - </TanStackQueryProvider> + ) + } + + return ( + <HydrateQueryClient searchParams={searchParams}> + <MarketplaceView + {...viewProps} + banners={trendingBanners} + language={language} + variant={variant} + /> + </HydrateQueryClient> ) } 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..5511b439f00 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 @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { ThemeProvider } from 'next-themes' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { trackMarketplaceSiteCardClick } from '@/utils/marketplace-site-track' import CardWrapper from '../card-wrapper' vi.mock('@/app/components/plugins/hooks', () => ({ @@ -45,10 +46,35 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permis useOptionalPluginInstallPermission: () => ({ canInstallPlugin: true }), })) +vi.mock('../../detail-dialog', () => ({ + default: ({ + isInstalled, + open, + onOpenChange, + }: { + isInstalled: boolean + open: boolean + onOpenChange: (open: boolean) => void + }) => + open ? ( + <div role="dialog" aria-label="marketplace detail" data-installed={isInstalled}> + <button type="button" onClick={() => onOpenChange(false)}> + close detail + </button> + </div> + ) : null, +})) + vi.mock('../../utils', () => ({ getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`, - getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) => - `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + trackMarketplaceSiteCardClick: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', })) const plugin = { @@ -91,13 +117,52 @@ 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') }) + it('opens marketplace detail from the card surface', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.click(screen.getByRole('button', { name: 'Plugin A' })) + + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument() + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) + + it('opens marketplace detail from the keyboard', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.tab() + expect(screen.getByRole('button', { name: 'Plugin A' })).toHaveFocus() + await user.keyboard('{Enter}') + + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument() + }) + + it('keeps install as its own action when the card is clicked through the install button', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })) + + expect(screen.getByTestId('install-modal')).toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: 'marketplace detail' })).not.toBeInTheDocument() + }) + it('links the card to its marketplace detail when explicitly enabled', () => { renderCardWrapper({ linkToMarketplaceDetail: true }) expect(screen.getByRole('link')).toHaveAttribute('href', '/detail/dify/plugin-a') + fireEvent.click(screen.getByRole('link')) + expect(trackMarketplaceSiteCardClick).toHaveBeenCalledWith({ + itemId: 'dify/plugin-a', + itemType: 'plugin', + itemName: 'Plugin A', + section: 'list', + }) }) it('renders install and marketplace detail actions when install button is shown', () => { @@ -107,7 +172,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 +187,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 +210,14 @@ describe('CardWrapper', () => { fireEvent.click(screen.getByTestId('close-install-modal')) expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() }) + + it('does not open the install confirmation 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' })) + + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument() + expect(screen.queryByTestId('install-modal')).not.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<string, Set<() => 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<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }[] = [] + const autoplayOptions: Record<string, unknown>[] = [] + + 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<string, unknown>) => { + 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: <div>Page content {index + 1}</div>, +})) + +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType<typeof vi.fn> + observe: ReturnType<typeof vi.fn> + 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(<Carousel pages={pages} deferMountPages />) + + 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(<Carousel pages={pages.slice(0, 3)} deferMountPages />) + + 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(<Carousel pages={pages} />) + + 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( + <Carousel pages={pages} autoPlay deferMountPages pauseWhenOffscreen />, + { 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(<Carousel pages={pages} autoPlay />) + + 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(<Carousel pages={pages} autoPlay />) + const autoplay = mocks.autoplayInstances[0]! + + expect(autoplay.stop).toHaveBeenCalled() + expect(autoplay.play).not.toHaveBeenCalled() + + reducedMotion = false + act(() => reducedMotionListener?.()) + + expect(autoplay.play).toHaveBeenCalled() + }) + + it('keeps off-screen pages out of the tab order and accessibility tree', () => { + render(<Carousel pages={pages} ariaLabel="Featured tools" />) + + 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(<Carousel pages={pages} autoPlay />) + 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(<Carousel pages={pages.slice(0, 1)} autoPlay deferMountPages pauseWhenOffscreen />) + 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(<Carousel pages={pages.slice(0, 1)} autoPlay />) + 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<string, string> = { + '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 }) => <div>{plugin.name}</div>, +})) + +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( + <div + data-testid="collection-shell" + data-marketplace-standalone={standalone || undefined} + style={{ width }} + > + <ListWithCollection + marketplaceCollections={[partnerCollection]} + marketplaceCollectionPluginsMap={{ partners: partnerPlugins.slice(0, pluginCount) }} + /> + </div>, + ) + +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( + <div className="w-[1200px]" data-marketplace-standalone> + <ListWithCollection + marketplaceCollections={[partnerCollection]} + marketplaceCollectionPluginsMap={{ partners: partnerPlugins }} + /> + </div>, + ) + + 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 02df8e88684..6cd03ac2bb7 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<string, Plugin[]> = { empty: [], } +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType<typeof vi.fn> + observe: ReturnType<typeof vi.fn> + 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( <ListWithCollection @@ -201,7 +276,9 @@ describe('ListWithCollection', () => { ) 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', { name: 'Featured' }) 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( + <ListWithCollection + marketplaceCollections={fixtureCollections} + marketplaceCollectionPluginsMap={fixturePluginsMap} + deferOffscreenCollections + />, + { 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( + <ListWithCollection + marketplaceCollections={collections} + marketplaceCollectionPluginsMap={pluginsMap} + deferOffscreenCollections + />, + ) + + 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( + <ListWithCollection + marketplaceCollections={fixtureCollections} + marketplaceCollectionPluginsMap={fixturePluginsMap} + />, + ) + + 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<string, Plugin[]>, + isLoading: false, + isRefreshing: false, + isError: false, + refetch: vi.fn(), + isFetchingNextPage: false, + page: 1, +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (_selector: unknown, options?: Record<string, unknown>) => + `${options?.num ?? 0} plugins found`, + }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () => <div>loading</div>, +})) + +vi.mock('../../sort-dropdown', () => ({ + default: () => <div>sort</div>, +})) + +vi.mock('../index', () => ({ + default: () => ( + <div data-testid="catalog-results" style={{ height: 900, paddingTop: 80 }}> + <span>Catalog result anchor</span> + </div> + ), +})) + +vi.mock('../../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('../../atoms', () => ({ + useSearchPluginText: () => [''], +})) + +function SearchResultsHarness() { + const [searchVersion, setSearchVersion] = useState(0) + + return ( + <div + data-search-version={searchVersion} + data-testid="marketplace-scroll-container" + style={{ height: 320, overflowY: 'auto' }} + > + <button + type="button" + style={{ position: 'sticky', top: 0, zIndex: 1 }} + onClick={() => { + mockMarketplaceData.plugins = [{ plugin_id: 'plugin-1', name: 'Search result' } as Plugin] + mockMarketplaceData.pluginsTotal = 1 + setSearchVersion((version) => version + 1) + }} + > + Type search + </button> + <div aria-hidden style={{ height: 220 }} /> + <ListWrapper /> + </div> + ) +} + +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(<SearchResultsHarness />) + 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<string, Plugin[]>, 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<string, unknown>) => { + 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 }) => ( <div data-testid="loading" className={className}> @@ -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(<NuqsWrapper>{ui}</NuqsWrapper>) +} + 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(<ListWrapper />) + renderListWrapper(<ListWrapper />) 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(<ListWrapper />) + renderListWrapper(<ListWrapper />) 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(<ListWrapper />) + + 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(<ListWrapper showInstallButton />) + renderListWrapper(<ListWrapper showInstallButton />) 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(<ListWrapper />) + renderListWrapper(<ListWrapper />) 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(<ListWrapper className="catalog-content-min-height" />) + + 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(<ListWrapper />) + + 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..d090a11e368 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,133 +1,148 @@ '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 { useGetLanguage } from '@/context/i18n' +import { renderI18nObject } from '@/i18n-config' 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 locale = useGetLanguage() 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], - ) + const pluginLabel = renderI18nObject(plugin.label, locale) || plugin.name // 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 + const cardBody = ( + <Card + key={plugin.name} + payload={plugin} + variant="marketplace" + footer={ + <CardMoreInfo downloadCount={plugin.install_count} tags={tagLabels} variant="marketplace" /> + } + /> + ) + + if (linkToMarketplaceDetail) { + const itemId = `${plugin.org}/${plugin.name}` - if (showInstallAction) { return ( - <div className="group relative cursor-pointer rounded-xl"> - <Card - key={plugin.name} - payload={plugin} - variant="marketplace" - footer={ - <CardMoreInfo - downloadCount={plugin.install_count} - tags={tagLabels} - variant="marketplace" - /> - } - /> + <Link + href={getPluginDetailLinkInMarketplace(plugin)} + className="block rounded-xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + onClick={() => { + trackMarketplaceSiteCardClick({ + itemId, + itemType: 'plugin', + itemName: pluginLabel, + section, + }) + }} + > + <div className="group relative rounded-xl" data-marketplace-card={plugin.plugin_id}> + {cardBody} + </div> + </Link> + ) + } + + return ( + <div + className="group relative cursor-pointer rounded-xl" + data-marketplace-card={plugin.plugin_id} + > + <button + type="button" + aria-label={pluginLabel} + className="absolute inset-0 z-[1] rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + onClick={showMarketplaceDetail} + /> + {cardBody} + {showInstallAction && ( <div className="pointer-events-none absolute right-[-0.5px] bottom-[-0.5px] left-[-0.5px] z-10 flex items-center gap-2 rounded-b-xl bg-linear-to-t from-components-panel-on-panel-item-bg-hover from-60% to-background-gradient-mask-transparent px-4 pt-8 pb-4 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"> <Button variant={isInstalled ? 'secondary' : 'primary'} className="min-w-0 flex-1 shadow-md" disabled={isInstalled} - onClick={isInstalled ? undefined : showInstallFromMarketplace} + onClick={(event) => { + event.stopPropagation() + if (!isInstalled) showInstallFromMarketplace() + }} > {isInstalled ? t(($) => $['task.installed'], { ns: 'plugin' }) : t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })} </Button> - <a - href={getPluginLinkInMarketplace(plugin, marketplaceLinkParams)} - target="_blank" - rel="noopener noreferrer" - className={cn(buttonVariants(), 'min-w-0 flex-1 shadow-xs backdrop-blur-[5px]')} + <Button + className="min-w-0 flex-1 shadow-xs backdrop-blur-[5px]" + onClick={(event) => { + event.stopPropagation() + showMarketplaceDetail() + }} > {t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })} - <span aria-hidden className="i-ri-arrow-right-up-line size-4" /> - </a> + </Button> </div> - {isShowInstallFromMarketplace && ( - <InstallFromMarketplace - manifest={plugin} - uniqueIdentifier={plugin.latest_package_identifier} - onClose={hideInstallFromMarketplace} - onSuccess={hideInstallFromMarketplace} - /> - )} - </div> - ) - } - - const card = ( - <div className="group relative rounded-xl"> - <Card - key={plugin.name} - payload={plugin} - variant="marketplace" - footer={ - <CardMoreInfo - downloadCount={plugin.install_count} - tags={tagLabels} - variant="marketplace" - /> - } + )} + <MarketplaceDetailDialog + isInstalled={isInstalled} + open={isShowMarketplaceDetail} + plugin={plugin} + onOpenChange={handleMarketplaceDetailOpenChange} /> + {isShowInstallFromMarketplace && ( + <InstallFromMarketplace + manifest={plugin} + uniqueIdentifier={plugin.latest_package_identifier} + onClose={hideInstallFromMarketplace} + onSuccess={hideInstallFromMarketplace} + /> + )} </div> ) - - if (!linkToMarketplaceDetail) return card - - return ( - <Link - href={getPluginDetailLinkInMarketplace(plugin)} - className="block rounded-xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - > - {card} - </Link> - ) } // Memoize the component to prevent unnecessary re-renders when props haven't changed 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 57570f7f7d8..5d4ffc9542f 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -1,39 +1,46 @@ '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<typeof useEmblaCarousel>[1] +export type CarouselPage = { + id: string + content: ReactNode +} type CarouselProps = { - 'aria-labelledby': string - children: React.ReactNode + pages: CarouselPage[] + ariaLabel?: string + 'aria-labelledby'?: 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) => ( <button - className={cn( - 'flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover', - disabled && 'cursor-not-allowed opacity-50 hover:bg-components-button-secondary-bg', - )} + type="button" + className="flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover" onClick={onClick} - disabled={disabled} - aria-label={`Scroll ${direction}`} + aria-label={label} > <span aria-hidden @@ -43,22 +50,23 @@ const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonPro ) type CarouselControlsProps = { - api: CarouselApi showPagination: boolean selectedIndex: number scrollNext: () => 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, @@ -70,7 +78,7 @@ const CarouselControls = ({ return ( <div className="absolute -top-10 right-0 flex items-center gap-3"> {showPagination && ( - <div className="flex items-center gap-1"> + <div className={cn(styles.pagination, 'flex items-center gap-1')}> {paginationItems.map((item, index) => ( <button key={item.id} @@ -80,22 +88,23 @@ const CarouselControls = ({ ? 'w-4 bg-components-button-primary-bg' : 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover', )} - onClick={() => api?.scrollTo(index)} - aria-label={`Go to page ${index + 1}`} + onClick={() => scrollTo(index)} + aria-label={t(($) => $['marketplace.carousel.goToPage'], { + ns: 'plugin', + page: index + 1, + })} /> ))} </div> )} <div className="flex items-center gap-1"> <NavButton - direction="left" - disabled={totalPages <= 1} + label={t(($) => $['marketplace.carousel.scrollPrevious'], { ns: 'plugin' })} onClick={scrollPrev} iconClassName="i-ri-arrow-left-s-line" /> <NavButton - direction="right" - disabled={totalPages <= 1} + label={t(($) => $['marketplace.carousel.scrollNext'], { ns: 'plugin' })} onClick={scrollNext} iconClassName="i-ri-arrow-right-s-line" /> @@ -104,45 +113,107 @@ 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 = ({ + pages, + ariaLabel, 'aria-labelledby': ariaLabelledBy, - children, className, showNavigation = true, showPagination = true, autoPlay = false, autoPlayInterval = 5000, + deferMountPages = false, + pauseWhenOffscreen = false, }: CarouselProps) => { - const plugins = useMemo(() => { - if (!autoPlay) return [] + const carouselRootRef = useRef<HTMLDivElement>(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<number[]>([]) + 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() @@ -153,28 +224,168 @@ 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 ( <div + ref={carouselRootRef} className={cn('relative', className)} role="region" aria-roledescription="carousel" + aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} > {showNavigation && ( <CarouselControls - api={api} showPagination={showPagination} selectedIndex={selectedIndex} scrollNext={scrollNext} scrollPrev={scrollPrev} scrollSnaps={scrollSnaps} + scrollTo={scrollTo} /> )} <div ref={carouselRef} className="overflow-hidden rounded-[inherit]"> <div className="flex" style={{ columnGap: '12px' }}> - {children} + {pages.map((page, index) => { + const isMounted = !deferMountPages || mountedPageIds.has(page.id) + const isCurrent = index === selectedIndex + + return ( + <div + key={page.id} + role="group" + aria-roledescription="slide" + aria-label={`${index + 1} / ${pages.length}`} + // Off-screen pages stay mounted for Embla, but must not be + // reachable through the tab order or the accessibility tree. + aria-hidden={!isCurrent} + inert={!isCurrent} + className={CAROUSEL_PAGE_CLASS} + data-carousel-page={page.id} + data-carousel-page-mounted={isMounted ? 'true' : 'false'} + style={{ scrollSnapAlign: 'start' }} + > + {isMounted ? page.content : null} + </div> + ) + })} </div> </div> </div> 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 2654bf6961b..ecebf4990e2 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, useId, useMemo, useState } from 'react' +import { useEffect, useId, 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<string> + 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,237 @@ 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<string> + deferMount: boolean +} + +const CollectionPlaceholder = ({ + cardContainerClassName, + count, +}: { + cardContainerClassName?: string + count: number +}) => ( + <div + aria-hidden + className={cn('mt-2', GRID_CLASS, cardContainerClassName)} + data-marketplace-collection-placeholder + > + {Array.from({ length: count }, (_, index) => ( + <div + key={index} + className="h-[148px] min-w-0 rounded-xl border border-components-panel-border-subtle bg-background-default-subtle" + /> + ))} + </div> +) + +const CollectionSection = ({ + collection, + plugins, + itemsPerPage, + showInstallButton, + linkToMarketplaceDetail, + cardContainerClassName, + cardRender, + onMoreClick, + installedPluginIds, + deferMount, +}: CollectionSectionProps) => { + const { t } = useTranslation() + const locale = useLocale() + const collectionLabelId = useId() + const sectionRef = useRef<HTMLDivElement>(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: ( + <div className={cn(GRID_CLASS, cardContainerClassName)}> + {pageItems.map((plugin) => ( + <div key={plugin.plugin_id} className="min-w-0 *:w-full"> + <PluginCard + plugin={plugin} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardRender={cardRender} + isInstalled={installedPluginIds?.has(plugin.plugin_id)} + section={collection.name} + /> + </div> + ))} + </div> + ), + })), + [ + cardContainerClassName, + cardRender, + collection.name, + installedPluginIds, + itemsPerPage, + linkToMarketplaceDetail, + pages, + showInstallButton, + ], + ) + + return ( + <div ref={sectionRef} className="py-3" data-marketplace-collection={collection.name}> + <div className="flex items-end justify-between"> + <div + className={cn( + isPartnersCollection && styles.partnerHeader, + isPartnersCollection && hasMultiplePages && styles.partnerHeaderWithNavigation, + )} + > + <div + id={collectionLabelId} + className={cn( + 'title-xl-semi-bold text-text-primary', + isPartnersCollection && styles.partnerTitle, + )} + > + {collection.label[getLanguage(locale)]} + </div> + <div + className={cn( + 'flex items-center gap-x-2 system-xs-regular text-text-tertiary', + isPartnersCollection && styles.partnerMetadata, + )} + > + {isPartnersCollection ? ( + <span className={styles.partnerDescription}> + {collection.description[getLanguage(locale)]} + </span> + ) : ( + collection.description[getLanguage(locale)] + )} + {isPartnersCollection && ( + <> + <span className={cn(styles.partnerSeparator, 'text-divider-regular')}>|</span> + <a + href={BECOME_PARTNER_URL} + target="_blank" + rel="noopener noreferrer" + className={cn( + styles.partnerAction, + 'flex items-center gap-x-0.5 text-text-accent hover:underline', + )} + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'Become a Partner', + }) + }} + > + <span className={styles.partnerActionLabel}> + {t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })} + </span> + <span + aria-hidden + className={cn(styles.partnerActionIcon, 'i-ri-external-link-line size-3')} + /> + </a> + </> + )} + </div> + </div> + {collection.searchable && !hasMultiplePages && ( + <button + type="button" + className="flex cursor-pointer items-center system-xs-medium text-text-accent" + onClick={() => onMoreClick(collection.search_params)} + > + {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + </button> + )} + </div> + {!isMounted ? ( + <CollectionPlaceholder + cardContainerClassName={cardContainerClassName} + count={Math.min(plugins.length, itemsPerPage, MAX_PLACEHOLDER_CARDS)} + /> + ) : hasMultiplePages ? ( + <Carousel + pages={carouselPages} + aria-labelledby={collectionLabelId} + className="mt-2" + showNavigation + showPagination + autoPlay={isPartnersCollection} + autoPlayInterval={5000} + deferMountPages={deferMount} + pauseWhenOffscreen={deferMount} + /> + ) : ( + <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> + {plugins.map((plugin) => ( + <PluginCard + key={plugin.plugin_id} + plugin={plugin} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardRender={cardRender} + isInstalled={installedPluginIds?.has(plugin.plugin_id)} + section={collection.name} + /> + ))} + </div> + )} + </div> + ) +} + const ListWithCollection = ({ marketplaceCollections, marketplaceCollectionPluginsMap, @@ -78,121 +297,33 @@ const ListWithCollection = ({ cardRender, onCollectionMoreClick, installedPluginIds, + deferOffscreenCollections = false, }: ListWithCollectionProps) => { - const { t } = useTranslation() - const locale = useLocale() - const collectionLabelPrefixId = useId() 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) - const collectionLabelId = `${collectionLabelPrefixId}-${encodeURIComponent(collection.name)}` - - return ( - <div key={collection.name} className="py-3"> - <div className="flex items-end justify-between"> - <div> - <div id={collectionLabelId} className="title-xl-semi-bold text-text-primary"> - {collection.label[getLanguage(locale)]} - </div> - <div className="flex items-center gap-x-2 system-xs-regular text-text-tertiary"> - {collection.description[getLanguage(locale)]} - {isPartnersCollection && ( - <> - <span className="text-divider-regular">|</span> - <a - href={BECOME_PARTNER_URL} - target="_blank" - rel="noopener noreferrer" - className="flex items-center gap-x-0.5 text-text-accent hover:underline" - > - <span>{t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })}</span> - <span aria-hidden className="i-ri-external-link-line size-3" /> - </a> - </> - )} - </div> - </div> - {collection.searchable && !hasMultiplePages && ( - <div - className="flex cursor-pointer items-center system-xs-medium text-text-accent" - onClick={() => handleMoreClick(collection.search_params)} - > - {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} - <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> - </div> - )} - </div> - {hasMultiplePages ? ( - <Carousel - aria-labelledby={collectionLabelId} - className="mt-2" - showNavigation - showPagination - autoPlay - autoPlayInterval={5000} - > - {pages.map((pageItems) => ( - <div - key={pageItems.map((plugin) => plugin.plugin_id).join('-')} - className={CAROUSEL_PAGE_CLASS} - style={{ scrollSnapAlign: 'start' }} - > - <div className={cn(GRID_CLASS, cardContainerClassName)}> - {pageItems.map((plugin) => ( - <div key={plugin.plugin_id} className="min-w-0 *:w-full"> - <PluginCard - plugin={plugin} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - cardRender={cardRender} - isInstalled={installedPluginIds?.has(plugin.plugin_id)} - /> - </div> - ))} - </div> - </div> - ))} - </Carousel> - ) : ( - <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> - {plugins.map((plugin) => ( - <PluginCard - key={plugin.plugin_id} - plugin={plugin} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - cardRender={cardRender} - isInstalled={installedPluginIds?.has(plugin.plugin_id)} - /> - ))} - </div> - )} - </div> - ) - })} - </> - ) + return marketplaceCollections + .filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length) + .map((collection, index) => ( + <CollectionSection + key={collection.name} + collection={collection} + plugins={marketplaceCollectionPluginsMap[collection.name]!} + itemsPerPage={itemsPerPage} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardContainerClassName={cardContainerClassName} + cardRender={cardRender} + onMoreClick={handleMoreClick} + installedPluginIds={installedPluginIds} + // The first collection is above-the-fold content: it must render its + // cards in the server-rendered HTML so a direct visit shows real + // content without waiting for client-side JS. Only collections below + // it defer to the IntersectionObserver. + deferMount={deferOffscreenCollections && 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 ( <div style={{ + // The first live-search response inserts the result summary above the + // existing grid. Keep Chromium from treating a card in this dynamic + // region as the scroll anchor and compensating by moving the page. + overflowAnchor: 'none', scrollbarGutter: 'stable', paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))', }} - className="relative flex grow flex-col bg-background-default-subtle px-8 py-2" + className={cn( + 'relative flex grow flex-col bg-background-default-subtle px-8 py-2', + className, + )} > <div className="flex w-full grow flex-col"> {plugins && ( @@ -40,14 +92,34 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper <SortDropdown /> </div> )} - {(!isLoading || page > 1) && ( - <List - marketplaceCollections={marketplaceCollections || []} - marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} - plugins={plugins} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - /> + {isError && !plugins?.length ? ( + <div className="flex min-h-60 flex-col items-center justify-center gap-3 text-sm text-text-tertiary"> + <span>{t(($) => $['marketplace.loadError'], { ns: 'plugin' })}</span> + <Button size="small" variant="secondary" onClick={() => void refetch()}> + {t(($) => $['operation.retry'], { ns: 'common' })} + </Button> + </div> + ) : ( + // 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. + <div + className={cn( + 'flex grow flex-col transition-opacity duration-150', + isRefreshing && 'opacity-60', + )} + aria-busy={isRefreshing || undefined} + > + <List + marketplaceCollections={marketplaceCollections || []} + marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} + plugins={plugins} + deferOffscreenCollections={deferOffscreenCollections} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardSection={searchPluginText ? 'search' : 'list'} + /> + </div> )} </div> {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 ? <PluginIcon className={iconClassName} /> : null, + icon: isHero || isHome ? <PluginIcon className={iconClassName} /> : null, }, { value: PLUGIN_TYPE_SEARCH_MAP.model, text: t(($) => $['category.models'], { ns: 'plugin' }), - icon: <RiBrain2Line className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-brain-2-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.tool, text: t(($) => $['category.tools'], { ns: 'plugin' }), - icon: <RiHammerLine className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-hammer-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.datasource, - text: t(($) => $['category.datasources'], { ns: 'plugin' }), - icon: <RiDatabase2Line className={iconClassName} />, + text: t(($) => $[isHome ? 'categorySingle.datasource' : 'category.datasources'], { + ns: 'plugin', + }), + icon: <span aria-hidden className={cn('i-ri-database-2-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.agent, - text: t(($) => $['category.agents'], { ns: 'plugin' }), - icon: <RiSpeakAiLine className={iconClassName} />, + text: t(($) => $[isHome ? 'categorySingle.agent' : 'category.agents'], { ns: 'plugin' }), + icon: ( + <span + aria-hidden + className={cn('i-custom-vender-integrations-agent-strategy', iconClassName)} + /> + ), }, { value: PLUGIN_TYPE_SEARCH_MAP.trigger, text: t(($) => $['category.triggers'], { ns: 'plugin' }), - icon: <TriggerIcon className={iconClassName} />, + icon: ( + <span aria-hidden className={cn('i-custom-vender-integrations-trigger', iconClassName)} /> + ), }, { value: PLUGIN_TYPE_SEARCH_MAP.extension, text: t(($) => $['category.extensions'], { ns: 'plugin' }), - icon: <RiPuzzle2Line className={iconClassName} />, - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.bundle, - text: t(($) => $['category.bundles'], { ns: 'plugin' }), - icon: <RiArchive2Line className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-puzzle-2-line', iconClassName)} />, }, ] @@ -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/prefetch-marketplace-dehydrated-state.ts b/web/app/components/plugins/marketplace/prefetch-marketplace-dehydrated-state.ts new file mode 100644 index 00000000000..8ccfad85fe3 --- /dev/null +++ b/web/app/components/plugins/marketplace/prefetch-marketplace-dehydrated-state.ts @@ -0,0 +1,50 @@ +import type { SearchParams } from 'nuqs/server' +import type { MarketplaceSearchParams } from './search-params' +import { dehydrate, noop } from '@tanstack/react-query' +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 { getMarketplacePluginsInfiniteQueryOptions } from './query-options' +import { + getMarketplacePluginsSearchParams, + marketplaceSearchParamsParsers, + shouldSearchMarketplacePlugins, +} from './search-params' +import { withinServerBudget } from './server-budget' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' + +export async function prefetchMarketplaceDehydratedState(searchParams?: Promise<SearchParams>) { + if (!searchParams) { + return + } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) + const params: MarketplaceSearchParams = await loadSearchParams(searchParams) + + const queryClient = getQueryClient() + + if (shouldSearchMarketplacePlugins(params)) { + await withinServerBudget( + queryClient + .infiniteQuery( + getMarketplacePluginsInfiniteQueryOptions(getMarketplacePluginsSearchParams(params)), + ) + .catch(noop), + ) + return dehydrate(queryClient) + } + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) return + + await withinServerBudget( + queryClient + .query({ + queryKey: marketplaceQuery.collections.queryKey({ + input: { query: getCollectionsParams(params.category) }, + }), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + .catch(noop), + ) + return dehydrate(queryClient) +} 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(<SearchBoxWrapper />) + render(<SearchBoxWrapper searchIconName="i-ri-search-line" />) 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({ <span aria-hidden className={cn( - 'i-ri-search-line', + searchIconName, 'size-4 text-components-input-text-placeholder', searchIconClassName, )} diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index a1d3c76dfbc..31ed25cfdd0 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -8,6 +8,7 @@ type SearchBoxWrapperProps = { wrapperClassName?: string inputClassName?: string inputElementClassName?: string + searchIconName?: string searchIconClassName?: string placeholder?: string showTags?: boolean @@ -18,6 +19,7 @@ const SearchBoxWrapper = ({ wrapperClassName = 'z-11 mx-auto w-[640px] shrink-0', inputClassName = 'w-full', inputElementClassName, + searchIconName, searchIconClassName, placeholder, showTags = true, @@ -32,6 +34,7 @@ const SearchBoxWrapper = ({ wrapperClassName={wrapperClassName} inputClassName={inputClassName} inputElementClassName={inputElementClassName} + searchIconName={searchIconName} searchIconClassName={searchIconClassName} search={searchPluginText} onSearchChange={handleSearchPluginTextChange} diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx index 1c62e2b8d92..fba271a5061 100644 --- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx +++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx @@ -7,6 +7,7 @@ import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from '#i18n' import { useTags } from '@/app/components/plugins/hooks' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' import MarketplaceTrigger from './trigger/marketplace' import ToolSelectorTrigger from './trigger/tool-selector' @@ -24,6 +25,17 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte option.label.toLowerCase().includes(searchText.toLowerCase()), ) const selectedTagsLength = tags.length + const handleTagsChange = (nextTags: string[]) => { + 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 ( <Popover open={open} onOpenChange={setOpen}> @@ -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} /> )} <PopoverContent @@ -74,7 +86,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte <CheckboxGroup aria-label={t(($) => $.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<ActivePluginType>( 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<typeof marketplaceSearchParamsParsers> + +export const shouldSearchMarketplacePlugins = ({ + category, + q, + tags, +}: Pick<MarketplaceSearchParams, 'category' | 'q' | 'tags'>) => + Boolean(q || tags.length > 0 || !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(category)) + +export const getMarketplacePluginsSearchParams = ( + { category, q, tags }: Pick<MarketplaceSearchParams, 'category' | 'q' | 'tags'>, + 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..5e8c3627899 --- /dev/null +++ b/web/app/components/plugins/marketplace/server-budget.ts @@ -0,0 +1,36 @@ +/** + * 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. + * + * Homepage (`variant="home"`) overlaps banners and catalog prefetch under one + * budget so a slow banner cannot add a second 2.5s onto the catalog wait. + */ +export const SERVER_PREFETCH_BUDGET_MS = 2_500 + +export async function withinServerBudget(work: Promise<unknown>): Promise<void> { + let cancelBudget = () => {} + try { + await Promise.race([ + work, + new Promise<void>((resolve) => { + const timer = setTimeout(resolve, SERVER_PREFETCH_BUDGET_MS) + cancelBudget = () => clearTimeout(timer) + }), + ]) + } finally { + cancelBudget() + } +} diff --git a/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts new file mode 100644 index 00000000000..7268b30e558 --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vite-plus/test' +import { standaloneMarketplaceClient } from '../client' +import { standaloneMarketplaceServer } from '../server' + +vi.mock('../../index', () => ({ default: () => null })) +vi.mock('../../hydration-server', () => ({ + HydrateQueryClient: () => null, +})) + +vi.mock('../../prefetch-marketplace-dehydrated-state', () => ({ + prefetchMarketplaceDehydratedState: vi.fn(), +})) + +describe('standalone Marketplace host entry', () => { + it('exports the client search surface', () => { + expect(standaloneMarketplaceClient.MarketplaceLiveSearch).toEqual(expect.any(Function)) + expect(standaloneMarketplaceClient.MarketplaceSearchAutocomplete).toEqual(expect.any(Function)) + }) + + it('exports the server prefetch helpers and creator model', () => { + expect(standaloneMarketplaceServer.withinServerBudget).toEqual(expect.any(Function)) + expect(standaloneMarketplaceServer.prefetchMarketplaceDehydratedState).toEqual( + expect.any(Function), + ) + expect(standaloneMarketplaceServer.SERVER_PREFETCH_BUDGET_MS).toBeGreaterThan(0) + expect(standaloneMarketplaceServer.parseCreatorSortField('popularity')).toBe('popularity') + }) +}) diff --git a/web/app/components/plugins/marketplace/standalone/client.ts b/web/app/components/plugins/marketplace/standalone/client.ts new file mode 100644 index 00000000000..16cca794a4f --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/client.ts @@ -0,0 +1,17 @@ +'use client' + +/** + * Public client surface for the standalone Marketplace host (dify-marketplace). + * Import this module instead of treating private Marketplace paths as Knip entries. + */ +import MarketplaceLiveSearch from '../home/marketplace-live-search' +import { + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} from '../home/marketplace-search-autocomplete' + +export const standaloneMarketplaceClient = { + MarketplaceLiveSearch, + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} diff --git a/web/app/components/plugins/marketplace/standalone/server.ts b/web/app/components/plugins/marketplace/standalone/server.ts new file mode 100644 index 00000000000..8e3a27a39f9 --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/server.ts @@ -0,0 +1,36 @@ +/** + * Public server surface for the standalone Marketplace host (dify-marketplace). + * Import this module instead of treating private Marketplace paths as Knip entries. + */ +import { + adaptCreatorProfile, + CREATOR_SORT_FIELDS, + DEFAULT_CREATOR_SORT_FIELD, + DEFAULT_CREATOR_SORT_ORDER, + getStandaloneCreationHref, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, + toPublisherSortQuery, +} from '../creator-profile/model' +import { HydrateQueryClient } from '../hydration-server' +import Marketplace from '../index' +import { prefetchMarketplaceDehydratedState } from '../prefetch-marketplace-dehydrated-state' +import { SERVER_PREFETCH_BUDGET_MS, withinServerBudget } from '../server-budget' + +export const standaloneMarketplaceServer = { + Marketplace, + HydrateQueryClient, + prefetchMarketplaceDehydratedState, + SERVER_PREFETCH_BUDGET_MS, + withinServerBudget, + adaptCreatorProfile, + CREATOR_SORT_FIELDS, + DEFAULT_CREATOR_SORT_FIELD, + DEFAULT_CREATOR_SORT_ORDER, + getStandaloneCreationHref, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, + toPublisherSortQuery, +} 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<string>() + 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..d34e28811fb --- /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: () => <div aria-hidden />, +})) + +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( + <ThemeProvider forcedTheme="dark"> + <TemplateCard partnerText="Verified by a Dify partner" template={template} /> + </ThemeProvider>, + ) + + 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.getByText('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 }) => <div>{template.template_name}</div>, +})) + +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( + <div + data-testid="collection-shell" + data-marketplace-standalone={standalone || undefined} + style={{ width }} + > + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: partnerTemplates.slice(0, templateCount) }} + viewMoreText="View more" + /> + </div>, + ) + +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( + <div className="w-[1200px]" data-marketplace-standalone> + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: partnerTemplates }} + viewMoreText="View more" + /> + </div>, + ) + + 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 }) => ( + <div data-testid="template-card">{template.template_name}</div> + ), +})) + +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( + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[featuredCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ featured: buildTemplates('Featured', 9) }} + viewMoreText="View more" + />, + ) + + 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( + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: buildTemplates('Partner', 9) }} + viewMoreText="View more" + />, + ) + + 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..da35a40ec91 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/index.tsx @@ -0,0 +1,299 @@ +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 { 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 { 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 ( + <div className="flex min-h-60 items-center justify-center rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary"> + {children} + </div> + ) +} + +function TemplateGrid({ + partnerText, + templates, +}: { + partnerText: string + templates: MarketplaceTemplate[] +}) { + return ( + <div className={GRID_CLASS}> + {templates.map((template) => ( + <TemplateCard key={template.id} partnerText={partnerText} template={template} /> + ))} + </div> + ) +} + +// 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 ( + <div className="flex min-h-60 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary"> + <span>{message}</span> + <a href={retryHref} className={PAGE_LINK_CLASS}> + {retryLabel} + </a> + </div> + ) +} + +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 = ( + <LoadErrorState + message={tPlugin(($) => $['marketplace.loadError'], { ns: 'plugin' })} + retryHref={currentHref} + retryLabel={tCommon(($) => $['operation.retry'], { ns: 'common' })} + /> + ) + + return ( + <HomeShell + banners={banners} + isMarketplacePlatform={false} + page="templates" + header={ + <HomeHeader + activeTab="templates" + catalogLabels={{ plugins: pluginsLabel, templates: templatesLabel }} + isMarketplacePlatform={false} + /> + } + hero={ + <HomeHero + isMarketplacePlatform={false} + title={templatesLabel} + subtitle={tExplore(($) => $['apps.description'], { ns: 'explore' })} + /> + } + search={<HomeSearch enableSearchShortcut={false} />} + navigation={ + <HomeCatalogNavigation + isMarketplacePlatform={false} + catalogTabs={ + <HomeCatalogTabs + activeTab="templates" + isMarketplacePlatform={false} + labels={{ plugins: pluginsLabel, templates: templatesLabel }} + /> + } + catalogCategories={ + <TemplateCategoryNavigation + activeCategory={category} + ariaLabel={tPlugin(($) => $.allCategories, { ns: 'plugin' })} + labels={categoryLabels} + languages={selectedLanguages} + query={query} + /> + } + catalogTrailing={<CatalogLanguagesFilter />} + /> + } + > + {/* The app shell already renders the main landmark; use a plain div + to avoid nested main elements. */} + <div + className={cn( + 'relative flex grow flex-col bg-background-default px-8 py-2', + styles.catalogContent, + )} + > + {loadFailed ? ( + loadErrorState + ) : collectionsResult ? ( + hasVisibleCollections ? ( + <TemplateCollectionList + becomePartnerText={tPlugin(($) => $['marketplace.becomePartner'], { + ns: 'plugin', + })} + collections={collectionsResult.collections} + locale={locale} + partnerText={partnerText} + templatesByCollection={visibleTemplatesByCollection} + viewMoreText={tPlugin(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + /> + ) : ( + <EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState> + ) + ) : ( + <> + {/* 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. */} + <div className="mb-5 text-right text-sm text-text-tertiary"> + {tExplore(($) => $['apps.resultNum'], { ns: 'explore', num: templates.length })} + </div> + {templates.length > 0 ? ( + <TemplateGrid partnerText={partnerText} templates={templates} /> + ) : ( + <EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState> + )} + <TemplatePagination + category={category} + languages={selectedLanguages} + navigationLabel={tCommon(($) => $['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} + /> + </> + )} + </div> + </HomeShell> + ) +} 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..de0a577e62b --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-card.tsx @@ -0,0 +1,111 @@ +'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 ( + <> + <article + className={cn( + 'relative flex h-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md', + className, + )} + > + <button + type="button" + aria-label={template.template_name} + className="absolute inset-0 z-[1] cursor-pointer rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + onClick={showDetail} + /> + <div className="relative z-0 flex shrink-0 items-center gap-3 px-4 pt-4 pb-2"> + <AppIcon + size="large" + iconType={imageUrl ? 'image' : 'emoji'} + icon={imageUrl ? undefined : template.icon || '📄'} + imageUrl={imageUrl} + background={template.icon_background} + /> + <div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5"> + <div className="flex items-center"> + <span className="truncate text-left system-md-medium text-text-primary"> + {template.template_name} + </span> + {template.badges?.includes('partner') && ( + <Partner className="relative z-[2] ml-0.5 size-4 shrink-0" text={partnerText} /> + )} + </div> + <div className="flex items-center gap-2 system-xs-regular text-text-tertiary"> + {publisher && <span className="truncate">{publisher}</span>} + {publisher && <span>·</span>} + <span>{formatNumberAbbreviated(template.usage_count)}</span> + </div> + </div> + </div> + <div className="min-h-8 px-4 pt-1 pb-2 system-xs-regular text-text-secondary"> + <p className="line-clamp-2" title={template.overview}> + {template.overview} + </p> + </div> + <div className="mt-auto flex min-h-7 items-center gap-1 px-4 py-1"> + {visiblePlugins.map((pluginId) => ( + <img + key={pluginId} + className="size-6 rounded-md border-[0.5px] border-effects-icon-border object-cover" + src={getIconFromMarketPlace(pluginId)} + alt="" + title={pluginId} + /> + ))} + {remainingPluginCount > 0 && ( + <span className="system-xs-regular text-text-tertiary">+{remainingPluginCount}</span> + )} + </div> + </article> + <TemplateDetailDialog + open={isDetailOpen} + template={template} + onInstall={handleInstall} + onOpenChange={handleOpenChange} + /> + </> + ) +} 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<TemplateCategory, string> + +export default function TemplateCategoryNavigation({ + activeCategory, + ariaLabel, + labels, + languages, + query, +}: { + activeCategory: TemplateCategory + ariaLabel: string + labels: TemplateCategoryLabels + languages: string[] + query: string +}) { + return ( + <nav + aria-label={ariaLabel} + className="flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto" + > + {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 ( + <MarketplaceFilterTrackLink + key={category} + href={href} + scroll={false} + aria-current={category === activeCategory ? 'page' : undefined} + filterType="category" + filterValue={category} + selectedValues={[category]} + trackFilter={category !== activeCategory} + className={cn( + 'flex h-8 min-w-12 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap text-text-tertiary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + pluginTypeStyles.homeItem, + category === activeCategory && pluginTypeStyles.homeItemActive, + )} + > + {labels[category]} + </MarketplaceFilterTrackLink> + ) + })} + </nav> + ) +} 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..ae3259f2bff --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx @@ -0,0 +1,176 @@ +'use client' + +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { cn } from '@langgenius/dify-ui/cn' +import { useId } from 'react' +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<string, MarketplaceTemplate[]> + 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() + const collectionLabelPrefixId = useId() + + 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 + const collectionLabelId = `${collectionLabelPrefixId}-${encodeURIComponent(collection.name)}` + + return ( + <section key={collection.name} className="py-3"> + <div className="mb-2 flex items-end justify-between gap-4"> + <div + className={cn( + 'min-w-0', + isPartnerCollection && styles.partnerHeader, + isPartnerCollection && hasMultiplePages && styles.partnerHeaderWithNavigation, + )} + > + <h2 + id={collectionLabelId} + className={cn( + 'title-xl-semi-bold text-text-primary', + isPartnerCollection && styles.partnerTitle, + )} + > + {getTemplateCollectionText(collection.label, locale)} + </h2> + <div + className={cn( + 'flex flex-wrap items-center gap-x-2 system-xs-regular text-text-tertiary', + isPartnerCollection && styles.partnerMetadata, + )} + > + {isPartnerCollection ? ( + <span className={styles.partnerDescription}> + {getTemplateCollectionText(collection.description, locale)} + </span> + ) : ( + getTemplateCollectionText(collection.description, locale) + )} + {isPartnerCollection && ( + <> + <span className={cn(styles.partnerSeparator, 'text-divider-regular')}>|</span> + <a + href={BECOME_PARTNER_URL} + target="_blank" + rel="noopener noreferrer" + className={cn( + styles.partnerAction, + 'flex items-center gap-x-0.5 text-text-accent hover:underline', + )} + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'Become a Partner', + }) + }} + > + <span className={styles.partnerActionLabel}>{becomePartnerText}</span> + <span + aria-hidden + className={cn(styles.partnerActionIcon, 'i-ri-external-link-line size-3')} + /> + </a> + </> + )} + </div> + </div> + {collection.searchable && ( + <Link + href={getViewMoreHref(collection)} + className="flex shrink-0 items-center system-xs-medium text-text-accent hover:underline" + > + {viewMoreText} + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + </Link> + )} + </div> + {collection.searchable ? ( + <div className={GRID_CLASS}> + {templates.slice(0, 4).map((template) => ( + <TemplateCard key={template.id} partnerText={partnerText} template={template} /> + ))} + </div> + ) : ( + <Carousel + pages={Array.from( + { length: Math.ceil(templates.length / itemsPerPage) }, + (_, pageIndex) => { + const pageTemplates = templates.slice( + pageIndex * itemsPerPage, + (pageIndex + 1) * itemsPerPage, + ) + + return { + id: `${collection.name}-${itemsPerPage}-${pageIndex}`, + content: ( + <div className={cn(GRID_CLASS)}> + {pageTemplates.map((template) => ( + <div key={template.id} className="min-w-0 *:w-full"> + <TemplateCard partnerText={partnerText} template={template} /> + </div> + ))} + </div> + ), + } + }, + )} + aria-labelledby={collectionLabelId} + showNavigation + showPagination + autoPlay={isPartnerCollection} + autoPlayInterval={5000} + pauseWhenOffscreen + /> + )} + </section> + ) + }) +} 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 ( + <MarketplaceDetailDialogFrame + open={open} + src={detailURL} + title={`${template.template_name} · ${detailLabel}`} + onMessage={handleMessage} + onOpenChange={onOpenChange} + /> + ) +} 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<MarketplaceTemplate, 'preferred_languages'>, +>(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<string, string>, 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 ( + <nav aria-label={navigationLabel} className="mt-6 flex items-center justify-center gap-3 pb-4"> + {page > 1 ? ( + <Link href={buildHref(page - 1)} className={PAGE_LINK_CLASS}> + {previousLabel} + </Link> + ) : ( + <span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}> + {previousLabel} + </span> + )} + <span aria-current="page" className="system-sm-regular text-text-tertiary"> + {page} / {pageCount} + </span> + {page < pageCount ? ( + <Link href={buildHref(page + 1)} className={PAGE_LINK_CLASS}> + {nextLabel} + </Link> + ) : ( + <span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}> + {nextLabel} + </span> + )} + </nav> + ) +} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index acc77a84ebf..5f1462e06e2 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' @@ -16,6 +16,46 @@ type MarketplaceFetchOptions = { signal?: AbortSignal } +// Matches backend warmup homepageCollectionPluginsRequests Limit: 20 so the +// public POST hits the Redis bucket the scheduler already writes. +export const COLLECTION_PREVIEW_PLUGIN_LIMIT = 20 + +type MarketplacePluginListExtras = { + agent_strategy?: unknown + data_sources?: unknown + model?: unknown + plugins?: unknown + privacy_options?: unknown + privacy_policy?: unknown + readme_meta?: unknown + resource?: unknown + tool?: unknown + triggers?: unknown +} + +export const toListPlugin = (plugin: Plugin): Plugin => { + const { + agent_strategy: _agentStrategy, + data_sources: _dataSources, + introduction: _introduction, + model: _model, + plugins: _plugins, + privacy_options: _privacyOptions, + privacy_policy: _privacyPolicy, + readme_meta: _readmeMeta, + resource: _resource, + tool: _tool, + triggers: _triggers, + ...listFields + } = plugin as Plugin & MarketplacePluginListExtras + + return { + ...listFields, + introduction: '', + endpoint: { settings: [] }, + } +} + export function buildCarouselPages<T>(items: T[], itemsPerPage: number): T[][] { const pages: T[][] = [] @@ -70,72 +110,111 @@ export const getPluginDetailLinkInMarketplace = ( return `/plugin/${org}/${name}` } +export const getTemplateDetailLinkInMarketplace = ( + template: Pick< + MarketplaceTemplate, + 'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name' + >, +) => { + const publisher = template.publisher_handle || template.publisher_unique_handle || 'template' + const search = new URLSearchParams({ templateId: template.id }) + + return `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}?${search.toString()}` +} + +export const getTemplateLinkInMarketplace = ( + template: Pick< + MarketplaceTemplate, + 'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name' + >, + params?: Record<string, string | undefined>, +) => { + 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<string, string | undefined>, ) => { 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: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, ...query }, + }, + { + signal: options?.signal, + }, + ) - return plugins + return (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) => + toListPlugin(getFormattedPlugin(plugin)), + ) } export const getMarketplaceCollectionsAndPlugins = async ( query?: CollectionsAndPluginsSearchParams, options?: MarketplaceFetchOptions, ) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {} - 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<string, Plugin[]> = {} - 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 (error) { + if (options?.signal?.aborted) throw error + // One empty carousel beats a blank catalog: the collection list itself + // loaded, so render what did arrive. Cancellation must not take this + // path — react-query would cache the empty carousels as a success. + marketplaceCollectionPluginsMap[collection.name] = [] + } + } } + await Promise.all( + Array.from( + { length: Math.min(COLLECTION_PLUGINS_CONCURRENCY, pending.length) }, + fetchCollectionPlugins, + ), + ) return { marketplaceCollections, @@ -159,39 +238,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, } } @@ -226,11 +301,12 @@ export function getCollectionsParams( category: ActivePluginType, ): CollectionsAndPluginsSearchParams { if (category === PLUGIN_TYPE_SEARCH_MAP.all) { - return {} + return { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT } } return { category, condition: getMarketplaceListCondition(category), type: getMarketplaceListFilterType(category), + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, } } 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 ( + <PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}> + {variant === 'home' ? ( + <MarketplaceHome + actions={homeHeaderActions} + activePluginType={homeActivePluginType} + banners={banners} + catalogCategories={homeCatalogCategories} + catalogLabels={homeCatalogLabels} + search={homeSearch} + isMarketplacePlatform={isMarketplacePlatform} + language={language} + linkToMarketplaceDetail={linkToMarketplaceDetail} + showInstallButton={showInstallButton} + /> + ) : ( + <> + <Description + isMarketplacePlatform={isMarketplacePlatform} + marketplaceNav={marketplaceNav} + /> + {!isMarketplacePlatform && ( + <StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} /> + )} + <ListWrapper + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + /> + </> + )} + </PluginInstallPermissionProviderGuard> + ) +} diff --git a/web/app/components/plugins/plugin-page/nav-operations.tsx b/web/app/components/plugins/plugin-page/nav-operations.tsx index fb0341ac807..10f15c7cb97 100644 --- a/web/app/components/plugins/plugin-page/nav-operations.tsx +++ b/web/app/components/plugins/plugin-page/nav-operations.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import { MARKETPLACE_URL_PREFIX } from '@/config' @@ -75,28 +76,43 @@ 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) + const guideLabel = t(($) => $['marketplace.home.guide'], { ns: 'plugin' }) return ( <DropdownMenu open={open} onOpenChange={setOpen}> - <DropdownMenuTrigger - render={ - <IconButton - aria-label={t(($) => $.requestSubmit, { - ns: 'plugin', - defaultValue: t(($) => $.requestAPlugin, { ns: 'plugin' }), - })} - size="lg" - className="data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary" - > - <span aria-hidden className="i-ri-book-open-line size-4 shrink-0" /> - </IconButton> - } - /> + <Tooltip disabled={open}> + <DropdownMenuTrigger + render={ + <TooltipTrigger + render={ + <IconButton + aria-label={guideLabel} + size="lg" + className="data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary" + > + <span aria-hidden className="i-ri-book-open-line size-4 shrink-0" /> + </IconButton> + } + /> + } + /> + <TooltipContent placement="bottom" role="tooltip"> + {guideLabel} + </TooltipContent> + </Tooltip> <DropdownMenuContent placement="bottom-end" sideOffset={4} className="min-w-50 p-1"> {options.map((option, index) => ( <Fragment key={option.href}> @@ -112,3 +128,8 @@ export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdo </DropdownMenu> ) } + +export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) { + const docLink = useDocLink() + return <SubmitRequestDropdownMenu dividerAfterFirst={dividerAfterFirst} docLink={docLink} /> +} 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/signin/utils/__tests__/post-login-redirect.spec.ts b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts index 6457e90e8c0..e4671dc5c83 100644 --- a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts +++ b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts @@ -48,6 +48,18 @@ describe('post-login redirect utilities', () => { ).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<typeof resolvePostLoginRedirect>[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<string, ReadonlySet<string>> = { '/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/config/index.ts b/web/config/index.ts index f6e2883c6d0..018373396f6 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -11,6 +11,19 @@ const getStringConfig = (envVar: string | undefined, defaultValue: string) => { return defaultValue } +const isLocalMarketplaceApiUrl = (url: string | undefined) => + /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/)/i.test(url ?? '') + +// NEXT_PUBLIC_* is inlined at build. The standalone marketplace image sets +// MARKETPLACE_API_URL at runtime instead, so SSR must read that here or it +// falls through to localhost:5002 and creator pages throw. +const runtimeMarketplaceApiUrl = + typeof globalThis.window === 'undefined' && + process.env.MARKETPLACE_API_URL && + !isLocalMarketplaceApiUrl(process.env.MARKETPLACE_API_URL) + ? process.env.MARKETPLACE_API_URL + : undefined + export const API_PREFIX = getStringConfig( env.NEXT_PUBLIC_API_PREFIX, 'http://localhost:5001/console/api', @@ -20,7 +33,7 @@ export const PUBLIC_API_PREFIX = getStringConfig( 'http://localhost:5001/api', ) export const MARKETPLACE_API_PREFIX = getStringConfig( - env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, + runtimeMarketplaceApiUrl || env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, 'http://localhost:5002/api', ) export const MARKETPLACE_URL_PREFIX = getStringConfig(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX, '') diff --git a/web/global.d.ts b/web/global.d.ts index 5a68838920c..5e25133dd25 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -19,5 +19,22 @@ declare global { interface Window { gtag?: Gtag dataLayer?: unknown[] + /** + * Optional analytics bridge injected by the standalone Marketplace host. + * Absent in Dify console builds; see `utils/marketplace-site-track.ts`. + */ + __marketplaceTracking__?: { + track: (eventName: string, properties?: Record<string, unknown>) => 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/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index afda3c9652a..b348de9e896 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": "فتح قائمة المساعدة", @@ -670,6 +671,7 @@ "userProfile.about": "حول", "userProfile.compliance": "الامتثال", "userProfile.contactUs": "اتصل بنا", + "userProfile.discord": "Discord", "userProfile.emailSupport": "دعم البريد الإلكتروني", "userProfile.github": "GitHub", "userProfile.helpCenter": "عرض المستندات", diff --git a/web/i18n/ar-TN/plugin.json b/web/i18n/ar-TN/plugin.json index 5d61557b411..cacf0548c6c 100644 --- a/web/i18n/ar-TN/plugin.json +++ b/web/i18n/ar-TN/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "جميع الإضافات", "marketplace.and": "و", "marketplace.becomePartner": "كن شريكًا", + "marketplace.by": "بواسطة", + "marketplace.carousel.goToPage": "الانتقال إلى الصفحة {{page}}", + "marketplace.carousel.scrollNext": "الصفحة التالية", + "marketplace.carousel.scrollPrevious": "الصفحة السابقة", + "marketplace.creatorProfile.breadcrumbLabel": "مسار التنقل", + "marketplace.creatorProfile.creations": "الأعمال", + "marketplace.creatorProfile.empty": "لا توجد أعمال بعد.", + "marketplace.creatorProfile.home": "الصفحة الرئيسية للسوق", + "marketplace.creatorProfile.loadMore": "تحميل المزيد", + "marketplace.creatorProfile.loadMoreFailed": "تعذر تحميل المزيد من الأعمال.", + "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 973c59213ae..ebdddd038f1 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", @@ -670,6 +671,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/plugin.json b/web/i18n/de-DE/plugin.json index 7a60b2b7a49..170db5351bc 100644 --- a/web/i18n/de-DE/plugin.json +++ b/web/i18n/de-DE/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Alle Plugins", "marketplace.and": "und", "marketplace.becomePartner": "Partner werden", + "marketplace.by": "Von", + "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.loadMore": "Mehr laden", + "marketplace.creatorProfile.loadMoreFailed": "Weitere Kreationen konnten nicht geladen werden.", + "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 b25d1191ba5..76475612a39 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", @@ -670,6 +671,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/plugin.json b/web/i18n/en-US/plugin.json index 4c4577a2827..00405eb1d4c 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "All integrations", "marketplace.and": "and", "marketplace.becomePartner": "Become a Partner", + "marketplace.by": "by", + "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.loadMore": "Load more", + "marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.", + "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 35f58083a55..7b1b6adeff3 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", @@ -670,6 +671,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/plugin.json b/web/i18n/es-ES/plugin.json index c1f0551703f..b0961184051 100644 --- a/web/i18n/es-ES/plugin.json +++ b/web/i18n/es-ES/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Todas las integraciones", "marketplace.and": "y", "marketplace.becomePartner": "Conviértete en socio", + "marketplace.by": "Por", + "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.loadMore": "Cargar más", + "marketplace.creatorProfile.loadMoreFailed": "No se pudieron cargar más creaciones.", + "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 e25b7985cba..c974aef526d 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": "باز کردن منوی راهنما", @@ -670,6 +671,7 @@ "userProfile.about": "درباره", "userProfile.compliance": "انطباق", "userProfile.contactUs": "با ما تماس بگیرید", + "userProfile.discord": "Discord", "userProfile.emailSupport": "پشتیبانی ایمیل", "userProfile.github": "گیت‌هاب", "userProfile.helpCenter": "راهنما", diff --git a/web/i18n/fa-IR/plugin.json b/web/i18n/fa-IR/plugin.json index 762511f6284..fbd6f94a3e4 100644 --- a/web/i18n/fa-IR/plugin.json +++ b/web/i18n/fa-IR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "همه افزونه‌ها", "marketplace.and": "و", "marketplace.becomePartner": "شریک شوید", + "marketplace.by": "توسط", + "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.loadMore": "بارگذاری بیشتر", + "marketplace.creatorProfile.loadMoreFailed": "بارگذاری آثار بیشتر ممکن نشد.", + "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 2750b22825d..b842ca44a2b 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", @@ -670,6 +671,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/plugin.json b/web/i18n/fr-FR/plugin.json index 28cd0e54140..0c78219f4bc 100644 --- a/web/i18n/fr-FR/plugin.json +++ b/web/i18n/fr-FR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Toutes les intégrations", "marketplace.and": "et", "marketplace.becomePartner": "Devenir partenaire", + "marketplace.by": "Par", + "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.loadMore": "Charger plus", + "marketplace.creatorProfile.loadMoreFailed": "Impossible de charger plus de créations.", + "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 a63eebfa40b..a6c3ef31328 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": "सहायता मेनू खोलें", @@ -670,6 +671,7 @@ "userProfile.about": "के बारे में", "userProfile.compliance": "अनुपालन", "userProfile.contactUs": "संपर्क करें", + "userProfile.discord": "Discord", "userProfile.emailSupport": "सहायता", "userProfile.github": "गिटहब", "userProfile.helpCenter": "सहायता", diff --git a/web/i18n/hi-IN/plugin.json b/web/i18n/hi-IN/plugin.json index 3db1aa5f6b0..1e2c74c04e2 100644 --- a/web/i18n/hi-IN/plugin.json +++ b/web/i18n/hi-IN/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "सभी इंटीग्रेशन", "marketplace.and": "और", "marketplace.becomePartner": "भागीदार बनें", + "marketplace.by": "द्वारा", + "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.loadMore": "और लोड करें", + "marketplace.creatorProfile.loadMoreFailed": "और रचनाएँ लोड नहीं हो सकीं।", + "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 dd5e1bd3361..343f59965a8 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", @@ -670,6 +671,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/plugin.json b/web/i18n/id-ID/plugin.json index 9a8c974b659..fca1dd5e777 100644 --- a/web/i18n/id-ID/plugin.json +++ b/web/i18n/id-ID/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Semua integrasi", "marketplace.and": "dan", "marketplace.becomePartner": "Menjadi Partner", + "marketplace.by": "Oleh", + "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.loadMore": "Muat lebih banyak", + "marketplace.creatorProfile.loadMoreFailed": "Tidak dapat memuat lebih banyak karya.", + "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 b3b368485f2..df35dfb25f3 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", @@ -670,6 +671,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/plugin.json b/web/i18n/it-IT/plugin.json index 0ae8ae120ca..e14e547e1e4 100644 --- a/web/i18n/it-IT/plugin.json +++ b/web/i18n/it-IT/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Tutte le integrazioni", "marketplace.and": "e", "marketplace.becomePartner": "Diventa un partner", + "marketplace.by": "Di", + "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.loadMore": "Carica altro", + "marketplace.creatorProfile.loadMoreFailed": "Impossibile caricare altre creazioni.", + "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 b01f04d92b9..9456ebe50e1 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": "ヘルプメニューを開く", @@ -670,6 +671,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/plugin.json b/web/i18n/ja-JP/plugin.json index dc25586073a..3617103693e 100644 --- a/web/i18n/ja-JP/plugin.json +++ b/web/i18n/ja-JP/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "すべてのインテグレーション", "marketplace.and": "と", "marketplace.becomePartner": "パートナーになる", + "marketplace.by": "著者:", + "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.loadMore": "さらに読み込む", + "marketplace.creatorProfile.loadMoreFailed": "作品を追加で読み込めませんでした。", + "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 56fcb1e5ef9..788bc27b70c 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": "도움말 메뉴 열기", @@ -670,6 +671,7 @@ "userProfile.about": "Dify 소개", "userProfile.compliance": "컴플라이언스", "userProfile.contactUs": "문의하기", + "userProfile.discord": "Discord", "userProfile.emailSupport": "이메일 지원", "userProfile.github": "깃허브", "userProfile.helpCenter": "도움말 센터", diff --git a/web/i18n/ko-KR/plugin.json b/web/i18n/ko-KR/plugin.json index ef484a92dab..65634700c22 100644 --- a/web/i18n/ko-KR/plugin.json +++ b/web/i18n/ko-KR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "모든 플러그인", "marketplace.and": "그리고", "marketplace.becomePartner": "파트너 되기", + "marketplace.by": "저자", + "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.loadMore": "더 보기", + "marketplace.creatorProfile.loadMoreFailed": "작품을 더 불러오지 못했습니다.", + "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 c9331276ba2..0727087aea2 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": "ເປີດເມນູຊ່ວຍເຫຼືອ", @@ -670,6 +671,7 @@ "userProfile.about": "ກ່ຽວກັບ", "userProfile.compliance": "ການປະຕິບັດຕາມກົດລະບຽບ", "userProfile.contactUs": "ຕິດຕໍ່ພວກເຮົາ", + "userProfile.discord": "Discord", "userProfile.emailSupport": "ການຊ່ວຍເຫຼືອຜ່ານອີເມວ", "userProfile.github": "GitHub", "userProfile.helpCenter": "ເບິ່ງເອກະສານ", diff --git a/web/i18n/lo-LA/plugin.json b/web/i18n/lo-LA/plugin.json index 6737b98ac14..a7223e1380b 100644 --- a/web/i18n/lo-LA/plugin.json +++ b/web/i18n/lo-LA/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "ການເຊື່ອມຕໍ່ທັງໝົດ", "marketplace.and": "ແລະ", "marketplace.becomePartner": "ເຂົ້າຮ່ວມເປັນພັດທະນາມິດ", + "marketplace.by": "ໂດຍ", + "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.loadMore": "ໂຫຼດເພີ່ມເຕີມ", + "marketplace.creatorProfile.loadMoreFailed": "ໂຫຼດຜົນງານເພີ່ມເຕີມບໍ່ສຳເລັດ.", + "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 d4a45e17ac1..a066a08717f 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", @@ -670,6 +671,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/plugin.json b/web/i18n/nl-NL/plugin.json index 4d65218e103..6635ad425cb 100644 --- a/web/i18n/nl-NL/plugin.json +++ b/web/i18n/nl-NL/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Alle plugins", "marketplace.and": "and", "marketplace.becomePartner": "Word partner", + "marketplace.by": "Door", + "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.loadMore": "Meer laden", + "marketplace.creatorProfile.loadMoreFailed": "Kon geen extra creaties laden.", + "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 f95132beafe..ad7c068017a 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", @@ -670,6 +671,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/plugin.json b/web/i18n/pl-PL/plugin.json index 1443bc0f79e..f053575dd93 100644 --- a/web/i18n/pl-PL/plugin.json +++ b/web/i18n/pl-PL/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Wszystkie integracje", "marketplace.and": "i", "marketplace.becomePartner": "Zostań partnerem", + "marketplace.by": "Przez", + "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.loadMore": "Załaduj więcej", + "marketplace.creatorProfile.loadMoreFailed": "Nie udało się załadować kolejnych prac.", + "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 1b84bd37e5c..19d4f1a554a 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", @@ -670,6 +671,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/plugin.json b/web/i18n/pt-BR/plugin.json index 83d0c8053f9..e9c921a6e2c 100644 --- a/web/i18n/pt-BR/plugin.json +++ b/web/i18n/pt-BR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Todas as integrações", "marketplace.and": "e", "marketplace.becomePartner": "Torne-se um parceiro", + "marketplace.by": "Por", + "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.loadMore": "Carregar mais", + "marketplace.creatorProfile.loadMoreFailed": "Não foi possível carregar mais criações.", + "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 d5969e7ab76..b920afc5b2f 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", @@ -670,6 +671,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/plugin.json b/web/i18n/ro-RO/plugin.json index 7c4911e1e2d..0cd34a81faa 100644 --- a/web/i18n/ro-RO/plugin.json +++ b/web/i18n/ro-RO/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Toate pluginurile", "marketplace.and": "și", "marketplace.becomePartner": "Deveniți partener", + "marketplace.by": "De", + "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.loadMore": "Încarcă mai multe", + "marketplace.creatorProfile.loadMoreFailed": "Nu s-au putut încărca mai multe creații.", + "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 ac071704d7f..150f549514d 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": "Открыть меню помощи", @@ -670,6 +671,7 @@ "userProfile.about": "О нас", "userProfile.compliance": "Соблюдение", "userProfile.contactUs": "Свяжитесь с нами", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Поддержка по электронной почте", "userProfile.github": "ГитХаб", "userProfile.helpCenter": "Помощь", diff --git a/web/i18n/ru-RU/plugin.json b/web/i18n/ru-RU/plugin.json index 484b33cffcf..12cb07aaa05 100644 --- a/web/i18n/ru-RU/plugin.json +++ b/web/i18n/ru-RU/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Все плагины", "marketplace.and": "и", "marketplace.becomePartner": "Стать партнёром", + "marketplace.by": "Автор", + "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.loadMore": "Загрузить ещё", + "marketplace.creatorProfile.loadMoreFailed": "Не удалось загрузить больше работ.", + "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 2db7fc57bf6..95b4fd6ccc5 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", @@ -670,6 +671,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/plugin.json b/web/i18n/sl-SI/plugin.json index 7e79ef2ecea..19f3882fb60 100644 --- a/web/i18n/sl-SI/plugin.json +++ b/web/i18n/sl-SI/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Vsi vtičniki", "marketplace.and": "in", "marketplace.becomePartner": "Postanite partner", + "marketplace.by": "Avtor", + "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.loadMore": "Naloži več", + "marketplace.creatorProfile.loadMoreFailed": "Ni bilo mogoče naložiti več stvaritev.", + "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 1318ab13927..64f2f9e3d88 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": "เปิดเมนูช่วยเหลือ", @@ -670,6 +671,7 @@ "userProfile.about": "ประมาณ", "userProfile.compliance": "การปฏิบัติตามข้อกำหนด", "userProfile.contactUs": "ติดต่อเรา", + "userProfile.discord": "Discord", "userProfile.emailSupport": "การสนับสนุนทางอีเมล", "userProfile.github": "GitHub", "userProfile.helpCenter": "วิธีใช้", diff --git a/web/i18n/th-TH/plugin.json b/web/i18n/th-TH/plugin.json index f8655669d0f..fb7918c0e6d 100644 --- a/web/i18n/th-TH/plugin.json +++ b/web/i18n/th-TH/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "ปลั๊กอินทั้งหมด", "marketplace.and": "และ", "marketplace.becomePartner": "เป็นพันธมิตร", + "marketplace.by": "โดย", + "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.loadMore": "โหลดเพิ่มเติม", + "marketplace.creatorProfile.loadMoreFailed": "ไม่สามารถโหลดผลงานเพิ่มเติมได้", + "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 2e11f9626da..a0cc36eddfe 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ç", @@ -670,6 +671,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/plugin.json b/web/i18n/tr-TR/plugin.json index a17fd883143..305c6412fc0 100644 --- a/web/i18n/tr-TR/plugin.json +++ b/web/i18n/tr-TR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Tüm eklentiler", "marketplace.and": "ve", "marketplace.becomePartner": "Partner Olun", + "marketplace.by": "Tarafından", + "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.loadMore": "Daha fazla yükle", + "marketplace.creatorProfile.loadMoreFailed": "Daha fazla çalışma yüklenemedi.", + "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 388e89620b1..b198a13d192 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": "Відкрити меню довідки", @@ -670,6 +671,7 @@ "userProfile.about": "Про нас", "userProfile.compliance": "Відповідність", "userProfile.contactUs": "Зв’яжіться з нами", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Підтримка по електронній пошті", "userProfile.github": "Гітхаб", "userProfile.helpCenter": "Довідковий центр", diff --git a/web/i18n/uk-UA/plugin.json b/web/i18n/uk-UA/plugin.json index 1aca4b26eec..8510376cca9 100644 --- a/web/i18n/uk-UA/plugin.json +++ b/web/i18n/uk-UA/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Всі плагіни", "marketplace.and": "і", "marketplace.becomePartner": "Стати партнером", + "marketplace.by": "Автор", + "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.loadMore": "Завантажити ще", + "marketplace.creatorProfile.loadMoreFailed": "Не вдалося завантажити більше робіт.", + "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 2d662d037bd..2b450a6d015 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", @@ -670,6 +671,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/plugin.json b/web/i18n/vi-VN/plugin.json index 50eab03483e..d16fbf7ddf8 100644 --- a/web/i18n/vi-VN/plugin.json +++ b/web/i18n/vi-VN/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Tất cả plugin", "marketplace.and": "và", "marketplace.becomePartner": "Trở thành đối tác", + "marketplace.by": "Tác giả", + "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.loadMore": "Tải thêm", + "marketplace.creatorProfile.loadMoreFailed": "Không thể tải thêm tác phẩm.", + "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 b54d8e5293f..72df809d2b9 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": "打开帮助菜单", @@ -670,6 +671,7 @@ "userProfile.about": "关于", "userProfile.compliance": "合规", "userProfile.contactUs": "联系我们", + "userProfile.discord": "Discord", "userProfile.emailSupport": "邮件支持", "userProfile.github": "GitHub", "userProfile.helpCenter": "查看帮助文档", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index bffed30d1bd..6e016f22a64 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "所有集成", "marketplace.and": "和", "marketplace.becomePartner": "成为合作伙伴", + "marketplace.by": "作者", + "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.loadMore": "加载更多", + "marketplace.creatorProfile.loadMoreFailed": "无法加载更多作品。", + "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 ac5fb952f34..1559004d997 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": "開啟幫助選單", @@ -670,6 +671,7 @@ "userProfile.about": "關於", "userProfile.compliance": "合規", "userProfile.contactUs": "聯絡我們", + "userProfile.discord": "Discord", "userProfile.emailSupport": "電子郵件支援", "userProfile.github": "GitHub", "userProfile.helpCenter": "查看幫助文件", diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json index 207d5f47bba..90a936619cb 100644 --- a/web/i18n/zh-Hant/plugin.json +++ b/web/i18n/zh-Hant/plugin.json @@ -70,19 +70,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}}", @@ -202,15 +202,56 @@ "marketplace.allPlugins": "所有集成", "marketplace.and": "和", "marketplace.becomePartner": "成為合作夥伴", + "marketplace.by": "作者", + "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.loadMore": "載入更多", + "marketplace.creatorProfile.loadMoreFailed": "無法載入更多作品。", + "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 16e9289630a..ee4d4399c69 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -19,15 +19,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 = @@ -36,13 +56,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 @@ -81,6 +95,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}'` @@ -93,6 +109,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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Vector"> +<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#E8E8E8"/> +<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#E8E8E8"/> +<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="#E8E8E8"/> +<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="#E8E8E8"/> +<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="#E8E8E8"/> +<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="#E8E8E8"/> +<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="#E8E8E8"/> +<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="#E8E8E8"/> +<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="#E8E8E8"/> +<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="#E8E8E8"/> +<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="#E8E8E8"/> +<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="#E8E8E8"/> +<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="#E8E8E8"/> +<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="#E8E8E8"/> +<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="#E8E8E8"/> +</g> +</svg> 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 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Vector"> +<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#0033FF"/> +<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#0033FF"/> +<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="black"/> +<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="black"/> +<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="black"/> +<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="black"/> +<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="black"/> +<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="black"/> +<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="black"/> +<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="black"/> +<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="black"/> +<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="black"/> +<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="black"/> +<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="black"/> +<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="black"/> +</g> +</svg> diff --git a/web/service/client.ts b/web/service/client.ts index a5a4a6a684c..41ced571a8e 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -41,6 +41,32 @@ 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 isMarketplacePackageDownload(input: Request | URL | string): boolean { + const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + try { + return new URL(href).pathname.endsWith('/download') + } catch { + return false + } +} + function isURL(path: string) { try { // oxlint-disable-next-line no-new @@ -104,9 +130,14 @@ const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { url: MARKETPLACE_API_PREFIX, headers: () => getMarketplaceHeaders(), fetch: (request, init) => { + const requestInit = init as RequestInit | undefined + const callerSignal = requestInit?.signal ?? request.signal return globalThis.fetch(request, { - ...init, + ...requestInit, cache: 'no-store', + signal: isMarketplacePackageDownload(request) + ? callerSignal + : withRequestDeadline(callerSignal), }) }, interceptors: [ diff --git a/web/service/marketplace-template-discovery.spec.ts b/web/service/marketplace-template-discovery.spec.ts new file mode 100644 index 00000000000..e2c910aab94 --- /dev/null +++ b/web/service/marketplace-template-discovery.spec.ts @@ -0,0 +1,213 @@ +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.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith( + 1, + { + params: { collectionName: 'featured' }, + body: { limit: 20 }, + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(result.ok).toBe(false) + expect(result.templatesByCollection).toEqual({ + featured: [{ id: 'template-1' }], + partners: [], + }) + }) + + it('does not cache a partial collection failure', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [ + { name: 'featured', label: {}, description: {}, priority: 1 }, + { name: 'partners', label: {}, description: {}, priority: 2 }, + ], + }, + }) + mocks.templateCollectionTemplates + .mockRejectedValueOnce(new Error('Unavailable')) + .mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } }) + .mockResolvedValue({ data: { templates: [{ id: 'template-2' }] } }) + + const failed = await getMarketplaceTemplateCollectionsAndTemplates() + expect(failed.ok).toBe(false) + + const recovered = await getMarketplaceTemplateCollectionsAndTemplates() + expect(recovered.ok).toBe(true) + expect(recovered.templatesByCollection).toEqual({ + featured: [{ id: 'template-2' }], + partners: [{ id: 'template-2' }], + }) + }) + + 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('strips list-unused template fields before they can enter the RSC payload', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }], + }, + }) + mocks.templateCollectionTemplates.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Inbox', + readme: '# long', + review_comment: 'ship it', + dsl_file_key: 'dsl.yml', + partner_link: 'https://example.com', + asset_files: [{ name: 'a' }], + asset_tree_nodes: [{ path: '/' }], + dsl_raw_file_key: 'raw.yml', + }, + ], + }, + }) + + const result = await getMarketplaceTemplateCollectionsAndTemplates() + const [template] = result.templatesByCollection.featured ?? [] + + expect(template).toMatchObject({ id: 'template-1', template_name: 'Inbox' }) + expect(template).not.toHaveProperty('readme') + expect(template).not.toHaveProperty('review_comment') + expect(template).not.toHaveProperty('dsl_file_key') + expect(template).not.toHaveProperty('partner_link') + expect(template).not.toHaveProperty('asset_files') + expect(template).not.toHaveProperty('asset_tree_nodes') + expect(template).not.toHaveProperty('dsl_raw_file_key') + }) + + 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..55e194f8ea4 --- /dev/null +++ b/web/service/marketplace-template-discovery.ts @@ -0,0 +1,196 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { SERVER_PREFETCH_BUDGET_MS } from '@/app/components/plugins/marketplace/server-budget' +import { marketplaceClient } from './client' + +export type MarketplaceTemplateCollectionsResult = { + collections: MarketplaceTemplateCollection[] + templatesByCollection: Record<string, MarketplaceTemplate[]> + /** + * 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 = 20 + +type MarketplaceTemplateListExtras = { + asset_files?: unknown + asset_tree_nodes?: unknown + dsl_file_key?: unknown + dsl_raw_file_key?: unknown + partner_link?: unknown + readme?: unknown + review_comment?: unknown +} + +export const toListTemplate = (template: MarketplaceTemplate): MarketplaceTemplate => { + const { + asset_files: _assetFiles, + asset_tree_nodes: _assetTreeNodes, + dsl_file_key: _dslFileKey, + dsl_raw_file_key: _dslRawFileKey, + partner_link: _partnerLink, + readme: _readme, + review_comment: _reviewComment, + ...listFields + } = template as MarketplaceTemplate & MarketplaceTemplateListExtras + + return listFields +} +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<MarketplaceTemplateCollectionsResult> | null = null + +async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> { + const budget = AbortSignal.timeout(SERVER_PREFETCH_BUDGET_MS) + + try { + const response = await marketplaceClient.templateCollections( + { + query: { + page: 1, + page_size: 100, + }, + }, + { signal: budget }, + ) + const collections = response.data?.collections ?? [] + const entries: (readonly [string, MarketplaceTemplate[]])[] = [] + let hadCollectionFailure = false + + // Bounded fan-out: fetch collection previews in small batches instead of + // firing one uncached request per collection all at once. The route budget + // aborts leftover work so `/templates` cannot wait N batches × 15s. + for ( + let batchStart = 0; + batchStart < collections.length; + batchStart += COLLECTION_FETCH_BATCH_SIZE + ) { + if (budget.aborted) return FAILED_COLLECTIONS_RESULT + + 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 }, + }, + { signal: budget }, + ) + + return [ + collection.name, + (collectionResponse.data?.templates ?? []).map(toListTemplate), + ] as const + } catch (error) { + if (budget.aborted) throw error + hadCollectionFailure = true + return [collection.name, [] as MarketplaceTemplate[]] as const + } + }), + )), + ) + } + + return { + collections, + templatesByCollection: Object.fromEntries(entries), + ok: !hadCollectionFailure, + } + } catch (error) { + if (budget.aborted) return FAILED_COLLECTIONS_RESULT + throw error + } +} + +/** + * Server-side cached view of the template collections and their previews. + * `marketplaceClient` opts out of the framework fetch cache (`no-store`), so + * without this cache every server render of /templates would fan out to up to + * 1 + N external requests. Successful results are reused for a few minutes and + * concurrent renders share a single in-flight fetch; failures are not cached. + */ +export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> { + if (collectionsCache && collectionsCache.expiresAt > Date.now()) return collectionsCache.result + if (collectionsInFlight) return collectionsInFlight + + collectionsInFlight = fetchCollectionsAndTemplates() + .then((result) => { + if (result.ok) 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 ?? []).map(toListTemplate), + 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/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..db6c228ce56 --- /dev/null +++ b/web/utils/__tests__/marketplace-site-track.spec.ts @@ -0,0 +1,68 @@ +// @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', + itemName: 'OpenAI', + 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', + item_name: 'OpenAI', + section: 'partners', + }) + }) +}) diff --git a/web/utils/marketplace-site-track.ts b/web/utils/marketplace-site-track.ts new file mode 100644 index 00000000000..56c7b7f6a22 --- /dev/null +++ b/web/utils/marketplace-site-track.ts @@ -0,0 +1,87 @@ +/** + * Standalone Marketplace host contract. + * + * The Dify console never stamps `data-is-marketplace` or assigns + * `window.__marketplaceTracking__`, so every helper here is a no-op in a Dify + * build. The standalone marketplace (dify-marketplace) owns the producer: it + * sets `data-is-marketplace` on `<body>` and injects `__marketplaceTracking__` + * from its analytics runtime. Shared Marketplace UI calls these helpers; the + * host implements the bridge. + */ +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<string, unknown>, +) => { + 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, + itemName, + section, +}: { + itemId: string + itemType: 'plugin' | 'template' + itemName: string + section: string +}) => { + rememberMarketplaceSiteReferrer(itemId, section === 'search' ? 'search' : 'list') + trackMarketplaceSiteEvent('marketplace_card_click', { + click_target: 'card', + item_id: itemId, + item_type: itemType, + item_name: itemName, + 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) }) }