diff --git a/packages/contracts/marketplace.ts b/packages/contracts/marketplace.ts index 069c4d52f53..d404245cc4b 100644 --- a/packages/contracts/marketplace.ts +++ b/packages/contracts/marketplace.ts @@ -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 + label: Record + 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()) +const templateCollectionsContract = base + .route({ + path: '/template-collections', + method: 'GET', + }) + .input( + type<{ + query?: { + page?: number + page_size?: number + } + }>(), + ) + .output(type()) + +const templateCollectionTemplatesContract = base + .route({ + path: '/template-collections/{collectionName}/templates', + method: 'POST', + }) + .input( + type<{ + params: { + collectionName: string + } + body?: { + limit?: number + } + }>(), + ) + .output(type()) + +const templateSearchContract = base + .route({ + path: '/templates/search/advanced', + method: 'POST', + }) + .input( + type<{ + body: { + page: number + page_size: number + query: string + sort_by: string + sort_order: string + categories?: string[] + } + }>(), + ) + .output(type()) + 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, } diff --git a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx new file mode 100644 index 00000000000..b528f3b5e56 --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx @@ -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 }) => ( +
{`Templates catalog: ${category}:${query}`}
+ ), +})) + +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: () => Promise.resolve('en-US'), +})) + +vi.mock('@/next/navigation', () => ({ + redirect: vi.fn((path: string) => { + throw new Error(`redirect:${path}`) + }), +})) + +describe('embedded templates route', () => { + it('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') + }) +}) diff --git a/web/app/(commonLayout)/templates/[[...category]]/page.tsx b/web/app/(commonLayout)/templates/[[...category]]/page.tsx new file mode 100644 index 00000000000..3c02c067b95 --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/page.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 9bbc932eb25..276d1030cc5 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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' diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index 7d308a1cdea..34616420ea7 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -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, diff --git a/web/app/components/plugins/base/badges/partner.tsx b/web/app/components/plugins/base/badges/partner.tsx index 6d97d3b4898..41663ffee80 100644 --- a/web/app/components/plugins/base/badges/partner.tsx +++ b/web/app/components/plugins/base/badges/partner.tsx @@ -1,3 +1,5 @@ +'use client' + import type { FC } from 'react' import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark' import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight' diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx index dea5c12fbfe..e41c0e5ffbd 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx @@ -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', () => { diff --git a/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx index 59b3105ecb8..f3fd597ae6e 100644 --- a/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx +++ b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx @@ -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' }) diff --git a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx index 3ebf15d845e..3136c6bef50 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx @@ -5,7 +5,10 @@ import Carousel from '../carousel' const mocks = vi.hoisted(() => { const listeners = new Map 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() + const autoplay = mocks.autoplayInstances[0]! + + triggerIntersection(intersectionObservers[0]!, 1) + + expect(autoplay.play).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx index 5db04d0dab6..bf6d5105fa3 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -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 = () => { diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx new file mode 100644 index 00000000000..529b4a3818b --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx @@ -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: () =>
, +})) + +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() + + 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() + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/categories.ts b/web/app/components/plugins/marketplace/templates/categories.ts new file mode 100644 index 00000000000..2d8d02943e9 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/categories.ts @@ -0,0 +1,17 @@ +export const TEMPLATE_CATEGORIES = [ + 'all', + 'marketing', + 'sales', + 'support', + 'operations', + 'it', + 'knowledge', + 'design', + 'others', +] as const + +export type TemplateCategory = (typeof TEMPLATE_CATEGORIES)[number] + +export function isTemplateCategory(value: string | undefined): value is TemplateCategory { + return TEMPLATE_CATEGORIES.includes(value as TemplateCategory) +} diff --git a/web/app/components/plugins/marketplace/templates/index.tsx b/web/app/components/plugins/marketplace/templates/index.tsx new file mode 100644 index 00000000000..62131aa5a3d --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/index.tsx @@ -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 + +function TemplateSearchForm({ + action, + placeholder, + query, +}: { + action: string + placeholder: string + query: string +}) { + return ( +
+ + + + ) +} + +function TemplateCategoryNavigation({ + activeCategory, + ariaLabel, + labels, + query, +}: { + activeCategory: TemplateCategory + ariaLabel: string + labels: TemplateCategoryLabels + query: string +}) { + return ( + + ) +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function TemplateGrid({ + partnerText, + templates, +}: { + partnerText: string + templates: MarketplaceTemplate[] +}) { + return ( +
+ {templates.map((template) => ( + + ))} +
+ ) +} + +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 ( + +
+ + +
+ } + catalogLabels={{ plugins: pluginsLabel, templates: templatesLabel }} + isMarketplacePlatform={false} + /> +
+ + + + + {banners.length > 0 && ( + <> + +
+
+ ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-card.tsx b/web/app/components/plugins/marketplace/templates/template-card.tsx new file mode 100644 index 00000000000..109beae8f0c --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-card.tsx @@ -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 ( +
+
+ +
+
+ + {template.template_name} + + {template.badges?.includes('partner') && ( + + )} +
+
+ {publisher && {publisher}} + {publisher && ยท} + {formatNumberAbbreviated(template.usage_count)} +
+
+
+
+

+ {template.overview} +

+
+
+ {visiblePlugins.map((pluginId) => ( + + ))} + {remainingPluginCount > 0 && ( + +{remainingPluginCount} + )} +
+
+ ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-collection-list.tsx b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx new file mode 100644 index 00000000000..5e0e9f3fb0b --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx @@ -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 + 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: ( +
+ {pageTemplates.map((template) => ( +
+ +
+ ))} +
+ ), + } + }, + ) + const isPartnerCollection = PARTNER_COLLECTION_NAMES.has(collection.name) + + return ( +
+
+
+

+ {getTemplateCollectionText(collection.label, locale)} +

+
+ {getTemplateCollectionText(collection.description, locale)} + {isPartnerCollection && ( + <> + | + + {becomePartnerText} + + + + )} +
+
+ {collection.searchable && ( + + {viewMoreText} + + + )} +
+ {collection.searchable ? ( +
+ {templates.slice(0, 4).map((template) => ( + + ))} +
+ ) : ( + + )} +
+ ) + }) +} diff --git a/web/app/components/plugins/marketplace/templates/template-language.ts b/web/app/components/plugins/marketplace/templates/template-language.ts new file mode 100644 index 00000000000..fc2b293eeca --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-language.ts @@ -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, +>(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, locale: string) { + const localeKey = locale.replace('-', '_') + + return value[localeKey] || value.en_US || Object.values(value)[0] || '' +} diff --git a/web/service/marketplace-template-discovery.spec.ts b/web/service/marketplace-template-discovery.spec.ts new file mode 100644 index 00000000000..0730b51df5b --- /dev/null +++ b/web/service/marketplace-template-discovery.spec.ts @@ -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 }) + }) +}) diff --git a/web/service/marketplace-template-discovery.ts b/web/service/marketplace-template-discovery.ts new file mode 100644 index 00000000000..49e93d75ffa --- /dev/null +++ b/web/service/marketplace-template-discovery.ts @@ -0,0 +1,85 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { marketplaceClient } from './client' + +export type MarketplaceTemplateCollectionsResult = { + collections: MarketplaceTemplateCollection[] + templatesByCollection: Record +} + +type SearchMarketplaceTemplatesOptions = { + category: string + query: string + sortBy?: string + sortOrder?: string +} + +const EMPTY_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = { + collections: [], + templatesByCollection: {}, +} + +export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise { + 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, + } + } +}