diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx new file mode 100644 index 00000000000..9b1cbc34254 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -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: () => , +})) + +vi.mock('@/app/components/plugins/base/badges/verified', () => ({ + default: () => , +})) + +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() + + 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() + + 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() + + 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() + + expect( + screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPlay', + }), + ).toBeInTheDocument() + + matchMedia.mockRestore() + }) + + it('renders no carousel when the API returns no banners', () => { + render() + + expect( + screen.queryByRole('region', { + name: 'plugin.marketplace.home.trendingTitle', + }), + ).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png b/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png new file mode 100644 index 00000000000..12d192cef36 Binary files /dev/null and b/web/app/components/plugins/marketplace/home/assets/dify-updates-art.png differ diff --git a/web/app/components/plugins/marketplace/home/banners.spec.ts b/web/app/components/plugins/marketplace/home/banners.spec.ts index dbc9f62d1ae..65dfc1f4581 100644 --- a/web/app/components/plugins/marketplace/home/banners.spec.ts +++ b/web/app/components/plugins/marketplace/home/banners.spec.ts @@ -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([]) }) }) diff --git a/web/app/components/plugins/marketplace/home/banners.ts b/web/app/components/plugins/marketplace/home/banners.ts index a6c4ff15c38..8f909e40fe3 100644 --- a/web/app/components/plugins/marketplace/home/banners.ts +++ b/web/app/components/plugins/marketplace/home/banners.ts @@ -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 => { return typeof value === 'object' && value !== null && !Array.isArray(value) } -const parseRecommendCard = (value: unknown): BannerRecommendCard | null => { - if (!isRecord(value)) +const parseBannerBase = (value: Record): 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, +): 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): 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, +): 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 => { +export const fetchPluginBanners = async (language: string): Promise => { const response = await marketplaceClient.banners.list({ query: { page: 'plugins', @@ -123,5 +278,5 @@ export const fetchPluginRecommendBanners = async (language: string): Promise 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 deleted file mode 100644 index e459f77d1f7..00000000000 --- a/web/app/components/plugins/marketplace/home/home-trending-indicator.module.css +++ /dev/null @@ -1,33 +0,0 @@ -@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.module.css b/web/app/components/plugins/marketplace/home/home-trending.module.css new file mode 100644 index 00000000000..417a903e70e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.module.css @@ -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; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx index 9a231d59068..ab7d6509b49 100644 --- a/web/app/components/plugins/marketplace/home/home-trending.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -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 ( - - ) -} +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) => { - 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 (
-
-

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

+

+ {banner.title}

-

+ {heading}

-

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

+ {description}

- -
$['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 + 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 (
- {!iconURL && card.icon - ? {card.icon} - : null} - {!iconURL && !card.icon - ?
-

- {card.display_name} -

- {creator - ? ( -

+

+
+
+

+ {card.display_name} +

+ {(isPartner || isVerified) && ( +
+ {isPartner && ( + $['marketplace.partnerTip'])} /> + )} + {isVerified && ( + $['marketplace.verifiedTip'])} /> + )} +
+ )} +
+ {creator && ( +

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

- ) - : null} - -
+ + {t(($) => $['marketplace.home.trendingView'])} + +
) } -type TrendingSlideProps = { - banner: BannerRecommend - isMarketplacePlatform: boolean -} - -const TrendingSlide = ({ +function TrendingRecommendationSlide({ banner, isMarketplacePlatform, -}: TrendingSlideProps) => { +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean +}) { return ( -
- -
+
+ +
+ +
-
- {banner.content.cards.map(card => ( - - ))} +
+ {banner.content.cards.map((card) => ( + + ))} +
) } -type HomeTrendingProps = { - banners: BannerRecommend[] - isMarketplacePlatform: boolean +function BlogBannerSlide({ banner }: { banner: BannerBlog }) { + const opensInNewTab = /^https?:\/\//.test(banner.content.link) + + return ( +
+
+
+

+ {banner.title} +

+
+

+ {banner.content.blog_title} +

+
+ {banner.content.subtitle && ( +

+ {banner.content.subtitle} +

+ )} + {banner.content.description && ( +

+ {banner.content.description} +

+ )} + + Read more + + +
+
+
+
+ +
+ ) } -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 ( + + + {mobileImage && } + {tabletImage && } + + + + ) +} + +function HomeBannerSlide({ + banner, + isMarketplacePlatform, +}: { + banner: PluginBanner + isMarketplacePlatform: boolean +}) { + if (banner.style_type === 'blog') return + + if (banner.style_type === 'event' || banner.style_type === 'ad') + return + + return ( + + ) +} + +function TrendingNavigation({ + banners, + selectedIndex, + carouselRootRef, + onSelect, + onNext, +}: { + banners: PluginBanner[] + selectedIndex: number + carouselRootRef: RefObject + onSelect: (index: number) => void + onNext: () => void +}) { + const { t } = useTranslation('plugin') + const progressRef = useRef(null) + const progressAnimationRef = useRef(null) + const pauseReasonsRef = useRef(new Set()) + 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 ( +
$['marketplace.home.trendingPaginationLabel'])} + className={cn( + styles.navigation, + 'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2', + )} + > +
+ + + + {banners.map((banner, index) => { + const isCurrent = index === selectedIndex + + return ( +
+
+ +
+ ) +} + +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(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 (
$['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', )} >
- - )} +
$['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" > - - {banners.map(banner => ( - - - - ))} - - + +
+
+ {banners.map((banner, index) => { + const isActive = index === selectedIndex + + return ( +
+ +
+ ) + })} +
+
+
) diff --git a/web/app/components/plugins/marketplace/home/index.tsx b/web/app/components/plugins/marketplace/home/index.tsx index 26d7b562848..5a35d95d327 100644 --- a/web/app/components/plugins/marketplace/home/index.tsx +++ b/web/app/components/plugins/marketplace/home/index.tsx @@ -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 = ({
-