diff --git a/packages/contracts/marketplace.ts b/packages/contracts/marketplace.ts index 459f3d778e6..069c4d52f53 100644 --- a/packages/contracts/marketplace.ts +++ b/packages/contracts/marketplace.ts @@ -156,6 +156,21 @@ export type TemplateDetailResponse = { export type DownloadPluginResponse = Blob +const bannerListContract = base + .route({ + path: '/banners', + method: 'GET', + }) + .input( + type<{ + query: { + page: 'plugins' + language: string + } + }>(), + ) + .output(type()) + const collectionsContract = base .route({ path: '/collections', @@ -229,6 +244,9 @@ const downloadPluginContract = base .output(type()) export const marketplaceRouterContract = { + banners: { + list: bannerListContract, + }, collections: collectionsContract, collectionPlugins: collectionPluginsContract, searchAdvanced: searchAdvancedContract, diff --git a/web/app/components/base/carousel/index.tsx b/web/app/components/base/carousel/index.tsx index 765c1fd4f9a..14d70c1a207 100644 --- a/web/app/components/base/carousel/index.tsx +++ b/web/app/components/base/carousel/index.tsx @@ -15,6 +15,7 @@ type CarouselProps = Readonly<{ opts?: CarouselOptions plugins?: CarouselPlugin orientation?: 'horizontal' | 'vertical' + overlay?: React.ReactNode }> type CarouselContextValue = { @@ -49,7 +50,7 @@ type TCarousel = { > const Carousel: TCarousel = React.forwardRef( - ({ orientation = 'horizontal', opts, plugins, className, children, ...props }, ref) => { + ({ orientation = 'horizontal', opts, plugins, overlay, className, children, ...props }, ref) => { const [carouselRef, api] = useEmblaCarousel( { ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' }, plugins, @@ -98,6 +99,35 @@ const Carousel: TCarousel = React.forwardRef( canScrollNext, })) + const carousel = overlay + ? ( +
+ {overlay} +
+ {children} +
+
+ ) + : ( +
+ {children} +
+ ) + return ( -
- {children} -
+ {carousel}
) }, diff --git a/web/app/components/plugins/marketplace/home/assets/background.jpg b/web/app/components/plugins/marketplace/home/assets/background.jpg new file mode 100644 index 00000000000..38c304faa99 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/background.jpg differ diff --git a/web/app/components/plugins/marketplace/home/assets/blue-brick.png b/web/app/components/plugins/marketplace/home/assets/blue-brick.png new file mode 100644 index 00000000000..c11f0061dca Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/blue-brick.png differ diff --git a/web/app/components/plugins/marketplace/home/assets/green-flag.png b/web/app/components/plugins/marketplace/home/assets/green-flag.png new file mode 100644 index 00000000000..b9544b54e01 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/green-flag.png differ diff --git a/web/app/components/plugins/marketplace/home/assets/magnifying-glass.png b/web/app/components/plugins/marketplace/home/assets/magnifying-glass.png new file mode 100644 index 00000000000..3d4e95391d9 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/magnifying-glass.png differ diff --git a/web/app/components/plugins/marketplace/home/assets/red-brick.png b/web/app/components/plugins/marketplace/home/assets/red-brick.png new file mode 100644 index 00000000000..0fd6fe7e57b Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/red-brick.png differ diff --git a/web/app/components/plugins/marketplace/home/assets/star.png b/web/app/components/plugins/marketplace/home/assets/star.png new file mode 100644 index 00000000000..e8a2c4a352b Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/star.png differ diff --git a/web/app/components/plugins/marketplace/home/assets/telescope.png b/web/app/components/plugins/marketplace/home/assets/telescope.png new file mode 100644 index 00000000000..b097c828b6c Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/telescope.png differ diff --git a/web/app/components/plugins/marketplace/home/banners.spec.ts b/web/app/components/plugins/marketplace/home/banners.spec.ts new file mode 100644 index 00000000000..dbc9f62d1ae --- /dev/null +++ b/web/app/components/plugins/marketplace/home/banners.spec.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { marketplaceClient } from '@/service/client' +import { fetchPluginRecommendBanners } from './banners' + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + banners: { + list: vi.fn(), + }, + }, +})) + +const mockedListBanners = vi.mocked(marketplaceClient.banners.list) + +describe('fetchPluginRecommendBanners', () => { + beforeEach(() => { + mockedListBanners.mockReset() + }) + + it('normalizes, sorts, and limits recommend banners from the public contract', async () => { + mockedListBanners.mockResolvedValue({ + code: 0, + msg: 'success', + data: { + banners: [ + { + id: 'event', + style_type: 'event', + title: 'Event', + sort: 0, + language: 'en', + content: {}, + }, + { + id: 'recommend-2', + style_type: 'recommend', + title: 'Second', + sort: 2, + language: 'en', + content: { + cards: [ + { + item_type: 'plugin', + item_id: 'langgenius/fifth', + display_name: 'Fifth', + link: '/plugins/langgenius/fifth', + card_position: 4, + }, + { + item_type: 'plugin', + item_id: 'langgenius/first', + display_name: 'First', + icon_url: '/api/v1/plugins/langgenius/first/icon', + link: '/plugins/langgenius/first', + card_position: 0, + }, + { + item_type: 'plugin', + item_id: 'langgenius/third', + display_name: 'Third', + link: '/plugins/langgenius/third', + card_position: 2, + }, + { + item_type: 'plugin', + item_id: 'langgenius/second', + display_name: 'Second', + link: '/plugins/langgenius/second', + card_position: 1, + }, + { + item_type: 'plugin', + item_id: 'langgenius/fourth', + display_name: 'Fourth', + link: '/plugins/langgenius/fourth', + card_position: 3, + }, + ], + }, + }, + { + id: 'recommend-1', + style_type: 'recommend', + title: 'First', + sort: 1, + language: 'en', + content: { + cards: [ + { + item_type: 'plugin', + item_id: 'langgenius/agent', + display_name: 'Agent', + link: '/plugins/langgenius/agent', + card_position: 0, + }, + ], + }, + }, + ], + }, + }) + + const banners = await fetchPluginRecommendBanners('en-US') + + expect(mockedListBanners).toHaveBeenCalledWith({ + query: { + page: 'plugins', + language: 'en-US', + }, + }) + expect(banners.map(banner => banner.id)).toEqual(['recommend-1', 'recommend-2']) + expect(banners[1]!.content.cards.map(card => card.display_name)) + .toEqual(['First', 'Second', 'Third', 'Fourth']) + }) + + it('returns no banners for an empty response', async () => { + mockedListBanners.mockResolvedValue('') + + await expect(fetchPluginRecommendBanners('en-US')).resolves.toEqual([]) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/banners.ts b/web/app/components/plugins/marketplace/home/banners.ts new file mode 100644 index 00000000000..a6c4ff15c38 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/banners.ts @@ -0,0 +1,127 @@ +import { marketplaceClient } from '@/service/client' + +const MAX_TRENDING_PAGES = 3 +const MAX_CARDS_PER_PAGE = 4 + +export type BannerRecommendCard = { + item_type: 'plugin' | 'template' + item_id: string + display_name: string + icon_url?: string + icon?: string + icon_background?: string + link: string + card_position: number +} + +export type BannerRecommend = { + id: string + style_type: 'recommend' + title: string + sort: number + language: string + content: { + theme_type?: string + heading?: string + subheadings?: string[] + description?: string + cards: BannerRecommendCard[] + } +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +const parseRecommendCard = (value: unknown): BannerRecommendCard | null => { + if (!isRecord(value)) + return null + + const itemType = value.item_type + const itemId = value.item_id + const displayName = value.display_name + if ( + (itemType !== 'plugin' && itemType !== 'template') + || typeof itemId !== 'string' + || !itemId + || typeof displayName !== 'string' + || !displayName + ) { + return null + } + + return { + item_type: itemType, + item_id: itemId, + display_name: displayName, + icon_url: typeof value.icon_url === 'string' ? value.icon_url : undefined, + icon: typeof value.icon === 'string' ? value.icon : undefined, + icon_background: typeof value.icon_background === 'string' ? value.icon_background : undefined, + link: typeof value.link === 'string' ? value.link : '', + card_position: typeof value.card_position === 'number' ? value.card_position : 0, + } +} + +const parseRecommendBanner = (value: unknown): BannerRecommend | null => { + if (!isRecord(value) || value.style_type !== 'recommend' || !isRecord(value.content)) + return null + + const cards = Array.isArray(value.content.cards) + ? value.content.cards + .map(parseRecommendCard) + .filter((card): card is BannerRecommendCard => Boolean(card)) + .sort((a, b) => a.card_position - b.card_position) + .slice(0, MAX_CARDS_PER_PAGE) + : [] + + if ( + typeof value.id !== 'string' + || typeof value.title !== 'string' + || typeof value.sort !== 'number' + || typeof value.language !== 'string' + || cards.length === 0 + ) { + return null + } + + const subheadings = Array.isArray(value.content.subheadings) + ? value.content.subheadings.filter((item): item is string => typeof item === 'string') + : undefined + + return { + id: value.id, + style_type: 'recommend', + title: value.title, + sort: value.sort, + language: value.language, + content: { + theme_type: typeof value.content.theme_type === 'string' ? value.content.theme_type : undefined, + heading: typeof value.content.heading === 'string' ? value.content.heading : undefined, + subheadings, + description: typeof value.content.description === 'string' ? value.content.description : undefined, + cards, + }, + } +} + +export const normalizePluginRecommendBanners = (response: unknown): BannerRecommend[] => { + if (!isRecord(response) || !isRecord(response.data) || !Array.isArray(response.data.banners)) + return [] + + return response.data.banners + .map(parseRecommendBanner) + .filter((banner): banner is BannerRecommend => Boolean(banner)) + .sort((a, b) => a.sort - b.sort) + .slice(0, MAX_TRENDING_PAGES) +} + +export const fetchPluginRecommendBanners = async (language: string): Promise => { + const response = await marketplaceClient.banners.list({ + query: { + page: 'plugins', + language, + }, + }) + + return normalizePluginRecommendBanners(response) +} diff --git a/web/app/components/plugins/marketplace/home/home-header.tsx b/web/app/components/plugins/marketplace/home/home-header.tsx new file mode 100644 index 00000000000..2e5152c535e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-header.tsx @@ -0,0 +1,54 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import DifyLogo from '@/app/components/base/logo/dify-logo' +import Link from '@/next/link' + +type HomeHeaderProps = { + actions?: React.ReactNode + isMarketplacePlatform: boolean +} + +const CreatorCenter = () => ( + + + +) + +const HomeHeader = ({ + actions, + isMarketplacePlatform, +}: HomeHeaderProps) => { + const { t } = useTranslation('plugin') + + return ( +
+ + + + {t(($) => $['marketplace.difyMarketplace'])} + + + +
+ + {actions} +
+
+ ) +} + +export default HomeHeader diff --git a/web/app/components/plugins/marketplace/home/home-hero.tsx b/web/app/components/plugins/marketplace/home/home-hero.tsx new file mode 100644 index 00000000000..142d9a01314 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-hero.tsx @@ -0,0 +1,89 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import blueBrick from './assets/blue-brick.png' +import greenFlag from './assets/green-flag.png' +import magnifyingGlass from './assets/magnifying-glass.png' +import redBrick from './assets/red-brick.png' +import star from './assets/star.png' +import telescope from './assets/telescope.png' + +type HomeHeroProps = { + isMarketplacePlatform: boolean +} + +const HomeHero = ({ isMarketplacePlatform }: HomeHeroProps) => { + const { t } = useTranslation('plugin') + + return ( +
+
+
+

+ {t(($) => $['marketplace.home.heroTitle'])} +

+

+ {t(($) => $['marketplace.home.heroSubtitle'])} +

+
+ + + + + + + +
+
+ ) +} + +export default HomeHero diff --git a/web/app/components/plugins/marketplace/home/home-search.tsx b/web/app/components/plugins/marketplace/home/home-search.tsx new file mode 100644 index 00000000000..8b67e3c3d7c --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-search.tsx @@ -0,0 +1,53 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useRef } from 'react' +import { useTranslation } from '#i18n' +import SearchBoxWrapper from '@/app/components/plugins/marketplace/search-box/search-box-wrapper' + +type HomeSearchProps = { + isMarketplacePlatform: boolean +} + +const HomeSearch = ({ isMarketplacePlatform }: HomeSearchProps) => { + const searchRef = useRef(null) + const { t } = useTranslation('plugin') + + useEffect(() => { + const handleGlobalSearchShortcut = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) + return + + event.preventDefault() + searchRef.current?.querySelector('input')?.focus() + } + + document.addEventListener('keydown', handleGlobalSearchShortcut) + return () => document.removeEventListener('keydown', handleGlobalSearchShortcut) + }, []) + + return ( +
+
+ $['marketplace.home.searchPlaceholder'])} + showTags={false} + usedInMarketplace={false} + /> + + ⌘ K + +
+
+ ) +} + +export default HomeSearch diff --git a/web/app/components/plugins/marketplace/home/home-trending-indicator.module.css b/web/app/components/plugins/marketplace/home/home-trending-indicator.module.css new file mode 100644 index 00000000000..e459f77d1f7 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-indicator.module.css @@ -0,0 +1,33 @@ +@property --trending-progress-angle { + syntax: ''; + inherits: false; + initial-value: 0deg; +} + +.progress { + --trending-progress-angle: 0deg; + + position: absolute; + inset: 0; + border-radius: 7px; + background: conic-gradient( + from 0deg, + var(--color-text-primary) var(--trending-progress-angle), + transparent var(--trending-progress-angle) + ); + animation-name: progress; + animation-timing-function: linear; + animation-fill-mode: forwards; +} + +@keyframes progress { + to { + --trending-progress-angle: 360deg; + } +} + +@media (prefers-reduced-motion: reduce) { + .progress { + animation: none; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx new file mode 100644 index 00000000000..9a231d59068 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -0,0 +1,377 @@ +'use client' + +import type { FocusEvent } from 'react' +import type { BannerRecommend, BannerRecommendCard } from './banners' +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { Carousel, useCarousel } from '@/app/components/base/carousel' +import { MARKETPLACE_API_PREFIX } from '@/config' +import Link from '@/next/link' +import background from './assets/background.jpg' +import styles from './home-trending-indicator.module.css' + +const AUTOPLAY_DELAY = 5000 + +type TrendingIndicatorProps = { + index: number + label: string + isCurrent: boolean + isNextSlide: boolean + isPaused: boolean + onClick: () => void +} + +const TrendingIndicator = ({ + index, + label, + isCurrent, + isNextSlide, + isPaused, + onClick, +}: TrendingIndicatorProps) => { + return ( + + ) +} + +type TrendingCopyProps = { + banners: BannerRecommend[] + isMarketplacePlatform: boolean +} + +const TrendingCopy = ({ + banners, + isMarketplacePlatform, +}: TrendingCopyProps) => { + const { t } = useTranslation('plugin') + const { api, selectedIndex } = useCarousel() + const [isPlaying, setIsPlaying] = useState(false) + const shouldResumeAfterFocusRef = useRef(false) + const nextIndex = (selectedIndex + 1) % banners.length + + const pauseRotationForFocus = () => { + const autoplay = api?.plugins().autoplay + if (!autoplay?.isPlaying()) + return + + shouldResumeAfterFocusRef.current = true + autoplay.stop() + } + + const resumeRotationAfterFocus = (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget)) + return + if (!shouldResumeAfterFocusRef.current) + return + + shouldResumeAfterFocusRef.current = false + api?.plugins().autoplay?.play() + } + + useEffect(() => { + if (!api) + return + + const handleAutoplayPlay = () => setIsPlaying(true) + const handleAutoplayStop = () => setIsPlaying(false) + + // oxlint-disable-next-line eslint-react/set-state-in-effect -- Embla owns this external playback state. + setIsPlaying(api.plugins().autoplay?.isPlaying() ?? false) + api.on('autoplay:play', handleAutoplayPlay) + api.on('autoplay:stop', handleAutoplayStop) + + return () => { + api.off('autoplay:play', handleAutoplayPlay) + api.off('autoplay:stop', handleAutoplayStop) + } + }, [api]) + + return ( +
+
+

+ {t(($) => $['marketplace.home.trendingEyebrow'])} +

+ +

+ {t(($) => $['marketplace.home.trendingDescription'])} +

+
+ +
$['marketplace.home.trendingPaginationLabel'])} + className="flex shrink-0 items-center py-1 pr-10" + onFocusCapture={pauseRotationForFocus} + onBlurCapture={resumeRotationAfterFocus} + > +
+ {banners.map((banner, index) => ( + api?.scrollTo(index)} + /> + ))} +
+
+
+ ) +} + +const getMarketplaceAssetURL = (path?: string) => { + if (!path) + return '' + if (/^https?:\/\//.test(path)) + return path + + try { + const apiURL = new URL(MARKETPLACE_API_PREFIX) + if (path.startsWith('/api/')) + return `${apiURL.origin}${path}` + return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}` + } + catch { + return path + } +} + +const getLocalCardHref = (card: BannerRecommendCard) => { + if (card.item_type === 'plugin') { + const [organization, pluginName] = card.item_id.split('/') + if (organization && pluginName) + return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}` + } + + if (card.item_type === 'template') + return `/templates?tid=${encodeURIComponent(card.item_id)}` + + return '/' +} + +const getCardHref = ( + card: BannerRecommendCard, + isMarketplacePlatform: boolean, +) => { + if (!isMarketplacePlatform && card.link) + return card.link + return getLocalCardHref(card) +} + +const getCardCreator = (card: BannerRecommendCard) => { + if (card.item_type !== 'plugin') + return '' + + return card.item_id.split('/')[0] || '' +} + +type TrendingCardProps = { + card: BannerRecommendCard + isMarketplacePlatform: boolean +} + +const TrendingCard = ({ + card, + isMarketplacePlatform, +}: TrendingCardProps) => { + const { t } = useTranslation('plugin') + const iconURL = getMarketplaceAssetURL(card.icon_url) + const creator = getCardCreator(card) + const href = getCardHref(card, isMarketplacePlatform) + const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href) + + return ( + +
+ {!iconURL && card.icon + ? {card.icon} + : null} + {!iconURL && !card.icon + ?
+ +

+ {card.display_name} +

+ {creator + ? ( +

+ {t(($) => $['marketplace.home.trendingByCreator'], { creator })} +

+ ) + : null} + +