fix(web): address marketplace review follow-ups on assets, locale and a11y

- resample the 3MB trending background to a 97KB webp and serve a frozen
  recommend banner from the E2E stub so the benchmark covers the carousel
- show the on-page filtered count in template search and redirect
  out-of-range pages to the last available page
- localize the carousel controls across all 24 locales and replace the
  i18n type casts with typed selectors
- surface Marketplace API failures with a retry action instead of
  rendering them as empty results
- use iconify marks for the hero decorations, share the carousel
  page-size hook to avoid a hydration mismatch, move banner types into
  @dify/contracts, scope the Cmd+K shortcut to the standalone platform,
  point the embedded logo at /marketplace, pass resolvedTheme to the
  detail dialogs, and reveal the detail iframe after a loading timeout

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
CodingOnStar 2026-08-16 13:48:34 +08:00
parent 3087b45017
commit a4a57ec662
60 changed files with 544 additions and 297 deletions

View File

@ -6,7 +6,8 @@ import { e2eBrowser } from '../../test-env'
// Baseline against the frozen marketplace fixture stub: the first card lands
// around 2.3-2.6s under Fast 4G + 4x CPU throttling (dominated by the ~630KB
// server-rendered HTML), so 4s guards regressions with headroom for slower CI
// runners.
// runners. The stub serves a frozen recommend banner, so the measured first
// screen also includes the trending carousel and its background image.
const FIRST_CARD_BUDGET_MS = 4_000
const DOCUMENT_ELEMENT_BUDGET = 2_000
// Hydrating the server-rendered list peaks around ~220ms on shared CI runners

View File

@ -64,6 +64,33 @@ const frozenCollections = [
makeFrozenCollection('e2e-frozen-popular', 'Frozen Popular'),
]
// A frozen recommend banner keeps the trending carousel (and its decorative
// background image) inside the measured first screen, so the benchmark covers
// the same rendering paths as production instead of an empty banner state.
const frozenBanners = [
{
id: 'e2e-frozen-banner-trending',
title: 'Trending',
sort: 1,
language: 'en-US',
style_type: 'recommend',
content: {
theme_type: 'hottest',
heading: 'Frozen Trending Plugins',
description: 'Frozen fixture banner for the performance benchmark.',
cards: Array.from({ length: 4 }, (_, index) => ({
item_type: 'plugin',
item_id: `e2e-fixtures/featured-plugin-${index + 1}`,
display_name: `Featured Plugin ${index + 1}`,
icon_url: `/api/v1/plugins/e2e-fixtures/featured-plugin-${index + 1}/icon`,
creator: 'e2e-fixtures',
link: '',
card_position: index + 1,
})),
},
},
]
const frozenCollectionPlugins: Record<string, unknown[]> = {
'e2e-frozen-featured': Array.from({ length: 8 }, (_, index) =>
makeFrozenPlugin(
@ -92,7 +119,7 @@ const jsonResponse = (data: unknown): StubResponse => ({
})
const resolveStubResponse = (method: string, pathname: string): StubResponse | undefined => {
if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: [] })
if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: frozenBanners })
if (method === 'GET' && pathname === '/collections')
return jsonResponse({ collections: frozenCollections })

View File

@ -192,6 +192,78 @@ export type TemplateSearchResponse = {
export type DownloadPluginResponse = Blob
// 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
}
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',

View File

@ -1,7 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#DE5833"/>
<path d="M9.1 14.05c.15-2.7 2.05-4.75 4.85-5.2 1.35-.22 2.65.2 3.25.95.3.4.2.85-.25 1.05l-1.05.45c-.5-1-1.6-1.25-2.8-.9-1.75.5-2.85 1.95-2.95 3.7-.1 1.4.8 2.6 2.15 3.1 1.4.5 2.95.05 3.75-1.05.25-.35.7-.35.95-.05.3.3.3.7 0 1.05-1.25 1.7-3.5 2.3-5.55 1.55-2.1-.75-3.4-2.6-3.25-4.65Z" fill="white"/>
<circle cx="15.15" cy="10.55" r="1.05" fill="#1A1A1A"/>
<circle cx="15.4" cy="10.35" r=".3" fill="white"/>
<path d="M16.35 11.25c.8.15 1.55-.1 2-.6.15-.15 0-.4-.25-.35-.85.15-1.4.15-1.9 0-.15-.05-.3.1-.15.3.15.25.2.5.3.65Z" fill="#F5A623"/>
</svg>

Before

Width:  |  Height:  |  Size: 704 B

View File

