feat: complete marketplace trending carousel

This commit is contained in:
CodingOnStar 2026-07-28 15:07:38 +08:00
parent 09698d3cca
commit 3fb85f76ad
14 changed files with 1177 additions and 418 deletions

View File

@ -0,0 +1,208 @@
import type { PluginBanner } from '../banners'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import HomeTrending from '../home-trending'
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: (namespace: string) => ({
t: withSelectorKey((key: string) => `${namespace}.${key}`),
}),
}
})
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
default: () => <span data-testid="partner-badge" />,
}))
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
default: () => <span data-testid="verified-badge" />,
}))
const banners: PluginBanner[] = [
{
id: 'recommend',
style_type: 'recommend',
title: 'Trending',
sort: 0,
language: 'en',
content: {
theme_type: 'hottest',
heading: 'Popular plugins',
description: 'Chosen from real usage.',
cards: [
{
item_type: 'plugin',
item_id: 'langgenius/dropbox',
display_name: 'Dropbox',
icon_url: '/api/v1/plugins/langgenius/dropbox/icon',
creator: 'langgenius',
badges: ['partner', 'verified'],
link: '/plugins/langgenius/dropbox',
card_position: 0,
},
{
item_type: 'plugin',
item_id: 'langgenius/zapier',
display_name: 'Zapier',
link: '/plugins/langgenius/zapier',
card_position: 1,
},
{
item_type: 'plugin',
item_id: 'langgenius/notion',
display_name: 'Notion',
link: '/plugins/langgenius/notion',
card_position: 2,
},
{
item_type: 'plugin',
item_id: 'langgenius/slack',
display_name: 'Slack',
link: '/plugins/langgenius/slack',
card_position: 3,
},
],
},
},
{
id: 'blog',
style_type: 'blog',
title: 'Dify Updates',
sort: 1,
language: 'en',
content: {
blog_title: 'Dify v1.9 new launch',
subtitle: 'New Agent node support',
description: 'Build agent workflows with the new Agent node.',
link: 'https://dify.ai/blog',
link_target_type: 'blog',
},
},
{
id: 'event',
style_type: 'event',
title: 'Duck Duck Go',
sort: 2,
language: 'en',
content: {
images: {
desktop: '/api/v1/banners/images/banners/duckduckgo.png',
mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png',
},
link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
alt_text: 'DuckDuckGo plugin',
},
},
]
describe('HomeTrending', () => {
it('renders and switches between the three API-backed banner layouts', async () => {
const user = userEvent.setup()
render(<HomeTrending banners={banners} isMarketplacePlatform />)
expect(screen.getByRole('heading', { name: 'Popular plugins' })).toBeInTheDocument()
const recommendationSlide = screen.getByRole('group', { name: 'Trending' })
expect(
within(recommendationSlide)
.getAllByRole('link')
.map((link) => link.getAttribute('aria-label')),
).toEqual(['Dropbox', 'Zapier', 'Notion', 'Slack'])
await user.click(screen.getByRole('button', { name: 'Dify Updates' }))
expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument()
expect(
screen.getByRole('link', {
name: 'Read more about Dify v1.9 new launch',
}),
).toHaveAttribute('href', 'https://dify.ai/blog')
await user.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
expect(screen.getByRole('link', { name: 'DuckDuckGo plugin' })).toHaveAttribute(
'href',
'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
)
})
it('switches to the selected slide from the pagination with the keyboard', async () => {
const user = userEvent.setup()
render(<HomeTrending banners={banners} isMarketplacePlatform />)
const duckDuckGoButton = screen.getByRole('button', { name: 'Duck Duck Go' })
duckDuckGoButton.focus()
await user.keyboard('{Enter}')
expect(duckDuckGoButton).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('button', { name: 'Trending' })).not.toHaveAttribute('aria-current')
expect(screen.getByRole('group', { name: 'Duck Duck Go' })).toHaveAttribute(
'aria-hidden',
'false',
)
})
it('toggles the carousel between paused and playing states', async () => {
const user = userEvent.setup()
render(<HomeTrending banners={banners} isMarketplacePlatform />)
const pauseButton = screen.getByRole('button', {
name: 'plugin.marketplace.home.trendingPause',
})
pauseButton.focus()
await user.keyboard('{Enter}')
const playButton = screen.getByRole('button', {
name: 'plugin.marketplace.home.trendingPlay',
})
playButton.focus()
await user.keyboard(' ')
expect(
screen.getByRole('button', {
name: 'plugin.marketplace.home.trendingPause',
}),
).toBeInTheDocument()
})
it('starts with autoplay paused when reduced motion is enabled', () => {
const matchMedia = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})
render(<HomeTrending banners={banners} isMarketplacePlatform />)
expect(
screen.getByRole('button', {
name: 'plugin.marketplace.home.trendingPlay',
}),
).toBeInTheDocument()
matchMedia.mockRestore()
})
it('renders no carousel when the API returns no banners', () => {
render(<HomeTrending banners={[]} isMarketplacePlatform />)
expect(
screen.queryByRole('region', {
name: 'plugin.marketplace.home.trendingTitle',
}),
).not.toBeInTheDocument()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { marketplaceClient } from '@/service/client'
import { fetchPluginRecommendBanners } from './banners'
import { fetchPluginBanners } from './banners'
vi.mock('@/service/client', () => ({
marketplaceClient: {
@ -12,12 +12,12 @@ vi.mock('@/service/client', () => ({
const mockedListBanners = vi.mocked(marketplaceClient.banners.list)
describe('fetchPluginRecommendBanners', () => {
describe('fetchPluginBanners', () => {
beforeEach(() => {
mockedListBanners.mockReset()
})
it('normalizes, sorts, and limits recommend banners from the public contract', async () => {
it('normalizes every public banner style in API sort order', async () => {
mockedListBanners.mockResolvedValue({
code: 0,
msg: 'success',
@ -26,31 +26,44 @@ describe('fetchPluginRecommendBanners', () => {
{
id: 'event',
style_type: 'event',
title: 'Event',
sort: 0,
language: 'en',
content: {},
},
{
id: 'recommend-2',
style_type: 'recommend',
title: 'Second',
sort: 2,
title: 'Dify Event',
sort: 3,
language: 'en',
content: {
images: {
desktop: '/api/v1/banners/images/banners/event.png',
mobile: '/api/v1/banners/images/banners/event-mobile.png',
},
link: 'https://dify.ai/events',
alt_text: 'Dify Event',
activity_id: 'event-1',
},
},
{
id: 'recommend',
style_type: 'recommend',
title: 'Trending Now',
sort: 1,
language: 'en',
content: {
theme_type: 'hottest',
heading: 'Popular plugins',
description: 'Chosen from real usage.',
cards: [
{
item_type: 'plugin',
item_id: 'langgenius/fifth',
display_name: 'Fifth',
link: '/plugins/langgenius/fifth',
card_position: 4,
item_id: 'langgenius/fourth',
display_name: 'Fourth',
link: '/plugins/langgenius/fourth',
card_position: 3,
},
{
item_type: 'plugin',
item_id: 'langgenius/first',
display_name: 'First',
icon_url: '/api/v1/plugins/langgenius/first/icon',
creator: 'langgenius',
badges: ['verified', 'partner', 'unknown'],
link: '/plugins/langgenius/first',
card_position: 0,
},
@ -68,39 +81,51 @@ describe('fetchPluginRecommendBanners', () => {
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,
id: 'ad',
style_type: 'ad',
title: 'Partner campaign',
sort: 4,
language: 'en',
content: {
cards: [
{
item_type: 'plugin',
item_id: 'langgenius/agent',
display_name: 'Agent',
link: '/plugins/langgenius/agent',
card_position: 0,
},
],
images: {
desktop: '/api/v1/banners/images/banners/ad.webp',
},
link: 'https://example.com',
partner_id: 'partner-1',
campaign_id: 'campaign-1',
},
},
{
id: 'blog',
style_type: 'blog',
title: 'Dify Updates',
sort: 2,
language: 'en',
content: {
blog_title: 'Dify v1.9 new launch',
subtitle: 'New Agent node support',
description: 'Build agent workflows with the new Agent node.',
link: 'https://dify.ai/blog',
link_target_type: 'blog',
},
},
{
id: 'unsupported',
style_type: 'popup',
title: 'Unsupported',
sort: 0,
language: 'en',
content: {},
},
],
},
})
const banners = await fetchPluginRecommendBanners('en-US')
const banners = await fetchPluginBanners('en-US')
expect(mockedListBanners).toHaveBeenCalledWith({
query: {
@ -108,14 +133,68 @@ describe('fetchPluginRecommendBanners', () => {
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'])
expect(banners.map((banner) => banner.id)).toEqual(['recommend', 'blog', 'event', 'ad'])
const recommend = banners[0]
expect(recommend?.style_type).toBe('recommend')
if (recommend?.style_type === 'recommend') {
expect(recommend.content.cards.map((card) => card.display_name)).toEqual([
'First',
'Second',
'Third',
'Fourth',
])
expect(recommend.content.cards[0]).toMatchObject({
creator: 'langgenius',
badges: ['verified', 'partner'],
})
}
const event = banners[2]
expect(event?.style_type).toBe('event')
if (event?.style_type === 'event') {
expect(event.content.images).toEqual({
desktop: '/api/v1/banners/images/banners/event.png',
mobile: '/api/v1/banners/images/banners/event-mobile.png',
})
}
})
it('returns no banners for an empty response', async () => {
mockedListBanners.mockResolvedValue('')
it('drops malformed banners and returns no placeholders for an empty response', async () => {
mockedListBanners
.mockResolvedValueOnce({
data: {
banners: [
{
id: 'empty-recommend',
style_type: 'recommend',
title: 'Empty',
sort: 0,
language: 'en',
content: {
theme_type: 'hottest',
cards: [],
},
},
{
id: 'event-without-desktop',
style_type: 'event',
title: 'Broken',
sort: 1,
language: 'en',
content: {
images: {
mobile: '/api/v1/banners/images/banners/mobile.png',
},
link: 'https://example.com',
},
},
],
},
})
.mockResolvedValueOnce('')
await expect(fetchPluginRecommendBanners('en-US')).resolves.toEqual([])
await expect(fetchPluginBanners('en-US')).resolves.toEqual([])
await expect(fetchPluginBanners('en-US')).resolves.toEqual([])
})
})

View File

@ -1,8 +1,14 @@
import { marketplaceClient } from '@/service/client'
const MAX_TRENDING_PAGES = 3
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
@ -10,18 +16,16 @@ export type BannerRecommendCard = {
icon_url?: string
icon?: string
icon_background?: string
creator?: string
badges?: Array<'partner' | 'verified'>
link: string
card_position: number
}
export type BannerRecommend = {
id: string
export type BannerRecommend = BannerBase & {
style_type: 'recommend'
title: string
sort: number
language: string
content: {
theme_type?: string
theme_type: 'newest' | 'hottest' | 'partner'
heading?: string
subheadings?: string[]
description?: string
@ -29,27 +33,90 @@ export type BannerRecommend = {
}
}
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)
}
const parseRecommendCard = (value: unknown): BannerRecommendCard | null => {
if (!isRecord(value))
const parseBannerBase = (value: Record<string, unknown>): BannerBase | null => {
if (
typeof value.id !== 'string' ||
!value.id ||
typeof value.title !== 'string' ||
!value.title ||
typeof value.sort !== 'number' ||
typeof value.language !== 'string' ||
!value.language
) {
return null
}
return {
id: value.id,
title: value.title,
sort: value.sort,
language: value.language,
}
}
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
(itemType !== 'plugin' && itemType !== 'template') ||
typeof itemId !== 'string' ||
!itemId ||
typeof displayName !== 'string' ||
!displayName
) {
return null
}
const badges = Array.isArray(value.badges)
? value.badges.filter(
(badge): badge is 'partner' | 'verified' => badge === 'partner' || badge === 'verified',
)
: undefined
return {
item_type: itemType,
item_id: itemId,
@ -57,65 +124,153 @@ const parseRecommendCard = (value: unknown): BannerRecommendCard | null => {
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,
creator: typeof value.creator === 'string' ? value.creator : undefined,
badges,
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 parseRecommendBanner = (
base: BannerBase,
content: Record<string, unknown>,
): BannerRecommend | null => {
const themeType = content.theme_type
if (themeType !== 'newest' && themeType !== 'hottest' && themeType !== 'partner') return null
const cards = Array.isArray(value.content.cards)
? value.content.cards
const cards = Array.isArray(content.cards)
? 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
}
if (cards.length === 0) return null
const subheadings = Array.isArray(value.content.subheadings)
? value.content.subheadings.filter((item): item is string => typeof item === 'string')
const subheadings = Array.isArray(content.subheadings)
? content.subheadings.filter((item): item is string => typeof item === 'string')
: undefined
return {
id: value.id,
...base,
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,
theme_type: themeType,
heading: typeof content.heading === 'string' ? content.heading : undefined,
subheadings,
description: typeof value.content.description === 'string' ? value.content.description : undefined,
description: typeof content.description === 'string' ? content.description : undefined,
cards,
},
}
}
export const normalizePluginRecommendBanners = (response: unknown): BannerRecommend[] => {
const parseBlogBanner = (base: BannerBase, content: Record<string, unknown>): BannerBlog | null => {
const linkTargetType = content.link_target_type
if (
typeof content.blog_title !== 'string' ||
!content.blog_title ||
typeof content.link !== 'string' ||
!content.link ||
(linkTargetType !== 'blog' && linkTargetType !== 'github')
) {
return null
}
return {
...base,
style_type: 'blog',
content: {
blog_title: content.blog_title,
subtitle: typeof content.subtitle === 'string' ? content.subtitle : undefined,
description: typeof content.description === 'string' ? content.description : undefined,
link: content.link,
link_target_type: linkTargetType,
},
}
}
const parseImageBanner = (
base: BannerBase,
styleType: 'event' | 'ad',
content: Record<string, unknown>,
): BannerEvent | BannerAd | null => {
if (
!isRecord(content.images) ||
typeof content.images.desktop !== 'string' ||
!content.images.desktop ||
typeof content.link !== 'string' ||
!content.link
) {
return null
}
const imageContent: BannerImageContent = {
images: {
desktop: content.images.desktop,
tablet:
typeof content.images.tablet === 'string' && content.images.tablet
? content.images.tablet
: undefined,
mobile:
typeof content.images.mobile === 'string' && content.images.mobile
? content.images.mobile
: undefined,
},
link: content.link,
alt_text: typeof content.alt_text === 'string' ? content.alt_text : undefined,
activity_id: typeof content.activity_id === 'string' ? content.activity_id : undefined,
}
if (styleType === 'event') {
return {
...base,
style_type: 'event',
content: imageContent,
}
}
return {
...base,
style_type: 'ad',
content: {
...imageContent,
partner_id: typeof content.partner_id === 'string' ? content.partner_id : undefined,
campaign_id: typeof content.campaign_id === 'string' ? content.campaign_id : undefined,
},
}
}
const parsePluginBanner = (value: unknown): PluginBanner | null => {
if (!isRecord(value) || !isRecord(value.content)) return null
const base = parseBannerBase(value)
if (!base) return null
switch (value.style_type) {
case 'recommend':
return parseRecommendBanner(base, value.content)
case 'blog':
return parseBlogBanner(base, value.content)
case 'event':
case 'ad':
return parseImageBanner(base, value.style_type, value.content)
default:
return null
}
}
export const normalizePluginBanners = (response: unknown): PluginBanner[] => {
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))
.map(parsePluginBanner)
.filter((banner): banner is PluginBanner => Boolean(banner))
.sort((a, b) => a.sort - b.sort)
.slice(0, MAX_TRENDING_PAGES)
}
export const fetchPluginRecommendBanners = async (language: string): Promise<BannerRecommend[]> => {
export const fetchPluginBanners = async (language: string): Promise<PluginBanner[]> => {
const response = await marketplaceClient.banners.list({
query: {
page: 'plugins',
@ -123,5 +278,5 @@ export const fetchPluginRecommendBanners = async (language: string): Promise<Ban
},
})
return normalizePluginRecommendBanners(response)
return normalizePluginBanners(response)
}

View File

@ -33,7 +33,7 @@ const HomeHeader = ({ actions, brandName, isMarketplacePlatform }: HomeHeaderPro
return (
<header
className={cn(
'sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 backdrop-blur-sm md:px-9',
'sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 py-1.5 backdrop-blur-sm md:px-9',
styles.header,
)}
>

View File

@ -1,33 +0,0 @@
@property --trending-progress-angle {
syntax: '<angle>';
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;
}
}

View File

@ -0,0 +1,108 @@
.wrapper {
padding-bottom: 30px;
}
.copy {
flex: none;
width: 36.9167%;
height: 200px;
}
.recommendVisual {
flex: 1;
min-width: 0;
container-type: inline-size;
}
.recommendCards {
display: flex;
justify-content: space-between;
gap: 12px;
overflow: hidden;
padding: 42px 36px;
}
.navigation {
top: 208px;
width: 100%;
}
.contentTrack {
transition: transform 400ms ease-out;
}
.card {
flex: 1 1 161px;
width: auto;
min-width: 161px;
max-width: 210px;
box-shadow: 0 8px 7.2px -6px rgb(0 0 0 / 19%);
scroll-snap-align: start;
}
@container (max-width: 751px) {
.recommendCards > .card:nth-child(n + 4) {
display: none;
}
}
@container (max-width: 578px) {
.recommendCards > .card:nth-child(n + 3) {
display: none;
}
}
@container (max-width: 405px) {
.recommendCards {
justify-content: flex-start;
overflow-x: auto;
scroll-snap-type: x proximity;
scrollbar-width: none;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.recommendCards::-webkit-scrollbar {
display: none;
}
.recommendCards > .card:nth-child(n) {
display: flex;
flex: 0 0 161px;
}
}
.updatesArt {
width: 33.3333%;
max-width: 400px;
}
.updatesDescription {
display: -webkit-box;
max-height: 40px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (prefers-reduced-motion: reduce) {
.contentTrack {
transition-duration: 0ms;
}
}
@media (min-width: 1232px) {
.marketplaceCopy {
width: 443px;
}
.updatesArt {
width: 400px;
}
}
@media (min-width: 1260px) {
.embeddedCopy {
width: 431px;
}
}

View File

@ -1,181 +1,83 @@
'use client'
import type { FocusEvent } from 'react'
import type { BannerRecommend, BannerRecommendCard } from './banners'
import type { RefObject } from 'react'
import type {
BannerAd,
BannerBlog,
BannerEvent,
BannerRecommend,
BannerRecommendCard,
PluginBanner,
} from './banners'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import { Carousel, useCarousel } from '@/app/components/base/carousel'
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 styles from './home-trending-indicator.module.css'
import difyUpdatesArt from './assets/dify-updates-art.png'
import styles from './home-trending.module.css'
const AUTOPLAY_DELAY = 5000
const PAGINATION_DOT_SIZE = 6
const PAGINATION_ACTIVE_WIDTH = 40
const PAGINATION_GAP = 8
const PAGINATION_STEP = PAGINATION_DOT_SIZE + PAGINATION_GAP
const PAGINATION_ACTIVE_SHIFT = PAGINATION_ACTIVE_WIDTH - PAGINATION_DOT_SIZE
type TrendingIndicatorProps = {
index: number
label: string
isCurrent: boolean
isNextSlide: boolean
isPaused: boolean
onClick: () => void
}
const getPaginationItemOffset = (index: number, selectedIndex: number) =>
index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0)
const TrendingIndicator = ({
index,
label,
isCurrent,
isNextSlide,
isPaused,
onClick,
}: TrendingIndicatorProps) => {
return (
<button
type="button"
aria-label={label}
aria-current={isCurrent ? 'true' : undefined}
onClick={onClick}
className="group relative flex size-6 shrink-0 items-center justify-center rounded-lg p-0 hover:bg-transparent"
>
<span
className={cn(
'relative flex h-5 w-[22px] items-center justify-center overflow-hidden rounded-[7px] p-px ring-1 ring-divider-subtle ring-inset',
isCurrent && 'bg-text-primary ring-text-primary',
)}
>
{isNextSlide && !isCurrent && !isPaused
? (
<span
data-progress-ring
className={styles.progress}
aria-hidden="true"
style={{ animationDuration: `${AUTOPLAY_DELAY}ms` }}
/>
)
: null}
<span className="relative z-10 flex h-[18px] w-5 items-center justify-center rounded-md bg-components-panel-on-panel-item-bg p-0.5 text-center text-[10px] leading-3 font-semibold text-text-tertiary transition-colors group-hover:text-text-secondary group-aria-[current=true]:bg-text-primary group-aria-[current=true]:text-components-panel-on-panel-item-bg">
{String(index + 1).padStart(2, '0')}
</span>
</span>
</button>
)
}
type AutoplayPauseReason = 'focus' | 'hover' | 'reduced-motion' | 'user' | 'visibility'
type TrendingCopyProps = {
banners: BannerRecommend[]
isMarketplacePlatform: boolean
}
const TrendingCopy = ({
banners,
function TrendingCopy({
banner,
isMarketplacePlatform,
}: TrendingCopyProps) => {
}: {
banner: BannerRecommend
isMarketplacePlatform: boolean
}) {
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<HTMLDivElement>) => {
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])
const heading = banner.content.heading || t(($) => $['marketplace.home.trendingTitle'])
const description =
banner.content.description ||
banner.content.subheadings?.join(' · ') ||
t(($) => $['marketplace.home.trendingDescription'])
return (
<div
className={cn(
'relative flex h-[200px] w-full flex-col gap-2.5 overflow-hidden bg-background-body p-4',
isMarketplacePlatform
? 'min-[1232px]:absolute min-[1232px]:top-0 min-[1232px]:right-full min-[1232px]:w-[443px]'
: 'min-[1260px]:absolute min-[1260px]:top-0 min-[1260px]:right-full min-[1260px]:w-[431px]',
styles.copy,
'flex min-w-0 flex-col items-start overflow-hidden p-5',
isMarketplacePlatform ? styles.marketplaceCopy : styles.embeddedCopy,
)}
>
<div className="flex min-h-0 flex-1 flex-col items-start gap-2 overflow-hidden">
<p className="shrink-0 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-accent">
{t(($) => $['marketplace.home.trendingEyebrow'])}
<div className="flex w-full flex-col items-start gap-2 overflow-hidden">
<p className="shrink-0 rounded-sm bg-state-accent-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-accent">
{banner.title}
</p>
<h2
id="home-trending-title"
className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary"
>
{t(($) => $['marketplace.home.trendingTitle'])}
<h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary">
{heading}
</h2>
<p className="text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary">
{t(($) => $['marketplace.home.trendingDescription'])}
<p className="w-full text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary">
{description}
</p>
</div>
<div
role="group"
aria-label={t(($) => $['marketplace.home.trendingPaginationLabel'])}
className="flex shrink-0 items-center py-1 pr-10"
onFocusCapture={pauseRotationForFocus}
onBlurCapture={resumeRotationAfterFocus}
>
<div className="flex items-center gap-0.5">
{banners.map((banner, index) => (
<TrendingIndicator
key={banner.id}
index={index}
label={`${String(index + 1).padStart(2, '0')} ${banner.title}`}
isCurrent={index === selectedIndex}
isNextSlide={index === nextIndex}
isPaused={!isPlaying}
onClick={() => api?.scrollTo(index)}
/>
))}
</div>
</div>
</div>
)
}
const getMarketplaceAssetURL = (path?: string) => {
if (!path)
return ''
if (/^https?:\/\//.test(path))
return path
if (!path) return ''
if (/^https?:\/\//.test(path) || path.startsWith('/_next/')) return path
try {
const apiURL = new URL(MARKETPLACE_API_PREFIX)
if (path.startsWith('/api/'))
return `${apiURL.origin}${path}`
if (path.startsWith('/api/')) return `${apiURL.origin}${path}`
return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
}
catch {
} catch {
return path
}
}
@ -187,42 +89,37 @@ const getLocalCardHref = (card: BannerRecommendCard) => {
return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}`
}
if (card.item_type === 'template')
return `/templates?tid=${encodeURIComponent(card.item_id)}`
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
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 ''
if (card.creator) return card.creator
if (card.item_type !== 'plugin') return ''
return card.item_id.split('/')[0] || ''
}
type TrendingCardProps = {
card: BannerRecommendCard
isMarketplacePlatform: boolean
}
const TrendingCard = ({
function TrendingCard({
card,
isMarketplacePlatform,
}: TrendingCardProps) => {
}: {
card: BannerRecommendCard
isMarketplacePlatform: boolean
}) {
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)
const isPartner = card.badges?.includes('partner')
const isVerified = card.badges?.includes('verified')
return (
<Link
@ -230,145 +127,480 @@ const TrendingCard = ({
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={card.display_name}
className="relative flex h-[116px] w-[161px] shrink-0 flex-col items-start overflow-hidden rounded-lg bg-components-panel-on-panel-item-bg-transparent p-3.5 shadow-md backdrop-blur-md"
className={cn(
styles.card,
'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)}
>
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge bg-cover bg-center bg-no-repeat"
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
style={{
backgroundColor: !iconURL ? card.icon_background : undefined,
backgroundImage: iconURL ? `url(${iconURL})` : undefined,
}}
>
{!iconURL && card.icon
? <span className="text-xl leading-none">{card.icon}</span>
: null}
{!iconURL && !card.icon
? <span aria-hidden="true" className="i-ri-image-line size-5 text-text-quaternary" />
: null}
{iconURL ? (
<img
src={iconURL}
width={40}
height={40}
alt=""
aria-hidden
className="size-full object-cover"
/>
) : card.icon ? (
<span className="text-xl leading-none">{card.icon}</span>
) : (
<span aria-hidden="true" className="i-ri-image-line size-5 text-text-quaternary" />
)}
</div>
<h3 className="mt-3 w-full truncate text-sm leading-[normal] font-medium text-text-primary">
{card.display_name}
</h3>
{creator
? (
<p className="mt-[3px] w-full truncate text-xs leading-[normal] font-normal text-text-tertiary">
<div className="flex w-full items-end gap-1">
<div className="flex min-w-0 flex-1 flex-col items-start gap-[3px]">
<div className="flex w-full min-w-0 items-center gap-[3px]">
<h3 className="min-w-0 truncate text-sm leading-[normal] font-medium text-text-primary">
{card.display_name}
</h3>
{(isPartner || isVerified) && (
<div className="flex shrink-0 items-start gap-[3.5px]">
{isPartner && (
<Partner className="size-3.5" text={t(($) => $['marketplace.partnerTip'])} />
)}
{isVerified && (
<Verified className="size-3.5" text={t(($) => $['marketplace.verifiedTip'])} />
)}
</div>
)}
</div>
{creator && (
<p className="w-full truncate text-xs leading-[normal] font-normal text-text-tertiary">
{t(($) => $['marketplace.home.trendingByCreator'], { creator })}
</p>
)
: null}
<span
aria-hidden="true"
className="i-ri-arrow-right-up-line absolute top-1.5 right-2 size-4 text-text-quaternary"
/>
)}
</div>
<span className="shrink-0 rounded-full bg-background-section-burn px-1.5 py-[3px] text-[10px] leading-3 font-normal text-text-primary">
{t(($) => $['marketplace.home.trendingView'])}
</span>
</div>
</Link>
)
}
type TrendingSlideProps = {
banner: BannerRecommend
isMarketplacePlatform: boolean
}
const TrendingSlide = ({
function TrendingRecommendationSlide({
banner,
isMarketplacePlatform,
}: TrendingSlideProps) => {
}: {
banner: BannerRecommend
isMarketplacePlatform: boolean
}) {
return (
<div className="relative h-[200px] w-full overflow-hidden rounded-xl bg-text-accent">
<img
src={background.src}
width={3840}
height={2160}
alt=""
aria-hidden
className="absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80"
/>
<div aria-hidden className="absolute inset-0 bg-text-accent mix-blend-color" />
<div
className={cn(
styles.recommendSlide,
'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body',
)}
>
<TrendingCopy banner={banner} isMarketplacePlatform={isMarketplacePlatform} />
<div
className={cn(
styles.recommendVisual,
'relative h-[200px] shrink-0 overflow-hidden rounded-xl bg-background-body',
)}
>
<img
src={background.src}
width={3840}
height={2160}
alt=""
aria-hidden
className="absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80"
/>
<div aria-hidden className="absolute inset-0 bg-text-accent mix-blend-color" />
<div className="relative z-10 flex h-full items-center justify-between px-9 py-[42px]">
{banner.content.cards.map(card => (
<TrendingCard
key={`${card.item_type}:${card.item_id}`}
card={card}
isMarketplacePlatform={isMarketplacePlatform}
/>
))}
<div className={cn(styles.recommendCards, 'relative z-10 h-full items-center')}>
{banner.content.cards.map((card) => (
<TrendingCard
key={`${card.item_type}:${card.item_id}`}
card={card}
isMarketplacePlatform={isMarketplacePlatform}
/>
))}
</div>
</div>
</div>
)
}
type HomeTrendingProps = {
banners: BannerRecommend[]
isMarketplacePlatform: boolean
function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
const opensInNewTab = /^https?:\/\//.test(banner.content.link)
return (
<div className="flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body">
<div className="flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5">
<div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2">
<p className="shrink-0 rounded-sm bg-state-success-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-success">
{banner.title}
</p>
<div className="flex min-h-0 w-full max-w-[800px] flex-1 flex-col items-start gap-3">
<h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary">
{banner.content.blog_title}
</h2>
<div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2">
{banner.content.subtitle && (
<p className="shrink-0 text-[15px] leading-[18px] font-normal tracking-[-0.3px] text-text-primary">
{banner.content.subtitle}
</p>
)}
{banner.content.description && (
<p className="min-h-0 w-full flex-1 overflow-hidden text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary">
<span className={styles.updatesDescription}>{banner.content.description}</span>
</p>
)}
<Link
href={banner.content.link}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={`Read more about ${banner.content.blog_title}`}
className="flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2"
>
<span>Read more</span>
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
</Link>
</div>
</div>
</div>
</div>
<img
src={difyUpdatesArt.src}
width={400}
height={200}
alt=""
aria-hidden
className={cn(styles.updatesArt, 'h-[200px] shrink-0 object-cover')}
/>
</div>
)
}
const HomeTrending = ({
function ImageBannerSlide({ banner }: { banner: BannerEvent | BannerAd }) {
const desktopImage = getMarketplaceAssetURL(banner.content.images.desktop)
const tabletImage = getMarketplaceAssetURL(banner.content.images.tablet)
const mobileImage = getMarketplaceAssetURL(banner.content.images.mobile)
return (
<Link
href={banner.content.link}
target="_blank"
rel="noopener noreferrer"
aria-label={banner.content.alt_text || banner.title}
className="block h-[200px] w-full overflow-hidden rounded-2xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<picture className="block size-full">
{mobileImage && <source media="(max-width: 639px)" srcSet={mobileImage} />}
{tabletImage && <source media="(max-width: 1023px)" srcSet={tabletImage} />}
<img
src={desktopImage}
width={1200}
height={200}
alt=""
aria-hidden
className="size-full object-cover"
/>
</picture>
</Link>
)
}
function HomeBannerSlide({
banner,
isMarketplacePlatform,
}: {
banner: PluginBanner
isMarketplacePlatform: boolean
}) {
if (banner.style_type === 'blog') return <BlogBannerSlide banner={banner} />
if (banner.style_type === 'event' || banner.style_type === 'ad')
return <ImageBannerSlide banner={banner} />
return (
<TrendingRecommendationSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} />
)
}
function TrendingNavigation({
banners,
selectedIndex,
carouselRootRef,
onSelect,
onNext,
}: {
banners: PluginBanner[]
selectedIndex: number
carouselRootRef: RefObject<HTMLDivElement | null>
onSelect: (index: number) => void
onNext: () => void
}) {
const { t } = useTranslation('plugin')
const progressRef = useRef<HTMLSpanElement>(null)
const progressAnimationRef = useRef<Animation | null>(null)
const pauseReasonsRef = useRef(new Set<AutoplayPauseReason>())
const [isUserPaused, setIsUserPaused] = useState(false)
const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false)
const isExplicitlyPaused = isUserPaused || isReducedMotionPaused
const paginationWidth =
PAGINATION_ACTIVE_WIDTH + Math.max(0, banners.length - 1) * PAGINATION_STEP
const setPauseReason = useCallback((reason: AutoplayPauseReason, shouldPause: boolean) => {
if (shouldPause) pauseReasonsRef.current.add(reason)
else pauseReasonsRef.current.delete(reason)
const progressAnimation = progressAnimationRef.current
if (!progressAnimation) return
if (pauseReasonsRef.current.size > 0) progressAnimation.pause()
else progressAnimation.play()
}, [])
useEffect(() => {
const progressElement = progressRef.current
if (!progressElement?.animate) return
const progressAnimation = progressElement.animate(
[{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }],
{
duration: AUTOPLAY_DELAY,
easing: 'linear',
fill: 'forwards',
},
)
progressAnimationRef.current = progressAnimation
if (pauseReasonsRef.current.size > 0) progressAnimation.pause()
progressAnimation.onfinish = onNext
return () => {
progressAnimation.onfinish = null
progressAnimation.cancel()
if (progressAnimationRef.current === progressAnimation) progressAnimationRef.current = null
}
}, [onNext, selectedIndex])
useEffect(() => {
const carouselRoot = carouselRootRef.current
if (!carouselRoot) return
const handleMouseEnter = () => setPauseReason('hover', true)
const handleMouseLeave = () => setPauseReason('hover', false)
const handleFocusIn = () => setPauseReason('focus', true)
const handleFocusOut = (event: FocusEvent) => {
if (carouselRoot.contains(event.relatedTarget as Node | null)) return
setPauseReason('focus', false)
}
const handleVisibilityChange = () =>
setPauseReason('visibility', document.visibilityState === 'hidden')
carouselRoot.addEventListener('mouseenter', handleMouseEnter)
carouselRoot.addEventListener('mouseleave', handleMouseLeave)
carouselRoot.addEventListener('focusin', handleFocusIn)
carouselRoot.addEventListener('focusout', handleFocusOut)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
carouselRoot.removeEventListener('mouseleave', handleMouseLeave)
carouselRoot.removeEventListener('focusin', handleFocusIn)
carouselRoot.removeEventListener('focusout', handleFocusOut)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [carouselRootRef, setPauseReason])
useEffect(() => {
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const syncReducedMotion = () => {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- This state mirrors an external media query.
setIsReducedMotionPaused(reducedMotionQuery.matches)
setPauseReason('reduced-motion', reducedMotionQuery.matches)
}
syncReducedMotion()
reducedMotionQuery.addEventListener('change', syncReducedMotion)
return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion)
}, [setPauseReason])
const toggleAutoplay = () => {
if (isExplicitlyPaused) {
setIsUserPaused(false)
setIsReducedMotionPaused(false)
setPauseReason('user', false)
setPauseReason('reduced-motion', false)
return
}
setIsUserPaused(true)
setPauseReason('user', true)
}
return (
<div
role="group"
aria-label={t(($) => $['marketplace.home.trendingPaginationLabel'])}
className={cn(
styles.navigation,
'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2',
)}
>
<div className="relative h-1.5 shrink-0" style={{ width: paginationWidth }}>
<span
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-1 flex h-1.5 w-10 items-center overflow-hidden rounded-full bg-state-base-handle transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none"
style={{
transform: `translate3d(${selectedIndex * PAGINATION_STEP}px, 0, 0)`,
}}
>
<span
key={selectedIndex}
ref={progressRef}
data-carousel-progress
className="h-full w-full rounded-full bg-text-accent"
style={{ transform: 'scaleX(0)', transformOrigin: 'left center' }}
/>
</span>
{banners.map((banner, index) => {
const isCurrent = index === selectedIndex
return (
<button
key={banner.id}
type="button"
aria-label={banner.title}
aria-current={isCurrent ? 'true' : undefined}
onClick={() => {
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',
)}
style={{
width: isCurrent ? PAGINATION_ACTIVE_WIDTH : PAGINATION_DOT_SIZE,
transform: `translate3d(${getPaginationItemOffset(index, selectedIndex)}px, 0, 0)`,
}}
/>
)
})}
</div>
<div className="min-w-0 flex-1" />
<button
type="button"
aria-label={t(
($) =>
$[
isExplicitlyPaused
? 'marketplace.home.trendingPlay'
: 'marketplace.home.trendingPause'
],
)}
onClick={toggleAutoplay}
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 ? (
<span aria-hidden className="i-ri-play-large-fill size-2 opacity-30" />
) : (
<span aria-hidden className="i-ri-pause-large-fill size-2 opacity-30" />
)}
</button>
</div>
)
}
function HomeTrending({
banners,
isMarketplacePlatform,
}: HomeTrendingProps) => {
}: {
banners: PluginBanner[]
isMarketplacePlatform: boolean
}) {
const { t } = useTranslation('plugin')
const [carouselPlugins] = useState(() => [
Carousel.Plugin.Autoplay({
delay: AUTOPLAY_DELAY,
stopOnFocusIn: true,
stopOnInteraction: false,
stopOnMouseEnter: true,
breakpoints: {
'(prefers-reduced-motion: reduce)': { active: false },
},
}),
])
const carouselRootRef = useRef<HTMLDivElement>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const selectSlide = useCallback((index: number) => setSelectedIndex(index), [])
const selectNextSlide = useCallback(
() => setSelectedIndex((currentIndex) => (currentIndex + 1) % banners.length),
[banners.length],
)
if (banners.length === 0)
return null
if (banners.length === 0) return null
return (
<section
aria-labelledby="home-trending-title"
aria-label={t(($) => $['marketplace.home.trendingTitle'])}
className={cn(
'shrink-0 bg-background-default pb-6',
isMarketplacePlatform
? 'px-4 min-[1232px]:px-0'
: 'px-4 md:px-9',
isMarketplacePlatform ? 'px-4 min-[1232px]:px-0' : 'px-4 md:px-9',
)}
>
<div
className={cn(
'mx-auto w-full overflow-hidden rounded-xl bg-background-body',
styles.wrapper,
'mx-auto w-full',
isMarketplacePlatform ? 'max-w-[1200px]' : 'max-w-[1188px]',
)}
>
<Carousel
opts={{ loop: true }}
plugins={carouselPlugins}
overlay={(
<TrendingCopy
banners={banners}
isMarketplacePlatform={isMarketplacePlatform}
/>
)}
<div
role="region"
aria-roledescription="carousel"
aria-label={t(($) => $['marketplace.home.trendingTitle'])}
className={cn(
'ml-auto w-full rounded-xl',
isMarketplacePlatform
? 'min-[1232px]:w-[757px]'
: 'min-[1260px]:w-[757px]',
)}
className="relative h-[200px] w-full rounded-2xl"
>
<Carousel.Content aria-live="polite" className="rounded-xl">
{banners.map(banner => (
<Carousel.Item key={banner.id}>
<TrendingSlide
banner={banner}
isMarketplacePlatform={isMarketplacePlatform}
/>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
<TrendingNavigation
banners={banners}
selectedIndex={selectedIndex}
carouselRootRef={carouselRootRef}
onSelect={selectSlide}
onNext={selectNextSlide}
/>
<div ref={carouselRootRef} className="h-full overflow-hidden rounded-2xl">
<div
aria-live="polite"
className={cn(styles.contentTrack, 'flex h-full')}
style={{ transform: `translate3d(-${selectedIndex * 100}%, 0, 0)` }}
>
{banners.map((banner, index) => {
const isActive = index === selectedIndex
return (
<div
key={banner.id}
role="group"
aria-roledescription="slide"
aria-label={banner.title}
aria-hidden={!isActive}
inert={!isActive}
className="h-full min-w-0 shrink-0 grow-0 basis-full"
>
<HomeBannerSlide
banner={banner}
isMarketplacePlatform={isMarketplacePlatform}
/>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
)

View File

@ -1,5 +1,4 @@
import type { BannerRecommend } from './banners'
import { cn } from '@langgenius/dify-ui/cn'
import type { PluginBanner } from './banners'
import ListWrapper from '../list/list-wrapper'
import HomeCatalogNavigation from './home-catalog-navigation'
import HomeCatalogTabs from './home-catalog-tabs'
@ -11,7 +10,7 @@ import HomeTrending from './home-trending'
type MarketplaceHomeProps = {
actions?: React.ReactNode
banners: BannerRecommend[]
banners: PluginBanner[]
brandName?: React.ReactNode
isMarketplacePlatform: boolean
linkToMarketplaceDetail: boolean
@ -37,11 +36,12 @@ const MarketplaceHome = ({
<div className="relative flex w-full flex-col">
<HomeHero isMarketplacePlatform={isMarketplacePlatform} />
<HomeSearch />
<div
aria-hidden="true"
className={cn('shrink-0', isMarketplacePlatform ? 'h-6' : 'h-12')}
/>
<HomeTrending banners={banners} isMarketplacePlatform={isMarketplacePlatform} />
{banners.length > 0 && (
<>
<div aria-hidden="true" className="h-12 shrink-0" />
<HomeTrending banners={banners} isMarketplacePlatform={isMarketplacePlatform} />
</>
)}
<HomeCatalogNavigation
catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />}
/>

View File

@ -1,11 +1,11 @@
import type { SearchParams } from 'nuqs'
import type { BannerRecommend } from './home/banners'
import type { PluginBanner } from './home/banners'
import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
import { TanStackQueryProvider } from '@/app/query-provider'
import { getLocaleOnServer } from '@/i18n-config/server'
import Description from './description'
import MarketplaceHome from './home'
import { fetchPluginRecommendBanners } from './home/banners'
import { fetchPluginBanners } from './home/banners'
import { HydrateQueryClient } from './hydration-server'
import ListWrapper from './list/list-wrapper'
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
@ -40,15 +40,14 @@ const Marketplace = async ({
homeHeaderBrandName,
searchParams,
}: MarketplaceProps) => {
let trendingBanners: BannerRecommend[] = []
let trendingBanners: PluginBanner[] = []
if (variant === 'home') {
const locale = language ?? await getLocaleOnServer()
const locale = language ?? (await getLocaleOnServer())
try {
trendingBanners = await fetchPluginRecommendBanners(locale)
}
catch {
trendingBanners = await fetchPluginBanners(locale)
} catch {
// Keep the homepage available if Marketplace banner delivery is unavailable.
}
}
@ -57,32 +56,32 @@ const Marketplace = async ({
<TanStackQueryProvider>
<HydrateQueryClient searchParams={searchParams}>
<PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}>
{variant === 'home'
? (
<MarketplaceHome
actions={homeHeaderActions}
banners={trendingBanners}
brandName={homeHeaderBrandName}
isMarketplacePlatform={isMarketplacePlatform}
linkToMarketplaceDetail={linkToMarketplaceDetail}
showInstallButton={showInstallButton}
{variant === 'home' ? (
<MarketplaceHome
actions={homeHeaderActions}
banners={trendingBanners}
brandName={homeHeaderBrandName}
isMarketplacePlatform={isMarketplacePlatform}
linkToMarketplaceDetail={linkToMarketplaceDetail}
showInstallButton={showInstallButton}
/>
) : (
<>
<Description
isMarketplacePlatform={isMarketplacePlatform}
marketplaceNav={marketplaceNav}
/>
{!isMarketplacePlatform && (
<StickySearchAndSwitchWrapper
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
/>
)
: (
<>
<Description
isMarketplacePlatform={isMarketplacePlatform}
marketplaceNav={marketplaceNav}
/>
{!isMarketplacePlatform && (
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} />
)}
<ListWrapper
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>
</>
)}
<ListWrapper
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>
</>
)}
</PluginInstallPermissionProviderGuard>
</HydrateQueryClient>
</TanStackQueryProvider>

View File

@ -33,6 +33,8 @@
/* ---------- JS plugins ------------------------------------------------ */
@plugin './plugins/icons.ts';
@plugin './plugins/typography.ts';
@source inline('i-ri-pause-large-fill');
@source inline('i-ri-play-large-fill');
/* ---------- Project-only theme tokens --------------------------------- */
@theme {

View File

@ -235,8 +235,11 @@
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPause": "Pause",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPlay": "Play",
"marketplace.home.trendingTitle": "The plugins everyone is installing",
"marketplace.home.trendingView": "View",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.noPluginFound": "No integration found",
"marketplace.partnerTip": "Verified by a Dify partner",

View File

@ -235,8 +235,11 @@
"marketplace.home.trendingByCreator": "由 {{creator}} 发布",
"marketplace.home.trendingDescription": "基于真实使用情况选出的热门插件,每两周更新一次。榜单按各工作区的实际运行次数排序,不含付费推广或编辑推荐。",
"marketplace.home.trendingEyebrow": "当前热门",
"marketplace.home.trendingPause": "暂停",
"marketplace.home.trendingPaginationLabel": "热门推荐页码",
"marketplace.home.trendingPlay": "播放",
"marketplace.home.trendingTitle": "大家都在安装的插件",
"marketplace.home.trendingView": "查看",
"marketplace.moreFrom": "来自 Marketplace 的更多内容",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",

View File

@ -235,8 +235,11 @@
"marketplace.home.trendingByCreator": "由 {{creator}} 發布",
"marketplace.home.trendingDescription": "根據真實使用情況選出的熱門外掛程式,每兩週更新一次。榜單按各工作區的實際執行次數排序,不含付費推廣或編輯推薦。",
"marketplace.home.trendingEyebrow": "目前熱門",
"marketplace.home.trendingPause": "暫停",
"marketplace.home.trendingPaginationLabel": "熱門推薦頁碼",
"marketplace.home.trendingPlay": "播放",
"marketplace.home.trendingTitle": "大家都在安裝的外掛程式",
"marketplace.home.trendingView": "查看",
"marketplace.moreFrom": "來自 Marketplace 的更多內容",
"marketplace.noPluginFound": "未找到集成",
"marketplace.partnerTip": "由 Dify 合作夥伴驗證",