feat(web): add embedded marketplace templates

This commit is contained in:
CodingOnStar 2026-08-05 16:43:47 +08:00
parent 00861ac5eb
commit 1bf2d191fe
18 changed files with 1007 additions and 18 deletions

View File

@ -53,9 +53,24 @@ 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[]
}
export type MarketplaceTemplateCollection = {
name: string
description: Record<string, string>
label: Record<string, string>
searchable?: boolean
search_params?: SearchParamsFromCollection
priority: number
}
export type MarketplacePluginCategory =
@ -154,6 +169,27 @@ 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
const bannerListContract = base
@ -227,6 +263,57 @@ const templateDetailContract = base
)
.output(type<TemplateDetailResponse>())
const templateCollectionsContract = base
.route({
path: '/template-collections',
method: 'GET',
})
.input(
type<{
query?: {
page?: number
page_size?: number
}
}>(),
)
.output(type<TemplateCollectionsResponse>())
const templateCollectionTemplatesContract = base
.route({
path: '/template-collections/{collectionName}/templates',
method: 'POST',
})
.input(
type<{
params: {
collectionName: string
}
body?: {
limit?: number
}
}>(),
)
.output(type<TemplateCollectionTemplatesResponse>())
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[]
}
}>(),
)
.output(type<TemplateSearchResponse>())
const downloadPluginContract = base
.route({
path: '/plugins/{organization}/{pluginName}/{version}/download',
@ -250,7 +337,10 @@ export const marketplaceRouterContract = {
collections: collectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
templateCollections: templateCollectionsContract,
templateCollectionTemplates: templateCollectionTemplatesContract,
templateDetail: templateDetailContract,
templateSearch: templateSearchContract,
downloadPlugin: downloadPluginContract,
}

View File

@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { redirect } from '@/next/navigation'
import TemplatesPage from '../page'
vi.mock('@/app/components/plugins/marketplace/templates', () => ({
EmbeddedTemplatesMarketplace: ({ category, query }: { category: string; query: string }) => (
<div>{`Templates catalog: ${category}:${query}`}</div>
),
}))
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('renders the templates catalog at /templates', async () => {
const page = await TemplatesPage({
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 TemplatesPage({
params: Promise.resolve({ category: ['marketing'] }),
searchParams: Promise.resolve({}),
})
render(page)
expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument()
})
it('opens template recommendations in the existing Dify import flow', async () => {
await expect(
TemplatesPage({
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')
})
})

View File

@ -0,0 +1,46 @@
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<{
q?: string
sort_by?: string
sort_order?: string
tid?: string
view?: string
}>
}
export default async function TemplatesPage({ 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 (
<div
id="marketplace-container"
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
>
<EmbeddedTemplatesMarketplace
category={category}
locale={locale}
query={resolvedSearchParams.q ?? ''}
sortBy={resolvedSearchParams.sort_by}
sortOrder={resolvedSearchParams.sort_order}
view={resolvedSearchParams.view}
/>
</div>
)
}

View File

@ -906,14 +906,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'

View File

@ -90,7 +90,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',
activeIcon: 'i-custom-vender-main-nav-marketplace-active',
visibility: VISIBLE_TO_ALL,

View File

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

View File

@ -130,7 +130,7 @@ describe('HomeCatalogNavigation', () => {
expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument()
})
it('links Dify users to the hosted Marketplace templates page', () => {
it('keeps Dify template navigation on the current origin', () => {
renderNavigation(false)
expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
@ -139,7 +139,7 @@ describe('HomeCatalogNavigation', () => {
)
expect(
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
).toHaveAttribute('href', 'https://marketplace.dify.ai/templates?source=console')
).toHaveAttribute('href', '/templates')
})
it('shows the compact navigation and header tabs after reaching the sticky header', () => {

View File

@ -23,15 +23,15 @@ const HomeCatalogTabs = ({
}: HomeCatalogTabsProps) => {
const { t } = useTranslation()
const catalogParams = language ? { language } : undefined
const getCatalogHref = (path: string) => {
if (!isMarketplacePlatform) return getMarketplaceUrl(path, catalogParams)
const getRelativeCatalogHref = (path: string) => {
const searchParams = new URLSearchParams(catalogParams)
const queryString = searchParams.toString()
return queryString ? `${path}?${queryString}` : path
}
const pluginsHref = getCatalogHref('/plugins')
const templatesHref = getCatalogHref('/templates')
const pluginsHref = isMarketplacePlatform
? getRelativeCatalogHref('/plugins')
: getMarketplaceUrl('/plugins', catalogParams)
const templatesHref = getRelativeCatalogHref('/templates')
const isPluginsActive = activeTab === 'plugins'
const isTemplatesActive = activeTab === 'templates'
const pluginsLabel = labels?.plugins ?? t(($) => $['marketplace.home.plugins'], { ns: 'plugin' })

View File

@ -5,7 +5,10 @@ import Carousel from '../carousel'
const mocks = vi.hoisted(() => {
const listeners = new Map<string, Set<() => void>>()
const carouselState = { selectedIndex: 0 }
const carouselState = {
scrollSnaps: [0, 1, 2, 3, 4],
selectedIndex: 0,
}
const api = {
off: vi.fn((event: string, listener: () => void) => {
listeners.get(event)?.delete(listener)
@ -17,7 +20,7 @@ const mocks = vi.hoisted(() => {
}),
scrollNext: vi.fn(),
scrollPrev: vi.fn(),
scrollSnapList: vi.fn(() => [0, 1, 2, 3, 4]),
scrollSnapList: vi.fn(() => carouselState.scrollSnaps),
scrollTo: vi.fn(),
selectedScrollSnap: vi.fn(() => carouselState.selectedIndex),
}
@ -108,6 +111,7 @@ describe('Marketplace Carousel', () => {
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) => {
@ -263,4 +267,16 @@ describe('Marketplace Carousel', () => {
})
expect(intersectionObservers).toHaveLength(0)
})
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()
})
})

View File

@ -226,7 +226,10 @@ const Carousel = ({
let isReducedMotion = reducedMotionQuery?.matches ?? false
const syncAutoplay = () => {
if (isInViewport && isDocumentVisible && !isReducedMotion && !isHovered) autoplay.play()
const hasMultiplePages = api.scrollSnapList().length > 1
if (hasMultiplePages && isInViewport && isDocumentVisible && !isReducedMotion && !isHovered)
autoplay.play()
else autoplay.stop()
}
const handleVisibilityChange = () => {

View File

@ -0,0 +1,35 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import TemplateCard from '../template-card'
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', () => {
it('opens a Marketplace template through the Dify import flow', () => {
render(<TemplateCard partnerText="Verified by a Dify partner" template={template} />)
expect(screen.getByRole('link', { name: 'Campaign planner' })).toHaveAttribute(
'href',
'/apps?template-id=template%2Fone',
)
expect(screen.getByText('dify')).toBeInTheDocument()
expect(screen.getByText('1.2k')).toBeInTheDocument()
expect(screen.getByLabelText('Verified by a Dify partner')).toBeInTheDocument()
})
})

View File

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

View File

@ -0,0 +1,274 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import type { TemplateCategory } from './categories'
import type { Locale } from '@/i18n-config'
import { cn } from '@langgenius/dify-ui/cn'
import AccountSection from '@/app/components/main-nav/components/account-section'
import { getTranslation } from '@/i18n-config/server'
import Link from '@/next/link'
import {
getMarketplaceTemplateCollectionsAndTemplates,
searchMarketplaceTemplates,
} from '@/service/marketplace-template-discovery'
import { fetchPluginBanners } from '../home/banners'
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 { HomeStickyStateProvider } from '../home/home-sticky-state-provider'
import styles from '../home/home-sticky.module.css'
import HomeTrending from '../home/home-trending'
import { GRID_CLASS } from '../list/collection-constants'
import pluginTypeStyles from '../plugin-type-switch.module.css'
import { TEMPLATE_CATEGORIES } from './categories'
import TemplateCard from './template-card'
import TemplateCollectionList from './template-collection-list'
import { filterTemplatesForLocale } from './template-language'
type EmbeddedTemplatesMarketplaceProps = {
category: TemplateCategory
locale: Locale
query: string
sortBy?: string
sortOrder?: string
view?: string
}
type TemplateCategoryLabels = Record<TemplateCategory, string>
function TemplateSearchForm({
action,
placeholder,
query,
}: {
action: string
placeholder: string
query: string
}) {
return (
<form action={action} className="relative w-full shrink-0">
<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"
defaultValue={query}
placeholder={placeholder}
className="h-9 w-full rounded-[10px] border-[0.5px] border-components-input-border-active bg-components-input-bg-normal py-2 pr-3 pl-9 text-sm text-text-primary outline-none placeholder:text-text-quaternary focus:border-state-accent-solid"
/>
</form>
)
}
function TemplateCategoryNavigation({
activeCategory,
ariaLabel,
labels,
query,
}: {
activeCategory: TemplateCategory
ariaLabel: string
labels: TemplateCategoryLabels
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)
const queryString = searchParams.toString()
const href = `/templates/${category}${queryString ? `?${queryString}` : ''}`
return (
<Link
key={category}
href={href}
scroll={false}
aria-current={category === activeCategory ? 'page' : undefined}
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]}
</Link>
)
})}
</nav>
)
}
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>
)
}
export async function EmbeddedTemplatesMarketplace({
category,
locale,
query,
sortBy,
sortOrder,
view,
}: EmbeddedTemplatesMarketplaceProps) {
const normalizedQuery = query.trim()
const showCollections = category === 'all' && !normalizedQuery && view !== 'search'
const [
{ t: tPlugin },
{ t: tApp },
{ t: tExplore },
{ t: tPluginTags },
collectionsResult,
searchResult,
banners,
] = await Promise.all([
getTranslation(locale, 'plugin'),
getTranslation(locale, 'app'),
getTranslation(locale, 'explore'),
getTranslation(locale, 'pluginTags'),
showCollections ? getMarketplaceTemplateCollectionsAndTemplates() : Promise.resolve(null),
showCollections
? Promise.resolve(null)
: searchMarketplaceTemplates({
category,
query: normalizedQuery,
sortBy,
sortOrder,
}),
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),
}
const templates = filterTemplatesForLocale(searchResult?.templates ?? [], locale)
const hasVisibleCollections =
collectionsResult?.collections.some(
(collection) =>
filterTemplatesForLocale(
collectionsResult.templatesByCollection[collection.name] ?? [],
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)
return (
<HomeStickyStateProvider>
<div className="flex min-h-full w-full flex-col bg-background-default">
<HomeHeader
activeTab="templates"
actions={
<div className="p-0.5">
<AccountSection compact />
</div>
}
catalogLabels={{ plugins: pluginsLabel, templates: templatesLabel }}
isMarketplacePlatform={false}
/>
<div className="relative flex w-full flex-col">
<HomeHero
isMarketplacePlatform={false}
title={templatesLabel}
subtitle={tExplore('apps.description' as never)}
/>
<HomeSearch>
<TemplateSearchForm
action={category === 'all' ? '/templates' : `/templates/${category}`}
placeholder={tApp('newAppFromTemplate.searchAllTemplate' as never)}
query={query}
/>
</HomeSearch>
{banners.length > 0 && (
<>
<div aria-hidden="true" className="h-12 shrink-0" />
<HomeTrending banners={banners} isMarketplacePlatform={false} />
</>
)}
<HomeCatalogNavigation
catalogTabs={
<HomeCatalogTabs
activeTab="templates"
isMarketplacePlatform={false}
labels={{ plugins: pluginsLabel, templates: templatesLabel }}
/>
}
catalogCategories={
<TemplateCategoryNavigation
activeCategory={category}
ariaLabel={tPlugin('allCategories' as never)}
labels={categoryLabels}
query={query}
/>
}
/>
<main
className={cn(
'relative flex grow flex-col bg-background-default px-8 py-2',
styles.catalogContent,
)}
>
{collectionsResult ? (
hasVisibleCollections ? (
<TemplateCollectionList
becomePartnerText={tPlugin('marketplace.becomePartner' as never)}
collections={collectionsResult.collections}
locale={locale}
partnerText={partnerText}
templatesByCollection={collectionsResult.templatesByCollection}
viewMoreText={tPlugin('marketplace.viewMore' as never)}
/>
) : (
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
)
) : (
<>
<div className="mb-5 text-right text-sm text-text-tertiary">
{tExplore('apps.resultNum' as never, { num: searchResult?.total ?? 0 })}
</div>
{templates.length > 0 ? (
<TemplateGrid partnerText={partnerText} templates={templates} />
) : (
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
)}
</>
)}
</main>
</div>
</div>
</HomeStickyStateProvider>
)
}

View File

@ -0,0 +1,85 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import { cn } from '@langgenius/dify-ui/cn'
import AppIcon from '@/app/components/base/app-icon'
import Partner from '@/app/components/plugins/base/badges/partner'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Link from '@/next/link'
import { formatNumberAbbreviated } from '@/utils/format'
import { getIconFromMarketPlace } from '@/utils/get-icon'
type TemplateCardProps = {
template: MarketplaceTemplate
className?: string
partnerText: string
}
const MAX_VISIBLE_PLUGIN_DEPENDENCIES = 7
export default function TemplateCard({ template, className, partnerText }: TemplateCardProps) {
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
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',
className,
)}
>
<div className="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">
<Link
href={`/apps?template-id=${encodeURIComponent(template.id)}`}
className="truncate system-md-medium text-text-primary after:absolute after:inset-0"
>
{template.template_name}
</Link>
{template.badges?.includes('partner') && (
<Partner className="relative z-[1] 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>
)
}

View File

@ -0,0 +1,153 @@
'use client'
import type {
MarketplaceTemplate,
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 TemplateCard from './template-card'
import { filterTemplatesForLocale, getTemplateCollectionText } from './template-language'
const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
const PARTNER_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template'])
type TemplateCollectionListProps = {
becomePartnerText: string
collections: MarketplaceTemplateCollection[]
locale: string
partnerText: string
templatesByCollection: Record<string, MarketplaceTemplate[]>
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
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 viewportWidth = useSyncExternalStore(
subscribeToViewport,
getViewportWidth,
getServerViewportWidth,
)
const itemsPerPage = getCarouselItemsPerPage(viewportWidth)
return collections.map((collection) => {
const templates = filterTemplatesForLocale(templatesByCollection[collection.name] ?? [], locale)
if (!templates.length) return null
const carouselPages = 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>
),
}
},
)
const isPartnerCollection = PARTNER_COLLECTION_NAMES.has(collection.name)
return (
<section key={collection.name} className="py-3">
<div className="mb-2 flex items-end justify-between gap-4">
<div className="min-w-0">
<h2 className="title-xl-semi-bold text-text-primary">
{getTemplateCollectionText(collection.label, locale)}
</h2>
<div className="flex flex-wrap items-center gap-x-2 system-xs-regular text-text-tertiary">
<span>{getTemplateCollectionText(collection.description, locale)}</span>
{isPartnerCollection && (
<>
<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>{becomePartnerText}</span>
<span aria-hidden className="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={carouselPages}
showNavigation
showPagination
autoPlay
autoPlayInterval={5000}
pauseWhenOffscreen
/>
)}
</section>
)
})
}

View File

@ -0,0 +1,40 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
type TemplateLanguageFamily = 'en' | 'ja' | 'other' | 'zh'
function getTemplateLanguageFamily(locale: string): TemplateLanguageFamily {
const normalizedLocale = locale.toLowerCase()
if (normalizedLocale.startsWith('en')) return 'en'
if (normalizedLocale.startsWith('zh')) return 'zh'
if (normalizedLocale.startsWith('ja')) return 'ja'
return 'other'
}
export function filterTemplatesForLocale<
T extends Pick<MarketplaceTemplate, 'preferred_languages'>,
>(templates: T[], locale: string) {
const languageFamily = getTemplateLanguageFamily(locale)
return templates.filter((template) => {
const preferredLanguages = (template.preferred_languages ?? []).map((language) =>
language.toLowerCase(),
)
if (languageFamily === 'other') {
return !preferredLanguages.some(
(language) =>
language.startsWith('en') || language.startsWith('zh') || language.startsWith('ja'),
)
}
return preferredLanguages.some((language) => language.startsWith(languageFamily))
})
}
export function getTemplateCollectionText(value: Record<string, string>, locale: string) {
const localeKey = locale.replace('-', '_')
return value[localeKey] || value.en_US || Object.values(value)[0] || ''
}

View File

@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getMarketplaceTemplateCollectionsAndTemplates,
searchMarketplaceTemplates,
} from './marketplace-template-discovery'
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),
},
}))
describe('marketplace template discovery', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads each template collection and isolates a failed collection', async () => {
mocks.templateCollections.mockResolvedValue({
data: {
collections: [
{ name: 'featured', label: {}, description: {}, priority: 1 },
{ name: 'partners', label: {}, description: {}, priority: 2 },
],
},
})
mocks.templateCollectionTemplates
.mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } })
.mockRejectedValueOnce(new Error('Unavailable'))
const result = await getMarketplaceTemplateCollectionsAndTemplates()
expect(mocks.templateCollections).toHaveBeenCalledWith({
query: { page: 1, page_size: 100 },
})
expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(1, {
params: { collectionName: 'featured' },
body: { limit: 100 },
})
expect(result.templatesByCollection).toEqual({
featured: [{ id: 'template-1' }],
partners: [],
})
})
it('sends category searches through the Marketplace contract', async () => {
mocks.templateSearch.mockResolvedValue({
data: {
templates: [{ id: 'template-1' }],
total: 1,
},
})
const result = await searchMarketplaceTemplates({
category: 'marketing',
query: 'campaign',
})
expect(mocks.templateSearch).toHaveBeenCalledWith({
body: {
page: 1,
page_size: 40,
query: 'campaign',
sort_by: 'usage_count',
sort_order: 'DESC',
categories: ['marketing'],
},
})
expect(result).toEqual({ templates: [{ id: 'template-1' }], total: 1 })
})
})