@ -1,6 +1,6 @@
{
"prefix": "custom-public",
"lastModified": 1786630059,
"lastModified": 1786856617,
"icons": {
"agent-building-blocks": {
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
@ -72,7 +72,6 @@
},
"common-d": {
"body": "<g fill=\"none\"><path fill=\"#fff\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID0)\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID1)\" d=\"M7.943 8h.265v7h-.265z\"/><defs><radialGradient id=\"svgID0\" cx=\"0\" cy=\"0\" r=\"1\" gradientTransform=\"matrix(0 8.75 -8.75 0 7.943 8)\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#001FC2\"/><stop offset=\".711\" stop-color=\"#0667F8\" stop-opacity=\".2\"/><stop offset=\"1\" stop-color=\"#155EEF\" stop-opacity=\"0\"/></radialGradient><linearGradient id=\"svgID1\" x1=\"8.062\" x2=\"7.937\" y1=\"8.438\" y2=\"9.203\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></g>",
"width": 16,
"height": 16
},
"common-diagonal-dividing-line": {
@ -90,14 +89,8 @@
"width": 24,
"height": 24
},
"common-duckduckgo": {
"body": "<g fill=\"none\"><rect width=\"24\" height=\"24\" fill=\"#DE5833\" rx=\"6\"/><path fill=\"#fff\" d=\"M9.1 14.05c.15-2.7 2.05-4.75 4.85-5.2c1.35-.22 2.65.2 3.25.95c.3.4.2.85-.25 1.05l-1.05.45c-.5-1-1.6-1.25-2.8-.9c-1.75.5-2.85 1.95-2.95 3.7c-.1 1.4.8 2.6 2.15 3.1c1.4.5 2.95.05 3.75-1.05c.25-.35.7-.35.95-.05c.3.3.3.7 0 1.05c-1.25 1.7-3.5 2.3-5.55 1.55c-2.1-.75-3.4-2.6-3.25-4.65Z\"/><circle cx=\"15.15\" cy=\"10.55\" r=\"1.05\" fill=\"#1A1A1A\"/><circle cx=\"15.4\" cy=\"10.35\" r=\".3\" fill=\"#fff\"/><path fill=\"#F5A623\" d=\"M16.35 11.25c.8.15 1.55-.1 2-.6c.15-.15 0-.4-.25-.35c-.85.15-1.4.15-1.9 0c-.15-.05-.3.1-.15.3c.15.25.2.5.3.65\"/></g>",
"width": 24,
"height": 24
},
"common-enter-key": {
"body": "<g fill=\"#fff\"><path fill-opacity=\".12\" d=\"M0 4a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4z\"/><path d=\"M3.428 8.736V7.628h7.448q.486 0 .887-.239a1.78 1.78 0 0 0 .873-1.525q0-.486-.238-.882a1.8 1.8 0 0 0-.64-.64a1.7 1.7 0 0 0-.882-.238H10.4V3h.477q.793 0 1.44.388q.65.387 1.036 1.035q.388.648.388 1.44q0 .593-.226 1.113a2.92 2.92 0 0 1-1.525 1.538a2.8 2.8 0 0 1-1.113.222zm2.74 3.32L2.294 8.181l3.874-3.874l.762.763l-3.115 3.11l3.115 3.112z\"/></g>",
"width": 16,
"height": 16
},
"common-firecrawl": {
@ -142,12 +135,10 @@
},
"common-lock": {
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8 1.75a3.125 3.125 0 0 0-3.125 3.125v1.25C3.839 6.125 3 6.965 3 8v4.375c0 1.036.84 1.875 1.875 1.875h6.25c1.036 0 1.875-.84 1.875-1.875V8c0-1.036-.84-1.875-1.875-1.875v-1.25c0-1.726-1.4-3.125-3.125-3.125m1.875 4.375v-1.25a1.875 1.875 0 1 0-3.75 0v1.25zM8 8.625c.345 0 .625.28.625.625v1.875a.625.625 0 1 1-1.25 0V9.25c0-.345.28-.625.625-.625\" clip-rule=\"evenodd\"/>",
"width": 16,
"height": 16
},
"common-message-chat-square": {
"body": "<g fill=\"#444CE7\"><path fill-rule=\"evenodd\" d=\"M8.774 6.667h3.785c.352 0 .655 0 .904.02c.264.021.526.069.778.197a2 2 0 0 1 .874.875c.129.252.176.514.198.777c.02.25.02.553.02.905v1.856c0 .293 0 .545-.014.754c-.015.22-.048.44-.138.657A2 2 0 0 1 14.1 13.79c-.217.09-.437.124-.657.139l-.109.005v.732a.667.667 0 0 1-1.047.548l-1.45-1.009c-.224-.155-.27-.184-.312-.203a.7.7 0 0 0-.154-.048c-.046-.009-.1-.011-.372-.011H8.774c-.351 0-.654 0-.904-.02a2 2 0 0 1-.778-.198a2 2 0 0 1-.874-.874a2 2 0 0 1-.198-.778C6 11.823 6 11.52 6 11.168V9.441c0-.352 0-.655.02-.905c.022-.263.07-.525.198-.777a2 2 0 0 1 .874-.875c.252-.128.515-.176.778-.197c.25-.02.553-.02.904-.02\" clip-rule=\"evenodd\"/><path d=\"M9.494.667H4.506c-.537 0-.98 0-1.34.029c-.375.03-.72.096-1.043.261A2.67 2.67 0 0 0 .957 2.123c-.164.323-.23.668-.26 1.042c-.03.361-.03.804-.03 1.34V7.68c0 .295 0 .513.028.706a2.67 2.67 0 0 0 2.252 2.252a.2.2 0 0 1 .09.036v1.052c0 .181 0 .36.013.503c.011.128.04.391.228.61a1 1 0 0 0 .842.345c.287-.023.493-.19.59-.273l.087-.077a4 4 0 0 1-.105-.653c-.025-.305-.025-.659-.025-.984V9.413c0-.326 0-.68.025-.985c.028-.346.098-.803.338-1.275c.32-.627.83-1.137 1.457-1.456c.471-.24.928-.31 1.274-.339c.306-.025.66-.025.985-.025h3.841c.244 0 .503 0 .746.01v-.837c0-.537 0-.98-.029-1.34c-.03-.375-.096-.72-.261-1.043A2.67 2.67 0 0 0 11.877.957c-.323-.165-.668-.23-1.042-.261c-.361-.03-.804-.03-1.34-.03\"/></g>",
"width": 16,
"height": 16
},
"common-multi-path-retrieval": {
@ -177,7 +168,6 @@
},
"common-sparkles-soft-accent": {
"body": "<g fill=\"#155AEF\"><path d=\"M12.567 1.563a.253.253 0 0 0-.247-.23a.253.253 0 0 0-.247.23c-.068.61-.241 1.028-.514 1.311c-.272.284-.673.465-1.259.535a.256.256 0 0 0-.22.258c0 .132.095.242.22.257c.576.068.987.25 1.266.535c.278.284.455.701.506 1.305c.012.134.12.236.248.236c.13 0 .237-.103.248-.237c.05-.593.226-1.02.506-1.311s.69-.476 1.259-.527a.255.255 0 0 0 .227-.258a.255.255 0 0 0-.227-.259c-.58-.053-.98-.238-1.253-.527c-.274-.29-.447-.718-.513-1.318\" opacity=\".5\"/><path d=\"M8.156 3.258a.65.65 0 0 0-.636-.591a.65.65 0 0 0-.636.59c-.174 1.567-.62 2.643-1.32 3.372S3.83 7.824 2.325 8.004a.66.66 0 0 0-.566.663c0 .34.244.624.568.662c1.479.175 2.535.64 3.253 1.374c.714.73 1.169 1.804 1.301 3.356a.65.65 0 0 0 .638.608a.65.65 0 0 0 .637-.61c.127-1.525.582-2.623 1.3-3.372c.72-.748 1.773-1.222 3.238-1.354a.657.657 0 0 0 .585-.664a.657.657 0 0 0-.584-.664c-1.49-.138-2.52-.612-3.221-1.356c-.705-.748-1.152-1.848-1.32-3.389\"/></g>",
"width": 16,
"height": 16
},
"education-triangle": {

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-public",
"name": "Dify Custom Public",
"total": 152,
"total": 151,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -1,5 +1,5 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { ReactNode } from 'react'
import type { PluginBanner } from '../home/banners'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

View File

@ -1,5 +1,5 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { ReactNode } from 'react'
import type { PluginBanner } from '../home/banners'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

View File

@ -58,7 +58,9 @@ describe('MarketplaceDetailDialog', () => {
const frame = screen.getByTitle('Plugin A · plugin.detailPanel.operation.detail')
expect(frame).toHaveAttribute(
'src',
'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=system&view=modal',
// resolvedTheme maps the "system" preference to the concrete value, so
// the embedded detail page receives light/dark rather than "system".
'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal',
)
expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument()

View File

@ -20,6 +20,11 @@ type MarketplaceDetailDialogFrameProps = {
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,
@ -32,6 +37,13 @@ export default function MarketplaceDetailDialogFrame({
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

View File

@ -26,14 +26,16 @@ function MarketplaceDetailDialog({
}: MarketplaceDetailDialogProps) {
const { t } = useTranslation()
const locale = useLocale()
const { theme } = useTheme()
// resolvedTheme maps the "system" preference to the concrete light/dark
// value the marketplace page expects.
const { resolvedTheme } = useTheme()
const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
const detailURL = getPluginLinkInMarketplace(plugin, {
installed: String(isInstalled),
language: locale,
source: globalThis.location?.origin,
theme,
theme: resolvedTheme,
view: 'modal',
})

View File

@ -1,10 +1,9 @@
'use client'
import type { PluginBanner } from './home/banners'
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 { marketplaceQuery } from '@/service/client'
import { useResetMarketplaceSearchModeOnMount } from './atoms'
import { fetchPluginBanners } from './home/banners'
import { MarketplaceView } from './view'
@ -30,15 +29,12 @@ export function EmbeddedMarketplace({
}: EmbeddedMarketplaceProps) {
useResetMarketplaceSearchModeOnMount()
const locale = useLocale()
const input = {
query: {
page: 'plugins' as const,
language: locale,
},
}
const { data: banners = [] } = useQuery(
queryOptions({
queryKey: [...marketplaceQuery.banners.list.queryKey({ input }), locale],
// 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,

View File

@ -1,4 +1,4 @@
import type { PluginBanner } from '../banners'
import type { PluginBanner } from '@dify/contracts/marketplace'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'

View File

@ -27,16 +27,16 @@ vi.mock('ahooks', async (importOriginal) => {
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) =>
({
'gotoAnything.searching': 'Searching...',
'marketplace.noPluginFound': 'No integration found',
'newApp.noTemplateFound': 'No templates found',
})[key] ?? key,
}),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
clearSearch: 'Clear search',
'gotoAnything.searching': 'Searching...',
'marketplace.noPluginFound': 'No integration found',
'newApp.noTemplateFound': 'No templates found',
})
})
vi.mock('@/service/client', () => ({
marketplaceQuery: {
@ -125,7 +125,7 @@ describe('MarketplaceSearchAutocomplete', () => {
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="clearSearch"]')).toHaveLength(1)
expect(container.querySelectorAll('button[aria-label="Clear search"]')).toHaveLength(1)
expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US')
expect(mockPluginSearch).not.toHaveBeenCalled()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@ -1,75 +1,20 @@
import type {
BannerAd,
BannerBase,
BannerBlog,
BannerEvent,
BannerImageContent,
BannerRecommend,
BannerRecommendCard,
PluginBanner,
} from '@dify/contracts/marketplace'
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
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
}
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'
}
}
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 isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

View File

@ -35,7 +35,9 @@ const HomeHeader = ({
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<Link
href="/"
// 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"
>

View File

@ -13,41 +13,12 @@ type HomeHeroProps = {
const heroDecorationIconFrameClassName =
'absolute flex size-10 items-center justify-center overflow-hidden rounded-[10px] bg-components-panel-bg shadow-lg'
const DropboxIcon = () => (
<svg className="size-10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill="#0061FF"
d="m7 3-5 3.2L7 9.4l5-3.2L7 3Zm10 0-5 3.2 5 3.2 5-3.2L17 3ZM7 10.6l-5 3.2L7 17l5-3.2-5-3.2Zm10 0-5 3.2 5 3.2 5-3.2-5-3.2ZM7.2 18.1l4.8 3 4.8-3-4.8-3-4.8 3Z"
/>
</svg>
)
// The DuckDuckGo mark stays a raster export from the design frame; the other
// brand marks come from the shared iconify collections instead of inline SVG.
const DuckDuckGoIcon = () => (
<img src={duckDuckGoIcon.src} alt="" className="size-10 object-cover" />
)
const GmailIcon = () => (
<svg className="size-10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="white" />
<path
d="M4.5 7.2v9.3c0 .66.54 1.2 1.2 1.2h1.35V10.2L12 13.95l4.95-3.75v7.5h1.35c.66 0 1.2-.54 1.2-1.2V7.2c0-1.5-1.7-2.35-2.9-1.45L12 9.15 7.4 5.75C6.2 4.85 4.5 5.7 4.5 7.2Z"
fill="#EA4335"
/>
<path d="M5.7 17.7h1.35V10.2L4.5 8.4v8.1c0 .66.54 1.2 1.2 1.2Z" fill="#34A853" />
<path d="M18.3 17.7h-1.35V10.2l2.55-1.8v8.1c0 .66-.54 1.2-1.2 1.2Z" fill="#4285F4" />
<path
d="M19.5 7.2v-.75c0-1.5-1.7-2.35-2.9-1.45L12 9.15 7.4 5.75C6.2 4.85 4.5 5.7 4.5 6.45V8.4L12 13.95 19.5 8.4V7.2Z"
fill="#C5221F"
/>
<path
d="M4.5 8.4 12 13.95 19.5 8.4"
stroke="#EA4335"
strokeWidth="1.1"
strokeLinejoin="round"
/>
</svg>
)
const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => {
const { t } = useTranslation('plugin')
@ -61,7 +32,7 @@ const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) =>
<div className="relative flex h-[162px] w-full max-w-[726px] flex-col items-center pt-[41px]">
<div aria-hidden className="pointer-events-none absolute inset-0 max-[879px]:hidden">
<span className={heroDecorationIconFrameClassName} style={{ left: 99, top: 26 }}>
<DropboxIcon />
<span className="i-custom-public-common-dropbox size-10" />
</span>
<span className={heroDecorationIconFrameClassName} style={{ left: 12, top: 89 }}>
<DuckDuckGoIcon />
@ -70,7 +41,7 @@ const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) =>
<Github className="size-10" />
</span>
<span className={heroDecorationIconFrameClassName} style={{ left: 653, top: 68 }}>
<GmailIcon />
<span className="i-custom-public-common-gmail size-10" />
</span>
</div>
<div className="flex flex-col items-center gap-2 text-center">

View File

@ -7,11 +7,23 @@ import { useTranslation } from '#i18n'
import styles from './home-sticky.module.css'
import MarketplacePluginSearch from './marketplace-plugin-search'
const HomeSearch = ({ children }: { children?: ReactNode }) => {
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
}
const HomeSearch = ({ children, enableSearchShortcut = true }: HomeSearchProps) => {
const searchRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation('plugin')
useEffect(() => {
if (!enableSearchShortcut) return
const handleGlobalSearchShortcut = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return
@ -21,7 +33,7 @@ const HomeSearch = ({ children }: { children?: ReactNode }) => {
document.addEventListener('keydown', handleGlobalSearchShortcut)
return () => document.removeEventListener('keydown', handleGlobalSearchShortcut)
}, [])
}, [enableSearchShortcut])
return (
<div

View File

@ -1,6 +1,5 @@
'use client'
import type { RefObject } from 'react'
import type {
BannerAd,
BannerBlog,
@ -8,7 +7,8 @@ import type {
BannerRecommend,
BannerRecommendCard,
PluginBanner,
} from './banners'
} 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'
@ -16,7 +16,7 @@ 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 background from './assets/background.jpg'
import background from './assets/background.webp'
import difyUpdatesArt from './assets/dify-updates-art.png'
import styles from './home-trending.module.css'
@ -208,8 +208,8 @@ function TrendingRecommendationSlide({
>
<img
src={background.src}
width={3840}
height={2160}
width={1600}
height={900}
alt=""
aria-hidden
className="absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80"
@ -534,12 +534,6 @@ function TrendingNavigation({
if (isCurrent) return
onSelect(index)
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCurrent) return
onSelect(index)
}}
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',
@ -564,11 +558,6 @@ function TrendingNavigation({
],
)}
onClick={toggleAutoplay}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
toggleAutoplay()
}}
className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-handle text-text-primary outline-hidden hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{isExplicitlyPaused ? (
@ -625,15 +614,19 @@ function HomeTrending({
className="relative h-[200px] w-full rounded-2xl"
data-home-trending-carousel-root
>
<TrendingNavigation
banners={banners}
selectedIndex={selectedIndex}
carouselRootRef={carouselRootRef}
pauseWhenOffscreen={!isMarketplacePlatform}
onSelect={selectSlide}
onNext={selectNextSlide}
onPausedChange={setIsRotationPaused}
/>
{/* 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}
/>
)}
<div className="h-full overflow-hidden rounded-2xl">
<div
// Keep automatic rotation silent for screen readers; announce

View File

@ -1,5 +1,5 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { ActivePluginType } from '../constants'
import type { PluginBanner } from './banners'
import type { HomeCatalogTabLabels } from './home-catalog-tabs'
import ListWrapper from '../list/list-wrapper'
import HomeCatalogNavigation from './home-catalog-navigation'
@ -47,7 +47,7 @@ const MarketplaceHome = ({
/>
<div className="relative flex w-full flex-col">
<HomeHero isMarketplacePlatform={isMarketplacePlatform} />
<HomeSearch>{search}</HomeSearch>
<HomeSearch enableSearchShortcut={isMarketplacePlatform}>{search}</HomeSearch>
{banners.length > 0 && (
<>
<div aria-hidden="true" className="h-12 shrink-0" />

View File

@ -18,7 +18,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '#i18n'
import { renderI18nObject } from '@/i18n-config/index'
import { marketplaceQuery } from '@/service/client'
@ -77,7 +77,6 @@ export function MarketplaceSearchAutocomplete({
}: MarketplaceSearchAutocompleteProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const translate = t as (key: string, options?: Record<string, unknown>) => string
const debouncedSearch = useDebounce(value.trim(), { wait: 300 })
const hasQuery = Boolean(debouncedSearch)
const showDropdown = isOpen && hasQuery
@ -137,10 +136,17 @@ export function MarketplaceSearchAutocomplete({
: []
const suggestions = [...templateSuggestions, ...pluginSuggestions]
const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching
const emptyText =
scope === 'templates'
? translate('newApp.noTemplateFound', { ns: 'app' })
: translate('marketplace.noPluginFound', { ns: 'plugin' })
// 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' })
return (
<Autocomplete
@ -176,7 +182,7 @@ export function MarketplaceSearchAutocomplete({
/>
{!!value && (
<AutocompleteClear
aria-label={translate('clearSearch', { ns: 'plugin', label: placeholder })}
aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })}
size="large"
/>
)}
@ -189,7 +195,7 @@ export function MarketplaceSearchAutocomplete({
>
{isSearching && suggestions.length === 0 && (
<AutocompleteStatus>
{translate('gotoAnything.searching', { ns: 'app' })}
{t(($) => $['gotoAnything.searching'], { ns: 'app' })}
</AutocompleteStatus>
)}
<AutocompleteList<MarketplaceSuggestion>>

View File

@ -1,5 +1,5 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { SearchParams } from 'nuqs'
import type { PluginBanner } from './home/banners'
import type { MarketplaceViewProps } from './view'
import { getLocaleOnServer } from '@/i18n-config/server'
import { fetchPluginBanners } from './home/banners'

View File

@ -138,7 +138,9 @@ describe('Marketplace Carousel', () => {
expect(screen.getByText('Page content 5')).toBeInTheDocument()
expect(screen.queryByText('Page content 3')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Go to page 4' }))
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()
@ -162,8 +164,10 @@ describe('Marketplace Carousel', () => {
expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(5)
fireEvent.click(screen.getByRole('button', { name: 'Scroll left' }))
fireEvent.click(screen.getByRole('button', { name: 'Scroll right' }))
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()

View File

@ -276,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')
const carouselViewport = carousel.querySelector('.overflow-hidden')
const carouselContent = carouselViewport?.firstElementChild

View File

@ -27,21 +27,16 @@ type CarouselProps = {
}
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',
)}
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
@ -97,21 +92,22 @@ const CarouselControls = ({
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
)}
onClick={() => scrollTo(index)}
aria-label={`Go to page ${index + 1}`}
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"
/>

View File

@ -10,7 +10,8 @@ import { useMarketplaceMoreClick } from '../atoms'
import { buildCarouselPages } from '../utils'
import CardWrapper from './card-wrapper'
import Carousel from './carousel'
import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE, GRID_CLASS } from './collection-constants'
import { GRID_CLASS } from './collection-constants'
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'])
@ -18,17 +19,6 @@ const COLLECTION_PRELOAD_MARGIN = '320px 0px'
const COLLECTION_INTERSECTION_THRESHOLD = 0.01
const MAX_PLACEHOLDER_CARDS = 8
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
}
type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
@ -269,16 +259,7 @@ const ListWithCollection = ({
}: ListWithCollectionProps) => {
const defaultOnMoreClick = useMarketplaceMoreClick()
const handleMoreClick = onCollectionMoreClick ?? defaultOnMoreClick
const [viewportWidth, setViewportWidth] = useState(getViewportWidth)
const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth])
useEffect(() => {
const handleResize = () => setViewportWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const itemsPerPage = useCarouselItemsPerPage()
return marketplaceCollections
.filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length)

View File

@ -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
export 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)
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vite-plus/test'
import { filterTemplatesForLocale } from '../template-language'
import { filterTemplatesForLocale, getTemplateCollectionText } from '../template-language'
const template = (id: string, preferredLanguages?: string[]) => ({
id,
@ -66,3 +66,22 @@ describe('filterTemplatesForLocale', () => {
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('')
})
})

View File

@ -5,6 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import AccountSection from '@/app/components/main-nav/components/account-section'
import { getTranslation } from '@/i18n-config/server'
import Link from '@/next/link'
import { redirect } from '@/next/navigation'
import {
getMarketplaceTemplateCollectionsAndTemplates,
searchMarketplaceTemplates,
@ -110,6 +111,55 @@ const PAGE_LINK_CLASS =
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'
type TemplatesHrefOptions = {
category: TemplateCategory
page?: number
query?: string
sortBy?: string
sortOrder?: string
view?: string
}
function buildTemplatesHref({
category,
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 (page > 1) searchParams.set('page', String(page))
const queryString = searchParams.toString()
const basePath = category === 'all' ? '/templates' : `/templates/${category}`
return queryString ? `${basePath}?${queryString}` : basePath
}
// 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>
)
}
// Server-rendered pagination: plain links keep the search results reachable
// beyond the first page without any client-side state.
function TemplatePagination({
@ -137,17 +187,8 @@ function TemplatePagination({
}) {
if (pageCount <= 1) return null
const buildHref = (targetPage: number) => {
const searchParams = new URLSearchParams()
if (query) searchParams.set('q', query)
if (sortBy) searchParams.set('sort_by', sortBy)
if (sortOrder) searchParams.set('sort_order', sortOrder)
if (view) searchParams.set('view', view)
if (targetPage > 1) searchParams.set('page', String(targetPage))
const queryString = searchParams.toString()
const basePath = category === 'all' ? '/templates' : `/templates/${category}`
return queryString ? `${basePath}?${queryString}` : basePath
}
const buildHref = (targetPage: number) =>
buildTemplatesHref({ category, page: targetPage, query, sortBy, sortOrder, view })
return (
<nav aria-label={navigationLabel} className="mt-6 flex items-center justify-center gap-3 pb-4">
@ -215,16 +256,32 @@ export async function EmbeddedTemplatesMarketplace({
fetchPluginBanners(locale).catch(() => []),
])
const categoryLabels: TemplateCategoryLabels = {
all: tPlugin('category.all' as never),
marketing: tApp('marketplace.template.category.marketing' as never),
sales: tApp('marketplace.template.category.sales' as never),
support: tApp('marketplace.template.category.support' as never),
operations: tApp('marketplace.template.category.operations' as never),
it: tApp('marketplace.template.category.it' as never),
knowledge: tApp('marketplace.template.category.knowledge' as never),
design: tApp('marketplace.template.category.design' as never),
others: tPluginTags('tags.other' as never),
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,
page: pageCount,
query: normalizedQuery,
sortBy,
sortOrder,
view,
}),
)
}
const templates = filterTemplatesForLocale(searchResult?.templates ?? [], locale)
const hasVisibleCollections =
collectionsResult?.collections.some(
@ -234,9 +291,29 @@ export async function EmbeddedTemplatesMarketplace({
locale,
).length > 0,
) ?? false
const pluginsLabel = tPlugin('marketplace.home.plugins' as never)
const templatesLabel = tPlugin('marketplace.home.templates' as never)
const partnerText = tPlugin('marketplace.partnerTip' as never)
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,
page,
query: normalizedQuery,
sortBy,
sortOrder,
view,
})
const loadErrorState = (
<LoadErrorState
message={tPlugin(($) => $['marketplace.loadError'], { ns: 'plugin' })}
retryHref={currentHref}
retryLabel={tCommon(($) => $['operation.retry'], { ns: 'common' })}
/>
)
return (
<HomeStickyStateProvider>
@ -255,15 +332,15 @@ export async function EmbeddedTemplatesMarketplace({
<HomeHero
isMarketplacePlatform={false}
title={templatesLabel}
subtitle={tExplore('apps.description' as never)}
subtitle={tExplore(($) => $['apps.description'], { ns: 'explore' })}
/>
<HomeSearch>
<HomeSearch enableSearchShortcut={false}>
<MarketplaceSearchForm
action={category === 'all' ? '/templates' : `/templates/${category}`}
category={category}
className="w-full"
locale={locale}
placeholder={tApp('newAppFromTemplate.searchAllTemplate' as never)}
placeholder={tApp(($) => $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' })}
query={query}
scope="templates"
/>
@ -285,7 +362,7 @@ export async function EmbeddedTemplatesMarketplace({
catalogCategories={
<TemplateCategoryNavigation
activeCategory={category}
ariaLabel={tPlugin('allCategories' as never)}
ariaLabel={tPlugin(($) => $.allCategories, { ns: 'plugin' })}
labels={categoryLabels}
query={query}
/>
@ -299,36 +376,43 @@ export async function EmbeddedTemplatesMarketplace({
styles.catalogContent,
)}
>
{collectionsResult ? (
{loadFailed ? (
loadErrorState
) : collectionsResult ? (
hasVisibleCollections ? (
<TemplateCollectionList
becomePartnerText={tPlugin('marketplace.becomePartner' as never)}
becomePartnerText={tPlugin(($) => $['marketplace.becomePartner'], {
ns: 'plugin',
})}
collections={collectionsResult.collections}
locale={locale}
partnerText={partnerText}
templatesByCollection={collectionsResult.templatesByCollection}
viewMoreText={tPlugin('marketplace.viewMore' as never)}
viewMoreText={tPlugin(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
/>
) : (
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
<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' as never, { num: searchResult?.total ?? 0 })}
{tExplore(($) => $['apps.resultNum'], { ns: 'explore', num: templates.length })}
</div>
{templates.length > 0 ? (
<TemplateGrid partnerText={partnerText} templates={templates} />
) : (
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
<EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState>
)}
<TemplatePagination
category={category}
navigationLabel={tCommon('pagination.pageNumber' as never)}
nextLabel={tCommon('pagination.next' as never)}
navigationLabel={tCommon(($) => $['pagination.pageNumber'], { ns: 'common' })}
nextLabel={tCommon(($) => $['pagination.next'], { ns: 'common' })}
page={page}
pageCount={Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE)}
previousLabel={tCommon('pagination.previous' as never)}
pageCount={pageCount}
previousLabel={tCommon(($) => $['pagination.previous'], { ns: 'common' })}
query={normalizedQuery}
sortBy={sortBy}
sortOrder={sortOrder}

View File

@ -5,10 +5,10 @@ import type {
MarketplaceTemplateCollection,
} from '@dify/contracts/marketplace'
import { cn } from '@langgenius/dify-ui/cn'
import { useSyncExternalStore } from 'react'
import Link from '@/next/link'
import Carousel from '../list/carousel'
import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE, GRID_CLASS } from '../list/collection-constants'
import { GRID_CLASS } from '../list/collection-constants'
import { useCarouselItemsPerPage } from '../list/use-carousel-items-per-page'
import TemplateCard from './template-card'
import { filterTemplatesForLocale, getTemplateCollectionText } from './template-language'
@ -24,23 +24,6 @@ type TemplateCollectionListProps = {
viewMoreText: string
}
function 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
}
function getViewMoreHref(collection: MarketplaceTemplateCollection) {
const searchParams = new URLSearchParams({ view: 'search' })
const collectionSearch = collection.search_params
@ -60,12 +43,7 @@ export default function TemplateCollectionList({
templatesByCollection,
viewMoreText,
}: TemplateCollectionListProps) {
const viewportWidth = useSyncExternalStore(
subscribeToViewport,
getViewportWidth,
getServerViewportWidth,
)
const itemsPerPage = getCarouselItemsPerPage(viewportWidth)
const itemsPerPage = useCarouselItemsPerPage()
return collections.map((collection) => {
const templates = filterTemplatesForLocale(templatesByCollection[collection.name] ?? [], locale)

View File

@ -24,12 +24,14 @@ export default function TemplateDetailDialog({
}: TemplateDetailDialogProps) {
const { t } = useTranslation()
const locale = useLocale()
const { theme } = useTheme()
// 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,
theme: resolvedTheme,
view: 'modal',
})
const handleMessage = useCallback(

View File

@ -1,5 +1,5 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { ActivePluginType } from './constants'
import type { PluginBanner } from './home/banners'
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'

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "جميع الإضافات",
"marketplace.and": "و",
"marketplace.becomePartner": "كن شريكًا",
"marketplace.carousel.goToPage": "الانتقال إلى الصفحة {{page}}",
"marketplace.carousel.scrollNext": "الصفحة التالية",
"marketplace.carousel.scrollPrevious": "الصفحة السابقة",
"marketplace.difyMarketplace": "سوق Dify",
"marketplace.discover": "اكتشف",
"marketplace.empower": "تمكين تطوير الذكاء الاصطناعي الخاص بك",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "اقرأ المزيد عن {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "عرض",
"marketplace.loadError": "فشل التحميل. يرجى المحاولة مرة أخرى.",
"marketplace.moreFrom": "المزيد من السوق",
"marketplace.noPluginFound": "لم يتم العثور على إضافة",
"marketplace.partnerTip": "تم التحقق بواسطة شريك Dify",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Alle Plugins",
"marketplace.and": "und",
"marketplace.becomePartner": "Partner werden",
"marketplace.carousel.goToPage": "Zu Seite {{page}} wechseln",
"marketplace.carousel.scrollNext": "Nächste Seite",
"marketplace.carousel.scrollPrevious": "Vorherige Seite",
"marketplace.difyMarketplace": "Dify Marktplatz",
"marketplace.discover": "Entdecken",
"marketplace.empower": "Unterstützen Sie Ihre KI-Entwicklung",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Mehr erfahren über {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Ansehen",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "All integrations",
"marketplace.and": "and",
"marketplace.becomePartner": "Become a Partner",
"marketplace.carousel.goToPage": "Go to page {{page}}",
"marketplace.carousel.scrollNext": "Next page",
"marketplace.carousel.scrollPrevious": "Previous page",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Discover",
"marketplace.empower": "Empower your AI development",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Read more about {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "View",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Todas las integraciones",
"marketplace.and": "y",
"marketplace.becomePartner": "Conviértete en socio",
"marketplace.carousel.goToPage": "Ir a la página {{page}}",
"marketplace.carousel.scrollNext": "Página siguiente",
"marketplace.carousel.scrollPrevious": "Página anterior",
"marketplace.difyMarketplace": "Mercado de Dify",
"marketplace.discover": "Descubrir",
"marketplace.empower": "Potencie su desarrollo de IA",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Leer más sobre {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Ver",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "همه افزونه‌ها",
"marketplace.and": "و",
"marketplace.becomePartner": "شریک شوید",
"marketplace.carousel.goToPage": "رفتن به صفحه {{page}}",
"marketplace.carousel.scrollNext": "صفحه بعدی",
"marketplace.carousel.scrollPrevious": "صفحه قبلی",
"marketplace.difyMarketplace": "بازار دیفی",
"marketplace.discover": "کشف",
"marketplace.empower": "توسعه هوش مصنوعی خود را توانمند کنید",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "ادامه مطلب درباره {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "مشاهده",
"marketplace.loadError": "بارگیری ناموفق بود. لطفاً دوباره تلاش کنید.",
"marketplace.moreFrom": "اطلاعات بیشتر از Marketplace",
"marketplace.noPluginFound": "هیچ افزونه‌ای یافت نشد",
"marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Toutes les intégrations",
"marketplace.and": "et",
"marketplace.becomePartner": "Devenir partenaire",
"marketplace.carousel.goToPage": "Aller à la page {{page}}",
"marketplace.carousel.scrollNext": "Page suivante",
"marketplace.carousel.scrollPrevious": "Page précédente",
"marketplace.difyMarketplace": "Marché Dify",
"marketplace.discover": "Découvrir",
"marketplace.empower": "Renforcez le développement de votre IA",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "En savoir plus sur {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Voir",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "सभी इंटीग्रेशन",
"marketplace.and": "और",
"marketplace.becomePartner": "भागीदार बनें",
"marketplace.carousel.goToPage": "पृष्ठ {{page}} पर जाएं",
"marketplace.carousel.scrollNext": "अगला पृष्ठ",
"marketplace.carousel.scrollPrevious": "पिछला पृष्ठ",
"marketplace.difyMarketplace": "डिफाई मार्केटप्लेस",
"marketplace.discover": "खोजें",
"marketplace.empower": "अपने एआई विकास को सशक्त बनाएं",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "{{title}} के बारे में और पढ़ें",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "देखें",
"marketplace.loadError": "लोड नहीं हो सका। कृपया पुनः प्रयास करें।",
"marketplace.moreFrom": "मार्केटप्लेस से अधिक",
"marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला",
"marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Semua integrasi",
"marketplace.and": "dan",
"marketplace.becomePartner": "Menjadi Partner",
"marketplace.carousel.goToPage": "Buka halaman {{page}}",
"marketplace.carousel.scrollNext": "Halaman berikutnya",
"marketplace.carousel.scrollPrevious": "Halaman sebelumnya",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Menemukan",
"marketplace.empower": "Berdayakan pengembangan AI Anda",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Baca selengkapnya tentang {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Lihat",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Tutte le integrazioni",
"marketplace.and": "e",
"marketplace.becomePartner": "Diventa un partner",
"marketplace.carousel.goToPage": "Vai alla pagina {{page}}",
"marketplace.carousel.scrollNext": "Pagina successiva",
"marketplace.carousel.scrollPrevious": "Pagina precedente",
"marketplace.difyMarketplace": "Mercato Dify",
"marketplace.discover": "Scoprire",
"marketplace.empower": "Potenzia lo sviluppo dell'intelligenza artificiale",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Scopri di più su {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Visualizza",
"marketplace.loadError": "Caricamento non riuscito. Riprova.",
"marketplace.moreFrom": "Altro da Marketplace",
"marketplace.noPluginFound": "Nessuna integrazione trovata",
"marketplace.partnerTip": "Verificato da un partner Dify",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "すべてのインテグレーション",
"marketplace.and": "と",
"marketplace.becomePartner": "パートナーになる",
"marketplace.carousel.goToPage": "{{page}}ページへ移動",
"marketplace.carousel.scrollNext": "次のページ",
"marketplace.carousel.scrollPrevious": "前のページ",
"marketplace.difyMarketplace": "Dify マーケットプレイス",
"marketplace.discover": "探索",
"marketplace.empower": "AI 開発をサポートする",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "{{title}} の続きを読む",
"marketplace.home.trendingTitle": "みんながインストールしているプラグイン",
"marketplace.home.trendingView": "表示",
"marketplace.loadError": "読み込みに失敗しました。もう一度お試しください。",
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
"marketplace.noPluginFound": "インテグレーションが見つかりません",
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "모든 플러그인",
"marketplace.and": "그리고",
"marketplace.becomePartner": "파트너 되기",
"marketplace.carousel.goToPage": "{{page}}페이지로 이동",
"marketplace.carousel.scrollNext": "다음 페이지",
"marketplace.carousel.scrollPrevious": "이전 페이지",
"marketplace.difyMarketplace": "Dify 마켓플레이스",
"marketplace.discover": "발견하다",
"marketplace.empower": "AI 개발 역량 강화",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "{{title}}에 대해 더 알아보기",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "보기",
"marketplace.loadError": "불러오지 못했습니다. 다시 시도해 주세요.",
"marketplace.moreFrom": "Marketplace 에서 더 보기",
"marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.",
"marketplace.partnerTip": "Dify 파트너에 의해 확인됨",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "ການເຊື່ອມຕໍ່ທັງໝົດ",
"marketplace.and": "ແລະ",
"marketplace.becomePartner": "ເຂົ້າຮ່ວມເປັນພັດທະນາມິດ",
"marketplace.carousel.goToPage": "ໄປທີ່ໜ້າ {{page}}",
"marketplace.carousel.scrollNext": "ໜ້າຕໍ່ໄປ",
"marketplace.carousel.scrollPrevious": "ໜ້າກ່ອນໜ້າ",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "ຄົ້ນຫາ",
"marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ",
@ -233,6 +236,7 @@
"marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ",
"marketplace.home.trendingReadMoreAbout": "ອ່ານເພີ່ມເຕີມກ່ຽວກັບ {{title}}",
"marketplace.home.trendingView": "ເບິ່ງ",
"marketplace.loadError": "ໂຫຼດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ.",
"marketplace.moreFrom": "ເພີ່ມເຕີມຈາກ Marketplace",
"marketplace.noPluginFound": "ບໍ່ພົບການເຊື່ອມຕໍ່",
"marketplace.partnerTip": "ໄດ້ຮັບການຢືນຢັນໂດຍພັດທະນາມິດຂອງ Dify",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Alle plugins",
"marketplace.and": "and",
"marketplace.becomePartner": "Word partner",
"marketplace.carousel.goToPage": "Ga naar pagina {{page}}",
"marketplace.carousel.scrollNext": "Volgende pagina",
"marketplace.carousel.scrollPrevious": "Vorige pagina",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Discover",
"marketplace.empower": "Empower your AI development",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Lees meer over {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Bekijken",
"marketplace.loadError": "Laden mislukt. Probeer het opnieuw.",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noPluginFound": "Geen plugin gevonden",
"marketplace.partnerTip": "Verified by a Dify partner",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Wszystkie integracje",
"marketplace.and": "i",
"marketplace.becomePartner": "Zostań partnerem",
"marketplace.carousel.goToPage": "Przejdź do strony {{page}}",
"marketplace.carousel.scrollNext": "Następna strona",
"marketplace.carousel.scrollPrevious": "Poprzednia strona",
"marketplace.difyMarketplace": "Rynek Dify",
"marketplace.discover": "Odkryć",
"marketplace.empower": "Zwiększ możliwości rozwoju sztucznej inteligencji",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Czytaj więcej o {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Zobacz",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Todas as integrações",
"marketplace.and": "e",
"marketplace.becomePartner": "Torne-se um parceiro",
"marketplace.carousel.goToPage": "Ir para a página {{page}}",
"marketplace.carousel.scrollNext": "Próxima página",
"marketplace.carousel.scrollPrevious": "Página anterior",
"marketplace.difyMarketplace": "Mercado Dify",
"marketplace.discover": "Descobrir",
"marketplace.empower": "Capacite seu desenvolvimento de IA",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Leia mais sobre {{title}}",
"marketplace.home.trendingTitle": "Os plugins que todos estão instalando",
"marketplace.home.trendingView": "Ver",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Toate pluginurile",
"marketplace.and": "și",
"marketplace.becomePartner": "Deveniți partener",
"marketplace.carousel.goToPage": "Mergi la pagina {{page}}",
"marketplace.carousel.scrollNext": "Pagina următoare",
"marketplace.carousel.scrollPrevious": "Pagina anterioară",
"marketplace.difyMarketplace": "Piața Dify",
"marketplace.discover": "Descoperi",
"marketplace.empower": "Îmbunătățește-ți dezvoltarea AI",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Citește mai mult despre {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Vezi",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Все плагины",
"marketplace.and": "и",
"marketplace.becomePartner": "Стать партнёром",
"marketplace.carousel.goToPage": "Перейти на страницу {{page}}",
"marketplace.carousel.scrollNext": "Следующая страница",
"marketplace.carousel.scrollPrevious": "Предыдущая страница",
"marketplace.difyMarketplace": "Торговая площадка Dify",
"marketplace.discover": "Обнаруживать",
"marketplace.empower": "Расширьте возможности разработки ИИ",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Подробнее о {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Открыть",
"marketplace.loadError": "Не удалось загрузить. Повторите попытку.",
"marketplace.moreFrom": "Больше из Marketplace",
"marketplace.noPluginFound": "Плагин не найден",
"marketplace.partnerTip": "Подтверждено партнером Dify",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Vsi vtičniki",
"marketplace.and": "in",
"marketplace.becomePartner": "Postanite partner",
"marketplace.carousel.goToPage": "Pojdi na stran {{page}}",
"marketplace.carousel.scrollNext": "Naslednja stran",
"marketplace.carousel.scrollPrevious": "Prejšnja stran",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Odkrijte",
"marketplace.empower": "Okrepite svoj razvoj AI",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Preberi več o {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Ogled",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "ปลั๊กอินทั้งหมด",
"marketplace.and": "และ",
"marketplace.becomePartner": "เป็นพันธมิตร",
"marketplace.carousel.goToPage": "ไปที่หน้า {{page}}",
"marketplace.carousel.scrollNext": "หน้าถัดไป",
"marketplace.carousel.scrollPrevious": "หน้าก่อนหน้า",
"marketplace.difyMarketplace": "ตลาด Dify",
"marketplace.discover": "ค้นพบ",
"marketplace.empower": "เพิ่มศักยภาพในการพัฒนา AI ของคุณ",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "อ่านเพิ่มเติมเกี่ยวกับ {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "ดู",
"marketplace.loadError": "โหลดไม่สำเร็จ โปรดลองอีกครั้ง",
"marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace",
"marketplace.noPluginFound": "ไม่พบปลั๊กอิน",
"marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Tüm eklentiler",
"marketplace.and": "ve",
"marketplace.becomePartner": "Partner Olun",
"marketplace.carousel.goToPage": "{{page}}. sayfaya git",
"marketplace.carousel.scrollNext": "Sonraki sayfa",
"marketplace.carousel.scrollPrevious": "Önceki sayfa",
"marketplace.difyMarketplace": "Dify Pazar Yeri",
"marketplace.discover": "Keşfet",
"marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin",
@ -245,6 +248,7 @@
"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.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ı",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Всі плагіни",
"marketplace.and": "і",
"marketplace.becomePartner": "Стати партнером",
"marketplace.carousel.goToPage": "Перейти на сторінку {{page}}",
"marketplace.carousel.scrollNext": "Наступна сторінка",
"marketplace.carousel.scrollPrevious": "Попередня сторінка",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Виявити",
"marketplace.empower": "Розширюйте можливості розробки штучного інтелекту",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Дізнатися більше про {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Переглянути",
"marketplace.loadError": "Не вдалося завантажити. Спробуйте ще раз.",
"marketplace.moreFrom": "Більше від Marketplace",
"marketplace.noPluginFound": "Плагін не знайдено",
"marketplace.partnerTip": "Перевірено партнером Dify",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "Tất cả plugin",
"marketplace.and": "và",
"marketplace.becomePartner": "Trở thành đối tác",
"marketplace.carousel.goToPage": "Đi tới trang {{page}}",
"marketplace.carousel.scrollNext": "Trang sau",
"marketplace.carousel.scrollPrevious": "Trang trước",
"marketplace.difyMarketplace": "Thị trường Dify",
"marketplace.discover": "Khám phá",
"marketplace.empower": "Hỗ trợ phát triển AI của bạn",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "Đọc thêm về {{title}}",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "Xem",
"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",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "所有集成",
"marketplace.and": "和",
"marketplace.becomePartner": "成为合作伙伴",
"marketplace.carousel.goToPage": "转到第 {{page}} 页",
"marketplace.carousel.scrollNext": "下一页",
"marketplace.carousel.scrollPrevious": "上一页",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "探索",
"marketplace.empower": "助力您的 AI 开发",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "阅读更多关于 {{title}} 的内容",
"marketplace.home.trendingTitle": "大家都在安装的插件",
"marketplace.home.trendingView": "查看",
"marketplace.loadError": "加载失败,请重试。",
"marketplace.moreFrom": "来自 Marketplace 的更多内容",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",

View File

@ -224,6 +224,9 @@
"marketplace.allPlugins": "所有集成",
"marketplace.and": "和",
"marketplace.becomePartner": "成為合作夥伴",
"marketplace.carousel.goToPage": "轉到第 {{page}} 頁",
"marketplace.carousel.scrollNext": "下一頁",
"marketplace.carousel.scrollPrevious": "上一頁",
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "發現",
"marketplace.empower": "為您的 AI 開發提供支援",
@ -245,6 +248,7 @@
"marketplace.home.trendingReadMoreAbout": "閱讀更多關於 {{title}} 的內容",
"marketplace.home.trendingTitle": "大家都在安裝的外掛程式",
"marketplace.home.trendingView": "查看",
"marketplace.loadError": "載入失敗,請重試。",
"marketplace.moreFrom": "來自 Marketplace 的更多內容",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "由 Dify 合作夥伴驗證",

View File

@ -83,7 +83,7 @@ describe('marketplace template discovery', () => {
mocks.templateCollections.mockRejectedValueOnce(new Error('Unavailable'))
const failed = await getMarketplaceTemplateCollectionsAndTemplates()
expect(failed).toEqual({ collections: [], templatesByCollection: {} })
expect(failed).toEqual({ collections: [], templatesByCollection: {}, ok: false })
mocks.templateCollections.mockResolvedValue({
data: {
@ -95,6 +95,7 @@ describe('marketplace template discovery', () => {
})
const recovered = await getMarketplaceTemplateCollectionsAndTemplates()
expect(recovered.ok).toBe(true)
expect(recovered.templatesByCollection).toEqual({ featured: [{ id: 'template-1' }] })
})
@ -123,6 +124,18 @@ describe('marketplace template discovery', () => {
categories: ['marketing'],
},
})
expect(result).toEqual({ page: 2, templates: [{ id: 'template-1' }], total: 1 })
expect(result).toEqual({ ok: true, page: 2, templates: [{ id: 'template-1' }], total: 1 })
})
it('marks a failed template search instead of reporting an empty result', async () => {
const { searchMarketplaceTemplates } = await importDiscovery()
mocks.templateSearch.mockRejectedValueOnce(new Error('Unavailable'))
const result = await searchMarketplaceTemplates({
category: 'all',
query: 'campaign',
})
expect(result).toEqual({ ok: false, page: 1, templates: [], total: 0 })
})
})

View File

@ -7,6 +7,11 @@ 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
@ -19,9 +24,10 @@ type SearchMarketplaceTemplatesOptions = {
sortOrder?: string
}
const EMPTY_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
const FAILED_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
collections: [],
templatesByCollection: {},
ok: false,
}
const COLLECTION_PREVIEW_TEMPLATE_LIMIT = 24
@ -73,6 +79,7 @@ async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollec
return {
collections,
templatesByCollection: Object.fromEntries(entries),
ok: true,
}
}
@ -92,7 +99,7 @@ export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<M
collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result }
return result
})
.catch(() => EMPTY_COLLECTIONS_RESULT)
.catch(() => FAILED_COLLECTIONS_RESULT)
.finally(() => {
collectionsInFlight = null
})
@ -120,12 +127,16 @@ export async function searchMarketplaceTemplates({
})
return {
ok: true,
page,
templates: response.data?.templates ?? [],
total: response.data?.total ?? 0,
}
} catch {
// Marked as failed so callers can distinguish an API outage from a
// genuinely empty search result.
return {
ok: false,
page,
templates: [],
total: 0,