View File

@ -0,0 +1,85 @@
import type {
MarketplaceTemplate,
MarketplaceTemplateCollection,
} from '@dify/contracts/marketplace'
import { marketplaceClient } from './client'
export type MarketplaceTemplateCollectionsResult = {
collections: MarketplaceTemplateCollection[]
templatesByCollection: Record<string, MarketplaceTemplate[]>
}
type SearchMarketplaceTemplatesOptions = {
category: string
query: string
sortBy?: string
sortOrder?: string
}
const EMPTY_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
collections: [],
templatesByCollection: {},
}
export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
try {
const response = await marketplaceClient.templateCollections({
query: {
page: 1,
page_size: 100,
},
})
const collections = response.data?.collections ?? []
const entries = await Promise.all(
collections.map(async (collection) => {
try {
const collectionResponse = await marketplaceClient.templateCollectionTemplates({
params: { collectionName: collection.name },
body: { limit: 100 },
})
return [collection.name, collectionResponse.data?.templates ?? []] as const
} catch {
return [collection.name, []] as const
}
}),
)
return {
collections,
templatesByCollection: Object.fromEntries(entries),
}
} catch {
return EMPTY_COLLECTIONS_RESULT
}
}
export async function searchMarketplaceTemplates({
category,
query,
sortBy = 'usage_count',
sortOrder = 'DESC',
}: SearchMarketplaceTemplatesOptions) {
try {
const response = await marketplaceClient.templateSearch({
body: {
page: 1,
page_size: 40,
query,
sort_by: sortBy,
sort_order: sortOrder,
...(category === 'all' ? {} : { categories: [category] }),
},
})
return {
templates: response.data?.templates ?? [],
total: response.data?.total ?? 0,
}
} catch {
return {
templates: [],
total: 0,
}
}
}