From cb6c04637b64b341642c6279bde565c8865fb219 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 4 Sep 2026 08:09:45 +0000 Subject: [PATCH] feat(web): add marketplace creator profiles and homepage redesign (#41538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: zxhlyh Co-authored-by: fatelei Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: CodingOnStar Co-authored-by: Cursor Co-authored-by: 姜涵煦 Co-authored-by: L1nSn0w Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- knip.config.ts | 4 + oxlint-suppressions.json | 14 +- packages/contracts/marketplace.ts | 339 ++++++- packages/dify-ui/src/dialog/index.stories.tsx | 4 +- .../assets/public/common/gmail.svg | 8 + .../custom-public/icons.json | 22 +- .../custom-public/info.json | 2 +- .../custom-vender/icons.json | 36 +- .../custom-vender/info.json | 2 +- web/__tests__/proxy-frame-options.spec.ts | 32 + .../marketplace/__tests__/layout.spec.tsx | 33 + .../marketplace/__tests__/page.spec.tsx | 39 + .../creator/[uniqueHandle]/page.tsx | 51 + .../marketplace/document-title.tsx | 12 + web/app/(commonLayout)/marketplace/layout.tsx | 16 +- web/app/(commonLayout)/marketplace/page.tsx | 17 +- .../[[...category]]/__tests__/page.spec.tsx | 151 +++ .../templates/[[...category]]/page.tsx | 78 ++ .../oauth/authorize/__tests__/page.spec.tsx | 102 ++ web/app/account/oauth/authorize/page.tsx | 80 +- .../base/icons/src/vender/plugin/Trigger.json | 73 -- .../base/icons/src/vender/plugin/Trigger.tsx | 18 - .../base/icons/src/vender/plugin/index.ts | 1 - .../use-marketplace-all-plugins.spec.ts | 4 +- .../model-provider-page/hooks.ts | 3 + .../main-nav/__tests__/index.spec.tsx | 37 +- .../main-nav/__tests__/layout.spec.tsx | 91 +- .../__tests__/support-menu.spec.tsx | 24 +- .../main-nav/components/help-menu.tsx | 15 + .../main-nav/components/support-menu.tsx | 2 +- web/app/components/main-nav/routes.ts | 6 +- .../plugins/base/badges/partner.tsx | 2 + .../plugins/card/__tests__/index.spec.tsx | 108 +++ web/app/components/plugins/card/index.tsx | 56 +- .../marketplace/__tests__/atoms.spec.tsx | 20 + .../__tests__/creator-center-url.spec.ts | 44 + .../marketplace/__tests__/embedded.spec.tsx | 154 +++ .../marketplace/__tests__/hooks.spec.tsx | 78 ++ .../__tests__/hydration-server.spec.tsx | 73 +- .../__tests__/plugin-type-switch.spec.tsx | 33 +- .../marketplace/__tests__/query.spec.tsx | 11 +- .../__tests__/search-params.spec.ts | 5 + .../__tests__/server-entry.spec.tsx | 95 ++ .../marketplace/__tests__/state.spec.tsx | 82 +- .../marketplace/__tests__/utils.spec.ts | 219 ++++- .../components/plugins/marketplace/atoms.ts | 51 +- .../plugins/marketplace/constants.ts | 6 + .../plugins/marketplace/creator-center-url.ts | 48 + .../__tests__/creation-card.spec.tsx | 50 + .../__tests__/creator-content.spec.tsx | 163 ++++ .../__tests__/creator-sidebar.spec.tsx | 83 ++ .../__tests__/data.server.spec.ts | 276 ++++++ .../__tests__/dify-profile.spec.tsx | 213 ++++ .../creator-profile/__tests__/header.spec.tsx | 44 + .../creator-profile/__tests__/model.spec.ts | 224 +++++ .../__tests__/view-layout.browser.spec.tsx | 64 ++ .../creator-profile/__tests__/view.spec.tsx | 88 ++ .../assets/default-background.png | Bin 0 -> 3217 bytes .../creator-profile/creation-card.tsx | 94 ++ .../creator-profile/creator-content.tsx | 261 +++++ .../creator-profile/creator-sidebar.tsx | 114 +++ .../creator-profile/data.server.ts | 140 +++ .../creator-profile/dify-profile.tsx | 137 +++ .../marketplace/creator-profile/header.tsx | 82 ++ .../marketplace/creator-profile/model.ts | 361 +++++++ .../creator-profile/publisher-avatar.tsx | 75 ++ .../marketplace/creator-profile/publisher.ts | 102 ++ .../marketplace/creator-profile/view.tsx | 110 +++ .../plugins/marketplace/description/index.tsx | 3 +- .../detail-dialog/__tests__/index.spec.tsx | 251 +++++ .../__tests__/use-silent-install.spec.ts | 110 +++ .../marketplace/detail-dialog/frame.tsx | 137 +++ .../marketplace/detail-dialog/index.tsx | 161 ++++ .../detail-dialog/use-silent-install.ts | 64 ++ .../plugins/marketplace/embedded.tsx | 46 + .../plugins/marketplace/filter-track-link.tsx | 40 + .../plugins/marketplace/home/README.md | 12 + .../catalog-languages-filter.spec.tsx | 32 + .../__tests__/catalog-tags-filter.spec.tsx | 47 + .../embedded-marketplace-search.spec.tsx | 243 +++++ .../__tests__/event-ad-banner-image.spec.ts | 61 ++ .../home-catalog-alignment.browser.spec.tsx | 41 + .../home-catalog-handoff.browser.spec.tsx | 241 +++++ .../home-catalog-navigation.spec.tsx | 320 ++++++ .../home/__tests__/home-guide.spec.tsx | 129 +++ .../home/__tests__/home-header.spec.tsx | 155 +++ .../home/__tests__/home-hero.spec.tsx | 89 ++ ...home-search-mobile-layout.browser.spec.tsx | 211 ++++ .../home-trending-layout.browser.spec.tsx | 268 ++++++ .../home-trending-swipe.browser.spec.tsx | 207 ++++ .../home/__tests__/home-trending.spec.tsx | 911 ++++++++++++++++++ .../home/__tests__/marketplace-href.spec.ts | 21 + .../marketplace-live-search.spec.tsx | 83 ++ ...place-search-autocomplete.browser.spec.tsx | 306 ++++++ .../marketplace-search-autocomplete.spec.tsx | 710 ++++++++++++++ ...erve-sticky-search-scroll.browser.spec.tsx | 116 +++ .../__tests__/use-banner-viewability.spec.ts | 137 +++ .../marketplace/home/assets/background.webp | Bin 0 -> 97014 bytes .../marketplace/home/assets/brain-2-fill.svg | 5 + .../home/assets/dify-updates-art.png | Bin 0 -> 21416 bytes .../home/assets/image-circle-ai-line.svg | 5 + .../marketplace/home/assets/plug-fill.svg | 5 + .../marketplace/home/assets/puzzle-fill.svg | 5 + .../assets/recommend-mobile-backdrop.webp | Bin 0 -> 292856 bytes .../home/assets/sparkling-fill.svg | 5 + .../marketplace/home/assets/voice-ai-fill.svg | 5 + .../plugins/marketplace/home/banners.spec.ts | 218 +++++ .../plugins/marketplace/home/banners.ts | 154 +++ .../home/catalog-languages-filter.tsx | 177 ++++ .../marketplace/home/catalog-tags-filter.tsx | 15 + .../home/embedded-marketplace-search.tsx | 101 ++ .../marketplace/home/event-ad-banner-image.ts | 22 + .../marketplace/home/home-catalog-focus.ts | 21 + .../home/home-catalog-navigation.tsx | 135 +++ .../marketplace/home/home-catalog-tabs.tsx | 77 ++ .../marketplace/home/home-constants.ts | 15 + .../marketplace/home/home-creator-center.tsx | 38 + .../plugins/marketplace/home/home-guide.tsx | 25 + .../plugins/marketplace/home/home-header.tsx | 93 ++ .../marketplace/home/home-hero.module.css | 74 ++ .../plugins/marketplace/home/home-hero.tsx | 100 ++ .../plugins/marketplace/home/home-search.tsx | 73 ++ .../plugins/marketplace/home/home-shell.tsx | 77 ++ .../home/home-sticky-state-provider.tsx | 31 + .../marketplace/home/home-sticky-state.ts | 5 + .../marketplace/home/home-sticky.module.css | 171 ++++ .../home/home-trending-navigation.tsx | 283 ++++++ .../marketplace/home/home-trending-slides.tsx | 504 ++++++++++ .../home/home-trending-track.spec.ts | 139 +++ .../marketplace/home/home-trending-track.ts | 62 ++ .../marketplace/home/home-trending.module.css | 315 ++++++ .../marketplace/home/home-trending.tsx | 403 ++++++++ .../plugins/marketplace/home/index.tsx | 82 ++ .../marketplace/home/marketplace-href.ts | 15 + .../home/marketplace-live-search.tsx | 84 ++ .../home/marketplace-search-autocomplete.tsx | 432 +++++++++ .../home/preserve-sticky-search-scroll.ts | 147 +++ .../home/use-banner-viewability.ts | 58 ++ .../components/plugins/marketplace/hooks.ts | 58 +- .../plugins/marketplace/hydration-server.tsx | 42 +- .../components/plugins/marketplace/index.tsx | 87 +- .../list/__tests__/card-wrapper.spec.tsx | 98 +- .../list/__tests__/carousel.spec.tsx | 371 +++++++ ...st-with-collection-layout.browser.spec.tsx | 204 ++++ .../__tests__/list-with-collection.spec.tsx | 173 +++- .../list-wrapper-scroll.browser.spec.tsx | 101 ++ .../list/__tests__/list-wrapper.spec.tsx | 87 +- .../plugins/marketplace/list/card-wrapper.tsx | 163 ++-- .../marketplace/list/carousel.module.css | 5 + .../plugins/marketplace/list/carousel.tsx | 293 +++++- .../marketplace/list/collection-constants.ts | 10 + .../plugins/marketplace/list/index.tsx | 6 + .../marketplace/list/list-with-collection.tsx | 393 +++++--- .../plugins/marketplace/list/list-wrapper.tsx | 94 +- .../list/partner-header.module.css | 52 + .../list/use-carousel-items-per-page.ts | 37 + .../marketplace/plugin-type-switch.module.css | 25 + .../marketplace/plugin-type-switch.tsx | 74 +- .../prefetch-marketplace-dehydrated-state.ts | 50 + .../plugins/marketplace/query-options.ts | 35 + .../components/plugins/marketplace/query.ts | 26 +- .../__tests__/search-box-wrapper.spec.tsx | 3 +- .../plugins/marketplace/search-box/index.tsx | 4 +- .../search-box/search-box-wrapper.tsx | 3 + .../marketplace/search-box/tags-filter.tsx | 18 +- .../plugins/marketplace/search-params.ts | 28 +- .../plugins/marketplace/server-budget.ts | 36 + .../standalone/__tests__/exports.spec.ts | 28 + .../plugins/marketplace/standalone/client.ts | 17 + .../plugins/marketplace/standalone/server.ts | 36 + .../components/plugins/marketplace/state.ts | 62 +- .../__tests__/template-card.spec.tsx | 91 ++ ...te-collection-list-layout.browser.spec.tsx | 137 +++ .../template-collection-list.spec.tsx | 98 ++ .../__tests__/template-language.spec.ts | 117 +++ .../__tests__/template-links.spec.ts | 10 + .../marketplace/templates/categories.ts | 17 + .../plugins/marketplace/templates/index.tsx | 299 ++++++ .../marketplace/templates/template-card.tsx | 111 +++ .../template-category-navigation.tsx | 56 ++ .../templates/template-collection-list.tsx | 176 ++++ .../templates/template-detail-dialog.tsx | 63 ++ .../templates/template-language.ts | 67 ++ .../marketplace/templates/template-links.ts | 37 + .../templates/template-pagination.tsx | 70 ++ .../components/plugins/marketplace/utils.ts | 230 +++-- .../components/plugins/marketplace/view.tsx | 75 ++ .../plugins/plugin-page/nav-operations.tsx | 53 +- .../tools/marketplace/__tests__/hooks.spec.ts | 8 +- web/app/components/tools/marketplace/hooks.ts | 8 +- .../__tests__/post-login-redirect.spec.ts | 23 + web/app/signin/utils/post-login-redirect.ts | 8 +- web/config/index.ts | 15 +- web/global.d.ts | 17 + web/i18n/ar-TN/common.json | 2 + web/i18n/ar-TN/plugin.json | 41 + web/i18n/de-DE/common.json | 2 + web/i18n/de-DE/plugin.json | 41 + web/i18n/en-US/common.json | 2 + web/i18n/en-US/plugin.json | 41 + web/i18n/es-ES/common.json | 2 + web/i18n/es-ES/plugin.json | 41 + web/i18n/fa-IR/common.json | 2 + web/i18n/fa-IR/plugin.json | 41 + web/i18n/fr-FR/common.json | 2 + web/i18n/fr-FR/plugin.json | 41 + web/i18n/hi-IN/common.json | 2 + web/i18n/hi-IN/plugin.json | 41 + web/i18n/id-ID/common.json | 2 + web/i18n/id-ID/plugin.json | 41 + web/i18n/it-IT/common.json | 2 + web/i18n/it-IT/plugin.json | 41 + web/i18n/ja-JP/common.json | 2 + web/i18n/ja-JP/plugin.json | 41 + web/i18n/ko-KR/common.json | 2 + web/i18n/ko-KR/plugin.json | 41 + web/i18n/lo-LA/common.json | 2 + web/i18n/lo-LA/plugin.json | 41 + web/i18n/nl-NL/common.json | 2 + web/i18n/nl-NL/plugin.json | 41 + web/i18n/pl-PL/common.json | 2 + web/i18n/pl-PL/plugin.json | 41 + web/i18n/pt-BR/common.json | 2 + web/i18n/pt-BR/plugin.json | 41 + web/i18n/ro-RO/common.json | 2 + web/i18n/ro-RO/plugin.json | 41 + web/i18n/ru-RU/common.json | 2 + web/i18n/ru-RU/plugin.json | 41 + web/i18n/sl-SI/common.json | 2 + web/i18n/sl-SI/plugin.json | 41 + web/i18n/th-TH/common.json | 2 + web/i18n/th-TH/plugin.json | 41 + web/i18n/tr-TR/common.json | 2 + web/i18n/tr-TR/plugin.json | 41 + web/i18n/uk-UA/common.json | 2 + web/i18n/uk-UA/plugin.json | 41 + web/i18n/vi-VN/common.json | 2 + web/i18n/vi-VN/plugin.json | 41 + web/i18n/zh-Hans/common.json | 2 + web/i18n/zh-Hans/plugin.json | 41 + web/i18n/zh-Hant/common.json | 2 + web/i18n/zh-Hant/plugin.json | 59 +- web/proxy.ts | 33 +- .../dify-marketplace-logo-dark.svg | 19 + .../marketplace/dify-marketplace-logo.svg | 19 + web/service/client.ts | 33 +- .../marketplace-template-discovery.spec.ts | 213 ++++ web/service/marketplace-template-discovery.ts | 196 ++++ web/types/assets.d.ts | 5 + .../__tests__/marketplace-site-track.spec.ts | 68 ++ web/utils/marketplace-site-track.ts | 87 ++ web/utils/var.spec.ts | 12 + web/utils/var.ts | 2 +- 253 files changed, 19799 insertions(+), 853 deletions(-) create mode 100644 packages/iconify-collections/assets/public/common/gmail.svg create mode 100644 web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx create mode 100644 web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx create mode 100644 web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx create mode 100644 web/app/(commonLayout)/marketplace/document-title.tsx create mode 100644 web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx create mode 100644 web/app/(commonLayout)/templates/[[...category]]/page.tsx delete mode 100644 web/app/components/base/icons/src/vender/plugin/Trigger.json delete mode 100644 web/app/components/base/icons/src/vender/plugin/Trigger.tsx create mode 100644 web/app/components/plugins/card/__tests__/index.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts create mode 100644 web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-center-url.ts create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/assets/default-background.png create mode 100644 web/app/components/plugins/marketplace/creator-profile/creation-card.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/creator-content.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/data.server.ts create mode 100644 web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/header.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/model.ts create mode 100644 web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx create mode 100644 web/app/components/plugins/marketplace/creator-profile/publisher.ts create mode 100644 web/app/components/plugins/marketplace/creator-profile/view.tsx create mode 100644 web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx create mode 100644 web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts create mode 100644 web/app/components/plugins/marketplace/detail-dialog/frame.tsx create mode 100644 web/app/components/plugins/marketplace/detail-dialog/index.tsx create mode 100644 web/app/components/plugins/marketplace/detail-dialog/use-silent-install.ts create mode 100644 web/app/components/plugins/marketplace/embedded.tsx create mode 100644 web/app/components/plugins/marketplace/filter-track-link.tsx create mode 100644 web/app/components/plugins/marketplace/home/README.md create mode 100644 web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts create mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts create mode 100644 web/app/components/plugins/marketplace/home/assets/background.webp create mode 100644 web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg create mode 100644 web/app/components/plugins/marketplace/home/assets/dify-updates-art.png create mode 100644 web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg create mode 100644 web/app/components/plugins/marketplace/home/assets/plug-fill.svg create mode 100644 web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg create mode 100644 web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp create mode 100644 web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg create mode 100644 web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg create mode 100644 web/app/components/plugins/marketplace/home/banners.spec.ts create mode 100644 web/app/components/plugins/marketplace/home/banners.ts create mode 100644 web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx create mode 100644 web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx create mode 100644 web/app/components/plugins/marketplace/home/embedded-marketplace-search.tsx create mode 100644 web/app/components/plugins/marketplace/home/event-ad-banner-image.ts create mode 100644 web/app/components/plugins/marketplace/home/home-catalog-focus.ts create mode 100644 web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-constants.ts create mode 100644 web/app/components/plugins/marketplace/home/home-creator-center.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-guide.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-header.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-hero.module.css create mode 100644 web/app/components/plugins/marketplace/home/home-hero.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-search.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-shell.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-sticky-state.ts create mode 100644 web/app/components/plugins/marketplace/home/home-sticky.module.css create mode 100644 web/app/components/plugins/marketplace/home/home-trending-navigation.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-trending-slides.tsx create mode 100644 web/app/components/plugins/marketplace/home/home-trending-track.spec.ts create mode 100644 web/app/components/plugins/marketplace/home/home-trending-track.ts create mode 100644 web/app/components/plugins/marketplace/home/home-trending.module.css create mode 100644 web/app/components/plugins/marketplace/home/home-trending.tsx create mode 100644 web/app/components/plugins/marketplace/home/index.tsx create mode 100644 web/app/components/plugins/marketplace/home/marketplace-href.ts create mode 100644 web/app/components/plugins/marketplace/home/marketplace-live-search.tsx create mode 100644 web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx create mode 100644 web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts create mode 100644 web/app/components/plugins/marketplace/home/use-banner-viewability.ts create mode 100644 web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx create mode 100644 web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/list/carousel.module.css create mode 100644 web/app/components/plugins/marketplace/list/partner-header.module.css create mode 100644 web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts create mode 100644 web/app/components/plugins/marketplace/plugin-type-switch.module.css create mode 100644 web/app/components/plugins/marketplace/prefetch-marketplace-dehydrated-state.ts create mode 100644 web/app/components/plugins/marketplace/query-options.ts create mode 100644 web/app/components/plugins/marketplace/server-budget.ts create mode 100644 web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts create mode 100644 web/app/components/plugins/marketplace/standalone/client.ts create mode 100644 web/app/components/plugins/marketplace/standalone/server.ts create mode 100644 web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx create mode 100644 web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx create mode 100644 web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx create mode 100644 web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts create mode 100644 web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts create mode 100644 web/app/components/plugins/marketplace/templates/categories.ts create mode 100644 web/app/components/plugins/marketplace/templates/index.tsx create mode 100644 web/app/components/plugins/marketplace/templates/template-card.tsx create mode 100644 web/app/components/plugins/marketplace/templates/template-category-navigation.tsx create mode 100644 web/app/components/plugins/marketplace/templates/template-collection-list.tsx create mode 100644 web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx create mode 100644 web/app/components/plugins/marketplace/templates/template-language.ts create mode 100644 web/app/components/plugins/marketplace/templates/template-links.ts create mode 100644 web/app/components/plugins/marketplace/templates/template-pagination.tsx create mode 100644 web/app/components/plugins/marketplace/view.tsx create mode 100644 web/public/marketplace/dify-marketplace-logo-dark.svg create mode 100644 web/public/marketplace/dify-marketplace-logo.svg create mode 100644 web/service/marketplace-template-discovery.spec.ts create mode 100644 web/service/marketplace-template-discovery.ts create mode 100644 web/utils/__tests__/marketplace-site-track.spec.ts create mode 100644 web/utils/marketplace-site-track.ts diff --git a/knip.config.ts b/knip.config.ts index 2914f398f8c..3931cfd7a2f 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -15,6 +15,10 @@ const config: KnipConfig = { 'tsslint.config.ts', 'dev-proxy.config.ts', 'plugins/eslint/index.js', + // Public surface consumed by the standalone Marketplace host. + // The `!` suffix keeps these entries in `knip --production`. + 'app/components/plugins/marketplace/standalone/server.ts!', + 'app/components/plugins/marketplace/standalone/client.ts!', ], project: [ '**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,css,mdx}!', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 47aa90acc9a..3c3e2bcf88e 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1131,7 +1131,7 @@ }, "web/app/components/base/icons/src/vender/plugin/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 3 + "count": 2 } }, "web/app/components/base/icons/src/vender/solid/FinanceAndECommerce/index.ts": { @@ -2565,23 +2565,15 @@ }, "web/app/components/plugins/marketplace/hooks.ts": { "@tanstack/query/prefer-query-options": { - "count": 4 + "count": 3 }, "no-restricted-imports": { "count": 1 } }, - "web/app/components/plugins/marketplace/list/list-with-collection.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/plugins/marketplace/query.ts": { "@tanstack/query/prefer-query-options": { - "count": 2 + "count": 1 } }, "web/app/components/plugins/plugin-auth/authorized/index.tsx": { diff --git a/packages/contracts/marketplace.ts b/packages/contracts/marketplace.ts index 459f3d778e6..06353129a8f 100644 --- a/packages/contracts/marketplace.ts +++ b/packages/contracts/marketplace.ts @@ -22,6 +22,10 @@ export type MarketplaceCollection = { search_params?: SearchParamsFromCollection } +export type MarketplaceTimestamp = string | number +export type MarketplaceCreatorStatus = 'pending' | 'active' | 'inactive' | 'deleted' +export type MarketplaceOrganizationStatus = 'active' | 'inactive' | 'deleted' + export type PluginsSearchParams = { query: string page?: number @@ -44,6 +48,7 @@ export type CollectionsAndPluginsSearchParams = { condition?: string exclude?: string[] type?: 'plugin' | 'bundle' + limit?: number } export type MarketplaceTemplate = { @@ -53,9 +58,65 @@ export type MarketplaceTemplate = { icon: string icon_background: string icon_file_key: string - publisher_unique_handle: string + publisher_unique_handle?: string + publisher_handle?: string + publisher_type?: string + creator_email?: string usage_count: number categories: string[] + deps_plugins?: string[] + preferred_languages?: string[] + badges?: string[] + created_at?: MarketplaceTimestamp + updated_at?: MarketplaceTimestamp +} + +export type MarketplaceCreator = { + id?: string + email?: string + name?: string + display_name?: string + unique_handle: string + display_email?: string + description?: string + avatar?: string + background_image?: string + social_links?: string[] + badges?: string[] + verified?: boolean + status?: MarketplaceCreatorStatus + public?: boolean + plugin_count?: number + template_count?: number + created_at?: string + updated_at?: string +} + +export type MarketplaceOrganization = { + id?: string + email?: string + name?: string + display_name?: string + unique_handle?: string + display_email?: string + description?: string + avatar?: string + background_image?: string + social_links?: string[] + badges?: string[] + verified?: boolean + status?: MarketplaceOrganizationStatus + created_at?: string + updated_at?: string +} + +export type MarketplaceTemplateCollection = { + name: string + description: Record + label: Record + searchable?: boolean + search_params?: SearchParamsFromCollection + priority: number } export type MarketplacePluginCategory = @@ -109,6 +170,9 @@ export type MarketplacePlugin = { authorized_category: 'langgenius' | 'partner' | 'community' } from: MarketplacePluginDependencySource + created_at?: MarketplaceTimestamp + updated_at?: MarketplaceTimestamp + version_updated_at?: MarketplaceTimestamp | null } export type PluginInfoFromMarketPlace = { @@ -154,8 +218,151 @@ export type TemplateDetailResponse = { data: MarketplaceTemplate } +export type TemplateCollectionsResponse = { + data?: { + collections?: MarketplaceTemplateCollection[] + total?: number + } +} + +export type TemplateCollectionTemplatesResponse = { + data?: { + templates?: MarketplaceTemplate[] + total?: number + } +} + +export type TemplateSearchResponse = { + data?: { + templates?: MarketplaceTemplate[] + total?: number + } +} + export type DownloadPluginResponse = Blob +export type CreatorDetailResponse = { + code?: number + data?: { + creator?: MarketplaceCreator + } + msg?: string +} + +export type OrganizationDetailResponse = { + code?: number + data?: { + organization?: MarketplaceOrganization + } + msg?: string +} + +export type PublisherPluginsResponse = { + code?: number + data?: { + plugins?: MarketplacePlugin[] + total?: number + } + msg?: string +} + +export type PublisherTemplatesResponse = { + code?: number + data?: { + templates?: MarketplaceTemplate[] + total?: number + } + msg?: string +} + +// Banner payload shapes shared by the standalone marketplace and the embedded +// console. The banners endpoint output stays `unknown` in the contract because +// the delivery format is normalized and runtime-validated in +// `web/app/components/plugins/marketplace/home/banners.ts`. +export type BannerBase = { + id: string + title: string + sort: number + language: string +} + +export type BannerRecommendCard = { + item_type: 'plugin' | 'template' + item_id: string + display_name: string + icon_url?: string + icon?: string + icon_background?: string + creator?: string + badges?: Array<'partner' | 'verified'> + link: string + card_position: number + auto_batch_id?: string | null +} + +export type BannerRecommend = BannerBase & { + style_type: 'recommend' + content: { + theme_type: 'newest' | 'hottest' | 'partner' + heading?: string + subheadings?: string[] + description?: string + cards: BannerRecommendCard[] + } +} + +export type BannerBlog = BannerBase & { + style_type: 'blog' + content: { + blog_title: string + subtitle?: string + description?: string + link: string + link_target_type: 'blog' | 'github' + } +} + +export 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 bannerListContract = base + .route({ + path: '/banners', + method: 'GET', + }) + .input( + type<{ + query: { + page: 'plugins' | 'templates' + language: string + } + }>(), + ) + .output(type()) + const collectionsContract = base .route({ path: '/collections', @@ -212,6 +419,58 @@ const templateDetailContract = base ) .output(type()) +const templateCollectionsContract = base + .route({ + path: '/template-collections', + method: 'GET', + }) + .input( + type<{ + query?: { + page?: number + page_size?: number + } + }>(), + ) + .output(type()) + +const templateCollectionTemplatesContract = base + .route({ + path: '/template-collections/{collectionName}/templates', + method: 'POST', + }) + .input( + type<{ + params: { + collectionName: string + } + body?: { + limit?: number + } + }>(), + ) + .output(type()) + +const templateSearchContract = base + .route({ + path: '/templates/search/advanced', + method: 'POST', + }) + .input( + type<{ + body: { + page: number + page_size: number + query: string + sort_by: string + sort_order: string + categories?: string[] + languages?: string[] + } + }>(), + ) + .output(type()) + const downloadPluginContract = base .route({ path: '/plugins/{organization}/{pluginName}/{version}/download', @@ -228,12 +487,90 @@ const downloadPluginContract = base ) .output(type()) +const creatorDetailContract = base + .route({ + path: '/creators/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + }>(), + ) + .output(type()) + +const organizationDetailContract = base + .route({ + path: '/organizations/{id}', + method: 'GET', + }) + .input( + type<{ + params: { + id: string + } + }>(), + ) + .output(type()) + +const publisherPluginsContract = base + .route({ + path: '/plugins/publisher/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + query: { + page: number + page_size: number + sort_by?: string + sort_order?: string + } + }>(), + ) + .output(type()) + +const publisherTemplatesContract = base + .route({ + path: '/templates/publisher/{uniqueHandle}', + method: 'GET', + }) + .input( + type<{ + params: { + uniqueHandle: string + } + query: { + page: number + page_size: number + sort_by?: string + sort_order?: string + } + }>(), + ) + .output(type()) + export const marketplaceRouterContract = { + banners: { + list: bannerListContract, + }, collections: collectionsContract, collectionPlugins: collectionPluginsContract, searchAdvanced: searchAdvancedContract, + templateCollections: templateCollectionsContract, + templateCollectionTemplates: templateCollectionTemplatesContract, templateDetail: templateDetailContract, + templateSearch: templateSearchContract, downloadPlugin: downloadPluginContract, + creatorDetail: creatorDetailContract, + organizationDetail: organizationDetailContract, + publisherPlugins: publisherPluginsContract, + publisherTemplates: publisherTemplatesContract, } export type MarketPlaceInputs = InferContractRouterInputs diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index f985644c633..bd454010a1b 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -363,7 +363,9 @@ export const FormDialog: Story = { await userEvent.click(canvas.getByRole('button', { name: 'Configure API extension' })) - await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus() + await waitFor(async () => { + await expect(body.getByRole('textbox', { name: 'Name' })).toHaveFocus() + }) }, } diff --git a/packages/iconify-collections/assets/public/common/gmail.svg b/packages/iconify-collections/assets/public/common/gmail.svg new file mode 100644 index 00000000000..1e5afcbf624 --- /dev/null +++ b/packages/iconify-collections/assets/public/common/gmail.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index 510de02889d..2e6fbd864c8 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-public", - "lastModified": 1785332090, + "lastModified": 1786856617, "icons": { "agent-building-blocks": { "body": "" @@ -71,7 +71,8 @@ "height": 24 }, "common-d": { - "body": "" + "body": "", + "height": 16 }, "common-diagonal-dividing-line": { "body": "", @@ -89,7 +90,8 @@ "height": 24 }, "common-enter-key": { - "body": "" + "body": "", + "height": 16 }, "common-firecrawl": { "body": "", @@ -106,6 +108,11 @@ "width": 18, "height": 18 }, + "common-gmail": { + "body": "", + "width": 24, + "height": 24 + }, "common-google-drive": { "body": "", "width": 24, @@ -127,10 +134,12 @@ "height": 12 }, "common-lock": { - "body": "" + "body": "", + "height": 16 }, "common-message-chat-square": { - "body": "" + "body": "", + "height": 16 }, "common-multi-path-retrieval": { "body": "", @@ -158,7 +167,8 @@ "height": 14 }, "common-sparkles-soft-accent": { - "body": "" + "body": "", + "height": 16 }, "education-triangle": { "body": "", diff --git a/packages/iconify-collections/custom-public/info.json b/packages/iconify-collections/custom-public/info.json index d283dc036f5..09bd7c5e800 100644 --- a/packages/iconify-collections/custom-public/info.json +++ b/packages/iconify-collections/custom-public/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-public", "name": "Dify Custom Public", - "total": 150, + "total": 151, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index 4daa46430b9..d92c4e2fd1e 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -43,24 +43,6 @@ "body": "", "width": 17 }, - "app-publisher-deploying-chevron": { - "body": "", - "width": 8.27613, - "height": 5.08087 - }, - "deploy-code-block": { - "body": "" - }, - "deploy-line-5": { - "body": "", - "width": 12, - "height": 39 - }, - "deploy-rocket": { - "body": "", - "width": 14, - "height": 14 - }, "features-citations": { "body": "", "width": 24, @@ -1610,6 +1592,24 @@ "body": "", "width": 16, "height": 16 + }, + "app-publisher-deploying-chevron": { + "body": "", + "width": 8.27613, + "height": 5.08087 + }, + "deploy-code-block": { + "body": "" + }, + "deploy-line-5": { + "body": "", + "width": 12, + "height": 39 + }, + "deploy-rocket": { + "body": "", + "width": 14, + "height": 14 } } } diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index 097f2c15865..6c61adcc7c4 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 346, + "total": 350, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/__tests__/proxy-frame-options.spec.ts b/web/__tests__/proxy-frame-options.spec.ts index 54385b10f13..315898f77da 100644 --- a/web/__tests__/proxy-frame-options.spec.ts +++ b/web/__tests__/proxy-frame-options.spec.ts @@ -4,6 +4,7 @@ import { canEmbedPath, proxy } from '@/proxy' const mockEnv = vi.hoisted(() => ({ NEXT_PUBLIC_ALLOW_EMBED: false, NEXT_PUBLIC_CSP_WHITELIST: 'https://example.com', + NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: '', NEXT_PUBLIC_TURNSTILE_SITE_KEY: '', })) @@ -24,6 +25,7 @@ const createRequest = (url: string) => { describe('proxy frame options', () => { afterEach(() => { mockEnv.NEXT_PUBLIC_ALLOW_EMBED = false + mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = '' mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = '' vi.unstubAllEnvs() }) @@ -86,6 +88,36 @@ describe('proxy frame options', () => { expect(response.headers.get('x-frame-options')).toBe('DENY') expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") }) + + it('should deny framing for the Marketplace OAuth authorize route', () => { + const response = proxy( + createRequest('https://cloud.dify.ai/account/oauth/authorize?client_id=marketplace-client'), + ) + + expect(response.headers.get('x-frame-options')).toBe('DENY') + expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") + }) + + it('should allow framing Marketplace pages when a Marketplace origin is configured', () => { + vi.stubEnv('NODE_ENV', 'production') + mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = 'https://marketplace.dify.ai' + + const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + + expect(response.headers.get('content-security-policy') ?? '').toMatch( + /frame-src[^;]*https:\/\/marketplace\.dify\.ai/, + ) + }) + + it('should not add a Marketplace frame origin when the prefix is unset', () => { + vi.stubEnv('NODE_ENV', 'production') + + const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + const contentSecurityPolicy = response.headers.get('content-security-policy') ?? '' + + expect(contentSecurityPolicy).toContain('frame-src') + expect(contentSecurityPolicy).not.toContain('https://marketplace.dify.ai') + }) }) describe('proxy CookieYes consent logging', () => { diff --git a/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx b/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx new file mode 100644 index 00000000000..f89e3633885 --- /dev/null +++ b/web/app/(commonLayout)/marketplace/__tests__/layout.spec.tsx @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../document-title', () => ({ + default: () => marketplace document title, +})) + +describe('marketplace route layout', () => { + it('stays a server module so Flight can stream the marketplace page', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../layout.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/^['"]use client['"]/) + }) + + it('renders marketplace children and the document title island', async () => { + const { default: MarketplaceLayout } = await import('../layout') + + render( + +

marketplace page

+
, + ) + + expect(screen.getByText('marketplace document title')).toBeInTheDocument() + expect(screen.getByText('marketplace page')).toBeInTheDocument() + }) +}) diff --git a/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx new file mode 100644 index 00000000000..8f2d5e1526d --- /dev/null +++ b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/plugins/marketplace/marketplace-install-permission-provider', () => ({ + default: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/plugins/marketplace/embedded', () => ({ + EmbeddedMarketplace: () =>

Embedded marketplace home

, +})) + +describe('embedded marketplace home route', () => { + it('does not stream async server children that Flight would double-resolve', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/const MarketplacePage = async/) + expect(source).not.toContain('HydrateQueryClient') + expect(source).not.toContain('AccountSection') + expect(source).not.toContain('homeHeaderActions') + }) + + it('renders the client marketplace home inside the install-permission provider', async () => { + const { default: MarketplacePage } = await import('../page') + render() + + const permission = screen.getByRole('region', { name: 'install permission' }) + + expect(permission).toContainElement(screen.getByText('Embedded marketplace home')) + }) +}) diff --git a/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx new file mode 100644 index 00000000000..1c9c5615aa6 --- /dev/null +++ b/web/app/(commonLayout)/marketplace/creator/[uniqueHandle]/page.tsx @@ -0,0 +1,51 @@ +import { loadCreatorProfile } from '@/app/components/plugins/marketplace/creator-profile/data.server' +import DifyCreatorProfile from '@/app/components/plugins/marketplace/creator-profile/dify-profile' +import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' +import { getLocaleOnServer } from '@/i18n-config/server' +import { notFound } from '@/next/navigation' + +type CreatorPageSearchParams = { + publisher_type?: string + sort_by?: string + sort_order?: string +} + +type CreatorProfilePageProps = { + params: Promise<{ uniqueHandle: string }> + searchParams: Promise +} + +// Sync route: async pages under this client shell Flight-double-resolve. +export default function CreatorProfilePage(props: CreatorProfilePageProps) { + return ( +
+ +
+ ) +} + +async function CreatorProfileContent({ params, searchParams }: CreatorProfilePageProps) { + const [{ uniqueHandle }, query, locale] = await Promise.all([ + params, + searchParams, + getLocaleOnServer(), + ]) + const loadedProfile = await loadCreatorProfile({ + uniqueHandle, + publisherType: query.publisher_type, + locale, + sortBy: query.sort_by, + sortOrder: query.sort_order, + }) + + if (!loadedProfile) notFound() + + return ( + + + + ) +} diff --git a/web/app/(commonLayout)/marketplace/document-title.tsx b/web/app/(commonLayout)/marketplace/document-title.tsx new file mode 100644 index 00000000000..d053ab9cb3c --- /dev/null +++ b/web/app/(commonLayout)/marketplace/document-title.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import useDocumentTitle from '@/hooks/use-document-title' + +const MarketplaceDocumentTitle = () => { + const { t } = useTranslation() + useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' })) + return null +} + +export default MarketplaceDocumentTitle diff --git a/web/app/(commonLayout)/marketplace/layout.tsx b/web/app/(commonLayout)/marketplace/layout.tsx index 5a40a6a3a62..7bf5569a955 100644 --- a/web/app/(commonLayout)/marketplace/layout.tsx +++ b/web/app/(commonLayout)/marketplace/layout.tsx @@ -1,12 +1,12 @@ -'use client' - import type { PropsWithChildren } from 'react' -import { useTranslation } from 'react-i18next' -import useDocumentTitle from '@/hooks/use-document-title' +import MarketplaceDocumentTitle from './document-title' +// Server layout: a client layout here Flight-double-resolves the page. export default function MarketplaceLayout({ children }: PropsWithChildren) { - const { t } = useTranslation() - useDocumentTitle(t(($) => $['mainNav.marketplace'], { ns: 'common' })) - - return children + return ( + <> + + {children} + + ) } diff --git a/web/app/(commonLayout)/marketplace/page.tsx b/web/app/(commonLayout)/marketplace/page.tsx index 9f56a38d4a9..a6594a8048c 100644 --- a/web/app/(commonLayout)/marketplace/page.tsx +++ b/web/app/(commonLayout)/marketplace/page.tsx @@ -1,19 +1,16 @@ -import type { SearchParams } from 'nuqs' -import Marketplace from '@/app/components/plugins/marketplace' +import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants' +import { EmbeddedMarketplace } from '@/app/components/plugins/marketplace/embedded' import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' -type MarketplacePageProps = { - searchParams?: Promise -} - -const MarketplacePage = ({ searchParams }: MarketplacePageProps) => { +// Sync route: async pages under this client shell Flight-double-resolve. +const MarketplacePage = () => { return (
- +
) diff --git a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx new file mode 100644 index 00000000000..6c86517898b --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx @@ -0,0 +1,151 @@ +import type { FunctionComponent, ReactElement } from 'react' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { redirect } from '@/next/navigation' +import TemplatesPage from '../page' + +type TemplatesPageProps = Parameters[0] + +const resolveTemplatesPage = async (props: TemplatesPageProps) => { + const tree = TemplatesPage(props) as ReactElement<{ + children: ReactElement + className: string + id: string + }> + const child = tree.props.children + const content = await (child.type as FunctionComponent)(child.props) + return createElement(tree.type, tree.props, content) +} + +vi.mock('@/app/components/plugins/marketplace/templates', () => ({ + EmbeddedTemplatesMarketplace: ({ + category, + page, + query, + sortBy, + sortOrder, + view, + }: { + category: string + page: number + query: string + sortBy?: string + sortOrder?: string + view?: string + }) => ( +
+ {`Templates catalog: ${category}:${query}`} +
+ ), +})) + +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: () => Promise.resolve('en-US'), +})) + +vi.mock('@/next/navigation', () => ({ + redirect: vi.fn((path: string) => { + throw new Error(`redirect:${path}`) + }), +})) + +describe('embedded templates route', () => { + it('does not stream async server children that Flight would double-resolve', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../page.tsx'), + 'utf8', + ) + + expect(source).not.toMatch(/export default async function TemplatesPage/) + expect(TemplatesPage.constructor.name).not.toBe('AsyncFunction') + }) + + it('renders the templates catalog at /templates', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ q: 'agent' }), + }) + + render(page) + + expect(screen.getByText('Templates catalog: all:agent')).toBeInTheDocument() + expect(screen.getByText('Templates catalog: all:agent').parentElement).toHaveAttribute( + 'id', + 'marketplace-container', + ) + }) + + it('passes a supported path category to the templates catalog', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({ category: ['marketing'] }), + searchParams: Promise.resolve({}), + }) + + render(page) + + expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument() + }) + + it('validates page, view and sort params at the route boundary', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ + page: '3', + q: 'agent', + sort_by: 'created_at', + sort_order: 'ASC', + view: 'search', + }), + }) + + render(page) + + const catalog = screen.getByTestId('catalog') + expect(catalog).toHaveAttribute('data-page', '3') + expect(catalog).toHaveAttribute('data-sort-by', 'created_at') + expect(catalog).toHaveAttribute('data-sort-order', 'ASC') + expect(catalog).toHaveAttribute('data-view', 'search') + }) + + it('falls back to defaults for unsupported page, view and sort params', async () => { + const page = await resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ + page: '-2', + q: 'agent', + sort_by: 'garbage', + sort_order: 'sideways', + view: 'iframe', + }), + }) + + render(page) + + const catalog = screen.getByTestId('catalog') + expect(catalog).toHaveAttribute('data-page', '1') + expect(catalog).not.toHaveAttribute('data-sort-by') + expect(catalog).not.toHaveAttribute('data-sort-order') + expect(catalog).not.toHaveAttribute('data-view') + }) + + it('opens template recommendations in the existing Dify import flow', async () => { + await expect( + resolveTemplatesPage({ + params: Promise.resolve({}), + searchParams: Promise.resolve({ tid: 'template/one' }), + }), + ).rejects.toThrow('redirect:/apps?template-id=template%2Fone') + + expect(redirect).toHaveBeenCalledWith('/apps?template-id=template%2Fone') + }) +}) diff --git a/web/app/(commonLayout)/templates/[[...category]]/page.tsx b/web/app/(commonLayout)/templates/[[...category]]/page.tsx new file mode 100644 index 00000000000..4d31ad4a2e0 --- /dev/null +++ b/web/app/(commonLayout)/templates/[[...category]]/page.tsx @@ -0,0 +1,78 @@ +import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants' +import { EmbeddedTemplatesMarketplace } from '@/app/components/plugins/marketplace/templates' +import { isTemplateCategory } from '@/app/components/plugins/marketplace/templates/categories' +import { getLocaleOnServer } from '@/i18n-config/server' +import { redirect } from '@/next/navigation' + +type TemplatesPageProps = { + params: Promise<{ category?: string[] }> + searchParams: Promise<{ + languages?: string | string[] + page?: string + q?: string + sort_by?: string + sort_order?: string + tid?: string + view?: string + }> +} + +// These values arrive from a public URL, so validate them against the +// supported enums here at the route boundary. Unknown values fall back to the +// defaults instead of reaching the Marketplace API, where e.g. +// `sort_order=garbage` fails and would surface as a false "no templates" state. +const TEMPLATE_SORT_FIELDS = new Set(['usage_count', 'created_at']) +const TEMPLATE_SORT_ORDERS = new Set(['ASC', 'DESC']) + +const parseView = (value?: string) => (value === 'search' ? 'search' : undefined) + +const parseSortBy = (value?: string) => + value && TEMPLATE_SORT_FIELDS.has(value) ? value : undefined + +const parseSortOrder = (value?: string) => + value && TEMPLATE_SORT_ORDERS.has(value) ? value : undefined + +const parsePage = (value?: string) => { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1 +} + +// Sync route: async pages under this client shell Flight-double-resolve. +export default function TemplatesPage(props: TemplatesPageProps) { + return ( +
+ +
+ ) +} + +async function TemplatesPageContent({ params, searchParams }: TemplatesPageProps) { + const [resolvedParams, resolvedSearchParams, locale] = await Promise.all([ + params, + searchParams, + getLocaleOnServer(), + ]) + + if (resolvedSearchParams.tid) { + redirect(`/apps?template-id=${encodeURIComponent(resolvedSearchParams.tid)}`) + } + + const requestedCategory = resolvedParams.category?.[0] + const category = isTemplateCategory(requestedCategory) ? requestedCategory : 'all' + + return ( + + ) +} diff --git a/web/app/account/oauth/authorize/__tests__/page.spec.tsx b/web/app/account/oauth/authorize/__tests__/page.spec.tsx index a06d4499e46..31bba9a90e8 100644 --- a/web/app/account/oauth/authorize/__tests__/page.spec.tsx +++ b/web/app/account/oauth/authorize/__tests__/page.spec.tsx @@ -127,6 +127,24 @@ describe('OAuthAuthorize', () => { ) }) + it('preserves an encoded redirect URI when requesting the OAuth app', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?next=%2Fplugins', + state: 'state-1', + }) + + renderPage() + + expect((await screen.findAllByText('Test OAuth App')).length).toBeGreaterThan(0) + const providerRequest = findRequest('/oauth/provider') + const providerTransportRequest = providerRequest?.[2]?.request as Request + await expect(providerTransportRequest.clone().json()).resolves.toEqual({ + client_id: 'client-1', + redirect_uri: 'https://client.example.com/callback?next=%2Fplugins', + }) + }) + it('silently authorizes an app flagged with auto_authorize without rendering consent', async () => { mocks.searchParams = new URLSearchParams({ client_id: 'marketplace-client', @@ -177,6 +195,56 @@ describe('OAuthAuthorize', () => { expect(findRequest('/oauth/provider/authorize')).toBeUndefined() }) + it('does not auto-authorize with incomplete OAuth parameters', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'marketplace-client', + }) + mockProviderResponses({ autoAuthorize: true }) + + renderPage() + + expect(await screen.findByText('oauth.error.invalidParams')).toBeInTheDocument() + expect(findRequest('/oauth/provider')).toBeUndefined() + expect(findRequest('/oauth/provider/authorize')).toBeUndefined() + }) + + it('retries app info loading and resumes auto-authorization', async () => { + mocks.searchParams = new URLSearchParams({ + client_id: 'marketplace-client', + redirect_uri: 'https://api.marketplace.example.com/api/v1/auth/callback/dify', + response_type: 'code', + state: 'marketplace-state', + }) + let providerAttempts = 0 + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider/authorize')) return jsonResponse({ code: 'oauth-code' }) + if (url.endsWith('/oauth/provider')) { + providerAttempts += 1 + if (providerAttempts === 1) throw new Error('Failed to load OAuth app') + return jsonResponse({ + app_icon: '', + app_label: { en_US: 'Test OAuth App' }, + auto_authorize: true, + scope: '', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + const user = userEvent.setup() + renderPage() + + expect(await screen.findByText('oauth.error.authAppInfoFetchFailed')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + await waitFor(() => expect(findRequest('/oauth/provider/authorize')).toBeDefined()) + await waitFor(() => + expect(globalThis.location.href).toBe( + 'https://api.marketplace.example.com/api/v1/auth/callback/dify?code=oauth-code&state=marketplace-state', + ), + ) + }) + it('falls back to manual confirmation when silent authorization fails', async () => { mocks.searchParams = new URLSearchParams({ client_id: 'marketplace-client', @@ -215,4 +283,38 @@ describe('OAuthAuthorize', () => { ), ) }) + + it('renders an unknown OAuth scope without crashing', async () => { + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider')) { + return jsonResponse({ + app_icon: '', + app_label: { en_US: 'Test OAuth App' }, + scope: 'read:custom_profile', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + renderPage() + + expect(await screen.findByText('read:custom_profile')).toBeInTheDocument() + }) + + it('supports OAuth app labels that use a hyphenated locale key', async () => { + mocks.request.mockImplementation(async (url: string) => { + if (url.endsWith('/oauth/provider')) { + return jsonResponse({ + app_icon: '', + app_label: { 'en-US': 'Hyphenated OAuth App' }, + scope: '', + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + + renderPage() + + expect((await screen.findAllByText('Hyphenated OAuth App')).length).toBeGreaterThan(0) + }) }) diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 28d54aa82f0..a8924817e71 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -13,7 +13,6 @@ import { } from '@remixicon/react' import { skipToken, useMutation, useQuery } from '@tanstack/react-query' import * as React from 'react' -import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -57,10 +56,10 @@ export default function OAuthAuthorize() { const router = useRouter() const language = useLanguage() const searchParams = useSearchParams() - const client_id = decodeURIComponent(searchParams.get('client_id') || '') - const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '') + const clientId = searchParams.get('client_id') || '' + const redirectUri = searchParams.get('redirect_uri') || '' const state = searchParams.get('state') - const hasOAuthParams = Boolean(client_id && redirect_uri) + const hasOAuthParams = Boolean(clientId && redirectUri) // Probe user profile. 401 stays as `error` (legitimate "not logged in" state), // other errors throw to the nearest error.tsx; jumpTo same-pathname guard in // service/base.ts prevents a redirect loop here. @@ -77,10 +76,14 @@ export default function OAuthAuthorize() { const { data: authAppInfo, isLoading: isOAuthLoading, - isError, + isFetching: isOAuthFetching, + isError: isOAuthError, + refetch: refetchOAuthApp, } = useQuery( consoleQuery.oauth.provider.post.queryOptions({ - input: hasOAuthParams ? { body: { client_id, redirect_uri } } : skipToken, + input: hasOAuthParams + ? { body: { client_id: clientId, redirect_uri: redirectUri } } + : skipToken, context: { silent: true }, }), ) @@ -91,17 +94,17 @@ export default function OAuthAuthorize() { const { isAutoAuthorizing } = useSilentAuthorize({ authAppInfo, authorize, - clientId: client_id, + clientId, hasOAuthParams, isLoggedIn, isProfileLoading, - redirectUri: redirect_uri, + redirectUri, searchParams, state, }) - const hasNotifiedRef = useRef(false) - const localizedAppLabel = authAppInfo?.app_label[language] - const englishAppLabel = authAppInfo?.app_label.en_US + const localizedAppLabel = + authAppInfo?.app_label[language] ?? authAppInfo?.app_label[language.replace('_', '-')] + const englishAppLabel = authAppInfo?.app_label.en_US ?? authAppInfo?.app_label['en-US'] const appLabel = (typeof localizedAppLabel === 'string' && localizedAppLabel) || (typeof englishAppLabel === 'string' && englishAppLabel) || @@ -112,7 +115,6 @@ export default function OAuthAuthorize() { : t(($) => $.connect, { ns: 'oauth' }), ) - const isLoading = isOAuthLoading || isProfileLoading const onLoginSwitchClick = async () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`) @@ -124,30 +126,39 @@ export default function OAuthAuthorize() { } const onAuthorize = async () => { - if (!client_id || !redirect_uri) return + if (!clientId || !redirectUri) return try { - const { code } = await authorize({ body: { client_id } }) - globalThis.location.href = buildOAuthCallbackUrl(redirect_uri, code, state) + const { code } = await authorize({ body: { client_id: clientId } }) + globalThis.location.href = buildOAuthCallbackUrl(redirectUri, code, state) } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error) toast.error(`${t(($) => $['error.authorizeFailed'], { ns: 'oauth' })}: ${message}`) } } - useEffect(() => { - const invalidParams = !client_id || !redirect_uri - if ((invalidParams || isError) && !hasNotifiedRef.current) { - hasNotifiedRef.current = true - toast.error( - invalidParams - ? t(($) => $['error.invalidParams'], { ns: 'oauth' }) - : t(($) => $['error.authAppInfoFetchFailed'], { ns: 'oauth' }), - { timeout: 0 }, - ) - } - }, [client_id, redirect_uri, isError]) + if (!hasOAuthParams || isOAuthError) { + return ( +
+
+ {t(($) => $[hasOAuthParams ? 'error.authAppInfoFetchFailed' : 'error.invalidParams'], { + ns: 'oauth', + })} +
+ {isOAuthError && ( + + )} +
+ ) + } - if (isLoading || isAutoAuthorizing) { + if (isProfileLoading || isOAuthLoading || isAutoAuthorizing) { return (
@@ -203,18 +214,15 @@ export default function OAuthAuthorize() { .split(/\s+/) .filter(Boolean) .map((scope: string) => { - const Icon = SCOPE_INFO_MAP[scope] + const scopeInfo = SCOPE_INFO_MAP[scope] + const ScopeIcon = scopeInfo?.icon ?? RiAccountCircleLine return (
- {Icon ? ( - - ) : ( - - )} - {Icon!.label} + + {scopeInfo?.label ?? scope}
) })} @@ -238,7 +246,7 @@ export default function OAuthAuthorize() { size="large" className="w-full" onClick={onAuthorize} - disabled={!client_id || !redirect_uri || isError || authorizing} + disabled={!clientId || !redirectUri || isOAuthError || authorizing} loading={authorizing} > {t(($) => $.continue, { ns: 'oauth' })} diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.json b/web/app/components/base/icons/src/vender/plugin/Trigger.json deleted file mode 100644 index 4ed8923e6cb..00000000000 --- a/web/app/components/base/icons/src/vender/plugin/Trigger.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "icon": { - "type": "element", - "isRootNode": true, - "name": "svg", - "attributes": { - "width": "16", - "height": "16", - "viewBox": "0 0 16 16", - "fill": "none", - "xmlns": "http://www.w3.org/2000/svg" - }, - "children": [ - { - "type": "element", - "name": "path", - "attributes": { - "fill-rule": "evenodd", - "clip-rule": "evenodd", - "d": "M7.1499 6.35213L7.25146 6.38208L14.2248 9.03898L14.3172 9.08195C14.7224 9.30788 14.778 9.87906 14.424 10.179L14.342 10.2389L11.8172 11.817L10.2391 14.3417C9.96271 14.7839 9.32424 14.751 9.08219 14.317L9.03923 14.2245L6.38232 7.25122C6.18829 6.74188 6.64437 6.24196 7.1499 6.35213ZM9.81201 12.5084L10.7671 10.981L10.8114 10.9185C10.8589 10.8589 10.9163 10.8075 10.9813 10.7668L12.5086 9.81177L8.15251 8.15226L9.81201 12.5084Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M5.2124 10.3977L3.56266 12.0474L2.61995 11.1047L4.26969 9.455L5.2124 10.3977Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M3.66683 7.99992H1.3335V6.66659H3.66683V7.99992Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M5.2124 4.2688L4.26969 5.21151L2.61995 3.56177L3.56266 2.61906L5.2124 4.2688Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M12.0477 3.56177L10.3979 5.21151L9.45524 4.2688L11.105 2.61906L12.0477 3.56177Z", - "fill": "currentColor" - }, - "children": [] - }, - { - "type": "element", - "name": "path", - "attributes": { - "d": "M8.00016 3.66659H6.66683V1.33325H8.00016V3.66659Z", - "fill": "currentColor" - }, - "children": [] - } - ] - }, - "name": "Trigger" -} diff --git a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx b/web/app/components/base/icons/src/vender/plugin/Trigger.tsx deleted file mode 100644 index 0db03c59836..00000000000 --- a/web/app/components/base/icons/src/vender/plugin/Trigger.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATE BY script -// DON NOT EDIT IT MANUALLY - -import type { IconData } from '@/app/components/base/icons/IconBase' -import * as React from 'react' -import IconBase from '@/app/components/base/icons/IconBase' -import data from './Trigger.json' - -const Icon = ({ - ref, - ...props -}: React.SVGProps & { - ref?: React.RefObject> -}) => - -Icon.displayName = 'Trigger' - -export default Icon diff --git a/web/app/components/base/icons/src/vender/plugin/index.ts b/web/app/components/base/icons/src/vender/plugin/index.ts index b345526eb77..943c7641161 100644 --- a/web/app/components/base/icons/src/vender/plugin/index.ts +++ b/web/app/components/base/icons/src/vender/plugin/index.ts @@ -1,3 +1,2 @@ export { default as BoxSparkleFill } from './BoxSparkleFill' export { default as LeftCorner } from './LeftCorner' -export { default as Trigger } from './Trigger' diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts index 8e0dc0675d8..cae824f953e 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts @@ -25,7 +25,7 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ describe('useMarketplaceAllPlugins', () => { const mockQueryPlugins = vi.fn() const mockQueryPluginsWithDebounced = vi.fn() - const mockResetPlugins = vi.fn() + const mockResetQueryParams = vi.fn() const mockCancelQueryPluginsWithDebounced = vi.fn() const mockFetchNextPage = vi.fn() @@ -35,7 +35,7 @@ describe('useMarketplaceAllPlugins', () => { ({ plugins: [], total: 0, - resetPlugins: mockResetPlugins, + resetQueryParams: mockResetQueryParams, queryPlugins: mockQueryPlugins, queryPluginsWithDebounced: mockQueryPluginsWithDebounced, cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced, diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 222077a41dc..737bcf1aa4d 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -268,12 +268,14 @@ export const useMarketplaceAllPlugins = ( queryPlugins, queryPluginsWithDebounced, cancelQueryPluginsWithDebounced = () => {}, + resetQueryParams = () => {}, isLoading: isPluginsLoading, } = useMarketplacePlugins(enabled) useEffect(() => { if (!enabled) { cancelQueryPluginsWithDebounced() + resetQueryParams() return } @@ -302,6 +304,7 @@ export const useMarketplaceAllPlugins = ( enabled, queryPlugins, queryPluginsWithDebounced, + resetQueryParams, searchText, exclude, ]) diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 9408839897d..e5c22d4edbb 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -979,14 +979,17 @@ describe('MainNav', () => { ) }) - it('marks marketplace active on marketplace routes', () => { - mockPathname = '/marketplace' + it.each(['/marketplace', '/plugins', '/templates', '/templates/marketing'])( + 'marks marketplace active on route %s', + (pathname) => { + mockPathname = pathname - renderMainNav() + renderMainNav() - const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) - expect(marketplaceLink).toHaveClass(activeGradientMaskClassName) - }) + const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) + expect(marketplaceLink).toHaveClass(activeGradientMaskClassName) + }, + ) it('marks roster active on roster routes', () => { mockPathname = '/agents' @@ -1184,7 +1187,8 @@ describe('MainNav', () => { 'common.mainNav.help.learnDify', 'common.mainNav.help.stepByStepTour', 'common.userProfile.compliance', - 'Discord', + 'common.userProfile.discord', + 'common.mainNav.help.creatorCenter', 'common.userProfile.github', 'common.userProfile.about', ] @@ -1195,6 +1199,23 @@ describe('MainNav', () => { }) }) + it('opens Creator Center from the help menu above GitHub', async () => { + renderMainNav() + + fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + + const creatorCenter = await screen.findByRole('menuitem', { + name: 'common.mainNav.help.creatorCenter', + }) + const github = screen.getByRole('menuitem', { name: /common\.userProfile\.github/ }) + + expect(creatorCenter).toHaveAttribute('href', 'https://creators.dify.ai/') + expect(creatorCenter).toHaveAttribute('target', '_blank') + expect(creatorCenter).toHaveAttribute('rel', 'noopener noreferrer') + expect(creatorCenter.compareDocumentPosition(github)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(creatorCenter.querySelector('.i-ri-user-star-line')).toBeTruthy() + }) + it('opens About from its real Help menu owner and restores focus when closed', async () => { const user = userEvent.setup() mockConsoleState.current = { @@ -1267,7 +1288,7 @@ describe('MainNav', () => { fireEvent.click(contactUsItem) await waitFor(() => { - expect(screen.queryByText('Discord')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.discord')).not.toBeInTheDocument() }) expect(mockSetShowPricingModal).toHaveBeenCalled() }) diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx index 1645b32a690..c2833868fea 100644 --- a/web/app/components/main-nav/__tests__/layout.spec.tsx +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import type { Mock } from 'vite-plus/test' +import { useSuspenseQuery } from '@tanstack/react-query' import { fireEvent, screen } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' @@ -10,6 +11,7 @@ import MainNavLayout from '../layout' const mockConsoleState = vi.hoisted(() => ({ current: { isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, }, })) @@ -22,6 +24,14 @@ vi.mock('@/app/components/header/header-wrapper', () => ({
{children}
), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: vi.fn(), + } +}) + vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => mockConsoleState.current) @@ -55,7 +65,13 @@ describe('MainNavLayout', () => { ;(usePathname as Mock).mockReturnValue('/apps') mockConsoleState.current = { isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, } + ;(useSuspenseQuery as Mock).mockReturnValue({ + data: { + enable_app_deploy: true, + }, + }) ;(isAgentV2Enabled as Mock).mockReturnValue(true) }) @@ -205,29 +221,64 @@ describe('MainNavLayout', () => { expect(screen.getByTestId('main-nav')).toBeInTheDocument() }) - it.each(['/datasets/create', '/datasets/new/create', '/datasets/dataset-1/documents/create'])( - 'keeps the global main nav on collection and creation route %s', - (pathname) => { - ;(usePathname as Mock).mockReturnValue(pathname) + it.each([ + '/datasets/create', + '/datasets/new/create', + '/datasets/dataset-1/documents/create', + '/deployments/create', + ])('keeps the global main nav on collection and creation route %s', (pathname) => { + ;(usePathname as Mock).mockReturnValue(pathname) - render( - Detail sidebar}> -
content
-
, - ) + render( + Detail sidebar}> +
content
+
, + ) - expect(screen.getByTestId('main-nav')).toBeInTheDocument() - expect( - screen.queryByRole('complementary', { name: 'Detail sidebar' }), - ).not.toBeInTheDocument() + expect(screen.getByTestId('main-nav')).toBeInTheDocument() + expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument() + }) + + it.each([ + { + label: 'agent detail route for dataset operators', + pathname: '/agents/agent-1/configure', + consoleState: { + isCurrentWorkspaceDatasetOperator: true, + isCurrentWorkspaceEditor: true, + }, + systemFeatures: { + enable_app_deploy: true, + }, }, - ) - - it('keeps the global main nav on agent detail routes for dataset operators', () => { - ;(usePathname as Mock).mockReturnValue('/agents/agent-1/configure') - mockConsoleState.current = { - isCurrentWorkspaceDatasetOperator: true, - } + { + label: 'deployment detail route for non-editor workspaces', + pathname: '/deployments/app-instance-1/overview', + consoleState: { + isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: false, + }, + systemFeatures: { + enable_app_deploy: true, + }, + }, + { + label: 'deployment detail route when deployment is disabled', + pathname: '/deployments/app-instance-1/overview', + consoleState: { + isCurrentWorkspaceDatasetOperator: false, + isCurrentWorkspaceEditor: true, + }, + systemFeatures: { + enable_app_deploy: false, + }, + }, + ])('keeps the global main nav on $label', ({ pathname, consoleState, systemFeatures }) => { + ;(usePathname as Mock).mockReturnValue(pathname) + mockConsoleState.current = consoleState + ;(useSuspenseQuery as Mock).mockReturnValue({ + data: systemFeatures, + }) render( Detail sidebar}> diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx index dc6ba29391a..2d56e31c559 100644 --- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx @@ -117,12 +117,18 @@ describe('SupportMenu', () => { renderSupportMenu() expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() + expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument() expect( screen .getByText('common.userProfile.contactUs') - .compareDocumentPosition(screen.getByText('Discord')), + .compareDocumentPosition(screen.getByText('common.userProfile.discord')), ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getByRole('menuitem', { name: 'common.userProfile.discord' })).toHaveClass( + 'mx-0', + 'px-3', + ) fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' })) @@ -177,7 +183,7 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() }) it('keeps Zendesk contact us for Cloud sandbox plan with support email and Zendesk configured', () => { @@ -229,7 +235,7 @@ describe('SupportMenu', () => { expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument() - expect(screen.getByText('Discord')).toBeInTheDocument() + expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument() }) it('renders email support when Zendesk is not configured for a dedicated support channel', () => { @@ -245,12 +251,12 @@ describe('SupportMenu', () => { ).toHaveAttribute('href', 'mailto:support@example.com') }) - it('has the correct Discord link', () => { + it('has the Discord link and no Forum entry', () => { renderSupportMenu() - expect(screen.getByRole('menuitem', { name: 'Discord' })).toHaveAttribute( - 'href', - 'https://discord.gg/5AEfbxcd9k', - ) + const discordLink = screen.getByText('common.userProfile.discord').closest('a') + expect(discordLink).toHaveAttribute('href', 'https://discord.gg/5AEfbxcd9k') + expect(screen.queryByText('common.userProfile.forum')).not.toBeInTheDocument() + expect(screen.queryByText('common.userProfile.community')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index e6851679659..f7d26d8a3a1 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -29,6 +29,7 @@ import { MenuItemContent, } from '@/app/components/header/account-dropdown/menu-item-content' import GithubStar from '@/app/components/header/github-star' +import { useCreatorCenterUrl } from '@/app/components/plugins/marketplace/creator-center-url' import { trackStepByStepTourEvent } from '@/app/components/step-by-step-tour/analytics' import { disableStepByStepTourForCurrentWorkspaceAtom, @@ -38,6 +39,7 @@ import { stepByStepTourStateUpdatingAtom, } from '@/app/components/step-by-step-tour/state' import { useSetStepByStepTourShellMode } from '@/app/components/step-by-step-tour/storage' +import { MARKETPLACE_URL_PREFIX } from '@/config' import { getLangGeniusVersionInfo } from '@/context/app-context-normalizers' import { useDocLink } from '@/context/i18n' import { @@ -91,6 +93,7 @@ const MenuSwitchIndicator = ({ checked }: { checked: boolean }) => ( const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: HelpMenuProps) => { const { t } = useTranslation() const docLink = useDocLink() + const creatorCenterUrl = useCreatorCenterUrl(MARKETPLACE_URL_PREFIX) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: profileMeta } = useSuspenseQuery({ ...userProfileQueryOptions(), @@ -252,6 +255,18 @@ const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: He + + $['mainNav.help.creatorCenter'], { ns: 'common' })} + trailing={} + /> + $['userProfile.discord'], { ns: 'common' })} trailing={} /> diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index 78c5958ca4b..c54c9af346d 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -43,7 +43,7 @@ export const MAIN_NAV_ROUTES = [ key: 'home', href: '/', labelKey: 'mainNav.home', - active: (path: string) => path === '/', + active: (path: string) => path === '/' || path === '/explore/apps', icon: 'i-custom-vender-main-nav-home-v2', activeIcon: 'i-custom-vender-main-nav-home-v2-active', visibility: VISIBLE_TO_ALL, @@ -103,7 +103,9 @@ export const MAIN_NAV_ROUTES = [ href: '/marketplace', labelKey: 'mainNav.marketplace', active: (path: string) => - isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'), + isPathUnderRoute(path, '/marketplace') || + isPathUnderRoute(path, '/plugins') || + isPathUnderRoute(path, '/templates'), icon: 'i-custom-vender-main-nav-marketplace-v2', activeIcon: 'i-custom-vender-main-nav-marketplace-v2-active', visibility: VISIBLE_TO_ALL, diff --git a/web/app/components/plugins/base/badges/partner.tsx b/web/app/components/plugins/base/badges/partner.tsx index 6d97d3b4898..41663ffee80 100644 --- a/web/app/components/plugins/base/badges/partner.tsx +++ b/web/app/components/plugins/base/badges/partner.tsx @@ -1,3 +1,5 @@ +'use client' + import type { FC } from 'react' import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark' import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight' diff --git a/web/app/components/plugins/card/__tests__/index.spec.tsx b/web/app/components/plugins/card/__tests__/index.spec.tsx new file mode 100644 index 00000000000..1a237cb9c90 --- /dev/null +++ b/web/app/components/plugins/card/__tests__/index.spec.tsx @@ -0,0 +1,108 @@ +import type { CardPayload } from '../index' +import { render } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { PluginCategoryEnum } from '../../types' +import Card from '../index' + +vi.mock('jotai', () => ({ + useAtomValue: vi.fn(), +})) + +vi.mock('@/context/workspace-state', () => ({ + currentWorkspaceIdAtom: Symbol('currentWorkspaceIdAtom'), +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string | ((dict: Record) => string), options?: { ns?: string }) => { + if (typeof key === 'string') return key + + // Independent Marketplace does not load the tools namespace, so + // tools.author falls back to the key name "author". + const dict: Record = + options?.ns === 'tools' + ? { author: 'author' } + : { + 'marketplace.by': 'by', + 'marketplace.partnerTip': 'Verified by a Dify partner', + 'marketplace.verifiedTip': 'Verified by Dify', + install: '{{num}} installs', + } + return key(dict) + }, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record) => value['en-US'] ?? '', +})) + +vi.mock('../../hooks', () => ({ + useCategories: () => ({ + categoriesMap: { + tool: { label: 'Tool' }, + }, + }), +})) + +const marketplacePlugin = { + badges: [], + brief: { 'en-US': 'Marketplace plugin description' }, + category: PluginCategoryEnum.tool, + description: { 'en-US': 'Marketplace plugin description' }, + endpoint: { settings: [] }, + from: 'marketplace', + icon: 'icon.png', + install_count: 0, + introduction: '', + label: { 'en-US': 'Marketplace plugin' }, + latest_package_identifier: 'langgenius/demo-plugin:1.0.0', + latest_version: '1.0.0', + name: 'demo-plugin', + org: 'langgenius', + plugin_id: 'langgenius/demo-plugin', + repository: '', + tags: [], + type: 'plugin', + verified: false, + verification: { authorized_category: 'langgenius' }, + version: '1.0.0', +} satisfies CardPayload + +describe('Plugin card workspace boundary', () => { + it('renders Marketplace variant icons without reading Dify workspace state', () => { + vi.mocked(useAtomValue).mockImplementation(() => { + throw new Error('Dify workspace state must not be read') + }) + + const payloadWithoutSource = { + ...marketplacePlugin, + from: undefined, + } as unknown as CardPayload + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute( + 'src', + `${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`, + ) + expect(useAtomValue).not.toHaveBeenCalled() + }) + + it('labels the marketplace author as by, not the tools.author key fallback', () => { + const { container } = render() + + expect(container).toHaveTextContent('by') + expect(container).toHaveTextContent('langgenius') + expect(container).not.toHaveTextContent('author') + }) +}) diff --git a/web/app/components/plugins/card/index.tsx b/web/app/components/plugins/card/index.tsx index f5dc18cb55b..4a5ebaa6d9f 100644 --- a/web/app/components/plugins/card/index.tsx +++ b/web/app/components/plugins/card/index.tsx @@ -42,6 +42,37 @@ type Props = Readonly<{ variant?: 'default' | 'marketplace' }> +type CardIconProps = { + icon: CardPayload['icon'] + installFailed?: boolean + installed?: boolean + marketplace?: boolean + plugin: Pick +} + +const WorkspaceCardIcon = ({ icon, installFailed, installed, plugin }: CardIconProps) => { + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) + const iconSrc = getPluginCardIconUrl(plugin, icon, currentWorkspaceId) + + return +} + +const CardIcon = ({ icon, installFailed, installed, marketplace, plugin }: CardIconProps) => { + if (marketplace || plugin.from === 'marketplace') { + const iconSrc = getPluginCardIconUrl({ ...plugin, from: 'marketplace' }, icon, '') + return + } + + return ( + + ) +} + const Card = ({ className, payload, @@ -60,15 +91,11 @@ const Card = ({ const locale = useGetLanguage() const { t } = useTranslation() const { categoriesMap } = useCategories(true) - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload const badges = payload.badges ?? [] const { theme } = useTheme() - const iconSrc = getPluginCardIconUrl( - { from, name, org, type }, - theme === Theme.dark && icon_dark ? icon_dark : icon, - currentWorkspaceId, - ) + const activeIcon = theme === Theme.dark && icon_dark ? icon_dark : icon + const pluginIdentity = { from, name, org, type } const getLocalizedText = (obj: Record | undefined) => obj ? renderI18nObject(obj, locale) : '' const isPartner = badges.includes('partner') @@ -92,7 +119,13 @@ const Card = ({
{!hideCornerMark && }
- +
@@ -116,7 +149,7 @@ const Card = ({ {org && (
- {t(($) => $.author, { ns: 'tools' })} + {t(($) => $['marketplace.by'], { ns: 'plugin' })} {org}
@@ -152,7 +185,12 @@ const Card = ({ {!hideCornerMark && } {/* Header */}
- +
diff --git a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx index cc440d567f4..87fb5039af0 100644 --- a/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/atoms.spec.tsx @@ -6,6 +6,7 @@ import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { useActivePluginType, useFilterPluginTags, + useFilterTemplateLanguages, useMarketplaceMoreClick, useMarketplaceSearchMode, useMarketplaceSort, @@ -128,6 +129,25 @@ describe('useFilterPluginTags', () => { }) }) +describe('useFilterTemplateLanguages', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return empty array as default', () => { + const { wrapper } = createWrapper() + const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper }) + + expect(result.current[0]).toEqual([]) + }) + + it('parses languages from search params', () => { + const { wrapper } = createWrapper('?languages=ja') + const { result } = renderHook(() => useFilterTemplateLanguages(), { wrapper }) + expect(result.current[0]).toEqual(['ja']) + }) +}) + describe('useMarketplaceSearchMode', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts b/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts new file mode 100644 index 00000000000..a8a82ebe809 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/creator-center-url.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + getCreatorCenterUrl, + PUBLIC_CREATOR_CENTER_URL, + rewriteMarketplaceOriginToCreators, +} from '../creator-center-url' + +describe('getCreatorCenterUrl', () => { + it('maps the public Marketplace to the public Creator Center', () => { + expect(getCreatorCenterUrl('https://marketplace.dify.ai')).toBe('https://creators.dify.ai/') + }) + + it('maps marketplace.dify.dev to creators.dify.dev', () => { + expect(getCreatorCenterUrl('https://marketplace.dify.dev')).toBe('https://creators.dify.dev/') + }) + + it('keeps the staging suffix on the Creators host', () => { + expect(getCreatorCenterUrl('https://marketplace-staging.dify.dev')).toBe( + 'https://creators-staging.dify.dev/', + ) + }) + + it('falls back to the public Creator Center for localhost', () => { + expect(getCreatorCenterUrl('http://localhost:3000')).toBe(PUBLIC_CREATOR_CENTER_URL) + }) + + it('falls back to the public Creator Center when the prefix is empty', () => { + expect(getCreatorCenterUrl('')).toBe(PUBLIC_CREATOR_CENTER_URL) + }) + + it('prefers the current Marketplace page over a stale configured prefix', () => { + expect(getCreatorCenterUrl('https://marketplace.dify.ai', 'https://marketplace.dify.dev')).toBe( + 'https://creators.dify.dev/', + ) + }) +}) + +describe('rewriteMarketplaceOriginToCreators', () => { + it('returns null for hosts that are not a Marketplace surface', () => { + expect(rewriteMarketplaceOriginToCreators('https://cloud.dify.ai')).toBeNull() + expect(rewriteMarketplaceOriginToCreators('http://localhost:3000')).toBeNull() + expect(rewriteMarketplaceOriginToCreators('')).toBeNull() + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx new file mode 100644 index 00000000000..5f20986e463 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/embedded.spec.tsx @@ -0,0 +1,154 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockFetchPluginBanners = vi.fn() + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'zh-Hans', +})) + +vi.mock('../home/banners', async (importOriginal) => { + const original = await importOriginal<typeof import('../home/banners')>() + + return { + ...original, + fetchPluginBanners: (...args: unknown[]) => mockFetchPluginBanners(...args), + } +}) + +vi.mock('../view', () => ({ + MarketplaceView: ({ + banners, + showInstallButton, + }: { + banners: PluginBanner[] + showInstallButton: boolean + }) => ( + <div> + <p>Trending banners: {banners.length}</p> + <p>{showInstallButton ? 'Install enabled' : 'Install disabled'}</p> + </div> + ), +})) + +let queryClient: QueryClient + +function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +describe('EmbeddedMarketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }) + }) + + it('loads homepage banners on the client for the active locale', async () => { + mockFetchPluginBanners.mockResolvedValue([ + { + id: 'banner-1', + title: 'Trending', + sort: 1, + language: 'zh-Hans', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[]) + + const { EmbeddedMarketplace } = await import('../embedded') + + render(<EmbeddedMarketplace showInstallButton variant="home" />, { wrapper: Wrapper }) + + expect(await screen.findByText('Trending banners: 1')).toBeInTheDocument() + expect(screen.getByText('Install enabled')).toBeInTheDocument() + expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans') + }) + + it('uses server-rendered homepage banners without requesting them again on hydration', async () => { + const initialBanners = [ + { + id: 'banner-1', + title: 'Trending', + sort: 1, + language: 'zh-Hans', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[] + + const { EmbeddedMarketplace } = await import('../embedded') + + render( + <EmbeddedMarketplace + initialBanners={initialBanners} + initialLocale="zh-Hans" + showInstallButton + variant="home" + />, + { wrapper: Wrapper }, + ) + + expect(screen.getByText('Trending banners: 1')).toBeInTheDocument() + expect(mockFetchPluginBanners).not.toHaveBeenCalled() + }) + + it('refetches banners when the client locale differs from the server-rendered locale', async () => { + const initialBanners = [ + { + id: 'banner-en', + title: 'Trending', + sort: 1, + language: 'en-US', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[] + mockFetchPluginBanners.mockResolvedValue([]) + + const { EmbeddedMarketplace } = await import('../embedded') + + render( + <EmbeddedMarketplace + initialBanners={initialBanners} + initialLocale="en-US" + showInstallButton + variant="home" + />, + { wrapper: Wrapper }, + ) + + expect(await screen.findByText('Trending banners: 0')).toBeInTheDocument() + expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans') + }) + + it('does not request homepage banners for the default catalog variant', async () => { + const { EmbeddedMarketplace } = await import('../embedded') + + render(<EmbeddedMarketplace variant="default" />, { wrapper: Wrapper }) + + expect(screen.getByText('Trending banners: 0')).toBeInTheDocument() + expect(mockFetchPluginBanners).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx index 46c770694b2..567f32f5b58 100644 --- a/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hooks.spec.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react' +import type { Plugin } from '@/app/components/plugins/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' +import { PluginCategoryEnum } from '@/app/components/plugins/types' const getMarketplacePluginsByCollectionId = vi.hoisted(() => vi.fn()) const getMarketplaceCollectionsAndPlugins = vi.hoisted(() => vi.fn()) @@ -149,3 +151,79 @@ describe('useMarketplaceCollectionsAndPlugins', () => { }) }) }) + +const createPlugin = (pluginID: string, category: PluginCategoryEnum) => + ({ + plugin_id: pluginID, + type: 'plugin', + category, + }) as Plugin + +const createInfiniteData = (plugin: Plugin, pageSize: number) => ({ + pages: [ + { + plugins: [plugin], + total: 1, + page: 1, + page_size: pageSize, + }, + ], + pageParams: [1], +}) + +const createWrapperWithQueryClient = (queryClient: QueryClient) => + function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + } + +describe('useMarketplacePlugins', () => { + it('should reset local query params without removing marketplace plugin caches', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + }, + }) + const toolPlugin = createPlugin('tool-plugin', PluginCategoryEnum.tool) + const modelPlugin = createPlugin('model-plugin', PluginCategoryEnum.model) + const toolParams = { + query: 'search', + category: PluginCategoryEnum.tool, + type: 'plugin' as const, + page_size: 40, + } + const modelParams = { + query: '', + category: PluginCategoryEnum.model, + type: 'plugin' as const, + page_size: 1000, + } + const toolQueryKey = ['marketplacePlugins', toolParams] + const modelQueryKey = ['marketplacePlugins', modelParams] + const toolQueryData = createInfiniteData(toolPlugin, toolParams.page_size) + const modelQueryData = createInfiniteData(modelPlugin, modelParams.page_size) + + queryClient.setQueryData(toolQueryKey, toolQueryData) + queryClient.setQueryData(modelQueryKey, modelQueryData) + + const { useMarketplacePlugins } = await import('../hooks') + const { result } = renderHook(() => useMarketplacePlugins(), { + wrapper: createWrapperWithQueryClient(queryClient), + }) + + act(() => { + result.current.queryPlugins(toolParams) + }) + + await waitFor(() => { + expect(result.current.plugins).toEqual([toolPlugin]) + }) + + act(() => { + result.current.resetQueryParams() + }) + + expect(result.current.plugins).toBeUndefined() + expect(queryClient.getQueryData(toolQueryKey)).toEqual(toolQueryData) + expect(queryClient.getQueryData(modelQueryKey)).toEqual(modelQueryData) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx index 50e703aae4e..80c0a37463a 100644 --- a/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/hydration-server.spec.tsx @@ -17,16 +17,21 @@ vi.mock('@/utils/var', () => ({ const mockCollections = vi.fn() const mockCollectionPlugins = vi.fn() +const mockSearchAdvanced = vi.fn() vi.mock('@/service/client', () => ({ marketplaceClient: { collections: (...args: unknown[]) => mockCollections(...args), collectionPlugins: (...args: unknown[]) => mockCollectionPlugins(...args), + searchAdvanced: (...args: unknown[]) => mockSearchAdvanced(...args), }, marketplaceQuery: { collections: { queryKey: (params: unknown) => ['marketplace', 'collections', params], }, + searchAdvanced: { + queryKey: (params: unknown) => ['marketplace', 'searchAdvanced', params], + }, }, })) @@ -50,6 +55,9 @@ describe('HydrateQueryClient', () => { mockCollectionPlugins.mockResolvedValue({ data: { plugins: [] }, }) + mockSearchAdvanced.mockResolvedValue({ + data: { plugins: [], total: 0 }, + }) }) it('should render children within HydrationBoundary', async () => { @@ -104,7 +112,7 @@ describe('HydrateQueryClient', () => { expect(state.queries[0]?.queryKey).toEqual([ 'marketplace', 'collections', - { input: { query: {} } }, + { input: { query: { limit: 20 } } }, ]) }) @@ -119,7 +127,28 @@ describe('HydrateQueryClient', () => { expect(mockCollections).toHaveBeenCalled() }) - it('should not prefetch when category does not have collections (model)', async () => { + it('should prefetch plugin search when q is present', async () => { + const { HydrateQueryClient } = await import('../hydration-server') + + await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'all', q: 'openai' }), + children: <div>Child</div>, + }) + + expect(mockCollections).not.toHaveBeenCalled() + expect(mockSearchAdvanced).toHaveBeenCalledWith( + expect.objectContaining({ + params: { kind: 'plugins' }, + body: expect.objectContaining({ + page: 1, + query: 'openai', + }), + }), + expect.any(Object), + ) + }) + + it('should prefetch when category does not have collections (model)', async () => { const { HydrateQueryClient } = await import('../hydration-server') await HydrateQueryClient({ @@ -128,9 +157,10 @@ describe('HydrateQueryClient', () => { }) expect(mockCollections).not.toHaveBeenCalled() + expect(mockSearchAdvanced).toHaveBeenCalled() }) - it('should not prefetch when category does not have collections (bundle)', async () => { + it('should prefetch when category does not have collections (bundle)', async () => { const { HydrateQueryClient } = await import('../hydration-server') await HydrateQueryClient({ @@ -139,5 +169,42 @@ describe('HydrateQueryClient', () => { }) expect(mockCollections).not.toHaveBeenCalled() + expect(mockSearchAdvanced).toHaveBeenCalled() + }) + + it('should keep the catalog shell when collections prefetch fails', async () => { + mockCollections.mockRejectedValue(new Error('collections unavailable')) + const { HydrateQueryClient } = await import('../hydration-server') + + const element = await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'all' }), + children: <div>Child</div>, + }) + + const renderClient = new QueryClient() + const { getByText } = render( + <QueryClientProvider client={renderClient}> + {element as React.ReactElement} + </QueryClientProvider>, + ) + expect(getByText('Child')).toBeInTheDocument() + }) + + it('should keep the catalog shell when plugin search prefetch fails', async () => { + mockSearchAdvanced.mockRejectedValue(new Error('search unavailable')) + const { HydrateQueryClient } = await import('../hydration-server') + + const element = await HydrateQueryClient({ + searchParams: Promise.resolve({ category: 'all', q: 'openai' }), + children: <div>Child</div>, + }) + + const renderClient = new QueryClient() + const { getByText } = render( + <QueryClientProvider client={renderClient}> + {element as React.ReactElement} + </QueryClientProvider>, + ) + expect(getByText('Child')).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx index 8b78b7bba3c..32671514e43 100644 --- a/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/plugin-type-switch.spec.tsx @@ -1,10 +1,11 @@ -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Provider as JotaiProvider } from 'jotai' import { describe, expect, it, vi } from 'vite-plus/test' import { createNuqsTestWrapper } from '@/test/nuqs-testing' import PluginTypeSwitch from '../plugin-type-switch' +import styles from '../plugin-type-switch.module.css' vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') @@ -13,7 +14,7 @@ vi.mock('#i18n', async () => { } }) -const renderSwitch = (searchParams = '') => { +const renderSwitch = (searchParams = '', props?: ComponentProps<typeof PluginTypeSwitch>) => { const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) const Wrapper = ({ children }: { children: ReactNode }) => ( <JotaiProvider> @@ -21,7 +22,7 @@ const renderSwitch = (searchParams = '') => { </JotaiProvider> ) - return { ...render(<PluginTypeSwitch />, { wrapper: Wrapper }), onUrlUpdate } + return { ...render(<PluginTypeSwitch {...props} />, { wrapper: Wrapper }), onUrlUpdate } } describe('PluginTypeSwitch', () => { @@ -41,7 +42,7 @@ describe('PluginTypeSwitch', () => { expect(screen.getByRole('button', { name: 'category.agents' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'category.triggers' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'category.extensions' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'category.bundles' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'category.bundles' })).not.toBeInTheDocument() }) it('updates the category in the URL when selected', async () => { @@ -56,4 +57,28 @@ describe('PluginTypeSwitch', () => { expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe('model') expect(modelsButton).toHaveAttribute('aria-pressed', 'true') }) + + it('exposes the selected category and updates the URL in the home variant', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderSwitch('?category=all', { variant: 'home' }) + const categoryGroup = screen.getByRole('group', { name: 'allCategories' }) + + expect(categoryGroup).toHaveClass('w-full', 'justify-start', 'gap-1') + const activeCategory = screen.getByRole('button', { name: 'category.all' }) + const inactiveCategory = screen.getByRole('button', { name: 'category.models' }) + + expect(activeCategory).toHaveAttribute('aria-pressed', 'true') + expect(activeCategory).toHaveClass(styles.homeItem!, styles.homeItemActive!) + expect(inactiveCategory).toHaveClass(styles.homeItem!) + expect(inactiveCategory).not.toHaveClass(styles.homeItemActive!) + expect(screen.getByRole('button', { name: 'categorySingle.datasource' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'categorySingle.agent' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'category.models' })) + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + const update = onUrlUpdate.mock.calls.at(-1)?.[0] + expect(update?.searchParams.get('category')).toBe('model') + expect(update?.options.scroll).toBe(false) + }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx index ec93fe23bde..9cf84fed0dc 100644 --- a/web/app/components/plugins/marketplace/__tests__/query.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/query.spec.tsx @@ -163,7 +163,7 @@ describe('useMarketplacePlugins', () => { }) }) - it('should handle API error gracefully', async () => { + it('should surface API errors instead of an empty success', async () => { mockSearchAdvanced.mockRejectedValue(new Error('Network error')) const { useMarketplacePlugins } = await import('../query') @@ -177,11 +177,14 @@ describe('useMarketplacePlugins', () => { ) await waitFor(() => { - expect(result.current.data).toBeDefined() + expect(result.current.isError).toBe(true) }) - expect(result.current.data?.pages[0]!.plugins).toEqual([]) - expect(result.current.data?.pages[0]!.total).toBe(0) + // No synthesized page: an empty success let a backend outage render as + // "no plugins found", suppressed retries, and permanently disabled + // getNextPageParam for this key. + expect(result.current.data).toBeUndefined() + expect(result.current.error).toEqual(new Error('Network error')) }) it('should determine next page correctly via getNextPageParam', async () => { diff --git a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts index 62a786e5be1..7f0654e35f0 100644 --- a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts @@ -9,6 +9,7 @@ describe('marketplace search params', () => { ) expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('') expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([]) + expect(marketplaceSearchParamsParsers.languages.parseServerSide(undefined)).toEqual([]) }) it('parses supported query values with the configured parsers', () => { @@ -23,5 +24,9 @@ describe('marketplace search params', () => { 'rag', 'search', ]) + expect(marketplaceSearchParamsParsers.languages.parseServerSide('en,zh-Hans')).toEqual([ + 'en', + 'zh-Hans', + ]) }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx new file mode 100644 index 00000000000..cbb2ed377f1 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx @@ -0,0 +1,95 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetchPluginBanners, mockGetLocaleOnServer } = vi.hoisted(() => ({ + mockFetchPluginBanners: vi.fn(), + mockGetLocaleOnServer: vi.fn(), +})) + +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: mockGetLocaleOnServer, +})) + +vi.mock('../home/banners', async (importOriginal) => { + const original = await importOriginal<typeof import('../home/banners')>() + + return { + ...original, + fetchPluginBanners: mockFetchPluginBanners, + } +}) + +vi.mock('../hydration-server', () => ({ + HydrateQueryClient: ({ children }: { children: ReactNode }) => children, +})) + +vi.mock('../prefetch-marketplace-dehydrated-state', () => ({ + prefetchMarketplaceDehydratedState: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../view', () => ({ + MarketplaceView: ({ banners }: { banners: PluginBanner[] }) => ( + <p>Server banners: {banners.length}</p> + ), +})) + +describe('Marketplace server entry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('prefetches localized homepage banners before rendering the standalone view', async () => { + mockGetLocaleOnServer.mockResolvedValue('en-US') + mockFetchPluginBanners.mockResolvedValue([ + { + id: 'banner-1', + title: 'Trending', + sort: 1, + language: 'en-US', + style_type: 'blog', + content: { + blog_title: 'Dify update', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + }, + ] satisfies PluginBanner[]) + + const { default: Marketplace } = await import('../index') + const element = await Marketplace({ variant: 'home' }) + + render(<QueryClientProvider client={new QueryClient()}>{element}</QueryClientProvider>) + + expect(screen.getByText('Server banners: 1')).toBeInTheDocument() + expect(mockGetLocaleOnServer).toHaveBeenCalledOnce() + expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US') + }) + + it('starts catalog prefetch without waiting for banners to finish', async () => { + const { prefetchMarketplaceDehydratedState } = + await import('../prefetch-marketplace-dehydrated-state') + let resolveBanners: (banners: PluginBanner[]) => void = () => {} + mockGetLocaleOnServer.mockResolvedValue('en-US') + mockFetchPluginBanners.mockImplementation( + () => + new Promise<PluginBanner[]>((resolve) => { + resolveBanners = resolve + }), + ) + vi.mocked(prefetchMarketplaceDehydratedState).mockResolvedValue(undefined) + + const { default: Marketplace } = await import('../index') + const renderPromise = Marketplace({ variant: 'home', searchParams: Promise.resolve({}) }) + + await vi.waitFor(() => { + expect(prefetchMarketplaceDehydratedState).toHaveBeenCalled() + }) + expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US') + + resolveBanners([]) + await renderPromise + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx index 03fd80dd333..a223d6524b0 100644 --- a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { Provider as JotaiProvider } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { createNuqsTestWrapper } from '@/test/nuqs-testing' +import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' vi.mock('@/config', () => ({ API_PREFIX: '/api', @@ -116,6 +117,7 @@ describe('useMarketplaceData', () => { expect(result.current.plugins).toBeDefined() expect(result.current.pluginsTotal).toBeDefined() + expect(mockCollections).not.toHaveBeenCalled() document.body.removeChild(container) }) @@ -161,6 +163,35 @@ describe('useMarketplaceData', () => { document.body.removeChild(container) }) + it('should use the server route category for hydrated standalone search', async () => { + const { useMarketplaceData } = await import('../state') + const { Wrapper } = createWrapper('?q=openai') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook(() => useMarketplaceData(PLUGIN_TYPE_SEARCH_MAP.model), { + wrapper: Wrapper, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(mockSearchAdvanced).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + category: 'model', + query: 'openai', + }), + }), + expect.any(Object), + ) + + document.body.removeChild(container) + }) + it('should trigger scroll pagination via handlePageChange callback', async () => { // Return enough data to indicate hasNextPage (40 of 200 total) mockSearchAdvanced.mockResolvedValue({ @@ -287,4 +318,53 @@ describe('useMarketplaceData', () => { document.body.removeChild(container) }) + + // Regression: `isSearchMode` was derived from the raw URL value while the + // request body used the 500ms-debounced one. Keystroke #1 therefore flipped + // the hook into search mode with an empty query, firing a full search for '' + // whose generic top-plugins results rendered until the real ones replaced + // them — the wrong-results flash at the start of every search session. + it('should never issue an empty-query search when typing starts', async () => { + const { useMarketplaceData } = await import('../state') + const { useSearchPluginText } = await import('../atoms') + const { Wrapper } = createWrapper('?category=all') + + const container = document.createElement('div') + container.id = 'marketplace-container' + document.body.appendChild(container) + + const { result } = renderHook( + () => ({ + data: useMarketplaceData(), + setSearch: useSearchPluginText()[1], + }), + { wrapper: Wrapper }, + ) + + await waitFor(() => { + expect(result.current.data.isLoading).toBe(false) + }) + + await act(async () => { + await result.current.setSearch('openai') + }) + + await waitFor( + () => { + expect(mockSearchAdvanced).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) + + expect(mockSearchAdvanced).not.toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ query: '' }) }), + expect.anything(), + ) + expect(mockSearchAdvanced).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ query: 'openai' }) }), + expect.anything(), + ) + + document.body.removeChild(container) + }) }) diff --git a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts index ec9e0b66772..a51a208fc96 100644 --- a/web/app/components/plugins/marketplace/__tests__/utils.spec.ts +++ b/web/app/components/plugins/marketplace/__tests__/utils.spec.ts @@ -138,6 +138,34 @@ describe('getPluginDetailLinkInMarketplace', () => { }) }) +describe('getTemplateDetailLinkInMarketplace', () => { + it('should return the local template detail link', async () => { + const { getTemplateDetailLinkInMarketplace } = await import('../utils') + + expect( + getTemplateDetailLinkInMarketplace({ + id: 'template-1', + template_name: 'Legal Research Agent', + publisher_handle: 'dify', + publisher_unique_handle: 'dify-unique', + }), + ).toBe('/template/dify/Legal%20Research%20Agent?templateId=template-1') + }) + + it('should fall back to the unique publisher handle', async () => { + const { getTemplateDetailLinkInMarketplace } = await import('../utils') + + expect( + getTemplateDetailLinkInMarketplace({ + id: 'template-2', + template_name: 'Inbox', + publisher_handle: '', + publisher_unique_handle: 'langgenius', + }), + ).toBe('/template/langgenius/Inbox?templateId=template-2') + }) +}) + describe('getMarketplaceListCondition', () => { it('should return category condition for tool', async () => { const { getMarketplaceListCondition } = await import('../utils') @@ -229,21 +257,23 @@ describe('getMarketplacePluginsByCollectionId', () => { expect(result).toHaveLength(2) }) - it('should handle fetch error and return empty array', async () => { + it('should propagate fetch errors', async () => { mockCollectionPlugins.mockRejectedValueOnce(new Error('Network error')) const { getMarketplacePluginsByCollectionId } = await import('../utils') - const result = await getMarketplacePluginsByCollectionId('test-collection') - expect(result).toEqual([]) + await expect(getMarketplacePluginsByCollectionId('test-collection')).rejects.toThrow( + 'Network error', + ) }) - it('should send an empty body when query is omitted', async () => { + it('should send the warmed preview limit when query is omitted', async () => { mockCollectionPlugins.mockResolvedValueOnce({ data: { plugins: [] }, }) - const { getMarketplacePluginsByCollectionId } = await import('../utils') + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplacePluginsByCollectionId } = + await import('../utils') await getMarketplacePluginsByCollectionId('test-collection') expect(mockCollectionPlugins).toHaveBeenCalledWith( @@ -251,7 +281,7 @@ describe('getMarketplacePluginsByCollectionId', () => { params: { collectionId: 'test-collection', }, - body: {}, + body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT }, }, expect.objectContaining({ signal: undefined, @@ -289,7 +319,8 @@ describe('getMarketplaceCollectionsAndPlugins', () => { mockCollections.mockResolvedValueOnce({ data: { collections: mockCollectionData } }) mockCollectionPlugins.mockResolvedValue({ data: { plugins: mockPluginData } }) - const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } = + await import('../utils') const result = await getMarketplaceCollectionsAndPlugins({ condition: 'category=tool', type: 'plugin', @@ -297,16 +328,104 @@ describe('getMarketplaceCollectionsAndPlugins', () => { expect(result.marketplaceCollections).toBeDefined() expect(result.marketplaceCollectionPluginsMap).toBeDefined() + expect(mockCollectionPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + params: { collectionId: 'collection1' }, + body: { + condition: 'category=tool', + type: 'plugin', + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, + }, + }), + expect.any(Object), + ) }) - it('should handle fetch error and return empty data', async () => { + it('posts the warmed preview limit when the catalog has no extra filters', async () => { + mockCollections.mockResolvedValueOnce({ + data: { + collections: [ + { + name: 'featured', + label: {}, + description: {}, + rule: '', + created_at: '', + updated_at: '', + }, + ], + }, + }) + mockCollectionPlugins.mockResolvedValue({ data: { plugins: [] } }) + + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getMarketplaceCollectionsAndPlugins } = + await import('../utils') + await getMarketplaceCollectionsAndPlugins() + + expect(mockCollectionPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + params: { collectionId: 'featured' }, + body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT }, + }), + expect.any(Object), + ) + }) + + it('should propagate a failing collections request', async () => { mockCollections.mockRejectedValueOnce(new Error('Network error')) + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + + // Resolving an empty catalog here made a backend outage indistinguishable + // from "no collections", cached as a success for the whole staleTime. + await expect(getMarketplaceCollectionsAndPlugins()).rejects.toThrow('Network error') + }) + + it('should keep the catalog when a single collection fails', async () => { + mockCollections.mockResolvedValueOnce({ + data: { + collections: [ + { name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + { name: 'broken', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ], + }, + }) + mockCollectionPlugins + .mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } }) + .mockRejectedValueOnce(new Error('collection down')) + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') const result = await getMarketplaceCollectionsAndPlugins() - expect(result.marketplaceCollections).toEqual([]) - expect(result.marketplaceCollectionPluginsMap).toEqual({}) + expect(result.marketplaceCollections).toHaveLength(2) + expect(result.marketplaceCollectionPluginsMap.ok).toHaveLength(1) + expect(result.marketplaceCollectionPluginsMap.broken).toEqual([]) + }) + + it('propagates cancellation instead of resolving empty carousels', async () => { + const controller = new AbortController() + mockCollections.mockResolvedValueOnce({ + data: { + collections: [ + { name: 'ok', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + { name: 'slow', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, + ], + }, + }) + mockCollectionPlugins + .mockResolvedValueOnce({ data: { plugins: [{ type: 'plugin', org: 'a', name: 'b' }] } }) + .mockImplementationOnce(async () => { + controller.abort() + const error = new Error('Aborted') + error.name = 'AbortError' + throw error + }) + + const { getMarketplaceCollectionsAndPlugins } = await import('../utils') + + await expect( + getMarketplaceCollectionsAndPlugins({}, { signal: controller.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }) }) it('should append condition and type to URL when provided', async () => { @@ -327,22 +446,74 @@ describe('getMarketplaceCollectionsAndPlugins', () => { }) describe('getCollectionsParams', () => { - it('should return empty object for all category', async () => { - const { getCollectionsParams } = await import('../utils') - expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({}) + it('should return the warmed preview limit for all category', async () => { + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils') + expect(getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.all)).toEqual({ + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, + }) + expect(COLLECTION_PREVIEW_PLUGIN_LIMIT).toBe(20) }) - it('should return category, condition, and type for tool category', async () => { - const { getCollectionsParams } = await import('../utils') + it('should return category, condition, type, and preview limit for tool category', async () => { + const { COLLECTION_PREVIEW_PLUGIN_LIMIT, getCollectionsParams } = await import('../utils') const result = getCollectionsParams(PLUGIN_TYPE_SEARCH_MAP.tool) expect(result).toEqual({ category: PluginCategoryEnum.tool, condition: 'category=tool', type: 'plugin', + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, }) }) }) +describe('toListPlugin', () => { + it('keeps card fields and drops list-unused payload', async () => { + const { toListPlugin } = await import('../utils') + const plugin = { + ...createMockPlugin({ + introduction: 'A very long readme that must not enter the catalog RSC payload', + }), + resource: { memory: 256 }, + plugins: { tools: ['x'] }, + tool: { identity: { name: 'search' } }, + model: { provider: 'openai' }, + agent_strategy: { features: ['a'] }, + data_sources: { items: [] }, + triggers: { events: [] }, + privacy_policy: 'https://example.com/privacy', + privacy_options: 'all', + readme_meta: { available_languages: ['en_US'] }, + endpoint: { settings: [{ name: 'api_key' }] }, + } as unknown as Plugin + + const listed = toListPlugin(plugin) + + expect(listed.org).toBe('test-org') + expect(listed.name).toBe('test-plugin') + expect(listed.plugin_id).toBe('plugin-1') + expect(listed.label).toEqual({ 'en-US': 'Test Plugin' }) + expect(listed.brief).toEqual({ 'en-US': 'Test plugin brief' }) + expect(listed.badges).toEqual([]) + expect(listed.verification).toEqual({ authorized_category: 'community' }) + expect(listed.install_count).toBe(1000) + expect(listed.category).toBe(PluginCategoryEnum.tool) + expect(listed.tags).toEqual([{ name: 'search' }]) + expect(listed.type).toBe('plugin') + expect(listed.introduction).toBe('') + expect(listed.endpoint).toEqual({ settings: [] }) + expect(listed).not.toHaveProperty('resource') + expect(listed).not.toHaveProperty('plugins') + expect(listed).not.toHaveProperty('tool') + expect(listed).not.toHaveProperty('model') + expect(listed).not.toHaveProperty('agent_strategy') + expect(listed).not.toHaveProperty('data_sources') + expect(listed).not.toHaveProperty('triggers') + expect(listed).not.toHaveProperty('privacy_policy') + expect(listed).not.toHaveProperty('privacy_options') + expect(listed).not.toHaveProperty('readme_meta') + }) +}) + describe('getMarketplacePlugins', () => { beforeEach(() => { vi.clearAllMocks() @@ -431,23 +602,15 @@ describe('getMarketplacePlugins', () => { expect(call![0].body.category).toBe('') }) - it('should handle API error and return empty result', async () => { + it('should propagate API errors instead of synthesizing an empty page', async () => { mockSearchAdvanced.mockRejectedValueOnce(new Error('API error')) const { getMarketplacePlugins } = await import('../utils') - const result = await getMarketplacePlugins( - { - query: 'fail', - }, - 2, - ) - expect(result).toEqual({ - plugins: [], - total: 0, - page: 2, - page_size: 40, - }) + // A synthesized `{ plugins: [], total: 0 }` resolved as a *success*: no + // isError, no retry, a cached empty result, and getNextPageParam saw + // total 0 and killed pagination for that key permanently. + await expect(getMarketplacePlugins({ query: 'fail' }, 2)).rejects.toThrow('API error') }) it('should pass abort signal when provided', async () => { diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index a2118997a96..c01990d548a 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,9 +1,10 @@ import type { PluginsSort, SearchParamsFromCollection } from '@dify/contracts/marketplace' +import type { ActivePluginType } from './constants' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { useQueryState } from 'nuqs' -import { useCallback } from 'react' -import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { marketplaceSearchParamsParsers } from './search-params' +import { useCallback, useEffect } from 'react' +import { DEFAULT_SORT } from './constants' +import { marketplaceSearchParamsParsers, shouldSearchMarketplacePlugins } from './search-params' const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT) export function useMarketplaceSort() { @@ -21,6 +22,9 @@ export function useActivePluginType() { export function useFilterPluginTags() { return useQueryState('tags', marketplaceSearchParamsParsers.tags) } +export function useFilterTemplateLanguages() { + return useQueryState('languages', marketplaceSearchParamsParsers.languages) +} /** * Not all categories have collections, so we need to @@ -28,19 +32,48 @@ export function useFilterPluginTags() { */ export const searchModeAtom = atom<true | null>(null) -export function useMarketplaceSearchMode() { - const [searchPluginText] = useSearchPluginText() +export function useMarketplaceSearchMode( + activePluginTypeOverride?: ActivePluginType, + // Callers that debounce the query text MUST pass the debounced value here. + // Deciding "are we searching?" from the raw URL value while the request body + // carries the debounced one flips this hook true on keystroke #1, firing a + // wasted empty-query search whose generic top-plugins list renders for the + // debounce window before the real results replace it. '' is a meaningful + // override, so this is `??`, not `||`. + searchPluginTextOverride?: string, +) { + const [searchPluginTextFromUrl] = useSearchPluginText() + const searchPluginText = searchPluginTextOverride ?? searchPluginTextFromUrl const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() + const [activePluginTypeFromUrl] = useActivePluginType() + const activePluginType = activePluginTypeOverride ?? activePluginTypeFromUrl const searchMode = useAtomValue(searchModeAtom) const isSearchMode = - !!searchPluginText || - filterPluginTags.length > 0 || - (searchMode ?? !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)) + searchMode === true || + shouldSearchMarketplacePlugins({ + category: activePluginType, + q: searchPluginText, + tags: filterPluginTags, + }) return isSearchMode } +/** + * The forced search mode lives in the app-wide Jotai store, so a "View More" + * click would otherwise leak into the next visit of the plugin catalog after + * navigating away (e.g. to /templates) and back, rendering empty-query search + * results instead of the prefetched collections. Reset it when the catalog + * route mounts; URL-owned state (q, tags, category) is not affected. + */ +export function useResetMarketplaceSearchModeOnMount() { + const setSearchMode = useSetAtom(searchModeAtom) + + useEffect(() => { + setSearchMode(null) + }, [setSearchMode]) +} + export function useMarketplaceMoreClick() { const [, setQ] = useSearchPluginText() const setSort = useSetAtom(marketplaceSortAtom) diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 5db8045a547..9dda37bd3dc 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -5,6 +5,12 @@ export const DEFAULT_SORT = { sortOrder: 'DESC', } +/** + * DOM id of the marketplace scroll container. The route components render it + * and the scroll/viewport observers below the marketplace tree look it up. + */ +export const MARKETPLACE_CONTAINER_ID = 'marketplace-container' + export const SCROLL_BOTTOM_THRESHOLD = 100 export const PLUGIN_TYPE_SEARCH_MAP = { diff --git a/web/app/components/plugins/marketplace/creator-center-url.ts b/web/app/components/plugins/marketplace/creator-center-url.ts new file mode 100644 index 00000000000..33f2d3d5566 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-center-url.ts @@ -0,0 +1,48 @@ +import { useSyncExternalStore } from 'react' + +export const PUBLIC_CREATOR_CENTER_URL = 'https://creators.dify.ai/' + +const subscribe = () => () => {} + +/** + * marketplace.dify.ai → creators.dify.ai + * marketplace.dify.dev → creators.dify.dev + * marketplace-staging.dify.dev → creators-staging.dify.dev + */ +export const rewriteMarketplaceOriginToCreators = (origin: string): string | null => { + if (!origin) return null + + try { + const marketplaceUrl = new URL(origin) + const [service, ...domain] = marketplaceUrl.hostname.split('.') + if (!service?.startsWith('marketplace') || domain.length === 0) return null + + marketplaceUrl.hostname = [service.replace(/^marketplace/, 'creators'), ...domain].join('.') + marketplaceUrl.pathname = '/' + marketplaceUrl.search = '' + marketplaceUrl.hash = '' + return marketplaceUrl.toString() + } catch { + return null + } +} + +export const getCreatorCenterUrl = (marketplaceUrlPrefix: string, pageOrigin?: string): string => { + return ( + rewriteMarketplaceOriginToCreators(pageOrigin ?? '') || + rewriteMarketplaceOriginToCreators(marketplaceUrlPrefix) || + PUBLIC_CREATOR_CENTER_URL + ) +} + +/** + * Prefer the current page origin when this is the standalone Marketplace, so a + * .dev deployment cannot inherit a baked-in .ai Creator Center URL. + */ +export const useCreatorCenterUrl = (marketplaceUrlPrefix: string) => { + return useSyncExternalStore( + subscribe, + () => getCreatorCenterUrl(marketplaceUrlPrefix, window.location.origin), + () => getCreatorCenterUrl(marketplaceUrlPrefix), + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx new file mode 100644 index 00000000000..e81cb64ffa3 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creation-card.spec.tsx @@ -0,0 +1,50 @@ +import type { CreatorCreation } from '../model' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import CreationCard from '../creation-card' + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <span data-testid="creation-icon" />, +})) + +const creation: CreatorCreation = { + id: 'plugin:dify/search', + kind: 'plugin', + title: 'Search', + description: 'Search the web.', + target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: 'search' }, + icon: { type: 'emoji', value: '🔎' }, + dependencyIcons: ['/one.png', '/two.png'], + dependencyCount: 4, + updatedAt: 1, + createdAt: 1, + popularity: 1, +} + +describe('CreationCard', () => { + it('renders a host link without selecting', () => { + render( + <CreationCard + creation={creation} + action={{ type: 'link', href: '/plugin/dify/search?language=en-US' }} + />, + ) + + expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute( + 'href', + '/plugin/dify/search?language=en-US', + ) + expect(screen.getByText('+2')).toBeInTheDocument() + }) + + it('selects in Dify without rendering a navigation target', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + render(<CreationCard creation={creation} action={{ type: 'select', onSelect }} />) + + await user.click(screen.getByRole('button', { name: 'Search' })) + expect(onSelect).toHaveBeenCalledOnce() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx new file mode 100644 index 00000000000..b34d152ec5b --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-content.spec.tsx @@ -0,0 +1,163 @@ +import type { CreatorCreation } from '../model' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { renderWithNuqs } from '@/test/nuqs-testing' +import CreatorContent from '../creator-content' + +const publisherMocks = vi.hoisted(() => ({ + fetchPublisherPluginPage: vi.fn(), + fetchPublisherTemplatePage: vi.fn(), +})) + +vi.mock('../publisher', async (importOriginal) => { + const actual = await importOriginal<typeof import('../publisher')>() + return { + ...actual, + fetchPublisherPluginPage: publisherMocks.fetchPublisherPluginPage, + fetchPublisherTemplatePage: publisherMocks.fetchPublisherTemplatePage, + } +}) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.creatorProfile.creations': 'Creations', + 'marketplace.creatorProfile.sortBy': 'Sort by', + 'marketplace.creatorProfile.sort.updatedAt': 'Recently updated', + 'marketplace.creatorProfile.sort.createdAt': 'Recently created', + 'marketplace.creatorProfile.sort.popularity': 'Most popular', + 'marketplace.creatorProfile.sort.asc': 'Sort ascending', + 'marketplace.creatorProfile.sort.desc': 'Sort descending', + 'marketplace.creatorProfile.type.plugin': 'Plugin', + 'marketplace.creatorProfile.type.template': 'Template', + 'marketplace.creatorProfile.loadMore': 'Load more', + 'marketplace.creatorProfile.loadMoreFailed': "Couldn't load more creations.", + } + + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => translations[key] ?? key), + }), + } +}) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <span aria-hidden />, +})) + +const createCreation = ( + id: string, + title: string, + updatedAt: number, + createdAt: number, + popularity: number, +): CreatorCreation => ({ + id, + kind: 'plugin', + title, + description: `${title} description`, + target: { type: 'plugin', pluginType: 'plugin', org: 'dify', name: id }, + icon: { type: 'emoji', value: 'P' }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt, + createdAt, + popularity, +}) + +const creations = [ + createCreation('alpha', 'Alpha', 2, 3, 1), + createCreation('bravo', 'Bravo', 3, 1, 2), + createCreation('charlie', 'Charlie', 1, 2, 3), +] + +const cardNames = () => screen.getAllByRole('link').map((link) => link.getAttribute('aria-label')) + +describe('CreatorContent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes sort into the URL and reorders the current cards', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs( + <CreatorContent + creations={creations} + getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })} + />, + ) + + expect(cardNames()).toEqual(['Bravo', 'Alpha', 'Charlie']) + + await user.click(screen.getByRole('button', { name: 'Sort by Recently updated' })) + const recentlyUpdatedOption = screen.getByRole('menuitemradio', { + name: 'Recently updated', + }) + const mostPopularOption = screen.getByRole('menuitemradio', { name: 'Most popular' }) + expect(recentlyUpdatedOption).toHaveAttribute('aria-checked', 'true') + expect(mostPopularOption).toHaveAttribute('aria-checked', 'false') + + await user.click(mostPopularOption) + await waitFor(() => { + expect(cardNames()).toEqual(['Charlie', 'Bravo', 'Alpha']) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity') + }) + + await user.click(screen.getByRole('button', { name: 'Sort ascending' })) + await waitFor(() => { + expect(cardNames()).toEqual(['Alpha', 'Bravo', 'Charlie']) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_order')).toBe('asc') + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity') + }) + }) + + it('loads the next publisher pages when more creations exist', async () => { + const user = userEvent.setup() + publisherMocks.fetchPublisherPluginPage.mockResolvedValue({ + items: [ + { + type: 'plugin', + org: 'dify', + name: 'delta', + labels: { 'en-US': 'Delta' }, + brief: { 'en-US': 'Delta plugin' }, + install_count: 4, + created_at: '2026-01-04T00:00:00Z', + updated_at: '2026-02-04T00:00:00Z', + }, + ], + hasMore: false, + }) + publisherMocks.fetchPublisherTemplatePage.mockResolvedValue({ items: [], hasMore: false }) + + renderWithNuqs( + <CreatorContent + creations={creations} + locale="en-US" + inventory={{ + uniqueHandle: 'scarlettmao', + pluginHasMore: true, + templateHasMore: false, + pluginNextPage: 2, + templateNextPage: 2, + }} + getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })} + />, + ) + + await user.click(screen.getByRole('button', { name: 'Load more' })) + + await waitFor(() => { + expect(cardNames()).toContain('Delta') + }) + expect(publisherMocks.fetchPublisherPluginPage).toHaveBeenCalledWith({ + uniqueHandle: 'scarlettmao', + page: 2, + sortField: 'updatedAt', + sortOrder: 'desc', + }) + expect(publisherMocks.fetchPublisherTemplatePage).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx new file mode 100644 index 00000000000..d2d7327502e --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/creator-sidebar.spec.tsx @@ -0,0 +1,83 @@ +import type { CreatorProfileViewModel } from '../model' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CreatorSidebar from '../creator-sidebar' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('../publisher-avatar', () => ({ + default: ({ className, size }: { className?: string; size?: number }) => ( + <div data-testid="publisher-avatar" data-size={size} className={className} /> + ), +})) + +const profile: CreatorProfileViewModel['profile'] = { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '', + backgroundUrl: '', + badges: [], + socialLinks: [ + { platform: 'website', href: 'https://example.com/', label: 'example.com' }, + { platform: 'x', href: 'https://x.com/creator', label: 'x.com/creator' }, + { + platform: 'instagram', + href: 'https://instagram.com/creator', + label: 'instagram.com/creator', + }, + { + platform: 'youtube', + href: 'https://youtube.com/creator', + label: 'youtube.com/creator', + }, + { platform: 'figma', href: 'https://figma.com/@creator', label: 'figma.com/@creator' }, + { platform: 'github', href: 'https://github.com/creator', label: 'github.com/creator' }, + ], +} + +describe('CreatorSidebar social links', () => { + it('adds a light shadow without changing the avatar geometry', () => { + render(<CreatorSidebar profile={profile} />) + + const avatar = screen.getByTestId('publisher-avatar') + + expect(avatar).toHaveClass('shadow-xs') + expect(avatar).toHaveClass( + 'absolute', + '-top-12', + '-left-2', + '!size-20', + 'border-[1.5px]', + 'md:-top-[68px]', + 'md:!size-[100px]', + ) + expect(avatar).toHaveAttribute('data-size', '100') + }) + + it('renders a static platform icon at the start of every social row', () => { + render(<CreatorSidebar profile={profile} />) + + const expectedClasses = [ + ['example.com', 'i-ri-global-line'], + ['x.com/creator', 'i-ri-twitter-x-fill'], + ['instagram.com/creator', 'i-ri-instagram-line'], + ['youtube.com/creator', 'i-ri-youtube-fill'], + ['figma.com/@creator', 'i-ri-figma-line'], + ['github.com/creator', 'i-ri-github-fill'], + ] + + for (const [name, iconClass] of expectedClasses) { + const link = screen.getByRole('link', { name }) + expect(link.firstElementChild).toHaveClass(iconClass!) + expect(link.firstElementChild).toHaveClass('size-4') + } + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts new file mode 100644 index 00000000000..8aac42bfb92 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/data.server.spec.ts @@ -0,0 +1,276 @@ +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { loadCreatorProfile } from '../data.server' + +const mocks = vi.hoisted(() => ({ + creatorDetail: vi.fn(), + organizationDetail: vi.fn(), + publisherPlugins: vi.fn(), + publisherTemplates: vi.fn(), +})) + +vi.mock('server-only', () => ({})) +vi.mock('@/config', () => ({ MARKETPLACE_API_PREFIX: 'https://marketplace.example/api/v1' })) +vi.mock('@/service/client', () => ({ marketplaceClient: mocks })) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'search', + plugin_id: 'dify/search', + label: { en_US: 'Search' }, + brief: { en_US: 'Search the web.' }, + tags: [], +} as unknown as MarketplacePlugin + +const template = { + id: 'template-one', + template_name: 'Template one', + overview: 'Build an app.', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + usage_count: 1, + categories: [], +} as MarketplaceTemplate + +describe('loadCreatorProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.creatorDetail.mockResolvedValue({ + data: { + creator: { + unique_handle: 'creator', + display_name: 'Creator', + social_links: [], + }, + }, + }) + mocks.organizationDetail.mockResolvedValue({ data: {} }) + mocks.publisherPlugins.mockResolvedValue({ data: { plugins: [plugin] } }) + mocks.publisherTemplates.mockResolvedValue({ data: { templates: [template] } }) + }) + + it('loads individual data through all publisher contracts', async () => { + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-one', + locale: 'en-US', + }) + + expect(mocks.creatorDetail).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + }) + expect(mocks.publisherPlugins).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' }, + }) + expect(loaded?.viewModel.creations).toHaveLength(2) + expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined() + expect(loaded?.viewModel.profile.backgroundUrl).toBe('') + expect(loaded?.viewModel.profile.avatarUrl).toBe('') + }) + + it('only emits the remote background URL when the API reports an uploaded background', async () => { + mocks.creatorDetail.mockResolvedValue({ + data: { + creator: { + unique_handle: 'creator-with-background', + display_name: 'Creator with background', + background_image: 'creator/background.png', + social_links: [], + }, + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-with-background', + locale: 'en-US', + }) + + expect(loaded?.viewModel.profile.backgroundUrl).toBe( + 'https://marketplace.example/api/v1/creators/creator-with-background/background-image', + ) + }) + + it('only emits the remote avatar URL when the API reports an uploaded avatar', async () => { + mocks.creatorDetail.mockResolvedValue({ + data: { + creator: { + unique_handle: 'creator-with-avatar', + display_name: 'Creator with avatar', + avatar: 'creator/avatar.png', + social_links: [], + }, + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-with-avatar', + locale: 'en-US', + }) + + expect(loaded?.viewModel.profile.avatarUrl).toBe( + 'https://marketplace.example/api/v1/creators/creator-with-avatar/avatar', + ) + }) + + it('loads evanz from the Marketplace API without a development fixture branch', async () => { + await loadCreatorProfile({ uniqueHandle: 'evanz', locale: 'en-US' }) + + expect(mocks.creatorDetail).toHaveBeenCalledWith({ params: { uniqueHandle: 'evanz' } }) + expect(mocks.publisherTemplates).toHaveBeenCalledWith({ + params: { uniqueHandle: 'evanz' }, + query: { page: 1, page_size: 40, sort_by: 'updated_at', sort_order: 'DESC' }, + }) + }) + + it('forwards popularity sort to each publisher API column', async () => { + await loadCreatorProfile({ + uniqueHandle: 'creator-one', + locale: 'en-US', + sortBy: 'popularity', + sortOrder: 'asc', + }) + + expect(mocks.publisherPlugins).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + query: { page: 1, page_size: 40, sort_by: 'install_count', sort_order: 'ASC' }, + }) + expect(mocks.publisherTemplates).toHaveBeenCalledWith({ + params: { uniqueHandle: 'creator-one' }, + query: { page: 1, page_size: 40, sort_by: 'usage_count', sort_order: 'ASC' }, + }) + }) + + it('merge-sorts mixed creations after the publisher responses return', async () => { + mocks.publisherPlugins.mockResolvedValue({ + data: { + plugins: [{ ...plugin, install_count: 2, created_at: '2026-01-01T00:00:00Z' }], + }, + }) + mocks.publisherTemplates.mockResolvedValue({ + data: { + templates: [{ ...template, usage_count: 5, created_at: '2026-01-02T00:00:00Z' }], + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-one', + locale: 'en-US', + sortBy: 'popularity', + sortOrder: 'desc', + }) + + expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template', 'plugin']) + }) + + it('loads only the first publisher page and reports remaining inventory', async () => { + mocks.publisherPlugins.mockResolvedValue({ + data: { + plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })), + total: 90, + }, + }) + mocks.publisherTemplates.mockResolvedValue({ + data: { templates: [template], total: 1 }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'paged-creator', + locale: 'en-US', + }) + + expect(mocks.publisherPlugins).toHaveBeenCalledOnce() + expect(mocks.publisherPlugins).toHaveBeenCalledWith({ + params: { uniqueHandle: 'paged-creator' }, + query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' }, + }) + expect(loaded?.inventory).toMatchObject({ + uniqueHandle: 'paged-creator', + pluginHasMore: true, + templateHasMore: false, + pluginNextPage: 2, + }) + expect(loaded?.viewModel.creations).toHaveLength(41) + }) + + it('does not treat a full first page as the complete inventory when total is missing', async () => { + mocks.publisherPlugins.mockResolvedValue({ + data: { + plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })), + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'uncounted-creator', + locale: 'en-US', + }) + + expect(mocks.publisherPlugins).toHaveBeenCalledOnce() + expect(loaded?.inventory.pluginHasMore).toBe(true) + }) + + it('keeps successful creations when one publisher request fails', async () => { + mocks.publisherPlugins.mockRejectedValue(new Error('plugin request failed')) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'creator-partial', + locale: 'en-US', + }) + + expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template']) + }) + + it('returns null when the primary creator does not exist', async () => { + mocks.creatorDetail.mockResolvedValue({ data: {} }) + + await expect( + loadCreatorProfile({ uniqueHandle: 'missing-creator', locale: 'en-US' }), + ).resolves.toBeNull() + }) + + it('rethrows when the primary creator request fails', async () => { + mocks.creatorDetail.mockRejectedValue(new Error('creator request timed out')) + + await expect( + loadCreatorProfile({ uniqueHandle: 'slow-creator', locale: 'en-US' }), + ).rejects.toThrow('creator request timed out') + }) + + it('rethrows when the organization request fails', async () => { + mocks.organizationDetail.mockRejectedValue(new Error('organization request timed out')) + + await expect( + loadCreatorProfile({ + uniqueHandle: 'slow-org', + publisherType: 'organization', + locale: 'en-US', + }), + ).rejects.toThrow('organization request timed out') + }) + + it('maps organizations to the shared creator profile shape', async () => { + mocks.organizationDetail.mockResolvedValue({ + data: { + organization: { + id: 'org-id', + unique_handle: 'dify-org', + display_name: 'Dify Org', + social_links: [], + }, + }, + }) + + const loaded = await loadCreatorProfile({ + uniqueHandle: 'dify-org', + publisherType: 'organization', + locale: 'en-US', + }) + + expect(mocks.organizationDetail).toHaveBeenCalledWith({ params: { id: 'dify-org' } }) + expect(loaded?.viewModel.profile).toMatchObject({ + kind: 'organization', + displayName: 'Dify Org', + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx new file mode 100644 index 00000000000..1eb2c318b94 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/dify-profile.spec.tsx @@ -0,0 +1,213 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { MarketplaceSearchSelection } from '../../home/marketplace-search-autocomplete' +import type { LoadedCreatorProfile } from '../model' +import type { Plugin } from '@/app/components/plugins/types' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithNuqs } from '@/test/nuqs-testing' +import DifyCreatorProfile from '../dify-profile' + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + installedInfo: { 'dify/deep_research': { version: '0.0.1' } }, +})) + +const deepResearchPlugin = { + type: 'plugin', + org: 'dify', + name: 'deep_research', + plugin_id: 'dify/deep_research', + latest_package_identifier: 'dify/deep_research:0.0.1@test', + label: { 'en-US': 'Deep Research' }, + brief: { 'en-US': 'Research the web.' }, +} as unknown as Plugin + +const searchPlugin = { + ...deepResearchPlugin, + name: 'search_result', + plugin_id: 'dify/search_result', + latest_package_identifier: 'dify/search_result:0.0.1@test', + label: { 'en-US': 'Search result' }, +} as Plugin + +const template: MarketplaceTemplate = { + id: 'template-one', + template_name: 'Research Template', + overview: 'Build a research app.', + icon: 'R', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 1, + categories: [], +} + +const loadedProfile: LoadedCreatorProfile = { + viewModel: { + profile: { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '', + backgroundUrl: '', + badges: [], + socialLinks: [], + }, + creations: [ + { + id: 'plugin:dify/deep_research', + kind: 'plugin', + title: 'Deep Research', + description: 'Research the web.', + target: { + type: 'plugin', + pluginType: 'plugin', + org: 'dify', + name: 'deep_research', + }, + icon: { type: 'emoji', value: 'R' }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt: 1, + createdAt: 1, + popularity: 1, + }, + { + id: 'template:template-one', + kind: 'template', + title: 'Research Template', + description: 'Build a research app.', + target: { + type: 'template', + id: 'template-one', + publisher: 'dify', + templateName: 'Research Template', + }, + icon: { type: 'emoji', value: 'R' }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt: 1, + createdAt: 1, + popularity: 1, + }, + ], + }, + pluginsByCreationId: { + 'plugin:dify/deep_research': deepResearchPlugin, + }, + templatesByCreationId: { + 'template:template-one': template, + }, + inventory: { + uniqueHandle: 'creator', + pluginHasMore: false, + templateHasMore: false, + pluginNextPage: 2, + templateNextPage: 2, + }, +} + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <span aria-hidden />, +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: () => ({ installedInfo: mocks.installedInfo }), +})) + +vi.mock('../../detail-dialog', () => ({ + default: ({ isInstalled, plugin }: { isInstalled: boolean; plugin: { name: string } }) => ( + <div role="dialog" aria-label="plugin-detail"> + <span>{plugin.name}</span> + <span>{isInstalled ? 'installed' : 'not installed'}</span> + </div> + ), +})) + +vi.mock('../../templates/template-detail-dialog', () => ({ + default: ({ + onInstall, + template, + }: { + onInstall: () => void + template: { template_name: string } + }) => ( + <div role="dialog" aria-label="template-detail"> + <span>{template.template_name}</span> + <button type="button" onClick={onInstall}> + Install template + </button> + </div> + ), +})) + +vi.mock('../header', () => ({ + default: ({ + onSuggestionSelect, + }: { + onSuggestionSelect: (selection: MarketplaceSearchSelection) => void + }) => ( + <button + type="button" + onClick={() => { + onSuggestionSelect({ kind: 'plugin', plugin: searchPlugin }) + }} + > + Select search plugin + </button> + ), +})) + +describe('DifyCreatorProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the existing plugin detail flow with installed state', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + await user.click(screen.getByRole('button', { name: 'Deep Research' })) + + const dialog = screen.getByRole('dialog', { name: 'plugin-detail' }) + expect(dialog).toHaveTextContent('deep_research') + expect(dialog).toHaveTextContent('installed') + expect(screen.queryByTestId('install-plugin')).not.toBeInTheDocument() + }) + + it('opens a template detail and imports it inside Dify', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + await user.click(screen.getByRole('button', { name: 'Research Template' })) + expect(screen.getByRole('dialog', { name: 'template-detail' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Install template' })) + expect(mocks.push).toHaveBeenCalledWith('/apps?template-id=template-one') + }) + + it('opens search results in the same plugin dialog controller', async () => { + const user = userEvent.setup() + renderWithNuqs(<DifyCreatorProfile loadedProfile={loadedProfile} locale="en-US" />) + + await user.click(screen.getByRole('button', { name: 'Select search plugin' })) + + const dialog = screen.getByRole('dialog', { name: 'plugin-detail' }) + expect(dialog).toHaveTextContent('search_result') + expect(dialog).toHaveTextContent('not installed') + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx new file mode 100644 index 00000000000..e29f19fde0c --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/header.spec.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CreatorProfileHeader from '../header' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.creatorProfile.searchPlaceholder': 'Search plugins or templates', + 'mainNav.marketplace': 'Marketplace', + } + + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => translations[key] ?? key), + }), + } +}) + +vi.mock('../../home/home-guide', () => ({ + default: () => <div data-testid="marketplace-guide" />, +})) + +vi.mock('../../home/marketplace-search-autocomplete', () => ({ + MarketplaceSearchAutocomplete: () => <div data-testid="marketplace-search" />, +})) + +describe('CreatorProfileHeader', () => { + it('returns to the native Marketplace without marking a catalog tab active', () => { + render(<CreatorProfileHeader locale="en-US" onSuggestionSelect={vi.fn()} />) + + const pluginsLink = screen.getByRole('link', { name: 'Plugins' }) + const templatesLink = screen.getByRole('link', { name: 'Templates' }) + + expect(pluginsLink).toHaveAttribute('href', '/marketplace') + expect(pluginsLink).not.toHaveAttribute('aria-current') + expect(pluginsLink).not.toHaveClass('bg-state-base-active') + expect(templatesLink).not.toHaveAttribute('aria-current') + expect(templatesLink).not.toHaveClass('bg-state-base-active') + expect(screen.getByTestId('marketplace-guide')).toBeInTheDocument() + expect(screen.queryByTestId('account-section')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts new file mode 100644 index 00000000000..99696799f8c --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/model.spec.ts @@ -0,0 +1,224 @@ +import type { + MarketplaceCreator, + MarketplacePlugin, + MarketplaceTemplate, +} from '@dify/contracts/marketplace' +import { describe, expect, it } from 'vitest' +import { + adaptCreatorProfile, + getStandaloneCreationHref, + normalizeCreatorSocialLink, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, + toPublisherSortQuery, +} from '../model' + +const creator: MarketplaceCreator = { + unique_handle: 'evanz', + display_name: 'Evan.Z', + social_links: ['github.com/evanz', 'javascript:alert(1)'], + badges: ['partner'], + verified: true, +} + +const plugin = { + type: 'bundle', + org: 'dify', + name: 'research', + labels: { en_US: 'Research bundle' }, + description: { en_US: 'Research reliably.' }, + install_count: 20, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-02-01T00:00:00Z', +} as unknown as MarketplacePlugin + +const template = { + id: 'template/one', + template_name: 'Research template', + overview: 'Start a research app.', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 10, + categories: [], + deps_plugins: ['dify/search'], + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-02-02T00:00:00Z', +} as MarketplaceTemplate + +describe('creator profile model', () => { + it('normalizes DTOs into host-neutral creation targets and safe social links', () => { + const viewModel = adaptCreatorProfile({ + creator, + kind: 'organization', + locale: 'en-US', + avatarUrl: '/avatar', + backgroundUrl: '/background', + plugins: [plugin], + templates: [template], + resolvePluginIcon: () => '/plugin-icon', + resolveTemplateIcon: () => '', + resolveDependencyIcon: (id) => `/dependency/${id}`, + }) + + expect(viewModel.profile.badges).toEqual(['partner', 'verified']) + expect(viewModel.profile.socialLinks).toEqual([ + expect.objectContaining({ platform: 'github', href: 'https://github.com/evanz' }), + ]) + expect(viewModel.creations[0]).toMatchObject({ + title: 'Research bundle', + target: { type: 'plugin', pluginType: 'bundle', org: 'dify', name: 'research' }, + }) + expect(viewModel.creations[1]).toMatchObject({ + target: { + type: 'template', + id: 'template/one', + publisher: 'dify', + templateName: 'Research template', + }, + dependencyCount: 1, + }) + }) + + it('builds standalone plugin, bundle, and template URLs outside the shared model', () => { + const viewModel = adaptCreatorProfile({ + creator, + kind: 'individual', + locale: 'en-US', + avatarUrl: '', + backgroundUrl: '', + plugins: [plugin], + templates: [template], + resolvePluginIcon: () => '', + resolveTemplateIcon: () => '', + resolveDependencyIcon: () => '', + }) + + expect(getStandaloneCreationHref(viewModel.creations[0]!, 'zh-Hans')).toBe( + '/bundles/dify/research?language=zh-Hans', + ) + expect(getStandaloneCreationHref(viewModel.creations[1]!, 'zh-Hans')).toBe( + '/template/dify/Research%20template?templateId=template%2Fone&creationType=templates&language=zh-Hans', + ) + }) + + it('normalizes Unix-second, Unix-millisecond, and ISO timestamps', () => { + const unixSeconds = 1_767_225_600 + const unixMilliseconds = 1_767_225_700_000 + const viewModel = adaptCreatorProfile({ + creator, + kind: 'individual', + locale: 'en-US', + avatarUrl: '', + backgroundUrl: '', + plugins: [ + { + ...plugin, + created_at: unixSeconds, + version_updated_at: unixSeconds + 100, + }, + ], + templates: [ + { + ...template, + created_at: '2026-01-02T00:00:00Z', + updated_at: unixMilliseconds, + }, + ], + resolvePluginIcon: () => '', + resolveTemplateIcon: () => '', + resolveDependencyIcon: () => '', + }) + + expect(viewModel.creations[0]).toMatchObject({ + createdAt: unixSeconds * 1000, + updatedAt: (unixSeconds + 100) * 1000, + }) + expect(viewModel.creations[1]).toMatchObject({ + createdAt: Date.parse('2026-01-02T00:00:00Z'), + updatedAt: unixMilliseconds, + }) + }) + + it('maps each UI sort onto the matching plugin and template API columns', () => { + expect(toPublisherSortQuery('updatedAt', 'desc')).toEqual({ + plugins: { sort_by: 'version_updated_at', sort_order: 'DESC' }, + templates: { sort_by: 'updated_at', sort_order: 'DESC' }, + }) + expect(toPublisherSortQuery('createdAt', 'asc')).toEqual({ + plugins: { sort_by: 'created_at', sort_order: 'ASC' }, + templates: { sort_by: 'created_at', sort_order: 'ASC' }, + }) + expect(toPublisherSortQuery('popularity', 'desc')).toEqual({ + plugins: { sort_by: 'install_count', sort_order: 'DESC' }, + templates: { sort_by: 'usage_count', sort_order: 'DESC' }, + }) + }) + + it('falls back to recently updated descending for unknown URL sort values', () => { + expect(parseCreatorSortField('garbage')).toBe('updatedAt') + expect(parseCreatorSortField(undefined)).toBe('updatedAt') + expect(parseCreatorSortOrder('sideways')).toBe('desc') + expect(parseCreatorSortOrder('ASC')).toBe('asc') + }) + + it('sorts all fields in both directions and preserves equal-value order', () => { + const creations = [ + { id: 'first', updatedAt: 1, createdAt: 3, popularity: 2 }, + { id: 'second', updatedAt: 1, createdAt: 2, popularity: 3 }, + { id: 'third', updatedAt: 2, createdAt: 1, popularity: 1 }, + ] as ReturnType<typeof adaptCreatorProfile>['creations'] + + expect(sortCreatorCreations(creations, 'updatedAt', 'asc').map(({ id }) => id)).toEqual([ + 'first', + 'second', + 'third', + ]) + expect(sortCreatorCreations(creations, 'createdAt', 'desc').map(({ id }) => id)).toEqual([ + 'first', + 'second', + 'third', + ]) + expect(sortCreatorCreations(creations, 'popularity', 'desc').map(({ id }) => id)).toEqual([ + 'second', + 'first', + 'third', + ]) + }) + + it('rejects unsafe URL schemes', () => { + expect(normalizeCreatorSocialLink('data:text/html,bad')).toBeNull() + expect(normalizeCreatorSocialLink('mailto:test@example.com')).toBeNull() + }) + + it('ignores non-string social links and template dependencies instead of throwing', () => { + expect(normalizeCreatorSocialLink({ href: 'https://x.com/x' })).toBeNull() + expect(normalizeCreatorSocialLink(null)).toBeNull() + + const viewModel = adaptCreatorProfile({ + creator: { + ...creator, + social_links: [{ href: 'https://x.com/x' }, 'github.com/evanz'] as unknown as string[], + }, + kind: 'individual', + locale: 'en-US', + avatarUrl: '/avatar', + backgroundUrl: '/background', + plugins: [], + templates: [ + { + ...template, + deps_plugins: [null, 'dify/search', ''] as unknown as string[], + }, + ], + resolvePluginIcon: () => '/plugin-icon', + resolveTemplateIcon: () => '', + resolveDependencyIcon: (id) => `/dependency/${id}`, + }) + + expect(viewModel.profile.socialLinks).toEqual([expect.objectContaining({ platform: 'github' })]) + expect(viewModel.creations[0]?.dependencyIcons).toEqual(['/dependency/dify/search']) + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx new file mode 100644 index 00000000000..ae7959e16aa --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/view-layout.browser.spec.tsx @@ -0,0 +1,64 @@ +import type { CreatorProfileViewModel } from '../model' +import { render } from 'vitest-browser-react' +import CreatorProfileView from '../view' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('../creator-sidebar', () => ({ + default: () => <aside>Creator sidebar</aside>, +})) + +vi.mock('../creator-content', () => ({ + default: () => ( + <section data-testid="creator-creations" style={{ height: 640, flexShrink: 0 }}> + Creator content + </section> + ), +})) + +const profile: CreatorProfileViewModel = { + profile: { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '', + backgroundUrl: '', + badges: [], + socialLinks: [], + }, + creations: [], +} + +describe('CreatorProfileView layout', () => { + it('keeps the profile background behind content taller than its scrollport', async () => { + const screen = await render( + <div + data-testid="creator-scrollport" + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + /> + </div>, + ) + + const scrollport = screen.getByTestId('creator-scrollport').element() + const profileRoot = scrollport.firstElementChild as HTMLElement + const creations = screen.getByTestId('creator-creations').element() + + expect(profileRoot.getBoundingClientRect().bottom).toBeGreaterThanOrEqual( + creations.getBoundingClientRect().bottom, + ) + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx b/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx new file mode 100644 index 00000000000..e6f5dec38c5 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/__tests__/view.spec.tsx @@ -0,0 +1,88 @@ +import type { CreatorProfileViewModel } from '../model' +import { fireEvent, render } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import CreatorProfileView from '../view' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('../creator-sidebar', () => ({ + default: () => <aside>Creator sidebar</aside>, +})) + +vi.mock('../creator-content', () => ({ + default: () => <section>Creator content</section>, +})) + +const profile: CreatorProfileViewModel = { + profile: { + kind: 'individual', + displayName: 'Creator', + handle: 'creator', + avatarUrl: '/creator-avatar.png', + backgroundUrl: '/creator-background.png', + badges: [], + socialLinks: [], + }, + creations: [], +} + +describe('CreatorProfileView SSR background', () => { + it('includes the default background in server markup before the remote background loads', () => { + const markup = renderToStaticMarkup( + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + + expect(markup).toContain('default-background.png') + expect(markup).toContain('src="/creator-background.png"') + }) + + it('server-renders only the default background when the profile has no background', () => { + const markup = renderToStaticMarkup( + <CreatorProfileView + profile={{ + ...profile, + profile: { ...profile.profile, backgroundUrl: '' }, + }} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + + expect(markup).toContain('default-background.png') + expect(markup).not.toContain('<img') + expect(markup).toContain('border-0') + }) + + it('hides a stale remote image after a loading failure', () => { + const { container } = render( + <CreatorProfileView + profile={profile} + homeHref="/" + isMarketplacePlatform={false} + getCreationAction={() => ({ type: 'link', href: '/' })} + />, + ) + const remoteBackground = container.querySelector<HTMLImageElement>( + 'img[src="/creator-background.png"]', + )! + + fireEvent.error(remoteBackground) + + expect(remoteBackground).toHaveAttribute('hidden') + expect(remoteBackground).toHaveClass('border-0') + }) +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png b/web/app/components/plugins/marketplace/creator-profile/assets/default-background.png new file mode 100644 index 0000000000000000000000000000000000000000..704fbae82e1d1edf2d9891f9dd7b6e977b2861af GIT binary patch literal 3217 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 literal 0 HcmV?d00001 diff --git a/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx b/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx new file mode 100644 index 00000000000..72d62ba275e --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/creation-card.tsx @@ -0,0 +1,94 @@ +'use client' + +import type { CreatorCreation, CreatorCreationAction } from './model' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import AppIcon from '@/app/components/base/app-icon' +import CornerMark from '@/app/components/plugins/card/base/corner-mark' +import Link from '@/next/link' + +const MAX_VISIBLE_DEPENDENCIES = 7 + +type CreationCardProps = { + creation: CreatorCreation + action: CreatorCreationAction +} + +const cardClassName = + 'group relative flex h-[152px] min-w-0 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 text-left shadow-xs outline-hidden transition-shadow hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid' + +function CreationCardContent({ creation }: { creation: CreatorCreation }) { + const { t } = useTranslation() + const visibleDependencies = creation.dependencyIcons.slice(0, MAX_VISIBLE_DEPENDENCIES) + const remainingDependencies = Math.max(0, creation.dependencyCount - visibleDependencies.length) + + return ( + <> + <CornerMark + text={t(($) => $[`marketplace.creatorProfile.type.${creation.kind}`], { ns: 'plugin' })} + className={cn( + creation.kind === 'plugin' && '[&>div]:text-text-accent', + creation.kind === 'template' && '[&>div]:text-text-warning', + )} + /> + + <div className="flex min-w-0 shrink-0 items-center gap-3 px-4 pt-4 pr-20 pb-2"> + {creation.icon.type === 'image' ? ( + <AppIcon size="large" iconType="image" imageUrl={creation.icon.src} /> + ) : ( + <AppIcon + size="large" + iconType="emoji" + icon={creation.icon.value} + background={creation.icon.background} + /> + )} + <h3 className="min-w-0 flex-1 truncate system-md-medium text-text-primary"> + {creation.title} + </h3> + </div> + + <p className="mx-4 line-clamp-2 min-h-8 system-xs-regular text-text-secondary"> + {creation.description} + </p> + + <div className="mt-auto flex min-h-7 items-center gap-1 overflow-hidden px-4 py-1"> + {visibleDependencies.map((icon) => ( + <img + key={icon} + alt="" + aria-hidden + src={icon} + className="size-6 shrink-0 rounded-md border-[0.5px] border-effects-icon-border object-cover" + /> + ))} + {remainingDependencies > 0 && ( + <span className="shrink-0 system-xs-regular text-text-tertiary"> + +{remainingDependencies} + </span> + )} + </div> + </> + ) +} + +export default function CreationCard({ creation, action }: CreationCardProps) { + if (action.type === 'link') { + return ( + <Link href={action.href} aria-label={creation.title} className={cardClassName}> + <CreationCardContent creation={creation} /> + </Link> + ) + } + + return ( + <button + type="button" + aria-label={creation.title} + className={cardClassName} + onClick={action.onSelect} + > + <CreationCardContent creation={creation} /> + </button> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx new file mode 100644 index 00000000000..bc3a890fad2 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/creator-content.tsx @@ -0,0 +1,261 @@ +'use client' + +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { + CreatorCreation, + CreatorCreationAction, + CreatorInventory, + CreatorSortField, + CreatorSortOrder, +} from './model' +import type { Plugin } from '@/app/components/plugins/types' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { parseAsStringEnum, useQueryStates } from 'nuqs' +import { useMemo, useState } from 'react' +import { useTranslation } from '#i18n' +import CreationCard from './creation-card' +import { + CREATOR_SORT_FIELDS, + DEFAULT_CREATOR_SORT_FIELD, + DEFAULT_CREATOR_SORT_ORDER, + sortCreatorCreations, +} from './model' +import { fetchPublisherPluginPage, fetchPublisherTemplatePage, toCreatorRecords } from './publisher' + +type CreatorContentProps = { + creations: CreatorCreation[] + getCreationAction: (creation: CreatorCreation) => CreatorCreationAction + inventory?: CreatorInventory + locale?: string + onRecordsLoaded?: (records: { + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> + }) => void +} + +const sortSearchOptions = { history: 'replace' as const, shallow: false, scroll: false } +const creatorSortSearchParsers = { + sort_by: parseAsStringEnum<CreatorSortField>([...CREATOR_SORT_FIELDS]).withDefault( + DEFAULT_CREATOR_SORT_FIELD, + ), + sort_order: parseAsStringEnum<CreatorSortOrder>(['asc', 'desc']).withDefault( + DEFAULT_CREATOR_SORT_ORDER, + ), +} + +export default function CreatorContent({ + creations, + getCreationAction, + inventory, + locale = 'en-US', + onRecordsLoaded, +}: CreatorContentProps) { + const { t } = useTranslation() + const [sort, setSort] = useQueryStates(creatorSortSearchParsers, sortSearchOptions) + const sortField = sort.sort_by + const sortOrder = sort.sort_order + const [sourceCreations, setSourceCreations] = useState(creations) + const [loadedCreations, setLoadedCreations] = useState(creations) + const [pluginHasMore, setPluginHasMore] = useState(inventory?.pluginHasMore ?? false) + const [templateHasMore, setTemplateHasMore] = useState(inventory?.templateHasMore ?? false) + const [pluginNextPage, setPluginNextPage] = useState(inventory?.pluginNextPage ?? 2) + const [templateNextPage, setTemplateNextPage] = useState(inventory?.templateNextPage ?? 2) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [loadMoreFailed, setLoadMoreFailed] = useState(false) + if (creations !== sourceCreations) { + setSourceCreations(creations) + setLoadedCreations(creations) + setPluginHasMore(inventory?.pluginHasMore ?? false) + setTemplateHasMore(inventory?.templateHasMore ?? false) + setPluginNextPage(inventory?.pluginNextPage ?? 2) + setTemplateNextPage(inventory?.templateNextPage ?? 2) + setLoadMoreFailed(false) + } + const sortOptions: Array<{ value: CreatorSortField; label: string }> = [ + { + value: 'updatedAt', + label: t(($) => $['marketplace.creatorProfile.sort.updatedAt'], { ns: 'plugin' }), + }, + { + value: 'createdAt', + label: t(($) => $['marketplace.creatorProfile.sort.createdAt'], { ns: 'plugin' }), + }, + { + value: 'popularity', + label: t(($) => $['marketplace.creatorProfile.sort.popularity'], { ns: 'plugin' }), + }, + ] + const selectedSort = sortOptions.find((option) => option.value === sortField) ?? sortOptions[0]! + const sortedCreations = useMemo( + () => sortCreatorCreations(loadedCreations, sortField, sortOrder), + [loadedCreations, sortField, sortOrder], + ) + const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc' + const hasMore = pluginHasMore || templateHasMore + const uniqueHandle = inventory?.uniqueHandle + + const loadMore = async () => { + if (!uniqueHandle || isLoadingMore || !hasMore) return + + setIsLoadingMore(true) + setLoadMoreFailed(false) + try { + const [pluginPage, templatePage] = await Promise.all([ + pluginHasMore + ? fetchPublisherPluginPage({ + uniqueHandle, + page: pluginNextPage, + sortField, + sortOrder, + }) + : Promise.resolve({ items: [] as MarketplacePlugin[], hasMore: false }), + templateHasMore + ? fetchPublisherTemplatePage({ + uniqueHandle, + page: templateNextPage, + sortField, + sortOrder, + }) + : Promise.resolve({ items: [] as MarketplaceTemplate[], hasMore: false }), + ]) + const records = toCreatorRecords({ + locale, + plugins: pluginPage.items, + templates: templatePage.items, + }) + setLoadedCreations((current) => { + const seen = new Set(current.map((creation) => creation.id)) + return [...current, ...records.creations.filter((creation) => !seen.has(creation.id))] + }) + if (pluginHasMore) { + setPluginHasMore(pluginPage.hasMore) + setPluginNextPage((page) => page + 1) + } + if (templateHasMore) { + setTemplateHasMore(templatePage.hasMore) + setTemplateNextPage((page) => page + 1) + } + onRecordsLoaded?.(records) + } catch { + setLoadMoreFailed(true) + } finally { + setIsLoadingMore(false) + } + } + + return ( + <section + aria-labelledby="creator-creations-title" + className="flex min-w-0 flex-1 flex-col items-start pt-6" + > + <div className="flex w-full flex-wrap items-center justify-between gap-2"> + <h2 id="creator-creations-title" className="system-xl-semibold text-text-primary"> + {t(($) => $['marketplace.creatorProfile.creations'], { ns: 'plugin' })} + </h2> + + <div className="flex h-8 items-center"> + <DropdownMenu> + <DropdownMenuTrigger + aria-label={`${t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })} ${selectedSort.label}`} + className="flex h-8 items-center rounded-lg px-2 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span className="mr-1 system-sm-regular text-text-tertiary"> + {t(($) => $['marketplace.creatorProfile.sortBy'], { ns: 'plugin' })} + </span> + <span className="system-sm-medium text-text-secondary">{selectedSort.label}</span> + <span aria-hidden className="ml-1 i-ri-arrow-down-s-line size-4 text-text-tertiary" /> + </DropdownMenuTrigger> + <DropdownMenuContent + placement="bottom-end" + sideOffset={4} + className="min-w-[176px] p-1" + > + <DropdownMenuRadioGroup<CreatorSortField> + value={sortField} + onValueChange={(nextField) => { + void setSort({ sort_by: nextField, sort_order: sortOrder }) + }} + > + {sortOptions.map((option) => ( + <DropdownMenuRadioItem<CreatorSortField> + key={option.value} + value={option.value} + closeOnClick + className="justify-between px-3 pr-2 system-md-regular text-text-primary" + > + {option.label} + <DropdownMenuRadioItemIndicator className="ml-2" /> + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + + <div className="mx-1 h-4 w-px bg-divider-regular" /> + <button + type="button" + aria-label={t(($) => $[`marketplace.creatorProfile.sort.${nextSortOrder}`], { + ns: 'plugin', + })} + title={t(($) => $[`marketplace.creatorProfile.sort.${nextSortOrder}`], { + ns: 'plugin', + })} + className="flex size-8 items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" + onClick={() => { + void setSort({ sort_by: sortField, sort_order: nextSortOrder }) + }} + > + <span + aria-hidden + className={sortOrder === 'desc' ? 'i-ri-sort-desc size-4' : 'i-ri-sort-asc size-4'} + /> + </button> + </div> + </div> + + {sortedCreations.length > 0 ? ( + <div className="grid w-full grid-cols-1 gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3"> + {sortedCreations.map((creation) => ( + <CreationCard + key={creation.id} + creation={creation} + action={getCreationAction(creation)} + /> + ))} + </div> + ) : ( + <div className="w-full py-12 text-center system-sm-regular text-text-tertiary"> + {t(($) => $['marketplace.creatorProfile.empty'], { ns: 'plugin' })} + </div> + )} + + {hasMore && ( + <div className="flex w-full flex-col items-center gap-2 pt-6"> + <button + type="button" + aria-busy={isLoadingMore || undefined} + disabled={isLoadingMore} + className="flex h-8 items-center rounded-lg px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:opacity-50" + onClick={() => { + void loadMore() + }} + > + {t(($) => $['marketplace.creatorProfile.loadMore'], { ns: 'plugin' })} + </button> + {loadMoreFailed && ( + <p className="system-xs-regular text-text-destructive"> + {t(($) => $['marketplace.creatorProfile.loadMoreFailed'], { ns: 'plugin' })} + </p> + )} + </div> + )} + </section> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx b/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx new file mode 100644 index 00000000000..0241983e870 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/creator-sidebar.tsx @@ -0,0 +1,114 @@ +'use client' + +import type { CreatorProfileViewModel, CreatorSocialPlatform } from './model' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import Partner from '@/app/components/plugins/base/badges/partner' +import Verified from '@/app/components/plugins/base/badges/verified' +import PublisherAvatar from './publisher-avatar' + +type CreatorSidebarProps = { + profile: CreatorProfileViewModel['profile'] +} + +function SocialIcon({ platform }: { platform: CreatorSocialPlatform }) { + const className = 'size-4 shrink-0 text-text-tertiary' + + if (platform === 'x') return <span aria-hidden className={cn(className, 'i-ri-twitter-x-fill')} /> + if (platform === 'instagram') + return <span aria-hidden className={cn(className, 'i-ri-instagram-line')} /> + if (platform === 'youtube') + return <span aria-hidden className={cn(className, 'i-ri-youtube-fill')} /> + if (platform === 'figma') return <span aria-hidden className={cn(className, 'i-ri-figma-line')} /> + if (platform === 'github') + return <span aria-hidden className={cn(className, 'i-ri-github-fill')} /> + + return <span aria-hidden className={cn(className, 'i-ri-global-line')} /> +} + +export default function CreatorSidebar({ profile }: CreatorSidebarProps) { + const { t } = useTranslation() + const isOrganization = profile.kind === 'organization' + const isPartner = profile.badges.includes('partner') + const isVerified = profile.badges.includes('verified') + + return ( + <aside className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12"> + <PublisherAvatar + avatarUrl={profile.avatarUrl} + name={profile.displayName} + isOrganization={isOrganization} + size={100} + className={cn( + 'absolute -top-12 -left-2 z-10 !size-20 border-[1.5px] border-components-panel-bg bg-background-default-dodge shadow-xs md:-top-[68px] md:!size-[100px]', + isOrganization && 'rounded-[10px]', + )} + /> + + <div className="flex flex-col gap-1"> + <div className="flex flex-wrap items-center gap-1"> + <h1 className="title-2xl-semi-bold text-text-primary">{profile.displayName}</h1> + {isOrganization && ( + <span className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary uppercase"> + {t(($) => $['marketplace.creatorProfile.organization'], { ns: 'plugin' })} + </span> + )} + {isPartner && ( + <Partner + className="size-[18px] shrink-0" + text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })} + /> + )} + {isVerified && ( + <Verified + className="size-[18px] shrink-0" + text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })} + /> + )} + </div> + <span className="system-sm-regular text-text-tertiary">@{profile.handle}</span> + </div> + + {profile.description && ( + <p className="system-sm-regular whitespace-pre-wrap text-text-secondary"> + {profile.description} + </p> + )} + + {profile.email && ( + <a + href={`mailto:${profile.email}`} + className="flex min-w-0 items-center gap-1.5 py-1 system-sm-regular text-text-secondary outline-hidden hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span aria-hidden className="i-ri-mail-line size-4 shrink-0 text-text-tertiary" /> + <span className="truncate">{profile.email}</span> + </a> + )} + + {profile.socialLinks.length > 0 && ( + <div className="flex flex-col gap-2 py-1"> + <div className="flex w-full items-center gap-2"> + <span className="shrink-0 system-xs-medium text-text-tertiary uppercase"> + {t(($) => $['marketplace.creatorProfile.onTheWeb'], { ns: 'plugin' })} + </span> + <div className="h-px min-w-0 flex-1 bg-gradient-to-r from-divider-regular to-transparent" /> + </div> + <div className="flex flex-col gap-2"> + {profile.socialLinks.map((link) => ( + <a + key={link.href} + href={link.href} + target="_blank" + rel="noopener noreferrer" + className="flex min-w-0 items-center gap-1.5 system-sm-regular text-text-secondary outline-hidden transition-colors hover:text-text-accent focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <SocialIcon platform={link.platform} /> + <span className="truncate">{link.label}</span> + </a> + ))} + </div> + </div> + )} + </aside> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/data.server.ts b/web/app/components/plugins/marketplace/creator-profile/data.server.ts new file mode 100644 index 00000000000..38910290da6 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/data.server.ts @@ -0,0 +1,140 @@ +import type { MarketplaceCreator, MarketplaceOrganization } from '@dify/contracts/marketplace' +import type { CreatorSortField, CreatorSortOrder, LoadedCreatorProfile } from './model' +import { cache } from 'react' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { marketplaceClient } from '@/service/client' +import { getPluginIconInMarketplace } from '../utils' +import { + adaptCreatorProfile, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, +} from './model' +import { + fetchPublisherPluginPage, + fetchPublisherTemplatePage, + getDependencyIcon, + getTemplateIcon, + toCreatorRecords, +} from './publisher' +import 'server-only' + +const mapOrganizationToCreator = ( + organization: MarketplaceOrganization, + uniqueHandle: string, +): MarketplaceCreator => ({ + id: organization.id || organization.name, + email: organization.email, + name: organization.name || organization.display_name || uniqueHandle, + display_name: organization.display_name || organization.name || uniqueHandle, + unique_handle: organization.unique_handle || uniqueHandle, + display_email: organization.display_email, + description: organization.description, + avatar: organization.avatar, + background_image: organization.background_image, + social_links: organization.social_links ?? [], + badges: organization.badges, + verified: organization.verified, + status: organization.status, + created_at: organization.created_at, + updated_at: organization.updated_at, +}) + +const getPublisher = async (uniqueHandle: string, publisherType?: string) => { + if (publisherType === 'organization') { + const response = await marketplaceClient.organizationDetail({ + params: { id: uniqueHandle }, + }) + const organization = response.data?.organization + return organization ? mapOrganizationToCreator(organization, uniqueHandle) : undefined + } + + const response = await marketplaceClient.creatorDetail({ + params: { uniqueHandle }, + }) + return response.data?.creator +} + +const loadCreatorProfileCached = cache( + async ( + uniqueHandle: string, + publisherType: string | undefined, + locale: string, + sortField: CreatorSortField, + sortOrder: CreatorSortOrder, + ): Promise<LoadedCreatorProfile | null> => { + const [creatorResult, pluginsResult, templatesResult] = await Promise.allSettled([ + getPublisher(uniqueHandle, publisherType), + fetchPublisherPluginPage({ uniqueHandle, page: 1, sortField, sortOrder }), + fetchPublisherTemplatePage({ uniqueHandle, page: 1, sortField, sortOrder }), + ]) + + if (creatorResult.status === 'rejected') throw creatorResult.reason + const creator = creatorResult.value + if (!creator) return null + + const plugins = pluginsResult.status === 'fulfilled' ? pluginsResult.value.items : [] + const templates = templatesResult.status === 'fulfilled' ? templatesResult.value.items : [] + const pluginPage = pluginsResult.status === 'fulfilled' ? pluginsResult.value : undefined + const templatePage = templatesResult.status === 'fulfilled' ? templatesResult.value : undefined + const kind = publisherType === 'organization' ? 'organization' : 'individual' + const resource = kind === 'organization' ? 'organizations' : 'creators' + const encodedHandle = encodeURIComponent(uniqueHandle) + const backgroundUrl = creator.background_image + ? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/background-image` + : '' + const avatarUrl = creator.avatar + ? `${MARKETPLACE_API_PREFIX}/${resource}/${encodedHandle}/avatar` + : '' + const viewModel = adaptCreatorProfile({ + creator, + kind, + locale, + avatarUrl, + backgroundUrl, + plugins, + templates, + resolvePluginIcon: getPluginIconInMarketplace, + resolveTemplateIcon: getTemplateIcon, + resolveDependencyIcon: getDependencyIcon, + }) + const records = toCreatorRecords({ locale, plugins, templates }) + + return { + viewModel: { + ...viewModel, + creations: sortCreatorCreations(viewModel.creations, sortField, sortOrder), + }, + pluginsByCreationId: records.pluginsByCreationId, + templatesByCreationId: records.templatesByCreationId, + inventory: { + uniqueHandle, + pluginHasMore: pluginPage?.hasMore ?? false, + templateHasMore: templatePage?.hasMore ?? false, + pluginNextPage: 2, + templateNextPage: 2, + }, + } + }, +) + +export const loadCreatorProfile = ({ + uniqueHandle, + publisherType, + locale, + sortBy, + sortOrder, +}: { + uniqueHandle: string + publisherType?: string + locale: string + sortBy?: string + sortOrder?: string +}) => + loadCreatorProfileCached( + uniqueHandle, + publisherType, + locale, + parseCreatorSortField(sortBy), + parseCreatorSortOrder(sortOrder), + ) diff --git a/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx new file mode 100644 index 00000000000..8ae4997db63 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/dify-profile.tsx @@ -0,0 +1,137 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete' +import type { CreatorCreation, LoadedCreatorProfile } from './model' +import type { Plugin } from '@/app/components/plugins/types' +import { useMemo, useState } from 'react' +import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' +import { useRouter } from '@/next/navigation' +import MarketplaceDetailDialog from '../detail-dialog' +import TemplateDetailDialog from '../templates/template-detail-dialog' +import { getFormattedPlugin } from '../utils' +import CreatorProfileHeader from './header' +import CreatorProfileView from './view' + +type SelectedCreation = + | { kind: 'plugin'; plugin: Plugin } + | { kind: 'template'; template: MarketplaceTemplate } + +type DifyCreatorProfileProps = { + loadedProfile: LoadedCreatorProfile + locale: string +} + +const normalizePlugin = (plugin: Plugin): Plugin => ({ + ...plugin, + label: plugin.label ?? {}, + brief: plugin.brief ?? {}, + description: plugin.description ?? {}, + tags: plugin.tags ?? [], + badges: plugin.badges ?? null, +}) + +export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreatorProfileProps) { + const router = useRouter() + const [selected, setSelected] = useState<SelectedCreation | null>(null) + const [sourceProfile, setSourceProfile] = useState(loadedProfile) + const [pluginsByCreationId, setPluginsByCreationId] = useState(loadedProfile.pluginsByCreationId) + const [templatesByCreationId, setTemplatesByCreationId] = useState( + loadedProfile.templatesByCreationId, + ) + if (loadedProfile !== sourceProfile) { + setSourceProfile(loadedProfile) + setPluginsByCreationId(loadedProfile.pluginsByCreationId) + setTemplatesByCreationId(loadedProfile.templatesByCreationId) + } + + const profilePlugins = Object.values(pluginsByCreationId) + const pluginIds = useMemo( + () => + Array.from( + new Set([ + ...profilePlugins.map((plugin) => plugin.plugin_id), + ...(selected?.kind === 'plugin' ? [selected.plugin.plugin_id] : []), + ]), + ).sort(), + [profilePlugins, selected], + ) + const { installedInfo } = useCheckInstalled({ + pluginIds, + enabled: pluginIds.length > 0, + }) + + const selectCreation = (creation: CreatorCreation) => { + if (creation.kind === 'plugin') { + const plugin = pluginsByCreationId[creation.id] + if (plugin) setSelected({ kind: 'plugin', plugin: normalizePlugin(plugin) }) + return + } + + const template = templatesByCreationId[creation.id] + if (template) setSelected({ kind: 'template', template }) + } + + const selectSearchResult = (selection: MarketplaceSearchSelection) => { + if (selection.kind === 'plugin') { + setSelected({ + kind: 'plugin', + plugin: normalizePlugin(getFormattedPlugin(selection.plugin)), + }) + return + } + setSelected({ kind: 'template', template: selection.template }) + } + + const closeSelected = () => setSelected(null) + const selectedPlugin = selected?.kind === 'plugin' ? selected.plugin : null + const selectedTemplate = selected?.kind === 'template' ? selected.template : null + + return ( + <> + <CreatorProfileView + profile={loadedProfile.viewModel} + homeHref="/marketplace" + isMarketplacePlatform + inventory={loadedProfile.inventory} + locale={locale} + onRecordsLoaded={(records) => { + setPluginsByCreationId((current) => ({ ...current, ...records.pluginsByCreationId })) + setTemplatesByCreationId((current) => ({ + ...current, + ...records.templatesByCreationId, + })) + }} + getCreationAction={(creation) => ({ + type: 'select', + onSelect: () => selectCreation(creation), + })} + header={<CreatorProfileHeader locale={locale} onSuggestionSelect={selectSearchResult} />} + /> + + {selectedPlugin && ( + <MarketplaceDetailDialog + isInstalled={Boolean(installedInfo?.[selectedPlugin.plugin_id])} + open + plugin={selectedPlugin} + onOpenChange={(open) => { + if (!open) closeSelected() + }} + /> + )} + {selectedTemplate && ( + <TemplateDetailDialog + open + template={selectedTemplate} + onInstall={() => { + closeSelected() + router.push(`/apps?template-id=${encodeURIComponent(selectedTemplate.id)}`) + }} + onOpenChange={(open) => { + if (!open) closeSelected() + }} + /> + )} + </> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/header.tsx b/web/app/components/plugins/marketplace/creator-profile/header.tsx new file mode 100644 index 00000000000..038c7d052c4 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/header.tsx @@ -0,0 +1,82 @@ +'use client' + +import type { MarketplaceSearchSelection } from '../home/marketplace-search-autocomplete' +import { cn } from '@langgenius/dify-ui/cn' +import { useState } from 'react' +import { useTranslation } from '#i18n' +import Link from '@/next/link' +import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg' +import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg' +import HomeCatalogTabs from '../home/home-catalog-tabs' +import HomeGuide from '../home/home-guide' +import styles from '../home/home-sticky.module.css' +import { MarketplaceSearchAutocomplete } from '../home/marketplace-search-autocomplete' + +type CreatorProfileHeaderProps = { + actions?: React.ReactNode + locale: string + onSuggestionSelect: (selection: MarketplaceSearchSelection) => void +} + +export default function CreatorProfileHeader({ + actions, + locale, + onSuggestionSelect, +}: CreatorProfileHeaderProps) { + const { t } = useTranslation() + const [searchValue, setSearchValue] = useState('') + + return ( + <header className="sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-4 border-b border-divider-regular bg-background-default px-4 md:px-6"> + <div className="flex min-w-0 flex-1 items-center gap-4"> + <Link + href="/marketplace" + aria-label="Dify Marketplace" + className="flex h-full w-[141.933px] shrink-0 items-center" + > + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoLight, + )} + src={MarketplaceLogo.src} + /> + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoDark, + )} + src={MarketplaceLogoDark.src} + /> + </Link> + <div className="hidden md:block"> + <HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} /> + </div> + </div> + + <div className="hidden w-80 shrink-0 md:block"> + <MarketplaceSearchAutocomplete + locale={locale} + onSuggestionSelect={onSuggestionSelect} + onValueChange={setSearchValue} + placeholder={t(($) => $['marketplace.creatorProfile.searchPlaceholder'], { + ns: 'plugin', + })} + scope="all" + value={searchValue} + /> + </div> + + <div className="flex min-w-0 flex-1 items-center justify-end gap-2.5"> + <div className="hidden md:block"> + <HomeGuide isMarketplacePlatform={false} /> + </div> + {actions} + </div> + </header> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/model.ts b/web/app/components/plugins/marketplace/creator-profile/model.ts new file mode 100644 index 00000000000..9814dcec192 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/model.ts @@ -0,0 +1,361 @@ +import type { + MarketplaceCreator, + MarketplacePlugin, + MarketplaceTemplate, + MarketplaceTimestamp, +} from '@dify/contracts/marketplace' +import type { Plugin } from '@/app/components/plugins/types' + +type CreatorProfileKind = 'individual' | 'organization' +type CreatorProfileBadge = 'partner' | 'verified' +export type CreatorSocialPlatform = 'website' | 'x' | 'instagram' | 'youtube' | 'figma' | 'github' +export type CreatorSortField = 'updatedAt' | 'createdAt' | 'popularity' +export type CreatorSortOrder = 'asc' | 'desc' +export const CREATOR_SORT_FIELDS = ['updatedAt', 'createdAt', 'popularity'] as const +export const DEFAULT_CREATOR_SORT_FIELD: CreatorSortField = 'updatedAt' +export const DEFAULT_CREATOR_SORT_ORDER: CreatorSortOrder = 'desc' + +export const parseCreatorSortField = (value?: string | null): CreatorSortField => + CREATOR_SORT_FIELDS.includes(value as CreatorSortField) + ? (value as CreatorSortField) + : DEFAULT_CREATOR_SORT_FIELD + +export const parseCreatorSortOrder = (value?: string | null): CreatorSortOrder => { + const normalized = value?.toLowerCase() + return normalized === 'asc' || normalized === 'desc' ? normalized : DEFAULT_CREATOR_SORT_ORDER +} + +export const toPublisherSortQuery = (field: CreatorSortField, order: CreatorSortOrder) => { + const sort_order = order === 'asc' ? 'ASC' : 'DESC' + return { + plugins: { + sort_by: + field === 'updatedAt' + ? 'version_updated_at' + : field === 'createdAt' + ? 'created_at' + : 'install_count', + sort_order, + }, + templates: { + sort_by: + field === 'updatedAt' ? 'updated_at' : field === 'createdAt' ? 'created_at' : 'usage_count', + sort_order, + }, + } +} + +export type CreatorSocialLink = { + platform: CreatorSocialPlatform + href: string + label: string +} + +type CreatorCreationTarget = + | { + type: 'plugin' + org: string + name: string + pluginType: MarketplacePlugin['type'] + } + | { + type: 'template' + id: string + publisher: string + templateName: string + } + +type CreatorCreationIcon = + | { type: 'image'; src: string } + | { type: 'emoji'; value: string; background?: string } + +export type CreatorCreation = { + id: string + kind: 'plugin' | 'template' + title: string + description: string + target: CreatorCreationTarget + icon: CreatorCreationIcon + dependencyIcons: string[] + dependencyCount: number + updatedAt: number + createdAt: number + popularity: number +} + +export type CreatorProfileViewModel = { + profile: { + kind: CreatorProfileKind + displayName: string + handle: string + description?: string + email?: string + avatarUrl: string + backgroundUrl: string + badges: CreatorProfileBadge[] + socialLinks: CreatorSocialLink[] + } + creations: CreatorCreation[] +} + +export type CreatorInventory = { + uniqueHandle: string + pluginHasMore: boolean + templateHasMore: boolean + pluginNextPage: number + templateNextPage: number +} + +export type LoadedCreatorProfile = { + viewModel: CreatorProfileViewModel + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> + inventory: CreatorInventory +} + +export type CreatorCreationAction = + | { type: 'link'; href: string } + | { type: 'select'; onSelect: () => void } + +export type CreatorProfileAdapterInput = { + creator: MarketplaceCreator + kind: CreatorProfileKind + locale: string + avatarUrl: string + backgroundUrl: string + plugins: MarketplacePlugin[] + templates: MarketplaceTemplate[] + resolvePluginIcon: (plugin: MarketplacePlugin) => string + resolveTemplateIcon: (template: MarketplaceTemplate) => string + resolveDependencyIcon: (pluginId: string) => string +} + +const toTimestamp = (value?: MarketplaceTimestamp | null) => { + if (value === undefined || value === null || value === '') return 0 + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return 0 + + // Marketplace search responses use Unix seconds, while some consumers may already + // provide JavaScript timestamps in milliseconds. + return Math.abs(value) < 1_000_000_000_000 ? value * 1000 : value + } + + const timestamp = Date.parse(value) + return Number.isNaN(timestamp) ? 0 : timestamp +} + +const firstLocalizedString = (value: object, keys: string[]) => { + for (const key of keys) { + const entry = (value as Record<string, unknown>)[key] + if (typeof entry === 'string' && entry) return entry + } + return ( + Object.values(value).find((entry): entry is string => typeof entry === 'string' && !!entry) ?? + '' + ) +} + +const getCreatorLocalizedText = ( + value: Partial<Record<string, string>> | string | undefined, + locale: string, +) => { + if (typeof value === 'string') return value + if (!value || typeof value !== 'object') return '' + + const normalizedLocale = locale.replace('-', '_') + return firstLocalizedString(value, [locale, normalizedLocale, 'en-US', 'en_US']) +} + +const getSocialPlatform = (hostname: string): CreatorSocialPlatform => { + if ( + hostname === 'x.com' || + hostname.endsWith('.x.com') || + hostname === 'twitter.com' || + hostname.endsWith('.twitter.com') + ) + return 'x' + if (hostname === 'instagram.com' || hostname.endsWith('.instagram.com')) return 'instagram' + if (hostname === 'youtube.com' || hostname.endsWith('.youtube.com') || hostname === 'youtu.be') + return 'youtube' + if (hostname === 'figma.com' || hostname.endsWith('.figma.com')) return 'figma' + if (hostname === 'github.com' || hostname.endsWith('.github.com')) return 'github' + return 'website' +} + +export const normalizeCreatorSocialLink = (value: unknown): CreatorSocialLink | null => { + if (typeof value !== 'string') return null + const trimmedValue = value.trim() + if (!trimmedValue) return null + + const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(trimmedValue) + if (hasScheme && !/^https?:\/\//i.test(trimmedValue)) return null + + try { + const url = new URL( + /^https?:\/\//i.test(trimmedValue) ? trimmedValue : `https://${trimmedValue}`, + ) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + + const hostname = url.hostname.toLowerCase().replace(/^www\./, '') + return { + platform: getSocialPlatform(hostname), + href: url.toString(), + label: trimmedValue.replace(/^https?:\/\//i, '').replace(/\/$/, ''), + } + } catch { + return null + } +} + +const getCreatorBadges = (creator: MarketplaceCreator) => { + const badges = new Set<CreatorProfileBadge>() + if (creator.badges?.includes('partner')) badges.add('partner') + if (creator.verified || creator.badges?.includes('verified')) badges.add('verified') + return Array.from(badges) +} + +export const adaptCreations = ({ + locale, + plugins, + templates, + resolvePluginIcon, + resolveTemplateIcon, + resolveDependencyIcon, +}: Pick< + CreatorProfileAdapterInput, + | 'locale' + | 'plugins' + | 'templates' + | 'resolvePluginIcon' + | 'resolveTemplateIcon' + | 'resolveDependencyIcon' +>): CreatorCreation[] => { + const pluginCreations = plugins.map((plugin): CreatorCreation => ({ + id: `${plugin.type}:${plugin.org}/${plugin.name}`, + kind: 'plugin', + title: getCreatorLocalizedText(plugin.labels ?? plugin.label, locale) || plugin.name, + description: + getCreatorLocalizedText( + plugin.type === 'bundle' ? plugin.description : plugin.brief, + locale, + ) || + plugin.introduction || + '', + target: { + type: 'plugin', + org: plugin.org, + name: plugin.name, + pluginType: plugin.type, + }, + icon: { type: 'image', src: resolvePluginIcon(plugin) }, + dependencyIcons: [], + dependencyCount: 0, + updatedAt: toTimestamp(plugin.version_updated_at || plugin.updated_at), + createdAt: toTimestamp(plugin.created_at), + popularity: plugin.install_count || 0, + })) + + const templateCreations = templates.map((template): CreatorCreation => { + const templateIcon = resolveTemplateIcon(template) + const dependencyIds = (template.deps_plugins ?? []).filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ) + const publisher = + template.publisher_handle || + template.publisher_unique_handle || + template.creator_email || + 'template' + + return { + id: `template:${template.id}`, + kind: 'template', + title: template.template_name, + description: template.overview || '', + target: { + type: 'template', + id: template.id, + publisher, + templateName: template.template_name, + }, + icon: templateIcon + ? { type: 'image', src: templateIcon } + : { type: 'emoji', value: template.icon || '📄', background: template.icon_background }, + dependencyIcons: dependencyIds.map(resolveDependencyIcon), + dependencyCount: dependencyIds.length, + updatedAt: toTimestamp(template.updated_at), + createdAt: toTimestamp(template.created_at), + popularity: template.usage_count || 0, + } + }) + + return [...pluginCreations, ...templateCreations] +} + +export const adaptCreatorProfile = ({ + creator, + kind, + locale, + avatarUrl, + backgroundUrl, + plugins, + templates, + resolvePluginIcon, + resolveTemplateIcon, + resolveDependencyIcon, +}: CreatorProfileAdapterInput): CreatorProfileViewModel => { + return { + profile: { + kind, + displayName: creator.display_name || creator.name || creator.unique_handle, + handle: creator.unique_handle, + description: creator.description || undefined, + email: creator.display_email || creator.email || undefined, + avatarUrl, + backgroundUrl, + badges: getCreatorBadges(creator), + socialLinks: (creator.social_links ?? []) + .map(normalizeCreatorSocialLink) + .filter((link): link is CreatorSocialLink => link !== null), + }, + creations: adaptCreations({ + locale, + plugins, + templates, + resolvePluginIcon, + resolveTemplateIcon, + resolveDependencyIcon, + }), + } +} + +export const sortCreatorCreations = ( + creations: CreatorCreation[], + field: CreatorSortField, + order: CreatorSortOrder, +) => { + const direction = order === 'asc' ? 1 : -1 + return creations + .map((creation, index) => ({ creation, index })) + .sort((left, right) => { + const difference = (left.creation[field] - right.creation[field]) * direction + return difference || left.index - right.index + }) + .map(({ creation }) => creation) +} + +export const getStandaloneCreationHref = (creation: CreatorCreation, locale?: string) => { + const language = locale ? `language=${encodeURIComponent(locale)}` : '' + if (creation.target.type === 'plugin') { + const resource = creation.target.pluginType === 'bundle' ? 'bundles' : 'plugin' + const path = `/${resource}/${encodeURIComponent(creation.target.org)}/${encodeURIComponent(creation.target.name)}` + return language ? `${path}?${language}` : path + } + + const params = new URLSearchParams({ + templateId: creation.target.id, + creationType: 'templates', + }) + if (locale) params.set('language', locale) + return `/template/${encodeURIComponent(creation.target.publisher)}/${encodeURIComponent(creation.target.templateName)}?${params.toString()}` +} diff --git a/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx b/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx new file mode 100644 index 00000000000..2776410cd51 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/publisher-avatar.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { CSSProperties } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useState } from 'react' + +type PublisherAvatarProps = { + avatarUrl: string + name: string + isOrganization: boolean + size?: number + className?: string +} + +// Keep in sync with Creator Center `components/ui/avatar.tsx`. +const DEFAULT_AVATAR_BG = + 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%), linear-gradient(90deg, #155aef 0%, #155aef 100%)' + +const DEFAULT_AVATAR_LETTER_STYLE: CSSProperties = { + color: '#FFFFFF', + textShadow: '0px 0.25px 0.5px rgba(0, 0, 0, 0.20)', + lineHeight: '120%', + textTransform: 'uppercase', +} + +function getFallbackTextClass(size: number) { + if (size <= 32) return 'text-xs' + if (size <= 50) return 'text-base' + return 'text-[40px]' +} + +export default function PublisherAvatar({ + avatarUrl, + name, + isOrganization, + size = 24, + className, +}: PublisherAvatarProps) { + const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null) + const shapeClass = isOrganization ? 'rounded-md' : 'rounded-full' + const shouldShowImage = Boolean(avatarUrl) && failedAvatarUrl !== avatarUrl + const fallbackLetter = name?.[0]?.toUpperCase() || 'U' + + return ( + <div + style={{ width: size, height: size }} + className={cn( + 'relative shrink-0 overflow-hidden border-[0.5px] border-divider-regular', + shapeClass, + className, + )} + > + {shouldShowImage ? ( + <img + src={avatarUrl} + alt={name} + className={cn('size-full object-cover', shapeClass)} + onError={() => setFailedAvatarUrl(avatarUrl)} + /> + ) : ( + <div + className={cn('flex size-full items-center justify-center', shapeClass)} + style={{ background: DEFAULT_AVATAR_BG }} + > + <span + className={cn(getFallbackTextClass(size), 'font-semibold')} + style={DEFAULT_AVATAR_LETTER_STYLE} + > + {fallbackLetter} + </span> + </div> + )} + </div> + ) +} diff --git a/web/app/components/plugins/marketplace/creator-profile/publisher.ts b/web/app/components/plugins/marketplace/creator-profile/publisher.ts new file mode 100644 index 00000000000..624a30c2234 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/publisher.ts @@ -0,0 +1,102 @@ +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { CreatorCreation, CreatorSortField, CreatorSortOrder } from './model' +import type { Plugin } from '@/app/components/plugins/types' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { marketplaceClient } from '@/service/client' +import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils' +import { adaptCreations, toPublisherSortQuery } from './model' + +const CREATOR_PAGE_SIZE = 40 + +export type PublisherPage<T> = { + items: T[] + total?: number + hasMore: boolean +} + +const publisherPageHasMore = (page: number, itemCount: number, total?: number) => + typeof total === 'number' ? page * CREATOR_PAGE_SIZE < total : itemCount === CREATOR_PAGE_SIZE + +export const getTemplateIcon = (template: MarketplaceTemplate) => + template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon` + : '' + +export const getDependencyIcon = (pluginId: string) => { + if (!pluginId.includes('/')) return '' + return `${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon` +} + +export async function fetchPublisherPluginPage({ + uniqueHandle, + page, + sortField, + sortOrder, +}: { + uniqueHandle: string + page: number + sortField: CreatorSortField + sortOrder: CreatorSortOrder +}): Promise<PublisherPage<MarketplacePlugin>> { + const { plugins } = toPublisherSortQuery(sortField, sortOrder) + const response = await marketplaceClient.publisherPlugins({ + params: { uniqueHandle }, + query: { page, page_size: CREATOR_PAGE_SIZE, ...plugins }, + }) + const items = response.data?.plugins ?? [] + const total = response.data?.total + return { items, total, hasMore: publisherPageHasMore(page, items.length, total) } +} + +export async function fetchPublisherTemplatePage({ + uniqueHandle, + page, + sortField, + sortOrder, +}: { + uniqueHandle: string + page: number + sortField: CreatorSortField + sortOrder: CreatorSortOrder +}): Promise<PublisherPage<MarketplaceTemplate>> { + const { templates } = toPublisherSortQuery(sortField, sortOrder) + const response = await marketplaceClient.publisherTemplates({ + params: { uniqueHandle }, + query: { page, page_size: CREATOR_PAGE_SIZE, ...templates }, + }) + const items = response.data?.templates ?? [] + const total = response.data?.total + return { items, total, hasMore: publisherPageHasMore(page, items.length, total) } +} + +export const toCreatorRecords = ({ + locale, + plugins, + templates, +}: { + locale: string + plugins: MarketplacePlugin[] + templates: MarketplaceTemplate[] +}): { + creations: CreatorCreation[] + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> +} => ({ + creations: adaptCreations({ + locale, + plugins, + templates, + resolvePluginIcon: getPluginIconInMarketplace, + resolveTemplateIcon: getTemplateIcon, + resolveDependencyIcon: getDependencyIcon, + }), + pluginsByCreationId: Object.fromEntries( + plugins.map((plugin) => [ + `${plugin.type}:${plugin.org}/${plugin.name}`, + getFormattedPlugin(plugin), + ]), + ), + templatesByCreationId: Object.fromEntries( + templates.map((template) => [`template:${template.id}`, template]), + ), +}) diff --git a/web/app/components/plugins/marketplace/creator-profile/view.tsx b/web/app/components/plugins/marketplace/creator-profile/view.tsx new file mode 100644 index 00000000000..fec3ac7e8d8 --- /dev/null +++ b/web/app/components/plugins/marketplace/creator-profile/view.tsx @@ -0,0 +1,110 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { ReactNode } from 'react' +import type { + CreatorCreation, + CreatorCreationAction, + CreatorInventory, + CreatorProfileViewModel, +} from './model' +import type { Plugin } from '@/app/components/plugins/types' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import Link from '@/next/link' +import DefaultCreatorBackground from './assets/default-background.png' +import CreatorContent from './creator-content' +import CreatorSidebar from './creator-sidebar' + +export type CreatorProfileViewProps = { + profile: CreatorProfileViewModel + getCreationAction: (creation: CreatorCreation) => CreatorCreationAction + header?: ReactNode + homeHref: string + isMarketplacePlatform: boolean + inventory?: CreatorInventory + locale?: string + onRecordsLoaded?: (records: { + pluginsByCreationId: Record<string, Plugin> + templatesByCreationId: Record<string, MarketplaceTemplate> + }) => void +} + +export default function CreatorProfileView({ + profile, + getCreationAction, + header, + homeHref, + isMarketplacePlatform, + inventory, + locale, + onRecordsLoaded, +}: CreatorProfileViewProps) { + const { t } = useTranslation() + + return ( + <div className="flex min-h-full shrink-0 flex-col bg-background-default"> + {header} + <main + className={cn( + 'flex w-full flex-1 flex-col px-4', + isMarketplacePlatform ? 'md:px-6' : 'md:px-9', + )} + > + <nav + aria-label={t(($) => $['marketplace.creatorProfile.breadcrumbLabel'], { ns: 'plugin' })} + className="flex h-12 shrink-0 items-end gap-2 overflow-hidden" + > + <Link + href={homeHref} + aria-label={t(($) => $['marketplace.creatorProfile.home'], { ns: 'plugin' })} + className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid" + > + <span aria-hidden className="i-ri-home-4-line size-4" /> + </Link> + <span aria-hidden className="pb-0.5 system-md-regular text-text-quaternary"> + / + </span> + <span className="pb-0.5 system-md-regular text-text-primary"> + {t(($) => $['marketplace.creatorProfile.title'], { ns: 'plugin' })} + </span> + </nav> + + <div className="w-full pt-5 pb-8"> + <div + className="relative h-40 w-full overflow-hidden rounded-xl border-0 bg-cover bg-center bg-no-repeat md:h-60" + style={{ backgroundImage: `url("${DefaultCreatorBackground.src}")` }} + > + {profile.profile.backgroundUrl && ( + <img + alt="" + aria-hidden + src={profile.profile.backgroundUrl} + className="size-full border-0 object-cover object-center" + onError={(event) => { + event.currentTarget.hidden = true + }} + /> + )} + </div> + + <div + className={cn( + 'grid min-w-0 grid-cols-1 gap-8 md:grid-cols-[234px_minmax(0,1fr)]', + isMarketplacePlatform ? 'md:pl-4' : 'md:pl-9', + )} + > + <CreatorSidebar profile={profile.profile} /> + <CreatorContent + creations={profile.creations} + getCreationAction={getCreationAction} + inventory={inventory} + locale={locale} + onRecordsLoaded={onRecordsLoaded} + /> + </div> + </div> + </main> + </div> + ) +} diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index d4dc268ab93..af8a7ea12ef 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -7,6 +7,7 @@ import { useLocale, useTranslation } from '#i18n' import Divider from '@/app/components/base/divider' import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { SubmitRequestDropdown } from '@/app/components/plugins/plugin-page/nav-operations' +import { MARKETPLACE_CONTAINER_ID } from '../constants' import PluginTypeSwitch from '../plugin-type-switch' import SearchBoxWrapper from '../search-box/search-box-wrapper' @@ -27,7 +28,7 @@ const EXPANDED_TABS_MARGIN_TOP = 32 const Description = ({ isMarketplacePlatform = false, marketplaceNav, - scrollContainerId = 'marketplace-container', + scrollContainerId = MARKETPLACE_CONTAINER_ID, }: DescriptionProps) => { const { t } = useTranslation('plugin') const { t: tCommon } = useTranslation('common') diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx new file mode 100644 index 00000000000..47f2727ac08 --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx @@ -0,0 +1,251 @@ +import type { Plugin } from '@/app/components/plugins/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ThemeProvider } from 'next-themes' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import MarketplaceDetailDialog from '../index' + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), +})) + +vi.mock('../../utils', () => ({ + getPluginLinkInMarketplace: ( + plugin: Plugin, + params: { + canInstall?: string + installed: string + language: string + source?: string + theme?: string + view: string + }, + ) => + `about:blank?plugin=${plugin.org}/${plugin.name}&installed=${params.installed}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}&canInstall=${params.canInstall}`, +})) + +vi.mock('../use-silent-install', () => ({ + useSilentMarketplaceInstall: () => ({ install: mocks.install }), +})) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'plugin-a', + plugin_id: 'plugin-a', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'pkg', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'Plugin A' }, + brief: { 'en-US': 'Brief' }, + description: { 'en-US': 'Description' }, + introduction: 'Intro', + repository: 'https://github.com/dify/plugin-a', + category: PluginCategoryEnum.tool, + install_count: 42, + endpoint: { settings: [] }, + tags: [], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +} as Plugin + +describe('MarketplaceDetailDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.install.mockResolvedValue({ status: 'success' }) + }) + + it('renders the marketplace detail route in modal mode and closes in place', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog open isInstalled plugin={plugin} onOpenChange={onOpenChange} /> + </ThemeProvider>, + ) + + const frame = screen.getByTitle('Plugin A · plugin.detailPanel.operation.detail') + expect(frame).toHaveAttribute( + 'src', + // resolvedTheme maps the "system" preference to the concrete value, so + // the embedded detail page receives light/dark rather than "system". + 'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal&canInstall=true', + ) + expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.close' })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('installs from the embedded detail frame without opening a confirmation dialog', async () => { + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog open isInstalled={false} plugin={plugin} onOpenChange={vi.fn()} /> + </ThemeProvider>, + ) + + const frame = screen.getByTitle( + 'Plugin A · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage') + const installRequest = { + type: 'dify-marketplace:install-plugin', + pluginUniqueIdentifier: plugin.latest_package_identifier, + } + fireEvent( + window, + new MessageEvent('message', { + data: installRequest, + origin: 'https://attacker.example', + source: frame.contentWindow, + }), + ) + fireEvent( + window, + new MessageEvent('message', { + data: { + ...installRequest, + pluginUniqueIdentifier: 'another/plugin:1.0.0', + }, + origin: 'null', + source: frame.contentWindow, + }), + ) + expect(mocks.install).not.toHaveBeenCalled() + + fireEvent( + window, + new MessageEvent('message', { + data: installRequest, + origin: 'null', + source: frame.contentWindow, + }), + ) + + expect(mocks.install).toHaveBeenCalledOnce() + expect(mocks.install).toHaveBeenCalledWith(plugin) + expect(screen.queryByRole('dialog', { name: 'plugin.installModal.installPlugin' })).toBeNull() + + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith( + { + type: 'dify-marketplace:install-plugin-status', + pluginUniqueIdentifier: plugin.latest_package_identifier, + status: 'success', + }, + 'null', + ) + }) + }) + + it('does not install when the workspace lacks plugin.install', async () => { + render( + <PluginInstallPermissionProvider canInstallPlugin={false}> + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog + open + isInstalled={false} + plugin={plugin} + onOpenChange={vi.fn()} + /> + </ThemeProvider> + </PluginInstallPermissionProvider>, + ) + + const frame = screen.getByTitle( + 'Plugin A · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + expect(frame).toHaveAttribute( + 'src', + 'about:blank?plugin=dify/plugin-a&installed=false&language=en-US&source=http://localhost:3000&theme=light&view=modal&canInstall=false', + ) + const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage') + fireEvent( + window, + new MessageEvent('message', { + data: { + type: 'dify-marketplace:install-plugin', + pluginUniqueIdentifier: plugin.latest_package_identifier, + }, + origin: 'null', + source: frame.contentWindow, + }), + ) + + expect(mocks.install).not.toHaveBeenCalled() + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith( + { + type: 'dify-marketplace:install-plugin-status', + pluginUniqueIdentifier: plugin.latest_package_identifier, + status: 'failed', + }, + 'null', + ) + }) + }) + + it('ignores a late install result after the timeout has already settled', async () => { + vi.useFakeTimers() + let finishInstall: ((result: { status: 'success' }) => void) | undefined + mocks.install.mockImplementation( + () => + new Promise((resolve) => { + finishInstall = resolve + }), + ) + + try { + render( + <ThemeProvider forcedTheme="dark"> + <MarketplaceDetailDialog + open + isInstalled={false} + plugin={plugin} + onOpenChange={vi.fn()} + /> + </ThemeProvider>, + ) + + const frame = screen.getByTitle( + 'Plugin A · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + const postMessage = vi.spyOn(frame.contentWindow!, 'postMessage') + fireEvent( + window, + new MessageEvent('message', { + data: { + type: 'dify-marketplace:install-plugin', + pluginUniqueIdentifier: plugin.latest_package_identifier, + }, + origin: 'null', + source: frame.contentWindow, + }), + ) + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + expect(postMessage).toHaveBeenCalledWith( + { + type: 'dify-marketplace:install-plugin-status', + pluginUniqueIdentifier: plugin.latest_package_identifier, + status: 'timeout', + }, + 'null', + ) + + finishInstall?.({ status: 'success' }) + await Promise.resolve() + await vi.runAllTimersAsync() + + expect(postMessage).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts b/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts new file mode 100644 index 00000000000..b3bcfb00a8a --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/__tests__/use-silent-install.spec.ts @@ -0,0 +1,110 @@ +import type { Plugin } from '@/app/components/plugins/types' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types' +import { useSilentMarketplaceInstall } from '../use-silent-install' + +const mockInstallPackageFromMarketPlace = vi.fn() +const mockRefreshPluginList = vi.fn() +const mockCheckTaskStatus = vi.fn() + +vi.mock('@/service/use-plugins', () => ({ + useInstallPackageFromMarketPlace: () => ({ + mutateAsync: mockInstallPackageFromMarketPlace, + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: mockRefreshPluginList }), +})) + +vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({ + default: () => ({ + check: mockCheckTaskStatus, + stop: vi.fn(), + }), +})) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'plugin-a', + plugin_id: 'dify/plugin-a', + latest_package_identifier: 'dify/plugin-a:1.0.0@pkg', + category: PluginCategoryEnum.tool, +} as Plugin + +describe('useSilentMarketplaceInstall', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: true, + task_id: 'task-1', + }) + mockCheckTaskStatus.mockResolvedValue({ status: TaskStatus.success }) + }) + + it('installs immediately when the marketplace package is already fully installed', async () => { + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + await expect(result.current.install(plugin)).resolves.toEqual({ status: 'success' }) + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledWith(plugin.latest_package_identifier) + expect(mockCheckTaskStatus).not.toHaveBeenCalled() + expect(mockRefreshPluginList).toHaveBeenCalledWith(plugin) + }) + + it('waits for the install task instead of showing a confirmation step', async () => { + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: false, + task_id: 'task-2', + }) + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + await expect(result.current.install(plugin)).resolves.toEqual({ status: 'success' }) + expect(mockCheckTaskStatus).toHaveBeenCalledWith({ + taskId: 'task-2', + pluginUniqueIdentifier: plugin.latest_package_identifier, + }) + expect(mockRefreshPluginList).toHaveBeenCalledWith(plugin) + }) + + it('returns the task error when installation fails', async () => { + mockInstallPackageFromMarketPlace.mockResolvedValue({ + all_installed: false, + task_id: 'task-3', + }) + mockCheckTaskStatus.mockResolvedValue({ + status: TaskStatus.failed, + error: 'Package not found', + }) + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + await expect(result.current.install(plugin)).resolves.toEqual({ + status: 'failed', + error: 'Package not found', + }) + expect(mockRefreshPluginList).not.toHaveBeenCalled() + }) + + it('reuses an in-flight install instead of starting a second package request', async () => { + let resolveInstall: ((value: { all_installed: boolean; task_id: string }) => void) | undefined + mockInstallPackageFromMarketPlace.mockImplementation( + () => + new Promise((resolve) => { + resolveInstall = resolve + }), + ) + const { result } = renderHook(() => useSilentMarketplaceInstall()) + + const first = result.current.install(plugin) + const second = result.current.install(plugin) + + expect(mockInstallPackageFromMarketPlace).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveInstall?.({ all_installed: true, task_id: 'task-4' }) + await expect(first).resolves.toEqual({ status: 'success' }) + await expect(second).resolves.toEqual({ status: 'success' }) + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/detail-dialog/frame.tsx b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx new file mode 100644 index 00000000000..27969d2ac78 --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/frame.tsx @@ -0,0 +1,137 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { + Dialog, + DialogBackdrop, + DialogClose, + DialogPopup, + DialogPortal, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' + +type ReplyToMarketplaceFrame = (data: unknown) => void + +type MarketplaceDetailDialogFrameProps = { + open: boolean + src: string + title: string + onMessage?: (data: unknown, reply: ReplyToMarketplaceFrame) => void + onOpenChange: (open: boolean) => void +} + +// The iframe load event can be delayed indefinitely on a stalled connection +// (and cross-origin load errors are not observable), so reveal the frame after +// this timeout instead of keeping the skeleton up forever. +const LOADING_REVEAL_TIMEOUT_MS = 15_000 + +export default function MarketplaceDetailDialogFrame({ + open, + src, + title, + onMessage, + onOpenChange, +}: MarketplaceDetailDialogFrameProps) { + const { t } = useTranslation() + const iframeRef = useRef<HTMLIFrameElement>(null) + const closeButtonRef = useRef<HTMLButtonElement>(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + if (!open) return + + const timeout = window.setTimeout(() => setIsLoading(false), LOADING_REVEAL_TIMEOUT_MS) + return () => window.clearTimeout(timeout) + }, [open, src]) + + useEffect(() => { + if (!open || !onMessage) return + + const marketplaceOrigin = new URL(src, window.location.href).origin + const handleMessage = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow || event.origin !== marketplaceOrigin) + return + + onMessage(event.data, (payload) => { + iframeRef.current?.contentWindow?.postMessage(payload, marketplaceOrigin) + }) + } + + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [onMessage, open, src]) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setIsLoading(true) + onOpenChange(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogPortal> + <DialogBackdrop /> + {/* Keep initial focus on the visible close control: while the iframe is + still loading it is inert, so default focus could otherwise land on + an invisible cross-origin frame. */} + <DialogPopup + initialFocus={closeButtonRef} + className="fixed top-1/2 left-1/2 h-[min(800px,calc(100dvh-48px))] w-[min(1200px,calc(100vw-48px))] -translate-x-1/2 -translate-y-1/2 overflow-hidden border-0 p-0 shadow-xl" + > + <DialogTitle className="sr-only">{title}</DialogTitle> + <div + aria-hidden + className={cn( + 'absolute inset-0 bg-background-default transition-opacity', + isLoading ? 'opacity-100' : 'pointer-events-none opacity-0', + )} + > + <div className="flex h-[52px] items-center px-6"> + <div className="h-4 w-40 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + </div> + <div className="mx-auto flex w-full max-w-[1000px] gap-8 px-12 py-8"> + <div className="flex flex-1 flex-col gap-4"> + <div className="h-16 w-2/3 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-4 w-full animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-4 w-5/6 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" /> + <div className="mt-8 h-72 w-full animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + </div> + <div className="hidden w-60 flex-col gap-4 lg:flex"> + <div className="h-24 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + <div className="h-52 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" /> + </div> + </div> + </div> + <iframe + ref={iframeRef} + // While loading, remove the invisible frame from focus, pointer, + // and accessibility interaction until its content is presentable. + inert={isLoading} + className={cn( + 'size-full border-0 bg-background-default transition-opacity', + isLoading ? 'pointer-events-none opacity-0' : 'opacity-100', + )} + onLoad={() => setIsLoading(false)} + referrerPolicy="strict-origin-when-cross-origin" + src={src} + title={title} + /> + <DialogClose + render={ + <IconButton + ref={closeButtonRef} + aria-label={t(($) => $['operation.close'], { ns: 'common' })} + size="sm" + className="absolute top-5 right-5 z-10 size-8 rounded-lg" + > + <span aria-hidden className="i-ri-close-line size-4" /> + </IconButton> + } + /> + </DialogPopup> + </DialogPortal> + </Dialog> + ) +} diff --git a/web/app/components/plugins/marketplace/detail-dialog/index.tsx b/web/app/components/plugins/marketplace/detail-dialog/index.tsx new file mode 100644 index 00000000000..b491f5660cd --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/index.tsx @@ -0,0 +1,161 @@ +'use client' + +import type { Plugin } from '@/app/components/plugins/types' +import { useTheme } from 'next-themes' +import { useCallback, useEffect, useRef } from 'react' +import { useLocale, useTranslation } from '#i18n' +import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission' +import { getPluginLinkInMarketplace } from '../utils' +import MarketplaceDetailDialogFrame from './frame' +import { useSilentMarketplaceInstall } from './use-silent-install' + +const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-plugin' +const MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE = 'dify-marketplace:install-plugin-status' +const SILENT_INSTALL_TIMEOUT_MS = 5 * 60 * 1000 + +type MarketplaceDetailDialogProps = { + isInstalled: boolean + open: boolean + plugin: Plugin + onOpenChange: (open: boolean) => void +} + +const isInstallRequest = (data: unknown, pluginUniqueIdentifier: string) => { + return ( + typeof data === 'object' && + data !== null && + 'type' in data && + 'pluginUniqueIdentifier' in data && + data.type === MARKETPLACE_INSTALL_MESSAGE_TYPE && + data.pluginUniqueIdentifier === pluginUniqueIdentifier + ) +} + +function OpenMarketplaceDetailDialog({ + canInstallPlugin, + onOpenChange, + plugin, + src, + title, +}: { + canInstallPlugin: boolean + onOpenChange: (open: boolean) => void + plugin: Plugin + src: string + title: string +}) { + const { install } = useSilentMarketplaceInstall() + const timeoutIdsRef = useRef(new Set<number>()) + + useEffect( + () => () => { + timeoutIdsRef.current.forEach((id) => window.clearTimeout(id)) + timeoutIdsRef.current.clear() + }, + [], + ) + + const handleMessage = useCallback( + (data: unknown, reply: (payload: unknown) => void) => { + if (!isInstallRequest(data, plugin.latest_package_identifier)) return + + const uniqueIdentifier = plugin.latest_package_identifier + if (!canInstallPlugin) { + reply({ + type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE, + pluginUniqueIdentifier: uniqueIdentifier, + status: 'failed', + }) + return + } + + let settled = false + const settle = (payload: Record<string, unknown>) => { + if (settled) return + settled = true + reply(payload) + } + + const timeoutId = window.setTimeout(() => { + timeoutIdsRef.current.delete(timeoutId) + settle({ + type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE, + pluginUniqueIdentifier: uniqueIdentifier, + status: 'timeout', + }) + }, SILENT_INSTALL_TIMEOUT_MS) + timeoutIdsRef.current.add(timeoutId) + + void install(plugin).then((result) => { + window.clearTimeout(timeoutId) + timeoutIdsRef.current.delete(timeoutId) + settle({ + type: MARKETPLACE_INSTALL_STATUS_MESSAGE_TYPE, + pluginUniqueIdentifier: uniqueIdentifier, + ...result, + }) + }) + }, + [canInstallPlugin, install, plugin], + ) + + return ( + <MarketplaceDetailDialogFrame + open + src={src} + title={title} + onMessage={handleMessage} + onOpenChange={onOpenChange} + /> + ) +} + +function MarketplaceDetailDialog({ + isInstalled, + open, + plugin, + onOpenChange, +}: MarketplaceDetailDialogProps) { + const { t } = useTranslation() + const locale = useLocale() + const { canInstallPlugin } = useOptionalPluginInstallPermission() + // resolvedTheme maps the "system" preference to the concrete light/dark + // value the marketplace page expects. + const { resolvedTheme } = useTheme() + const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name + const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' }) + const installedForSrcRef = useRef(isInstalled) + if (!open) installedForSrcRef.current = isInstalled + const detailURL = getPluginLinkInMarketplace(plugin, { + canInstall: String(canInstallPlugin), + installed: String(installedForSrcRef.current), + language: locale, + source: globalThis.location?.origin, + theme: resolvedTheme, + view: 'modal', + }) + const title = `${pluginLabel} · ${detailLabel}` + + if (!open) { + return ( + <MarketplaceDetailDialogFrame + open={false} + src={detailURL} + title={title} + onOpenChange={onOpenChange} + /> + ) + } + + return ( + <OpenMarketplaceDetailDialog + canInstallPlugin={canInstallPlugin} + plugin={plugin} + src={detailURL} + title={title} + onOpenChange={onOpenChange} + /> + ) +} + +export default MarketplaceDetailDialog diff --git a/web/app/components/plugins/marketplace/detail-dialog/use-silent-install.ts b/web/app/components/plugins/marketplace/detail-dialog/use-silent-install.ts new file mode 100644 index 00000000000..9cbfa9a0a8c --- /dev/null +++ b/web/app/components/plugins/marketplace/detail-dialog/use-silent-install.ts @@ -0,0 +1,64 @@ +'use client' + +import type { Plugin } from '@/app/components/plugins/types' +import { useCallback } from 'react' +import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status' +import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' +import { TaskStatus } from '@/app/components/plugins/types' +import { useInstallPackageFromMarketPlace } from '@/service/use-plugins' + +export type SilentMarketplaceInstallResult = + | { status: 'failed'; error?: string } + | { status: 'success' } + +const inFlightInstalls = new Map<string, Promise<SilentMarketplaceInstallResult>>() + +const toErrorMessage = (error: unknown) => { + if (typeof error === 'string' && error) return error + if (error instanceof Error && error.message) return error.message + return undefined +} + +export const useSilentMarketplaceInstall = () => { + const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace() + const { refreshPluginList } = useRefreshPluginList() + + const install = useCallback( + (plugin: Plugin) => { + const uniqueIdentifier = plugin.latest_package_identifier + const inFlight = inFlightInstalls.get(uniqueIdentifier) + if (inFlight) return inFlight + + const pending = (async (): Promise<SilentMarketplaceInstallResult> => { + try { + const response = await installPackageFromMarketPlace(uniqueIdentifier) + if (response.all_installed) { + refreshPluginList(plugin) + return { status: 'success' } + } + if (!response.task_id) return { status: 'failed' } + + const { check } = checkTaskStatus() + const { status, error } = await check({ + taskId: response.task_id, + pluginUniqueIdentifier: uniqueIdentifier, + }) + if (status === TaskStatus.failed) return { status: 'failed', error } + + refreshPluginList(plugin) + return { status: 'success' } + } catch (error) { + return { status: 'failed', error: toErrorMessage(error) } + } + })().finally(() => { + inFlightInstalls.delete(uniqueIdentifier) + }) + + inFlightInstalls.set(uniqueIdentifier, pending) + return pending + }, + [installPackageFromMarketPlace, refreshPluginList], + ) + + return { install } +} diff --git a/web/app/components/plugins/marketplace/embedded.tsx b/web/app/components/plugins/marketplace/embedded.tsx new file mode 100644 index 00000000000..297d6504579 --- /dev/null +++ b/web/app/components/plugins/marketplace/embedded.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { MarketplaceViewProps } from './view' +import { queryOptions, useQuery } from '@tanstack/react-query' +import { useLocale } from '@/context/i18n' +import { useResetMarketplaceSearchModeOnMount } from './atoms' +import { fetchPluginBanners } from './home/banners' +import { MarketplaceView } from './view' + +const BANNER_STALE_TIME = 1000 * 60 * 5 + +export type EmbeddedMarketplaceProps = Omit<MarketplaceViewProps, 'banners'> & { + initialBanners?: PluginBanner[] + /** + * Locale used to fetch `initialBanners` during server rendering. `initialBanners` + * is only applied while the client locale still matches it, so a client-side + * language change refetches banners instead of seeding the new locale's cache + * with banners from the previous language. + */ + initialLocale?: string +} + +export function EmbeddedMarketplace({ + initialBanners, + initialLocale, + variant = 'default', + ...props +}: EmbeddedMarketplaceProps) { + useResetMarketplaceSearchModeOnMount() + const locale = useLocale() + const { data: banners = [] } = useQuery( + queryOptions({ + // fetchPluginBanners returns normalized PluginBanner[] rather than the + // raw contract response, so it uses its own cache key instead of + // impersonating the generated banners.list contract query. + queryKey: ['marketplace-banners', locale], + queryFn: () => fetchPluginBanners(locale), + enabled: variant === 'home', + initialData: locale === initialLocale ? initialBanners : undefined, + staleTime: BANNER_STALE_TIME, + }), + ) + + return <MarketplaceView {...props} banners={banners} variant={variant} /> +} diff --git a/web/app/components/plugins/marketplace/filter-track-link.tsx b/web/app/components/plugins/marketplace/filter-track-link.tsx new file mode 100644 index 00000000000..acb520df383 --- /dev/null +++ b/web/app/components/plugins/marketplace/filter-track-link.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { ComponentProps } from 'react' +import Link from '@/next/link' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' + +type MarketplaceFilterTrackLinkProps = ComponentProps<typeof Link> & { + filterValue: string + filterType: 'type_tab' | 'category' | 'language' + selectedValues: string[] + selectionMode?: 'single' | 'multi' + trackFilter?: boolean +} + +export default function MarketplaceFilterTrackLink({ + filterValue, + filterType, + selectedValues, + selectionMode = 'single', + trackFilter = true, + onClick, + ...props +}: MarketplaceFilterTrackLinkProps) { + return ( + <Link + {...props} + onClick={(event) => { + if (trackFilter) { + markMarketplaceSiteFilter({ + filter_type: filterType, + selection_mode: selectionMode, + filter_value: filterValue, + selected_values: selectedValues, + }) + } + onClick?.(event) + }} + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/README.md b/web/app/components/plugins/marketplace/home/README.md new file mode 100644 index 00000000000..2284732297e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/README.md @@ -0,0 +1,12 @@ +# Marketplace Catalog Home + +The redesigned Marketplace catalog shell provides the shared header, hero, search, trending, tabs, and sticky category navigation used by the Plugins and Templates pages. + +## Internal Modules + +- `marketplace/list/list-wrapper` +- `marketplace/plugin-type-switch` + +## External Modules + +None. diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx new file mode 100644 index 00000000000..79571b30f3e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/catalog-languages-filter.spec.tsx @@ -0,0 +1,32 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithNuqs } from '@/test/nuqs-testing' +import CatalogLanguagesFilter from '../catalog-languages-filter' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ), + }), + } +}) + +describe('CatalogLanguagesFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes selected languages into the URL', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs(<CatalogLanguagesFilter />) + await user.click(screen.getByRole('button', { name: 'plugin.marketplace.languages' })) + await user.click(screen.getByRole('checkbox', { name: '中文' })) + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('languages')).toBe('zh-Hans') + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx new file mode 100644 index 00000000000..97bdd8e1ce5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/catalog-tags-filter.spec.tsx @@ -0,0 +1,47 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithNuqs } from '@/test/nuqs-testing' +import CatalogTagsFilter from '../catalog-tags-filter' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ), + }), + } +}) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + ], + tagsMap: { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, + }, + }), +})) + +describe('CatalogTagsFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes selected tags into the URL', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderWithNuqs(<CatalogTagsFilter />) + await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' })) + await user.click(screen.getByRole('checkbox', { name: 'Agent' })) + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('tags')).toBe('agent') + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx new file mode 100644 index 00000000000..ba7d2be04dc --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/embedded-marketplace-search.spec.tsx @@ -0,0 +1,243 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { renderWithNuqs } from '@/test/nuqs-testing' +import EmbeddedMarketplaceSearch from '../embedded-marketplace-search' + +const { debounceState, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ + debounceState: { useRealDebounce: false }, + mockPluginSearch: vi.fn(), + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T, options?: { wait?: number }) => + debounceState.useRealDebounce ? original.useDebounce(value, options) : value, + } +}) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.home.searchPlaceholder': 'Search plugins or templates', + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.loadError': 'Failed to load. Please try again.', + 'marketplace.noPluginFound': 'No integration found', + 'newApp.noTemplateFound': 'No templates found', + clearSearch: 'Clear search', + loading: 'Loading', + } + + return { + useLocale: () => 'en-US', + useTranslation: () => ({ + t: withSelectorKey((key: string) => translations[key] ?? key), + }), + } +}) + +vi.mock('@/service/client', () => ({ + marketplaceQuery: { + searchAdvanced: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'plugins', input], + queryFn: () => mockPluginSearch(input), + }), + }, + templateSearch: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'templates', input], + queryFn: () => mockTemplateSearch(input), + }), + }, + }, +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: () => ({ installedInfo: {} }), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('../../detail-dialog', () => ({ + default: ({ plugin }: { plugin: { name: string } }) => ( + <div role="dialog" aria-label="plugin-detail"> + {plugin.name} + </div> + ), +})) + +vi.mock('../../templates/template-detail-dialog', () => ({ + default: ({ template }: { template: { template_name: string } }) => ( + <div role="dialog" aria-label="template-detail"> + {template.template_name} + </div> + ), +})) + +let queryClient: QueryClient + +const renderSearch = () => { + const { onUrlUpdate } = renderWithNuqs( + <QueryClientProvider client={queryClient}> + <EmbeddedMarketplaceSearch /> + </QueryClientProvider>, + ) + + return { onUrlUpdate } +} + +describe('EmbeddedMarketplaceSearch', () => { + beforeEach(() => { + vi.clearAllMocks() + debounceState.useRealDebounce = false + queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, + }) + mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } }) + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + it('shows mixed plugin and template suggestions in the in-app search popup', async () => { + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + }) + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const { onUrlUpdate } = renderSearch() + + await user.type(screen.getByRole('combobox'), 'search') + + const templateGroup = await screen.findByRole('group', { name: 'Templates' }) + const pluginGroup = screen.getByRole('group', { name: 'Plugins' }) + expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument() + expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument() + expect(onUrlUpdate).not.toHaveBeenCalled() + }) + + it('opens plugin and template details from the popup without filtering the catalog', async () => { + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + }) + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + plugin_id: 'langgenius/google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const { onUrlUpdate } = renderSearch() + + await user.type(screen.getByRole('combobox'), 'search') + await user.click(await screen.findByText('Google Search')) + + expect(screen.getByRole('dialog', { name: 'plugin-detail' })).toHaveTextContent('google-search') + expect(onUrlUpdate).not.toHaveBeenCalled() + + await user.type(screen.getByRole('combobox'), 'search') + await user.click(await screen.findByText('Legal Research Agent')) + + expect(screen.getByRole('dialog', { name: 'template-detail' })).toHaveTextContent( + 'Legal Research Agent', + ) + expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument() + }) + + it('filters the current catalog when Enter is pressed instead of opening a result', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + plugin_id: 'langgenius/google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const { onUrlUpdate } = renderSearch() + + await user.type(screen.getByRole('combobox'), 'google') + await user.hover(await screen.findByRole('option', { name: /Google Search/ })) + await user.keyboard('{Enter}') + + await waitFor(() => { + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('q')).toBe('google') + }) + expect(screen.queryByRole('dialog', { name: 'plugin-detail' })).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts new file mode 100644 index 00000000000..e1236270d32 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/event-ad-banner-image.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + EMBEDDED_MOBILE_BANNER_MEDIA, + MARKETPLACE_MOBILE_BANNER_MEDIA, + marketplaceTabletBannerMedia, + resolveEventAdBannerImageSrcs, +} from '../event-ad-banner-image' + +describe('resolveEventAdBannerImageSrcs', () => { + it('uses the mobile asset on the mobile slot when one exists', () => { + expect( + resolveEventAdBannerImageSrcs({ + desktop: '/desktop.png', + tablet: '/tablet.png', + mobile: '/mobile.png', + }), + ).toEqual({ + desktop: '/desktop.png', + mobile: '/mobile.png', + tablet: '/tablet.png', + }) + }) + + it('falls back to desktop on the mobile slot when mobile is missing', () => { + expect( + resolveEventAdBannerImageSrcs({ + desktop: '/desktop.png', + tablet: '/tablet.png', + }), + ).toEqual({ + desktop: '/desktop.png', + mobile: '/desktop.png', + tablet: '/tablet.png', + }) + }) + + it('omits tablet when the banner has no tablet asset', () => { + expect( + resolveEventAdBannerImageSrcs({ + desktop: '/desktop.png', + mobile: '/mobile.png', + }), + ).toEqual({ + desktop: '/desktop.png', + mobile: '/mobile.png', + tablet: undefined, + }) + }) +}) + +describe('marketplaceTabletBannerMedia', () => { + it('keeps tablet out of the standalone mobile breakpoint', () => { + expect(MARKETPLACE_MOBILE_BANNER_MEDIA).toBe('(max-width: 879px)') + expect(marketplaceTabletBannerMedia(true)).toBe('(min-width: 880px) and (max-width: 1023px)') + }) + + it('keeps tablet out of the embedded mobile breakpoint', () => { + expect(EMBEDDED_MOBILE_BANNER_MEDIA).toBe('(max-width: 639px)') + expect(marketplaceTabletBannerMedia(false)).toBe('(min-width: 640px) and (max-width: 1023px)') + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx new file mode 100644 index 00000000000..3e1f3117bd4 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-alignment.browser.spec.tsx @@ -0,0 +1,41 @@ +import { render } from 'vitest-browser-react' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeCatalogTabs from '../home-catalog-tabs' +import { HomeStickyStateProvider } from '../home-sticky-state-provider' +import styles from '../home-sticky.module.css' + +describe('Marketplace home catalog alignment', () => { + it('aligns catalog tabs and filters with the content container', async () => { + const screen = await render( + <HomeStickyStateProvider> + <div className="w-[1200px]" data-marketplace-standalone> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={ + <div data-testid="catalog-filter" role="group" aria-label="Categories" /> + } + catalogTabs={ + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + } + /> + <div className={`px-8 ${styles.catalogContent}`}> + <div role="region" aria-label="Catalog content" className="h-10" /> + </div> + </div> + </HomeStickyStateProvider>, + ) + + const contentLeft = screen + .getByRole('region', { name: 'Catalog content' }) + .element() + .getBoundingClientRect().left + const tabsLeft = screen.getByRole('navigation').element().getBoundingClientRect().left + const filtersLeft = screen.getByTestId('catalog-filter').element().getBoundingClientRect().left + + expect(tabsLeft).toBeCloseTo(contentLeft) + expect(filtersLeft).toBeCloseTo(contentLeft) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx new file mode 100644 index 00000000000..25b68060190 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-handoff.browser.spec.tsx @@ -0,0 +1,241 @@ +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeCatalogTabs from '../home-catalog-tabs' +import { + HOME_HEADER_HEIGHT_PX, + HOME_SEARCH_HEIGHT_PX, + HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, +} from '../home-constants' +import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider' +import styles from '../home-sticky.module.css' + +describe('Marketplace catalog tab handoff', () => { + it('hands off only when the in-flow tabs fully reach the sticky header', async () => { + await page.viewport(1200, 800) + const screen = await render( + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <div + data-testid="catalog-header" + style={{ + position: 'sticky', + top: 0, + zIndex: 50, + display: 'flex', + height: 48, + flexShrink: 0, + alignItems: 'center', + background: 'white', + }} + > + <HomeStickyCatalogTabs> + <div className={styles.headerCatalogTabs} data-testid="header-catalog-tabs"> + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + </div> + </HomeStickyCatalogTabs> + </div> + <div style={{ height: 220, flexShrink: 0 }} /> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div data-testid="catalog-categories">Categories</div>} + catalogTabs={ + <div data-testid="content-catalog-tabs"> + <HomeCatalogTabs + isMarketplacePlatform + labels={{ plugins: 'Plugins', templates: 'Templates' }} + /> + </div> + } + /> + <div data-testid="following-content" style={{ height: 640, flexShrink: 0 }} /> + </div> + </HomeStickyStateProvider>, + ) + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByTestId('catalog-header').element() + const navigation = screen.getByRole('region').element() + const categories = screen.getByTestId('catalog-categories').element() + const contentTabsSlot = screen.getByTestId('content-catalog-tabs').element().parentElement! + const contentTabsRegion = contentTabsSlot.parentElement! + const headerTabsSlot = screen.getByTestId('header-catalog-tabs').element().parentElement! + const followingContent = screen.getByTestId('following-content').element() as HTMLElement + const initialHeight = navigation.getBoundingClientRect().height + const initialCategoryOffset = + categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top + const initialHeaderSlotWidth = headerTabsSlot.getBoundingClientRect().width + const initialHeaderSlotHeight = headerTabsSlot.getBoundingClientRect().height + const initialFollowingOffset = followingContent.offsetTop + const initialScrollHeight = scrollContainer.scrollHeight + const contentPluginsLink = + contentTabsSlot.querySelector<HTMLAnchorElement>('a[href="/plugins"]')! + const headerPluginsLink = headerTabsSlot.querySelector<HTMLAnchorElement>('a[href="/plugins"]')! + + expect(initialHeaderSlotWidth).toBeGreaterThan(0) + expect(initialHeaderSlotHeight).toBeGreaterThan(0) + expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('none') + expect(getComputedStyle(headerTabsSlot).transitionProperty).toBe('opacity, transform') + expect(getComputedStyle(headerTabsSlot).transitionDuration).toBe('0.14s') + + contentPluginsLink.focus() + expect(document.activeElement).toBe(contentPluginsLink) + + const handoffScrollTop = + scrollContainer.scrollTop + + contentTabsRegion.getBoundingClientRect().bottom - + header.getBoundingClientRect().bottom + scrollContainer.scrollTop = handoffScrollTop - 1 + scrollContainer.dispatchEvent(new Event('scroll')) + await new Promise<void>((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ) + + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1) + expect( + contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom, + ).toBeCloseTo(1) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(document.activeElement).toBe(contentPluginsLink) + + scrollContainer.scrollTop = handoffScrollTop + scrollContainer.dispatchEvent(new Event('scroll')) + await vi.waitFor(() => { + expect(navigation).toHaveClass(styles.catalogNavigationPinned!) + }) + + expect(scrollContainer.scrollTop).toBe(handoffScrollTop) + expect(navigation.getBoundingClientRect().height).toBeCloseTo(initialHeight) + expect( + categories.getBoundingClientRect().top - navigation.getBoundingClientRect().top, + ).toBeCloseTo(initialCategoryOffset) + expect( + categories.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + ).toBeCloseTo(64) + expect( + contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom, + ).toBeCloseTo(0) + expect(headerTabsSlot.getBoundingClientRect().width).toBeCloseTo(initialHeaderSlotWidth) + expect(headerTabsSlot.getBoundingClientRect().height).toBeCloseTo(initialHeaderSlotHeight) + expect(followingContent.offsetTop).toBe(initialFollowingOffset) + expect(scrollContainer.scrollHeight).toBe(initialScrollHeight) + expect(getComputedStyle(contentTabsSlot).display).not.toBe('none') + expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('none') + expect(getComputedStyle(contentTabsSlot).transitionProperty).toBe('opacity, transform') + expect(getComputedStyle(contentTabsSlot).transitionDuration).toBe('0.14s') + expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(contentTabsSlot).toHaveAttribute('inert') + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + expect(getComputedStyle(headerTabsSlot).pointerEvents).toBe('auto') + await vi.waitFor( + () => { + expect(getComputedStyle(contentTabsSlot).opacity).toBe('0') + expect(getComputedStyle(headerTabsSlot).opacity).toBe('1') + expect(document.activeElement).toBe(headerPluginsLink) + }, + { timeout: 500 }, + ) + + scrollContainer.scrollTop = handoffScrollTop - 1 + scrollContainer.dispatchEvent(new Event('scroll')) + await vi.waitFor(() => { + expect(document.activeElement).toBe(contentPluginsLink) + }) + + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + expect(scrollContainer.scrollTop).toBe(handoffScrollTop - 1) + expect( + contentTabsRegion.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom, + ).toBeCloseTo(1) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + }) + + it('keeps the in-flow tabs active when the standalone header slot is hidden on mobile', async () => { + await page.viewport(879, 800) + const screen = await render( + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + style={{ display: 'flex', height: 320, flexDirection: 'column', overflowY: 'auto' }} + > + <div style={{ display: 'flex', height: 48, flexShrink: 0 }}> + <HomeStickyCatalogTabs> + <div className={styles.headerCatalogTabs} data-testid="mobile-header-tabs"> + Header tabs + </div> + </HomeStickyCatalogTabs> + </div> + <div style={{ height: 220, flexShrink: 0 }} /> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div>Categories</div>} + catalogTabs={<div data-testid="mobile-content-tabs">Content tabs</div>} + /> + <div style={{ height: 640, flexShrink: 0 }} /> + </div> + </HomeStickyStateProvider>, + ) + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const navigation = screen.getByRole('region').element() + const contentTabsSlot = screen.getByTestId('mobile-content-tabs').element().parentElement! + const headerTabs = screen.getByTestId('mobile-header-tabs').element() + const headerTabsSlot = headerTabs.parentElement! + + expect(getComputedStyle(headerTabs).display).toBe('none') + + scrollContainer.scrollTop = 300 + scrollContainer.dispatchEvent(new Event('scroll')) + + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(getComputedStyle(contentTabsSlot).opacity).toBe('1') + expect(getComputedStyle(contentTabsSlot).pointerEvents).toBe('auto') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect( + contentTabsSlot.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + ).toBeCloseTo( + HOME_HEADER_HEIGHT_PX + + HOME_SEARCH_HEIGHT_PX + + HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX /* .catalogTabsRegion padding-top, tucked under search padding */, + ) + + await page.viewport(880, 800) + await vi.waitFor(() => { + expect(navigation).toHaveClass(styles.catalogNavigationPinned!) + }) + expect(getComputedStyle(headerTabs).display).toBe('flex') + expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(contentTabsSlot).toHaveAttribute('inert') + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + + await page.viewport(879, 800) + await vi.waitFor(() => { + expect(navigation).not.toHaveClass(styles.catalogNavigationPinned!) + }) + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx new file mode 100644 index 00000000000..ecb121795f3 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx @@ -0,0 +1,320 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeCatalogTabs from '../home-catalog-tabs' +import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider' +import styles from '../home-sticky.module.css' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string, options?: { ns?: string }) => + options?.ns ? `${options.ns}.${key}` : key, + ), + }), + } +}) + +vi.mock('../../plugin-type-switch', () => ({ + default: ({ className, variant }: { className?: string; variant?: string }) => ( + <div data-testid="plugin-type-switch" className={className} data-variant={variant} /> + ), +})) + +afterEach(() => { + document.querySelectorAll('#marketplace-container').forEach((element) => element.remove()) +}) + +describe('HomeCatalogNavigation', () => { + const renderNavigation = (isMarketplacePlatform: boolean) => { + return render( + <HomeStickyStateProvider> + <HomeStickyCatalogTabs> + <div data-testid="header-catalog-tabs" /> + </HomeStickyCatalogTabs> + <HomeCatalogNavigation + isMarketplacePlatform={isMarketplacePlatform} + catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />} + /> + </HomeStickyStateProvider>, + ) + } + + it('keeps template navigation inside the Marketplace platform', () => { + renderNavigation(true) + + const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' }) + + expect(navigationSection).toHaveClass(styles.catalogNavigation!) + expect(navigationSection.firstElementChild).toHaveClass('w-full') + expect(navigationSection.firstElementChild).not.toHaveClass('mx-auto', 'max-w-[1200px]') + const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + expect(activeTab).toHaveAttribute('aria-current', 'page') + expect(activeTab).toHaveAttribute('href', '/plugins') + expect(activeTab).toHaveClass('bg-state-base-active') + expect(activeTab).not.toHaveClass('text-text-accent') + expect(activeTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + expect( + screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }), + ).toHaveAttribute('href', '/templates') + expect(screen.getByTestId('plugin-type-switch')).toHaveAttribute('data-variant', 'home') + }) + + it('keeps tabs clickable and uses only the active background', () => { + render(<HomeCatalogTabs isMarketplacePlatform />) + + const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' }) + + expect(pluginsTab).toHaveAttribute('href', '/plugins') + expect(pluginsTab).toHaveClass('cursor-pointer') + expect(pluginsTab).toHaveClass('bg-state-base-active') + expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + expect(templatesTab).toHaveAttribute('href', '/templates') + expect(templatesTab).toHaveClass('cursor-pointer') + expect(templatesTab).not.toHaveClass('bg-state-base-active') + }) + + it('leaves both catalog tabs inactive when no page is selected', () => { + render(<HomeCatalogTabs activeTab={null} isMarketplacePlatform={false} />) + + const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' }) + + expect(pluginsTab).toHaveAttribute('href', '/marketplace') + expect(pluginsTab).not.toHaveAttribute('aria-current') + expect(pluginsTab).not.toHaveClass('bg-state-base-active') + expect(templatesTab).not.toHaveAttribute('aria-current') + expect(templatesTab).not.toHaveClass('bg-state-base-active') + }) + + it('marks Templates as active when rendering the Templates catalog', () => { + render(<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />) + + const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' }) + const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' }) + + expect(pluginsTab).not.toHaveAttribute('aria-current') + expect(pluginsTab).not.toHaveClass('bg-state-base-active') + expect(pluginsTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + expect(templatesTab).toHaveAttribute('aria-current', 'page') + expect(templatesTab).toHaveClass('bg-state-base-active') + expect(templatesTab).not.toHaveClass('text-text-accent') + expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + }) + + it('uses request-localized labels and preserves the selected language', () => { + render( + <HomeCatalogTabs + isMarketplacePlatform + labels={{ + plugins: '插件', + templates: '模板', + }} + language="zh-Hans" + />, + ) + + expect(screen.getByRole('link', { name: '插件' })).toHaveAttribute( + 'href', + '/plugins?language=zh-Hans', + ) + expect(screen.getByRole('link', { name: '模板' })).toHaveAttribute( + 'href', + '/templates?language=zh-Hans', + ) + }) + + it('renders a supplied catalog category navigation', () => { + render( + <HomeStickyStateProvider> + <HomeCatalogNavigation + isMarketplacePlatform + catalogTabs={<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />} + catalogCategories={<nav aria-label="Template categories">Template categories</nav>} + /> + </HomeStickyStateProvider>, + ) + + expect(screen.getByRole('navigation', { name: 'Template categories' })).toBeInTheDocument() + expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument() + }) + + it('uses a short divider between the leading tag filter and categories', () => { + render( + <HomeStickyStateProvider> + <HomeCatalogNavigation + isMarketplacePlatform + catalogTabs={<HomeCatalogTabs isMarketplacePlatform />} + catalogLeading={<div>Tags</div>} + catalogTrailing={<div>Languages</div>} + catalogCategories={<nav aria-label="Plugin categories">Categories</nav>} + /> + </HomeStickyStateProvider>, + ) + // Categories sit in the flex-1 scroller; the row is one level up. + const row = screen.getByRole('navigation', { name: 'Plugin categories' }).parentElement + ?.parentElement + const divider = row?.children.item(1) + + expect(row?.children.item(0)).toHaveTextContent('Tags') + expect(divider).toHaveAttribute('aria-hidden', 'true') + expect(divider).toHaveClass( + 'mx-1', + 'h-3.5', + 'w-px', + 'shrink-0', + 'bg-divider-regular', + styles.catalogLeadingDivider!, + ) + expect(divider).toBeEmptyDOMElement() + expect(row?.children.item(2)).toHaveTextContent('Categories') + expect(row?.children.item(3)).toHaveTextContent('Languages') + expect(row).not.toHaveTextContent('·') + }) + + it('keeps Dify catalog navigation on the current origin', () => { + renderNavigation(false) + + expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute( + 'href', + '/marketplace', + ) + expect( + screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }), + ).toHaveAttribute('href', '/templates') + }) + + it('keeps both tab copies mounted while exposing only the active copy', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + const containerRect = vi + .spyOn(scrollContainer, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, -100, 100, 100)) + + renderNavigation(true) + + const contentTabs = document.querySelector<HTMLElement>( + '[data-home-catalog-tabs-slot="content"]', + )! + const catalogTabsRegion = contentTabs.parentElement! + const headerTabs = screen.getByTestId('header-catalog-tabs') + const headerTabsSlot = headerTabs.parentElement! + const handoffBoundaryRect = vi + .spyOn(catalogTabsRegion, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, -7, 100, 56)) + + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(contentTabs).not.toHaveAttribute('aria-hidden') + expect(contentTabs).not.toHaveAttribute('inert') + + containerRect.mockReturnValue(new DOMRect(0, 0, 100, 100)) + handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56)) + fireEvent.scroll(scrollContainer) + + expect(headerTabs).toBeInTheDocument() + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + expect(contentTabs).toHaveAttribute('aria-hidden', 'true') + expect(contentTabs).toHaveAttribute('inert') + + scrollContainer.remove() + }) + + it('shows the compact navigation and header tabs after reaching the sticky header', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + + renderNavigation(true) + + const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' }) + const contentTabsSlot = document.querySelector<HTMLElement>( + '[data-home-catalog-tabs-slot="content"]', + )! + const catalogTabsRegion = contentTabsSlot.parentElement! + const headerTabs = screen.getByTestId('header-catalog-tabs') + const headerTabsSlot = headerTabs.parentElement! + vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100)) + const handoffBoundaryRect = vi + .spyOn(catalogTabsRegion, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(0, -7, 100, 56)) + + fireEvent.scroll(scrollContainer) + expect(headerTabs).toBeInTheDocument() + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + + handoffBoundaryRect.mockReturnValue(new DOMRect(0, -8, 100, 56)) + fireEvent.scroll(scrollContainer) + + expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!) + expect(contentTabsSlot).toHaveClass(styles.catalogTabsPinned!) + expect(headerTabsSlot).not.toHaveAttribute('aria-hidden') + expect(headerTabsSlot).not.toHaveAttribute('inert') + expect(contentTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(contentTabsSlot).toHaveAttribute('inert') + + handoffBoundaryRect.mockReturnValue(new DOMRect(0, -7, 100, 56)) + fireEvent.scroll(scrollContainer) + + expect(navigationSection).not.toHaveClass(styles.catalogNavigationPinned!) + expect(headerTabs).toBeInTheDocument() + expect(headerTabsSlot).toHaveAttribute('aria-hidden', 'true') + expect(headerTabsSlot).toHaveAttribute('inert') + expect(contentTabsSlot).not.toHaveAttribute('aria-hidden') + expect(contentTabsSlot).not.toHaveAttribute('inert') + + scrollContainer.remove() + }) + + it('keeps the pinned state when compact styling moves the sticky section', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + + renderNavigation(true) + + const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' }) + const contentTabsSlot = document.querySelector<HTMLElement>( + '[data-home-catalog-tabs-slot="content"]', + )! + const catalogTabsRegion = contentTabsSlot.parentElement! + vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100)) + vi.spyOn(catalogTabsRegion, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, -9, 100, 56), + ) + vi.spyOn(navigationSection, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 49, 100, 60), + ) + + fireEvent.scroll(scrollContainer) + + expect(navigationSection).toHaveClass(styles.catalogNavigationPinned!) + expect(screen.getByTestId('header-catalog-tabs').parentElement).not.toHaveAttribute( + 'aria-hidden', + ) + + scrollContainer.remove() + }) + + it('leaves browser scroll anchoring enabled because the handoff preserves geometry', () => { + const scrollContainer = document.createElement('div') + scrollContainer.id = 'marketplace-container' + document.body.appendChild(scrollContainer) + + const { unmount } = renderNavigation(true) + + expect(scrollContainer.style.overflowAnchor).toBe('') + + unmount() + expect(scrollContainer.style.overflowAnchor).toBe('') + + scrollContainer.remove() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx new file mode 100644 index 00000000000..c86382c4464 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-guide.spec.tsx @@ -0,0 +1,129 @@ +import { TooltipProvider } from '@langgenius/dify-ui/tooltip' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import HomeGuide from '../home-guide' + +const mocks = vi.hoisted(() => ({ + marketplaceUrlPrefix: 'https://marketplace.dify.ai', + useDocLink: vi.fn(() => (path?: string) => `https://docs.dify.ai/console${path || ''}`), +})) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + i18n: { + language: 'en-US', + }, + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('@/context/i18n', () => ({ + defaultDocBaseUrl: 'https://docs.dify.ai', + useDocLink: mocks.useDocLink, +})) + +vi.mock('@/config', () => ({ + get MARKETPLACE_URL_PREFIX() { + return mocks.marketplaceUrlPrefix + }, +})) + +const GUIDE_BUTTON_NAME = /marketplace\.home\.guide/ + +const renderGuide = (isMarketplacePlatform: boolean) => + render( + <TooltipProvider delay={0} closeDelay={0}> + <HomeGuide isMarketplacePlatform={isMarketplacePlatform} /> + </TooltipProvider>, + ) + +const openGuideMenu = async (isMarketplacePlatform: boolean) => { + const user = userEvent.setup() + renderGuide(isMarketplacePlatform) + + expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: GUIDE_BUTTON_NAME })) + return within(await screen.findByRole('menu')) +} + +describe('HomeGuide', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai' + }) + + it('opens a four-option dropdown on the standalone Marketplace instead of navigating away', async () => { + const menu = await openGuideMenu(true) + const options = menu.getAllByRole('menuitem') + + expect(options).toHaveLength(4) + expect(options[0]).toHaveAttribute( + 'href', + 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml', + ) + expect(options[1]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin', + ) + expect(options[2]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/en/develop-plugin/publishing/marketplace-listing/release-overview', + ) + expect(options[3]).toHaveAttribute('href', 'https://creators.dify.ai') + expect(mocks.useDocLink).not.toHaveBeenCalled() + }) + + it('uses Dify deployment-aware documentation links inside the console', async () => { + const menu = await openGuideMenu(false) + const options = menu.getAllByRole('menuitem') + + expect(options).toHaveLength(4) + expect(options[1]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/console/develop-plugin/getting-started/getting-started-dify-plugin', + ) + expect(options[2]).toHaveAttribute( + 'href', + 'https://docs.dify.ai/console/develop-plugin/publishing/marketplace-listing/release-overview', + ) + expect(mocks.useDocLink).toHaveBeenCalledOnce() + }) + + it('labels the in-app Guide icon and shows a matching tooltip on hover and focus', async () => { + const user = userEvent.setup() + renderGuide(false) + + const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME }) + expect(trigger).toHaveAccessibleName(/marketplace\.home\.guide/) + + await user.hover(trigger) + expect(await screen.findByRole('tooltip')).toHaveTextContent(/marketplace\.home\.guide/) + + await user.unhover(trigger) + await waitFor(() => { + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) + + await user.tab() + expect(trigger).toHaveFocus() + expect(await screen.findByRole('tooltip')).toHaveTextContent(/marketplace\.home\.guide/) + }) + + it('keeps the Guide dropdown available after the tooltip is shown', async () => { + const user = userEvent.setup() + renderGuide(false) + + const trigger = screen.getByRole('button', { name: GUIDE_BUTTON_NAME }) + await user.hover(trigger) + expect(await screen.findByRole('tooltip')).toBeInTheDocument() + + await user.click(trigger) + expect(await screen.findByRole('menu')).toBeInTheDocument() + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx new file mode 100644 index 00000000000..f7c3f4182cd --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx @@ -0,0 +1,155 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import HomeHeader from '../home-header' + +const mocks = vi.hoisted(() => ({ + marketplaceUrlPrefix: 'https://marketplace.dify.ai', +})) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + i18n: { + language: 'en-US', + }, + t: withSelectorKey((key: string) => key), + }), + } +}) + +vi.mock('@/context/i18n', () => ({ + defaultDocBaseUrl: 'https://docs.dify.ai', + useDocLink: () => (path?: string) => `https://docs.dify.ai/console${path || ''}`, +})) + +vi.mock('@/config', () => ({ + get MARKETPLACE_URL_PREFIX() { + return mocks.marketplaceUrlPrefix + }, +})) + +vi.mock('../home-sticky-state-provider', () => ({ + HomeStickyCatalogTabs: ({ children }: { children: React.ReactNode }) => children, +})) + +describe('HomeHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.marketplaceUrlPrefix = 'https://marketplace.dify.ai' + }) + + it('keeps Creator Center and docs in the in-app header without an account action', () => { + render(<HomeHeader isMarketplacePlatform={false} />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /marketplace\.home\.guide/ })).toBeInTheDocument() + expect(screen.queryByTestId('account-section')).not.toBeInTheDocument() + }) + + it('shows Creator Center before the docs dropdown', () => { + render(<HomeHeader isMarketplacePlatform />) + + const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' }) + const guideButton = screen.getByRole('button', { name: /marketplace\.home\.guide/ }) + + expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/') + expect(creatorCenterLink).toHaveAttribute('target', '_blank') + expect(creatorCenterLink).toHaveAttribute('rel', 'noopener noreferrer') + expect(creatorCenterLink.parentElement?.className).toMatch(/standaloneHeaderActions/) + expect(creatorCenterLink.compareDocumentPosition(guideButton)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) + // Creator Center must be a single interactive element, not a link-wrapped button. + expect(creatorCenterLink.querySelector('button')).toBeNull() + expect(screen.queryByRole('link', { name: 'marketplace.home.guide' })).not.toBeInTheDocument() + }) + + it('links Creator Center to the staging Creators site in staging', () => { + mocks.marketplaceUrlPrefix = 'https://marketplace-staging.dify.dev' + + render(<HomeHeader isMarketplacePlatform />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute( + 'href', + 'https://creators-staging.dify.dev/', + ) + }) + + it('links Creator Center to the dev Creators site on marketplace.dify.dev', () => { + mocks.marketplaceUrlPrefix = 'https://marketplace.dify.dev' + + render(<HomeHeader isMarketplacePlatform />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute( + 'href', + 'https://creators.dify.dev/', + ) + }) + + it('falls back to the public Creator Center for a custom Marketplace origin', () => { + mocks.marketplaceUrlPrefix = 'http://localhost:3000' + + render(<HomeHeader isMarketplacePlatform />) + + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute( + 'href', + 'https://creators.dify.ai/', + ) + }) + + it('renders the Marketplace wordmark without a Marketplace text label', () => { + render(<HomeHeader isMarketplacePlatform />) + + const brandLink = screen.getByRole('link', { name: 'Dify Marketplace' }) + const [lightLogo, darkLogo] = brandLink.querySelectorAll('img') + expect(lightLogo).toHaveAttribute('src', expect.stringContaining('dify-marketplace-logo.svg')) + expect(darkLogo).toHaveAttribute( + 'src', + expect.stringContaining('dify-marketplace-logo-dark.svg'), + ) + expect(lightLogo).toHaveAttribute('width', '141.761') + expect(lightLogo).toHaveAttribute('height', '16.386') + expect(darkLogo).toHaveAttribute('width', '141.761') + expect(darkLogo).toHaveAttribute('height', '16.386') + expect(screen.queryByText('mainNav.marketplace')).not.toBeInTheDocument() + }) + + it('selects neither catalog tab on non-catalog pages', () => { + render( + <HomeHeader + activeTab={null} + catalogLabels={{ + plugins: 'Plugins', + templates: 'Templates', + }} + isMarketplacePlatform + />, + ) + + expect(screen.getByRole('link', { name: 'Plugins' })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('link', { name: 'Templates' })).not.toHaveAttribute('aria-current') + }) + + it('shows Templates with only the active background on the Templates catalog', () => { + render( + <HomeHeader + activeTab="templates" + catalogLabels={{ + plugins: '插件', + templates: '模板', + }} + isMarketplacePlatform + language="zh-Hans" + />, + ) + + expect(screen.getByRole('link', { name: '插件' })).not.toHaveAttribute('aria-current') + const templatesTab = screen.getByRole('link', { name: '模板' }) + expect(templatesTab).toHaveAttribute('aria-current', 'page') + expect(templatesTab).toHaveAttribute('href', '/templates?language=zh-Hans') + expect(templatesTab).toHaveClass('bg-state-base-active') + expect(templatesTab).not.toHaveClass('text-text-accent') + expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx new file mode 100644 index 00000000000..14d7ee9d0f6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-hero.spec.tsx @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { HERO_GRID_PITCH_PX, HERO_ICON_SIZE_PX } from '../home-constants' +import HomeHero from '../home-hero' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useTranslation: () => ({ + t: withSelectorKey((key: string) => key), + }), + } +}) + +describe('HomeHero', () => { + it('renders catalog-specific copy when supplied', () => { + render( + <HomeHero + isMarketplacePlatform + title="Discover templates" + subtitle="Start faster with ready-to-use workflows." + />, + ) + + expect(screen.getByRole('heading', { name: 'Discover templates' })).toBeInTheDocument() + expect(screen.getByText('Start faster with ready-to-use workflows.')).toBeInTheDocument() + expect(screen.queryByText('marketplace.home.heroTitle')).not.toBeInTheDocument() + }) + + it('renders the six decorative hero icons as images instead of iconify masks', () => { + const { container } = render(<HomeHero isMarketplacePlatform />) + + for (const name of [ + 'sparkling-fill', + 'plug-fill', + 'puzzle-fill', + 'brain-2-fill', + 'image-circle-ai-line', + 'voice-ai-fill', + ]) + expect(container.querySelector(`img[src*="${name}"]`)).not.toBeNull() + + expect(container.querySelector('img[src*="google"]')).toBeNull() + expect(container.querySelector('.i-ri-sparkling-fill')).toBeNull() + expect(container.querySelector('.i-custom-public-common-gmail')).toBeNull() + }) + + it('places each decorative icon flush inside a 41px grid cell', () => { + expect(HERO_ICON_SIZE_PX).toBe(HERO_GRID_PITCH_PX - 1) + + const { container } = render(<HomeHero isMarketplacePlatform />) + const icons = [...container.querySelectorAll<HTMLElement>('[aria-hidden] span.absolute')] + expect(icons).toHaveLength(6) + + const plusOffset = /^calc\(50% \+ (-?\d+)px\)$/ + const minusOffset = /^calc\(50% - (\d+)px\)$/ + + for (const icon of icons) { + const plusMatch = plusOffset.exec(icon.style.left) + const minusMatch = minusOffset.exec(icon.style.left) + const left = plusMatch + ? Number(plusMatch[1]) + : minusMatch + ? -Number(minusMatch[1]) + : Number.NaN + const top = Number.parseFloat(icon.style.top) + + expect(left).not.toBeNaN() + expect((left - 1) % HERO_GRID_PITCH_PX === 0).toBe(true) + expect(top % HERO_GRID_PITCH_PX === 0).toBe(true) + } + }) + + it('starts vertical grid lines on the same 50% origin as the icons', () => { + const css = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../home-hero.module.css'), + 'utf8', + ) + + expect(css).toMatch(/background-position:\s*calc\(50% \+ 0\.5px\)/) + expect(css).toMatch(/\.frame\s*\{\s*height:\s*163px/) + expect(css).toMatch(/\.glow\s*\{[\s\S]*?width:\s*555px/) + expect(css).toMatch(/\.glow\s*\{[\s\S]*?height:\s*245px/) + expect(css).toMatch(/\.glow\s*\{[\s\S]*?filter:\s*blur\(30px\)/) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx new file mode 100644 index 00000000000..9a0d6d8d9d8 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-search-mobile-layout.browser.spec.tsx @@ -0,0 +1,211 @@ +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import { HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX } from '../home-constants' +import HomeHeader from '../home-header' +import HomeSearch from '../home-search' +import { HomeShell } from '../home-shell' +import styles from '../home-sticky.module.css' + +vi.mock('@/public/marketplace/dify-marketplace-logo-dark.svg', () => ({ + default: { src: '/marketplace/dify-marketplace-logo-dark.svg' }, +})) + +vi.mock('@/public/marketplace/dify-marketplace-logo.svg', () => ({ + default: { src: '/marketplace/dify-marketplace-logo.svg' }, +})) + +vi.mock('../home-catalog-tabs', () => ({ + default: () => null, +})) + +vi.mock('../home-creator-center', () => ({ + default: () => null, +})) + +vi.mock('../home-guide', () => ({ + default: () => null, +})) + +const nextFrame = () => + new Promise<void>((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + +const overlaps = (a: DOMRect, b: DOMRect) => + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top + +const isCenterClickable = (target: Element) => { + const rect = target.getBoundingClientRect() + const node = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) + return Boolean(node && target.contains(node)) +} + +const renderMarketplaceHome = () => + render( + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <HomeShell + banners={[]} + header={ + <HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform /> + } + hero={<div aria-hidden style={{ height: 180, flexShrink: 0 }} />} + isMarketplacePlatform + navigation={<div aria-hidden style={{ height: 80, flexShrink: 0 }} />} + page="plugins" + search={ + <HomeSearch enableSearchShortcut={false}> + <input + aria-label="Search plugins or templates" + style={{ display: 'block', height: 36, width: '100%' }} + /> + </HomeSearch> + } + > + <div aria-hidden style={{ height: 640, flexShrink: 0 }} /> + </HomeShell> + </div>, + ) + +describe('Marketplace mobile search layout', () => { + it('pins the mobile search below the header without covering brand or actions', async () => { + await page.viewport(390, 844) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByRole('banner').element() + const brand = screen.getByRole('link', { name: 'Dify Marketplace' }).element() + const signIn = screen.getByRole('button', { name: 'Sign in' }).element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + const headerRect = header.getBoundingClientRect() + const searchRect = searchInput.getBoundingClientRect() + + expect(searchRect.top).toBeGreaterThanOrEqual(headerRect.bottom - 1) + expect(searchRect.top).toBeLessThanOrEqual(headerRect.bottom + 2) + expect(overlaps(searchRect, brand.getBoundingClientRect())).toBe(false) + expect(overlaps(searchRect, signIn.getBoundingClientRect())).toBe(false) + expect(isCenterClickable(brand)).toBe(true) + expect(isCenterClickable(signIn)).toBe(true) + expect(isCenterClickable(searchInput)).toBe(true) + }) + + it('keeps bottom padding under the stuck mobile search', async () => { + await page.viewport(390, 844) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + const searchRow = document.querySelector(`.${styles.search}`)! + const inputRect = searchInput.getBoundingClientRect() + const rowRect = searchRow.getBoundingClientRect() + + expect(getComputedStyle(searchRow).paddingBottom).toBe( + `${HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX}px`, + ) + expect(rowRect.bottom - inputRect.bottom).toBeCloseTo(HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, 0) + }) + + it('keeps the desktop search in the header gap while scrolling', async () => { + await page.viewport(1280, 900) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByRole('banner').element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + expect( + searchInput.getBoundingClientRect().top - header.getBoundingClientRect().top, + ).toBeCloseTo(6, 0) + expect(getComputedStyle(document.querySelector(`.${styles.search}`)!).paddingBottom).toBe('0px') + }) + + it('keeps a search-results search below the header when there is no hero to overlap', async () => { + await page.viewport(1280, 900) + const screen = await render( + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <HomeShell + banners={[]} + header={ + <HomeHeader actions={<button type="button">Sign in</button>} isMarketplacePlatform /> + } + hero={null} + isMarketplacePlatform + navigation={null} + page="plugins" + search={ + <HomeSearch enableSearchShortcut={false} overlapHero={false}> + <input + aria-label="Search plugins or templates" + style={{ display: 'block', height: 36, width: '100%' }} + /> + </HomeSearch> + } + > + <div aria-hidden style={{ height: 640, flexShrink: 0 }} /> + </HomeShell> + </div>, + ) + + const header = screen.getByRole('banner').element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + expect(searchInput.getBoundingClientRect().top).toBeGreaterThanOrEqual( + header.getBoundingClientRect().bottom - 1, + ) + expect(searchInput.getBoundingClientRect().width).toBeGreaterThan(300) + }) + + it('does not jump the page when the stuck desktop search is focused or typed into', async () => { + await page.viewport(1280, 900) + const screen = await renderMarketplaceHome() + + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const header = screen.getByRole('banner').element() + const searchInput = screen + .getByRole('textbox', { name: 'Search plugins or templates' }) + .element() + + scrollContainer.scrollTop = 400 + scrollContainer.dispatchEvent(new Event('scroll')) + await nextFrame() + + const scrollTopBefore = scrollContainer.scrollTop + const inputTopBefore = searchInput.getBoundingClientRect().top + expect(inputTopBefore - header.getBoundingClientRect().top).toBeCloseTo(6, 0) + + const searchLocator = screen.getByRole('textbox', { name: 'Search plugins or templates' }) + await searchLocator.click() + await nextFrame() + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore) + + await searchLocator.fill('g') + await nextFrame() + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + expect(searchInput.getBoundingClientRect().top).toBeCloseTo(inputTopBefore) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx new file mode 100644 index 00000000000..dee188a39ed --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx @@ -0,0 +1,268 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import HomeTrending from '../home-trending' +import { HomeBannerSlide } from '../home-trending-slides' + +const createBlogBanner = (id: string, title: string, sort: number): PluginBanner => ({ + id, + style_type: 'blog', + title, + sort, + language: 'en', + content: { + blog_title: title, + subtitle: 'New Agent node support', + description: 'Build agent workflows with the new Agent node.', + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, +}) + +const blogBanner = createBlogBanner('blog', 'Dify v1.9 new launch', 0) +const adBanner: PluginBanner = { + id: 'ad', + style_type: 'ad', + title: 'Partner campaign', + sort: 1, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/ad.png', + }, + link: 'https://partner.example.com', + alt_text: 'Partner campaign', + }, +} +const eventBanner: PluginBanner = { + id: 'event', + style_type: 'event', + title: 'Launch event', + sort: 2, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/event.png', + }, + link: 'https://dify.ai/event', + alt_text: 'Launch event', + }, +} +const carouselBanners = [ + createBlogBanner('first', 'First banner', 0), + createBlogBanner('second', 'Second banner', 1), + createBlogBanner('third', 'Third banner', 2), +] + +const visibleReadMore = (slide: Element) => + [...slide.querySelectorAll('[aria-hidden]')].find((el) => { + const text = el.textContent ?? '' + return /Read more|trendingReadMore/.test(text) && el.getBoundingClientRect().height > 0 + }) ?? null + +describe('Marketplace home trending layout', () => { + it('keeps standalone mobile blog banners at the stacked 357px height', async () => { + await page.viewport(600, 900) + await render( + <div data-marketplace-standalone className="w-[560px]"> + <div data-testid="blog-banner"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div> + </div>, + ) + + const blogSlide = document.querySelector<HTMLElement>('[data-testid="blog-banner"] > a')! + + expect(blogSlide.getBoundingClientRect().height).toBe(357) + }) + + it('clamps standalone mobile blog subtitle to one line and description to two', async () => { + await page.viewport(600, 900) + const subtitleText = + 'On September 10, 2026, LangGenius K.K. will host its flagship annual conference in Tokyo.' + const descriptionText = + 'It is a full day dedicated to turning generative AI from isolated pilots into real operations. Registration is open now for the second year of the conference.' + const longTag = 'IF Con Tokyo 2026 Annual Conference Extra Long Label' + const longTitle = 'IF Con Tokyo 2026: Turn “What If” into Production' + const longBlog: PluginBanner = { + id: 'blog-long', + style_type: 'blog', + title: longTag, + sort: 0, + language: 'en', + content: { + blog_title: longTitle, + subtitle: subtitleText, + description: descriptionText, + link: 'https://dify.ai/blog', + link_target_type: 'blog', + }, + } + const screen = await render( + <div data-marketplace-standalone className="w-[360px]"> + <HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const slide = screen.getByRole('link').element() + const tag = screen.getByText(longTag).element() + const title = screen.getByRole('heading', { name: longTitle }).element() + const subtitle = screen.getByText(subtitleText).element() + const description = screen.getByText(descriptionText).element() + const slideBox = slide.getBoundingClientRect() + const titleBox = title.getBoundingClientRect() + + expect(getComputedStyle(tag).whiteSpace).toBe('nowrap') + expect(getComputedStyle(tag).textOverflow).toBe('ellipsis') + expect(getComputedStyle(title).whiteSpace).toBe('normal') + expect(titleBox.height).toBeGreaterThan(24) + expect(titleBox.left - slideBox.left).toBeCloseTo(20, 0) + expect(slideBox.right - titleBox.right).toBeCloseTo(20, 0) + expect(slideBox.height).toBeGreaterThan(357) + expect(getComputedStyle(subtitle).whiteSpace).toBe('nowrap') + expect(getComputedStyle(subtitle).textOverflow).toBe('ellipsis') + expect(getComputedStyle(description).webkitLineClamp).toBe('2') + expect(description.getBoundingClientRect().height).toBeCloseTo(40, 0) + expect(visibleReadMore(slide)).toBeNull() + }) + + it('clamps the desktop blog tag to one line and lets the title wrap', async () => { + await page.viewport(1200, 900) + const longTag = + "Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents — extra-long green label" + const longTitle = + "Dify Raises $30M: Tomorrow's Organizations Will Be Built by People and AgentsDify Raises $30M: Tomorrow's Organizations Will Be Built by People and Agents" + const longBlog: PluginBanner = { + ...createBlogBanner('blog-desktop-long', longTitle, 0), + title: longTag, + } + const screen = await render( + <div className="w-[1100px]"> + <HomeBannerSlide banner={longBlog} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const tag = screen.getByText(longTag).element() + const title = screen.getByRole('heading', { name: longTitle }).element() + const tagBox = tag.getBoundingClientRect() + const titleBox = title.getBoundingClientRect() + + expect(getComputedStyle(tag).whiteSpace).toBe('nowrap') + expect(getComputedStyle(tag).textOverflow).toBe('ellipsis') + expect(tagBox.height).toBeLessThanOrEqual(20) + expect(getComputedStyle(title).whiteSpace).toBe('normal') + expect(titleBox.height).toBeGreaterThan(24) + expect(visibleReadMore(screen.getByRole('link').element())).not.toBeNull() + }) + + it('shows the standalone mobile event poster at the 800:721 delivery ratio', async () => { + await page.viewport(600, 900) + const screen = await render( + <div data-marketplace-standalone className="w-[360px]"> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const slide = screen.getByRole('link', { name: 'Launch event' }).element() + const box = slide.getBoundingClientRect() + const artwork = slide.querySelector('img') + + expect(box.height).toBeCloseTo((box.width * 721) / 800, 1) + expect(artwork).not.toBeNull() + expect(getComputedStyle(artwork!).objectFit).toBe('contain') + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + }) + + it('keeps event and ad artwork left-aligned so desktop cropping stays on the right', async () => { + await page.viewport(1000, 900) + const screen = await render( + <div data-marketplace-standalone className="w-[960px]"> + <HomeBannerSlide banner={adBanner} isMarketplacePlatform page="plugins" /> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + for (const name of ['Partner campaign', 'Launch event']) { + const artwork = screen.getByRole('link', { name }).element().querySelector('img') + + expect(artwork).not.toBeNull() + expect(getComputedStyle(artwork!).objectFit).toBe('cover') + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + } + }) + + it('keeps blog artwork at 400px on desktop so shrinking clips the right', async () => { + await page.viewport(1200, 900) + const screen = await render( + <div className="w-[900px]"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const artwork = screen.getByRole('link').element().querySelector('img') + + expect(artwork).not.toBeNull() + expect(artwork!.getBoundingClientRect().width).toBe(400) + expect(getComputedStyle(artwork!).objectFit).toBe('cover') + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + }) + + it('keeps desktop event artwork at least 1200px wide so overflow clips the right', async () => { + await page.viewport(1000, 900) + const screen = await render( + <div data-marketplace-standalone className="w-[900px]"> + <HomeBannerSlide banner={eventBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const artwork = screen + .getByRole('link', { name: 'Launch event' }) + .element() + .querySelector('img') + + expect(artwork).not.toBeNull() + expect(artwork!.getBoundingClientRect().width).toBeGreaterThanOrEqual(1200) + expect(getComputedStyle(artwork!).objectPosition).toBe('0% 50%') + }) + + it('keeps the blog artwork left corners rounded when its image is cropped', async () => { + const screen = await render( + <div className="w-[600px]"> + <HomeBannerSlide banner={blogBanner} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const artwork = screen.getByRole('link').element().querySelector('img') + + expect(artwork).not.toBeNull() + expect(getComputedStyle(artwork!).borderTopLeftRadius).toBe('16px') + expect(getComputedStyle(artwork!).borderBottomLeftRadius).toBe('16px') + }) + + it('wraps from the last banner back to the first visible slide', async () => { + const screen = await render( + <HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />, + ) + + await screen.getByRole('button', { name: 'Third banner' }).click() + expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute( + 'aria-current', + 'true', + ) + + await expect + .poll( + () => + screen + .getByRole('button', { name: 'First banner' }) + .element() + .getAttribute('aria-current'), + { timeout: 8000 }, + ) + .toBe('true') + + expect(screen.getByRole('group', { name: 'First banner' }).element()).not.toHaveAttribute( + 'inert', + ) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx new file mode 100644 index 00000000000..ca2f7d81983 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-swipe.browser.spec.tsx @@ -0,0 +1,207 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import HomeTrending from '../home-trending' + +const createBanner = (id: string, title: string, sort: number): PluginBanner => ({ + id, + style_type: 'blog', + title, + sort, + language: 'en', + content: { + blog_title: title, + subtitle: `${title} subtitle`, + description: `${title} description`, + link: `https://example.com/${id}`, + link_target_type: 'blog', + }, +}) + +const banners = [ + createBanner('first', 'First banner', 0), + createBanner('second', 'Second banner', 1), + createBanner('third', 'Third banner', 2), +] + +const dispatchTouchPointer = ( + target: Element, + type: 'pointerdown' | 'pointermove' | 'pointerup', + init: Pick<PointerEventInit, 'clientX' | 'clientY' | 'pointerId'>, +) => + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + isPrimary: true, + pointerType: 'touch', + ...init, + }), + ) + +describe('Marketplace home trending mobile swipe', () => { + it('switches in both directions without activating a dragged link or clearing Pause', async () => { + await page.viewport(600, 900) + const screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + + const firstSlideLocator = screen.getByRole('group', { name: 'First banner' }) + const secondSlideLocator = screen.getByRole('group', { + name: 'Second banner', + includeHidden: true, + }) + const firstSlide = firstSlideLocator.element() + const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')! + + dispatchTouchPointer(firstSlide, 'pointerdown', { + pointerId: 1, + clientX: 480, + clientY: 160, + }) + dispatchTouchPointer(firstSlide, 'pointermove', { + pointerId: 1, + clientX: 300, + clientY: 166, + }) + await expect.element(secondSlideLocator).toBeVisible() + dispatchTouchPointer(firstSlide, 'pointerup', { + pointerId: 1, + clientX: 300, + clientY: 166, + }) + const clickWasNotCanceled = firstLink.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ) + + expect(clickWasNotCanceled).toBe(false) + await expect + .element(screen.getByRole('button', { name: 'Second banner' })) + .toHaveAttribute('aria-current', 'true') + await screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }).click() + + const secondSlide = secondSlideLocator.element() + dispatchTouchPointer(secondSlide, 'pointerdown', { + pointerId: 2, + clientX: 260, + clientY: 160, + }) + dispatchTouchPointer(secondSlide, 'pointermove', { + pointerId: 2, + clientX: 440, + clientY: 166, + }) + dispatchTouchPointer(secondSlide, 'pointerup', { + pointerId: 2, + clientX: 440, + clientY: 166, + }) + + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + await expect + .element(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })) + .toBeInTheDocument() + }) + + it('suppresses the trailing click when a horizontal drag is pulled back before release', async () => { + await page.viewport(600, 900) + const screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + const firstSlide = screen.getByRole('group', { name: 'First banner' }).element() + const firstLink = firstSlide.querySelector<HTMLAnchorElement>('a')! + + dispatchTouchPointer(firstSlide, 'pointerdown', { + pointerId: 1, + clientX: 400, + clientY: 160, + }) + dispatchTouchPointer(firstSlide, 'pointermove', { + pointerId: 1, + clientX: 280, + clientY: 164, + }) + dispatchTouchPointer(firstSlide, 'pointermove', { + pointerId: 1, + clientX: 396, + clientY: 162, + }) + dispatchTouchPointer(firstSlide, 'pointerup', { + pointerId: 1, + clientX: 396, + clientY: 162, + }) + + expect( + firstLink.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })), + ).toBe(false) + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + }) + + it('keeps vertical gestures on the current slide and ignores desktop touch input', async () => { + await page.viewport(600, 900) + let screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + let activeSlide = screen.getByRole('group', { name: 'First banner' }).element() + + dispatchTouchPointer(activeSlide, 'pointerdown', { + pointerId: 1, + clientX: 300, + clientY: 120, + }) + dispatchTouchPointer(activeSlide, 'pointermove', { + pointerId: 1, + clientX: 270, + clientY: 300, + }) + dispatchTouchPointer(activeSlide, 'pointerup', { + pointerId: 1, + clientX: 270, + clientY: 300, + }) + + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + + screen.unmount() + await page.viewport(1000, 900) + screen = await render( + <div data-marketplace-standalone> + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" /> + </div>, + ) + activeSlide = screen.getByRole('group', { name: 'First banner' }).element() + + dispatchTouchPointer(activeSlide, 'pointerdown', { + pointerId: 2, + clientX: 480, + clientY: 160, + }) + dispatchTouchPointer(activeSlide, 'pointermove', { + pointerId: 2, + clientX: 260, + clientY: 160, + }) + dispatchTouchPointer(activeSlide, 'pointerup', { + pointerId: 2, + clientX: 260, + clientY: 160, + }) + + await expect + .element(screen.getByRole('button', { name: 'First banner' })) + .toHaveAttribute('aria-current', 'true') + }) +}) 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..6500bab8f73 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -0,0 +1,911 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { trackEvent } from '@/app/components/base/amplitude' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import HomeTrending from '../home-trending' + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + rememberMarketplaceSiteReferrer: vi.fn(), + trackMarketplaceSiteEvent: vi.fn(), +})) + +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" />, +})) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/config')>()), + MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com', +})) + +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, + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }, + { + 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', + }, + }, +] + +const mockTrackEvent = vi.mocked(trackEvent) +const mockTrackMarketplaceSiteEvent = vi.mocked(trackMarketplaceSiteEvent) + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('HomeTrending', () => { + it('renders and switches between the three API-backed banner layouts', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + expect(document.querySelector('[data-home-trending-carousel-root]')?.className).toMatch( + /carouselRoot/, + ) + 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() + const blogSlide = screen.getByRole('group', { name: 'Dify Updates' }) + const blogLink = within(blogSlide).getByRole('link', { + name: 'plugin.marketplace.home.trendingReadMoreAbout', + }) + expect(blogLink).toHaveAttribute('href', 'https://dify.ai/blog') + expect(within(blogSlide).getAllByRole('link')).toHaveLength(1) + expect( + within(blogLink).getByRole('heading', { name: 'Dify v1.9 new launch' }), + ).toBeInTheDocument() + + 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('marks inactive standalone slides so mobile CSS can collapse mixed banner heights', () => { + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const recommendSlide = screen.getByRole('group', { name: 'Trending' }) + const blogSlide = document.querySelector( + '[aria-roledescription="slide"][aria-label="Dify Updates"]', + ) + const eventSlide = document.querySelector( + '[aria-roledescription="slide"][aria-label="Duck Duck Go"]', + ) + const eventLink = document.querySelector('a[aria-label="DuckDuckGo plugin"]') + + expect(recommendSlide.className).toMatch(/slide/) + expect(recommendSlide.className).not.toMatch(/slideInactive/) + expect(blogSlide?.className).toMatch(/slideInactive/) + expect(eventSlide?.className).toMatch(/slideInactive/) + expect(recommendSlide.firstElementChild?.className).toMatch(/stackedSlide/) + expect(blogSlide?.firstElementChild?.className).toMatch(/stackedSlide/) + expect(eventLink?.className).toMatch(/imageSlide/) + expect(eventLink?.querySelector('source')).toHaveAttribute('media', '(max-width: 879px)') + expect(eventLink?.querySelector('source')?.getAttribute('srcset')).toContain( + 'duckduckgo-mobile.png', + ) + }) + + it('keeps the embedded event image breakpoint at 639px', () => { + render(<HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />) + + expect(document.querySelector('a[aria-label="DuckDuckGo plugin"] source')).toHaveAttribute( + 'media', + '(max-width: 639px)', + ) + }) + + it('falls back to desktop on the mobile source when an event banner has no mobile asset', () => { + const eventWithoutMobile: PluginBanner = { + id: 'event-desktop-only', + style_type: 'event', + title: 'Desktop Event', + sort: 0, + language: 'en', + content: { + images: { + desktop: '/api/v1/banners/images/banners/event-desktop.png', + tablet: '/api/v1/banners/images/banners/event-tablet.png', + }, + link: 'https://dify.ai/event', + alt_text: 'Desktop event', + }, + } + + render(<HomeTrending banners={[eventWithoutMobile]} isMarketplacePlatform page="plugins" />) + + const eventLink = screen.getByRole('link', { name: 'Desktop event' }) + const sources = eventLink.querySelectorAll('source') + + expect(sources[0]).toHaveAttribute('media', '(max-width: 879px)') + expect(sources[0]?.getAttribute('srcset')).toContain('event-desktop.png') + expect(sources[0]?.getAttribute('srcset')).not.toContain('event-tablet.png') + expect(sources[1]).toHaveAttribute('media', '(min-width: 880px) and (max-width: 1023px)') + expect(sources[1]?.getAttribute('srcset')).toContain('event-tablet.png') + expect(eventLink.querySelector('img')?.getAttribute('src')).toContain('event-desktop.png') + }) + + it('switches to the selected slide from the pagination with the keyboard', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + 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('loops from the last banner to a visual clone before resetting to the first banner', () => { + const animations: Array<{ + cancel: ReturnType<typeof vi.fn> + onfinish: (() => void) | null + pause: ReturnType<typeof vi.fn> + play: ReturnType<typeof vi.fn> + }> = [] + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => { + const animation = { + cancel: vi.fn(), + finished: Promise.resolve(), + onfinish: null, + pause: vi.fn(), + play: vi.fn(), + } + animations.push(animation) + return animation as unknown as Animation + }), + }) + + try { + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + const track = document.querySelector('[data-carousel-track]')! + + act(() => animations.at(-1)?.onfinish?.()) + + expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute( + 'aria-current', + 'true', + ) + expect(track).toHaveStyle({ transform: 'translate3d(-300%, 0, 0)' }) + expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument() + + fireEvent.transitionEnd(track, { propertyName: 'transform' }) + + expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute( + 'aria-current', + 'true', + ) + expect(track).toHaveStyle({ transform: 'translate3d(-0%, 0, 0)', transition: 'none' }) + } finally { + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + } + }) + + it('does not reject when the autoplay animation is canceled on unmount', async () => { + let rejectFinished: (reason: unknown) => void = () => {} + const finished = new Promise<Animation>((_resolve, reject) => { + rejectFinished = reject + }) + const progressAnimation = { + cancel: vi.fn(() => { + rejectFinished( + Object.assign(new Error('The animation was canceled.'), { name: 'AbortError' }), + ) + }), + onfinish: null, + pause: vi.fn(), + play: vi.fn(), + finished, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + + try { + const { unmount } = render( + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" />, + ) + + unmount() + await act(async () => { + await Promise.resolve() + }) + + expect(progressAnimation.cancel).toHaveBeenCalled() + } finally { + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + } + }) + + it('toggles the carousel between paused and playing states', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const carousel = document.querySelector('[data-home-trending-carousel-root]')! + const liveTrack = carousel.querySelector('[aria-live]')! + expect(liveTrack).toHaveAttribute('aria-live', 'off') + + const pauseButton = screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPause', + }) + expect(pauseButton).toHaveClass('bg-state-base-active') + + pauseButton.focus() + await user.keyboard('{Enter}') + + expect(liveTrack).toHaveAttribute('aria-live', 'polite') + + 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 page="plugins" />) + + expect( + screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPlay', + }), + ).toBeInTheDocument() + + matchMedia.mockRestore() + }) + + it('keeps embedded autoplay paused until every pause reason is cleared', () => { + const pause = vi.fn() + const play = vi.fn() + const cancel = vi.fn() + const progressAnimation = { + cancel, + finished: Promise.resolve(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const intersectionObservers: { + callback: IntersectionObserverCallback + options?: IntersectionObserverInit + }[] = [] + class MockIntersectionObserver { + disconnect = vi.fn() + observe = vi.fn() + root: Element | Document | null + rootMargin: string + takeRecords = vi.fn(() => []) + thresholds: readonly number[] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + intersectionObservers.push({ callback, options }) + } + } + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + let reducedMotion = false + let reducedMotionListener: (() => void) | undefined + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return reducedMotion + }, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_event: string, listener: () => void) => { + reducedMotionListener = listener + }, + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + const marketplaceContainer = document.createElement('div') + marketplaceContainer.id = 'marketplace-container' + document.body.appendChild(marketplaceContainer) + + const { unmount } = render( + <HomeTrending banners={banners} isMarketplacePlatform={false} page="plugins" />, + { + container: marketplaceContainer, + }, + ) + const carouselRoot = marketplaceContainer.querySelector('[data-home-trending-carousel-root]')! + const viewportObserver = intersectionObservers.find( + (observer) => observer.options?.threshold === 0.25, + ) + const setIntersectionRatio = (intersectionRatio: number) => { + act(() => { + viewportObserver?.callback( + [ + { + intersectionRatio, + isIntersecting: intersectionRatio > 0, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + }) + } + + expect(pause).toHaveBeenCalled() + + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledOnce() + + fireEvent.mouseEnter(carouselRoot) + setIntersectionRatio(0) + fireEvent.mouseLeave(carouselRoot) + expect(play).toHaveBeenCalledOnce() + + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(2) + + const playsBeforeFocus = play.mock.calls.length + const focusTarget = carouselRoot.querySelector('a')! + fireEvent.focusIn(focusTarget) + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeFocus) + fireEvent.focusOut(focusTarget, { relatedTarget: null }) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeFocus) + + // Navigation controls sit inside the pause boundary, so focusing them + // also stops the rotation. + const playsBeforeControlFocus = play.mock.calls.length + const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) + fireEvent.focusIn(paginationButton) + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeControlFocus) + fireEvent.focusOut(paginationButton, { relatedTarget: null }) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeControlFocus) + + const playsBeforeUserPause = play.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' })) + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeUserPause) + fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' })) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeUserPause) + + const playsBeforeVisibilityPause = play.mock.calls.length + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + fireEvent(document, new Event('visibilitychange')) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeVisibilityPause) + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + fireEvent(document, new Event('visibilitychange')) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeVisibilityPause) + + const playsBeforeReducedMotion = play.mock.calls.length + reducedMotion = true + reducedMotionListener?.() + setIntersectionRatio(0) + setIntersectionRatio(0.25) + expect(play).toHaveBeenCalledTimes(playsBeforeReducedMotion) + + reducedMotion = false + reducedMotionListener?.() + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeReducedMotion) + + unmount() + marketplaceContainer.remove() + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('resumes autoplay after a pointer click on pagination without waiting for blur', async () => { + const pause = vi.fn() + const play = vi.fn() + const progressAnimation = { + cancel: vi.fn(), + finished: Promise.resolve(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const carouselRoot = document.querySelector('[data-home-trending-carousel-root]')! + const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) + + // Pointer activation hovers and focuses the control, which normally + // pauses rotation until mouseleave/focusout. + fireEvent.mouseEnter(carouselRoot) + paginationButton.focus() + fireEvent.focusIn(paginationButton) + + const playsBeforeSelect = play.mock.calls.length + await user.click(paginationButton) + + expect(paginationButton).toHaveAttribute('aria-current', 'true') + expect(document.activeElement).toBe(paginationButton) + expect(play.mock.calls.length).toBeGreaterThan(playsBeforeSelect) + + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('keeps autoplay paused when pagination is selected from the keyboard', async () => { + const pause = vi.fn() + const play = vi.fn() + const progressAnimation = { + cancel: vi.fn(), + finished: Promise.resolve(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const paginationButton = screen.getByRole('button', { name: 'Dify Updates' }) + paginationButton.focus() + fireEvent.focusIn(paginationButton) + + const playsBeforeSelect = play.mock.calls.length + await user.keyboard('{Enter}') + + expect(paginationButton).toHaveAttribute('aria-current', 'true') + expect(document.activeElement).toBe(paginationButton) + expect(play).toHaveBeenCalledTimes(playsBeforeSelect) + + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('resumes autoplay when Play is activated without moving keyboard focus', async () => { + const pause = vi.fn() + const play = vi.fn() + const progressAnimation = { + cancel: vi.fn(), + finished: Promise.resolve(), + onfinish: null, + pause, + play, + } as unknown as Animation + const originalAnimate = Element.prototype.animate + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: vi.fn(() => progressAnimation), + }) + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + const toggleButton = screen.getByRole('button', { + name: 'plugin.marketplace.home.trendingPause', + }) + + // Focusing the toggle adds the implicit focus pause reason, then Enter + // adds the explicit user pause. + toggleButton.focus() + await user.keyboard('{Enter}') + expect(pause).toHaveBeenCalled() + + // Play must resume the rotation even though the button is still focused + // (and would normally keep the focus pause reason active). + const playsBeforePlay = play.mock.calls.length + await user.keyboard('{Enter}') + + expect(play.mock.calls.length).toBeGreaterThan(playsBeforePlay) + expect(document.activeElement).toBe(toggleButton) + expect( + screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }), + ).toBeInTheDocument() + + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + value: originalAnimate, + }) + }) + + it('sends embedded cards without a delivery link to the marketplace site', () => { + const bannerWithMixedLinks: PluginBanner = { + id: 'recommend-mixed', + style_type: 'recommend', + title: 'Trending', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + cards: [ + { + item_type: 'plugin', + item_id: 'langgenius/dropbox', + display_name: 'Dropbox', + link: 'https://external.example.com/dropbox', + card_position: 0, + }, + { + // The console has no local /plugin route, so a card without a + // delivery-provided link must open the marketplace detail page. + item_type: 'plugin', + item_id: 'langgenius/notion', + display_name: 'Notion', + link: '', + card_position: 1, + }, + { + item_type: 'template', + item_id: 'tpl-1', + display_name: 'Support Bot', + link: '', + card_position: 2, + }, + ], + }, + } + + render( + <HomeTrending + banners={[bannerWithMixedLinks]} + isMarketplacePlatform={false} + page="plugins" + />, + ) + + expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute( + 'href', + 'https://external.example.com/dropbox', + ) + const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' }) + expect(marketplaceFallbackLink.getAttribute('href')).toMatch( + /^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/, + ) + expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank') + expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute( + 'href', + '/templates?tid=tpl-1', + ) + }) + + it('clamps the active slide when a refetch shrinks the banner list', async () => { + const { rerender } = render( + <HomeTrending banners={banners} isMarketplacePlatform page="plugins" />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute( + 'aria-current', + 'true', + ) + + rerender(<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />) + + await waitFor(() => { + expect(screen.getByRole('group', { name: 'Trending' })).not.toHaveAttribute('inert') + }) + expect(screen.queryByRole('button', { name: 'Duck Duck Go' })).not.toBeInTheDocument() + }) + + it('renders no carousel when the API returns no banners', () => { + render(<HomeTrending banners={[]} isMarketplacePlatform page="plugins" />) + + expect( + screen.queryByRole('region', { + name: 'plugin.marketplace.home.trendingTitle', + }), + ).not.toBeInTheDocument() + }) + + it('tracks recommend card clicks as item clicks without a frame click', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="templates" />) + + await user.click(screen.getByRole('link', { name: 'Dropbox' })) + + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_item_click', { + banner_id: 'recommend', + sort: 0, + page: 'templates', + language: 'en', + style_type: 'recommend', + item_type: 'plugin', + item_id: 'langgenius/dropbox', + card_position: 0, + theme_type: 'hottest', + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }) + expect(mockTrackEvent).not.toHaveBeenCalledWith('marketplace_banner_click', expect.anything()) + expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( + 'marketplace_banner_click', + expect.objectContaining({ + click_target: 'recommendation', + item_id: 'langgenius/dropbox', + item_type: 'plugin', + item_name: 'Dropbox', + }), + ) + }) + + it('tracks whole-slide blog and event links as frame clicks', async () => { + const user = userEvent.setup() + + render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />) + + await user.click(screen.getByRole('button', { name: 'Dify Updates' })) + await user.click( + screen.getByRole('link', { name: 'plugin.marketplace.home.trendingReadMoreAbout' }), + ) + + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', { + banner_id: 'blog', + sort: 1, + page: 'plugins', + language: 'en', + style_type: 'blog', + }) + + await user.click(screen.getByRole('button', { name: 'Duck Duck Go' })) + await user.click(screen.getByRole('link', { name: 'DuckDuckGo plugin' })) + + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_click', { + banner_id: 'event', + sort: 2, + page: 'plugins', + language: 'en', + style_type: 'event', + }) + }) + + it('does not render banner slides whose CMS link is not http(s) or relative', () => { + const unsafeBlog: PluginBanner = { + id: 'blog-unsafe', + style_type: 'blog', + title: 'Unsafe Updates', + sort: 0, + language: 'en', + content: { + blog_title: 'Unsafe launch', + subtitle: 'Should not be clickable', + description: 'Reject javascript hrefs from CMS payloads.', + link: 'javascript:alert(1)', + link_target_type: 'blog', + }, + } + + render(<HomeTrending banners={[unsafeBlog]} isMarketplacePlatform page="plugins" />) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Unsafe launch' })).not.toBeInTheDocument() + }) + + it('dual-writes banner impressions to Amplitude and marketplace site tracking', () => { + vi.useFakeTimers() + const observers: Array<{ callback: IntersectionObserverCallback }> = [] + class MockIntersectionObserver { + disconnect = vi.fn() + observe = vi.fn() + root: Element | Document | null = null + rootMargin = '0px' + takeRecords = () => [] + thresholds = [0.5] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback) { + observers.push({ callback }) + } + } + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + + try { + const blogBanner = banners[1] + if (!blogBanner) throw new Error('Expected a blog banner fixture') + + render(<HomeTrending banners={[blogBanner]} isMarketplacePlatform page="plugins" />) + + const observer = observers.at(-1) + if (!observer) throw new Error('Expected IntersectionObserver to be registered') + + act(() => { + observer.callback( + [ + { + intersectionRatio: 0.5, + isIntersecting: true, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + }) + act(() => { + vi.advanceTimersByTime(1000) + }) + + const properties = { + banner_id: 'blog', + sort: 1, + page: 'plugins', + language: 'en', + style_type: 'blog', + } + expect(mockTrackEvent).toHaveBeenCalledWith('marketplace_banner_impression', properties) + expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith( + 'marketplace_banner_impression', + properties, + ) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts new file mode 100644 index 00000000000..2da3ec0be6f --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-href.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeMarketplaceHref } from '../marketplace-href' + +describe('sanitizeMarketplaceHref', () => { + it('allows http(s) URLs and same-origin relative paths', () => { + expect(sanitizeMarketplaceHref('https://dify.ai/blog')).toBe('https://dify.ai/blog') + expect(sanitizeMarketplaceHref('http://localhost:3000/plugin/a/b')).toBe( + 'http://localhost:3000/plugin/a/b', + ) + expect(sanitizeMarketplaceHref('/plugin/langgenius/dropbox')).toBe('/plugin/langgenius/dropbox') + }) + + it('rejects blank values and non-http schemes', () => { + expect(sanitizeMarketplaceHref('')).toBeNull() + expect(sanitizeMarketplaceHref(' ')).toBeNull() + expect(sanitizeMarketplaceHref('javascript:alert(1)')).toBeNull() + expect(sanitizeMarketplaceHref('data:text/html,bad')).toBeNull() + expect(sanitizeMarketplaceHref('mailto:test@example.com')).toBeNull() + expect(sanitizeMarketplaceHref('//evil.example')).toBeNull() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx new file mode 100644 index 00000000000..656a2e7f8ba --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-live-search.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MarketplaceLiveSearch from '../marketplace-live-search' + +const { mockReplace } = vi.hoisted(() => ({ + mockReplace: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T) => value, + } +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), +})) + +describe('MarketplaceLiveSearch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('updates the active tab result route while the user types', async () => { + const user = userEvent.setup() + + render( + <MarketplaceLiveSearch + action="/templates/knowledge" + language="en-US" + placeholder="Search templates" + query="" + />, + ) + + await user.type(screen.getByRole('searchbox'), 'legal') + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&language=en-US', { + scroll: false, + }) + }) + }) + + it('clears the query without leaving the active plugin tab', async () => { + const user = userEvent.setup() + + render( + <MarketplaceLiveSearch action="/plugins/tool" placeholder="Search plugins" query="maps" />, + ) + + await user.clear(screen.getByRole('searchbox')) + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/plugins/tool', { scroll: false }) + }) + }) + + it('preserves catalog filter params while the user types', async () => { + const user = userEvent.setup() + + render( + <MarketplaceLiveSearch + action="/templates/knowledge" + placeholder="Search templates" + query="" + preserveParams={{ languages: ['ja'] }} + />, + ) + + await user.type(screen.getByRole('searchbox'), 'legal') + + await waitFor(() => { + expect(mockReplace).toHaveBeenLastCalledWith('/templates/knowledge?q=legal&languages=ja', { + scroll: false, + }) + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx new file mode 100644 index 00000000000..d0b047063b9 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.browser.spec.tsx @@ -0,0 +1,306 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useState } from 'react' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import HomeCatalogNavigation from '../home-catalog-navigation' +import HomeSearch from '../home-search' +import { homeCatalogPinnedAtom } from '../home-sticky-state' +import { HomeStickyStateProvider } from '../home-sticky-state-provider' +import { MarketplaceSearchAutocomplete } from '../marketplace-search-autocomplete' + +const { mockTemplateSearch } = vi.hoisted(() => ({ + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T) => value, + } +}) + +vi.mock('react-i18next', async (importOriginal) => { + const original = await importOriginal<typeof import('react-i18next')>() + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return { + ...original, + ...createReactI18nextMock({ + clearSearch: 'Clear search', + loading: 'Loading', + 'marketplace.loadError': 'Failed to load. Please try again.', + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.noPluginFound': 'No integration found', + 'newApp.noTemplateFound': 'No templates found', + }), + } +}) + +vi.mock('@/service/client', async (importOriginal) => { + const original = await importOriginal<typeof import('@/service/client')>() + + return { + ...original, + marketplaceQuery: { + searchAdvanced: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'plugins', input], + queryFn: () => ({ data: { plugins: [], total: 0 } }), + }), + }, + templateSearch: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'templates', input], + queryFn: () => mockTemplateSearch(input), + }), + }, + }, + } +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, +}) + +function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +function StickyTemplateSearch() { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search templates" + scope="templates" + value={value} + /> + ) +} + +function PinnedHeaderState() { + const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom) + + return ( + <header className="sticky top-0 z-50 flex h-12 items-center bg-background-default"> + <span>Dify Marketplace</span> + {isCatalogPinned && ( + <div role="tablist" aria-label="Header catalog tabs"> + Plugins and templates + </div> + )} + </header> + ) +} + +describe('Marketplace search autocomplete layout', () => { + beforeEach(() => { + queryClient.clear() + mockTemplateSearch.mockReset() + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + it('keeps the pinned catalog layout stable while the results popup opens', async () => { + await page.viewport(1280, 720) + + const screen = await render( + <Wrapper> + <HomeStickyStateProvider> + <div + id={MARKETPLACE_CONTAINER_ID} + data-marketplace-standalone + data-testid="marketplace-scroll-container" + style={{ height: 360, width: 1200, overflowY: 'auto' }} + > + <PinnedHeaderState /> + <div style={{ height: 180 }} aria-hidden /> + <HomeSearch enableSearchShortcut={false}> + <StickyTemplateSearch /> + </HomeSearch> + <HomeCatalogNavigation + isMarketplacePlatform + catalogCategories={<div role="group" aria-label="Template categories" />} + catalogTabs={<div role="tablist" aria-label="Catalog tabs" />} + /> + <main aria-label="Template catalog" style={{ height: 900 }} /> + </div> + </HomeStickyStateProvider> + </Wrapper>, + ) + + const scrollContainer = screen.getByTestId('marketplace-scroll-container').element() + scrollContainer.scrollTop = 300 + scrollContainer.dispatchEvent(new Event('scroll')) + await new Promise(requestAnimationFrame) + + const input = screen.getByRole('combobox', { name: 'Search templates' }) + await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible() + + const catalogNavigation = screen + .getByRole('region', { name: 'common.mainNav.marketplace' }) + .element() + const scrollTopBefore = scrollContainer.scrollTop + const inputTopBefore = input.element().getBoundingClientRect().top + const navigationTopBefore = catalogNavigation.getBoundingClientRect().top + + await input.fill('open') + await expect.element(screen.getByText('No templates found')).toBeVisible() + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + await expect.element(screen.getByRole('tablist', { name: 'Header catalog tabs' })).toBeVisible() + expect(input.element().getBoundingClientRect().top).toBeCloseTo(inputTopBefore) + expect(catalogNavigation.getBoundingClientRect().top).toBeCloseTo(navigationTopBefore) + }) + + it('matches the reference grouped panel and compact result spacing', async () => { + await page.viewport(1280, 720) + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + { + id: 'template-2', + template_name: 'Contract Reviewer', + overview: 'Review contracts and identify risks.', + publisher_handle: 'dify', + usage_count: 80, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 2, + }, + }) + + const screen = await render( + <Wrapper> + <div className="w-[420px]"> + <StickyTemplateSearch /> + </div> + </Wrapper>, + ) + + await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal') + await expect.element(screen.getByText('Legal Research Agent')).toBeVisible() + + const input = screen.getByRole('combobox', { name: 'Search templates' }).element() + const searchBox = input.parentElement! + const list = screen.getByRole('listbox').element() + const panel = list.parentElement! + const templateGroup = screen.getByRole('group', { name: 'Templates' }).element() + const firstItem = screen.getByRole('option', { name: /Legal Research Agent/ }).element() + const lastItem = screen.getByRole('option', { name: /Contract Reviewer/ }).element() + const panelStyle = getComputedStyle(panel) + const listStyle = getComputedStyle(list) + const templateGroupStyle = getComputedStyle(templateGroup) + const firstItemStyle = getComputedStyle(firstItem) + const statusRoots = screen.getByRole('status').all() + const trailingStatus = statusRoots.at(-1)!.element() + + expect( + Math.abs(panel.getBoundingClientRect().width - searchBox.getBoundingClientRect().width), + ).toBeLessThanOrEqual(16) + expect(panelStyle.paddingTop).toBe('0px') + expect(panelStyle.paddingRight).toBe('0px') + expect(panelStyle.paddingBottom).toBe('0px') + expect(panelStyle.paddingLeft).toBe('0px') + expect(panelStyle.borderRadius).toBe('12px') + expect(listStyle.paddingTop).toBe('0px') + expect(templateGroupStyle.paddingTop).toBe('4px') + expect(templateGroupStyle.paddingRight).toBe('4px') + expect(templateGroupStyle.paddingBottom).toBe('4px') + expect(templateGroupStyle.paddingLeft).toBe('4px') + expect(firstItemStyle.paddingTop).toBe('4px') + expect(firstItemStyle.paddingRight).toBe('4px') + expect(firstItemStyle.paddingBottom).toBe('4px') + expect(firstItemStyle.paddingLeft).toBe('12px') + expect(firstItemStyle.borderRadius).toBe('8px') + expect(firstItemStyle.marginLeft).toBe('0px') + expect(firstItemStyle.marginRight).toBe('0px') + expect(trailingStatus.getBoundingClientRect().height).toBe(0) + expect( + panel.getBoundingClientRect().bottom - lastItem.getBoundingClientRect().bottom, + ).toBeCloseTo(5) + }) + + it('keeps result rows fully clickable without a persistent trailing arrow', async () => { + await page.viewport(390, 844) + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + }) + + const screen = await render( + <Wrapper> + <div className="w-full px-4"> + <StickyTemplateSearch /> + </div> + </Wrapper>, + ) + + await screen.getByRole('combobox', { name: 'Search templates' }).fill('legal') + const result = screen.getByRole('option', { name: /Legal Research Agent/ }) + await expect.element(result).toBeVisible() + + const resultElement = result.element() + const resultRect = resultElement.getBoundingClientRect() + const label = screen.getByText('Legal Research Agent').element() + const labelRectBeforeHover = label.getBoundingClientRect() + const trailingVisuals = Array.from( + resultElement.querySelectorAll<HTMLElement>('[aria-hidden="true"]'), + ).filter((element) => { + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.left >= resultRect.right - 40 + }) + + expect(trailingVisuals).toHaveLength(0) + expect(getComputedStyle(resultElement).cursor).toBe('pointer') + + const backgroundBeforeHover = getComputedStyle(resultElement).backgroundColor + await result.hover() + const labelRectAfterHover = label.getBoundingClientRect() + + expect(getComputedStyle(resultElement).backgroundColor).not.toBe(backgroundBeforeHover) + expect(labelRectAfterHover.left).toBeCloseTo(labelRectBeforeHover.left) + expect(labelRectAfterHover.width).toBeCloseTo(labelRectBeforeHover.width) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx new file mode 100644 index 00000000000..6c2a020a9a5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -0,0 +1,710 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} from '../marketplace-search-autocomplete' + +const { debounceState, mockAssign, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({ + // Most tests bypass the debounce for simplicity; the debounce-window test + // flips this on to exercise the real 300ms lag. + debounceState: { useRealDebounce: false }, + mockAssign: vi.fn(), + mockPluginSearch: vi.fn(), + mockTemplateSearch: vi.fn(), +})) + +vi.mock('ahooks', async (importOriginal) => { + const original = await importOriginal<typeof import('ahooks')>() + + return { + ...original, + useDebounce: <T,>(value: T, options?: { wait?: number }) => + debounceState.useRealDebounce ? original.useDebounce(value, options) : value, + } +}) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return createReactI18nextMock({ + clearSearch: 'Clear search', + loading: 'Loading', + 'marketplace.loadError': 'Failed to load. Please try again.', + 'marketplace.home.plugins': 'Plugins', + 'marketplace.home.templates': 'Templates', + 'marketplace.noPluginFound': 'No integration found', + 'newApp.noTemplateFound': 'No templates found', + }) +}) + +vi.mock('@/service/client', () => ({ + marketplaceQuery: { + searchAdvanced: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'plugins', input], + queryFn: () => mockPluginSearch(input), + }), + }, + templateSearch: { + queryOptions: ({ input }: { input: unknown }) => ({ + queryKey: ['marketplace', 'templates', input], + queryFn: () => mockTemplateSearch(input), + }), + }, + }, +})) + +let queryClient: QueryClient + +function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> +} + +describe('MarketplaceSearchAutocomplete', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAssign.mockReset() + vi.spyOn(window.location, 'assign').mockImplementation(mockAssign) + debounceState.useRealDebounce = false + queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + retry: false, + }, + }, + }) + mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } }) + mockTemplateSearch.mockResolvedValue({ data: { templates: [], total: 0 } }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows template suggestions and keeps the route search form contract', async () => { + let resolveTemplateSearch!: (value: unknown) => void + const templateSearchPromise = new Promise((resolve) => { + resolveTemplateSearch = resolve + }) + mockTemplateSearch.mockReturnValue(templateSearchPromise) + const templateSearchResponse = { + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + } + const user = userEvent.setup() + + const { container } = render( + <MarketplaceSearchForm + action="/templates/knowledge" + category="knowledge" + language="en-US" + locale="en-US" + placeholder="Search all templates..." + query="" + scope="templates" + />, + { wrapper: Wrapper }, + ) + + await user.type(screen.getByRole('combobox'), 'legal') + expect(screen.queryByText('Legal Research Agent')).not.toBeInTheDocument() + expect(screen.getByText(/Loading/)).toBeInTheDocument() + expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull() + resolveTemplateSearch(templateSearchResponse) + + expect(await screen.findByText('Legal Research Agent')).toBeInTheDocument() + expect(screen.getAllByRole('status').length).toBeGreaterThan(0) + expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() + expect(screen.getByText('Research legal questions with cited sources.')).toBeInTheDocument() + + await user.click(screen.getByText('Legal Research Agent')) + expect(mockAssign).toHaveBeenCalledWith( + '/template/dify/Legal%20Research%20Agent?templateId=template-1', + ) + + expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge') + expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q') + expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('type', 'text') + expect(container.querySelectorAll('button[aria-label="Clear search"]')).toHaveLength(1) + expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US') + expect(mockPluginSearch).not.toHaveBeenCalled() + }) + + it('shows plugin suggestions while preserving the controlled search owner', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const onValueChange = vi.fn() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={(nextValue) => { + onValueChange(nextValue) + setValue(nextValue) + }} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + + expect(await screen.findByText('Google Search')).toBeInTheDocument() + expect(screen.getByText('Search the web from your workflow.')).toBeInTheDocument() + expect(screen.getByRole('listbox').querySelector('img')).toHaveAttribute( + 'src', + `${MARKETPLACE_API_PREFIX}/plugins/langgenius/google-search/icon`, + ) + expect(onValueChange).toHaveBeenLastCalledWith('google') + expect(mockTemplateSearch).not.toHaveBeenCalled() + }) + + it('groups mixed suggestions and opens the selected result instead of viewing more', async () => { + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + }) + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const { container } = render( + <MarketplaceSearchForm + action="/" + locale="en-US" + placeholder="Search plugins or templates" + query="" + scope="all" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'search') + + const templateGroup = await screen.findByRole('group', { name: 'Templates' }) + const pluginGroup = screen.getByRole('group', { name: 'Plugins' }) + expect(within(templateGroup).getByText('Legal Research Agent')).toBeInTheDocument() + expect(within(pluginGroup).getByText('Google Search')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /view more/i })).not.toBeInTheDocument() + + await user.click(screen.getByText('Google Search')) + + expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() + }) + + it('submits the typed query on Enter without selecting a hovered suggestion', async () => { + mockTemplateSearch.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Legal Research Agent', + overview: 'Research legal questions with cited sources.', + publisher_handle: 'dify', + usage_count: 120, + categories: ['knowledge'], + icon: '📄', + icon_background: '#FFFFFF', + icon_file_key: '', + }, + ], + total: 1, + }, + }) + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const { container } = render( + <MarketplaceSearchForm + action="/search/all" + locale="en-US" + placeholder="Search plugins or templates" + query="" + scope="all" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'search') + await user.hover(await screen.findByRole('option', { name: /Legal Research Agent/ })) + await user.keyboard('{Enter}') + + expect(handleSubmit).toHaveBeenCalledOnce() + expect(screen.getByRole('combobox')).toHaveValue('search') + }) + + it('opens plugin detail when a suggestion is chosen', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const { container } = render( + <MarketplaceSearchForm + action="/plugins" + locale="en-US" + placeholder="Search plugins" + query="" + scope="plugins" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'google') + await user.click(await screen.findByText('Google Search')) + + expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() + }) + + it('selects a suggestion without submitting when the parent handles the result', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const onSuggestionSelect = vi.fn() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <form> + <MarketplaceSearchAutocomplete + inputName="q" + locale="en-US" + onSuggestionSelect={onSuggestionSelect} + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + </form> + ) + } + + const { container } = render(<ControlledSearch />, { wrapper: Wrapper }) + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'google') + await user.click(await screen.findByText('Google Search')) + + expect(onSuggestionSelect).toHaveBeenCalledOnce() + expect(onSuggestionSelect.mock.calls[0]?.[0]).toMatchObject({ + kind: 'plugin', + plugin: { name: 'google-search' }, + }) + expect(handleSubmit).not.toHaveBeenCalled() + }) + + it('keeps keyboard selection working for the highlighted suggestion', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const { container } = render( + <MarketplaceSearchForm + action="/plugins" + locale="en-US" + placeholder="Search plugins" + query="" + scope="plugins" + />, + { wrapper: Wrapper }, + ) + + container.querySelector('form')?.addEventListener('submit', handleSubmit) + + await user.type(screen.getByRole('combobox'), 'google') + expect(await screen.findByText('Google Search')).toBeInTheDocument() + await user.keyboard('{ArrowDown}{Enter}') + + expect(mockAssign).toHaveBeenCalledWith('/plugin/langgenius/google-search') + expect(handleSubmit).not.toHaveBeenCalled() + }) + + it('hands the selected plugin back to a creator-profile owner without submitting', async () => { + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + const onSuggestionSelect = vi.fn() + const handleSubmit = vi.fn((event: Event) => { + event.preventDefault() + }) + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <form + onSubmit={(event) => { + handleSubmit(event.nativeEvent) + }} + > + <MarketplaceSearchAutocomplete + locale="en-US" + onSuggestionSelect={onSuggestionSelect} + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + </form> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + await user.click(await screen.findByText('Google Search')) + + expect(onSuggestionSelect).toHaveBeenCalledWith({ + kind: 'plugin', + plugin: expect.objectContaining({ + org: 'langgenius', + name: 'google-search', + }), + }) + expect(handleSubmit).not.toHaveBeenCalled() + expect(screen.getByRole('combobox')).toHaveValue('') + }) + + it('does not offer the previous term suggestions while a new search is pending', async () => { + const googleResponse = { + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + } + mockPluginSearch.mockImplementation((input: { body: { query: string } }) => { + if (input.body.query === 'google') return Promise.resolve(googleResponse) + // Keep the follow-up term pending so stale suggestions would be visible + // if the query still returned placeholder data. + return new Promise(() => {}) + }) + const user = userEvent.setup() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + expect(await screen.findByText('Google Search')).toBeInTheDocument() + + await user.type(screen.getByRole('combobox'), ' drive') + + expect(screen.queryByText('Google Search')).not.toBeInTheDocument() + expect(screen.getByText(/Loading/)).toBeInTheDocument() + expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull() + }) + + it('does not reopen after dismiss while a request is still pending', async () => { + let resolvePluginSearch!: (value: unknown) => void + mockPluginSearch.mockReturnValue( + new Promise((resolve) => { + resolvePluginSearch = resolve + }), + ) + const user = userEvent.setup() + const pluginResponse = { + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + } + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <> + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + <button type="button">Outside search</button> + </> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'google') + expect(screen.getByText(/Loading/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Outside search' })) + await waitFor(() => { + expect(screen.getByText(/Loading/)).not.toBeVisible() + }) + + resolvePluginSearch(pluginResponse) + + await waitFor(() => { + expect(mockPluginSearch).toHaveBeenCalled() + }) + expect(screen.queryByText('Google Search')).not.toBeInTheDocument() + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() + }) + + it('keeps the empty and status roots mounted when nothing matches', async () => { + mockPluginSearch.mockResolvedValue({ data: { plugins: [], total: 0 } }) + const user = userEvent.setup() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + await user.type(screen.getByRole('combobox'), 'zzzz') + + expect(await screen.findByText('No integration found')).toBeInTheDocument() + expect(screen.getAllByRole('status').length).toBeGreaterThan(0) + expect(screen.queryByText(/Loading/)).not.toBeInTheDocument() + }) + + it('clears suggestions while the edited value is still debouncing', async () => { + debounceState.useRealDebounce = true + mockPluginSearch.mockResolvedValue({ + data: { + plugins: [ + { + type: 'plugin', + org: 'langgenius', + name: 'google-search', + label: { en_US: 'Google Search' }, + brief: { en_US: 'Search the web from your workflow.' }, + category: 'tool', + }, + ], + total: 1, + }, + }) + const user = userEvent.setup() + + const ControlledSearch = () => { + const [value, setValue] = useState('') + + return ( + <MarketplaceSearchAutocomplete + locale="en-US" + onValueChange={setValue} + placeholder="Search plugins" + scope="plugins" + value={value} + /> + ) + } + + render(<ControlledSearch />, { wrapper: Wrapper }) + + // Suggestions only appear once the real 300ms debounce has elapsed. + await user.type(screen.getByRole('combobox'), 'google') + expect(await screen.findByText('Google Search')).toBeInTheDocument() + + // For the first 300ms after editing, the debounced term still points at + // the old query; the previous suggestions must already be gone. + await user.type(screen.getByRole('combobox'), ' drive') + + expect(screen.queryByText('Google Search')).not.toBeInTheDocument() + expect(screen.getByText(/Loading/)).toBeInTheDocument() + expect(screen.getByText(/Loading/).closest('[aria-busy="true"]')).not.toBeNull() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx new file mode 100644 index 00000000000..c6006159b0c --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/preserve-sticky-search-scroll.browser.spec.tsx @@ -0,0 +1,116 @@ +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { MARKETPLACE_CONTAINER_ID } from '../../constants' +import { preserveStickySearchScroll } from '../preserve-sticky-search-scroll' + +const nextFrame = () => + new Promise<void>((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + +const SearchPage = ({ popup }: { popup?: boolean }) => ( + <> + <div id={MARKETPLACE_CONTAINER_ID} style={{ height: 320, overflowY: 'auto' }}> + <div style={{ height: 48, flexShrink: 0 }}>Header</div> + <div style={{ height: 180, flexShrink: 0 }}>Hero</div> + <div + data-testid="search-root" + style={{ position: 'sticky', top: 6, height: 36, marginTop: -36 }} + > + <input aria-label="Search plugins or templates" style={{ height: 36, width: '100%' }} /> + </div> + <div style={{ height: 900, flexShrink: 0 }}>Catalog</div> + </div> + {popup ? ( + <div + data-testid="search-popup" + style={{ + height: 80, + overflowY: 'auto', + position: 'fixed', + top: 50, + left: 100, + width: 200, + }} + > + Short + </div> + ) : null} + </> +) + +describe('Sticky search scroll guard', () => { + it('keeps the scroll position when Chromium focuses the in-flow sticky input', async () => { + await page.viewport(1280, 900) + + const screen = await render(<SearchPage />) + const container = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const searchRoot = screen.getByTestId('search-root').element() + const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element() + + const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container) + container.scrollTop = 400 + container.dispatchEvent(new Event('scroll')) + await nextFrame() + + const scrollTopBefore = container.scrollTop + HTMLInputElement.prototype.focus.call(input) + await nextFrame() + + expect(container.scrollTop).toBe(scrollTopBefore) + stop() + }) + + it('keeps visitor-initiated scroll after typing in the sticky search', async () => { + await page.viewport(1280, 900) + + const screen = await render(<SearchPage />) + const container = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const searchRoot = screen.getByTestId('search-root').element() + const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element() + + const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container) + container.scrollTop = 400 + container.dispatchEvent(new Event('scroll')) + await nextFrame() + + input.focus() + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' })) + await nextFrame() + + expect(container.scrollTop).toBe(400) + + container.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true })) + container.scrollTop = 520 + container.dispatchEvent(new Event('scroll')) + await nextFrame() + + expect(container.scrollTop).toBe(520) + stop() + }) + + it('scrolls the page when the visitor wheels over a portaled popup that cannot scroll', async () => { + await page.viewport(1280, 900) + + const screen = await render(<SearchPage popup />) + const container = document.getElementById(MARKETPLACE_CONTAINER_ID)! + const searchRoot = screen.getByTestId('search-root').element() + const input = screen.getByRole('textbox', { name: 'Search plugins or templates' }).element() + const popup = screen.getByTestId('search-popup').element() + + const stop = preserveStickySearchScroll(searchRoot as HTMLElement, container) + container.scrollTop = 400 + container.dispatchEvent(new Event('scroll')) + await nextFrame() + + input.focus() + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: 'open' })) + await nextFrame() + + popup.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })) + await nextFrame() + + expect(container.scrollTop).toBe(520) + stop() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts b/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts new file mode 100644 index 00000000000..e32a020f211 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/__tests__/use-banner-viewability.spec.ts @@ -0,0 +1,137 @@ +import { act, render } from '@testing-library/react' +import { createElement, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useBannerViewability } from '../use-banner-viewability' + +type ObserverRecord = { + callback: IntersectionObserverCallback + options?: IntersectionObserverInit +} + +let observers: ObserverRecord[] = [] + +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null + readonly rootMargin: string + readonly scrollMargin = '' + readonly thresholds: readonly number[] + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + takeRecords = () => [] + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + observers.push({ callback, options }) + } +} + +function ViewabilityProbe({ + enabled = true, + onImpression, +}: { + enabled?: boolean + onImpression: () => void +}) { + const targetRef = useRef<HTMLDivElement>(null) + useBannerViewability(targetRef, onImpression, enabled) + return createElement('div', { ref: targetRef, 'data-testid': 'banner-slide' }) +} + +function triggerIntersection(intersectionRatio: number) { + const observer = observers.at(-1) + if (!observer) throw new Error('Expected IntersectionObserver to be registered') + + act(() => { + observer.callback( + [ + { + intersectionRatio, + isIntersecting: intersectionRatio > 0, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + }) +} + +describe('useBannerViewability', () => { + beforeEach(() => { + observers = [] + vi.useFakeTimers() + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('records one impression after the slide stays at least 50% visible for 1000ms', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.5) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onImpression).toHaveBeenCalledOnce() + + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(onImpression).toHaveBeenCalledOnce() + }) + + it('records a second impression after the slide leaves and becomes viewable again', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.8) + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(onImpression).toHaveBeenCalledOnce() + + triggerIntersection(0) + triggerIntersection(0.6) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onImpression).toHaveBeenCalledTimes(2) + }) + + it('does not record an impression when the slide is visible for less than 1s', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.9) + act(() => { + vi.advanceTimersByTime(999) + }) + triggerIntersection(0) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onImpression).not.toHaveBeenCalled() + }) + + it('does not record an impression when the visible ratio stays below 0.5', () => { + const onImpression = vi.fn() + render(createElement(ViewabilityProbe, { onImpression })) + + triggerIntersection(0.49) + act(() => { + vi.advanceTimersByTime(2000) + }) + + expect(onImpression).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/home/assets/background.webp b/web/app/components/plugins/marketplace/home/assets/background.webp new file mode 100644 index 0000000000000000000000000000000000000000..ff09b6466a665e693e5b2f8d0e4de486eee874ea GIT binary patch literal 97014 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 z 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]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 Rb*B65kj8O)5Zfp~003~w<v;)c literal 0 HcmV?d00001 diff --git a/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg b/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg new file mode 100644 index 00000000000..c747d0dbf1d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/brain-2-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="brain-2-fill"> +<path id="Vector" d="M8.5 2C6.567 2 5 3.567 5 5.5C5 5.68016 5.01364 5.85714 5.03993 6.02997C3.32436 6.25523 2 7.72295 2 9.5C2 10.4793 2.40223 11.3647 3.05051 12C2.40223 12.6353 2 13.5207 2 14.5C2 15.9018 2.82359 17.1104 4.01353 17.6693C4.00457 17.7785 4 17.8888 4 18C4 20.2091 5.79086 22 8 22C9.19469 22 10.2671 21.4762 11 20.6458V3.05051C10.3647 2.40223 9.47934 2 8.5 2ZM13 3.05051V20.6458C13.7329 21.4762 14.8053 22 16 22C18.2091 22 20 20.2091 20 18C20 17.8888 19.9954 17.7785 19.9865 17.6693C21.1764 17.1104 22 15.9018 22 14.5C22 13.5207 21.5978 12.6353 20.9495 12C21.5978 11.3647 22 10.4793 22 9.5C22 7.72295 20.6756 6.25523 18.9601 6.02997C18.9864 5.85714 19 5.68016 19 5.5C19 3.567 17.433 2 15.5 2C14.5207 2 13.6353 2.40223 13 3.05051Z" fill="#0033FF"/> +</g> +</svg> 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 0000000000000000000000000000000000000000..12d192cef3619cefe753668e4c27f6d6bb84adc4 GIT binary patch literal 21416 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 literal 0 HcmV?d00001 diff --git a/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg b/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg new file mode 100644 index 00000000000..d2fa982771e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/image-circle-ai-line.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="image-circle-ai-line"> +<path id="Vector" d="M20.4668 8.69379L20.7134 8.12811C21.1529 7.11947 21.9445 6.31641 22.9323 5.87708L23.6919 5.53922C24.1027 5.35653 24.1027 4.75881 23.6919 4.57612L22.9748 4.25714C21.9616 3.80651 21.1558 2.97373 20.7238 1.93083L20.4706 1.31953C20.2942 0.893489 19.7058 0.893489 19.5293 1.31953L19.2761 1.93083C18.8442 2.97373 18.0384 3.80651 17.0252 4.25714L16.308 4.57612C15.8973 4.75881 15.8973 5.35653 16.308 5.53922L17.0677 5.87708C18.0555 6.31641 18.8471 7.11947 19.2866 8.12811L19.5331 8.69379C19.7136 9.10792 20.2864 9.10792 20.4668 8.69379ZM12 4C7.58172 4 4 7.58172 4 12C4 14.4636 5.11358 16.6671 6.86484 18.1346L14.2925 10.707C14.683 10.3164 15.3162 10.3164 15.7067 10.707L19.5761 14.5764C19.5773 14.5729 19.5785 14.5693 19.5797 14.5658C19.8522 13.7604 20 12.8975 20 12C20 11.6765 19.9809 11.3579 19.9437 11.0452L21.9298 10.8094C21.9762 11.2002 22 11.5975 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C12.8614 2 13.6987 2.10914 14.4983 2.31487L14 4.25179C13.3618 4.0876 12.6919 4 12 4ZM10.813 19.9125C11.2 19.9701 11.5962 19.9998 11.9996 19.9998C14.7613 19.9998 17.1992 18.6003 18.6379 16.4666L14.9996 12.8283L8.58927 19.2386L8.59334 19.2405C9.28476 19.5664 10.0304 19.7961 10.813 19.9125ZM11 10C11 11.1046 10.1046 12 9 12C7.89543 12 7 11.1046 7 10C7 8.89543 7.89543 8 9 8C10.1046 8 11 8.89543 11 10Z" fill="#FF4405"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/plug-fill.svg b/web/app/components/plugins/marketplace/home/assets/plug-fill.svg new file mode 100644 index 00000000000..d6c546294e6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/plug-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="plug-fill"> +<path id="Vector" d="M13 18V20H19V22H13C11.8954 22 11 21.1046 11 20V18H8C5.79086 18 4 16.2091 4 14V10H20V14C20 16.2091 18.2091 18 16 18H13ZM16 6H19C19.5523 6 20 6.44772 20 7V9H4V7C4 6.44772 4.44772 6 5 6H8V2H10V6H14V2H16V6ZM12 14.5C12.5523 14.5 13 14.0523 13 13.5C13 12.9477 12.5523 12.5 12 12.5C11.4477 12.5 11 12.9477 11 13.5C11 14.0523 11.4477 14.5 12 14.5Z" fill="#0E9384"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg b/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg new file mode 100644 index 00000000000..f9e75e09c07 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/puzzle-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="IconPuzzleFill"> +<path id="Vector" d="M9.5 4V3.5C9.5 2.11929 10.6193 1 12 1C13.3807 1 14.5 2.11929 14.5 3.5V4H20C20.5523 4 21 4.44772 21 5V9C21 9.27614 20.7761 9.5 20.5 9.5C19.1193 9.5 18 10.6193 18 12C18 13.3807 19.1193 14.5 20.5 14.5C20.7761 14.5 21 14.7239 21 15V19C21 19.5523 20.5523 20 20 20H4C3.44772 20 3 19.5523 3 19V5C3 4.44772 3.44772 4 4 4H9.5Z" fill="#0BA5EC"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp b/web/app/components/plugins/marketplace/home/assets/recommend-mobile-backdrop.webp new file mode 100644 index 0000000000000000000000000000000000000000..54e94fb7de7842733a6819ac2c6945df08c3eaaa GIT binary patch literal 292856 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%0D61B>#)QI{#w`TvjC+*Cx5U7^-LLjR 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 C)u#;}z(15CC#F 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 literal 0 HcmV?d00001 diff --git a/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg b/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg new file mode 100644 index 00000000000..3fa7e2c52af --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/sparkling-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="sparkling-fill"> +<path id="Vector" d="M14 4.4375C15.3462 4.4375 16.4375 3.34619 16.4375 2H17.5625C17.5625 3.34619 18.6538 4.4375 20 4.4375V5.5625C18.6538 5.5625 17.5625 6.65381 17.5625 8H16.4375C16.4375 6.65381 15.3462 5.5625 14 5.5625V4.4375ZM1 11C4.31371 11 7 8.31371 7 5H9C9 8.31371 11.6863 11 15 11V13C11.6863 13 9 15.6863 9 19H7C7 15.6863 4.31371 13 1 13V11ZM17.25 14C17.25 15.7949 15.7949 17.25 14 17.25V18.75C15.7949 18.75 17.25 20.2051 17.25 22H18.75C18.75 20.2051 20.2051 18.75 22 18.75V17.25C20.2051 17.25 18.75 15.7949 18.75 14H17.25Z" fill="#7839EE"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg b/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg new file mode 100644 index 00000000000..2124d153d36 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/assets/voice-ai-fill.svg @@ -0,0 +1,5 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="voice-ai-fill"> +<path id="Vector" d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM8.5 6H6.5V18H8.5V6ZM4 10H2V14H4V10ZM13 2H11V22H13V2ZM17.5 8H15.5V18H17.5V8ZM22 10H20V14H22V10Z" fill="#0BA5EC"/> +</g> +</svg> diff --git a/web/app/components/plugins/marketplace/home/banners.spec.ts b/web/app/components/plugins/marketplace/home/banners.spec.ts new file mode 100644 index 00000000000..6989ec912e5 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/banners.spec.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { marketplaceClient } from '@/service/client' +import { fetchPluginBanners } from './banners' + +vi.mock('@/service/client', () => ({ + marketplaceClient: { + banners: { + list: vi.fn(), + }, + }, +})) + +const mockedListBanners = vi.mocked(marketplaceClient.banners.list) + +describe('fetchPluginBanners', () => { + beforeEach(() => { + mockedListBanners.mockReset() + }) + + it('normalizes every public banner style in API sort order', async () => { + mockedListBanners.mockResolvedValue({ + code: 0, + msg: 'success', + data: { + banners: [ + { + id: 'event', + style_type: 'event', + 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/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, + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }, + { + item_type: 'plugin', + item_id: 'langgenius/third', + display_name: 'Third', + link: '/plugins/langgenius/third', + card_position: 2, + }, + { + item_type: 'plugin', + item_id: 'langgenius/second', + display_name: 'Second', + link: '/plugins/langgenius/second', + card_position: 1, + }, + ], + }, + }, + { + id: 'ad', + style_type: 'ad', + title: 'Partner campaign', + sort: 4, + language: 'en', + content: { + 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 fetchPluginBanners('en-US') + + expect(mockedListBanners).toHaveBeenCalledWith({ + query: { + page: 'plugins', + language: 'en-US', + }, + }) + 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'], + auto_batch_id: '11111111-1111-4111-8111-111111111111', + }) + } + + 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('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(fetchPluginBanners('en-US')).resolves.toEqual([]) + await expect(fetchPluginBanners('en-US')).resolves.toEqual([]) + }) + + it('requests templates banners when fetching for the templates page', async () => { + mockedListBanners.mockResolvedValue({ + data: { + banners: [], + }, + }) + + await expect(fetchPluginBanners('en-US', 'templates')).resolves.toEqual([]) + expect(mockedListBanners).toHaveBeenCalledWith({ + query: { + page: 'templates', + language: 'en-US', + }, + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/banners.ts b/web/app/components/plugins/marketplace/home/banners.ts new file mode 100644 index 00000000000..1b1f24cc985 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/banners.ts @@ -0,0 +1,154 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { z } from 'zod' +import { marketplaceClient } from '@/service/client' + +// The banner types live in @dify/contracts/marketplace so the standalone +// marketplace and the embedded console share one definition; this module owns +// the runtime normalization of the untyped delivery payload. +const MAX_CARDS_PER_PAGE = 4 + +// Mirrors the previous hand-rolled parsing: an optional field of the wrong +// type is dropped instead of rejecting the whole banner. +const lenientOptionalString = z.string().optional().catch(undefined) +// Same, but an empty string also collapses to undefined (responsive image +// variants are only useful when they actually point somewhere). +const lenientNonEmptyString = z.string().min(1).optional().catch(undefined) + +const bannerBaseShape = { + id: z.string().min(1), + title: z.string().min(1), + sort: z.number(), + language: z.string().min(1), +} + +const recommendCardSchema = z.object({ + item_type: z.enum(['plugin', 'template']), + item_id: z.string().min(1), + display_name: z.string().min(1), + icon_url: lenientOptionalString, + icon: lenientOptionalString, + icon_background: lenientOptionalString, + creator: lenientOptionalString, + badges: z + .unknown() + .transform((value) => + Array.isArray(value) + ? value.filter( + (badge): badge is 'partner' | 'verified' => badge === 'partner' || badge === 'verified', + ) + : undefined, + ) + // The trailing optional keeps the key optional in the inferred type and + // lets a missing field bypass the transform pipeline. + .optional(), + link: z.string().catch(''), + card_position: z.number().catch(0), + auto_batch_id: z.union([z.string(), z.null()]).optional().catch(undefined), +}) + +const recommendContentSchema = z.object({ + theme_type: z.enum(['newest', 'hottest', 'partner']), + heading: lenientOptionalString, + subheadings: z + .unknown() + .transform((value) => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined, + ) + .optional(), + description: lenientOptionalString, + cards: z + .array(recommendCardSchema.nullable().catch(null)) + .catch([]) + .transform((cards) => + cards + .flatMap((card) => (card === null ? [] : [card])) + .sort((a, b) => a.card_position - b.card_position) + .slice(0, MAX_CARDS_PER_PAGE), + ) + // A recommendation banner with no renderable card has nothing to show. + .refine((cards) => cards.length > 0), +}) + +const blogContentSchema = z.object({ + blog_title: z.string().min(1), + subtitle: lenientOptionalString, + description: lenientOptionalString, + link: z.string().min(1), + link_target_type: z.enum(['blog', 'github']), +}) + +const imageContentShape = { + images: z.object({ + desktop: z.string().min(1), + tablet: lenientNonEmptyString, + mobile: lenientNonEmptyString, + }), + link: z.string().min(1), + alt_text: lenientOptionalString, + activity_id: lenientOptionalString, +} + +const pluginBannerSchema = z.discriminatedUnion('style_type', [ + z.object({ + ...bannerBaseShape, + style_type: z.literal('recommend'), + content: recommendContentSchema, + }), + z.object({ + ...bannerBaseShape, + style_type: z.literal('blog'), + content: blogContentSchema, + }), + z.object({ + ...bannerBaseShape, + style_type: z.literal('event'), + content: z.object(imageContentShape), + }), + z.object({ + ...bannerBaseShape, + style_type: z.literal('ad'), + content: z.object({ + ...imageContentShape, + partner_id: lenientOptionalString, + campaign_id: lenientOptionalString, + }), + }), +]) + +const bannersResponseSchema = z.object({ + data: z.object({ + banners: z.array(z.unknown()), + }), +}) + +const normalizePluginBanners = (response: unknown): PluginBanner[] => { + const parsedResponse = bannersResponseSchema.safeParse(response) + if (!parsedResponse.success) return [] + + return parsedResponse.data.data.banners + .flatMap((banner): PluginBanner[] => { + // Malformed banners are dropped individually so one bad delivery entry + // does not blank the whole trending section. + const parsedBanner = pluginBannerSchema.safeParse(banner) + return parsedBanner.success ? [parsedBanner.data] : [] + }) + .sort((a, b) => a.sort - b.sort) +} + +export type MarketplaceBannerPage = 'plugins' | 'templates' + +export const fetchPluginBanners = async ( + language: string, + page: MarketplaceBannerPage = 'plugins', +): Promise<PluginBanner[]> => { + const response = await marketplaceClient.banners.list({ + query: { + page, + language, + }, + }) + + return normalizePluginBanners(response) +} diff --git a/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx b/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx new file mode 100644 index 00000000000..eae77708b2e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/catalog-languages-filter.tsx @@ -0,0 +1,177 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' +import { cn } from '@langgenius/dify-ui/cn' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' +import { useFilterTemplateLanguages } from '../atoms' +import { LANGUAGE_OPTIONS } from '../templates/template-language' + +export default function CatalogLanguagesFilter() { + const { t } = useTranslation() + const [languages, setLanguages] = useFilterTemplateLanguages() + const [open, setOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const triggerRef = useRef<HTMLButtonElement>(null) + const shouldRestoreFocusRef = useRef(false) + const selectedOptions = LANGUAGE_OPTIONS.filter((option) => languages.includes(option.value)) + const selectedNativeLabels = selectedOptions.map((option) => option.nativeLabel) + const selectedCount = selectedOptions.length + const triggerLabel = selectedNativeLabels.length + ? selectedNativeLabels.join(', ') + : t(($) => $['marketplace.languages'], { ns: 'plugin' }) + const searchQuery = searchText.toLowerCase() + const filteredOptions = LANGUAGE_OPTIONS.filter( + (option) => + option.label.toLowerCase().includes(searchQuery) || + option.nativeLabel.toLowerCase().includes(searchQuery), + ) + + useEffect(() => { + if (selectedCount || !shouldRestoreFocusRef.current) return + + shouldRestoreFocusRef.current = false + triggerRef.current?.focus() + }, [selectedCount]) + + const handleLanguagesChange = (next: string[]) => { + const addedLanguage = next.find((language) => !languages.includes(language)) + const removedLanguage = languages.find((language) => !next.includes(language)) + markMarketplaceSiteFilter({ + filter_type: 'language', + selection_mode: 'multi', + filter_value: addedLanguage ?? removedLanguage ?? next.at(-1) ?? '', + selected_values: next, + }) + // Server-rendered template results read `languages` from the URL, so this + // update must notify the App Router instead of only rewriting history. + setLanguages(next.length ? next : null, { shallow: false }) + } + + return ( + <Popover open={open} onOpenChange={setOpen}> + <div className="relative inline-flex h-8 shrink-0 items-center"> + <PopoverTrigger + render={ + <Button + ref={triggerRef} + variant="ghost" + size="medium" + aria-label={triggerLabel} + className={cn( + 'h-8 justify-start px-2 py-1 text-text-tertiary focus-visible:ring-inset', + !!selectedCount && + 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg pr-8 shadow-xs shadow-shadow-shadow-3', + !selectedCount && 'data-popup-open:bg-state-base-hover', + )} + > + <span className="py-0.5"> + <span + aria-hidden + className={cn( + 'i-ri-global-line block size-4', + !!selectedCount && 'text-text-secondary', + )} + /> + </span> + <span className="flex items-center gap-x-1 py-1 system-sm-medium"> + {!selectedCount && ( + <span>{t(($) => $['marketplace.languages'], { ns: 'plugin' })}</span> + )} + {!!selectedCount && ( + <span className="text-text-secondary"> + {selectedNativeLabels.slice(0, 2).join(',')} + </span> + )} + {selectedCount > 2 && ( + <span className="system-xs-medium text-text-tertiary">+{selectedCount - 2}</span> + )} + </span> + {!selectedCount && ( + <span className="py-0.5"> + <span + aria-hidden + className="i-ri-arrow-down-s-line block size-4 text-text-tertiary" + /> + </span> + )} + </Button> + } + /> + {!!selectedCount && ( + <IconButton + variant="ghost" + size="md" + aria-label={t(($) => $.clearSearch, { + ns: 'plugin', + label: triggerLabel, + })} + className="absolute right-1 focus-visible:ring-inset" + onClick={() => { + shouldRestoreFocusRef.current = true + handleLanguagesChange([]) + }} + > + <span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" /> + </IconButton> + )} + </div> + <PopoverContent + placement="bottom-end" + sideOffset={4} + alignOffset={-6} + className="border-none bg-transparent shadow-none" + > + <div className="w-60 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"> + <div className="p-2 pb-1"> + <InputGroup> + <InputGroupInput + type="search" + name="language-query" + autoComplete="off" + enterKeyHint="search" + aria-label={t(($) => $['marketplace.searchFilterLanguage'], { ns: 'plugin' }) || ''} + className="[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" + value={searchText} + onValueChange={setSearchText} + placeholder={ + t(($) => $['marketplace.searchFilterLanguage'], { ns: 'plugin' }) || '' + } + /> + <InputGroupAddon className="ps-1.75 pe-0.75"> + <span + aria-hidden + className="i-ri-search-line size-4 text-components-input-text-placeholder" + /> + </InputGroupAddon> + </InputGroup> + </div> + <CheckboxGroup + aria-label={t(($) => $['marketplace.languages'], { ns: 'plugin' })} + value={languages} + onValueChange={handleLanguagesChange} + className="max-h-112 overflow-y-auto p-1" + > + {filteredOptions.map((option) => ( + <label + key={option.value} + className="flex h-7 cursor-pointer items-center rounded-lg px-2 py-1.5 select-none hover:bg-state-base-hover" + > + <Checkbox className="mr-1" value={option.value} /> + <div className="px-1 system-sm-medium text-text-secondary"> + {option.nativeLabel} + </div> + </label> + ))} + </CheckboxGroup> + </div> + </PopoverContent> + </Popover> + ) +} diff --git a/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx b/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx new file mode 100644 index 00000000000..d0ee51665e3 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/catalog-tags-filter.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useFilterPluginTags } from '../atoms' +import TagsFilter from '../search-box/tags-filter' + +export default function CatalogTagsFilter() { + const [tags, setTags] = useFilterPluginTags() + return ( + <TagsFilter + tags={tags} + onTagsChange={(next) => setTags(next.length ? next : null)} + usedInMarketplace + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/embedded-marketplace-search.tsx b/web/app/components/plugins/marketplace/home/embedded-marketplace-search.tsx new file mode 100644 index 00000000000..4a91b69912e --- /dev/null +++ b/web/app/components/plugins/marketplace/home/embedded-marketplace-search.tsx @@ -0,0 +1,101 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { MarketplaceSearchSelection } from './marketplace-search-autocomplete' +import type { Plugin } from '@/app/components/plugins/types' +import { useCallback, useState } from 'react' +import { useLocale, useTranslation } from '#i18n' +import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' +import { useRouter } from '@/next/navigation' +import { useSearchPluginText } from '../atoms' +import MarketplaceDetailDialog from '../detail-dialog' +import TemplateDetailDialog from '../templates/template-detail-dialog' +import { getFormattedPlugin } from '../utils' +import { MarketplaceSearchAutocomplete } from './marketplace-search-autocomplete' + +const normalizePlugin = (plugin: Plugin): Plugin => ({ + ...plugin, + plugin_id: plugin.plugin_id || `${plugin.org}/${plugin.name}`, + label: plugin.label ?? {}, + brief: plugin.brief ?? {}, + description: plugin.description ?? {}, + tags: plugin.tags ?? [], + badges: plugin.badges ?? null, +}) + +export default function EmbeddedMarketplaceSearch() { + const { t } = useTranslation() + const locale = useLocale() + const router = useRouter() + const [query, setQuery] = useSearchPluginText() + const [value, setValue] = useState(query ?? '') + const [valueQuery, setValueQuery] = useState(query) + if (query !== valueQuery) { + setValueQuery(query) + setValue(query ?? '') + } + const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null) + const [selectedTemplate, setSelectedTemplate] = useState<MarketplaceTemplate | null>(null) + const { installedInfo } = useCheckInstalled({ + pluginIds: selectedPlugin ? [selectedPlugin.plugin_id] : [], + enabled: Boolean(selectedPlugin), + }) + + const handleSuggestionSelect = useCallback((selection: MarketplaceSearchSelection) => { + if (selection.kind === 'plugin') { + setSelectedTemplate(null) + setSelectedPlugin(normalizePlugin(getFormattedPlugin(selection.plugin))) + return + } + + setSelectedPlugin(null) + setSelectedTemplate(selection.template) + }, []) + + return ( + <> + <form + className="relative w-full shrink-0" + onSubmit={(event) => { + event.preventDefault() + void setQuery(value.trim() || null) + }} + > + <MarketplaceSearchAutocomplete + key={query ?? ''} + inputName="q" + locale={locale} + onSuggestionSelect={handleSuggestionSelect} + onValueChange={setValue} + placeholder={t(($) => $['marketplace.home.searchPlaceholder'], { ns: 'plugin' })} + scope="all" + value={value} + /> + </form> + {selectedPlugin && ( + <MarketplaceDetailDialog + isInstalled={Boolean(installedInfo?.[selectedPlugin.plugin_id])} + open + plugin={selectedPlugin} + onOpenChange={(open) => { + if (!open) setSelectedPlugin(null) + }} + /> + )} + {selectedTemplate && ( + <TemplateDetailDialog + open + template={selectedTemplate} + onInstall={() => { + const templateId = selectedTemplate.id + setSelectedTemplate(null) + router.push(`/apps?template-id=${encodeURIComponent(templateId)}`) + }} + onOpenChange={(open) => { + if (!open) setSelectedTemplate(null) + }} + /> + )} + </> + ) +} diff --git a/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts b/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts new file mode 100644 index 00000000000..7bc39fb631f --- /dev/null +++ b/web/app/components/plugins/marketplace/home/event-ad-banner-image.ts @@ -0,0 +1,22 @@ +export const MARKETPLACE_MOBILE_BANNER_MEDIA = '(max-width: 879px)' +export const EMBEDDED_MOBILE_BANNER_MEDIA = '(max-width: 639px)' + +export function marketplaceTabletBannerMedia(isMarketplacePlatform: boolean) { + return isMarketplacePlatform + ? '(min-width: 880px) and (max-width: 1023px)' + : '(min-width: 640px) and (max-width: 1023px)' +} + +export function resolveEventAdBannerImageSrcs(images: { + desktop: string + tablet?: string + mobile?: string +}) { + return { + desktop: images.desktop, + // Phones always get a source: the mobile asset when present, otherwise desktop. + // That keeps tablet from winning at mobile widths. + mobile: images.mobile || images.desktop, + tablet: images.tablet || undefined, + } +} diff --git a/web/app/components/plugins/marketplace/home/home-catalog-focus.ts b/web/app/components/plugins/marketplace/home/home-catalog-focus.ts new file mode 100644 index 00000000000..47614c6ce8b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-catalog-focus.ts @@ -0,0 +1,21 @@ +export type HomeCatalogTabSlot = 'content' | 'header' + +const getCatalogTabSlot = (slot: HomeCatalogTabSlot) => + document.querySelector<HTMLElement>(`[data-home-catalog-tabs-slot="${slot}"]`) + +export const getFocusedCatalogTabHref = (slot: HomeCatalogTabSlot) => { + const slotElement = getCatalogTabSlot(slot) + const activeElement = document.activeElement + if (!slotElement || !activeElement || !slotElement.contains(activeElement)) return null + + return activeElement.closest<HTMLAnchorElement>('a[href]')?.getAttribute('href') ?? null +} + +export const focusCatalogTab = (slot: HomeCatalogTabSlot, href: string) => { + const slotElement = getCatalogTabSlot(slot) + const matchingLink = Array.from( + slotElement?.querySelectorAll<HTMLAnchorElement>('a[href]') ?? [], + ).find((link) => link.getAttribute('href') === href) + + matchingLink?.focus({ preventScroll: true }) +} diff --git a/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx new file mode 100644 index 00000000000..46aa76f66fe --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-catalog-navigation.tsx @@ -0,0 +1,135 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import PluginTypeSwitch from '../plugin-type-switch' +import { focusCatalogTab, getFocusedCatalogTabHref } from './home-catalog-focus' +import { HOME_HEADER_HEIGHT_PX } from './home-constants' +import { homeCatalogPinnedAtom } from './home-sticky-state' +import styles from './home-sticky.module.css' + +type HomeCatalogNavigationProps = { + catalogCategories?: ReactNode + catalogLeading?: ReactNode + catalogTabs: ReactNode + catalogTrailing?: ReactNode + isMarketplacePlatform: boolean +} + +function HomeCatalogNavigation({ + catalogCategories, + catalogLeading, + catalogTabs, + catalogTrailing, + isMarketplacePlatform, +}: HomeCatalogNavigationProps) { + const { t } = useTranslation() + const isPinned = useAtomValue(homeCatalogPinnedAtom) + const setIsPinned = useSetAtom(homeCatalogPinnedAtom) + const isPinnedRef = useRef(isPinned) + const pendingFocusedTabHrefRef = useRef<string | null>(null) + const catalogTabsRegionRef = useRef<HTMLDivElement>(null) + + useLayoutEffect(() => { + isPinnedRef.current = isPinned + const focusedTabHref = pendingFocusedTabHrefRef.current + if (!focusedTabHref) return + + pendingFocusedTabHrefRef.current = null + focusCatalogTab(isPinned ? 'header' : 'content', focusedTabHref) + }, [isPinned]) + + useEffect(() => { + const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID) + if (!scrollContainer) return + const desktopHeaderSlotQuery = + isMarketplacePlatform && typeof window.matchMedia === 'function' + ? window.matchMedia('(min-width: 880px)') + : null + + const updatePinnedState = () => { + const catalogTabsRegion = catalogTabsRegionRef.current + if (!catalogTabsRegion) return + + const containerTop = scrollContainer.getBoundingClientRect().top + const catalogTabsRegionBottom = catalogTabsRegion.getBoundingClientRect().bottom + const canUseHeaderSlot = + !isMarketplacePlatform || !desktopHeaderSlotQuery || desktopHeaderSlotQuery.matches + const nextIsPinned = + canUseHeaderSlot && catalogTabsRegionBottom <= containerTop + HOME_HEADER_HEIGHT_PX + if (nextIsPinned === isPinnedRef.current) return + + pendingFocusedTabHrefRef.current = getFocusedCatalogTabHref( + nextIsPinned ? 'content' : 'header', + ) + isPinnedRef.current = nextIsPinned + setIsPinned(nextIsPinned) + } + + updatePinnedState() + scrollContainer.addEventListener('scroll', updatePinnedState, { passive: true }) + desktopHeaderSlotQuery?.addEventListener('change', updatePinnedState) + window.addEventListener('resize', updatePinnedState) + + return () => { + scrollContainer.removeEventListener('scroll', updatePinnedState) + desktopHeaderSlotQuery?.removeEventListener('change', updatePinnedState) + window.removeEventListener('resize', updatePinnedState) + } + }, [isMarketplacePlatform, setIsPinned]) + + return ( + <div className={styles.catalogNavigationGroup}> + <div + ref={catalogTabsRegionRef} + className={cn('w-full shrink-0 bg-background-default', styles.catalogTabsRegion)} + > + <div + aria-hidden={isPinned ? true : undefined} + className={cn(styles.catalogTabs, isPinned && styles.catalogTabsPinned)} + data-home-catalog-tabs-slot="content" + inert={isPinned ? true : undefined} + > + {catalogTabs} + </div> + </div> + <section + aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })} + className={cn( + 'w-full shrink-0 bg-background-default', + styles.catalogNavigation, + isPinned && styles.catalogNavigationPinned, + )} + // Pins directly below the header, so the offset is the header height. + style={{ top: HOME_HEADER_HEIGHT_PX }} + > + <div className="w-full"> + <div className="flex w-full items-center gap-2"> + {catalogLeading ? ( + <> + <div className={cn('shrink-0', styles.catalogLeading)}>{catalogLeading}</div> + <div + aria-hidden + className={cn( + 'mx-1 h-3.5 w-px shrink-0 bg-divider-regular', + styles.catalogLeadingDivider, + )} + /> + </> + ) : null} + <div className="min-w-0 flex-1 scrollbar-none overflow-x-auto"> + {catalogCategories ?? <PluginTypeSwitch className={undefined} variant="home" />} + </div> + {catalogTrailing ? <div className="shrink-0">{catalogTrailing}</div> : null} + </div> + </div> + </section> + </div> + ) +} + +export default HomeCatalogNavigation diff --git a/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx new file mode 100644 index 00000000000..30dcbc41106 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx @@ -0,0 +1,77 @@ +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import Link from '@/next/link' + +export type HomeCatalogTab = 'plugins' | 'templates' +export type HomeCatalogTabLabels = Record<HomeCatalogTab, string> + +type HomeCatalogTabsProps = { + activeTab?: HomeCatalogTab | null + className?: string + isMarketplacePlatform: boolean + labels?: HomeCatalogTabLabels + language?: string +} + +const HomeCatalogTabs = ({ + activeTab = 'plugins', + className, + isMarketplacePlatform, + labels, + language, +}: HomeCatalogTabsProps) => { + const { t } = useTranslation() + const catalogParams = language ? { language } : undefined + const getRelativeCatalogHref = (path: string) => { + const searchParams = new URLSearchParams(catalogParams) + const queryString = searchParams.toString() + return queryString ? `${path}?${queryString}` : path + } + const pluginsHref = isMarketplacePlatform + ? getRelativeCatalogHref('/plugins') + : getRelativeCatalogHref('/marketplace') + const templatesHref = getRelativeCatalogHref('/templates') + const isPluginsActive = activeTab === 'plugins' + const isTemplatesActive = activeTab === 'templates' + const pluginsLabel = labels?.plugins ?? t(($) => $['marketplace.home.plugins'], { ns: 'plugin' }) + const templatesLabel = + labels?.templates ?? t(($) => $['marketplace.home.templates'], { ns: 'plugin' }) + + return ( + <nav + aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })} + className={cn('flex h-8 items-center gap-1', className)} + > + <Link + href={pluginsHref} + aria-label={pluginsLabel} + aria-current={isPluginsActive ? 'page' : undefined} + className={cn( + 'flex h-8 cursor-pointer items-start rounded-lg px-[9px] pt-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isPluginsActive ? 'body-sm-medium' : 'body-sm-regular', + isPluginsActive + ? 'bg-state-base-active text-text-primary' + : 'text-text-tertiary hover:bg-state-base-hover', + )} + > + {pluginsLabel} + </Link> + <Link + href={templatesHref} + aria-label={templatesLabel} + aria-current={isTemplatesActive ? 'page' : undefined} + className={cn( + 'relative flex h-8 cursor-pointer items-center rounded-[10px] p-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isTemplatesActive ? 'body-sm-medium' : 'body-sm-regular', + isTemplatesActive + ? 'bg-state-base-active text-text-primary' + : 'text-text-tertiary hover:bg-state-base-hover', + )} + > + {templatesLabel} + </Link> + </nav> + ) +} + +export default HomeCatalogTabs diff --git a/web/app/components/plugins/marketplace/home/home-constants.ts b/web/app/components/plugins/marketplace/home/home-constants.ts new file mode 100644 index 00000000000..ed10c674f37 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-constants.ts @@ -0,0 +1,15 @@ +/** + * Height of the marketplace home header in pixels. Sticky home chrome reads + * this so the header, search, and catalog offsets cannot drift apart. + */ +export const HOME_HEADER_HEIGHT_PX = 48 + +/** Height of the home search row. HomeSearch and the mobile catalog offset both read this. */ +export const HOME_SEARCH_HEIGHT_PX = 36 + +/** Extra sticky-chrome gap under the mobile search row (ECO-475). */ +export const HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX = 16 + +/** 40px icon tiles + 1px divider-subtle lines in the marketplace home hero. */ +export const HERO_GRID_PITCH_PX = 41 +export const HERO_ICON_SIZE_PX = 40 diff --git a/web/app/components/plugins/marketplace/home/home-creator-center.tsx b/web/app/components/plugins/marketplace/home/home-creator-center.tsx new file mode 100644 index 00000000000..954ca24453b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-creator-center.tsx @@ -0,0 +1,38 @@ +'use client' + +import { buttonVariants } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import { MARKETPLACE_URL_PREFIX } from '@/config' +import Link from '@/next/link' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import { useCreatorCenterUrl } from '../creator-center-url' + +export default function HomeCreatorCenter() { + const { t } = useTranslation('plugin') + const creatorCenterUrl = useCreatorCenterUrl(MARKETPLACE_URL_PREFIX) + const label = t(($) => $['marketplace.home.creatorCenter']) + + return ( + <Link + href={creatorCenterUrl} + target="_blank" + rel="noopener noreferrer" + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'creator_center', + }) + }} + // The visible text is hidden below the lg breakpoint, so the link needs + // an explicit accessible name to avoid becoming an icon-only mystery. + aria-label={label} + className={cn( + buttonVariants({ variant: 'ghost' }), + 'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary [html[data-theme=dark]_&]:text-text-primary [html[data-theme=dark]_&]:hover:text-text-primary', + )} + > + <span aria-hidden className="i-ri-user-star-line size-4" /> + <span className="hidden system-sm-medium lg:inline">{label}</span> + </Link> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-guide.tsx b/web/app/components/plugins/marketplace/home/home-guide.tsx new file mode 100644 index 00000000000..bceac49c2df --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-guide.tsx @@ -0,0 +1,25 @@ +'use client' + +import type { DocPathWithoutLang } from '@/types/doc-paths' +import { useTranslation } from '#i18n' +import { + SubmitRequestDropdown, + SubmitRequestDropdownMenu, +} from '@/app/components/plugins/plugin-page/nav-operations' +import { defaultDocBaseUrl } from '@/context/i18n' +import { getDocLanguage } from '@/i18n-config/language' + +function MarketplaceGuide() { + const { i18n } = useTranslation() + const docLanguage = getDocLanguage(i18n.language) + const docLink = (path: DocPathWithoutLang) => `${defaultDocBaseUrl}/${docLanguage}${path}` + + return <SubmitRequestDropdownMenu dividerAfterFirst docLink={docLink} /> +} + +export default function HomeGuide({ isMarketplacePlatform }: { isMarketplacePlatform: boolean }) { + // Standalone Marketplace cannot call useDocLink(): it reads the console-only + // systemFeatures suspense query and crashes SSR. The dropdown paths have no + // product-specific variants, so composing the URL from the locale matches. + return isMarketplacePlatform ? <MarketplaceGuide /> : <SubmitRequestDropdown dividerAfterFirst /> +} diff --git a/web/app/components/plugins/marketplace/home/home-header.tsx b/web/app/components/plugins/marketplace/home/home-header.tsx new file mode 100644 index 00000000000..f608133b69c --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-header.tsx @@ -0,0 +1,93 @@ +import type { HomeCatalogTab, HomeCatalogTabLabels } from './home-catalog-tabs' +import { cn } from '@langgenius/dify-ui/cn' +import Link from '@/next/link' +import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg' +import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg' +import HomeCatalogTabs from './home-catalog-tabs' +import { HOME_HEADER_HEIGHT_PX } from './home-constants' +// HomeCreatorCenter stays in its own client module: it derives styles via +// buttonVariants(), which cannot be invoked inside this server component. +import HomeCreatorCenter from './home-creator-center' +import HomeGuide from './home-guide' +import { HomeStickyCatalogTabs } from './home-sticky-state-provider' +import styles from './home-sticky.module.css' + +type HomeHeaderProps = { + activeTab?: HomeCatalogTab | null + actions?: React.ReactNode + catalogLabels?: HomeCatalogTabLabels + isMarketplacePlatform: boolean + language?: string +} + +const HomeHeader = ({ + activeTab = 'plugins', + actions, + catalogLabels, + isMarketplacePlatform, + language, +}: HomeHeaderProps) => { + return ( + <header + className="sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 py-1.5 md:px-9" + style={{ height: HOME_HEADER_HEIGHT_PX }} + > + <div className="flex min-w-0 flex-1 items-center gap-4"> + <Link + // In the embedded console "/" leaves the marketplace entirely, so + // the brand mark points back at the marketplace home instead. + href={isMarketplacePlatform ? '/' : '/marketplace'} + aria-label="Dify Marketplace" + className="flex h-full w-[141.933px] shrink-0 items-center" + > + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoLight, + )} + height="16.386" + src={MarketplaceLogo.src} + width="141.761" + /> + <img + alt="" + aria-hidden + className={cn( + 'h-[16.386px] w-[141.761px] max-w-none shrink-0', + styles.marketplaceLogoDark, + )} + height="16.386" + src={MarketplaceLogoDark.src} + width="141.761" + /> + </Link> + <HomeStickyCatalogTabs> + <HomeCatalogTabs + activeTab={activeTab} + className={styles.headerCatalogTabs} + isMarketplacePlatform={isMarketplacePlatform} + labels={catalogLabels} + language={language} + /> + </HomeStickyCatalogTabs> + </div> + + <div className="flex h-full min-w-0 flex-1 items-center justify-end gap-2.5"> + <div + className={cn( + 'flex min-w-0 items-center gap-2.5', + isMarketplacePlatform && styles.standaloneHeaderActions, + )} + > + <HomeCreatorCenter /> + <HomeGuide isMarketplacePlatform={isMarketplacePlatform} /> + </div> + {actions} + </div> + </header> + ) +} + +export default HomeHeader diff --git a/web/app/components/plugins/marketplace/home/home-hero.module.css b/web/app/components/plugins/marketplace/home/home-hero.module.css new file mode 100644 index 00000000000..4b51646e05b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-hero.module.css @@ -0,0 +1,74 @@ +.decorations { + pointer-events: none; + position: absolute; + inset: 0; +} + +/* 4 grid rows (0–163) so the 40px icons at y=123 sit fully inside the hero + without overflowing into a scrollbar. */ +.frame { + height: 163px; +} + +/* 40px cells + 1px divider-subtle lines, matching Figma header/Variant2. + Figma's vertical lines are inset 141px on a 1512px canvas (~9%) and sit + under a white wash, so the grid fades out toward both edges instead of + meeting the viewport at full strength. + + The 41px tile is odd-sized, so `background-position: center` places the + 1px stroke on a half-pixel and leaves a 0.5px gap beside every icon. + +0.5px matches Figma (`left: calc(50% + 0.5px)`) so line starts sit on + the same pixels as `left: calc(50% + n * 41px)`. */ +.grid { + position: absolute; + inset: 0; + background-image: + linear-gradient( + to right, + transparent 20px, + var(--color-divider-subtle) 20px, + var(--color-divider-subtle) 21px, + transparent 21px + ), + linear-gradient(to bottom, var(--color-divider-subtle) 1px, transparent 1px); + background-size: + var(--hero-grid-pitch, 41px) 100%, + 100% var(--hero-grid-pitch, 41px); + background-position: + calc(50% + 0.5px) top, + left 40px; + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + #000 12%, + #000 88%, + transparent 100% + ); + mask-image: linear-gradient(to right, transparent 0%, #000 12%, #000 88%, transparent 100%); +} + +/* Figma Ellipse 5 (1159:70851): 555×245 white oval at (478, 63) on the + 1512×257 header, layer-blur 60. Hero y is shifted −44px so the top icon + row sits at 0. The blur washes grid lines out under the title and search + while fading toward the decorative icons. */ +.glow { + position: absolute; + top: 19px; + left: 50%; + width: 555px; + height: 245px; + transform: translateX(-50%); + border-radius: 50%; + background: var(--color-background-default); + filter: blur(30px); +} + +@media (max-width: 879px) { + .decorations { + display: none; + } + + :global([data-marketplace-standalone]) .copyBlock { + max-width: 360px; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-hero.tsx b/web/app/components/plugins/marketplace/home/home-hero.tsx new file mode 100644 index 00000000000..3b685c340ba --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-hero.tsx @@ -0,0 +1,100 @@ +'use client' + +import type { CSSProperties, ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import brain2FillIcon from './assets/brain-2-fill.svg' +import imageCircleAiLineIcon from './assets/image-circle-ai-line.svg' +import plugFillIcon from './assets/plug-fill.svg' +import puzzleFillIcon from './assets/puzzle-fill.svg' +import sparklingFillIcon from './assets/sparkling-fill.svg' +import voiceAiFillIcon from './assets/voice-ai-fill.svg' +import { HERO_GRID_PITCH_PX, HERO_ICON_SIZE_PX } from './home-constants' +import styles from './home-hero.module.css' + +type HomeHeroProps = { + isMarketplacePlatform: boolean + subtitle?: ReactNode + title?: ReactNode +} + +type HeroDecorationIcon = { + left: number + src: string + top: number +} + +const heroIconSrc = (icon: { src: string } | string) => (typeof icon === 'string' ? icon : icon.src) + +// Positions are Figma offsets from the 1512px canvas center, with the top +// icon row shifted to y=0 so the marks sit in HomeHero instead of the header. +const heroDecorationIcons: HeroDecorationIcon[] = [ + { src: heroIconSrc(sparklingFillIcon), left: -450, top: HERO_GRID_PITCH_PX }, + { src: heroIconSrc(plugFillIcon), left: -286, top: 0 }, + { src: heroIconSrc(puzzleFillIcon), left: -327, top: HERO_GRID_PITCH_PX * 3 }, + { src: heroIconSrc(brain2FillIcon), left: 247, top: HERO_GRID_PITCH_PX * 2 }, + { src: heroIconSrc(imageCircleAiLineIcon), left: 370, top: HERO_GRID_PITCH_PX * 3 }, + { src: heroIconSrc(voiceAiFillIcon), left: 411, top: 0 }, +] + +const heroGridStyle = { + '--hero-grid-pitch': `${HERO_GRID_PITCH_PX}px`, +} as CSSProperties + +const HeroDecorations = () => ( + <div aria-hidden className={styles.decorations} style={heroGridStyle}> + <div className={styles.grid} /> + <div className={styles.glow} /> + {heroDecorationIcons.map((icon) => ( + <span + key={icon.src} + className="absolute flex items-center justify-center overflow-hidden bg-state-accent-hover" + style={{ + height: HERO_ICON_SIZE_PX, + left: `calc(50% + ${icon.left}px)`, + top: icon.top, + width: HERO_ICON_SIZE_PX, + }} + > + <span className="relative size-[24px] overflow-hidden"> + <img alt="" aria-hidden className="size-full" height={24} src={icon.src} width={24} /> + </span> + </span> + ))} + </div> +) + +const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => { + const { t } = useTranslation('plugin') + + return ( + <section + className={cn( + 'relative flex shrink-0 justify-center overflow-hidden bg-background-default px-4', + !isMarketplacePlatform && 'pt-6', + )} + > + <HeroDecorations /> + <div + className={cn('relative flex w-full max-w-[726px] flex-col items-center', styles.frame)} + style={{ paddingTop: HERO_GRID_PITCH_PX }} + > + <div + className={cn('flex w-full flex-col items-center gap-2 text-center', styles.copyBlock)} + > + <h1 + className="text-[28px] leading-[1.2] font-medium tracking-[-0.56px] text-text-primary" + style={{ fontFamily: "var(--font-family-brand, 'Söhne', var(--font-sans))" }} + > + {title ?? t(($) => $['marketplace.home.heroTitle'])} + </h1> + <p className="w-full text-[13px] leading-4 font-light tracking-[-0.065px] text-text-tertiary"> + {subtitle ?? t(($) => $['marketplace.home.heroSubtitle'])} + </p> + </div> + </div> + </section> + ) +} + +export default HomeHero diff --git a/web/app/components/plugins/marketplace/home/home-search.tsx b/web/app/components/plugins/marketplace/home/home-search.tsx new file mode 100644 index 00000000000..14990bc5418 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-search.tsx @@ -0,0 +1,73 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useRef } from 'react' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import EmbeddedMarketplaceSearch from './embedded-marketplace-search' +import styles from './home-sticky.module.css' +import { preserveStickySearchScroll } from './preserve-sticky-search-scroll' + +type HomeSearchProps = { + children?: ReactNode + /** + * Registers the global Cmd/Ctrl+K focus shortcut. The embedded console + * already binds Mod+K to GotoAnything, so only the standalone marketplace + * should keep this enabled. + */ + enableSearchShortcut?: boolean + /** + * Pull the search row up over the hero. Search-results (and any other + * page without a hero) must leave this off so the field stays below the + * header instead of covering the brand. + */ + overlapHero?: boolean +} + +const HomeSearch = ({ + children, + enableSearchShortcut = true, + overlapHero = true, +}: HomeSearchProps) => { + const searchRef = useRef<HTMLDivElement>(null) + + useEffect(() => { + const searchRoot = searchRef.current + const container = document.getElementById(MARKETPLACE_CONTAINER_ID) + if (!searchRoot || !container) return + return preserveStickySearchScroll(searchRoot, container) + }, []) + + useEffect(() => { + if (!enableSearchShortcut) return + + const handleGlobalSearchShortcut = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return + + event.preventDefault() + searchRef.current?.querySelector('input')?.focus({ preventScroll: true }) + } + + document.addEventListener('keydown', handleGlobalSearchShortcut) + return () => document.removeEventListener('keydown', handleGlobalSearchShortcut) + }, [enableSearchShortcut]) + + return ( + <div + className={cn( + 'pointer-events-none flex shrink-0 justify-center', + overlapHero && '-mt-9', + styles.search, + )} + > + <div + ref={searchRef} + className={cn('pointer-events-auto relative w-full', styles.searchContent)} + > + {children ?? <EmbeddedMarketplaceSearch />} + </div> + </div> + ) +} + +export default HomeSearch diff --git a/web/app/components/plugins/marketplace/home/home-shell.tsx b/web/app/components/plugins/marketplace/home/home-shell.tsx new file mode 100644 index 00000000000..d5a1fe2d07b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-shell.tsx @@ -0,0 +1,77 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { CSSProperties, ReactNode } from 'react' +import type { MarketplaceBannerPage } from './banners' +import { cn } from '@langgenius/dify-ui/cn' +import { + HOME_HEADER_HEIGHT_PX, + HOME_SEARCH_HEIGHT_PX, + HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX, +} from './home-constants' +import { HomeStickyStateProvider } from './home-sticky-state-provider' +import styles from './home-sticky.module.css' +import HomeTrending from './home-trending' + +type HomeShellProps = { + banners: PluginBanner[] + children: ReactNode + header: ReactNode + hero: ReactNode + isMarketplacePlatform: boolean + navigation: ReactNode + page: MarketplaceBannerPage + search: ReactNode +} + +/** + * Shared scaffold for the marketplace catalog homes (Plugins and Templates): + * sticky header, hero, floating search, the optional trending banners, and + * the sticky catalog navigation above the page content. Keeping the structure + * in one place stops the two catalog pages from drifting apart. + */ +export function HomeShell({ + banners, + children, + header, + hero, + isMarketplacePlatform, + navigation, + page, + search, +}: HomeShellProps) { + return ( + <HomeStickyStateProvider> + <div + className="flex min-h-full w-full shrink-0 flex-col bg-background-default" + data-marketplace-standalone={isMarketplacePlatform ? '' : undefined} + style={ + { + '--home-header-height': `${HOME_HEADER_HEIGHT_PX}px`, + '--home-search-height': `${HOME_SEARCH_HEIGHT_PX}px`, + '--home-search-mobile-padding-bottom': `${HOME_SEARCH_MOBILE_PADDING_BOTTOM_PX}px`, + } as CSSProperties + } + > + {header} + <div className="relative flex w-full flex-col"> + {hero} + {search} + {banners.length > 0 && ( + <> + <div + aria-hidden="true" + className={cn('h-12 shrink-0', isMarketplacePlatform && styles.bannerSpacer)} + /> + <HomeTrending + banners={banners} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + </> + )} + {navigation} + {children} + </div> + </div> + </HomeStickyStateProvider> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx b/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx new file mode 100644 index 00000000000..436a89707b6 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-sticky-state-provider.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' +import { ScopeProvider } from 'jotai-scope' +import { homeCatalogPinnedAtom, homeStickyScopedAtoms } from './home-sticky-state' +import styles from './home-sticky.module.css' + +export function HomeStickyStateProvider({ children }: { children: ReactNode }) { + return ( + <ScopeProvider atoms={homeStickyScopedAtoms} name="MarketplaceHomeSticky"> + {children} + </ScopeProvider> + ) +} + +export function HomeStickyCatalogTabs({ children }: { children: ReactNode }) { + const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom) + + return ( + <div + aria-hidden={!isCatalogPinned ? true : undefined} + className={cn(styles.headerCatalogSlot, isCatalogPinned && styles.headerCatalogSlotPinned)} + data-home-catalog-tabs-slot="header" + inert={!isCatalogPinned ? true : undefined} + > + {children} + </div> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-sticky-state.ts b/web/app/components/plugins/marketplace/home/home-sticky-state.ts new file mode 100644 index 00000000000..e49206393a4 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-sticky-state.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai' + +export const homeCatalogPinnedAtom = atom(false) + +export const homeStickyScopedAtoms = [homeCatalogPinnedAtom] diff --git a/web/app/components/plugins/marketplace/home/home-sticky.module.css b/web/app/components/plugins/marketplace/home/home-sticky.module.css new file mode 100644 index 00000000000..e2f99827b1f --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-sticky.module.css @@ -0,0 +1,171 @@ +/* Sticky offsets read --home-header-height and --home-search-height from + HomeShell (HOME_HEADER_HEIGHT_PX / HOME_SEARCH_HEIGHT_PX). Desktop catalog + navigation still pins with an inline top of HOME_HEADER_HEIGHT_PX. */ + +.headerCatalogTabs { + display: flex; +} + +.headerCatalogSlot, +.catalogTabs { + transition-property: opacity, transform; + transition-duration: 140ms; + transition-timing-function: ease-out; + will-change: opacity, transform; +} + +.headerCatalogSlot { + display: flex; + flex-shrink: 0; + opacity: 0; + pointer-events: none; + transform: translateY(4px); +} + +.headerCatalogSlotPinned { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.marketplaceLogoLight { + display: block; +} + +.marketplaceLogoDark { + display: none; +} + +:global(html[data-theme='dark']) .marketplaceLogoLight { + display: none; +} + +:global(html[data-theme='dark']) .marketplaceLogoDark { + display: block; +} + +.search { + position: sticky; + z-index: 60; + top: 6px; + height: var(--home-search-height, 36px); + padding-right: 356px; + padding-left: 356px; + overflow-anchor: none; +} + +.searchContent { + max-width: 420px; +} + +.catalogNavigationGroup { + display: contents; +} + +.catalogTabsRegion { + padding: 24px 32px 0; +} + +.catalogNavigation { + position: sticky; + z-index: 40; + padding: 16px 32px; +} + +.catalogNavigationPinned { + background-color: var(--color-background-default); +} + +.catalogTabs { + opacity: 1; + transform: translateY(0); +} + +.catalogTabsPinned { + opacity: 0; + pointer-events: none; + transform: translateY(-4px); +} + +.catalogContent { + min-height: calc(100vh - 106px); + min-height: calc(100dvh - 106px); +} + +@media (max-width: 879px) { + .search { + position: relative; + z-index: 0; + top: auto; + padding-right: 16px; + padding-left: 16px; + } + + :global([data-marketplace-standalone]) .search { + position: sticky; + z-index: 45; + top: var(--home-header-height, 48px); + height: calc(var(--home-search-height, 36px) + var(--home-search-mobile-padding-bottom, 16px)); + padding-right: 20px; + padding-bottom: var(--home-search-mobile-padding-bottom, 16px); + padding-left: 20px; + background-color: var(--color-background-default); + } + + :global([data-marketplace-standalone]) .searchContent { + max-width: 360px; + } + + :global([data-marketplace-standalone]) .headerCatalogTabs { + display: none; + } + + :global([data-marketplace-standalone]) .standaloneHeaderActions { + display: none; + } + + /* Sit under the search row's padding-bottom so that gap is not stacked + on top of the tabs' own padding when this group pins. */ + :global([data-marketplace-standalone]) .catalogNavigationGroup { + position: sticky; + z-index: 40; + display: block; + top: calc(var(--home-header-height, 48px) + var(--home-search-height, 36px)); + background-color: var(--color-background-default); + } + + :global([data-marketplace-standalone]) .catalogTabsRegion { + padding-top: var(--home-search-mobile-padding-bottom, 16px); + padding-right: 20px; + padding-left: 20px; + } + + :global([data-marketplace-standalone]) .catalogNavigation { + position: static; + padding: 16px 20px; + } + + :global([data-marketplace-standalone]) .catalogLeading { + display: none; + } + + :global([data-marketplace-standalone]) .catalogLeadingDivider { + display: none; + } + + :global([data-marketplace-standalone]) .catalogContent { + padding-right: 20px; + padding-left: 20px; + } + + :global([data-marketplace-standalone]) .bannerSpacer { + height: 24px; + } +} + +@media (prefers-reduced-motion: reduce) { + .headerCatalogSlot, + .catalogTabs { + transition-duration: 0ms; + } +} diff --git a/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx new file mode 100644 index 00000000000..5ef8d53041d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-navigation.tsx @@ -0,0 +1,283 @@ +'use client' + +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { RefObject } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +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 + +const getPaginationItemOffset = (index: number, selectedIndex: number) => + index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0) + +type AutoplayPauseReason = + | 'focus' + | 'hover' + | 'interaction' + | 'reduced-motion' + | 'user' + | 'viewport' + | 'visibility' + +function TrendingNavigation({ + banners, + selectedIndex, + carouselRootRef, + interactionPaused, + pauseWhenOffscreen, + onSelect, + onNext, + onPausedChange, +}: { + banners: PluginBanner[] + selectedIndex: number + carouselRootRef: RefObject<HTMLDivElement | null> + interactionPaused: boolean + pauseWhenOffscreen: boolean + onSelect: (index: number) => void + onNext: () => void + onPausedChange?: (paused: boolean) => void +}) { + const { t } = useTranslation('plugin') + const progressRef = useRef<HTMLSpanElement>(null) + const progressAnimationRef = useRef<Animation | null>(null) + const pauseReasonsRef = useRef( + new Set<AutoplayPauseReason>(pauseWhenOffscreen ? ['viewport'] : []), + ) + 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 isPaused = pauseReasonsRef.current.size > 0 + onPausedChange?.(isPaused) + + const progressAnimation = progressAnimationRef.current + if (!progressAnimation) return + + if (isPaused) progressAnimation.pause() + else progressAnimation.play() + }, + [onPausedChange], + ) + + useEffect(() => { + setPauseReason('interaction', interactionPaused) + }, [interactionPaused, setPauseReason]) + + 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 + // cancel() rejects `finished` with AbortError; keep that from becoming unhandled. + void progressAnimation.finished.catch(() => {}) + + 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) + 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(() => { + if (!pauseWhenOffscreen) { + setPauseReason('viewport', false) + return + } + + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + if (typeof IntersectionObserver === 'undefined') { + setPauseReason('viewport', false) + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + const isVisible = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25 + setPauseReason('viewport', !isVisible) + }, + { + root: document.getElementById(MARKETPLACE_CONTAINER_ID), + threshold: 0.25, + }, + ) + + observer.observe(carouselRoot) + + return () => observer.disconnect() + }, [carouselRootRef, pauseWhenOffscreen, 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 clearImplicitPauseReasons = () => { + // Pointer activation leaves hover and/or focus on the control, which + // would otherwise keep rotation paused until the next mouseleave/focusout. + setPauseReason('focus', false) + setPauseReason('hover', false) + } + + const toggleAutoplay = () => { + if (isExplicitlyPaused) { + setIsUserPaused(false) + setIsReducedMotionPaused(false) + setPauseReason('user', false) + setPauseReason('reduced-motion', false) + // An explicit Play overrides the implicit reasons; they re-engage on + // the next mouseenter/focusin. + clearImplicitPauseReasons() + 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={(event) => { + if (!isCurrent) onSelect(index) + // Keyboard selection keeps the focus pause so rotation does + // not advance under the user. Pointer selection should keep + // timing immediately without waiting for blur. + if (event.detail === 0) return + clearImplicitPauseReasons() + }} + 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} + className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-active 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> + ) +} + +export default TrendingNavigation diff --git a/web/app/components/plugins/marketplace/home/home-trending-slides.tsx b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx new file mode 100644 index 00000000000..0510bdb6749 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx @@ -0,0 +1,504 @@ +'use client' + +import type { + BannerAd, + BannerBlog, + BannerEvent, + BannerRecommend, + BannerRecommendCard, + PluginBanner, +} from '@dify/contracts/marketplace' +import type { MarketplaceBannerPage } from './banners' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from '#i18n' +import { trackEvent } from '@/app/components/base/amplitude' +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 { + rememberMarketplaceSiteReferrer, + trackMarketplaceSiteEvent, +} from '@/utils/marketplace-site-track' +import { getPluginLinkInMarketplace } from '../utils' +import background from './assets/background.webp' +import difyUpdatesArt from './assets/dify-updates-art.png' +import { + EMBEDDED_MOBILE_BANNER_MEDIA, + MARKETPLACE_MOBILE_BANNER_MEDIA, + marketplaceTabletBannerMedia, + resolveEventAdBannerImageSrcs, +} from './event-ad-banner-image' +import { buildMarketplaceBannerClickProperties } from './home-trending-track' +import styles from './home-trending.module.css' +import { sanitizeMarketplaceHref } from './marketplace-href' + +const getMarketplaceAssetURL = (path?: string) => { + 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}` + return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}` + } catch { + return path + } +} + +const getLocalCardHref = (card: BannerRecommendCard) => { + if (card.item_type === 'plugin') { + const [organization, pluginName] = card.item_id.split('/') + if (organization && pluginName) + return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}` + } + + if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}` + + return '/' +} + +const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean) => { + if (isMarketplacePlatform) return getLocalCardHref(card) + const deliveryHref = card.link ? sanitizeMarketplaceHref(card.link) : null + if (deliveryHref) return deliveryHref + + // The embedded console has no local plugin detail route, so a plugin card + // without a delivery-provided link opens the marketplace site detail page. + if (card.item_type === 'plugin') { + const [organization, pluginName] = card.item_id.split('/') + if (organization && pluginName) + return getPluginLinkInMarketplace({ org: organization, name: pluginName, type: 'plugin' }) + } + + return getLocalCardHref(card) +} + +const getCardCreator = (card: BannerRecommendCard) => { + if (card.creator) return card.creator + if (card.item_type !== 'plugin') return '' + + return card.item_id.split('/')[0] || '' +} + +const getBannerFrameProps = (banner: PluginBanner, page: MarketplaceBannerPage) => ({ + banner_id: banner.id, + sort: banner.sort, + page, + language: banner.language, + style_type: banner.style_type, +}) + +const trackMarketplaceBannerClick = ( + banner: PluginBanner, + cardClick?: Parameters<typeof buildMarketplaceBannerClickProperties>[1], +) => { + trackMarketplaceSiteEvent( + 'marketplace_banner_click', + buildMarketplaceBannerClickProperties(banner, cardClick), + ) +} + +function TrendingCopy({ + banner, + isMarketplacePlatform, +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean +}) { + const { t } = useTranslation('plugin') + 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( + styles.copy, + 'flex min-w-0 flex-col items-start overflow-hidden p-5', + isMarketplacePlatform ? styles.marketplaceCopy : styles.embeddedCopy, + )} + > + <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 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary"> + {heading} + </h2> + <p + className={cn( + styles.copyDescription, + 'w-full text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary', + )} + > + {description} + </p> + </div> + </div> + ) +} + +function TrendingCard({ + banner, + card, + isMarketplacePlatform, + page, +}: { + banner: BannerRecommend + card: BannerRecommendCard + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const { t } = useTranslation('plugin') + const iconURL = getMarketplaceAssetURL(card.icon_url) + const creator = getCardCreator(card) + const href = getCardHref(card, isMarketplacePlatform) + if (!href) return null + const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href) + const isPartner = card.badges?.includes('partner') + const isVerified = card.badges?.includes('verified') + + return ( + <Link + href={href} + target={opensInNewTab ? '_blank' : undefined} + rel={opensInNewTab ? 'noopener noreferrer' : undefined} + aria-label={card.display_name} + onClick={() => { + trackEvent('marketplace_banner_item_click', { + ...getBannerFrameProps(banner, page), + item_type: card.item_type, + item_id: card.item_id, + card_position: card.card_position, + theme_type: banner.content.theme_type, + auto_batch_id: card.auto_batch_id ?? null, + }) + rememberMarketplaceSiteReferrer(card.item_id, 'banner') + trackMarketplaceBannerClick(banner, { + item_id: card.item_id, + item_type: card.item_type, + display_name: card.display_name, + link: href, + }) + }} + 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={cn( + styles.cardIcon, + '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, + }} + > + {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> + + <div className={cn(styles.cardMeta, '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> + )} + </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> + ) +} + +function TrendingRecommendationSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: BannerRecommend + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + return ( + <div + className={cn( + 'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body', + isMarketplacePlatform && styles.stackedSlide, + )} + > + <TrendingCopy banner={banner} isMarketplacePlatform={isMarketplacePlatform} /> + <div + className={cn( + styles.recommendVisual, + 'relative h-[200px] shrink-0 overflow-hidden rounded-xl bg-background-body', + isMarketplacePlatform && styles.stackedVisual, + )} + > + <img + src={background.src} + width={1600} + height={900} + alt="" + aria-hidden + className={cn( + styles.recommendBackdrop, + 'absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80', + )} + /> + <div + aria-hidden + className={cn( + styles.recommendBackdrop, + 'absolute inset-0 bg-text-accent mix-blend-color', + )} + /> + + <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}`} + banner={banner} + card={card} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ))} + </div> + </div> + </div> + ) +} + +function BlogBannerSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: BannerBlog + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const { t } = useTranslation('plugin') + const href = sanitizeMarketplaceHref(banner.content.link) + if (!href) return null + const opensInNewTab = /^https?:\/\//.test(href) + + return ( + <Link + href={href} + target={opensInNewTab ? '_blank' : undefined} + rel={opensInNewTab ? 'noopener noreferrer' : undefined} + onClick={() => { + trackEvent('marketplace_banner_click', getBannerFrameProps(banner, page)) + trackMarketplaceBannerClick(banner) + }} + aria-label={t(($) => $['marketplace.home.trendingReadMoreAbout'], { + title: banner.content.blog_title, + })} + className={cn( + 'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isMarketplacePlatform && styles.stackedSlide, + )} + > + <div + className={cn( + 'flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5', + isMarketplacePlatform && styles.stackedCopy, + )} + > + <div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2"> + <div className="flex w-full min-w-0 items-center"> + <p + className={cn( + 'max-w-full min-w-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', + isMarketplacePlatform && styles.blogTag, + )} + > + {banner.title} + </p> + </div> + <div className="flex min-h-0 w-full max-w-[800px] flex-1 flex-col items-start gap-3"> + <h2 + className={cn( + 'w-full min-w-0 shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary', + isMarketplacePlatform && styles.blogTitle, + )} + > + {banner.content.blog_title} + </h2> + <div + className={cn( + 'flex min-h-0 w-full flex-1 flex-col items-start gap-2', + isMarketplacePlatform && styles.stackedCopyMeta, + )} + > + {banner.content.subtitle && ( + <p + className={cn( + 'w-full min-w-0 shrink-0 text-[15px] leading-[18px] font-normal tracking-[-0.3px] text-text-primary', + isMarketplacePlatform && styles.blogSubtitle, + )} + > + {banner.content.subtitle} + </p> + )} + {banner.content.description && ( + <p + className={cn( + styles.updatesDescription, + 'min-h-0 w-full min-w-0 overflow-hidden text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary', + )} + > + {banner.content.description} + </p> + )} + <span + aria-hidden + className={cn( + 'flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2', + isMarketplacePlatform && styles.readMoreDesktop, + )} + > + <span>{t(($) => $['marketplace.home.trendingReadMore'])}</span> + <span className="i-ri-arrow-right-s-line size-4" /> + </span> + </div> + </div> + </div> + </div> + <img + src={difyUpdatesArt.src} + width={400} + height={200} + alt="" + aria-hidden + className={cn( + styles.updatesArt, + isMarketplacePlatform && styles.stackedVisual, + 'h-[200px] shrink-0 rounded-2xl object-cover object-left', + )} + /> + </Link> + ) +} + +function ImageBannerSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: BannerEvent | BannerAd + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const href = sanitizeMarketplaceHref(banner.content.link) + if (!href) return null + const resolved = resolveEventAdBannerImageSrcs({ + desktop: getMarketplaceAssetURL(banner.content.images.desktop), + tablet: getMarketplaceAssetURL(banner.content.images.tablet) || undefined, + mobile: getMarketplaceAssetURL(banner.content.images.mobile) || undefined, + }) + + return ( + <Link + href={href} + target="_blank" + rel="noopener noreferrer" + onClick={() => { + trackEvent('marketplace_banner_click', getBannerFrameProps(banner, page)) + trackMarketplaceBannerClick(banner) + }} + aria-label={banner.content.alt_text || banner.title} + className={cn( + 'block h-[200px] w-full overflow-hidden rounded-2xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + isMarketplacePlatform && styles.imageSlide, + )} + > + <picture className="block size-full"> + <source + media={ + isMarketplacePlatform ? MARKETPLACE_MOBILE_BANNER_MEDIA : EMBEDDED_MOBILE_BANNER_MEDIA + } + srcSet={resolved.mobile} + /> + {resolved.tablet && ( + <source + media={marketplaceTabletBannerMedia(isMarketplacePlatform)} + srcSet={resolved.tablet} + /> + )} + <img + src={resolved.desktop} + width={1200} + height={200} + alt="" + aria-hidden + className="size-full object-cover object-left" + /> + </picture> + </Link> + ) +} + +export function HomeBannerSlide({ + banner, + isMarketplacePlatform, + page, +}: { + banner: PluginBanner + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + if (banner.style_type === 'blog') + return ( + <BlogBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + ) + + if (banner.style_type === 'event' || banner.style_type === 'ad') + return ( + <ImageBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + ) + + return ( + <TrendingRecommendationSlide + banner={banner} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ) +} diff --git a/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts b/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts new file mode 100644 index 00000000000..276b1f6f1e1 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-track.spec.ts @@ -0,0 +1,139 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import { describe, expect, it } from 'vitest' +import { buildMarketplaceBannerClickProperties } from './home-trending-track' + +const recommendBanner: PluginBanner = { + id: 'banner-recommend', + style_type: 'recommend', + title: 'Trending', + sort: 0, + language: 'en', + content: { + theme_type: 'hottest', + cards: [], + }, +} + +describe('buildMarketplaceBannerClickProperties', () => { + it('maps a recommendation card click to the site-event payload', () => { + expect( + buildMarketplaceBannerClickProperties(recommendBanner, { + item_id: 'langgenius/dropbox', + item_type: 'plugin', + display_name: 'Dropbox', + link: '/plugin/langgenius/dropbox', + }), + ).toEqual({ + banner_id: 'banner-recommend', + title: 'Trending', + theme_type: 'most_popular', + click_target: 'recommendation', + sort: 0, + language: 'en', + item_id: 'langgenius/dropbox', + item_type: 'plugin', + item_name: 'Dropbox', + link: '/plugin/langgenius/dropbox', + }) + }) + + it('maps newest recommendation theme to new_arrivals', () => { + expect( + buildMarketplaceBannerClickProperties( + { + ...recommendBanner, + content: { theme_type: 'newest', cards: [] }, + }, + { + item_id: 'tpl-1', + item_type: 'template', + display_name: 'Support Bot', + link: '/templates?tid=tpl-1', + }, + ), + ).toMatchObject({ + theme_type: 'new_arrivals', + click_target: 'recommendation', + item_id: 'tpl-1', + item_type: 'template', + item_name: 'Support Bot', + }) + }) + + it('reports blog frame clicks with target_type and without card fields', () => { + expect( + buildMarketplaceBannerClickProperties({ + id: 'banner-blog', + style_type: 'blog', + title: 'Dify Updates', + sort: 1, + language: 'zh', + content: { + blog_title: 'Launch', + link: 'https://dify.ai/blog', + link_target_type: 'github', + }, + }), + ).toEqual({ + banner_id: 'banner-blog', + title: 'Dify Updates', + click_target: 'blog', + sort: 1, + language: 'zh', + target_type: 'github', + link: 'https://dify.ai/blog', + }) + }) + + it('reports event frame clicks with activity_id only', () => { + expect( + buildMarketplaceBannerClickProperties({ + id: 'banner-event', + style_type: 'event', + title: 'Meetup', + sort: 2, + language: 'ja', + content: { + images: { desktop: '/event.png' }, + link: 'https://dify.ai/events', + activity_id: 'act-1', + }, + }), + ).toEqual({ + banner_id: 'banner-event', + title: 'Meetup', + click_target: 'event', + sort: 2, + language: 'ja', + activity_id: 'act-1', + link: 'https://dify.ai/events', + }) + }) + + it('reports ad frame clicks with partner and campaign ids', () => { + expect( + buildMarketplaceBannerClickProperties({ + id: 'banner-ad', + style_type: 'ad', + title: 'Partner', + sort: 3, + language: 'en', + content: { + images: { desktop: '/ad.png' }, + link: 'https://partner.example', + partner_id: 'acme', + campaign_id: 'spring', + }, + }), + ).toEqual({ + banner_id: 'banner-ad', + title: 'Partner', + click_target: 'ad', + sort: 3, + language: 'en', + partner_id: 'acme', + campaign_id: 'spring', + link: 'https://partner.example', + }) + }) +}) diff --git a/web/app/components/plugins/marketplace/home/home-trending-track.ts b/web/app/components/plugins/marketplace/home/home-trending-track.ts new file mode 100644 index 00000000000..c141fd1b59d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending-track.ts @@ -0,0 +1,62 @@ +import type { BannerRecommendCard, PluginBanner } from '@dify/contracts/marketplace' + +const CLICK_TARGET_BY_STYLE = { + recommend: 'recommendation', + blog: 'blog', + event: 'event', + ad: 'ad', +} as const + +const THEME_TYPE_BY_BANNER = { + newest: 'new_arrivals', + hottest: 'most_popular', + partner: 'partner', +} as const + +export type MarketplaceBannerCardClick = Pick< + BannerRecommendCard, + 'item_id' | 'item_type' | 'display_name' +> & { + link: string +} + +const compact = (properties: Record<string, unknown>) => { + const next: Record<string, unknown> = {} + for (const [key, value] of Object.entries(properties)) { + if (value !== undefined && value !== '') next[key] = value + } + return next +} + +export const buildMarketplaceBannerClickProperties = ( + banner: PluginBanner, + cardClick?: MarketplaceBannerCardClick, +) => { + const clickTarget = CLICK_TARGET_BY_STYLE[banner.style_type] + const properties: Record<string, unknown> = { + banner_id: banner.id, + title: banner.title, + click_target: clickTarget, + sort: banner.sort, + language: banner.language, + link: cardClick?.link ?? (banner.style_type === 'recommend' ? undefined : banner.content.link), + } + + if (banner.style_type === 'recommend') { + properties.theme_type = THEME_TYPE_BY_BANNER[banner.content.theme_type] + properties.item_id = cardClick?.item_id + properties.item_type = cardClick?.item_type + properties.item_name = cardClick?.display_name + } + + if (banner.style_type === 'blog') properties.target_type = banner.content.link_target_type + + if (banner.style_type === 'event') properties.activity_id = banner.content.activity_id + + if (banner.style_type === 'ad') { + properties.partner_id = banner.content.partner_id + properties.campaign_id = banner.content.campaign_id + } + + return compact(properties) +} 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..34272cf272b --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.module.css @@ -0,0 +1,315 @@ +.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 { + /* Keep the 400×200 art at design size on PC so shrinking the frame + clips overflow on the right instead of scaling the bitmap. */ + width: 400px; + max-width: 400px; + flex-shrink: 0; + object-position: left; +} + +/* Desktop: crop from the right so left-side artwork stays visible when the + 6:1 frame is narrower than the image. */ +.imageSlide :is(picture, img) { + object-position: left; +} + +/* Desktop and mobile: keep the green label on one line. The title wraps. */ +.blogTag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.blogTitle { + overflow-wrap: break-word; + white-space: normal; +} + +.updatesDescription { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +@media (prefers-reduced-motion: reduce) { + .contentTrack { + transition-duration: 0ms; + } +} + +@media (max-width: 879px) { + :global([data-marketplace-standalone]) .section { + padding-right: 20px; + padding-left: 20px; + } + + :global([data-marketplace-standalone]) .wrapper { + padding-bottom: 0; + } + + :global([data-marketplace-standalone]) .copy { + width: 100%; + height: 160px; + } + + :global([data-marketplace-standalone]) .navigation { + position: static; + top: auto; + width: 100%; + } + + /* Recommend mobile: 96px app icons (Figma 1026:24938), not desktop cards. */ + :global([data-marketplace-standalone]) .recommendBackdrop { + display: none; + } + + :global([data-marketplace-standalone]) .recommendCards { + justify-content: center; + gap: 20px; + overflow: hidden; + padding: 36px 12px; + } + + :global([data-marketplace-standalone]) .recommendCards > .card { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 96px; + min-width: 96px; + max-width: 96px; + height: 96px; + padding: 0; + overflow: visible; + background: transparent; + border-radius: 20px; + box-shadow: none; + } + + :global([data-marketplace-standalone]) .recommendCards > .card:nth-child(-n + 3) { + display: flex; + } + + :global([data-marketplace-standalone]) .recommendCards > .card:nth-child(n + 4) { + display: none; + } + + :global([data-marketplace-standalone]) .cardIcon { + width: 96px; + height: 96px; + border-color: var(--color-effects-icon-border); + border-radius: 20px; + box-shadow: + 0 0.5px 5px 0 var(--color-shadow-shadow-4), + 0 0.5px 2px -0.5px var(--color-shadow-shadow-4); + backdrop-filter: blur(5px); + } + + :global([data-marketplace-standalone]) .cardMeta { + display: none; + } + + :global([data-marketplace-standalone]) .copyDescription { + font-size: 15px; + letter-spacing: -0.075px; + } + + :global([data-marketplace-standalone]) .updatesArt { + width: 100%; + max-width: none; + height: 197px; + } + + :global([data-marketplace-standalone]) .carouselRoot { + display: flex; + flex-direction: column; + gap: 8px; + height: auto; + border-radius: 0; + } + + :global([data-marketplace-standalone]) .slideViewport { + height: auto; + touch-action: pan-y pinch-zoom; + } + + :global([data-marketplace-standalone]) .contentTrack { + height: auto; + align-items: flex-start; + } + + :global([data-marketplace-standalone]) .slide { + height: auto; + } + + /* Flex rows size to the tallest item. Collapse hidden slides so image + banners do not inherit the stacked blog/recommend height. */ + :global([data-marketplace-standalone]) .slideInactive { + height: 0; + overflow: hidden; + } + + :global([data-marketplace-standalone]) .stackedSlide { + display: flex; + flex-direction: column-reverse; + height: auto; + } + + :global([data-marketplace-standalone]) .stackedVisual { + flex: none; + width: 100%; + height: 197px; + border-radius: 16px; + } + + /* Recommend mobile: already-tinted crop, not the desktop image + mix-blend. */ + :global([data-marketplace-standalone]) .recommendVisual { + border-radius: 12px; + background-color: var(--color-text-accent); + background-image: url('./assets/recommend-mobile-backdrop.webp'); + background-repeat: no-repeat; + background-position: center; + background-size: cover; + } + + :global([data-marketplace-standalone]) .stackedCopy { + flex: none; + height: auto; + min-height: 160px; + padding: 20px; + overflow: hidden; + } + + :global([data-marketplace-standalone]) .stackedCopyMeta { + flex: none; + gap: 2px; + } + + :global([data-marketplace-standalone]) .blogSubtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + :global([data-marketplace-standalone]) .updatesDescription { + flex-shrink: 0; + width: 100%; + min-width: 0; + height: 40px; + } + + /* Event/ad mobile: show the 800×721 poster whole (ops banner-meta), not + cover-cropped into the stacked 357px blog/recommend frame. */ + :global([data-marketplace-standalone]) .imageSlide { + height: auto; + aspect-ratio: 800 / 721; + } + + :global([data-marketplace-standalone]) .imageSlide :is(picture, img) { + width: 100%; + height: 100%; + object-fit: contain; + object-position: left; + } + + :global([data-marketplace-standalone]) .readMoreDesktop { + display: none; + } +} + +@media (min-width: 880px) { + /* Event/ad: keep the 6:1 bitmap at least 1200px wide so a narrower + overflow-hidden frame clips the right, not the left. */ + .imageSlide img { + min-width: 1200px; + max-width: none; + } +} + +@media (min-width: 1232px) { + .marketplaceCopy { + width: 443px; + } +} + +@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 new file mode 100644 index 00000000000..5ea89ce5aaa --- /dev/null +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -0,0 +1,403 @@ +'use client' + +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, + TransitionEvent, +} from 'react' +import type { MarketplaceBannerPage } from './banners' +import { cn } from '@langgenius/dify-ui/cn' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { trackEvent } from '@/app/components/base/amplitude' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import TrendingNavigation from './home-trending-navigation' +import { HomeBannerSlide } from './home-trending-slides' +import styles from './home-trending.module.css' +import { useBannerViewability } from './use-banner-viewability' + +type LoopPhase = 'idle' | 'resetting' | 'wrapping' +type GestureAxis = 'horizontal' | 'pending' | 'vertical' + +type SwipeGesture = { + axis: GestureAxis + pointerId: number + selectedIndex: number + startX: number + startY: number +} + +const MOBILE_VIEWPORT_QUERY = '(max-width: 879px)' +const GESTURE_AXIS_THRESHOLD = 8 +const MIN_SWIPE_THRESHOLD = 40 +const MAX_SWIPE_THRESHOLD = 64 + +function TrackedBannerSlide({ + banner, + isActive, + isDragging, + isMarketplacePlatform, + page, +}: { + banner: PluginBanner + isActive: boolean + isDragging: boolean + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const slideRef = useRef<HTMLDivElement>(null) + + useBannerViewability( + slideRef, + () => { + const properties = { + banner_id: banner.id, + sort: banner.sort, + page, + language: banner.language, + style_type: banner.style_type, + } + trackEvent('marketplace_banner_impression', properties) + trackMarketplaceSiteEvent('marketplace_banner_impression', properties) + }, + isActive, + ) + + return ( + <div + ref={slideRef} + role="group" + aria-roledescription="slide" + aria-label={banner.title} + aria-hidden={!isActive} + inert={!isActive} + className={cn( + 'h-full min-w-0 shrink-0 grow-0 basis-full', + isMarketplacePlatform && styles.slide, + isMarketplacePlatform && !isActive && !isDragging && styles.slideInactive, + )} + > + <HomeBannerSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} page={page} /> + </div> + ) +} + +function HomeTrending({ + banners, + isMarketplacePlatform, + page, +}: { + banners: PluginBanner[] + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const { t } = useTranslation('plugin') + const carouselRootRef = useRef<HTMLDivElement>(null) + const swipeGestureRef = useRef<SwipeGesture | null>(null) + const suppressClickRef = useRef(false) + const suppressClickTimerRef = useRef<number | null>(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const [trackIndex, setTrackIndex] = useState(0) + const [loopPhase, setLoopPhase] = useState<LoopPhase>('idle') + const [dragOffset, setDragOffset] = useState(0) + const [isDragging, setIsDragging] = useState(false) + const [isGestureActive, setIsGestureActive] = useState(false) + const [isRotationPaused, setIsRotationPaused] = useState(false) + const selectSlide = useCallback((index: number) => { + setLoopPhase('idle') + setTrackIndex(index) + setSelectedIndex(index) + }, []) + const lastIndex = Math.max(0, banners.length - 1) + if (selectedIndex > lastIndex) { + setLoopPhase('idle') + setSelectedIndex(lastIndex) + setTrackIndex(lastIndex) + } + const selectNextSlide = useCallback(() => { + if (selectedIndex < banners.length - 1) { + const nextIndex = selectedIndex + 1 + setTrackIndex(nextIndex) + setSelectedIndex(nextIndex) + return + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setTrackIndex(0) + setSelectedIndex(0) + return + } + + // Move forwards to a visual clone of the first slide. Once that + // transition completes, the track can snap back to the real first slide. + setLoopPhase('wrapping') + setTrackIndex(banners.length) + }, [banners.length, selectedIndex]) + + const handleTrackTransitionEnd = useCallback( + (event: TransitionEvent<HTMLDivElement>) => { + if (loopPhase !== 'wrapping' || event.target !== event.currentTarget) return + + setLoopPhase('resetting') + setTrackIndex(0) + setSelectedIndex(0) + }, + [loopPhase], + ) + + useEffect(() => { + if (loopPhase !== 'resetting') return + + let settled = false + const settle = () => { + if (settled) return + settled = true + setLoopPhase('idle') + } + + const frame = window.requestAnimationFrame(settle) + const timeout = window.setTimeout(settle, 50) + return () => { + window.cancelAnimationFrame(frame) + window.clearTimeout(timeout) + } + }, [loopPhase]) + + useEffect( + () => () => { + if (suppressClickTimerRef.current !== null) window.clearTimeout(suppressClickTimerRef.current) + }, + [], + ) + + const canStartSwipe = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => + isMarketplacePlatform && + banners.length > 1 && + loopPhase === 'idle' && + event.isPrimary && + event.pointerType === 'touch' && + window.matchMedia(MOBILE_VIEWPORT_QUERY).matches, + [banners.length, isMarketplacePlatform, loopPhase], + ) + + const handlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + if (!canStartSwipe(event)) return + + if (suppressClickTimerRef.current !== null) { + window.clearTimeout(suppressClickTimerRef.current) + suppressClickTimerRef.current = null + } + suppressClickRef.current = false + swipeGestureRef.current = { + axis: 'pending', + pointerId: event.pointerId, + selectedIndex, + startX: event.clientX, + startY: event.clientY, + } + setIsGestureActive(true) + }, + [canStartSwipe, selectedIndex], + ) + + const handlePointerMove = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + const gesture = swipeGestureRef.current + if (!gesture || gesture.pointerId !== event.pointerId) return + + const deltaX = event.clientX - gesture.startX + const deltaY = event.clientY - gesture.startY + + if (gesture.axis === 'pending') { + if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < GESTURE_AXIS_THRESHOLD) return + + if (Math.abs(deltaY) > Math.abs(deltaX)) { + gesture.axis = 'vertical' + setIsGestureActive(false) + return + } + + gesture.axis = 'horizontal' + setIsDragging(true) + try { + event.currentTarget.setPointerCapture(event.pointerId) + } catch { + // Touch pointers are implicitly captured; explicit capture is only a + // safeguard for browsers that retarget during a horizontal drag. + } + } + + if (gesture.axis !== 'horizontal') return + + const viewportWidth = event.currentTarget.getBoundingClientRect().width + const boundedOffset = Math.max(-viewportWidth, Math.min(viewportWidth, deltaX)) + const isPastStart = gesture.selectedIndex === 0 && boundedOffset > 0 + const isPastEnd = gesture.selectedIndex === banners.length - 1 && boundedOffset < 0 + setDragOffset(isPastStart || isPastEnd ? boundedOffset * 0.35 : boundedOffset) + }, + [banners.length], + ) + + const finishSwipe = useCallback( + (event: ReactPointerEvent<HTMLDivElement>, wasCanceled = false) => { + const gesture = swipeGestureRef.current + if (!gesture || gesture.pointerId !== event.pointerId) return + + const deltaX = event.clientX - gesture.startX + const wasHorizontal = gesture.axis === 'horizontal' + swipeGestureRef.current = null + setDragOffset(0) + setIsDragging(false) + setIsGestureActive(false) + + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId) + + if (!wasHorizontal) return + + // Once the gesture locks to the horizontal axis, suppress the browser's + // trailing click even if the finger returns near its starting point. + suppressClickRef.current = true + suppressClickTimerRef.current = window.setTimeout(() => { + suppressClickRef.current = false + suppressClickTimerRef.current = null + }, 0) + + if (wasCanceled) return + + const swipeThreshold = Math.min( + MAX_SWIPE_THRESHOLD, + Math.max(MIN_SWIPE_THRESHOLD, event.currentTarget.getBoundingClientRect().width * 0.12), + ) + if (Math.abs(deltaX) < swipeThreshold) return + + if (deltaX < 0 && gesture.selectedIndex < banners.length - 1) + selectSlide(gesture.selectedIndex + 1) + else if (deltaX > 0 && gesture.selectedIndex > 0) selectSlide(gesture.selectedIndex - 1) + }, + [banners.length, selectSlide], + ) + + const handleClickCapture = useCallback((event: ReactMouseEvent<HTMLDivElement>) => { + if (!suppressClickRef.current) return + + event.preventDefault() + event.stopPropagation() + suppressClickRef.current = false + if (suppressClickTimerRef.current !== null) { + window.clearTimeout(suppressClickTimerRef.current) + suppressClickTimerRef.current = null + } + }, []) + + if (banners.length === 0) return null + + return ( + <section + 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 && styles.section, + )} + > + <div + className={cn( + styles.wrapper, + 'mx-auto w-full', + isMarketplacePlatform ? 'max-w-[1200px]' : 'max-w-[1188px]', + )} + > + <div + // The pause boundary covers the whole carousel region, so hovering + // or focusing the navigation controls also stops the rotation. + ref={carouselRootRef} + role="region" + aria-roledescription="carousel" + aria-label={t(($) => $['marketplace.home.trendingTitle'])} + className={cn( + 'relative h-[200px] w-full rounded-2xl', + isMarketplacePlatform && styles.carouselRoot, + )} + data-home-trending-carousel-root + > + <div + className={cn( + 'h-full overflow-hidden rounded-2xl', + isMarketplacePlatform && styles.slideViewport, + )} + onClickCapture={handleClickCapture} + onPointerCancel={(event) => finishSwipe(event, true)} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={finishSwipe} + > + <div + // Keep automatic rotation silent for screen readers; announce + // the current slide only once rotation is paused or user-driven. + aria-live={isRotationPaused ? 'polite' : 'off'} + className={cn(styles.contentTrack, 'flex h-full')} + data-carousel-track + data-carousel-loop-phase={loopPhase} + onTransitionEnd={handleTrackTransitionEnd} + style={{ + transform: + dragOffset === 0 + ? `translate3d(-${trackIndex * 100}%, 0, 0)` + : `translate3d(calc(-${trackIndex * 100}% + ${dragOffset}px), 0, 0)`, + transition: loopPhase === 'resetting' || isDragging ? 'none' : undefined, + }} + > + {banners.map((banner, index) => ( + <TrackedBannerSlide + key={banner.id} + banner={banner} + isActive={index === selectedIndex} + isDragging={isDragging} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + ))} + {loopPhase !== 'idle' && banners[0] && ( + <div + aria-hidden + inert + data-carousel-loop-clone + className={cn( + 'h-full min-w-0 shrink-0 grow-0 basis-full', + isMarketplacePlatform && styles.slide, + )} + > + <HomeBannerSlide + banner={banners[0]} + isMarketplacePlatform={isMarketplacePlatform} + page={page} + /> + </div> + )} + </div> + </div> + {/* A single banner has nothing to rotate through, so skip the + pagination/autoplay controls entirely. */} + {banners.length > 1 && ( + <TrendingNavigation + banners={banners} + selectedIndex={selectedIndex} + carouselRootRef={carouselRootRef} + pauseWhenOffscreen={!isMarketplacePlatform} + onSelect={selectSlide} + onNext={selectNextSlide} + onPausedChange={setIsRotationPaused} + interactionPaused={isGestureActive} + /> + )} + </div> + </div> + </section> + ) +} + +export default HomeTrending diff --git a/web/app/components/plugins/marketplace/home/index.tsx b/web/app/components/plugins/marketplace/home/index.tsx new file mode 100644 index 00000000000..c555b94e66d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/index.tsx @@ -0,0 +1,82 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ActivePluginType } from '../constants' +import type { HomeCatalogTabLabels } from './home-catalog-tabs' +import ListWrapper from '../list/list-wrapper' +import CatalogTagsFilter from './catalog-tags-filter' +import HomeCatalogNavigation from './home-catalog-navigation' +import HomeCatalogTabs from './home-catalog-tabs' +import HomeHeader from './home-header' +import HomeHero from './home-hero' +import HomeSearch from './home-search' +import { HomeShell } from './home-shell' +import styles from './home-sticky.module.css' + +type MarketplaceHomeProps = { + actions?: React.ReactNode + activePluginType?: ActivePluginType + banners: PluginBanner[] + catalogCategories?: React.ReactNode + catalogLabels?: HomeCatalogTabLabels + isMarketplacePlatform: boolean + language?: string + linkToMarketplaceDetail: boolean + search?: React.ReactNode + showInstallButton: boolean +} + +const MarketplaceHome = ({ + actions, + activePluginType, + banners, + catalogCategories, + catalogLabels, + isMarketplacePlatform, + language, + linkToMarketplaceDetail, + search, + showInstallButton, +}: MarketplaceHomeProps) => { + return ( + <HomeShell + banners={banners} + isMarketplacePlatform={isMarketplacePlatform} + page="plugins" + header={ + <HomeHeader + actions={actions} + catalogLabels={catalogLabels} + isMarketplacePlatform={isMarketplacePlatform} + language={language} + /> + } + hero={<HomeHero isMarketplacePlatform={isMarketplacePlatform} />} + search={<HomeSearch enableSearchShortcut={isMarketplacePlatform}>{search}</HomeSearch>} + navigation={ + <HomeCatalogNavigation + catalogCategories={catalogCategories} + catalogLeading={<CatalogTagsFilter />} + isMarketplacePlatform={isMarketplacePlatform} + catalogTabs={ + <HomeCatalogTabs + isMarketplacePlatform={isMarketplacePlatform} + labels={catalogLabels} + language={language} + /> + } + /> + } + > + <div className="contents [&>div]:bg-background-default!"> + <ListWrapper + activePluginType={activePluginType} + className={styles.catalogContent} + deferOffscreenCollections={!isMarketplacePlatform} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + /> + </div> + </HomeShell> + ) +} + +export default MarketplaceHome diff --git a/web/app/components/plugins/marketplace/home/marketplace-href.ts b/web/app/components/plugins/marketplace/home/marketplace-href.ts new file mode 100644 index 00000000000..91b992209b8 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-href.ts @@ -0,0 +1,15 @@ +export function sanitizeMarketplaceHref(value: string): string | null { + const trimmed = value.trim() + if (!trimmed) return null + if (trimmed.startsWith('/') && !trimmed.startsWith('//') && !trimmed.includes('\\')) { + return trimmed + } + + try { + const url = new URL(trimmed) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.toString() + } catch { + return null + } +} diff --git a/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx new file mode 100644 index 00000000000..32a7b9eec93 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-live-search.tsx @@ -0,0 +1,84 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { useDebounce } from 'ahooks' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useRouter } from '@/next/navigation' +import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track' + +type MarketplaceLiveSearchProps = { + action: string + className?: string + language?: string + placeholder: string + preserveParams?: { + tags?: string[] + languages?: string[] + } + query: string +} + +export default function MarketplaceLiveSearch({ + action, + className, + language, + placeholder, + preserveParams, + query, +}: MarketplaceLiveSearchProps) { + const router = useRouter() + const [value, setValue] = useState(query) + const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) + const routedSearchRef = useRef(query.trim()) + const navigate = useCallback( + (nextQuery: string) => { + if (nextQuery) markMarketplaceSiteSearch(nextQuery) + const searchParams = new URLSearchParams() + if (nextQuery) searchParams.set('q', nextQuery) + if (language) searchParams.set('language', language) + if (preserveParams?.tags?.length) searchParams.set('tags', preserveParams.tags.join(',')) + if (preserveParams?.languages?.length) + searchParams.set('languages', preserveParams.languages.join(',')) + const queryString = searchParams.toString() + + router.replace(`${action}${queryString ? `?${queryString}` : ''}`, { scroll: false }) + }, + [action, language, preserveParams, router], + ) + + useEffect(() => { + if (debouncedSearch === routedSearchRef.current) return + + routedSearchRef.current = debouncedSearch + navigate(debouncedSearch) + }, [debouncedSearch, navigate]) + + return ( + <form + action={action} + className={cn('relative shrink-0', className)} + onSubmit={(event) => { + event.preventDefault() + const nextQuery = value.trim() + routedSearchRef.current = nextQuery + navigate(nextQuery) + }} + > + <span + aria-hidden + className="pointer-events-none absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" + /> + <input + type="search" + name="q" + autoComplete="off" + aria-label={placeholder} + value={value} + onChange={(event) => setValue(event.target.value)} + placeholder={placeholder} + className="h-9 w-full rounded-[10px] border border-transparent bg-components-input-bg-normal py-2 pr-3 pl-9 text-sm text-text-primary outline-none placeholder:text-text-quaternary hover:border-components-input-border-hover focus:border-components-input-border-active" + /> + {language && <input type="hidden" name="language" value={language} />} + </form> + ) +} diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx new file mode 100644 index 00000000000..96aadc24f8d --- /dev/null +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -0,0 +1,432 @@ +'use client' + +import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace' +import { + Autocomplete, + AutocompleteClear, + AutocompleteCollection, + AutocompleteEmpty, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteInput, + AutocompleteInputGroup, + AutocompleteItem, + AutocompleteItemText, + AutocompleteList, + AutocompletePortal, + AutocompletePositioner, + AutocompleteSeparator, + AutocompleteStatus, + useAutocompleteFilteredItems, +} from '@langgenius/dify-ui/autocomplete' +import { cn } from '@langgenius/dify-ui/cn' +import { useQuery } from '@tanstack/react-query' +import { useDebounce } from 'ahooks' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { renderI18nObject } from '@/i18n-config/index' +import { marketplaceQuery } from '@/service/client' +import { markMarketplaceSiteSearch } from '@/utils/marketplace-site-track' +import { + getPluginDetailLinkInMarketplace, + getPluginIconInMarketplace, + getTemplateDetailLinkInMarketplace, +} from '../utils' + +type MarketplaceSearchScope = 'all' | 'plugins' | 'templates' + +export type MarketplaceSearchSelection = + | { kind: 'plugin'; plugin: MarketplacePlugin } + | { kind: 'template'; template: MarketplaceTemplate } + +type MarketplaceSuggestion = { + description: string + iconUrl?: string + id: string + kind: 'plugin' | 'template' + label: string + meta: string + selection: MarketplaceSearchSelection +} + +type MarketplaceSuggestionGroup = { + id: MarketplaceSuggestion['kind'] + items: MarketplaceSuggestion[] + label: string +} + +type MarketplaceSearchAutocompleteProps = { + category?: string + inputName?: string + locale: string + onSuggestionSelect?: (selection: MarketplaceSearchSelection) => void + onValueChange: (value: string) => void + placeholder: string + scope: MarketplaceSearchScope + value: string +} + +const getPluginText = ( + value: MarketplacePlugin['brief'] | MarketplacePlugin['label'], + locale: string, +) => { + if (typeof value === 'string') return value + return renderI18nObject((value ?? {}) as Record<string, string>, locale) +} + +const toTemplateSuggestion = (template: MarketplaceTemplate): MarketplaceSuggestion => ({ + description: template.overview, + iconUrl: template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` + : undefined, + id: `template:${template.id}`, + kind: 'template', + label: template.template_name, + meta: template.publisher_handle || template.publisher_unique_handle || '', + selection: { kind: 'template', template }, +}) + +const toPluginSuggestion = (plugin: MarketplacePlugin, locale: string): MarketplaceSuggestion => ({ + description: getPluginText(plugin.brief, locale), + iconUrl: getPluginIconInMarketplace(plugin), + id: `plugin:${plugin.org}/${plugin.name}`, + kind: 'plugin', + label: getPluginText(plugin.label, locale) || plugin.name, + meta: plugin.org, + selection: { kind: 'plugin', plugin }, +}) + +function MarketplaceSuggestionList({ + onSelect, +}: { + onSelect: (selection: MarketplaceSearchSelection) => void +}) { + const groups = useAutocompleteFilteredItems<MarketplaceSuggestionGroup>() + + return ( + <AutocompleteList className="max-h-none overflow-visible p-0 data-empty:p-0"> + {groups.map((group, groupIndex) => ( + <AutocompleteGroup key={group.id} items={group.items} className="p-1"> + {groupIndex > 0 && <AutocompleteSeparator className="-mx-1 mb-1" />} + <AutocompleteGroupLabel className="px-3 pt-3 pb-2 system-xs-semibold-uppercase text-text-primary"> + {group.label} + </AutocompleteGroupLabel> + <AutocompleteCollection<MarketplaceSuggestion>> + {(item) => ( + <AutocompleteItem + key={item.id} + value={item} + className="mx-0 items-start gap-1 rounded-lg py-1 pr-1 pl-3 hover:bg-state-base-hover data-highlighted:bg-state-base-hover" + onClick={() => onSelect(item.selection)} + > + <span className="flex shrink-0 items-start py-1"> + {item.iconUrl ? ( + <img + alt="" + className={cn( + 'shrink-0 object-contain', + item.kind === 'template' + ? 'size-8 rounded-lg border-[0.5px] border-divider-regular' + : 'size-7 rounded-lg', + )} + src={item.iconUrl} + onError={({ currentTarget }) => { + currentTarget.style.display = 'none' + }} + /> + ) : ( + <span + aria-hidden + className={cn( + 'flex shrink-0 items-center justify-center text-text-tertiary', + item.kind === 'template' + ? 'i-ri-layout-grid-line size-8 rounded-lg border-[0.5px] border-divider-regular text-base' + : 'i-ri-puzzle-2-line size-7 rounded-lg text-base', + )} + /> + )} + </span> + <span className="flex min-w-0 flex-1 flex-col gap-0.5 p-1"> + <AutocompleteItemText className="px-0 system-md-medium text-text-primary"> + {item.label} + </AutocompleteItemText> + {!!item.description && ( + <span className="line-clamp-2 system-xs-regular text-text-tertiary"> + {item.description} + </span> + )} + {!!item.meta && ( + <span className="truncate pt-1 system-xs-regular text-text-tertiary"> + {item.meta} + </span> + )} + </span> + </AutocompleteItem> + )} + </AutocompleteCollection> + </AutocompleteGroup> + ))} + </AutocompleteList> + ) +} + +export function MarketplaceSearchAutocomplete({ + category = 'all', + inputName, + locale, + onSuggestionSelect, + onValueChange, + placeholder, + scope, + value, +}: MarketplaceSearchAutocompleteProps) { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const searchRootRef = useRef<HTMLDivElement>(null) + const resultsPanelRef = useRef<HTMLDivElement>(null) + const keyboardHighlightedRef = useRef(false) + + const submitSearchForm = () => { + const form = searchRootRef.current?.closest('form') + if (form instanceof HTMLFormElement) form.requestSubmit() + } + const openSuggestion = (selection: MarketplaceSearchSelection) => { + if (onSuggestionSelect) { + onSuggestionSelect(selection) + queueMicrotask(() => { + onValueChange('') + setIsOpen(false) + }) + return + } + + const href = + selection.kind === 'plugin' + ? getPluginDetailLinkInMarketplace(selection.plugin) + : getTemplateDetailLinkInMarketplace(selection.template) + setIsOpen(false) + window.location.assign(href) + } + const debouncedSearch = useDebounce(value.trim(), { wait: 300 }) + const hasQuery = Boolean(debouncedSearch) + const searchesPlugins = scope === 'all' || scope === 'plugins' + const searchesTemplates = scope === 'all' || scope === 'templates' + const isBundleSearch = category === 'bundle' + const pluginQuery = useQuery({ + ...marketplaceQuery.searchAdvanced.queryOptions({ + input: { + params: { kind: isBundleSearch ? 'bundles' : 'plugins' }, + body: { + page: 1, + page_size: 5, + query: debouncedSearch, + sort_by: 'install_count', + sort_order: 'DESC', + category: category !== 'all' && !isBundleSearch ? category : '', + }, + }, + retry: false, + }), + // No placeholderData here: showing the previous term's suggestions would + // leave stale items keyboard-selectable while the new request is pending. + enabled: hasQuery && searchesPlugins, + staleTime: 60_000, + }) + const templateQuery = useQuery({ + ...marketplaceQuery.templateSearch.queryOptions({ + input: { + body: { + page: 1, + page_size: 5, + query: debouncedSearch, + sort_by: 'usage_count', + sort_order: 'DESC', + ...(category !== 'all' ? { categories: [category] } : {}), + }, + }, + retry: false, + }), + enabled: hasQuery && searchesTemplates, + staleTime: 60_000, + }) + // While the edited value is still debouncing, the queries above still hold + // the previous term's data; gate the suggestions until both agree so stale + // options are never visible or keyboard-selectable. + const isDebouncing = value.trim() !== debouncedSearch + const pluginSuggestions = + !isDebouncing && searchesPlugins + ? (pluginQuery.data?.data.bundles ?? pluginQuery.data?.data.plugins ?? []).map((plugin) => + toPluginSuggestion(plugin, locale), + ) + : [] + const templateSuggestions = + !isDebouncing && searchesTemplates + ? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion) + : [] + const suggestions = [...templateSuggestions, ...pluginSuggestions] + const suggestionGroups: MarketplaceSuggestionGroup[] = [ + ...(templateSuggestions.length + ? [ + { + id: 'template' as const, + items: templateSuggestions, + label: t(($) => $['marketplace.home.templates'], { ns: 'plugin' }), + }, + ] + : []), + ...(pluginSuggestions.length + ? [ + { + id: 'plugin' as const, + items: pluginSuggestions, + label: t(($) => $['marketplace.home.plugins'], { ns: 'plugin' }), + }, + ] + : []), + ] + const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching + // Keep open tied to the typing session so outside-press can dismiss during + // debounce/fetch. Pending, empty, and error copy live inside the popup. + const hasTypedQuery = Boolean(value.trim()) + const isPopupOpen = isOpen && hasTypedQuery + // A failed request must not read as "nothing matched"; when every source in + // scope errored and nothing is displayable, surface a load failure instead. + const hasLoadError = + !isDebouncing && + suggestions.length === 0 && + ((searchesPlugins && pluginQuery.isError) || (searchesTemplates && templateQuery.isError)) + const emptyText = hasLoadError + ? t(($) => $['marketplace.loadError'], { ns: 'plugin' }) + : scope === 'templates' + ? t(($) => $['newApp.noTemplateFound'], { ns: 'app' }) + : t(($) => $['marketplace.noPluginFound'], { ns: 'plugin' }) + + useEffect(() => { + if (!isPopupOpen) return + + const handleOutsidePress = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof Node)) return + if (searchRootRef.current?.contains(target) || resultsPanelRef.current?.contains(target)) + return + setIsOpen(false) + } + + document.addEventListener('click', handleOutsidePress) + return () => document.removeEventListener('click', handleOutsidePress) + }, [isPopupOpen]) + + return ( + <div ref={searchRootRef} className="relative"> + <Autocomplete + filter={null} + itemToStringValue={(item) => item.label} + items={suggestionGroups} + mode="list" + name={inputName} + onOpenChange={setIsOpen} + onValueChange={(nextValue) => { + onValueChange(nextValue) + setIsOpen(Boolean(nextValue.trim())) + }} + open={isPopupOpen} + openOnInputClick + submitOnItemClick={false} + value={value} + onItemHighlighted={(item, details) => { + keyboardHighlightedRef.current = Boolean(item) && details.reason === 'keyboard' + }} + > + <AutocompleteInputGroup size="large"> + <span + aria-hidden + className="ml-3 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" + /> + <AutocompleteInput + aria-label={placeholder} + className="px-2 text-sm" + placeholder={placeholder} + size="large" + type="text" + onKeyDownCapture={(event) => { + if (event.key !== 'Enter' || !inputName) return + if (keyboardHighlightedRef.current) return + event.preventDefault() + event.stopPropagation() + if (value.trim()) submitSearchForm() + }} + /> + {!!value && ( + <AutocompleteClear + aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })} + size="large" + /> + )} + </AutocompleteInputGroup> + <AutocompletePortal hidden={!isPopupOpen}> + <AutocompletePositioner anchor={searchRootRef} sideOffset={8}> + <div + ref={resultsPanelRef} + className="max-h-[min(20rem,var(--available-height))] w-(--anchor-width) max-w-(--available-width) overflow-y-auto overscroll-contain rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl outline-hidden backdrop-blur-sm" + aria-busy={isSearching || undefined} + > + <MarketplaceSuggestionList onSelect={openSuggestion} /> + <AutocompleteEmpty> + {!isSearching && suggestions.length === 0 ? emptyText : null} + </AutocompleteEmpty> + <AutocompleteStatus className="empty:h-0 empty:p-0"> + {isSearching ? t(($) => $.loading, { ns: 'common' }) : null} + </AutocompleteStatus> + </div> + </AutocompletePositioner> + </AutocompletePortal> + </Autocomplete> + </div> + ) +} + +type MarketplaceSearchFormProps = { + action: string + category?: string + className?: string + language?: string + locale: string + placeholder: string + query: string + scope: MarketplaceSearchScope +} + +export function MarketplaceSearchForm({ + action, + category, + className, + language, + locale, + placeholder, + query, + scope, +}: MarketplaceSearchFormProps) { + const [value, setValue] = useState(query) + + return ( + <form + action={action} + className={cn('relative shrink-0', className)} + onSubmit={() => { + markMarketplaceSiteSearch(value) + }} + > + <MarketplaceSearchAutocomplete + category={category} + inputName="q" + locale={locale} + onValueChange={setValue} + placeholder={placeholder} + scope={scope} + value={value} + /> + {language && <input type="hidden" name="language" value={language} />} + </form> + ) +} diff --git a/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts new file mode 100644 index 00000000000..4c144f70af3 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/preserve-sticky-search-scroll.ts @@ -0,0 +1,147 @@ +const LARGE_SCROLL_JUMP_PX = 16 + +const canScrollY = (element: Element, deltaY: number) => { + const { overflowY } = getComputedStyle(element) + if (overflowY !== 'auto' && overflowY !== 'scroll') return false + const maxScrollTop = element.scrollHeight - element.clientHeight + if (maxScrollTop <= 0) return false + if (deltaY > 0) return element.scrollTop < maxScrollTop - 1 + if (deltaY < 0) return element.scrollTop > 1 + return false +} + +/** + * Sticky search sits in document flow below the hero, then visually pins in the + * header. Focusing or typing in that input makes Chromium scroll the layout box + * into view, which unpins the search and looks like the page rolling down. + * Remember the scroll position and snap back when a focused search input causes + * a large jump. Visitor-initiated movement (wheel, touch, scrollbar) must still + * scroll the page while the field is focused. The suggestions popup is portaled + * onto `document.body`, so leftover wheel delta is forwarded to the page + * scroller when the popup cannot consume it. + */ +export function preserveStickySearchScroll(searchRoot: HTMLElement, container: HTMLElement) { + let stableScrollTop = container.scrollTop + let suppressing = false + let visitorScrolling = false + + const remember = () => { + if (!suppressing) stableScrollTop = container.scrollTop + } + + const restore = () => { + if (container.scrollTop === stableScrollTop) return + suppressing = true + container.scrollTop = stableScrollTop + requestAnimationFrame(() => { + suppressing = false + }) + } + + const markVisitorScroll = () => { + visitorScrolling = true + } + + const onWindowWheel = (event: WheelEvent) => { + if (!searchRoot.contains(document.activeElement) || event.deltaY === 0) return + markVisitorScroll() + if (!(event.target instanceof Node) || container.contains(event.target)) return + + let node: Element | null = + event.target instanceof Element ? event.target : event.target.parentElement + while (node && node !== document.documentElement) { + if (node === container) return + if (canScrollY(node, event.deltaY)) return + node = node.parentElement + } + + event.preventDefault() + container.scrollTop += event.deltaY + } + + const onScroll = () => { + if (suppressing) return + if (searchRoot.contains(document.activeElement) && !visitorScrolling) { + if (Math.abs(container.scrollTop - stableScrollTop) > LARGE_SCROLL_JUMP_PX) { + restore() + return + } + } + remember() + } + + const onPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return + visitorScrolling = false + remember() + suppressing = true + requestAnimationFrame(() => { + restore() + suppressing = false + }) + } + + const onFocusIn = (event: FocusEvent) => { + if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return + visitorScrolling = false + restore() + requestAnimationFrame(restore) + } + + const onInput = (event: Event) => { + if (!(event.target instanceof Node) || !searchRoot.contains(event.target)) return + restore() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return + remember() + suppressing = true + requestAnimationFrame(() => { + suppressing = false + }) + } + + const patchInputFocus = (input: HTMLInputElement) => { + if (input.dataset.marketplaceSearchFocus === 'patched') return + input.dataset.marketplaceSearchFocus = 'patched' + const nativeFocus = input.focus.bind(input) + input.focus = (options) => nativeFocus({ ...options, preventScroll: true }) + } + + searchRoot.querySelectorAll('input').forEach((input) => { + patchInputFocus(input) + }) + const observer = new MutationObserver(() => { + searchRoot.querySelectorAll('input').forEach((input) => { + patchInputFocus(input) + }) + }) + observer.observe(searchRoot, { childList: true, subtree: true }) + + const onContainerPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node) || searchRoot.contains(event.target)) return + markVisitorScroll() + } + + container.addEventListener('scroll', onScroll, { passive: true }) + container.addEventListener('touchmove', markVisitorScroll, { passive: true }) + container.addEventListener('pointerdown', onContainerPointerDown) + searchRoot.addEventListener('pointerdown', onPointerDown, true) + searchRoot.addEventListener('focusin', onFocusIn) + searchRoot.addEventListener('input', onInput, true) + window.addEventListener('wheel', onWindowWheel, { passive: false }) + window.addEventListener('keydown', onKeyDown, true) + + return () => { + observer.disconnect() + container.removeEventListener('scroll', onScroll) + container.removeEventListener('touchmove', markVisitorScroll) + container.removeEventListener('pointerdown', onContainerPointerDown) + searchRoot.removeEventListener('pointerdown', onPointerDown, true) + searchRoot.removeEventListener('focusin', onFocusIn) + searchRoot.removeEventListener('input', onInput, true) + window.removeEventListener('wheel', onWindowWheel) + window.removeEventListener('keydown', onKeyDown, true) + } +} diff --git a/web/app/components/plugins/marketplace/home/use-banner-viewability.ts b/web/app/components/plugins/marketplace/home/use-banner-viewability.ts new file mode 100644 index 00000000000..4a2fdc5e356 --- /dev/null +++ b/web/app/components/plugins/marketplace/home/use-banner-viewability.ts @@ -0,0 +1,58 @@ +import type { RefObject } from 'react' +import { useEffect, useRef } from 'react' + +const BANNER_VIEWABILITY_THRESHOLD = 0.5 +const BANNER_VIEWABILITY_DWELL_MS = 1000 + +export function useBannerViewability( + targetRef: RefObject<Element | null>, + onImpression: () => void, + enabled = true, +) { + const onImpressionRef = useRef(onImpression) + onImpressionRef.current = onImpression + + useEffect(() => { + if (!enabled) return + + const target = targetRef.current + if (!target || typeof IntersectionObserver === 'undefined') return + + let dwellTimer: ReturnType<typeof setTimeout> | undefined + let didImpress = false + + const clearDwell = () => { + if (dwellTimer === undefined) return + clearTimeout(dwellTimer) + dwellTimer = undefined + } + + const observer = new IntersectionObserver( + ([entry]) => { + const isViewable = (entry?.intersectionRatio ?? 0) >= BANNER_VIEWABILITY_THRESHOLD + + if (!isViewable) { + didImpress = false + clearDwell() + return + } + + if (didImpress || dwellTimer !== undefined) return + + dwellTimer = setTimeout(() => { + dwellTimer = undefined + didImpress = true + onImpressionRef.current() + }, BANNER_VIEWABILITY_DWELL_MS) + }, + { threshold: BANNER_VIEWABILITY_THRESHOLD }, + ) + + observer.observe(target) + + return () => { + clearDwell() + observer.disconnect() + } + }, [enabled, targetRef]) +} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 455ae83dd92..df0a0ea8a11 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -5,11 +5,11 @@ import type { PluginsSearchParams, } from '@dify/contracts/marketplace' import type { Plugin } from '../types' -import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { postMarketplace } from '@/service/base' -import { SCROLL_BOTTOM_THRESHOLD } from './constants' +import { MARKETPLACE_CONTAINER_ID, SCROLL_BOTTOM_THRESHOLD } from './constants' import { getFormattedPlugin, getMarketplaceCollectionsAndPlugins, @@ -81,7 +81,6 @@ export const useMarketplacePluginsByCollectionId = ( * @deprecated Use useMarketplacePlugins from query.ts instead */ export const useMarketplacePlugins = (enabled = true) => { - const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState<PluginsSearchParams>() const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => { @@ -156,12 +155,9 @@ export const useMarketplacePlugins = (enabled = true) => { retry: false, }) - const resetPlugins = useCallback(() => { + const resetQueryParams = useCallback(() => { setQueryParams(undefined) - queryClient.removeQueries({ - queryKey: ['marketplacePlugins'], - }) - }, [queryClient]) + }, []) const handleUpdatePlugins = useCallback( (pluginsSearchParams: PluginsSearchParams) => { @@ -195,7 +191,7 @@ export const useMarketplacePlugins = (enabled = true) => { return { plugins, total, - resetPlugins, + resetQueryParams, queryPlugins: handleUpdatePlugins, queryPluginsWithDebounced, cancelQueryPluginsWithDebounced, @@ -211,24 +207,40 @@ export const useMarketplacePlugins = (enabled = true) => { export const useMarketplaceContainerScroll = ( callback: () => void, - scrollContainerId = 'marketplace-container', + scrollContainerId = MARKETPLACE_CONTAINER_ID, ) => { - const handleScroll = useCallback( - (e: Event) => { - const target = e.target as HTMLDivElement - const { scrollTop, scrollHeight, clientHeight } = target - if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) - callback() - }, - [callback], - ) + // The callback closes over isFetching, so its identity flips on every fetch + // boundary. Re-subscribing on each flip dropped the scroll events in that + // window; a ref keeps one listener for the container's lifetime. + const callbackRef = useRef(callback) + callbackRef.current = callback useEffect(() => { const container = document.getElementById(scrollContainerId) - if (container) container.addEventListener('scroll', handleScroll) + if (!container) return + + // scrollTop/scrollHeight/clientHeight force a synchronous layout, so + // measuring per scroll event janks the scroll. Worse, every threshold hit + // calls fetchNextPage, which defaults to cancelRefetch: true — a burst + // aborts and restarts the in-flight page request, and the backend counts + // those aborts against its search circuit breaker. One measurement per + // frame is both smoother and quieter on the wire. + let frame = 0 + const handleScroll = () => { + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + const { scrollTop, scrollHeight, clientHeight } = container + if (scrollTop > 0 && scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD) + callbackRef.current() + }) + } + + container.addEventListener('scroll', handleScroll, { passive: true }) return () => { - if (container) container.removeEventListener('scroll', handleScroll) + if (frame) cancelAnimationFrame(frame) + container.removeEventListener('scroll', handleScroll) } - }, [handleScroll]) + }, [scrollContainerId]) } diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx index 9da59135d56..6fc23a6efbf 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -1,44 +1,20 @@ +import type { DehydratedState } from '@tanstack/react-query' import type { SearchParams } from 'nuqs/server' -import type { MarketplaceSearchParams } from './search-params' -import { dehydrate, HydrationBoundary } from '@tanstack/react-query' -import { createLoader } from 'nuqs/server' -import { getQueryClient } from '@/app/get-query-client' -import { marketplaceQuery } from '@/service/client' -import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { marketplaceSearchParamsParsers } from './search-params' -import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' - -// The server side logic should move to marketplace's codebase so that we can get rid of Next.js - -async function getDehydratedState(searchParams?: Promise<SearchParams>) { - if (!searchParams) { - return - } - const loadSearchParams = createLoader(marketplaceSearchParamsParsers) - const params: MarketplaceSearchParams = await loadSearchParams(searchParams) - - if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { - return - } - - const queryClient = getQueryClient() - - await queryClient.prefetchQuery({ - queryKey: marketplaceQuery.collections.queryKey({ - input: { query: getCollectionsParams(params.category) }, - }), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), - }) - return dehydrate(queryClient) -} +import { HydrationBoundary } from '@tanstack/react-query' +import { prefetchMarketplaceDehydratedState } from './prefetch-marketplace-dehydrated-state' export async function HydrateQueryClient({ searchParams, + prefetchedState, children, }: { searchParams: Promise<SearchParams> | undefined + prefetchedState?: DehydratedState children: React.ReactNode }) { - const dehydratedState = await getDehydratedState(searchParams) + const dehydratedState = + prefetchedState === undefined + ? await prefetchMarketplaceDehydratedState(searchParams) + : prefetchedState return <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary> } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 98f1edd8de5..92baa6f3380 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,17 +1,15 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' import type { SearchParams } from 'nuqs' -import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' -import { TanStackQueryProvider } from '@/app/query-provider' -import Description from './description' +import type { MarketplaceViewProps } from './view' +import { getLocaleOnServer } from '@/i18n-config/server' +import { fetchPluginBanners } from './home/banners' import { HydrateQueryClient } from './hydration-server' -import ListWrapper from './list/list-wrapper' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' +import { prefetchMarketplaceDehydratedState } from './prefetch-marketplace-dehydrated-state' +import { withinServerBudget } from './server-budget' +import { MarketplaceView } from './view' -type MarketplaceProps = { - showInstallButton?: boolean - linkToMarketplaceDetail?: boolean - pluginTypeSwitchClassName?: string - isMarketplacePlatform?: boolean - marketplaceNav?: React.ReactNode +type MarketplaceProps = Omit<MarketplaceViewProps, 'banners'> & { + language?: string /** * Pass the search params from the request to prefetch data on the server. */ @@ -19,31 +17,54 @@ type MarketplaceProps = { } const Marketplace = async ({ - showInstallButton = false, - linkToMarketplaceDetail = false, - pluginTypeSwitchClassName, - isMarketplacePlatform = false, - marketplaceNav, + language, searchParams, + variant = 'default', + ...viewProps }: MarketplaceProps) => { - return ( - <TanStackQueryProvider> - <HydrateQueryClient searchParams={searchParams}> - <PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}> - <Description - isMarketplacePlatform={isMarketplacePlatform} - marketplaceNav={marketplaceNav} - /> - {!isMarketplacePlatform && ( - <StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} /> - )} - <ListWrapper - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - /> - </PluginInstallPermissionProviderGuard> + let trendingBanners: PluginBanner[] = [] + + if (variant === 'home') { + const locale = language ?? (await getLocaleOnServer()) + const prefetch = prefetchMarketplaceDehydratedState(searchParams) + + // Banners are decoration on a page whose point is the catalog. Overlap + // them with the catalog prefetch so the document waits at most one budget. + // A late banner resolution just misses this render; nothing waits on it. + await withinServerBudget( + Promise.all([ + fetchPluginBanners(locale) + .then((banners) => { + trendingBanners = banners + }) + .catch(() => { + // Keep the homepage available if Marketplace banner delivery is down. + }), + prefetch, + ]), + ) + + return ( + <HydrateQueryClient searchParams={undefined} prefetchedState={await prefetch}> + <MarketplaceView + {...viewProps} + banners={trendingBanners} + language={language} + variant={variant} + /> </HydrateQueryClient> - </TanStackQueryProvider> + ) + } + + return ( + <HydrateQueryClient searchParams={searchParams}> + <MarketplaceView + {...viewProps} + banners={trendingBanners} + language={language} + variant={variant} + /> + </HydrateQueryClient> ) } diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx index d61071aadc7..5511b439f00 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { ThemeProvider } from 'next-themes' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { trackMarketplaceSiteCardClick } from '@/utils/marketplace-site-track' import CardWrapper from '../card-wrapper' vi.mock('@/app/components/plugins/hooks', () => ({ @@ -45,10 +46,35 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permis useOptionalPluginInstallPermission: () => ({ canInstallPlugin: true }), })) +vi.mock('../../detail-dialog', () => ({ + default: ({ + isInstalled, + open, + onOpenChange, + }: { + isInstalled: boolean + open: boolean + onOpenChange: (open: boolean) => void + }) => + open ? ( + <div role="dialog" aria-label="marketplace detail" data-installed={isInstalled}> + <button type="button" onClick={() => onOpenChange(false)}> + close detail + </button> + </div> + ) : null, +})) + vi.mock('../../utils', () => ({ getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`, - getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) => - `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + trackMarketplaceSiteCardClick: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', })) const plugin = { @@ -91,13 +117,52 @@ describe('CardWrapper', () => { renderCardWrapper() expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(document.querySelector('[data-marketplace-card="plugin-a"]')).toBeInTheDocument() expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent') }) + it('opens marketplace detail from the card surface', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.click(screen.getByRole('button', { name: 'Plugin A' })) + + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument() + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) + + it('opens marketplace detail from the keyboard', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.tab() + expect(screen.getByRole('button', { name: 'Plugin A' })).toHaveFocus() + await user.keyboard('{Enter}') + + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument() + }) + + it('keeps install as its own action when the card is clicked through the install button', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })) + + expect(screen.getByTestId('install-modal')).toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: 'marketplace detail' })).not.toBeInTheDocument() + }) + it('links the card to its marketplace detail when explicitly enabled', () => { renderCardWrapper({ linkToMarketplaceDetail: true }) expect(screen.getByRole('link')).toHaveAttribute('href', '/detail/dify/plugin-a') + fireEvent.click(screen.getByRole('link')) + expect(trackMarketplaceSiteCardClick).toHaveBeenCalledWith({ + itemId: 'dify/plugin-a', + itemType: 'plugin', + itemName: 'Plugin A', + section: 'list', + }) }) it('renders install and marketplace detail actions when install button is shown', () => { @@ -107,7 +172,7 @@ describe('CardWrapper', () => { screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }), ).toBeInTheDocument() expect( - screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' }), + screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' }), ).toBeInTheDocument() }) @@ -122,13 +187,18 @@ describe('CardWrapper', () => { expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() }) - it('links the detail action to the marketplace', () => { - renderCardWrapper({ showInstallButton: true }) + it('opens and closes marketplace detail dialog from the detail action', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true, isInstalled: true }) - const link = screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' }) - expect(link).toHaveAttribute('href', '/marketplace/dify/plugin-a?language=en-US&theme=system') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' })) + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toHaveAttribute( + 'data-installed', + 'true', + ) + + await user.click(screen.getByRole('button', { name: 'close detail' })) + expect(screen.queryByRole('dialog', { name: 'marketplace detail' })).not.toBeInTheDocument() }) it('opens and closes install modal from install action', () => { @@ -140,4 +210,14 @@ describe('CardWrapper', () => { fireEvent.click(screen.getByTestId('close-install-modal')) expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() }) + + it('does not open the install confirmation modal from the marketplace detail dialog', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true }) + + await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.detail' })) + + expect(screen.getByRole('dialog', { name: 'marketplace detail' })).toBeInTheDocument() + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx new file mode 100644 index 00000000000..1b884005871 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx @@ -0,0 +1,371 @@ +import type { CarouselPage } from '../carousel' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Carousel from '../carousel' + +const mocks = vi.hoisted(() => { + const listeners = new Map<string, Set<() => void>>() + const carouselState = { + scrollSnaps: [0, 1, 2, 3, 4], + selectedIndex: 0, + } + const api = { + off: vi.fn((event: string, listener: () => void) => { + listeners.get(event)?.delete(listener) + }), + on: vi.fn((event: string, listener: () => void) => { + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + }), + scrollNext: vi.fn(), + scrollPrev: vi.fn(), + scrollSnapList: vi.fn(() => carouselState.scrollSnaps), + scrollTo: vi.fn(), + selectedScrollSnap: vi.fn(() => carouselState.selectedIndex), + } + const autoplayInstances: { play: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }[] = [] + const autoplayOptions: Record<string, unknown>[] = [] + + return { + api, + autoplayInstances, + autoplayOptions, + carouselState, + emit: (event: string) => listeners.get(event)?.forEach((listener) => listener()), + listeners, + } +}) + +vi.mock('embla-carousel-react', () => ({ + default: () => [vi.fn(), mocks.api], +})) + +vi.mock('embla-carousel-autoplay', () => ({ + default: (options: Record<string, unknown>) => { + const instance = { play: vi.fn(), stop: vi.fn() } + mocks.autoplayOptions.push(options) + mocks.autoplayInstances.push(instance) + return instance + }, +})) + +const pages: CarouselPage[] = Array.from({ length: 5 }, (_, index) => ({ + id: `page-${index + 1}`, + content: <div>Page content {index + 1}</div>, +})) + +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType<typeof vi.fn> + observe: ReturnType<typeof vi.fn> + options?: IntersectionObserverInit +} + +const intersectionObservers: IntersectionObserverRecord[] = [] + +class MockIntersectionObserver { + callback: IntersectionObserverCallback + disconnect = vi.fn() + observe = vi.fn() + options?: IntersectionObserverInit + root: Element | Document | null + rootMargin: string + takeRecords = vi.fn(() => []) + thresholds: readonly number[] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback + this.options = options + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + intersectionObservers.push(this) + } +} + +const installIntersectionObserver = () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) +} + +const triggerIntersection = (record: IntersectionObserverRecord, intersectionRatio: number) => { + act(() => { + record.callback( + [ + { + intersectionRatio, + isIntersecting: intersectionRatio > 0, + } as IntersectionObserverEntry, + ], + record as unknown as IntersectionObserver, + ) + }) +} + +describe('Marketplace Carousel', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listeners.clear() + mocks.autoplayInstances.length = 0 + mocks.autoplayOptions.length = 0 + mocks.carouselState.scrollSnaps = [0, 1, 2, 3, 4] + mocks.carouselState.selectedIndex = 0 + intersectionObservers.length = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps every slide shell while mounting only the current and adjacent pages', () => { + const { rerender } = render(<Carousel pages={pages} deferMountPages />) + + expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(5) + expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(3) + expect(screen.getByText('Page content 1')).toBeInTheDocument() + expect(screen.getByText('Page content 2')).toBeInTheDocument() + expect(screen.getByText('Page content 5')).toBeInTheDocument() + expect(screen.queryByText('Page content 3')).not.toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.goToPage:{"page":4}' }), + ) + + expect(screen.getByText('Page content 3')).toBeInTheDocument() + expect(screen.getByText('Page content 4')).toBeInTheDocument() + expect(mocks.api.scrollTo).toHaveBeenCalledWith(3) + + mocks.carouselState.selectedIndex = 3 + act(() => mocks.emit('select')) + mocks.carouselState.selectedIndex = 0 + act(() => mocks.emit('select')) + + expect(screen.getByText('Page content 4')).toBeInTheDocument() + + rerender(<Carousel pages={pages.slice(0, 3)} deferMountPages />) + + expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(3) + expect(screen.getByText('Page content 3')).toBeInTheDocument() + }) + + it('keeps eager consumers fully mounted and preserves loop navigation', () => { + render(<Carousel pages={pages} />) + + expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(5) + + fireEvent.click( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollPrevious' }), + ) + fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' })) + + expect(mocks.api.scrollPrev).toHaveBeenCalledOnce() + expect(mocks.api.scrollNext).toHaveBeenCalledOnce() + }) + + it('plays managed autoplay only while the carousel is visible and motion is allowed', () => { + installIntersectionObserver() + const marketplaceContainer = document.createElement('div') + marketplaceContainer.id = 'marketplace-container' + document.body.appendChild(marketplaceContainer) + let reducedMotion = false + let reducedMotionListener: (() => void) | undefined + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return reducedMotion + }, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_event: string, listener: () => void) => { + reducedMotionListener = listener + }, + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + const { unmount } = render( + <Carousel pages={pages} autoPlay deferMountPages pauseWhenOffscreen />, + { container: marketplaceContainer }, + ) + const autoplay = mocks.autoplayInstances[0]! + const carousel = screen.getByRole('region') + + expect(mocks.autoplayOptions[0]).toMatchObject({ + playOnInit: false, + stopOnInteraction: false, + stopOnMouseEnter: false, + }) + expect(intersectionObservers[0]!.options).toEqual({ + root: marketplaceContainer, + threshold: 0.25, + }) + expect(autoplay.stop).toHaveBeenCalled() + + triggerIntersection(intersectionObservers[0]!, 0.24) + triggerIntersection(intersectionObservers[0]!, 0.25) + expect(autoplay.play).toHaveBeenCalledOnce() + + fireEvent.mouseEnter(carousel) + expect(autoplay.stop).toHaveBeenCalled() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + fireEvent(document, new Event('visibilitychange')) + fireEvent.mouseLeave(carousel) + expect(autoplay.play).toHaveBeenCalledOnce() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + fireEvent(document, new Event('visibilitychange')) + expect(autoplay.play).toHaveBeenCalledTimes(2) + + reducedMotion = true + act(() => reducedMotionListener?.()) + expect(autoplay.stop).toHaveBeenCalled() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + fireEvent(document, new Event('visibilitychange')) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + fireEvent(document, new Event('visibilitychange')) + expect(autoplay.play).toHaveBeenCalledTimes(2) + + reducedMotion = false + act(() => reducedMotionListener?.()) + expect(autoplay.play).toHaveBeenCalledTimes(3) + + triggerIntersection(intersectionObservers[0]!, 0) + expect(autoplay.stop).toHaveBeenCalled() + + unmount() + marketplaceContainer.remove() + }) + + it('preserves standalone autoplay initialization', () => { + render(<Carousel pages={pages} autoPlay />) + + expect(mocks.autoplayOptions[0]).toMatchObject({ + playOnInit: true, + stopOnMouseEnter: true, + }) + expect(intersectionObservers).toHaveLength(0) + }) + + it('honors reduced motion for the eagerly playing first-collection carousel', () => { + let reducedMotion = true + let reducedMotionListener: (() => void) | undefined + vi.stubGlobal('matchMedia', () => ({ + get matches() { + return reducedMotion + }, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_event: string, listener: () => void) => { + reducedMotionListener = listener + }, + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + // The production first collection renders without pauseWhenOffscreen, so + // the reduced-motion guard must work outside the viewport-managed path. + render(<Carousel pages={pages} autoPlay />) + const autoplay = mocks.autoplayInstances[0]! + + expect(autoplay.stop).toHaveBeenCalled() + expect(autoplay.play).not.toHaveBeenCalled() + + reducedMotion = false + act(() => reducedMotionListener?.()) + + expect(autoplay.play).toHaveBeenCalled() + }) + + it('keeps off-screen pages out of the tab order and accessibility tree', () => { + render(<Carousel pages={pages} ariaLabel="Featured tools" />) + + expect(screen.getByRole('region', { name: 'Featured tools' })).toBeInTheDocument() + + const slides = document.querySelectorAll('[data-carousel-page]') + expect(slides[0]).toHaveAttribute('aria-roledescription', 'slide') + expect(slides[0]).toHaveAttribute('aria-label', '1 / 5') + expect(slides[0]).not.toHaveAttribute('aria-hidden', 'true') + expect(slides[0]).not.toHaveAttribute('inert') + expect(slides[1]).toHaveAttribute('aria-hidden', 'true') + expect(slides[1]).toHaveAttribute('inert') + + mocks.carouselState.selectedIndex = 3 + act(() => mocks.emit('select')) + + expect(slides[0]).toHaveAttribute('aria-hidden', 'true') + expect(slides[0]).toHaveAttribute('inert') + expect(slides[3]).not.toHaveAttribute('aria-hidden', 'true') + expect(slides[3]).not.toHaveAttribute('inert') + }) + + it('stops rotation for the rest of the session once focus enters', () => { + render(<Carousel pages={pages} autoPlay />) + const autoplay = mocks.autoplayInstances[0]! + const carousel = screen.getByRole('region') + + // The controls expose only the pagination dots and the two nav arrows. + expect(screen.getAllByRole('button')).toHaveLength(7) + + const playsBeforeFocus = autoplay.play.mock.calls.length + fireEvent.focusIn(carousel) + + expect(autoplay.stop).toHaveBeenCalled() + expect(autoplay.play).toHaveBeenCalledTimes(playsBeforeFocus) + + // Moving focus around does not resume rotation on its own. + fireEvent.focusIn(carousel) + expect(autoplay.play).toHaveBeenCalledTimes(playsBeforeFocus) + }) + + it('does not start managed autoplay when the carousel has only one page', () => { + installIntersectionObserver() + mocks.carouselState.scrollSnaps = [0] + + render(<Carousel pages={pages.slice(0, 1)} autoPlay deferMountPages pauseWhenOffscreen />) + const autoplay = mocks.autoplayInstances[0]! + + triggerIntersection(intersectionObservers[0]!, 1) + + expect(autoplay.play).not.toHaveBeenCalled() + }) + + // The autoplay plugin skips its own setup on single-page carousels, so an + // external play() call would crash inside the plugin (undefined delay list). + it('does not start eager autoplay when the carousel has only one page', () => { + mocks.carouselState.scrollSnaps = [0] + + render(<Carousel pages={pages.slice(0, 1)} autoPlay />) + const autoplay = mocks.autoplayInstances[0]! + + expect(autoplay.play).not.toHaveBeenCalled() + expect(autoplay.stop).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx new file mode 100644 index 00000000000..c939c9ea73a --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection-layout.browser.spec.tsx @@ -0,0 +1,204 @@ +import type { MarketplaceCollection } from '@dify/contracts/marketplace' +import type { Plugin } from '@/app/components/plugins/types' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import ListWithCollection from '../list-with-collection' + +const mockState = vi.hoisted(() => ({ + becomePartnerText: 'Become a Partner', +})) + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + const translations: Record<string, string> = { + 'marketplace.carousel.scrollPrevious': 'Previous', + } + + return { + useLocale: () => 'en-US', + useTranslation: () => ({ + t: withSelectorKey((key: string) => + key === 'marketplace.becomePartner' + ? mockState.becomePartnerText + : (translations[key] ?? key), + ), + }), + } +}) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: (locale: string) => locale, +})) + +vi.mock('../../atoms', () => ({ + useMarketplaceMoreClick: () => vi.fn(), +})) + +vi.mock('../card-wrapper', () => ({ + default: ({ plugin }: { plugin: Plugin }) => <div>{plugin.name}</div>, +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + trackMarketplaceSiteEvent: vi.fn(), +})) + +const partnerCollection: MarketplaceCollection = { + name: 'partners', + label: { 'en-US': 'Partners' }, + description: { 'en-US': 'Plugins verified by Dify partners.' }, + rule: 'partners', + created_at: '', + updated_at: '', + searchable: false, + search_params: {}, +} + +const partnerPlugins = Array.from({ length: 9 }, (_, index) => ({ + plugin_id: `partner-${index}`, + name: `Partner plugin ${index}`, +})) as Plugin[] + +const renderPartnerCollection = ({ + pluginCount = 9, + standalone = true, + width = 350, +}: { + pluginCount?: number + standalone?: boolean + width?: number +} = {}) => + render( + <div + data-testid="collection-shell" + data-marketplace-standalone={standalone || undefined} + style={{ width }} + > + <ListWithCollection + marketplaceCollections={[partnerCollection]} + marketplaceCollectionPluginsMap={{ partners: partnerPlugins.slice(0, pluginCount) }} + /> + </div>, + ) + +const getTextRect = (element: Element) => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect() +} + +describe('Partner collection header layout', () => { + beforeEach(() => { + mockState.becomePartnerText = 'Become a Partner' + }) + + it('keeps the mobile call to action beside the title and clear of carousel controls', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection() + + const title = screen.getByText('Partners', { exact: true }).element() + const description = screen.getByText('Plugins verified by Dify partners.').element() + const separator = screen.getByText('|').element() + const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element() + const previousButton = screen.getByRole('button', { name: 'Previous' }).element() + + const titleRect = getTextRect(title) + const descriptionRect = description.getBoundingClientRect() + const partnerLinkRect = partnerLink.getBoundingClientRect() + const previousButtonRect = previousButton.getBoundingClientRect() + + const titleCenter = titleRect.top + titleRect.height / 2 + const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2 + + expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2) + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8) + expect(descriptionRect.top).toBeGreaterThanOrEqual( + Math.max(titleRect.bottom, partnerLinkRect.bottom), + ) + expect(getComputedStyle(separator).display).toBe('none') + }) + + it('keeps the mobile action 12px from the title when navigation is absent', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection({ pluginCount: 2 }) + + const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect() + const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element()) + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right) + expect(screen.getByRole('button', { name: 'Previous' }).query()).toBeNull() + }) + + it('keeps the mobile action clear of navigation at a 320px viewport', async () => { + await page.viewport(320, 844) + mockState.becomePartnerText = 'Torne-se um parceiro' + const screen = await renderPartnerCollection({ width: 280 }) + + const titleRect = getTextRect(screen.getByText('Partners', { exact: true }).element()) + const partnerLinkRect = screen + .getByRole('link', { name: 'Torne-se um parceiro' }) + .element() + .getBoundingClientRect() + const previousButtonRect = screen + .getByRole('button', { name: 'Previous' }) + .element() + .getBoundingClientRect() + + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8) + }) + + it('preserves the narrow embedded metadata row', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection({ standalone: false }) + + const shellRect = screen.getByTestId('collection-shell').element().getBoundingClientRect() + const descriptionRect = screen + .getByText('Plugins verified by Dify partners.') + .element() + .getBoundingClientRect() + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2) + expect(partnerLinkRect.right).toBeLessThanOrEqual(shellRect.right) + expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none') + }) + + it('preserves the desktop title and metadata rows', async () => { + await page.viewport(1280, 900) + const screen = await render( + <div className="w-[1200px]" data-marketplace-standalone> + <ListWithCollection + marketplaceCollections={[partnerCollection]} + marketplaceCollectionPluginsMap={{ partners: partnerPlugins }} + /> + </div>, + ) + + const titleRect = screen + .getByText('Partners', { exact: true }) + .element() + .getBoundingClientRect() + const descriptionRect = screen + .getByText('Plugins verified by Dify partners.') + .element() + .getBoundingClientRect() + const separator = screen.getByText('|').element() + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom) + expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2) + expect(getComputedStyle(separator).display).not.toBe('none') + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx index 02df8e88684..6cd03ac2bb7 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx @@ -1,7 +1,7 @@ import type { MarketplaceCollection } from '@dify/contracts/marketplace' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import ListWithCollection from '../list-with-collection' const mockMoreClick = vi.fn() @@ -48,9 +48,80 @@ const pluginsMap: Record<string, Plugin[]> = { empty: [], } +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType<typeof vi.fn> + observe: ReturnType<typeof vi.fn> + options?: IntersectionObserverInit +} + +const intersectionObservers: IntersectionObserverRecord[] = [] + +class MockIntersectionObserver { + callback: IntersectionObserverCallback + disconnect = vi.fn() + observe = vi.fn() + options?: IntersectionObserverInit + root: Element | Document | null + rootMargin: string + takeRecords = vi.fn(() => []) + thresholds: readonly number[] + unobserve = vi.fn() + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback + this.options = options + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + intersectionObservers.push(this) + } +} + +const installIntersectionObserver = () => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) +} + +const triggerIntersection = ( + observer: IntersectionObserverRecord, + { intersectionRatio, isIntersecting }: { intersectionRatio: number; isIntersecting: boolean }, +) => { + act(() => { + observer.callback( + [{ intersectionRatio, isIntersecting } as IntersectionObserverEntry], + observer as unknown as IntersectionObserver, + ) + }) +} + +const buildPerformanceFixture = () => { + const pluginCounts = [61, 8, 8, 8, 8, 8, 8] + const fixtureCollections = pluginCounts.map((_, collectionIndex) => ({ + ...collections[0]!, + name: `collection-${collectionIndex}`, + label: { 'en-US': `Collection ${collectionIndex}` }, + description: { 'en-US': `Description ${collectionIndex}` }, + })) as MarketplaceCollection[] + const fixturePluginsMap = Object.fromEntries( + pluginCounts.map((pluginCount, collectionIndex) => [ + `collection-${collectionIndex}`, + Array.from({ length: pluginCount }, (_, pluginIndex) => ({ + plugin_id: `collection-${collectionIndex}-plugin-${pluginIndex}`, + name: `Collection ${collectionIndex} Plugin ${pluginIndex}`, + })) as Plugin[], + ]), + ) + + return { fixtureCollections, fixturePluginsMap } +} + describe('ListWithCollection', () => { beforeEach(() => { vi.clearAllMocks() + intersectionObservers.length = 0 + installIntersectionObserver() Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, @@ -58,6 +129,10 @@ describe('ListWithCollection', () => { }) }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('renders only collections that contain plugins', () => { render( <ListWithCollection @@ -201,7 +276,9 @@ describe('ListWithCollection', () => { ) expect(screen.queryByText('plugin.marketplace.viewMore')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Scroll right' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }), + ).toBeInTheDocument() const carousel = screen.getByRole('region', { name: 'Featured' }) const carouselViewport = carousel.querySelector('.overflow-hidden') const carouselContent = carouselViewport?.firstElementChild @@ -209,4 +286,94 @@ describe('ListWithCollection', () => { expect(carouselViewport).toHaveClass('overflow-hidden', 'rounded-[inherit]') expect(carouselContent).toHaveStyle({ columnGap: '12px' }) }) + + it('keeps the first collection eager and defers the rest until they enter the preload range', () => { + const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture() + const marketplaceContainer = document.createElement('div') + marketplaceContainer.id = 'marketplace-container' + document.body.appendChild(marketplaceContainer) + + const { unmount } = render( + <ListWithCollection + marketplaceCollections={fixtureCollections} + marketplaceCollectionPluginsMap={fixturePluginsMap} + deferOffscreenCollections + />, + { container: marketplaceContainer }, + ) + + expect(screen.getAllByText(/Collection \d$/)).toHaveLength(7) + expect(document.querySelectorAll('[data-marketplace-collection]')).toHaveLength(7) + // The first (above-the-fold) collection renders its real cards immediately + // so server-rendered HTML contains first-screen content; the six remaining + // collections keep placeholders until they intersect. + expect( + document.querySelectorAll('[data-marketplace-collection-placeholder] > div'), + ).toHaveLength(48) + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(61) + expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(8) + expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(8) + // Partner collections autoplay; this fixture is non-partner, so the only + // observers here are the collection preload observers. + const collectionObservers = intersectionObservers.filter( + (observer) => observer.options?.rootMargin === '320px 0px', + ) + expect(collectionObservers).toHaveLength(6) + expect(collectionObservers[0]!.options).toEqual({ + root: marketplaceContainer, + rootMargin: '320px 0px', + threshold: 0.01, + }) + + triggerIntersection(collectionObservers[0]!, { + intersectionRatio: 0.01, + isIntersecting: true, + }) + + expect(collectionObservers[0]!.disconnect).toHaveBeenCalled() + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(69) + + triggerIntersection(collectionObservers[0]!, { + intersectionRatio: 0, + isIntersecting: false, + }) + + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(69) + + unmount() + marketplaceContainer.remove() + }) + + it('mounts deferred collections after hydration when IntersectionObserver is unavailable', () => { + vi.stubGlobal('IntersectionObserver', undefined) + + render( + <ListWithCollection + marketplaceCollections={collections} + marketplaceCollectionPluginsMap={pluginsMap} + deferOffscreenCollections + />, + ) + + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2) + expect( + document.querySelector('[data-marketplace-collection-placeholder]'), + ).not.toBeInTheDocument() + }) + + it('keeps standalone collections eager for SSR-compatible rendering', () => { + const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture() + + render( + <ListWithCollection + marketplaceCollections={fixtureCollections} + marketplaceCollectionPluginsMap={fixturePluginsMap} + />, + ) + + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(109) + expect( + intersectionObservers.some((observer) => observer.options?.rootMargin === '320px 0px'), + ).toBe(false) + }) }) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx new file mode 100644 index 00000000000..0714b7cd62e --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper-scroll.browser.spec.tsx @@ -0,0 +1,101 @@ +import type { MarketplaceCollection } from '@dify/contracts/marketplace' +import type { Plugin } from '@/app/components/plugins/types' +import { useState } from 'react' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import ListWrapper from '../list-wrapper' + +const mockMarketplaceData = vi.hoisted(() => ({ + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>, + isLoading: false, + isRefreshing: false, + isError: false, + refetch: vi.fn(), + isFetchingNextPage: false, + page: 1, +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (_selector: unknown, options?: Record<string, unknown>) => + `${options?.num ?? 0} plugins found`, + }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () => <div>loading</div>, +})) + +vi.mock('../../sort-dropdown', () => ({ + default: () => <div>sort</div>, +})) + +vi.mock('../index', () => ({ + default: () => ( + <div data-testid="catalog-results" style={{ height: 900, paddingTop: 80 }}> + <span>Catalog result anchor</span> + </div> + ), +})) + +vi.mock('../../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('../../atoms', () => ({ + useSearchPluginText: () => [''], +})) + +function SearchResultsHarness() { + const [searchVersion, setSearchVersion] = useState(0) + + return ( + <div + data-search-version={searchVersion} + data-testid="marketplace-scroll-container" + style={{ height: 320, overflowY: 'auto' }} + > + <button + type="button" + style={{ position: 'sticky', top: 0, zIndex: 1 }} + onClick={() => { + mockMarketplaceData.plugins = [{ plugin_id: 'plugin-1', name: 'Search result' } as Plugin] + mockMarketplaceData.pluginsTotal = 1 + setSearchVersion((version) => version + 1) + }} + > + Type search + </button> + <div aria-hidden style={{ height: 220 }} /> + <ListWrapper /> + </div> + ) +} + +describe('Marketplace result scroll anchoring', () => { + beforeEach(() => { + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + }) + + // Scroll anchoring is owned by Chromium's layout engine and cannot be + // represented faithfully by the happy-dom unit project. + it('does not move the page when the first search result header appears', async () => { + await page.viewport(1280, 720) + const screen = await render(<SearchResultsHarness />) + const scrollContainer = screen.getByTestId('marketplace-scroll-container').element() + + scrollContainer.scrollTop = 260 + await new Promise(requestAnimationFrame) + const scrollTopBefore = scrollContainer.scrollTop + + await screen.getByRole('button', { name: 'Type search' }).click() + await expect.element(screen.getByText('1 plugins found')).toBeVisible() + await new Promise(requestAnimationFrame) + + expect(scrollContainer.scrollTop).toBe(scrollTopBefore) + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx index 3b882a804d2..b011e742797 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx @@ -1,7 +1,10 @@ import type { MarketplaceCollection } from '@dify/contracts/marketplace' +import type { ReactNode } from 'react' import type { Plugin } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' import ListWrapper from '../list-wrapper' const mockMarketplaceData = vi.hoisted(() => ({ @@ -10,6 +13,9 @@ const mockMarketplaceData = vi.hoisted(() => ({ marketplaceCollections: [] as MarketplaceCollection[], marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>, isLoading: false, + isRefreshing: false, + isError: false, + refetch: vi.fn(), isFetchingNextPage: false, page: 1, })) @@ -18,21 +24,14 @@ vi.mock('#i18n', async () => { const { withSelectorKey } = await import('@/test/i18n-mock') return { useTranslation: () => ({ - t: withSelectorKey((key: string, options?: { ns?: string; num?: number }) => - key === 'marketplace.pluginsResult' && options?.ns === 'plugin' - ? `${options.num} plugins found` - : options?.ns - ? `${options.ns}.${key}` - : key, - ), + t: withSelectorKey((key: string, options?: Record<string, unknown>) => { + if (key === 'marketplace.pluginsResult') return `${options?.num} plugins found` + return key + }), }), } }) -vi.mock('../../state', () => ({ - useMarketplaceData: () => mockMarketplaceData, -})) - vi.mock('@/app/components/base/loading', () => ({ default: ({ className }: { className?: string }) => ( <div data-testid="loading" className={className}> @@ -51,6 +50,17 @@ vi.mock('../index', () => ({ ), })) +vi.mock('../../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +// ListWrapper reads the raw `q` through nuqs for its analytics flush, so the +// tree needs an adapter even though the data hook itself is mocked. +const renderListWrapper = (ui: ReactNode) => { + const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams: '' }) + return render(<NuqsWrapper>{ui}</NuqsWrapper>) +} + describe('ListWrapper', () => { beforeEach(() => { vi.clearAllMocks() @@ -59,6 +69,8 @@ describe('ListWrapper', () => { mockMarketplaceData.marketplaceCollections = [] mockMarketplaceData.marketplaceCollectionPluginsMap = {} mockMarketplaceData.isLoading = false + mockMarketplaceData.isRefreshing = false + mockMarketplaceData.isError = false mockMarketplaceData.isFetchingNextPage = false mockMarketplaceData.page = 1 }) @@ -67,20 +79,35 @@ describe('ListWrapper', () => { mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] mockMarketplaceData.pluginsTotal = 1 - render(<ListWrapper />) + renderListWrapper(<ListWrapper />) expect(screen.getByText('1 plugins found')).toBeInTheDocument() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() }) - it('shows centered loading only on initial loading page', () => { + it('shows centered loading on a cold start', () => { mockMarketplaceData.isLoading = true mockMarketplaceData.page = 1 - render(<ListWrapper />) + renderListWrapper(<ListWrapper />) expect(screen.getByTestId('loading')).toBeInTheDocument() - expect(screen.queryByTestId('list')).not.toBeInTheDocument() + }) + + // The reported "jitter": every debounced keystroke used to unmount the grid + // behind a centre-absolute spinner, collapsing the container height and + // jumping the scroll position. + it('keeps the result grid mounted while a superseded query is in flight', () => { + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + mockMarketplaceData.pluginsTotal = 1 + mockMarketplaceData.isRefreshing = true + + renderListWrapper(<ListWrapper />) + + const list = screen.getByTestId('list') + expect(list).toBeInTheDocument() + expect(list.parentElement).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() }) it('renders list when loading additional pages', () => { @@ -88,7 +115,7 @@ describe('ListWrapper', () => { mockMarketplaceData.page = 2 mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] - render(<ListWrapper showInstallButton />) + renderListWrapper(<ListWrapper showInstallButton />) expect(screen.getByTestId('list')).toBeInTheDocument() }) @@ -97,8 +124,34 @@ describe('ListWrapper', () => { mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] mockMarketplaceData.isFetchingNextPage = true - render(<ListWrapper />) + renderListWrapper(<ListWrapper />) expect(screen.getAllByTestId('loading')).toHaveLength(1) }) + + it('keeps the supplied layout constraint while category results are loading', () => { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 + + const { container } = renderListWrapper(<ListWrapper className="catalog-content-min-height" />) + + expect(container.firstElementChild).toHaveClass('catalog-content-min-height') + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + // A failed search used to arrive as a successful empty page and render as + // "no plugins found", with nothing to retry. + it('offers a retry when the search failed with nothing to show', async () => { + const user = userEvent.setup() + mockMarketplaceData.isError = true + mockMarketplaceData.plugins = [] + + renderListWrapper(<ListWrapper />) + + expect(screen.queryByTestId('list')).not.toBeInTheDocument() + expect(screen.getByText('marketplace.loadError')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'operation.retry' })) + expect(mockMarketplaceData.refetch).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 0e3fcd0c186..d090a11e368 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,133 +1,148 @@ 'use client' import type { Plugin } from '@/app/components/plugins/types' -import { Button, buttonVariants } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' +import { Button } from '@langgenius/dify-ui/button' import { useBoolean } from 'ahooks' -import { useTheme } from 'next-themes' import * as React from 'react' import { useMemo } from 'react' -import { useLocale, useTranslation } from '#i18n' +import { useTranslation } from '#i18n' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import { useTags } from '@/app/components/plugins/hooks' import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' +import { useGetLanguage } from '@/context/i18n' +import { renderI18nObject } from '@/i18n-config' import Link from '@/next/link' -import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils' +import { trackMarketplaceSiteCardClick } from '@/utils/marketplace-site-track' +import MarketplaceDetailDialog from '../detail-dialog' +import { getPluginDetailLinkInMarketplace } from '../utils' type CardWrapperProps = { plugin: Plugin showInstallButton?: boolean isInstalled?: boolean linkToMarketplaceDetail?: boolean + section?: string } const CardWrapperComponent = ({ plugin, showInstallButton, isInstalled = false, linkToMarketplaceDetail = false, + section = 'list', }: CardWrapperProps) => { const { t } = useTranslation() - const { theme } = useTheme() + const locale = useGetLanguage() const [ isShowInstallFromMarketplace, { setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace }, ] = useBoolean(false) + const [ + isShowMarketplaceDetail, + { setTrue: showMarketplaceDetail, setFalse: hideMarketplaceDetail }, + ] = useBoolean(false) const { canInstallPlugin } = useOptionalPluginInstallPermission() - const locale = useLocale() const { getTagLabel } = useTags() - - // Memoize marketplace link params to prevent unnecessary re-renders - const marketplaceLinkParams = useMemo( - () => ({ - language: locale, - theme, - }), - [locale, theme], - ) + const pluginLabel = renderI18nObject(plugin.label, locale) || plugin.name // Memoize tag labels to prevent recreating array on every render const tagLabels = useMemo( () => plugin.tags.map((tag) => getTagLabel(tag.name)), [plugin.tags, getTagLabel], ) + const handleMarketplaceDetailOpenChange = (open: boolean) => { + if (open) showMarketplaceDetail() + else hideMarketplaceDetail() + } const showInstallAction = !!showInstallButton && canInstallPlugin + const cardBody = ( + <Card + key={plugin.name} + payload={plugin} + variant="marketplace" + footer={ + <CardMoreInfo downloadCount={plugin.install_count} tags={tagLabels} variant="marketplace" /> + } + /> + ) + + if (linkToMarketplaceDetail) { + const itemId = `${plugin.org}/${plugin.name}` - if (showInstallAction) { return ( - <div className="group relative cursor-pointer rounded-xl"> - <Card - key={plugin.name} - payload={plugin} - variant="marketplace" - footer={ - <CardMoreInfo - downloadCount={plugin.install_count} - tags={tagLabels} - variant="marketplace" - /> - } - /> + <Link + href={getPluginDetailLinkInMarketplace(plugin)} + className="block rounded-xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" + onClick={() => { + trackMarketplaceSiteCardClick({ + itemId, + itemType: 'plugin', + itemName: pluginLabel, + section, + }) + }} + > + <div className="group relative rounded-xl" data-marketplace-card={plugin.plugin_id}> + {cardBody} + </div> + </Link> + ) + } + + return ( + <div + className="group relative cursor-pointer rounded-xl" + data-marketplace-card={plugin.plugin_id} + > + <button + type="button" + aria-label={pluginLabel} + className="absolute inset-0 z-[1] rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + onClick={showMarketplaceDetail} + /> + {cardBody} + {showInstallAction && ( <div className="pointer-events-none absolute right-[-0.5px] bottom-[-0.5px] left-[-0.5px] z-10 flex items-center gap-2 rounded-b-xl bg-linear-to-t from-components-panel-on-panel-item-bg-hover from-60% to-background-gradient-mask-transparent px-4 pt-8 pb-4 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"> <Button variant={isInstalled ? 'secondary' : 'primary'} className="min-w-0 flex-1 shadow-md" disabled={isInstalled} - onClick={isInstalled ? undefined : showInstallFromMarketplace} + onClick={(event) => { + event.stopPropagation() + if (!isInstalled) showInstallFromMarketplace() + }} > {isInstalled ? t(($) => $['task.installed'], { ns: 'plugin' }) : t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })} </Button> - <a - href={getPluginLinkInMarketplace(plugin, marketplaceLinkParams)} - target="_blank" - rel="noopener noreferrer" - className={cn(buttonVariants(), 'min-w-0 flex-1 shadow-xs backdrop-blur-[5px]')} + <Button + className="min-w-0 flex-1 shadow-xs backdrop-blur-[5px]" + onClick={(event) => { + event.stopPropagation() + showMarketplaceDetail() + }} > {t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })} - <span aria-hidden className="i-ri-arrow-right-up-line size-4" /> - </a> + </Button> </div> - {isShowInstallFromMarketplace && ( - <InstallFromMarketplace - manifest={plugin} - uniqueIdentifier={plugin.latest_package_identifier} - onClose={hideInstallFromMarketplace} - onSuccess={hideInstallFromMarketplace} - /> - )} - </div> - ) - } - - const card = ( - <div className="group relative rounded-xl"> - <Card - key={plugin.name} - payload={plugin} - variant="marketplace" - footer={ - <CardMoreInfo - downloadCount={plugin.install_count} - tags={tagLabels} - variant="marketplace" - /> - } + )} + <MarketplaceDetailDialog + isInstalled={isInstalled} + open={isShowMarketplaceDetail} + plugin={plugin} + onOpenChange={handleMarketplaceDetailOpenChange} /> + {isShowInstallFromMarketplace && ( + <InstallFromMarketplace + manifest={plugin} + uniqueIdentifier={plugin.latest_package_identifier} + onClose={hideInstallFromMarketplace} + onSuccess={hideInstallFromMarketplace} + /> + )} </div> ) - - if (!linkToMarketplaceDetail) return card - - return ( - <Link - href={getPluginDetailLinkInMarketplace(plugin)} - className="block rounded-xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" - > - {card} - </Link> - ) } // Memoize the component to prevent unnecessary re-renders when props haven't changed diff --git a/web/app/components/plugins/marketplace/list/carousel.module.css b/web/app/components/plugins/marketplace/list/carousel.module.css new file mode 100644 index 00000000000..23978e585f4 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/carousel.module.css @@ -0,0 +1,5 @@ +@media (max-width: 879px) { + :global([data-marketplace-standalone]) .pagination { + display: none; + } +} diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx index 57570f7f7d8..5d4ffc9542f 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -1,39 +1,46 @@ 'use client' /* oxlint-disable eslint-react/set-state-in-effect */ +import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import Autoplay from 'embla-carousel-autoplay' import useEmblaCarousel from 'embla-carousel-react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from '#i18n' +import { MARKETPLACE_CONTAINER_ID } from '../constants' +import styles from './carousel.module.css' +import { CAROUSEL_PAGE_CLASS } from './collection-constants' -type CarouselApi = ReturnType<typeof useEmblaCarousel>[1] +export type CarouselPage = { + id: string + content: ReactNode +} type CarouselProps = { - 'aria-labelledby': string - children: React.ReactNode + pages: CarouselPage[] + ariaLabel?: string + 'aria-labelledby'?: string className?: string showNavigation?: boolean showPagination?: boolean autoPlay?: boolean autoPlayInterval?: number + deferMountPages?: boolean + pauseWhenOffscreen?: boolean } type NavButtonProps = { - direction: 'left' | 'right' - disabled: boolean + label: string onClick: () => void iconClassName: string } -const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonProps) => ( +const NavButton = ({ label, onClick, iconClassName }: NavButtonProps) => ( <button - className={cn( - 'flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover', - disabled && 'cursor-not-allowed opacity-50 hover:bg-components-button-secondary-bg', - )} + type="button" + className="flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover" onClick={onClick} - disabled={disabled} - aria-label={`Scroll ${direction}`} + aria-label={label} > <span aria-hidden @@ -43,22 +50,23 @@ const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonPro ) type CarouselControlsProps = { - api: CarouselApi showPagination: boolean selectedIndex: number scrollNext: () => void scrollPrev: () => void scrollSnaps: number[] + scrollTo: (index: number) => void } const CarouselControls = ({ - api, showPagination, selectedIndex, scrollNext, scrollPrev, scrollSnaps, + scrollTo, }: CarouselControlsProps) => { + const { t } = useTranslation() const paginationItems = scrollSnaps.map((snap, index) => ({ id: `${snap}-${index}`, snap, @@ -70,7 +78,7 @@ const CarouselControls = ({ return ( <div className="absolute -top-10 right-0 flex items-center gap-3"> {showPagination && ( - <div className="flex items-center gap-1"> + <div className={cn(styles.pagination, 'flex items-center gap-1')}> {paginationItems.map((item, index) => ( <button key={item.id} @@ -80,22 +88,23 @@ const CarouselControls = ({ ? 'w-4 bg-components-button-primary-bg' : 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover', )} - onClick={() => api?.scrollTo(index)} - aria-label={`Go to page ${index + 1}`} + onClick={() => scrollTo(index)} + aria-label={t(($) => $['marketplace.carousel.goToPage'], { + ns: 'plugin', + page: index + 1, + })} /> ))} </div> )} <div className="flex items-center gap-1"> <NavButton - direction="left" - disabled={totalPages <= 1} + label={t(($) => $['marketplace.carousel.scrollPrevious'], { ns: 'plugin' })} onClick={scrollPrev} iconClassName="i-ri-arrow-left-s-line" /> <NavButton - direction="right" - disabled={totalPages <= 1} + label={t(($) => $['marketplace.carousel.scrollNext'], { ns: 'plugin' })} onClick={scrollNext} iconClassName="i-ri-arrow-right-s-line" /> @@ -104,45 +113,107 @@ const CarouselControls = ({ ) } +const normalizePageIndex = (index: number, pageCount: number) => + ((index % pageCount) + pageCount) % pageCount + +const getPageWindowIds = (pages: CarouselPage[], centerIndex: number) => { + if (!pages.length) return [] + + return [-1, 0, 1].map( + (offset) => pages[normalizePageIndex(centerIndex + offset, pages.length)]!.id, + ) +} + const Carousel = ({ + pages, + ariaLabel, 'aria-labelledby': ariaLabelledBy, - children, className, showNavigation = true, showPagination = true, autoPlay = false, autoPlayInterval = 5000, + deferMountPages = false, + pauseWhenOffscreen = false, }: CarouselProps) => { - const plugins = useMemo(() => { - if (!autoPlay) return [] + const carouselRootRef = useRef<HTMLDivElement>(null) + const [isFocusPaused, setIsFocusPaused] = useState(false) + // Tracked independently of pauseWhenOffscreen so every autoplay path honors + // prefers-reduced-motion, including the eagerly-playing first collection. + const [isReducedMotion, setIsReducedMotion] = useState( + () => + typeof window !== 'undefined' && + (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false), + ) + const autoplay = useMemo(() => { + if (!autoPlay) return undefined - return [ - Autoplay({ - delay: autoPlayInterval, - stopOnInteraction: false, - stopOnMouseEnter: true, - }), - ] - }, [autoPlay, autoPlayInterval]) + return Autoplay({ + delay: autoPlayInterval, + playOnInit: !pauseWhenOffscreen, + stopOnInteraction: false, + stopOnMouseEnter: !pauseWhenOffscreen, + }) + }, [autoPlay, autoPlayInterval, pauseWhenOffscreen]) + const plugins = useMemo(() => (autoplay ? [autoplay] : []), [autoplay]) const [carouselRef, api] = useEmblaCarousel( { align: 'start', containScroll: 'trimSnaps', loop: true }, plugins, ) const [selectedIndex, setSelectedIndex] = useState(0) const [scrollSnaps, setScrollSnaps] = useState<number[]>([]) + const [mountedPageIds, setMountedPageIds] = useState( + () => new Set(deferMountPages ? getPageWindowIds(pages, 0) : pages.map((page) => page.id)), + ) + + const mountPageWindow = useCallback( + (centerIndex: number) => { + if (!deferMountPages || !pages.length) return + + const pageIds = getPageWindowIds(pages, centerIndex) + setMountedPageIds((currentPageIds) => { + if (pageIds.every((pageId) => currentPageIds.has(pageId))) return currentPageIds + + return new Set([...currentPageIds, ...pageIds]) + }) + }, + [deferMountPages, pages], + ) + + const scheduleScroll = useCallback((scroll: () => void) => { + window.requestAnimationFrame(scroll) + }, []) + + const scrollTo = useCallback( + (index: number) => { + mountPageWindow(index) + scheduleScroll(() => api?.scrollTo(index)) + }, + [api, mountPageWindow, scheduleScroll], + ) const scrollPrev = useCallback(() => { - api?.scrollPrev() - }, [api]) + mountPageWindow(selectedIndex - 1) + scheduleScroll(() => api?.scrollPrev()) + }, [api, mountPageWindow, scheduleScroll, selectedIndex]) const scrollNext = useCallback(() => { - api?.scrollNext() - }, [api]) + mountPageWindow(selectedIndex + 1) + scheduleScroll(() => api?.scrollNext()) + }, [api, mountPageWindow, scheduleScroll, selectedIndex]) + + useEffect(() => { + if (!deferMountPages) return + + mountPageWindow(selectedIndex) + }, [deferMountPages, mountPageWindow, pages, selectedIndex]) useEffect(() => { if (!api) return const handleSelect = () => { - setSelectedIndex(api.selectedScrollSnap()) + const nextSelectedIndex = api.selectedScrollSnap() + setSelectedIndex(nextSelectedIndex) setScrollSnaps(api.scrollSnapList()) + mountPageWindow(nextSelectedIndex) } handleSelect() @@ -153,28 +224,168 @@ const Carousel = ({ api.off('reInit', handleSelect) api.off('select', handleSelect) } - }, [api]) + }, [api, mountPageWindow]) + + useEffect(() => { + if (!autoplay) return + + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + // Once keyboard or assistive-technology focus enters the carousel + // (including its controls), rotation stays stopped so the content no + // longer changes underneath the user. + const handleFocusIn = () => setIsFocusPaused(true) + + carouselRoot.addEventListener('focusin', handleFocusIn) + return () => carouselRoot.removeEventListener('focusin', handleFocusIn) + }, [autoplay]) + + // The viewport-managed effect below tracks reduced motion itself; this + // effect covers the eager autoplay path (pauseWhenOffscreen=false), which + // previously ignored the preference entirely. + useEffect(() => { + if (!autoPlay || pauseWhenOffscreen) return + + const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)') + if (!reducedMotionQuery) return + + const syncReducedMotion = () => setIsReducedMotion(reducedMotionQuery.matches) + + syncReducedMotion() + reducedMotionQuery.addEventListener('change', syncReducedMotion) + return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion) + }, [autoPlay, pauseWhenOffscreen]) + + useEffect(() => { + if (!autoplay || !api || pauseWhenOffscreen) return + + // Autoplay skips its own setup on single-page carousels, so play() would + // crash inside the plugin; a lone page has nothing to rotate through anyway. + if (scrollSnaps.length <= 1 || isFocusPaused || isReducedMotion) autoplay.stop() + else autoplay.play() + }, [api, autoplay, isFocusPaused, isReducedMotion, pauseWhenOffscreen, scrollSnaps]) + + useEffect(() => { + if (!pauseWhenOffscreen || !autoplay || !api) return + + const carouselRoot = carouselRootRef.current + if (!carouselRoot) return + + let isInViewport = false + let isHovered = false + let isDocumentVisible = document.visibilityState === 'visible' + const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)') + let isReducedMotion = reducedMotionQuery?.matches ?? false + + const syncAutoplay = () => { + const hasMultiplePages = api.scrollSnapList().length > 1 + + if ( + hasMultiplePages && + isInViewport && + isDocumentVisible && + !isReducedMotion && + !isHovered && + !isFocusPaused + ) + autoplay.play() + else autoplay.stop() + } + const handleVisibilityChange = () => { + isDocumentVisible = document.visibilityState === 'visible' + syncAutoplay() + } + const handleReducedMotionChange = () => { + isReducedMotion = reducedMotionQuery?.matches ?? false + syncAutoplay() + } + const handleMouseEnter = () => { + isHovered = true + syncAutoplay() + } + const handleMouseLeave = () => { + isHovered = false + syncAutoplay() + } + + const observer = + typeof IntersectionObserver === 'undefined' + ? undefined + : new IntersectionObserver( + ([entry]) => { + isInViewport = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25 + syncAutoplay() + }, + { + root: document.getElementById(MARKETPLACE_CONTAINER_ID), + threshold: 0.25, + }, + ) + + if (observer) observer.observe(carouselRoot) + else isInViewport = true + + document.addEventListener('visibilitychange', handleVisibilityChange) + reducedMotionQuery?.addEventListener('change', handleReducedMotionChange) + carouselRoot.addEventListener('mouseenter', handleMouseEnter) + carouselRoot.addEventListener('mouseleave', handleMouseLeave) + syncAutoplay() + + return () => { + observer?.disconnect() + document.removeEventListener('visibilitychange', handleVisibilityChange) + reducedMotionQuery?.removeEventListener('change', handleReducedMotionChange) + carouselRoot.removeEventListener('mouseenter', handleMouseEnter) + carouselRoot.removeEventListener('mouseleave', handleMouseLeave) + autoplay.stop() + } + }, [api, autoplay, isFocusPaused, pauseWhenOffscreen]) return ( <div + ref={carouselRootRef} className={cn('relative', className)} role="region" aria-roledescription="carousel" + aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} > {showNavigation && ( <CarouselControls - api={api} showPagination={showPagination} selectedIndex={selectedIndex} scrollNext={scrollNext} scrollPrev={scrollPrev} scrollSnaps={scrollSnaps} + scrollTo={scrollTo} /> )} <div ref={carouselRef} className="overflow-hidden rounded-[inherit]"> <div className="flex" style={{ columnGap: '12px' }}> - {children} + {pages.map((page, index) => { + const isMounted = !deferMountPages || mountedPageIds.has(page.id) + const isCurrent = index === selectedIndex + + return ( + <div + key={page.id} + role="group" + aria-roledescription="slide" + aria-label={`${index + 1} / ${pages.length}`} + // Off-screen pages stay mounted for Embla, but must not be + // reachable through the tab order or the accessibility tree. + aria-hidden={!isCurrent} + inert={!isCurrent} + className={CAROUSEL_PAGE_CLASS} + data-carousel-page={page.id} + data-carousel-page-mounted={isMounted ? 'true' : 'false'} + style={{ scrollSnapAlign: 'start' }} + > + {isMounted ? page.content : null} + </div> + ) + })} </div> </div> </div> diff --git a/web/app/components/plugins/marketplace/list/collection-constants.ts b/web/app/components/plugins/marketplace/list/collection-constants.ts index 842d9acb785..c11acb6cd0f 100644 --- a/web/app/components/plugins/marketplace/list/collection-constants.ts +++ b/web/app/components/plugins/marketplace/list/collection-constants.ts @@ -1,5 +1,15 @@ export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' +export const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk' + +// Collections whose header shows the "Become a Partner" call to action, as +// named by the Marketplace API for the plugin and template catalogs. +export const PARTNER_COLLECTION_NAMES = new Set([ + 'partners', + 'partner-template', + 'Partner Template', +]) + export const CAROUSEL_PAGE_CLASS = 'w-full shrink-0' export const CAROUSEL_PAGE_SIZE = { diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 6d6c227b56e..f6d94dddc0c 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -20,6 +20,8 @@ type ListProps = { cardRender?: (plugin: Plugin) => React.JSX.Element | null emptyClassName?: string onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void + deferOffscreenCollections?: boolean + cardSection?: string } const List = ({ marketplaceCollections, @@ -31,6 +33,8 @@ const List = ({ cardRender, emptyClassName, onCollectionMoreClick, + deferOffscreenCollections, + cardSection = 'list', }: ListProps) => { const { canInstallPlugin } = useOptionalPluginInstallPermission() const pluginIds = useMemo(() => { @@ -69,6 +73,7 @@ const List = ({ cardRender={cardRender} onCollectionMoreClick={onCollectionMoreClick} installedPluginIds={installedPluginIds} + deferOffscreenCollections={deferOffscreenCollections} /> )} {plugins && !!plugins.length && ( @@ -83,6 +88,7 @@ const List = ({ showInstallButton={showInstallButton} isInstalled={installedPluginIds.has(plugin.plugin_id)} linkToMarketplaceDetail={linkToMarketplaceDetail} + section={cardSection} /> ) })} diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 2654bf6961b..ecebf4990e2 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -3,33 +3,22 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '@dify/contracts/marketplace' import type { Plugin } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' -import { useEffect, useId, useMemo, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { useLocale, useTranslation } from '#i18n' import { getLanguage } from '@/i18n-config/language' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' import { useMarketplaceMoreClick } from '../atoms' +import { MARKETPLACE_CONTAINER_ID } from '../constants' import { buildCarouselPages } from '../utils' import CardWrapper from './card-wrapper' import Carousel from './carousel' -import { - CAROUSEL_BREAKPOINTS, - CAROUSEL_PAGE_CLASS, - CAROUSEL_PAGE_SIZE, - GRID_CLASS, -} from './collection-constants' +import { BECOME_PARTNER_URL, GRID_CLASS, PARTNER_COLLECTION_NAMES } from './collection-constants' +import styles from './partner-header.module.css' +import { useCarouselItemsPerPage } from './use-carousel-items-per-page' -const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk' -const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template']) - -const getViewportWidth = () => - typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth - -const getCarouselItemsPerPage = (viewportWidth: number) => { - if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl - if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg - if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm - - return CAROUSEL_PAGE_SIZE.base -} +const COLLECTION_PRELOAD_MARGIN = '320px 0px' +const COLLECTION_INTERSECTION_THRESHOLD = 0.01 +const MAX_PLACEHOLDER_CARDS = 8 type ListWithCollectionProps = { marketplaceCollections: MarketplaceCollection[] @@ -40,6 +29,7 @@ type ListWithCollectionProps = { cardRender?: (plugin: Plugin) => React.JSX.Element | null onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void installedPluginIds?: ReadonlySet<string> + deferOffscreenCollections?: boolean } type PluginCardProps = { @@ -48,6 +38,7 @@ type PluginCardProps = { cardRender?: (plugin: Plugin) => React.JSX.Element | null isInstalled?: boolean linkToMarketplaceDetail?: boolean + section?: string } const PluginCard = ({ @@ -56,6 +47,7 @@ const PluginCard = ({ cardRender, isInstalled, linkToMarketplaceDetail, + section, }: PluginCardProps) => { if (cardRender) return cardRender(plugin) @@ -65,10 +57,237 @@ const PluginCard = ({ showInstallButton={showInstallButton} isInstalled={isInstalled} linkToMarketplaceDetail={linkToMarketplaceDetail} + section={section} /> ) } +type CollectionSectionProps = { + collection: MarketplaceCollection + plugins: Plugin[] + itemsPerPage: number + showInstallButton?: boolean + linkToMarketplaceDetail?: boolean + cardContainerClassName?: string + cardRender?: (plugin: Plugin) => React.JSX.Element | null + onMoreClick: (searchParams?: SearchParamsFromCollection) => void + installedPluginIds?: ReadonlySet<string> + deferMount: boolean +} + +const CollectionPlaceholder = ({ + cardContainerClassName, + count, +}: { + cardContainerClassName?: string + count: number +}) => ( + <div + aria-hidden + className={cn('mt-2', GRID_CLASS, cardContainerClassName)} + data-marketplace-collection-placeholder + > + {Array.from({ length: count }, (_, index) => ( + <div + key={index} + className="h-[148px] min-w-0 rounded-xl border border-components-panel-border-subtle bg-background-default-subtle" + /> + ))} + </div> +) + +const CollectionSection = ({ + collection, + plugins, + itemsPerPage, + showInstallButton, + linkToMarketplaceDetail, + cardContainerClassName, + cardRender, + onMoreClick, + installedPluginIds, + deferMount, +}: CollectionSectionProps) => { + const { t } = useTranslation() + const locale = useLocale() + const collectionLabelId = useId() + const sectionRef = useRef<HTMLDivElement>(null) + const [isMounted, setIsMounted] = useState(!deferMount) + const pages = useMemo(() => buildCarouselPages(plugins, itemsPerPage), [itemsPerPage, plugins]) + const hasMultiplePages = pages.length > 1 + const isPartnersCollection = PARTNER_COLLECTION_NAMES.has(collection.name) + + useEffect(() => { + if (!deferMount || isMounted) return + + const section = sectionRef.current + if (!section) return + + if (typeof IntersectionObserver === 'undefined') { + // oxlint-disable-next-line eslint-react/set-state-in-effect -- This is the hydration fallback for browsers without IntersectionObserver. + setIsMounted(true) + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return + + setIsMounted(true) + observer.disconnect() + }, + { + root: document.getElementById(MARKETPLACE_CONTAINER_ID), + rootMargin: COLLECTION_PRELOAD_MARGIN, + threshold: COLLECTION_INTERSECTION_THRESHOLD, + }, + ) + + observer.observe(section) + + return () => observer.disconnect() + }, [deferMount, isMounted]) + + const carouselPages = useMemo( + () => + pages.map((pageItems, pageIndex) => ({ + id: `${collection.name}-${itemsPerPage}-${pageIndex}`, + content: ( + <div className={cn(GRID_CLASS, cardContainerClassName)}> + {pageItems.map((plugin) => ( + <div key={plugin.plugin_id} className="min-w-0 *:w-full"> + <PluginCard + plugin={plugin} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardRender={cardRender} + isInstalled={installedPluginIds?.has(plugin.plugin_id)} + section={collection.name} + /> + </div> + ))} + </div> + ), + })), + [ + cardContainerClassName, + cardRender, + collection.name, + installedPluginIds, + itemsPerPage, + linkToMarketplaceDetail, + pages, + showInstallButton, + ], + ) + + return ( + <div ref={sectionRef} className="py-3" data-marketplace-collection={collection.name}> + <div className="flex items-end justify-between"> + <div + className={cn( + isPartnersCollection && styles.partnerHeader, + isPartnersCollection && hasMultiplePages && styles.partnerHeaderWithNavigation, + )} + > + <div + id={collectionLabelId} + className={cn( + 'title-xl-semi-bold text-text-primary', + isPartnersCollection && styles.partnerTitle, + )} + > + {collection.label[getLanguage(locale)]} + </div> + <div + className={cn( + 'flex items-center gap-x-2 system-xs-regular text-text-tertiary', + isPartnersCollection && styles.partnerMetadata, + )} + > + {isPartnersCollection ? ( + <span className={styles.partnerDescription}> + {collection.description[getLanguage(locale)]} + </span> + ) : ( + collection.description[getLanguage(locale)] + )} + {isPartnersCollection && ( + <> + <span className={cn(styles.partnerSeparator, 'text-divider-regular')}>|</span> + <a + href={BECOME_PARTNER_URL} + target="_blank" + rel="noopener noreferrer" + className={cn( + styles.partnerAction, + 'flex items-center gap-x-0.5 text-text-accent hover:underline', + )} + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'Become a Partner', + }) + }} + > + <span className={styles.partnerActionLabel}> + {t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })} + </span> + <span + aria-hidden + className={cn(styles.partnerActionIcon, 'i-ri-external-link-line size-3')} + /> + </a> + </> + )} + </div> + </div> + {collection.searchable && !hasMultiplePages && ( + <button + type="button" + className="flex cursor-pointer items-center system-xs-medium text-text-accent" + onClick={() => onMoreClick(collection.search_params)} + > + {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + </button> + )} + </div> + {!isMounted ? ( + <CollectionPlaceholder + cardContainerClassName={cardContainerClassName} + count={Math.min(plugins.length, itemsPerPage, MAX_PLACEHOLDER_CARDS)} + /> + ) : hasMultiplePages ? ( + <Carousel + pages={carouselPages} + aria-labelledby={collectionLabelId} + className="mt-2" + showNavigation + showPagination + autoPlay={isPartnersCollection} + autoPlayInterval={5000} + deferMountPages={deferMount} + pauseWhenOffscreen={deferMount} + /> + ) : ( + <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> + {plugins.map((plugin) => ( + <PluginCard + key={plugin.plugin_id} + plugin={plugin} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardRender={cardRender} + isInstalled={installedPluginIds?.has(plugin.plugin_id)} + section={collection.name} + /> + ))} + </div> + )} + </div> + ) +} + const ListWithCollection = ({ marketplaceCollections, marketplaceCollectionPluginsMap, @@ -78,121 +297,33 @@ const ListWithCollection = ({ cardRender, onCollectionMoreClick, installedPluginIds, + deferOffscreenCollections = false, }: ListWithCollectionProps) => { - const { t } = useTranslation() - const locale = useLocale() - const collectionLabelPrefixId = useId() const defaultOnMoreClick = useMarketplaceMoreClick() const handleMoreClick = onCollectionMoreClick ?? defaultOnMoreClick - const [viewportWidth, setViewportWidth] = useState(getViewportWidth) - const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth]) + const itemsPerPage = useCarouselItemsPerPage() - useEffect(() => { - const handleResize = () => setViewportWidth(window.innerWidth) - - window.addEventListener('resize', handleResize) - - return () => window.removeEventListener('resize', handleResize) - }, []) - - return ( - <> - {marketplaceCollections - .filter((collection) => { - return marketplaceCollectionPluginsMap[collection.name]?.length - }) - .map((collection) => { - const plugins = marketplaceCollectionPluginsMap[collection.name]! - const pages = buildCarouselPages(plugins, itemsPerPage) - const hasMultiplePages = pages.length > 1 - const isPartnersCollection = PARTNERS_COLLECTION_NAMES.has(collection.name) - const collectionLabelId = `${collectionLabelPrefixId}-${encodeURIComponent(collection.name)}` - - return ( - <div key={collection.name} className="py-3"> - <div className="flex items-end justify-between"> - <div> - <div id={collectionLabelId} className="title-xl-semi-bold text-text-primary"> - {collection.label[getLanguage(locale)]} - </div> - <div className="flex items-center gap-x-2 system-xs-regular text-text-tertiary"> - {collection.description[getLanguage(locale)]} - {isPartnersCollection && ( - <> - <span className="text-divider-regular">|</span> - <a - href={BECOME_PARTNER_URL} - target="_blank" - rel="noopener noreferrer" - className="flex items-center gap-x-0.5 text-text-accent hover:underline" - > - <span>{t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })}</span> - <span aria-hidden className="i-ri-external-link-line size-3" /> - </a> - </> - )} - </div> - </div> - {collection.searchable && !hasMultiplePages && ( - <div - className="flex cursor-pointer items-center system-xs-medium text-text-accent" - onClick={() => handleMoreClick(collection.search_params)} - > - {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} - <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> - </div> - )} - </div> - {hasMultiplePages ? ( - <Carousel - aria-labelledby={collectionLabelId} - className="mt-2" - showNavigation - showPagination - autoPlay - autoPlayInterval={5000} - > - {pages.map((pageItems) => ( - <div - key={pageItems.map((plugin) => plugin.plugin_id).join('-')} - className={CAROUSEL_PAGE_CLASS} - style={{ scrollSnapAlign: 'start' }} - > - <div className={cn(GRID_CLASS, cardContainerClassName)}> - {pageItems.map((plugin) => ( - <div key={plugin.plugin_id} className="min-w-0 *:w-full"> - <PluginCard - plugin={plugin} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - cardRender={cardRender} - isInstalled={installedPluginIds?.has(plugin.plugin_id)} - /> - </div> - ))} - </div> - </div> - ))} - </Carousel> - ) : ( - <div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}> - {plugins.map((plugin) => ( - <PluginCard - key={plugin.plugin_id} - plugin={plugin} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - cardRender={cardRender} - isInstalled={installedPluginIds?.has(plugin.plugin_id)} - /> - ))} - </div> - )} - </div> - ) - })} - </> - ) + return marketplaceCollections + .filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length) + .map((collection, index) => ( + <CollectionSection + key={collection.name} + collection={collection} + plugins={marketplaceCollectionPluginsMap[collection.name]!} + itemsPerPage={itemsPerPage} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardContainerClassName={cardContainerClassName} + cardRender={cardRender} + onMoreClick={handleMoreClick} + installedPluginIds={installedPluginIds} + // The first collection is above-the-fold content: it must render its + // cards in the server-rendered HTML so a direct visit shows real + // content without waiting for client-side JS. Only collections below + // it defer to the IntersectionObserver. + deferMount={deferOffscreenCollections && index > 0} + /> + )) } export default ListWithCollection diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 63a1debe9db..61942e562cd 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,15 +1,34 @@ 'use client' +import type { ActivePluginType } from '../constants' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useEffect, useRef } from 'react' import { useTranslation } from '#i18n' import Loading from '@/app/components/base/loading' +import { + flushMarketplaceSiteFilter, + flushMarketplaceSiteSearch, + markMarketplaceSiteSearch, +} from '@/utils/marketplace-site-track' +import { useSearchPluginText } from '../atoms' import SortDropdown from '../sort-dropdown' import { useMarketplaceData } from '../state' import List from './index' type ListWrapperProps = { + activePluginType?: ActivePluginType + className?: string + deferOffscreenCollections?: boolean showInstallButton?: boolean linkToMarketplaceDetail?: boolean } -const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapperProps) => { +const ListWrapper = ({ + activePluginType, + className, + deferOffscreenCollections, + showInstallButton, + linkToMarketplaceDetail, +}: ListWrapperProps) => { const { t } = useTranslation() const { @@ -18,17 +37,50 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper marketplaceCollections, marketplaceCollectionPluginsMap, isLoading, + isRefreshing, + isError, + refetch, isFetchingNextPage, page, - } = useMarketplaceData() + } = useMarketplaceData(activePluginType) + const [searchPluginText] = useSearchPluginText() + const previousSearchRef = useRef(searchPluginText) + const isFirstSearchRender = useRef(true) + + useEffect(() => { + if (isFirstSearchRender.current) { + isFirstSearchRender.current = false + previousSearchRef.current = searchPluginText + return + } + + if (searchPluginText && searchPluginText !== previousSearchRef.current) + markMarketplaceSiteSearch(searchPluginText) + + previousSearchRef.current = searchPluginText + }, [searchPluginText]) + + useEffect(() => { + if (isLoading || isError || pluginsTotal === undefined) return + + flushMarketplaceSiteSearch(pluginsTotal) + flushMarketplaceSiteFilter(pluginsTotal) + }, [isLoading, isError, pluginsTotal]) return ( <div style={{ + // The first live-search response inserts the result summary above the + // existing grid. Keep Chromium from treating a card in this dynamic + // region as the scroll anchor and compensating by moving the page. + overflowAnchor: 'none', scrollbarGutter: 'stable', paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))', }} - className="relative flex grow flex-col bg-background-default-subtle px-8 py-2" + className={cn( + 'relative flex grow flex-col bg-background-default-subtle px-8 py-2', + className, + )} > <div className="flex w-full grow flex-col"> {plugins && ( @@ -40,14 +92,34 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper <SortDropdown /> </div> )} - {(!isLoading || page > 1) && ( - <List - marketplaceCollections={marketplaceCollections || []} - marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} - plugins={plugins} - showInstallButton={showInstallButton} - linkToMarketplaceDetail={linkToMarketplaceDetail} - /> + {isError && !plugins?.length ? ( + <div className="flex min-h-60 flex-col items-center justify-center gap-3 text-sm text-text-tertiary"> + <span>{t(($) => $['marketplace.loadError'], { ns: 'plugin' })}</span> + <Button size="small" variant="secondary" onClick={() => void refetch()}> + {t(($) => $['operation.retry'], { ns: 'common' })} + </Button> + </div> + ) : ( + // Rendered even while a superseded query is in flight: unmounting + // the grid collapsed the container and jumped the scroll position + // on every search keystroke. `isRefreshing` dims it instead. + <div + className={cn( + 'flex grow flex-col transition-opacity duration-150', + isRefreshing && 'opacity-60', + )} + aria-busy={isRefreshing || undefined} + > + <List + marketplaceCollections={marketplaceCollections || []} + marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} + plugins={plugins} + deferOffscreenCollections={deferOffscreenCollections} + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + cardSection={searchPluginText ? 'search' : 'list'} + /> + </div> )} </div> {isLoading && page === 1 && ( diff --git a/web/app/components/plugins/marketplace/list/partner-header.module.css b/web/app/components/plugins/marketplace/list/partner-header.module.css new file mode 100644 index 00000000000..3124c729091 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/partner-header.module.css @@ -0,0 +1,52 @@ +@media (max-width: 879px) { + :global([data-marketplace-standalone]) .partnerHeader { + box-sizing: border-box; + display: grid; + width: 100%; + grid-template-areas: + 'title action' + 'description description'; + grid-template-columns: max-content minmax(0, 1fr); + align-items: center; + column-gap: 12px; + } + + :global([data-marketplace-standalone]) .partnerHeaderWithNavigation { + padding-right: 80px; + } + + :global([data-marketplace-standalone]) .partnerTitle { + grid-area: title; + } + + :global([data-marketplace-standalone]) .partnerMetadata { + display: contents; + } + + :global([data-marketplace-standalone]) .partnerDescription { + grid-area: description; + min-width: 0; + } + + :global([data-marketplace-standalone]) .partnerSeparator { + display: none; + } + + :global([data-marketplace-standalone]) .partnerAction { + grid-area: action; + justify-self: start; + min-width: 0; + max-width: 100%; + white-space: nowrap; + } + + :global([data-marketplace-standalone]) .partnerActionLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + :global([data-marketplace-standalone]) .partnerActionIcon { + flex-shrink: 0; + } +} diff --git a/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts new file mode 100644 index 00000000000..a17b3f1bee4 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/use-carousel-items-per-page.ts @@ -0,0 +1,37 @@ +'use client' + +import { useSyncExternalStore } from 'react' +import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE } from './collection-constants' + +const subscribeToViewport = (onStoreChange: () => void) => { + globalThis.window?.addEventListener('resize', onStoreChange) + + return () => globalThis.window?.removeEventListener('resize', onStoreChange) +} + +const getViewportWidth = () => globalThis.window?.innerWidth ?? CAROUSEL_BREAKPOINTS.xl +const getServerViewportWidth = () => CAROUSEL_BREAKPOINTS.xl + +function getCarouselItemsPerPage(viewportWidth: number) { + if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl + if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg + if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm + + return CAROUSEL_PAGE_SIZE.base +} + +/** + * Viewport-derived carousel page size. useSyncExternalStore keeps the + * hydration render on the server snapshot (xl) and applies the real viewport + * in a follow-up render, so narrow viewports do not trigger a hydration + * mismatch against the server-rendered markup. + */ +export function useCarouselItemsPerPage() { + const viewportWidth = useSyncExternalStore( + subscribeToViewport, + getViewportWidth, + getServerViewportWidth, + ) + + return getCarouselItemsPerPage(viewportWidth) +} diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.module.css b/web/app/components/plugins/marketplace/plugin-type-switch.module.css new file mode 100644 index 00000000000..50f0086da79 --- /dev/null +++ b/web/app/components/plugins/marketplace/plugin-type-switch.module.css @@ -0,0 +1,25 @@ +.homeItem { + transition: + color 150ms ease, + background-color 150ms ease; +} + +.homeItem:hover { + color: var(--color-text-secondary); + background-color: var(--color-state-base-hover); +} + +.homeItemActive { + color: var(--color-saas-dify-blue-inverted); + background-color: var(--color-background-interaction-from-bg-2); +} + +.homeItemActive:hover { + background-color: var(--color-state-base-hover); +} + +@media (prefers-reduced-motion: reduce) { + .homeItem { + transition: none; + } +} diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 6c3d6f876a5..823d5542089 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,31 +1,25 @@ 'use client' import type { ActivePluginType } from './constants' import { cn } from '@langgenius/dify-ui/cn' -import { - RiArchive2Line, - RiBrain2Line, - RiDatabase2Line, - RiHammerLine, - RiPuzzle2Line, - RiSpeakAiLine, -} from '@remixicon/react' import { useSetAtom } from 'jotai' import { Fragment } from 'react' import { useTranslation } from '#i18n' -import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import PluginIcon from '@/app/components/base/icons/src/vender/plugin/Plugin' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' import { searchModeAtom, useActivePluginType } from './atoms' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import styles from './plugin-type-switch.module.css' type PluginTypeSwitchProps = { className?: string - variant?: 'default' | 'hero' + variant?: 'default' | 'hero' | 'home' } const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchProps) => { const { t } = useTranslation() const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() const setSearchMode = useSetAtom(searchModeAtom) const isHero = variant === 'hero' + const isHome = variant === 'home' const iconClassName = 'mr-1.5 size-4' const options: Array<{ @@ -38,42 +32,46 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr text: isHero ? t(($) => $['marketplace.allPlugins'], { ns: 'plugin' }) : t(($) => $['category.all'], { ns: 'plugin' }), - icon: isHero ? <PluginIcon className={iconClassName} /> : null, + icon: isHero || isHome ? <PluginIcon className={iconClassName} /> : null, }, { value: PLUGIN_TYPE_SEARCH_MAP.model, text: t(($) => $['category.models'], { ns: 'plugin' }), - icon: <RiBrain2Line className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-brain-2-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.tool, text: t(($) => $['category.tools'], { ns: 'plugin' }), - icon: <RiHammerLine className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-hammer-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.datasource, - text: t(($) => $['category.datasources'], { ns: 'plugin' }), - icon: <RiDatabase2Line className={iconClassName} />, + text: t(($) => $[isHome ? 'categorySingle.datasource' : 'category.datasources'], { + ns: 'plugin', + }), + icon: <span aria-hidden className={cn('i-ri-database-2-line', iconClassName)} />, }, { value: PLUGIN_TYPE_SEARCH_MAP.agent, - text: t(($) => $['category.agents'], { ns: 'plugin' }), - icon: <RiSpeakAiLine className={iconClassName} />, + text: t(($) => $[isHome ? 'categorySingle.agent' : 'category.agents'], { ns: 'plugin' }), + icon: ( + <span + aria-hidden + className={cn('i-custom-vender-integrations-agent-strategy', iconClassName)} + /> + ), }, { value: PLUGIN_TYPE_SEARCH_MAP.trigger, text: t(($) => $['category.triggers'], { ns: 'plugin' }), - icon: <TriggerIcon className={iconClassName} />, + icon: ( + <span aria-hidden className={cn('i-custom-vender-integrations-trigger', iconClassName)} /> + ), }, { value: PLUGIN_TYPE_SEARCH_MAP.extension, text: t(($) => $['category.extensions'], { ns: 'plugin' }), - icon: <RiPuzzle2Line className={iconClassName} />, - }, - { - value: PLUGIN_TYPE_SEARCH_MAP.bundle, - text: t(($) => $['category.bundles'], { ns: 'plugin' }), - icon: <RiArchive2Line className={iconClassName} />, + icon: <span aria-hidden className={cn('i-ri-puzzle-2-line', iconClassName)} />, }, ] @@ -82,9 +80,15 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr className={cn( isHero ? 'flex shrink-0 items-center gap-1 overflow-x-auto' - : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', + : isHome + ? 'flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto' + : 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', className, )} + role="group" + // Labels the filter group itself; "All integrations" is already the + // first option's text and would read as a duplicate. + aria-label={t(($) => $.allCategories, { ns: 'plugin' })} > {options.map((option, index) => { const isActive = activePluginType === option.value @@ -96,17 +100,31 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr aria-pressed={isActive} className={cn( 'flex h-8 cursor-pointer appearance-none items-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', - isHero ? 'text-text-primary-on-surface' : 'text-text-tertiary', + isHero + ? 'text-text-primary-on-surface' + : isHome + ? cn('min-w-12 shrink-0 justify-center text-text-tertiary', styles.homeItem) + : 'text-text-tertiary', !isActive && (isHero ? 'hover:bg-white/20' - : 'hover:bg-state-base-hover hover:text-text-secondary'), + : !isHome && 'hover:bg-state-base-hover hover:text-text-secondary'), isActive && (isHero ? 'border-white/95 bg-components-main-nav-nav-button-bg-active text-saas-dify-blue-inverted shadow-md backdrop-blur-[5px]' - : 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'), + : isHome + ? styles.homeItemActive + : 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'), )} onClick={() => { + if (option.value !== activePluginType) { + markMarketplaceSiteFilter({ + filter_type: 'type_tab', + selection_mode: 'single', + filter_value: option.value, + selected_values: [option.value], + }) + } handleActivePluginTypeChange(option.value) if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { setSearchMode(null) diff --git a/web/app/components/plugins/marketplace/prefetch-marketplace-dehydrated-state.ts b/web/app/components/plugins/marketplace/prefetch-marketplace-dehydrated-state.ts new file mode 100644 index 00000000000..8ccfad85fe3 --- /dev/null +++ b/web/app/components/plugins/marketplace/prefetch-marketplace-dehydrated-state.ts @@ -0,0 +1,50 @@ +import type { SearchParams } from 'nuqs/server' +import type { MarketplaceSearchParams } from './search-params' +import { dehydrate, noop } from '@tanstack/react-query' +import { createLoader } from 'nuqs/server' +import { getQueryClient } from '@/app/get-query-client' +import { marketplaceQuery } from '@/service/client' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { getMarketplacePluginsInfiniteQueryOptions } from './query-options' +import { + getMarketplacePluginsSearchParams, + marketplaceSearchParamsParsers, + shouldSearchMarketplacePlugins, +} from './search-params' +import { withinServerBudget } from './server-budget' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' + +export async function prefetchMarketplaceDehydratedState(searchParams?: Promise<SearchParams>) { + if (!searchParams) { + return + } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) + const params: MarketplaceSearchParams = await loadSearchParams(searchParams) + + const queryClient = getQueryClient() + + if (shouldSearchMarketplacePlugins(params)) { + await withinServerBudget( + queryClient + .infiniteQuery( + getMarketplacePluginsInfiniteQueryOptions(getMarketplacePluginsSearchParams(params)), + ) + .catch(noop), + ) + return dehydrate(queryClient) + } + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) return + + await withinServerBudget( + queryClient + .query({ + queryKey: marketplaceQuery.collections.queryKey({ + input: { query: getCollectionsParams(params.category) }, + }), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + .catch(noop), + ) + return dehydrate(queryClient) +} diff --git a/web/app/components/plugins/marketplace/query-options.ts b/web/app/components/plugins/marketplace/query-options.ts new file mode 100644 index 00000000000..c9a8f5ba8ac --- /dev/null +++ b/web/app/components/plugins/marketplace/query-options.ts @@ -0,0 +1,35 @@ +import type { PluginsSearchParams } from '@dify/contracts/marketplace' +import { infiniteQueryOptions, keepPreviousData } from '@tanstack/react-query' +import { marketplaceQuery } from '@/service/client' +import { getMarketplacePlugins } from './utils' + +export const getMarketplacePluginsInfiniteQueryOptions = ( + queryParams: PluginsSearchParams | undefined, +) => + infiniteQueryOptions({ + queryKey: marketplaceQuery.searchAdvanced.queryKey({ + input: { + body: queryParams ?? { query: '' }, + params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' }, + }, + }), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.page_size + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: queryParams !== undefined, + // Hold the previous term's results while the new query is in flight. Without + // this, `data` goes undefined on every keystroke that survives the debounce, + // the grid unmounts, the container collapses, and the scroll position jumps — + // the "jitter" the Marketplace search is reported for. Consumers show a + // quiet pending state off `isPlaceholderData` instead. + placeholderData: keepPreviousData, + // Matches the autocomplete queries. Now that the fetcher propagates + // failures, react-query's default of 3 retries would hold isFetching true + // through ~7s of backoff — indistinguishable from a hang. Failing fast and + // offering an explicit Retry is both honest and fewer requests to abort. + retry: false, + }) diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts index ff966363686..17195d20efc 100644 --- a/web/app/components/plugins/marketplace/query.ts +++ b/web/app/components/plugins/marketplace/query.ts @@ -1,32 +1,24 @@ import type { MarketPlaceInputs, PluginsSearchParams } from '@dify/contracts/marketplace' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { marketplaceQuery } from '@/service/client' -import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' +import { getMarketplacePluginsInfiniteQueryOptions } from './query-options' +import { getMarketplaceCollectionsAndPlugins } from './utils' export function useMarketplaceCollectionsAndPlugins( collectionsParams: MarketPlaceInputs['collections']['query'], + enabled = true, ) { return useQuery({ queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }), queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + enabled, + // Matches the plugins query: the shared client default of 3 retries holds + // isFetching true for ~7s of backoff, which the catalog renders as a + // spinner indistinguishable from a hang. + retry: false, }) } export function useMarketplacePlugins(queryParams: PluginsSearchParams | undefined) { - return useInfiniteQuery({ - queryKey: marketplaceQuery.searchAdvanced.queryKey({ - input: { - body: queryParams!, - params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' }, - }, - }), - queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), - getNextPageParam: (lastPage) => { - const nextPage = lastPage.page + 1 - const loaded = lastPage.page * lastPage.page_size - return loaded < (lastPage.total || 0) ? nextPage : undefined - }, - initialPageParam: 1, - enabled: queryParams !== undefined, - }) + return useInfiniteQuery(getMarketplacePluginsInfiniteQueryOptions(queryParams)) } diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx index 0424f4396ee..e888aac2653 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx @@ -19,7 +19,7 @@ vi.mock('../index', () => ({ describe('SearchBoxWrapper', () => { it('passes marketplace search state into SearchBox', () => { - render(<SearchBoxWrapper />) + render(<SearchBoxWrapper searchIconName="i-ri-search-line" />) expect(screen.getByTestId('search-box')).toBeInTheDocument() expect(mockSearchBox).toHaveBeenCalledWith( @@ -31,6 +31,7 @@ describe('SearchBoxWrapper', () => { tags: ['agent', 'rag'], onTagsChange: mockHandleFilterPluginTagsChange, placeholder: 'plugin.searchPlugins', + searchIconName: 'i-ri-search-line', usedInMarketplace: true, }), ) diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx index 2a34bcc2bf5..c117c8cd177 100644 --- a/web/app/components/plugins/marketplace/search-box/index.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.tsx @@ -14,6 +14,7 @@ type SearchBoxProps = { wrapperClassName?: string inputClassName?: string inputElementClassName?: string + searchIconName?: string searchIconClassName?: string tags: string[] onTagsChange: (tags: string[]) => void @@ -31,6 +32,7 @@ function SearchBox({ wrapperClassName, inputClassName, inputElementClassName, + searchIconName = 'i-ri-search-line', searchIconClassName, tags, onTagsChange, @@ -111,7 +113,7 @@ function SearchBox({ <span aria-hidden className={cn( - 'i-ri-search-line', + searchIconName, 'size-4 text-components-input-text-placeholder', searchIconClassName, )} diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index a1d3c76dfbc..31ed25cfdd0 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -8,6 +8,7 @@ type SearchBoxWrapperProps = { wrapperClassName?: string inputClassName?: string inputElementClassName?: string + searchIconName?: string searchIconClassName?: string placeholder?: string showTags?: boolean @@ -18,6 +19,7 @@ const SearchBoxWrapper = ({ wrapperClassName = 'z-11 mx-auto w-[640px] shrink-0', inputClassName = 'w-full', inputElementClassName, + searchIconName, searchIconClassName, placeholder, showTags = true, @@ -32,6 +34,7 @@ const SearchBoxWrapper = ({ wrapperClassName={wrapperClassName} inputClassName={inputClassName} inputElementClassName={inputElementClassName} + searchIconName={searchIconName} searchIconClassName={searchIconClassName} search={searchPluginText} onSearchChange={handleSearchPluginTextChange} diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx index 1c62e2b8d92..fba271a5061 100644 --- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx +++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx @@ -7,6 +7,7 @@ import { Popover, PopoverContent } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from '#i18n' import { useTags } from '@/app/components/plugins/hooks' +import { markMarketplaceSiteFilter } from '@/utils/marketplace-site-track' import MarketplaceTrigger from './trigger/marketplace' import ToolSelectorTrigger from './trigger/tool-selector' @@ -24,6 +25,17 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte option.label.toLowerCase().includes(searchText.toLowerCase()), ) const selectedTagsLength = tags.length + const handleTagsChange = (nextTags: string[]) => { + const addedTag = nextTags.find((tag) => !tags.includes(tag)) + const removedTag = tags.find((tag) => !nextTags.includes(tag)) + markMarketplaceSiteFilter({ + filter_type: 'category', + selection_mode: 'multi', + filter_value: addedTag ?? removedTag ?? nextTags.at(-1) ?? '', + selected_values: nextTags, + }) + onTagsChange(nextTags) + } return ( <Popover open={open} onOpenChange={setOpen}> @@ -32,7 +44,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte selectedTagsLength={selectedTagsLength} tags={tags} tagsMap={tagsMap} - onTagsChange={onTagsChange} + onTagsChange={handleTagsChange} /> )} {!usedInMarketplace && ( @@ -40,7 +52,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte selectedTagsLength={selectedTagsLength} tags={tags} tagsMap={tagsMap} - onTagsChange={onTagsChange} + onTagsChange={handleTagsChange} /> )} <PopoverContent @@ -74,7 +86,7 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte <CheckboxGroup aria-label={t(($) => $.allTags, { ns: 'pluginTags' })} value={tags} - onValueChange={(nextTags) => onTagsChange(nextTags)} + onValueChange={handleTagsChange} className="max-h-112 overflow-y-auto p-1" > {filteredOptions.map((option) => ( diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index 9538543ea40..6ddc889c1f5 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -1,16 +1,38 @@ +import type { PluginsSearchParams, PluginsSort } from '@dify/contracts/marketplace' import type { inferParserType } from 'nuqs/server' import type { ActivePluginType } from './constants' import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server' -import { PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { getMarketplaceListFilterType } from './utils' export const marketplaceSearchParamsParsers = { category: parseAsStringEnum<ActivePluginType>( Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[], ) .withDefault('all') - .withOptions({ history: 'replace', clearOnDefault: false }), - q: parseAsString.withDefault('').withOptions({ history: 'replace' }), + .withOptions({ history: 'replace', clearOnDefault: false, scroll: false }), + q: parseAsString.withDefault('').withOptions({ history: 'replace', scroll: false }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), + languages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), } export type MarketplaceSearchParams = inferParserType<typeof marketplaceSearchParamsParsers> + +export const shouldSearchMarketplacePlugins = ({ + category, + q, + tags, +}: Pick<MarketplaceSearchParams, 'category' | 'q' | 'tags'>) => + Boolean(q || tags.length > 0 || !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(category)) + +export const getMarketplacePluginsSearchParams = ( + { category, q, tags }: Pick<MarketplaceSearchParams, 'category' | 'q' | 'tags'>, + sort: PluginsSort = DEFAULT_SORT, +): PluginsSearchParams => ({ + query: q, + category: category === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : category, + tags, + sort_by: sort.sortBy, + sort_order: sort.sortOrder, + type: getMarketplaceListFilterType(category), +}) diff --git a/web/app/components/plugins/marketplace/server-budget.ts b/web/app/components/plugins/marketplace/server-budget.ts new file mode 100644 index 00000000000..5e8c3627899 --- /dev/null +++ b/web/app/components/plugins/marketplace/server-budget.ts @@ -0,0 +1,36 @@ +/** + * How long a server render may wait for Marketplace data before giving up on + * server-side rendering it. + * + * The catalog routes prefetch on the server so results land in the initial HTML + * (crawlers, first paint). Awaiting that prefetch to completion makes the whole + * RSC response hostage to the Marketplace API: with a slow upstream the browser + * sits on the *previous* page with no feedback, which is what "search just spins + * forever" looks like from the outside. Measured against a 3s-delayed API, an + * unbounded await pushed time-to-first-byte to ~7s. + * + * Nothing is lost when the budget expires: the client re-requests whatever is + * missing from the dehydrated state, and TanStack Query is configured to + * dehydrate still-pending queries, so in-flight work streams instead of + * blocking. Server rendering degrades exactly when it is too slow to be worth + * waiting for. + * + * Homepage (`variant="home"`) overlaps banners and catalog prefetch under one + * budget so a slow banner cannot add a second 2.5s onto the catalog wait. + */ +export const SERVER_PREFETCH_BUDGET_MS = 2_500 + +export async function withinServerBudget(work: Promise<unknown>): Promise<void> { + let cancelBudget = () => {} + try { + await Promise.race([ + work, + new Promise<void>((resolve) => { + const timer = setTimeout(resolve, SERVER_PREFETCH_BUDGET_MS) + cancelBudget = () => clearTimeout(timer) + }), + ]) + } finally { + cancelBudget() + } +} diff --git a/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts new file mode 100644 index 00000000000..7268b30e558 --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vite-plus/test' +import { standaloneMarketplaceClient } from '../client' +import { standaloneMarketplaceServer } from '../server' + +vi.mock('../../index', () => ({ default: () => null })) +vi.mock('../../hydration-server', () => ({ + HydrateQueryClient: () => null, +})) + +vi.mock('../../prefetch-marketplace-dehydrated-state', () => ({ + prefetchMarketplaceDehydratedState: vi.fn(), +})) + +describe('standalone Marketplace host entry', () => { + it('exports the client search surface', () => { + expect(standaloneMarketplaceClient.MarketplaceLiveSearch).toEqual(expect.any(Function)) + expect(standaloneMarketplaceClient.MarketplaceSearchAutocomplete).toEqual(expect.any(Function)) + }) + + it('exports the server prefetch helpers and creator model', () => { + expect(standaloneMarketplaceServer.withinServerBudget).toEqual(expect.any(Function)) + expect(standaloneMarketplaceServer.prefetchMarketplaceDehydratedState).toEqual( + expect.any(Function), + ) + expect(standaloneMarketplaceServer.SERVER_PREFETCH_BUDGET_MS).toBeGreaterThan(0) + expect(standaloneMarketplaceServer.parseCreatorSortField('popularity')).toBe('popularity') + }) +}) diff --git a/web/app/components/plugins/marketplace/standalone/client.ts b/web/app/components/plugins/marketplace/standalone/client.ts new file mode 100644 index 00000000000..16cca794a4f --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/client.ts @@ -0,0 +1,17 @@ +'use client' + +/** + * Public client surface for the standalone Marketplace host (dify-marketplace). + * Import this module instead of treating private Marketplace paths as Knip entries. + */ +import MarketplaceLiveSearch from '../home/marketplace-live-search' +import { + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} from '../home/marketplace-search-autocomplete' + +export const standaloneMarketplaceClient = { + MarketplaceLiveSearch, + MarketplaceSearchAutocomplete, + MarketplaceSearchForm, +} diff --git a/web/app/components/plugins/marketplace/standalone/server.ts b/web/app/components/plugins/marketplace/standalone/server.ts new file mode 100644 index 00000000000..8e3a27a39f9 --- /dev/null +++ b/web/app/components/plugins/marketplace/standalone/server.ts @@ -0,0 +1,36 @@ +/** + * Public server surface for the standalone Marketplace host (dify-marketplace). + * Import this module instead of treating private Marketplace paths as Knip entries. + */ +import { + adaptCreatorProfile, + CREATOR_SORT_FIELDS, + DEFAULT_CREATOR_SORT_FIELD, + DEFAULT_CREATOR_SORT_ORDER, + getStandaloneCreationHref, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, + toPublisherSortQuery, +} from '../creator-profile/model' +import { HydrateQueryClient } from '../hydration-server' +import Marketplace from '../index' +import { prefetchMarketplaceDehydratedState } from '../prefetch-marketplace-dehydrated-state' +import { SERVER_PREFETCH_BUDGET_MS, withinServerBudget } from '../server-budget' + +export const standaloneMarketplaceServer = { + Marketplace, + HydrateQueryClient, + prefetchMarketplaceDehydratedState, + SERVER_PREFETCH_BUDGET_MS, + withinServerBudget, + adaptCreatorProfile, + CREATOR_SORT_FIELDS, + DEFAULT_CREATOR_SORT_FIELD, + DEFAULT_CREATOR_SORT_ORDER, + getStandaloneCreationHref, + parseCreatorSortField, + parseCreatorSortOrder, + sortCreatorCreations, + toPublisherSortQuery, +} diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index f9c723a6481..a2a826ff7c6 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,4 +1,5 @@ import type { PluginsSearchParams } from '@dify/contracts/marketplace' +import type { ActivePluginType } from './constants' import { useDebounce } from 'ahooks' import { useCallback, useMemo } from 'react' import { @@ -8,52 +9,79 @@ import { useMarketplaceSortValue, useSearchPluginText, } from './atoms' -import { PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' -import { getCollectionsParams, getMarketplaceListFilterType } from './utils' +import { getMarketplacePluginsSearchParams } from './search-params' +import { getCollectionsParams } from './utils' -export function useMarketplaceData() { +export function useMarketplaceData(activePluginTypeOverride?: ActivePluginType) { const [searchPluginTextOriginal] = useSearchPluginText() const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() + const [activePluginTypeFromUrl] = useActivePluginType() + const activePluginType = activePluginTypeOverride ?? activePluginTypeFromUrl + const isSearchMode = useMarketplaceSearchMode(activePluginType, searchPluginText) const collectionsQuery = useMarketplaceCollectionsAndPlugins( getCollectionsParams(activePluginType), + !isSearchMode, ) const sort = useMarketplaceSortValue() - const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) return undefined - return { - query: searchPluginText, - category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, - tags: filterPluginTags, - sort_by: sort.sortBy, - sort_order: sort.sortOrder, - type: getMarketplaceListFilterType(activePluginType), - } + return getMarketplacePluginsSearchParams( + { + q: searchPluginText, + category: activePluginType, + tags: filterPluginTags, + }, + sort, + ) }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) const pluginsQuery = useMarketplacePlugins(queryParams) const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery const handlePageChange = useCallback(() => { - if (hasNextPage && !isFetching) fetchNextPage() + if (hasNextPage && !isFetching) void fetchNextPage() }, [fetchNextPage, hasNextPage, isFetching]) // Scroll pagination useMarketplaceContainerScroll(handlePageChange) + const pages = pluginsQuery.data?.pages + // Meilisearch resolves ties in `install_count DESC` by internal document + // order, and the sync task rewrites those documents every minute, so + // offset-paginated pages can overlap. Without this, an overlap renders two + // cards with the same React key and remounts the grid. + const plugins = useMemo(() => { + if (!pages) return undefined + const seen = new Set<string>() + return pages.flatMap((page) => + page.plugins.filter((plugin) => { + const key = `${plugin.org}/${plugin.name}` + if (seen.has(key)) return false + seen.add(key) + return true + }), + ) + }, [pages]) + return { marketplaceCollections: collectionsQuery.data?.marketplaceCollections, marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, - plugins: pluginsQuery.data?.pages.flatMap((page) => page.plugins), - pluginsTotal: pluginsQuery.data?.pages[0]?.total, - page: pluginsQuery.data?.pages.length || 1, + plugins, + pluginsTotal: pages?.[0]?.total, + page: pages?.length || 1, isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, + // A superseded query keeps the previous results on screen (placeholderData) + // or has not been issued yet (still debouncing). Both need a quiet pending + // affordance; unmounting the grid instead collapses layout and jumps scroll. + isRefreshing: + pluginsQuery.isPlaceholderData || searchPluginTextOriginal.trim() !== searchPluginText.trim(), + isError: collectionsQuery.isError || pluginsQuery.isError, + refetch: isSearchMode ? pluginsQuery.refetch : collectionsQuery.refetch, isFetchingNextPage, } } diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx new file mode 100644 index 00000000000..d34e28811fb --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-card.spec.tsx @@ -0,0 +1,91 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ThemeProvider } from 'next-themes' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TemplateCard from '../template-card' + +const { mockPush } = vi.hoisted(() => ({ + mockPush: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('../../utils', () => ({ + getTemplateLinkInMarketplace: ( + currentTemplate: MarketplaceTemplate, + params: { language: string; source?: string; theme?: string; view: string }, + ) => + `about:blank?templateId=${currentTemplate.id}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`, +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () => <div aria-hidden />, +})) + +const template: MarketplaceTemplate = { + id: 'template/one', + template_name: 'Campaign planner', + overview: 'Plan a launch campaign.', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 1200, + categories: ['marketing'], + badges: ['partner'], +} + +describe('TemplateCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens template detail before starting the Dify import flow', async () => { + const user = userEvent.setup() + render( + <ThemeProvider forcedTheme="dark"> + <TemplateCard partnerText="Verified by a Dify partner" template={template} /> + </ThemeProvider>, + ) + + expect(screen.queryByRole('link', { name: 'Campaign planner' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Campaign planner' })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(mockPush).not.toHaveBeenCalled() + + const frame = screen.getByTitle( + 'Campaign planner · plugin.detailPanel.operation.detail', + ) as HTMLIFrameElement + const marketplaceOrigin = new URL(frame.getAttribute('src')!, window.location.href).origin + const installRequest = { + type: 'dify-marketplace:install-template', + templateId: template.id, + } + fireEvent( + window, + new MessageEvent('message', { + data: { ...installRequest, templateId: 'another-template' }, + origin: marketplaceOrigin, + source: frame.contentWindow, + }), + ) + expect(mockPush).not.toHaveBeenCalled() + + fireEvent( + window, + new MessageEvent('message', { + data: installRequest, + origin: marketplaceOrigin, + source: frame.contentWindow, + }), + ) + expect(mockPush).toHaveBeenCalledWith('/apps?template-id=template%2Fone') + expect(screen.getByText('dify')).toBeInTheDocument() + expect(screen.getByText('1.2k')).toBeInTheDocument() + expect(screen.getByText('Verified by a Dify partner')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx new file mode 100644 index 00000000000..27de0983450 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list-layout.browser.spec.tsx @@ -0,0 +1,137 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { page } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import TemplateCollectionList from '../template-collection-list' + +vi.mock('#i18n', async () => { + const { withSelectorKey } = await import('@/test/i18n-mock') + return { + useLocale: () => 'en-US', + useTranslation: () => ({ + t: withSelectorKey((key: string) => + key === 'marketplace.carousel.scrollPrevious' ? 'Previous' : key, + ), + }), + } +}) + +vi.mock('../template-card', () => ({ + default: ({ template }: { template: MarketplaceTemplate }) => <div>{template.template_name}</div>, +})) + +const partnerCollection: MarketplaceTemplateCollection = { + name: 'partners', + label: { en_US: 'Partners' }, + description: { en_US: 'Plugins verified by Dify partners.' }, + searchable: false, + search_params: {}, + priority: 0, +} + +const partnerTemplates = Array.from({ length: 9 }, (_, index) => ({ + id: `template-${index}`, + template_name: `Partner template ${index}`, + overview: 'Partner template', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 10, + categories: ['marketing'], +})) as MarketplaceTemplate[] + +const renderPartnerCollection = ({ + templateCount = 9, + standalone = true, + width = 350, +}: { + templateCount?: number + standalone?: boolean + width?: number +} = {}) => + render( + <div + data-testid="collection-shell" + data-marketplace-standalone={standalone || undefined} + style={{ width }} + > + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: partnerTemplates.slice(0, templateCount) }} + viewMoreText="View more" + /> + </div>, + ) + +const getTextRect = (element: Element) => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getBoundingClientRect() +} + +describe('Template partner collection header layout', () => { + it('keeps the mobile call to action beside the title and clear of carousel controls', async () => { + await page.viewport(390, 844) + const screen = await renderPartnerCollection() + + const title = screen.getByText('Partners', { exact: true }).element() + const description = screen.getByText('Plugins verified by Dify partners.').element() + const separator = screen.getByText('|').element() + const partnerLink = screen.getByRole('link', { name: 'Become a Partner' }).element() + const previousButton = screen.getByRole('button', { name: 'Previous' }).element() + + const titleRect = getTextRect(title) + const descriptionRect = description.getBoundingClientRect() + const partnerLinkRect = partnerLink.getBoundingClientRect() + const previousButtonRect = previousButton.getBoundingClientRect() + const titleCenter = titleRect.top + titleRect.height / 2 + const partnerLinkCenter = partnerLinkRect.top + partnerLinkRect.height / 2 + + expect(Math.abs(titleCenter - partnerLinkCenter)).toBeLessThanOrEqual(2) + expect(partnerLinkRect.left - titleRect.right).toBeCloseTo(12, 0) + expect(previousButtonRect.left - partnerLinkRect.right).toBeGreaterThanOrEqual(8) + expect(descriptionRect.top).toBeGreaterThanOrEqual( + Math.max(titleRect.bottom, partnerLinkRect.bottom), + ) + expect(getComputedStyle(separator).display).toBe('none') + }) + + it('preserves the desktop title and metadata rows', async () => { + await page.viewport(1280, 900) + const screen = await render( + <div className="w-[1200px]" data-marketplace-standalone> + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: partnerTemplates }} + viewMoreText="View more" + /> + </div>, + ) + + const titleRect = screen + .getByText('Partners', { exact: true }) + .element() + .getBoundingClientRect() + const descriptionRect = screen + .getByText('Plugins verified by Dify partners.') + .element() + .getBoundingClientRect() + const partnerLinkRect = screen + .getByRole('link', { name: 'Become a Partner' }) + .element() + .getBoundingClientRect() + + expect(descriptionRect.top).toBeGreaterThanOrEqual(titleRect.bottom) + expect(Math.abs(descriptionRect.top - partnerLinkRect.top)).toBeLessThanOrEqual(2) + expect(getComputedStyle(screen.getByText('|').element()).display).not.toBe('none') + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx new file mode 100644 index 00000000000..4efe7f90d92 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-collection-list.spec.tsx @@ -0,0 +1,98 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import TemplateCollectionList from '../template-collection-list' + +vi.mock('../template-card', () => ({ + default: ({ template }: { template: MarketplaceTemplate }) => ( + <div data-testid="template-card">{template.template_name}</div> + ), +})) + +vi.mock('@/utils/marketplace-site-track', () => ({ + trackMarketplaceSiteEvent: vi.fn(), +})) + +const partnerCollection: MarketplaceTemplateCollection = { + name: 'partners', + label: { en_US: 'Partners' }, + description: { en_US: 'Partner templates' }, + searchable: false, + search_params: {}, + priority: 0, +} + +const featuredCollection: MarketplaceTemplateCollection = { + name: 'featured', + label: { en_US: 'Featured' }, + description: { en_US: 'Featured templates' }, + searchable: false, + search_params: {}, + priority: 1, +} + +const buildTemplates = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => ({ + id: `${prefix}-${index}`, + template_name: `${prefix} ${index}`, + overview: 'Template', + icon: '📄', + icon_background: '#fff', + icon_file_key: '', + publisher_unique_handle: 'dify', + usage_count: 10, + categories: ['marketing'], + })) as MarketplaceTemplate[] + +describe('TemplateCollectionList carousel', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: 1280, + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps carousel navigation for non-partner collections that exceed two rows', () => { + render( + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[featuredCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ featured: buildTemplates('Featured', 9) }} + viewMoreText="View more" + />, + ) + + expect( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }), + ).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Featured' })).toBeInTheDocument() + }) + + it('keeps carousel navigation for partner collections that exceed two rows', () => { + render( + <TemplateCollectionList + becomePartnerText="Become a Partner" + collections={[partnerCollection]} + locale="en-US" + partnerText="Verified" + templatesByCollection={{ partners: buildTemplates('Partner', 9) }} + viewMoreText="View more" + />, + ) + + expect( + screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }), + ).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Partners' })).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts new file mode 100644 index 00000000000..77d55b2e942 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vite-plus/test' +import { + filterTemplatesForLocale, + getTemplateCollectionText, + parseListParam, + resolveTemplateSearchLanguages, +} from '../template-language' + +const template = (id: string, preferredLanguages?: string[]) => ({ + id, + preferred_languages: preferredLanguages, +}) + +const ids = (templates: { id: string }[]) => templates.map(({ id }) => id) + +describe('filterTemplatesForLocale', () => { + it('keeps templates matching the requested language prefix', () => { + const templates = [ + template('en', ['en-US']), + template('zh', ['zh-Hans']), + template('ja', ['ja-JP']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'zh-Hans'))).toEqual(['zh']) + expect(ids(filterTemplatesForLocale(templates, 'en-US'))).toEqual(['en']) + }) + + it('matches unrelated locales instead of collapsing them into "other"', () => { + const templates = [ + template('en', ['en-US']), + template('de', ['de-DE']), + template('fr', ['fr-FR']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['de']) + }) + + it('falls back to English templates when nothing matches the requested language', () => { + const templates = [ + template('en-1', ['en-US']), + template('en-2', ['en-GB']), + template('ja', ['ja-JP']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['en-1', 'en-2']) + }) + + it('falls back to the unfiltered list when neither the locale nor English matches', () => { + const templates = [template('zh', ['zh-Hans']), template('ja', ['ja-JP'])] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['zh', 'ja']) + }) + + it('always keeps language-agnostic templates', () => { + const templates = [ + template('agnostic-none'), + template('agnostic-empty', []), + template('de', ['de-DE']), + ] + + expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual([ + 'agnostic-none', + 'agnostic-empty', + 'de', + ]) + }) + + it('normalizes underscore locales', () => { + const templates = [template('zh', ['zh_Hans']), template('en', ['en_US'])] + + expect(ids(filterTemplatesForLocale(templates, 'zh_Hans'))).toEqual(['zh']) + }) +}) + +describe('getTemplateCollectionText', () => { + it('uses the matching collection translation and falls back to English', () => { + const label = { + en_US: 'Featured', + zh_Hans: '精选', + zh_Hant: '精選', + ja_JP: '注目', + } + + expect(getTemplateCollectionText(label, 'zh-Hant')).toBe('精選') + expect(getTemplateCollectionText(label, 'de-DE')).toBe('Featured') + }) + + it('falls back to the first available translation when English is missing', () => { + expect(getTemplateCollectionText({ ja_JP: '注目' }, 'de-DE')).toBe('注目') + expect(getTemplateCollectionText({}, 'de-DE')).toBe('') + }) +}) + +describe('parseListParam', () => { + it('normalizes undefined, comma-separated, and array language values', () => { + expect(parseListParam(undefined)).toEqual([]) + expect(parseListParam('en,zh-Hans')).toEqual(['en', 'zh-Hans']) + expect(parseListParam(['ja', ' other '])).toEqual(['ja', 'other']) + }) +}) + +describe('resolveTemplateSearchLanguages', () => { + it('uses the explicit filter when the visitor picked languages', () => { + expect(resolveTemplateSearchLanguages(['ja'], 'zh-Hans')).toEqual(['ja']) + }) + + it('maps UI locales onto catalog language values when the filter is unset', () => { + expect(resolveTemplateSearchLanguages([], 'en-US')).toEqual(['en']) + expect(resolveTemplateSearchLanguages([], 'zh-Hans')).toEqual(['zh-Hans']) + expect(resolveTemplateSearchLanguages([], 'zh_Hans')).toEqual(['zh-Hans']) + expect(resolveTemplateSearchLanguages([], 'ja-JP')).toEqual(['ja']) + }) + + it('keeps unmatched locale prefixes so pagination is not mixed-language', () => { + expect(resolveTemplateSearchLanguages([], 'de-DE')).toEqual(['de']) + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts new file mode 100644 index 00000000000..34993d58447 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-links.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vite-plus/test' +import { buildTemplatesHref } from '../template-links' + +describe('buildTemplatesHref', () => { + it('appends selected languages as a comma-separated query value', () => { + expect(buildTemplatesHref({ category: 'all', languages: ['en', 'ja'] })).toBe( + '/templates?languages=en%2Cja', + ) + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/categories.ts b/web/app/components/plugins/marketplace/templates/categories.ts new file mode 100644 index 00000000000..2d8d02943e9 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/categories.ts @@ -0,0 +1,17 @@ +export const TEMPLATE_CATEGORIES = [ + 'all', + 'marketing', + 'sales', + 'support', + 'operations', + 'it', + 'knowledge', + 'design', + 'others', +] as const + +export type TemplateCategory = (typeof TEMPLATE_CATEGORIES)[number] + +export function isTemplateCategory(value: string | undefined): value is TemplateCategory { + return TEMPLATE_CATEGORIES.includes(value as TemplateCategory) +} diff --git a/web/app/components/plugins/marketplace/templates/index.tsx b/web/app/components/plugins/marketplace/templates/index.tsx new file mode 100644 index 00000000000..da35a40ec91 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/index.tsx @@ -0,0 +1,299 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import type { TemplateCategory } from './categories' +import type { Locale } from '@/i18n-config' +import { cn } from '@langgenius/dify-ui/cn' +import { getTranslation } from '@/i18n-config/server' +import { redirect } from '@/next/navigation' +import { + getMarketplaceTemplateCollectionsAndTemplates, + searchMarketplaceTemplates, + TEMPLATE_SEARCH_PAGE_SIZE, +} from '@/service/marketplace-template-discovery' +import { fetchPluginBanners } from '../home/banners' +import CatalogLanguagesFilter from '../home/catalog-languages-filter' +import HomeCatalogNavigation from '../home/home-catalog-navigation' +import HomeCatalogTabs from '../home/home-catalog-tabs' +import HomeHeader from '../home/home-header' +import HomeHero from '../home/home-hero' +import HomeSearch from '../home/home-search' +import { HomeShell } from '../home/home-shell' +import styles from '../home/home-sticky.module.css' +import { GRID_CLASS } from '../list/collection-constants' +import TemplateCard from './template-card' +import TemplateCategoryNavigation from './template-category-navigation' +import TemplateCollectionList from './template-collection-list' +import { + filterTemplatesForLocale, + parseListParam, + resolveTemplateSearchLanguages, +} from './template-language' +import { buildTemplatesHref, PAGE_LINK_CLASS } from './template-links' +import TemplatePagination from './template-pagination' + +type EmbeddedTemplatesMarketplaceProps = { + category: TemplateCategory + languages?: string | string[] + locale: Locale + page?: number + query: string + sortBy?: string + sortOrder?: string + view?: string +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( + <div className="flex min-h-60 items-center justify-center rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary"> + {children} + </div> + ) +} + +function TemplateGrid({ + partnerText, + templates, +}: { + partnerText: string + templates: MarketplaceTemplate[] +}) { + return ( + <div className={GRID_CLASS}> + {templates.map((template) => ( + <TemplateCard key={template.id} partnerText={partnerText} template={template} /> + ))} + </div> + ) +} + +// The retry link is a plain anchor on purpose: a full navigation re-runs the +// failed (and uncached) server fetch instead of reusing the router cache. +function LoadErrorState({ + message, + retryHref, + retryLabel, +}: { + message: string + retryHref: string + retryLabel: string +}) { + return ( + <div className="flex min-h-60 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary"> + <span>{message}</span> + <a href={retryHref} className={PAGE_LINK_CLASS}> + {retryLabel} + </a> + </div> + ) +} + +export async function EmbeddedTemplatesMarketplace({ + category, + languages, + locale, + page = 1, + query, + sortBy, + sortOrder, + view, +}: EmbeddedTemplatesMarketplaceProps) { + const normalizedQuery = query.trim() + const selectedLanguages = parseListParam(languages) + const searchLanguages = resolveTemplateSearchLanguages(selectedLanguages, locale) + const showCollections = + category === 'all' && !normalizedQuery && view !== 'search' && selectedLanguages.length === 0 + const [ + { t: tPlugin }, + { t: tApp }, + { t: tExplore }, + { t: tPluginTags }, + { t: tCommon }, + collectionsResult, + searchResult, + banners, + ] = await Promise.all([ + getTranslation(locale, 'plugin'), + getTranslation(locale, 'app'), + getTranslation(locale, 'explore'), + getTranslation(locale, 'pluginTags'), + getTranslation(locale, 'common'), + showCollections ? getMarketplaceTemplateCollectionsAndTemplates() : Promise.resolve(null), + showCollections + ? Promise.resolve(null) + : searchMarketplaceTemplates({ + category, + page, + query: normalizedQuery, + sortBy, + sortOrder, + languages: searchLanguages, + }), + fetchPluginBanners(locale, 'templates').catch(() => []), + ]) + const categoryLabels = { + all: tPlugin(($) => $['category.all'], { ns: 'plugin' }), + marketing: tApp(($) => $['marketplace.template.category.marketing'], { ns: 'app' }), + sales: tApp(($) => $['marketplace.template.category.sales'], { ns: 'app' }), + support: tApp(($) => $['marketplace.template.category.support'], { ns: 'app' }), + operations: tApp(($) => $['marketplace.template.category.operations'], { ns: 'app' }), + it: tApp(($) => $['marketplace.template.category.it'], { ns: 'app' }), + knowledge: tApp(($) => $['marketplace.template.category.knowledge'], { ns: 'app' }), + design: tApp(($) => $['marketplace.template.category.design'], { ns: 'app' }), + others: tPluginTags(($) => $['tags.other'], { ns: 'pluginTags' }), + } + const pageCount = Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE) + // An out-of-range ?page= would render a misleading empty state; send the + // visitor to the last page that actually exists instead. + if (searchResult?.ok && searchResult.total > 0 && page > pageCount) { + redirect( + buildTemplatesHref({ + category, + languages: selectedLanguages, + page: pageCount, + query: normalizedQuery, + sortBy, + sortOrder, + view, + }), + ) + } + + const templates = searchResult?.templates ?? [] + // Collection previews have no language query, so they still need a locale + // pass. Search results are already paginated with `searchLanguages`. + const visibleTemplatesByCollection = Object.fromEntries( + (collectionsResult?.collections ?? []).map((collection) => [ + collection.name, + filterTemplatesForLocale( + collectionsResult?.templatesByCollection[collection.name] ?? [], + locale, + ), + ]), + ) + const hasVisibleCollections = (collectionsResult?.collections ?? []).some( + (collection) => (visibleTemplatesByCollection[collection.name]?.length ?? 0) > 0, + ) + const pluginsLabel = tPlugin(($) => $['marketplace.home.plugins'], { ns: 'plugin' }) + const templatesLabel = tPlugin(($) => $['marketplace.home.templates'], { ns: 'plugin' }) + const partnerText = tPlugin(($) => $['marketplace.partnerTip'], { ns: 'plugin' }) + const loadFailed = collectionsResult + ? !collectionsResult.ok + : searchResult + ? !searchResult.ok + : false + const currentHref = buildTemplatesHref({ + category, + languages: selectedLanguages, + page, + query: normalizedQuery, + sortBy, + sortOrder, + view, + }) + const loadErrorState = ( + <LoadErrorState + message={tPlugin(($) => $['marketplace.loadError'], { ns: 'plugin' })} + retryHref={currentHref} + retryLabel={tCommon(($) => $['operation.retry'], { ns: 'common' })} + /> + ) + + return ( + <HomeShell + banners={banners} + isMarketplacePlatform={false} + page="templates" + header={ + <HomeHeader + activeTab="templates" + catalogLabels={{ plugins: pluginsLabel, templates: templatesLabel }} + isMarketplacePlatform={false} + /> + } + hero={ + <HomeHero + isMarketplacePlatform={false} + title={templatesLabel} + subtitle={tExplore(($) => $['apps.description'], { ns: 'explore' })} + /> + } + search={<HomeSearch enableSearchShortcut={false} />} + navigation={ + <HomeCatalogNavigation + isMarketplacePlatform={false} + catalogTabs={ + <HomeCatalogTabs + activeTab="templates" + isMarketplacePlatform={false} + labels={{ plugins: pluginsLabel, templates: templatesLabel }} + /> + } + catalogCategories={ + <TemplateCategoryNavigation + activeCategory={category} + ariaLabel={tPlugin(($) => $.allCategories, { ns: 'plugin' })} + labels={categoryLabels} + languages={selectedLanguages} + query={query} + /> + } + catalogTrailing={<CatalogLanguagesFilter />} + /> + } + > + {/* The app shell already renders the main landmark; use a plain div + to avoid nested main elements. */} + <div + className={cn( + 'relative flex grow flex-col bg-background-default px-8 py-2', + styles.catalogContent, + )} + > + {loadFailed ? ( + loadErrorState + ) : collectionsResult ? ( + hasVisibleCollections ? ( + <TemplateCollectionList + becomePartnerText={tPlugin(($) => $['marketplace.becomePartner'], { + ns: 'plugin', + })} + collections={collectionsResult.collections} + locale={locale} + partnerText={partnerText} + templatesByCollection={visibleTemplatesByCollection} + viewMoreText={tPlugin(($) => $['marketplace.viewMore'], { ns: 'plugin' })} + /> + ) : ( + <EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState> + ) + ) : ( + <> + {/* The locale filter runs after pagination, so the API total + does not describe what is on screen; show the number of + templates actually rendered on this page instead. */} + <div className="mb-5 text-right text-sm text-text-tertiary"> + {tExplore(($) => $['apps.resultNum'], { ns: 'explore', num: templates.length })} + </div> + {templates.length > 0 ? ( + <TemplateGrid partnerText={partnerText} templates={templates} /> + ) : ( + <EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState> + )} + <TemplatePagination + category={category} + languages={selectedLanguages} + navigationLabel={tCommon(($) => $['pagination.pageNumber'], { ns: 'common' })} + nextLabel={tCommon(($) => $['pagination.next'], { ns: 'common' })} + page={page} + pageCount={pageCount} + previousLabel={tCommon(($) => $['pagination.previous'], { ns: 'common' })} + query={normalizedQuery} + sortBy={sortBy} + sortOrder={sortOrder} + view={view} + /> + </> + )} + </div> + </HomeShell> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-card.tsx b/web/app/components/plugins/marketplace/templates/template-card.tsx new file mode 100644 index 00000000000..de0a577e62b --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-card.tsx @@ -0,0 +1,111 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import { cn } from '@langgenius/dify-ui/cn' +import { useBoolean } from 'ahooks' +import { useCallback } from 'react' +import AppIcon from '@/app/components/base/app-icon' +import Partner from '@/app/components/plugins/base/badges/partner' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { useRouter } from '@/next/navigation' +import { formatNumberAbbreviated } from '@/utils/format' +import { getIconFromMarketPlace } from '@/utils/get-icon' +import TemplateDetailDialog from './template-detail-dialog' + +type TemplateCardProps = { + template: MarketplaceTemplate + className?: string + partnerText: string +} + +const MAX_VISIBLE_PLUGIN_DEPENDENCIES = 7 + +export default function TemplateCard({ template, className, partnerText }: TemplateCardProps) { + const router = useRouter() + const [isDetailOpen, { setTrue: showDetail, setFalse: hideDetail }] = useBoolean(false) + const publisher = + template.publisher_handle || template.publisher_unique_handle || template.creator_email || '' + const visiblePlugins = template.deps_plugins?.slice(0, MAX_VISIBLE_PLUGIN_DEPENDENCIES) ?? [] + const remainingPluginCount = Math.max( + 0, + (template.deps_plugins?.length ?? 0) - MAX_VISIBLE_PLUGIN_DEPENDENCIES, + ) + const imageUrl = template.icon_file_key + ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` + : undefined + const handleOpenChange = (open: boolean) => { + if (open) showDetail() + else hideDetail() + } + const handleInstall = useCallback(() => { + hideDetail() + router.push(`/apps?template-id=${encodeURIComponent(template.id)}`) + }, [hideDetail, router, template.id]) + + return ( + <> + <article + className={cn( + 'relative flex h-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-md', + className, + )} + > + <button + type="button" + aria-label={template.template_name} + className="absolute inset-0 z-[1] cursor-pointer rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid" + onClick={showDetail} + /> + <div className="relative z-0 flex shrink-0 items-center gap-3 px-4 pt-4 pb-2"> + <AppIcon + size="large" + iconType={imageUrl ? 'image' : 'emoji'} + icon={imageUrl ? undefined : template.icon || '📄'} + imageUrl={imageUrl} + background={template.icon_background} + /> + <div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5"> + <div className="flex items-center"> + <span className="truncate text-left system-md-medium text-text-primary"> + {template.template_name} + </span> + {template.badges?.includes('partner') && ( + <Partner className="relative z-[2] ml-0.5 size-4 shrink-0" text={partnerText} /> + )} + </div> + <div className="flex items-center gap-2 system-xs-regular text-text-tertiary"> + {publisher && <span className="truncate">{publisher}</span>} + {publisher && <span>·</span>} + <span>{formatNumberAbbreviated(template.usage_count)}</span> + </div> + </div> + </div> + <div className="min-h-8 px-4 pt-1 pb-2 system-xs-regular text-text-secondary"> + <p className="line-clamp-2" title={template.overview}> + {template.overview} + </p> + </div> + <div className="mt-auto flex min-h-7 items-center gap-1 px-4 py-1"> + {visiblePlugins.map((pluginId) => ( + <img + key={pluginId} + className="size-6 rounded-md border-[0.5px] border-effects-icon-border object-cover" + src={getIconFromMarketPlace(pluginId)} + alt="" + title={pluginId} + /> + ))} + {remainingPluginCount > 0 && ( + <span className="system-xs-regular text-text-tertiary">+{remainingPluginCount}</span> + )} + </div> + </article> + <TemplateDetailDialog + open={isDetailOpen} + template={template} + onInstall={handleInstall} + onOpenChange={handleOpenChange} + /> + </> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx b/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx new file mode 100644 index 00000000000..dfd809ef5fb --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-category-navigation.tsx @@ -0,0 +1,56 @@ +import type { TemplateCategory } from './categories' +import { cn } from '@langgenius/dify-ui/cn' +import MarketplaceFilterTrackLink from '../filter-track-link' +import pluginTypeStyles from '../plugin-type-switch.module.css' +import { TEMPLATE_CATEGORIES } from './categories' + +export type TemplateCategoryLabels = Record<TemplateCategory, string> + +export default function TemplateCategoryNavigation({ + activeCategory, + ariaLabel, + labels, + languages, + query, +}: { + activeCategory: TemplateCategory + ariaLabel: string + labels: TemplateCategoryLabels + languages: string[] + query: string +}) { + return ( + <nav + aria-label={ariaLabel} + className="flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto" + > + {TEMPLATE_CATEGORIES.map((category) => { + const searchParams = new URLSearchParams() + if (query) searchParams.set('q', query) + if (languages.length) searchParams.set('languages', languages.join(',')) + const queryString = searchParams.toString() + const href = `/templates/${category}${queryString ? `?${queryString}` : ''}` + + return ( + <MarketplaceFilterTrackLink + key={category} + href={href} + scroll={false} + aria-current={category === activeCategory ? 'page' : undefined} + filterType="category" + filterValue={category} + selectedValues={[category]} + trackFilter={category !== activeCategory} + className={cn( + 'flex h-8 min-w-12 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap text-text-tertiary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + pluginTypeStyles.homeItem, + category === activeCategory && pluginTypeStyles.homeItemActive, + )} + > + {labels[category]} + </MarketplaceFilterTrackLink> + ) + })} + </nav> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-collection-list.tsx b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx new file mode 100644 index 00000000000..ae3259f2bff --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-collection-list.tsx @@ -0,0 +1,176 @@ +'use client' + +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { cn } from '@langgenius/dify-ui/cn' +import { useId } from 'react' +import Link from '@/next/link' +import { trackMarketplaceSiteEvent } from '@/utils/marketplace-site-track' +import Carousel from '../list/carousel' +import { + BECOME_PARTNER_URL, + GRID_CLASS, + PARTNER_COLLECTION_NAMES, +} from '../list/collection-constants' +import styles from '../list/partner-header.module.css' +import { useCarouselItemsPerPage } from '../list/use-carousel-items-per-page' +import TemplateCard from './template-card' +import { getTemplateCollectionText } from './template-language' + +type TemplateCollectionListProps = { + becomePartnerText: string + collections: MarketplaceTemplateCollection[] + locale: string + partnerText: string + /** + * Templates per collection, already filtered for the request locale by the + * caller; this component only renders what it receives. + */ + templatesByCollection: Record<string, MarketplaceTemplate[]> + viewMoreText: string +} + +function getViewMoreHref(collection: MarketplaceTemplateCollection) { + const searchParams = new URLSearchParams({ view: 'search' }) + const collectionSearch = collection.search_params + + if (collectionSearch?.query) searchParams.set('q', collectionSearch.query) + if (collectionSearch?.sort_by) searchParams.set('sort_by', collectionSearch.sort_by) + if (collectionSearch?.sort_order) searchParams.set('sort_order', collectionSearch.sort_order) + + return `/templates/all?${searchParams.toString()}` +} + +export default function TemplateCollectionList({ + becomePartnerText, + collections, + locale, + partnerText, + templatesByCollection, + viewMoreText, +}: TemplateCollectionListProps) { + const itemsPerPage = useCarouselItemsPerPage() + const collectionLabelPrefixId = useId() + + return collections.map((collection) => { + const templates = templatesByCollection[collection.name] ?? [] + + if (!templates.length) return null + + const isPartnerCollection = PARTNER_COLLECTION_NAMES.has(collection.name) + const hasMultiplePages = !collection.searchable && templates.length > itemsPerPage + const collectionLabelId = `${collectionLabelPrefixId}-${encodeURIComponent(collection.name)}` + + return ( + <section key={collection.name} className="py-3"> + <div className="mb-2 flex items-end justify-between gap-4"> + <div + className={cn( + 'min-w-0', + isPartnerCollection && styles.partnerHeader, + isPartnerCollection && hasMultiplePages && styles.partnerHeaderWithNavigation, + )} + > + <h2 + id={collectionLabelId} + className={cn( + 'title-xl-semi-bold text-text-primary', + isPartnerCollection && styles.partnerTitle, + )} + > + {getTemplateCollectionText(collection.label, locale)} + </h2> + <div + className={cn( + 'flex flex-wrap items-center gap-x-2 system-xs-regular text-text-tertiary', + isPartnerCollection && styles.partnerMetadata, + )} + > + {isPartnerCollection ? ( + <span className={styles.partnerDescription}> + {getTemplateCollectionText(collection.description, locale)} + </span> + ) : ( + getTemplateCollectionText(collection.description, locale) + )} + {isPartnerCollection && ( + <> + <span className={cn(styles.partnerSeparator, 'text-divider-regular')}>|</span> + <a + href={BECOME_PARTNER_URL} + target="_blank" + rel="noopener noreferrer" + className={cn( + styles.partnerAction, + 'flex items-center gap-x-0.5 text-text-accent hover:underline', + )} + onClick={() => { + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'Become a Partner', + }) + }} + > + <span className={styles.partnerActionLabel}>{becomePartnerText}</span> + <span + aria-hidden + className={cn(styles.partnerActionIcon, 'i-ri-external-link-line size-3')} + /> + </a> + </> + )} + </div> + </div> + {collection.searchable && ( + <Link + href={getViewMoreHref(collection)} + className="flex shrink-0 items-center system-xs-medium text-text-accent hover:underline" + > + {viewMoreText} + <span aria-hidden className="i-ri-arrow-right-s-line size-4" /> + </Link> + )} + </div> + {collection.searchable ? ( + <div className={GRID_CLASS}> + {templates.slice(0, 4).map((template) => ( + <TemplateCard key={template.id} partnerText={partnerText} template={template} /> + ))} + </div> + ) : ( + <Carousel + pages={Array.from( + { length: Math.ceil(templates.length / itemsPerPage) }, + (_, pageIndex) => { + const pageTemplates = templates.slice( + pageIndex * itemsPerPage, + (pageIndex + 1) * itemsPerPage, + ) + + return { + id: `${collection.name}-${itemsPerPage}-${pageIndex}`, + content: ( + <div className={cn(GRID_CLASS)}> + {pageTemplates.map((template) => ( + <div key={template.id} className="min-w-0 *:w-full"> + <TemplateCard partnerText={partnerText} template={template} /> + </div> + ))} + </div> + ), + } + }, + )} + aria-labelledby={collectionLabelId} + showNavigation + showPagination + autoPlay={isPartnerCollection} + autoPlayInterval={5000} + pauseWhenOffscreen + /> + )} + </section> + ) + }) +} diff --git a/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx b/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx new file mode 100644 index 00000000000..1e230705e9e --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-detail-dialog.tsx @@ -0,0 +1,63 @@ +'use client' + +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' +import { useTheme } from 'next-themes' +import { useCallback } from 'react' +import { useLocale, useTranslation } from '#i18n' +import MarketplaceDetailDialogFrame from '../detail-dialog/frame' +import { getTemplateLinkInMarketplace } from '../utils' + +const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-template' + +type TemplateDetailDialogProps = { + open: boolean + template: MarketplaceTemplate + onInstall: () => void + onOpenChange: (open: boolean) => void +} + +export default function TemplateDetailDialog({ + open, + template, + onInstall, + onOpenChange, +}: TemplateDetailDialogProps) { + const { t } = useTranslation() + const locale = useLocale() + // resolvedTheme maps the "system" preference to the concrete light/dark + // value the marketplace page expects. + const { resolvedTheme } = useTheme() + const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' }) + const detailURL = getTemplateLinkInMarketplace(template, { + language: locale, + source: globalThis.location?.origin, + theme: resolvedTheme, + view: 'modal', + }) + const handleMessage = useCallback( + (data: unknown) => { + if ( + typeof data !== 'object' || + data === null || + !('type' in data) || + !('templateId' in data) || + data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE || + data.templateId !== template.id + ) + return + + onInstall() + }, + [onInstall, template.id], + ) + + return ( + <MarketplaceDetailDialogFrame + open={open} + src={detailURL} + title={`${template.template_name} · ${detailLabel}`} + onMessage={handleMessage} + onOpenChange={onOpenChange} + /> + ) +} diff --git a/web/app/components/plugins/marketplace/templates/template-language.ts b/web/app/components/plugins/marketplace/templates/template-language.ts new file mode 100644 index 00000000000..c2d10aa2e0e --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-language.ts @@ -0,0 +1,67 @@ +import type { MarketplaceTemplate } from '@dify/contracts/marketplace' + +export const LANGUAGE_OPTIONS = [ + { value: 'en', label: 'English', nativeLabel: 'English' }, + { value: 'zh-Hans', label: 'Simplified Chinese', nativeLabel: '中文' }, + { value: 'ja', label: 'Japanese', nativeLabel: '日本語' }, + { value: 'other', label: 'Other', nativeLabel: 'Other' }, +] as const + +export function parseListParam(value?: string | string[]) { + if (!value) return [] + const parts = Array.isArray(value) ? value : value.split(',') + return parts.map((part) => part.trim()).filter(Boolean) +} + +const getLanguagePrefix = (locale: string) => locale.toLowerCase().split(/[-_]/)[0] ?? '' + +function getSearchLanguagesForLocale(locale: string) { + const requestedLanguage = getLanguagePrefix(locale) + if (!requestedLanguage) return ['en'] + if (requestedLanguage === 'zh') return ['zh-Hans'] + + const knownOption = LANGUAGE_OPTIONS.find( + (option) => option.value !== 'other' && getLanguagePrefix(option.value) === requestedLanguage, + ) + if (knownOption) return [knownOption.value] + + return [requestedLanguage] +} + +export function resolveTemplateSearchLanguages(selectedLanguages: string[], locale: string) { + return selectedLanguages.length > 0 ? selectedLanguages : getSearchLanguagesForLocale(locale) +} + +/** + * Keeps the templates matching the requested locale's language. Templates + * without language metadata are treated as language-agnostic and always kept. + * When no template matches the requested language, the list explicitly falls + * back to English templates (and finally to the unfiltered list) so locales + * such as German render real content instead of an empty state. + */ +export function filterTemplatesForLocale< + T extends Pick<MarketplaceTemplate, 'preferred_languages'>, +>(templates: T[], locale: string) { + const requestedLanguage = getLanguagePrefix(locale) + + const filterByLanguage = (languagePrefix: string) => + templates.filter((template) => { + const preferredLanguages = template.preferred_languages ?? [] + if (preferredLanguages.length === 0) return true + return preferredLanguages.some((language) => getLanguagePrefix(language) === languagePrefix) + }) + + const requestedMatches = filterByLanguage(requestedLanguage) + if (requestedMatches.length > 0) return requestedMatches + + const englishMatches = requestedLanguage === 'en' ? [] : filterByLanguage('en') + if (englishMatches.length > 0) return englishMatches + + return templates +} + +export function getTemplateCollectionText(value: Record<string, string>, locale: string) { + const localeKey = locale.replace('-', '_') + + return value[localeKey] || value.en_US || Object.values(value)[0] || '' +} diff --git a/web/app/components/plugins/marketplace/templates/template-links.ts b/web/app/components/plugins/marketplace/templates/template-links.ts new file mode 100644 index 00000000000..fc0ae9b9425 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-links.ts @@ -0,0 +1,37 @@ +import type { TemplateCategory } from './categories' + +export const PAGE_LINK_CLASS = + 'flex h-8 items-center justify-center rounded-lg border-[0.5px] border-divider-regular px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +export const PAGE_LINK_DISABLED_CLASS = + 'flex h-8 cursor-not-allowed items-center justify-center rounded-lg border-[0.5px] border-divider-subtle px-3 system-sm-medium text-text-quaternary' + +export type TemplatesHrefOptions = { + category: TemplateCategory + languages?: string[] + page?: number + query?: string + sortBy?: string + sortOrder?: string + view?: string +} + +export function buildTemplatesHref({ + category, + languages, + page = 1, + query, + sortBy, + sortOrder, + view, +}: TemplatesHrefOptions) { + const searchParams = new URLSearchParams() + if (query) searchParams.set('q', query) + if (sortBy) searchParams.set('sort_by', sortBy) + if (sortOrder) searchParams.set('sort_order', sortOrder) + if (view) searchParams.set('view', view) + if (languages?.length) searchParams.set('languages', languages.join(',')) + if (page > 1) searchParams.set('page', String(page)) + const queryString = searchParams.toString() + const basePath = category === 'all' ? '/templates' : `/templates/${category}` + return queryString ? `${basePath}?${queryString}` : basePath +} diff --git a/web/app/components/plugins/marketplace/templates/template-pagination.tsx b/web/app/components/plugins/marketplace/templates/template-pagination.tsx new file mode 100644 index 00000000000..9cac5515cb1 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/template-pagination.tsx @@ -0,0 +1,70 @@ +import type { TemplateCategory } from './categories' +import Link from '@/next/link' +import { buildTemplatesHref, PAGE_LINK_CLASS, PAGE_LINK_DISABLED_CLASS } from './template-links' + +// Server-rendered pagination: plain links keep the search results reachable +// beyond the first page without any client-side state. +export default function TemplatePagination({ + category, + languages, + navigationLabel, + nextLabel, + page, + pageCount, + previousLabel, + query, + sortBy, + sortOrder, + view, +}: { + category: TemplateCategory + languages?: string[] + navigationLabel: string + nextLabel: string + page: number + pageCount: number + previousLabel: string + query: string + sortBy?: string + sortOrder?: string + view?: string +}) { + if (pageCount <= 1) return null + + const buildHref = (targetPage: number) => + buildTemplatesHref({ + category, + languages, + page: targetPage, + query, + sortBy, + sortOrder, + view, + }) + + return ( + <nav aria-label={navigationLabel} className="mt-6 flex items-center justify-center gap-3 pb-4"> + {page > 1 ? ( + <Link href={buildHref(page - 1)} className={PAGE_LINK_CLASS}> + {previousLabel} + </Link> + ) : ( + <span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}> + {previousLabel} + </span> + )} + <span aria-current="page" className="system-sm-regular text-text-tertiary"> + {page} / {pageCount} + </span> + {page < pageCount ? ( + <Link href={buildHref(page + 1)} className={PAGE_LINK_CLASS}> + {nextLabel} + </Link> + ) : ( + <span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}> + {nextLabel} + </span> + )} + </nav> + ) +} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index acc77a84ebf..5f1462e06e2 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,7 +1,7 @@ import type { CollectionsAndPluginsSearchParams, - MarketplaceCollection, MarketplacePlugin, + MarketplaceTemplate, PluginsSearchParams, } from '@dify/contracts/marketplace' import type { ActivePluginType } from './constants' @@ -16,6 +16,46 @@ type MarketplaceFetchOptions = { signal?: AbortSignal } +// Matches backend warmup homepageCollectionPluginsRequests Limit: 20 so the +// public POST hits the Redis bucket the scheduler already writes. +export const COLLECTION_PREVIEW_PLUGIN_LIMIT = 20 + +type MarketplacePluginListExtras = { + agent_strategy?: unknown + data_sources?: unknown + model?: unknown + plugins?: unknown + privacy_options?: unknown + privacy_policy?: unknown + readme_meta?: unknown + resource?: unknown + tool?: unknown + triggers?: unknown +} + +export const toListPlugin = (plugin: Plugin): Plugin => { + const { + agent_strategy: _agentStrategy, + data_sources: _dataSources, + introduction: _introduction, + model: _model, + plugins: _plugins, + privacy_options: _privacyOptions, + privacy_policy: _privacyPolicy, + readme_meta: _readmeMeta, + resource: _resource, + tool: _tool, + triggers: _triggers, + ...listFields + } = plugin as Plugin & MarketplacePluginListExtras + + return { + ...listFields, + introduction: '', + endpoint: { settings: [] }, + } +} + export function buildCarouselPages<T>(items: T[], itemsPerPage: number): T[][] { const pages: T[][] = [] @@ -70,72 +110,111 @@ export const getPluginDetailLinkInMarketplace = ( return `/plugin/${org}/${name}` } +export const getTemplateDetailLinkInMarketplace = ( + template: Pick< + MarketplaceTemplate, + 'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name' + >, +) => { + const publisher = template.publisher_handle || template.publisher_unique_handle || 'template' + const search = new URLSearchParams({ templateId: template.id }) + + return `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}?${search.toString()}` +} + +export const getTemplateLinkInMarketplace = ( + template: Pick< + MarketplaceTemplate, + 'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name' + >, + params?: Record<string, string | undefined>, +) => { + const publisher = template.publisher_handle || template.publisher_unique_handle || 'template' + const path = `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}` + + return getMarketplaceUrl(path, { + ...params, + templateId: template.id, + }) +} + export const getMarketplaceCategoryUrl = ( category?: string, params?: Record<string, string | undefined>, ) => { return getMarketplaceUrl(category ? `/plugins/${category}` : '/plugins', params) } +// One collections response lists every catalog carousel and each needs its own +// plugins request. Firing them all at once head-of-line blocks on the browser's +// per-origin connection cap, so the whole catalog waits on the slowest tail +// request — and every one of those is a request the next search has to abort. +const COLLECTION_PLUGINS_CONCURRENCY = 4 + export const getMarketplacePluginsByCollectionId = async ( collectionId: string, query?: CollectionsAndPluginsSearchParams, options?: MarketplaceFetchOptions, ) => { - let plugins: Plugin[] = [] - - try { - const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins( - { - params: { - collectionId, - }, - body: query ?? {}, + const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins( + { + params: { + collectionId, }, - { - signal: options?.signal, - }, - ) - plugins = (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) => - getFormattedPlugin(plugin), - ) - } catch { - plugins = [] - } + body: { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, ...query }, + }, + { + signal: options?.signal, + }, + ) - return plugins + return (marketplaceCollectionPluginsDataJson.data?.plugins || []).map((plugin) => + toListPlugin(getFormattedPlugin(plugin)), + ) } export const getMarketplaceCollectionsAndPlugins = async ( query?: CollectionsAndPluginsSearchParams, options?: MarketplaceFetchOptions, ) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {} - try { - const marketplaceCollectionsDataJson = await marketplaceClient.collections( - { - query: { - ...query, - page: 1, - page_size: 100, - }, + // Deliberately not wrapped in a catch: a swallowed failure resolves as an + // empty catalog, which react-query caches as a success for the whole + // staleTime and renders as "nothing here" with no retry and no error signal. + const marketplaceCollectionsDataJson = await marketplaceClient.collections( + { + query: { + ...query, + page: 1, + page_size: 100, }, - { - signal: options?.signal, - }, - ) - marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] - await Promise.all( - marketplaceCollections.map(async (collection: MarketplaceCollection) => { - const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options) + }, + { + signal: options?.signal, + }, + ) + const marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] + const marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {} - marketplaceCollectionPluginsMap[collection.name] = plugins - }), - ) - } catch { - marketplaceCollections = [] - marketplaceCollectionPluginsMap = {} + const pending = [...marketplaceCollections] + const fetchCollectionPlugins = async () => { + for (let collection = pending.shift(); collection; collection = pending.shift()) { + try { + marketplaceCollectionPluginsMap[collection.name] = + await getMarketplacePluginsByCollectionId(collection.name, query, options) + } catch (error) { + if (options?.signal?.aborted) throw error + // One empty carousel beats a blank catalog: the collection list itself + // loaded, so render what did arrive. Cancellation must not take this + // path — react-query would cache the empty carousels as a success. + marketplaceCollectionPluginsMap[collection.name] = [] + } + } } + await Promise.all( + Array.from( + { length: Math.min(COLLECTION_PLUGINS_CONCURRENCY, pending.length) }, + fetchCollectionPlugins, + ), + ) return { marketplaceCollections, @@ -159,39 +238,35 @@ export const getMarketplacePlugins = async ( const { query, sort_by, sort_order, category, tags, type, page_size = 40 } = queryParams - try { - const res = await marketplaceClient.searchAdvanced( - { - params: { - kind: type === 'bundle' ? 'bundles' : 'plugins', - }, - body: { - page: pageParam, - page_size, - query, - sort_by, - sort_order, - category: category !== 'all' ? category : '', - tags, - }, + // Errors propagate on purpose. Returning a synthesized empty page here made + // every backend failure — and every aborted keystroke — look like a + // successful zero-result search: react-query never saw isError, never + // retried, cached the emptiness, reported total 0 to the analytics flush, and + // permanently killed getNextPageParam for that key. + const res = await marketplaceClient.searchAdvanced( + { + params: { + kind: type === 'bundle' ? 'bundles' : 'plugins', }, - { signal }, - ) - const resPlugins = res.data.bundles || res.data.plugins || [] + body: { + page: pageParam, + page_size, + query, + sort_by, + sort_order, + category: category !== 'all' ? category : '', + tags, + }, + }, + { signal }, + ) + const resPlugins = res.data.bundles || res.data.plugins || [] - return { - plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)), - total: res.data.total, - page: pageParam, - page_size, - } - } catch { - return { - plugins: [], - total: 0, - page: pageParam, - page_size, - } + return { + plugins: resPlugins.map((plugin) => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + page_size, } } @@ -226,11 +301,12 @@ export function getCollectionsParams( category: ActivePluginType, ): CollectionsAndPluginsSearchParams { if (category === PLUGIN_TYPE_SEARCH_MAP.all) { - return {} + return { limit: COLLECTION_PREVIEW_PLUGIN_LIMIT } } return { category, condition: getMarketplaceListCondition(category), type: getMarketplaceListFilterType(category), + limit: COLLECTION_PREVIEW_PLUGIN_LIMIT, } } diff --git a/web/app/components/plugins/marketplace/view.tsx b/web/app/components/plugins/marketplace/view.tsx new file mode 100644 index 00000000000..4737daf3801 --- /dev/null +++ b/web/app/components/plugins/marketplace/view.tsx @@ -0,0 +1,75 @@ +import type { PluginBanner } from '@dify/contracts/marketplace' +import type { ActivePluginType } from './constants' +import type { HomeCatalogTabLabels } from './home/home-catalog-tabs' +import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' +import Description from './description' +import MarketplaceHome from './home' +import ListWrapper from './list/list-wrapper' +import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' + +type MarketplaceVariant = 'default' | 'home' + +export type MarketplaceViewProps = { + banners: PluginBanner[] + showInstallButton?: boolean + linkToMarketplaceDetail?: boolean + pluginTypeSwitchClassName?: string + isMarketplacePlatform?: boolean + marketplaceNav?: React.ReactNode + variant?: MarketplaceVariant + homeHeaderActions?: React.ReactNode + homeCatalogLabels?: HomeCatalogTabLabels + homeCatalogCategories?: React.ReactNode + homeActivePluginType?: ActivePluginType + homeSearch?: React.ReactNode + language?: string +} + +export function MarketplaceView({ + banners, + showInstallButton = false, + linkToMarketplaceDetail = false, + pluginTypeSwitchClassName, + isMarketplacePlatform = false, + marketplaceNav, + variant = 'default', + homeHeaderActions, + homeCatalogLabels, + homeCatalogCategories, + homeActivePluginType, + homeSearch, + language, +}: MarketplaceViewProps) { + return ( + <PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}> + {variant === 'home' ? ( + <MarketplaceHome + actions={homeHeaderActions} + activePluginType={homeActivePluginType} + banners={banners} + catalogCategories={homeCatalogCategories} + catalogLabels={homeCatalogLabels} + search={homeSearch} + isMarketplacePlatform={isMarketplacePlatform} + language={language} + linkToMarketplaceDetail={linkToMarketplaceDetail} + showInstallButton={showInstallButton} + /> + ) : ( + <> + <Description + isMarketplacePlatform={isMarketplacePlatform} + marketplaceNav={marketplaceNav} + /> + {!isMarketplacePlatform && ( + <StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} /> + )} + <ListWrapper + showInstallButton={showInstallButton} + linkToMarketplaceDetail={linkToMarketplaceDetail} + /> + </> + )} + </PluginInstallPermissionProviderGuard> + ) +} diff --git a/web/app/components/plugins/plugin-page/nav-operations.tsx b/web/app/components/plugins/plugin-page/nav-operations.tsx index fb0341ac807..10f15c7cb97 100644 --- a/web/app/components/plugins/plugin-page/nav-operations.tsx +++ b/web/app/components/plugins/plugin-page/nav-operations.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import { MARKETPLACE_URL_PREFIX } from '@/config' @@ -75,28 +76,43 @@ type SubmitRequestDropdownProps = { dividerAfterFirst?: boolean } -export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) { +type SubmitRequestDropdownMenuProps = SubmitRequestDropdownProps & { + docLink: (path: DocPathWithoutLang) => string +} + +// Presentational dropdown. Callers that cannot use useDocLink() -- standalone +// Marketplace SSR -- pass a locale-composed docLink instead. +export function SubmitRequestDropdownMenu({ + dividerAfterFirst, + docLink, +}: SubmitRequestDropdownMenuProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) - const docLink = useDocLink() const options = getOptions(docLink) + const guideLabel = t(($) => $['marketplace.home.guide'], { ns: 'plugin' }) return ( <DropdownMenu open={open} onOpenChange={setOpen}> - <DropdownMenuTrigger - render={ - <IconButton - aria-label={t(($) => $.requestSubmit, { - ns: 'plugin', - defaultValue: t(($) => $.requestAPlugin, { ns: 'plugin' }), - })} - size="lg" - className="data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary" - > - <span aria-hidden className="i-ri-book-open-line size-4 shrink-0" /> - </IconButton> - } - /> + <Tooltip disabled={open}> + <DropdownMenuTrigger + render={ + <TooltipTrigger + render={ + <IconButton + aria-label={guideLabel} + size="lg" + className="data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary" + > + <span aria-hidden className="i-ri-book-open-line size-4 shrink-0" /> + </IconButton> + } + /> + } + /> + <TooltipContent placement="bottom" role="tooltip"> + {guideLabel} + </TooltipContent> + </Tooltip> <DropdownMenuContent placement="bottom-end" sideOffset={4} className="min-w-50 p-1"> {options.map((option, index) => ( <Fragment key={option.href}> @@ -112,3 +128,8 @@ export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdo </DropdownMenu> ) } + +export function SubmitRequestDropdown({ dividerAfterFirst }: SubmitRequestDropdownProps) { + const docLink = useDocLink() + return <SubmitRequestDropdownMenu dividerAfterFirst={dividerAfterFirst} docLink={docLink} /> +} diff --git a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts index 4bc90a1b9bb..f1d734768ee 100644 --- a/web/app/components/tools/marketplace/__tests__/hooks.spec.ts +++ b/web/app/components/tools/marketplace/__tests__/hooks.spec.ts @@ -14,7 +14,7 @@ import { useMarketplace } from '../hooks' const mockQueryMarketplaceCollectionsAndPlugins = vi.fn() const mockQueryPlugins = vi.fn() const mockQueryPluginsWithDebounced = vi.fn() -const mockResetPlugins = vi.fn() +const mockResetQueryParams = vi.fn() const mockFetchNextPage = vi.fn() const mockUseMarketplaceCollectionsAndPlugins = vi.fn() @@ -70,7 +70,7 @@ const setupHookMocks = (overrides?: { }) mockUseMarketplacePlugins.mockReturnValue({ plugins: overrides?.plugins, - resetPlugins: mockResetPlugins, + resetQueryParams: mockResetQueryParams, queryPlugins: mockQueryPlugins, queryPluginsWithDebounced: mockQueryPluginsWithDebounced, isLoading: overrides?.isPluginsLoading ?? false, @@ -125,7 +125,7 @@ describe('useMarketplace', () => { }) expect(mockQueryPluginsWithDebounced).not.toHaveBeenCalled() expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled() - expect(mockResetPlugins).not.toHaveBeenCalled() + expect(mockResetQueryParams).not.toHaveBeenCalled() }) it('should query plugins immediately when only tags are provided', async () => { @@ -163,7 +163,7 @@ describe('useMarketplace', () => { type: 'plugin', }) }) - expect(mockResetPlugins).toHaveBeenCalledTimes(1) + expect(mockResetQueryParams).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index 1b692200c0b..2985e97dbc4 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -29,13 +29,13 @@ export const useMarketplace = ( } = useMarketplaceCollectionsAndPlugins() const { plugins, - resetPlugins, + resetQueryParams, queryPlugins, isLoading: isPluginsLoading, fetchNextPage, hasNextPage, page: pluginsPage, - } = useMarketplacePlugins() + } = useMarketplacePlugins(enabled) const searchPluginTextRef = useRef(searchPluginText) const filterPluginTagsRef = useRef(filterPluginTags) @@ -72,7 +72,7 @@ export const useMarketplace = ( exclude, type: 'plugin', }) - resetPlugins() + resetQueryParams() } } }, [ @@ -80,7 +80,7 @@ export const useMarketplace = ( filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, - resetPlugins, + resetQueryParams, exclude, enabled, isSuccess, diff --git a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts index 6457e90e8c0..e4671dc5c83 100644 --- a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts +++ b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts @@ -48,6 +48,18 @@ describe('post-login redirect utilities', () => { ).toEqual({ kind: 'absolute', href: redirectUrl }) }) + it('should reject a Marketplace origin that is not a trusted Dify login target', () => { + const searchParams = new URLSearchParams({ + redirect_url: 'http://localhost:3001/plugin/langgenius/openai?tab=reviews#rating', + }) + + expect( + resolvePostLoginRedirect( + searchParams as unknown as Parameters<typeof resolvePostLoginRedirect>[0], + ), + ).toEqual({ kind: 'internal', href: '/' }) + }) + it('should use the default target instead of a stored device target when the query target is invalid', () => { setPostLoginRedirect('/device?user_code=ABCD') const searchParams = new URLSearchParams({ redirect_url: 'https://google.com' }) @@ -109,4 +121,15 @@ describe('post-login redirect utilities', () => { expect(resolvePostLoginRedirect()).toEqual({ kind: 'internal', href: '/' }) }) + + it('should preserve every Marketplace OAuth authorize parameter across signin', () => { + setPostLoginRedirect( + '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code', + ) + + expect(resolvePostLoginRedirect()).toEqual({ + kind: 'internal', + href: '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code', + }) + }) }) diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index 8d9991a51f0..fc4ecaaca43 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -7,7 +7,13 @@ const DEVICE_TTL_MS = 15 * 60 * 1000 const ALLOWED: Record<string, ReadonlySet<string>> = { '/device': new Set(['user_code', 'sso_verified']), - '/account/oauth/authorize': new Set(['client_id', 'scope', 'state', 'redirect_uri']), + '/account/oauth/authorize': new Set([ + 'client_id', + 'redirect_uri', + 'response_type', + 'scope', + 'state', + ]), } function validateDeviceRedirect(target: string): string | null { diff --git a/web/config/index.ts b/web/config/index.ts index f6e2883c6d0..018373396f6 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -11,6 +11,19 @@ const getStringConfig = (envVar: string | undefined, defaultValue: string) => { return defaultValue } +const isLocalMarketplaceApiUrl = (url: string | undefined) => + /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/)/i.test(url ?? '') + +// NEXT_PUBLIC_* is inlined at build. The standalone marketplace image sets +// MARKETPLACE_API_URL at runtime instead, so SSR must read that here or it +// falls through to localhost:5002 and creator pages throw. +const runtimeMarketplaceApiUrl = + typeof globalThis.window === 'undefined' && + process.env.MARKETPLACE_API_URL && + !isLocalMarketplaceApiUrl(process.env.MARKETPLACE_API_URL) + ? process.env.MARKETPLACE_API_URL + : undefined + export const API_PREFIX = getStringConfig( env.NEXT_PUBLIC_API_PREFIX, 'http://localhost:5001/console/api', @@ -20,7 +33,7 @@ export const PUBLIC_API_PREFIX = getStringConfig( 'http://localhost:5001/api', ) export const MARKETPLACE_API_PREFIX = getStringConfig( - env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, + runtimeMarketplaceApiUrl || env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, 'http://localhost:5002/api', ) export const MARKETPLACE_URL_PREFIX = getStringConfig(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX, '') diff --git a/web/global.d.ts b/web/global.d.ts index 5a68838920c..5e25133dd25 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -19,5 +19,22 @@ declare global { interface Window { gtag?: Gtag dataLayer?: unknown[] + /** + * Optional analytics bridge injected by the standalone Marketplace host. + * Absent in Dify console builds; see `utils/marketplace-site-track.ts`. + */ + __marketplaceTracking__?: { + track: (eventName: string, properties?: Record<string, unknown>) => void + rememberReferrer: (itemId: string, section: 'banner' | 'search' | 'list' | 'direct') => void + markSearch: (query: string) => void + flushSearch: (resultCount: number) => void + markFilter: (filter: { + filter_type: 'type_tab' | 'category' | 'language' + selection_mode: 'single' | 'multi' + filter_value: string + selected_values: string[] + }) => void + flushFilter: (resultCount: number) => void + } } } diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index afda3c9652a..b348de9e896 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "تنتهي في {{count}} أيام", "license.unlimited": "غير محدود", "loading": "جارٍ التحميل", + "mainNav.help.creatorCenter": "مركز المبدعين", "mainNav.help.docs": "الوثائق", "mainNav.help.learnDify": "تعلّم Dify", "mainNav.help.openMenu": "فتح قائمة المساعدة", @@ -670,6 +671,7 @@ "userProfile.about": "حول", "userProfile.compliance": "الامتثال", "userProfile.contactUs": "اتصل بنا", + "userProfile.discord": "Discord", "userProfile.emailSupport": "دعم البريد الإلكتروني", "userProfile.github": "GitHub", "userProfile.helpCenter": "عرض المستندات", diff --git a/web/i18n/ar-TN/plugin.json b/web/i18n/ar-TN/plugin.json index 5d61557b411..cacf0548c6c 100644 --- a/web/i18n/ar-TN/plugin.json +++ b/web/i18n/ar-TN/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "جميع الإضافات", "marketplace.and": "و", "marketplace.becomePartner": "كن شريكًا", + "marketplace.by": "بواسطة", + "marketplace.carousel.goToPage": "الانتقال إلى الصفحة {{page}}", + "marketplace.carousel.scrollNext": "الصفحة التالية", + "marketplace.carousel.scrollPrevious": "الصفحة السابقة", + "marketplace.creatorProfile.breadcrumbLabel": "مسار التنقل", + "marketplace.creatorProfile.creations": "الأعمال", + "marketplace.creatorProfile.empty": "لا توجد أعمال بعد.", + "marketplace.creatorProfile.home": "الصفحة الرئيسية للسوق", + "marketplace.creatorProfile.loadMore": "تحميل المزيد", + "marketplace.creatorProfile.loadMoreFailed": "تعذر تحميل المزيد من الأعمال.", + "marketplace.creatorProfile.onTheWeb": "على الويب", + "marketplace.creatorProfile.organization": "منظمة", + "marketplace.creatorProfile.searchPlaceholder": "ابحث عن الإضافات والقوالب", + "marketplace.creatorProfile.sort.asc": "ترتيب تصاعدي", + "marketplace.creatorProfile.sort.createdAt": "الأحدث إنشاءً", + "marketplace.creatorProfile.sort.desc": "ترتيب تنازلي", + "marketplace.creatorProfile.sort.popularity": "الشعبية", + "marketplace.creatorProfile.sort.updatedAt": "الأحدث تحديثًا", + "marketplace.creatorProfile.sortBy": "ترتيب حسب", + "marketplace.creatorProfile.title": "ملف المنشئ", + "marketplace.creatorProfile.type.plugin": "إضافة", + "marketplace.creatorProfile.type.template": "قالب", "marketplace.difyMarketplace": "سوق Dify", "marketplace.discover": "اكتشف", "marketplace.empower": "تمكين تطوير الذكاء الاصطناعي الخاص بك", + "marketplace.home.creatorCenter": "مركز المبدعين", + "marketplace.home.guide": "دليل", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "اكتشف. وسّع. ابنِ", + "marketplace.home.plugins": "المكونات الإضافية", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "قوالب", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "إيقاف مؤقت", + "marketplace.home.trendingPlay": "تشغيل", + "marketplace.home.trendingReadMore": "اقرأ المزيد", + "marketplace.home.trendingReadMoreAbout": "اقرأ المزيد عن {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "عرض", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "فشل التحميل. يرجى المحاولة مرة أخرى.", "marketplace.moreFrom": "المزيد من السوق", "marketplace.noPluginFound": "لم يتم العثور على إضافة", "marketplace.partnerTip": "تم التحقق بواسطة شريك Dify", "marketplace.pluginsHeroSubtitle": "استخدم الإضافات التي بناها المجتمع لتعزيز تطوير الذكاء الاصطناعي الخاص بك.", "marketplace.pluginsHeroTitle": "اكتشف. وسّع. ابنِ.", "marketplace.pluginsResult": "{{num}} نتائج", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "فرز حسب", "marketplace.sortOption.firstReleased": "صدر لأول مرة", "marketplace.sortOption.mostPopular": "الأكثر شيوعًا", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 973c59213ae..ebdddd038f1 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Läuft in {{count}} Tagen ab", "license.unlimited": "Unbegrenzt", "loading": "Wird geladen", + "mainNav.help.creatorCenter": "Creator Center", "mainNav.help.docs": "Dokumentation", "mainNav.help.learnDify": "Dify kennenlernen", "mainNav.help.openMenu": "Hilfemenü öffnen", @@ -670,6 +671,7 @@ "userProfile.about": "Über", "userProfile.compliance": "Einhaltung", "userProfile.contactUs": "Kontaktieren Sie uns", + "userProfile.discord": "Discord", "userProfile.emailSupport": "E-Mail-Support", "userProfile.github": "GitHub", "userProfile.helpCenter": "Hilfe", diff --git a/web/i18n/de-DE/plugin.json b/web/i18n/de-DE/plugin.json index 7a60b2b7a49..170db5351bc 100644 --- a/web/i18n/de-DE/plugin.json +++ b/web/i18n/de-DE/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Alle Plugins", "marketplace.and": "und", "marketplace.becomePartner": "Partner werden", + "marketplace.by": "Von", + "marketplace.carousel.goToPage": "Zu Seite {{page}} wechseln", + "marketplace.carousel.scrollNext": "Nächste Seite", + "marketplace.carousel.scrollPrevious": "Vorherige Seite", + "marketplace.creatorProfile.breadcrumbLabel": "Brotkrümelnavigation", + "marketplace.creatorProfile.creations": "Kreationen", + "marketplace.creatorProfile.empty": "Noch keine Kreationen.", + "marketplace.creatorProfile.home": "Marketplace-Startseite", + "marketplace.creatorProfile.loadMore": "Mehr laden", + "marketplace.creatorProfile.loadMoreFailed": "Weitere Kreationen konnten nicht geladen werden.", + "marketplace.creatorProfile.onTheWeb": "Im Web", + "marketplace.creatorProfile.organization": "Organisation", + "marketplace.creatorProfile.searchPlaceholder": "Plugins und Vorlagen suchen", + "marketplace.creatorProfile.sort.asc": "Aufsteigend sortieren", + "marketplace.creatorProfile.sort.createdAt": "Zuletzt erstellt", + "marketplace.creatorProfile.sort.desc": "Absteigend sortieren", + "marketplace.creatorProfile.sort.popularity": "Beliebtheit", + "marketplace.creatorProfile.sort.updatedAt": "Zuletzt aktualisiert", + "marketplace.creatorProfile.sortBy": "Sortieren nach", + "marketplace.creatorProfile.title": "Creator-Profil", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Vorlage", "marketplace.difyMarketplace": "Dify Marktplatz", "marketplace.discover": "Entdecken", "marketplace.empower": "Unterstützen Sie Ihre KI-Entwicklung", + "marketplace.home.creatorCenter": "Creator Center", + "marketplace.home.guide": "Leitfaden", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Entdecken. Erweitern. Entwickeln", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Vorlagen", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pausieren", + "marketplace.home.trendingPlay": "Abspielen", + "marketplace.home.trendingReadMore": "Mehr erfahren", + "marketplace.home.trendingReadMoreAbout": "Mehr erfahren über {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Ansehen", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Laden fehlgeschlagen. Bitte versuchen Sie es erneut.", "marketplace.moreFrom": "Mehr aus dem Marketplace", "marketplace.noPluginFound": "Kein Plugin gefunden", "marketplace.partnerTip": "Von einem Dify-Partner verifiziert", "marketplace.pluginsHeroSubtitle": "Nutzen Sie von der Community erstellte Plugins, um Ihre KI-Entwicklung voranzutreiben.", "marketplace.pluginsHeroTitle": "Entdecken. Erweitern. Entwickeln.", "marketplace.pluginsResult": "{{num}} Ergebnisse", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sortieren nach", "marketplace.sortOption.firstReleased": "Zuerst veröffentlicht", "marketplace.sortOption.mostPopular": "Beliebteste", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index b25d1191ba5..76475612a39 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expiring in {{count}} days", "license.unlimited": "Unlimited", "loading": "Loading", + "mainNav.help.creatorCenter": "Creator Center", "mainNav.help.docs": "Documentation", "mainNav.help.learnDify": "Learn Dify", "mainNav.help.openMenu": "Open help menu", @@ -670,6 +671,7 @@ "userProfile.about": "About", "userProfile.compliance": "Compliance", "userProfile.contactUs": "Contact Us", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Email Support", "userProfile.github": "GitHub", "userProfile.helpCenter": "View Docs", diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index 4c4577a2827..00405eb1d4c 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "All integrations", "marketplace.and": "and", "marketplace.becomePartner": "Become a Partner", + "marketplace.by": "by", + "marketplace.carousel.goToPage": "Go to page {{page}}", + "marketplace.carousel.scrollNext": "Next page", + "marketplace.carousel.scrollPrevious": "Previous page", + "marketplace.creatorProfile.breadcrumbLabel": "Breadcrumb", + "marketplace.creatorProfile.creations": "Creations", + "marketplace.creatorProfile.empty": "No creations yet.", + "marketplace.creatorProfile.home": "Marketplace home", + "marketplace.creatorProfile.loadMore": "Load more", + "marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.", + "marketplace.creatorProfile.onTheWeb": "On the web", + "marketplace.creatorProfile.organization": "Organization", + "marketplace.creatorProfile.searchPlaceholder": "Search plugins and templates", + "marketplace.creatorProfile.sort.asc": "Sort ascending", + "marketplace.creatorProfile.sort.createdAt": "Recently created", + "marketplace.creatorProfile.sort.desc": "Sort descending", + "marketplace.creatorProfile.sort.popularity": "Popularity", + "marketplace.creatorProfile.sort.updatedAt": "Recently updated", + "marketplace.creatorProfile.sortBy": "Sort by", + "marketplace.creatorProfile.title": "Creator Profile", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Template", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Discover", "marketplace.empower": "Empower your AI development", + "marketplace.home.creatorCenter": "Creator Center", + "marketplace.home.guide": "Guide", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Discover. Extend. Build", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Templates", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pause", + "marketplace.home.trendingPlay": "Play", + "marketplace.home.trendingReadMore": "Read more", + "marketplace.home.trendingReadMoreAbout": "Read more about {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "View", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Failed to load. Please try again.", "marketplace.moreFrom": "More from Marketplace", "marketplace.noPluginFound": "No integration found", "marketplace.partnerTip": "Verified by a Dify partner", "marketplace.pluginsHeroSubtitle": "Use community-built integrations to power your AI development.", "marketplace.pluginsHeroTitle": "Discover. Extend. Build.", "marketplace.pluginsResult": "{{num}} results", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sort by", "marketplace.sortOption.firstReleased": "First Released", "marketplace.sortOption.mostPopular": "Most Popular", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 35f58083a55..7b1b6adeff3 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Caducando en {{count}} días", "license.unlimited": "Ilimitado", "loading": "Cargando", + "mainNav.help.creatorCenter": "Centro de creadores", "mainNav.help.docs": "Documentación", "mainNav.help.learnDify": "Aprende Dify", "mainNav.help.openMenu": "Abrir menú de ayuda", @@ -670,6 +671,7 @@ "userProfile.about": "Acerca de", "userProfile.compliance": "Cumplimiento", "userProfile.contactUs": "Contáctenos", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Soporte de Correo Electrónico", "userProfile.github": "GitHub", "userProfile.helpCenter": "Ayuda", diff --git a/web/i18n/es-ES/plugin.json b/web/i18n/es-ES/plugin.json index c1f0551703f..b0961184051 100644 --- a/web/i18n/es-ES/plugin.json +++ b/web/i18n/es-ES/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Todas las integraciones", "marketplace.and": "y", "marketplace.becomePartner": "Conviértete en socio", + "marketplace.by": "Por", + "marketplace.carousel.goToPage": "Ir a la página {{page}}", + "marketplace.carousel.scrollNext": "Página siguiente", + "marketplace.carousel.scrollPrevious": "Página anterior", + "marketplace.creatorProfile.breadcrumbLabel": "Ruta de navegación", + "marketplace.creatorProfile.creations": "Creaciones", + "marketplace.creatorProfile.empty": "Aún no hay creaciones.", + "marketplace.creatorProfile.home": "Inicio del Marketplace", + "marketplace.creatorProfile.loadMore": "Cargar más", + "marketplace.creatorProfile.loadMoreFailed": "No se pudieron cargar más creaciones.", + "marketplace.creatorProfile.onTheWeb": "En la web", + "marketplace.creatorProfile.organization": "Organización", + "marketplace.creatorProfile.searchPlaceholder": "Buscar plugins y plantillas", + "marketplace.creatorProfile.sort.asc": "Orden ascendente", + "marketplace.creatorProfile.sort.createdAt": "Recién creado", + "marketplace.creatorProfile.sort.desc": "Orden descendente", + "marketplace.creatorProfile.sort.popularity": "Popularidad", + "marketplace.creatorProfile.sort.updatedAt": "Recién actualizado", + "marketplace.creatorProfile.sortBy": "Ordenar por", + "marketplace.creatorProfile.title": "Perfil del creador", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Plantilla", "marketplace.difyMarketplace": "Mercado de Dify", "marketplace.discover": "Descubrir", "marketplace.empower": "Potencie su desarrollo de IA", + "marketplace.home.creatorCenter": "Centro de creadores", + "marketplace.home.guide": "Guía", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Descubre. Amplía. Crea", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Plantillas", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pausar", + "marketplace.home.trendingPlay": "Reproducir", + "marketplace.home.trendingReadMore": "Leer más", + "marketplace.home.trendingReadMoreAbout": "Leer más sobre {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Ver", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Error al cargar. Inténtalo de nuevo.", "marketplace.moreFrom": "Más de Marketplace", "marketplace.noPluginFound": "No se ha encontrado ninguna integración", "marketplace.partnerTip": "Verificado por un socio de Dify", "marketplace.pluginsHeroSubtitle": "Usa integraciones creadas por la comunidad para potenciar tu desarrollo de IA.", "marketplace.pluginsHeroTitle": "Descubre. Amplía. Crea.", "marketplace.pluginsResult": "{{num}} resultados", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Ordenar por", "marketplace.sortOption.firstReleased": "Lanzado por primera vez", "marketplace.sortOption.mostPopular": "Lo más popular", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index e25b7985cba..c974aef526d 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "انقضا در {{count}} روز", "license.unlimited": "نامحدود", "loading": "در حال بارگذاری", + "mainNav.help.creatorCenter": "مرکز سازندگان", "mainNav.help.docs": "مستندات", "mainNav.help.learnDify": "یادگیری Dify", "mainNav.help.openMenu": "باز کردن منوی راهنما", @@ -670,6 +671,7 @@ "userProfile.about": "درباره", "userProfile.compliance": "انطباق", "userProfile.contactUs": "با ما تماس بگیرید", + "userProfile.discord": "Discord", "userProfile.emailSupport": "پشتیبانی ایمیل", "userProfile.github": "گیت‌هاب", "userProfile.helpCenter": "راهنما", diff --git a/web/i18n/fa-IR/plugin.json b/web/i18n/fa-IR/plugin.json index 762511f6284..fbd6f94a3e4 100644 --- a/web/i18n/fa-IR/plugin.json +++ b/web/i18n/fa-IR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "همه افزونه‌ها", "marketplace.and": "و", "marketplace.becomePartner": "شریک شوید", + "marketplace.by": "توسط", + "marketplace.carousel.goToPage": "رفتن به صفحه {{page}}", + "marketplace.carousel.scrollNext": "صفحه بعدی", + "marketplace.carousel.scrollPrevious": "صفحه قبلی", + "marketplace.creatorProfile.breadcrumbLabel": "مسیر راهنما", + "marketplace.creatorProfile.creations": "آثار", + "marketplace.creatorProfile.empty": "هنوز اثری وجود ندارد.", + "marketplace.creatorProfile.home": "خانه Marketplace", + "marketplace.creatorProfile.loadMore": "بارگذاری بیشتر", + "marketplace.creatorProfile.loadMoreFailed": "بارگذاری آثار بیشتر ممکن نشد.", + "marketplace.creatorProfile.onTheWeb": "در وب", + "marketplace.creatorProfile.organization": "سازمان", + "marketplace.creatorProfile.searchPlaceholder": "جستجوی افزونه و قالب", + "marketplace.creatorProfile.sort.asc": "مرتب‌سازی صعودی", + "marketplace.creatorProfile.sort.createdAt": "تازه‌ساخته", + "marketplace.creatorProfile.sort.desc": "مرتب‌سازی نزولی", + "marketplace.creatorProfile.sort.popularity": "محبوبیت", + "marketplace.creatorProfile.sort.updatedAt": "تازه‌به‌روزرسانی", + "marketplace.creatorProfile.sortBy": "مرتب‌سازی بر اساس", + "marketplace.creatorProfile.title": "نمایه سازنده", + "marketplace.creatorProfile.type.plugin": "افزونه", + "marketplace.creatorProfile.type.template": "قالب", "marketplace.difyMarketplace": "بازار دیفی", "marketplace.discover": "کشف", "marketplace.empower": "توسعه هوش مصنوعی خود را توانمند کنید", + "marketplace.home.creatorCenter": "مرکز سازندگان", + "marketplace.home.guide": "راهنما", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "کشف کنید. گسترش دهید. بسازید", + "marketplace.home.plugins": "افزونه‌ها", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "الگوها", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "توقف", + "marketplace.home.trendingPlay": "پخش", + "marketplace.home.trendingReadMore": "ادامه مطلب", + "marketplace.home.trendingReadMoreAbout": "ادامه مطلب درباره {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "مشاهده", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "بارگیری ناموفق بود. لطفاً دوباره تلاش کنید.", "marketplace.moreFrom": "اطلاعات بیشتر از Marketplace", "marketplace.noPluginFound": "هیچ افزونه‌ای یافت نشد", "marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی", "marketplace.pluginsHeroSubtitle": "از افزونه‌های ساخته‌شده توسط جامعه برای تقویت توسعه هوش مصنوعی خود استفاده کنید.", "marketplace.pluginsHeroTitle": "کشف کنید. گسترش دهید. بسازید.", "marketplace.pluginsResult": "نتایج {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "شهر سیاه", "marketplace.sortOption.firstReleased": "اولین منتشر شد", "marketplace.sortOption.mostPopular": "محبوب ترین", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 2750b22825d..b842ca44a2b 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expirant dans {{count}} jours", "license.unlimited": "Illimité", "loading": "Chargement", + "mainNav.help.creatorCenter": "Centre des créateurs", "mainNav.help.docs": "Documentation", "mainNav.help.learnDify": "Apprendre Dify", "mainNav.help.openMenu": "Ouvrir le menu d’aide", @@ -670,6 +671,7 @@ "userProfile.about": "À propos", "userProfile.compliance": "Conformité", "userProfile.contactUs": "Contactez-nous", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Support par courriel", "userProfile.github": "GitHub", "userProfile.helpCenter": "Aide", diff --git a/web/i18n/fr-FR/plugin.json b/web/i18n/fr-FR/plugin.json index 28cd0e54140..0c78219f4bc 100644 --- a/web/i18n/fr-FR/plugin.json +++ b/web/i18n/fr-FR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Toutes les intégrations", "marketplace.and": "et", "marketplace.becomePartner": "Devenir partenaire", + "marketplace.by": "Par", + "marketplace.carousel.goToPage": "Aller à la page {{page}}", + "marketplace.carousel.scrollNext": "Page suivante", + "marketplace.carousel.scrollPrevious": "Page précédente", + "marketplace.creatorProfile.breadcrumbLabel": "Fil d'Ariane", + "marketplace.creatorProfile.creations": "Créations", + "marketplace.creatorProfile.empty": "Aucune création pour le moment.", + "marketplace.creatorProfile.home": "Accueil du Marketplace", + "marketplace.creatorProfile.loadMore": "Charger plus", + "marketplace.creatorProfile.loadMoreFailed": "Impossible de charger plus de créations.", + "marketplace.creatorProfile.onTheWeb": "Sur le web", + "marketplace.creatorProfile.organization": "Organisation", + "marketplace.creatorProfile.searchPlaceholder": "Rechercher des plugins et des modèles", + "marketplace.creatorProfile.sort.asc": "Trier par ordre croissant", + "marketplace.creatorProfile.sort.createdAt": "Récemment créé", + "marketplace.creatorProfile.sort.desc": "Trier par ordre décroissant", + "marketplace.creatorProfile.sort.popularity": "Popularité", + "marketplace.creatorProfile.sort.updatedAt": "Récemment mis à jour", + "marketplace.creatorProfile.sortBy": "Trier par", + "marketplace.creatorProfile.title": "Profil du créateur", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Modèle", "marketplace.difyMarketplace": "Marché Dify", "marketplace.discover": "Découvrir", "marketplace.empower": "Renforcez le développement de votre IA", + "marketplace.home.creatorCenter": "Centre des créateurs", + "marketplace.home.guide": "Guide", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Découvrez. Étendez. Créez", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Modèles", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Mettre en pause", + "marketplace.home.trendingPlay": "Lire", + "marketplace.home.trendingReadMore": "En savoir plus", + "marketplace.home.trendingReadMoreAbout": "En savoir plus sur {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Voir", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Échec du chargement. Veuillez réessayer.", "marketplace.moreFrom": "Plus de Marketplace", "marketplace.noPluginFound": "Aucune intégration trouvée", "marketplace.partnerTip": "Vérifié par un partenaire Dify", "marketplace.pluginsHeroSubtitle": "Utilisez des intégrations créées par la communauté pour propulser votre développement de l’IA.", "marketplace.pluginsHeroTitle": "Découvrir. Étendre. Construire.", "marketplace.pluginsResult": "{{num}} résultats", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Ville noire", "marketplace.sortOption.firstReleased": "Première sortie", "marketplace.sortOption.mostPopular": "Les plus populaires", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index a63eebfa40b..a6c3ef31328 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "{{count}} दिनों में समाप्त हो रहा है", "license.unlimited": "असीमित", "loading": "लोड हो रहा है", + "mainNav.help.creatorCenter": "क्रिएटर केंद्र", "mainNav.help.docs": "दस्तावेज़", "mainNav.help.learnDify": "Dify सीखें", "mainNav.help.openMenu": "सहायता मेनू खोलें", @@ -670,6 +671,7 @@ "userProfile.about": "के बारे में", "userProfile.compliance": "अनुपालन", "userProfile.contactUs": "संपर्क करें", + "userProfile.discord": "Discord", "userProfile.emailSupport": "सहायता", "userProfile.github": "गिटहब", "userProfile.helpCenter": "सहायता", diff --git a/web/i18n/hi-IN/plugin.json b/web/i18n/hi-IN/plugin.json index 3db1aa5f6b0..1e2c74c04e2 100644 --- a/web/i18n/hi-IN/plugin.json +++ b/web/i18n/hi-IN/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "सभी इंटीग्रेशन", "marketplace.and": "और", "marketplace.becomePartner": "भागीदार बनें", + "marketplace.by": "द्वारा", + "marketplace.carousel.goToPage": "पृष्ठ {{page}} पर जाएं", + "marketplace.carousel.scrollNext": "अगला पृष्ठ", + "marketplace.carousel.scrollPrevious": "पिछला पृष्ठ", + "marketplace.creatorProfile.breadcrumbLabel": "ब्रेडक्रम्ब", + "marketplace.creatorProfile.creations": "रचनाएँ", + "marketplace.creatorProfile.empty": "अभी कोई रचना नहीं।", + "marketplace.creatorProfile.home": "Marketplace होम", + "marketplace.creatorProfile.loadMore": "और लोड करें", + "marketplace.creatorProfile.loadMoreFailed": "और रचनाएँ लोड नहीं हो सकीं।", + "marketplace.creatorProfile.onTheWeb": "वेब पर", + "marketplace.creatorProfile.organization": "संगठन", + "marketplace.creatorProfile.searchPlaceholder": "प्लगिन और टेम्पलेट खोजें", + "marketplace.creatorProfile.sort.asc": "बढ़ते क्रम में", + "marketplace.creatorProfile.sort.createdAt": "हाल ही में बनाया गया", + "marketplace.creatorProfile.sort.desc": "घटते क्रम में", + "marketplace.creatorProfile.sort.popularity": "लोकप्रियता", + "marketplace.creatorProfile.sort.updatedAt": "हाल ही में अपडेट किया गया", + "marketplace.creatorProfile.sortBy": "क्रमबद्ध करें", + "marketplace.creatorProfile.title": "क्रिएटर प्रोफ़ाइल", + "marketplace.creatorProfile.type.plugin": "प्लगिन", + "marketplace.creatorProfile.type.template": "टेम्पलेट", "marketplace.difyMarketplace": "डिफाई मार्केटप्लेस", "marketplace.discover": "खोजें", "marketplace.empower": "अपने एआई विकास को सशक्त बनाएं", + "marketplace.home.creatorCenter": "क्रिएटर केंद्र", + "marketplace.home.guide": "मार्गदर्शिका", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "खोजें। विस्तार करें। बनाएँ", + "marketplace.home.plugins": "एकीकरण", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "टेम्पलेट", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "रोकें", + "marketplace.home.trendingPlay": "चलाएं", + "marketplace.home.trendingReadMore": "और पढ़ें", + "marketplace.home.trendingReadMoreAbout": "{{title}} के बारे में और पढ़ें", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "देखें", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "लोड नहीं हो सका। कृपया पुनः प्रयास करें।", "marketplace.moreFrom": "मार्केटप्लेस से अधिक", "marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला", "marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित", "marketplace.pluginsHeroSubtitle": "अपने एआई विकास को सशक्त बनाने के लिए समुदाय द्वारा निर्मित इंटीग्रेशन का उपयोग करें।", "marketplace.pluginsHeroTitle": "खोजें। विस्तार करें। निर्माण करें।", "marketplace.pluginsResult": "{{num}} परिणाम", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "काला शहर", "marketplace.sortOption.firstReleased": "पहली बार जारी किया गया", "marketplace.sortOption.mostPopular": "सबसे लोकप्रिय", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index dd5e1bd3361..343f59965a8 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Kedaluwarsa dalam {{count}} hari", "license.unlimited": "Unlimited", "loading": "Memuat", + "mainNav.help.creatorCenter": "Pusat Kreator", "mainNav.help.docs": "Dokumentasi", "mainNav.help.learnDify": "Pelajari Dify", "mainNav.help.openMenu": "Buka menu bantuan", @@ -670,6 +671,7 @@ "userProfile.about": "Tentang", "userProfile.compliance": "Kepatuhan", "userProfile.contactUs": "Hubungi Kami", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Dukungan Email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Docs", diff --git a/web/i18n/id-ID/plugin.json b/web/i18n/id-ID/plugin.json index 9a8c974b659..fca1dd5e777 100644 --- a/web/i18n/id-ID/plugin.json +++ b/web/i18n/id-ID/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Semua integrasi", "marketplace.and": "dan", "marketplace.becomePartner": "Menjadi Partner", + "marketplace.by": "Oleh", + "marketplace.carousel.goToPage": "Buka halaman {{page}}", + "marketplace.carousel.scrollNext": "Halaman berikutnya", + "marketplace.carousel.scrollPrevious": "Halaman sebelumnya", + "marketplace.creatorProfile.breadcrumbLabel": "Jalur navigasi", + "marketplace.creatorProfile.creations": "Karya", + "marketplace.creatorProfile.empty": "Belum ada karya.", + "marketplace.creatorProfile.home": "Beranda Marketplace", + "marketplace.creatorProfile.loadMore": "Muat lebih banyak", + "marketplace.creatorProfile.loadMoreFailed": "Tidak dapat memuat lebih banyak karya.", + "marketplace.creatorProfile.onTheWeb": "Di web", + "marketplace.creatorProfile.organization": "Organisasi", + "marketplace.creatorProfile.searchPlaceholder": "Cari plugin dan template", + "marketplace.creatorProfile.sort.asc": "Urutkan menaik", + "marketplace.creatorProfile.sort.createdAt": "Baru dibuat", + "marketplace.creatorProfile.sort.desc": "Urutkan menurun", + "marketplace.creatorProfile.sort.popularity": "Popularitas", + "marketplace.creatorProfile.sort.updatedAt": "Baru diperbarui", + "marketplace.creatorProfile.sortBy": "Urutkan berdasarkan", + "marketplace.creatorProfile.title": "Profil kreator", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Template", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Menemukan", "marketplace.empower": "Berdayakan pengembangan AI Anda", + "marketplace.home.creatorCenter": "Pusat Kreator", + "marketplace.home.guide": "Panduan", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Temukan. Perluas. Bangun", + "marketplace.home.plugins": "Plugin", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Templat", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Jeda", + "marketplace.home.trendingPlay": "Putar", + "marketplace.home.trendingReadMore": "Baca selengkapnya", + "marketplace.home.trendingReadMoreAbout": "Baca selengkapnya tentang {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Lihat", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Gagal memuat. Silakan coba lagi.", "marketplace.moreFrom": "Selengkapnya dari Marketplace", "marketplace.noPluginFound": "Tidak ada integrasi yang ditemukan", "marketplace.partnerTip": "Diverifikasi oleh partner Dify", "marketplace.pluginsHeroSubtitle": "Gunakan integrasi buatan komunitas untuk mendukung pengembangan AI Anda.", "marketplace.pluginsHeroTitle": "Temukan. Perluas. Bangun.", "marketplace.pluginsResult": "hasil {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Urutkan berdasarkan", "marketplace.sortOption.firstReleased": "Pertama Dirilis", "marketplace.sortOption.mostPopular": "Paling Populer", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index b3b368485f2..df35dfb25f3 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Scadenza tra {{count}} giorni", "license.unlimited": "Illimitato", "loading": "Caricamento", + "mainNav.help.creatorCenter": "Centro creatori", "mainNav.help.docs": "Documentazione", "mainNav.help.learnDify": "Impara Dify", "mainNav.help.openMenu": "Apri menu di aiuto", @@ -670,6 +671,7 @@ "userProfile.about": "Informazioni", "userProfile.compliance": "Conformità", "userProfile.contactUs": "Contattaci", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Supporto Email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Aiuto", diff --git a/web/i18n/it-IT/plugin.json b/web/i18n/it-IT/plugin.json index 0ae8ae120ca..e14e547e1e4 100644 --- a/web/i18n/it-IT/plugin.json +++ b/web/i18n/it-IT/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Tutte le integrazioni", "marketplace.and": "e", "marketplace.becomePartner": "Diventa un partner", + "marketplace.by": "Di", + "marketplace.carousel.goToPage": "Vai alla pagina {{page}}", + "marketplace.carousel.scrollNext": "Pagina successiva", + "marketplace.carousel.scrollPrevious": "Pagina precedente", + "marketplace.creatorProfile.breadcrumbLabel": "Percorso di navigazione", + "marketplace.creatorProfile.creations": "Creazioni", + "marketplace.creatorProfile.empty": "Nessuna creazione al momento.", + "marketplace.creatorProfile.home": "Home del Marketplace", + "marketplace.creatorProfile.loadMore": "Carica altro", + "marketplace.creatorProfile.loadMoreFailed": "Impossibile caricare altre creazioni.", + "marketplace.creatorProfile.onTheWeb": "Sul web", + "marketplace.creatorProfile.organization": "Organizzazione", + "marketplace.creatorProfile.searchPlaceholder": "Cerca plugin e modelli", + "marketplace.creatorProfile.sort.asc": "Ordine crescente", + "marketplace.creatorProfile.sort.createdAt": "Creati di recente", + "marketplace.creatorProfile.sort.desc": "Ordine decrescente", + "marketplace.creatorProfile.sort.popularity": "Popolarità", + "marketplace.creatorProfile.sort.updatedAt": "Aggiornati di recente", + "marketplace.creatorProfile.sortBy": "Ordina per", + "marketplace.creatorProfile.title": "Profilo del creator", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Modello", "marketplace.difyMarketplace": "Mercato Dify", "marketplace.discover": "Scoprire", "marketplace.empower": "Potenzia lo sviluppo dell'intelligenza artificiale", + "marketplace.home.creatorCenter": "Centro creatori", + "marketplace.home.guide": "Guida", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Scopri. Estendi. Crea", + "marketplace.home.plugins": "Plugin", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Modelli", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pausa", + "marketplace.home.trendingPlay": "Riproduci", + "marketplace.home.trendingReadMore": "Scopri di più", + "marketplace.home.trendingReadMoreAbout": "Scopri di più su {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Visualizza", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Caricamento non riuscito. Riprova.", "marketplace.moreFrom": "Altro da Marketplace", "marketplace.noPluginFound": "Nessuna integrazione trovata", "marketplace.partnerTip": "Verificato da un partner Dify", "marketplace.pluginsHeroSubtitle": "Usa integrazioni create dalla community per potenziare lo sviluppo della tua IA.", "marketplace.pluginsHeroTitle": "Scopri. Estendi. Costruisci.", "marketplace.pluginsResult": "{{num}} risultati", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Ordina per", "marketplace.sortOption.firstReleased": "Prima pubblicazione", "marketplace.sortOption.mostPopular": "I più popolari", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index b01f04d92b9..9456ebe50e1 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "有効期限 {{count}} 日", "license.unlimited": "無制限", "loading": "読み込み中", + "mainNav.help.creatorCenter": "クリエイターセンター", "mainNav.help.docs": "ドキュメント", "mainNav.help.learnDify": "Difyを学ぶ", "mainNav.help.openMenu": "ヘルプメニューを開く", @@ -670,6 +671,7 @@ "userProfile.about": "Dify について", "userProfile.compliance": "コンプライアンス", "userProfile.contactUs": "お問い合わせ", + "userProfile.discord": "Discord", "userProfile.emailSupport": "サポート", "userProfile.github": "GitHub", "userProfile.helpCenter": "ドキュメントを見る", diff --git a/web/i18n/ja-JP/plugin.json b/web/i18n/ja-JP/plugin.json index dc25586073a..3617103693e 100644 --- a/web/i18n/ja-JP/plugin.json +++ b/web/i18n/ja-JP/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "すべてのインテグレーション", "marketplace.and": "と", "marketplace.becomePartner": "パートナーになる", + "marketplace.by": "著者:", + "marketplace.carousel.goToPage": "{{page}}ページへ移動", + "marketplace.carousel.scrollNext": "次のページ", + "marketplace.carousel.scrollPrevious": "前のページ", + "marketplace.creatorProfile.breadcrumbLabel": "パンくずリスト", + "marketplace.creatorProfile.creations": "作品", + "marketplace.creatorProfile.empty": "作品はまだありません。", + "marketplace.creatorProfile.home": "Marketplace ホーム", + "marketplace.creatorProfile.loadMore": "さらに読み込む", + "marketplace.creatorProfile.loadMoreFailed": "作品を追加で読み込めませんでした。", + "marketplace.creatorProfile.onTheWeb": "ウェブサイト", + "marketplace.creatorProfile.organization": "組織", + "marketplace.creatorProfile.searchPlaceholder": "プラグインとテンプレートを検索", + "marketplace.creatorProfile.sort.asc": "昇順に並べ替え", + "marketplace.creatorProfile.sort.createdAt": "作成日時", + "marketplace.creatorProfile.sort.desc": "降順に並べ替え", + "marketplace.creatorProfile.sort.popularity": "人気順", + "marketplace.creatorProfile.sort.updatedAt": "更新日時", + "marketplace.creatorProfile.sortBy": "並び順", + "marketplace.creatorProfile.title": "クリエイタープロフィール", + "marketplace.creatorProfile.type.plugin": "プラグイン", + "marketplace.creatorProfile.type.template": "テンプレート", "marketplace.difyMarketplace": "Dify マーケットプレイス", "marketplace.discover": "探索", "marketplace.empower": "AI 開発をサポートする", + "marketplace.home.creatorCenter": "クリエイターセンター", + "marketplace.home.guide": "ガイド", + "marketplace.home.heroSubtitle": "Dify Marketplace で、より安全で信頼性の高いプラグインを見つけましょう。", + "marketplace.home.heroTitle": "見つける。拡張する。構築する", + "marketplace.home.plugins": "プラグイン", + "marketplace.home.searchPlaceholder": "プラグインまたはテンプレートを検索", + "marketplace.home.templates": "テンプレート", + "marketplace.home.trendingByCreator": "{{creator}} 作成", + "marketplace.home.trendingDescription": "実際の利用状況に基づく人気プラグインを2週間ごとに更新。ワークスペースでの実行数によるランキングで、有料掲載や編集部による選定はありません。", + "marketplace.home.trendingPaginationLabel": "トレンドページ", + "marketplace.home.trendingPause": "一時停止", + "marketplace.home.trendingPlay": "再生", + "marketplace.home.trendingReadMore": "続きを読む", + "marketplace.home.trendingReadMoreAbout": "{{title}} の続きを読む", + "marketplace.home.trendingTitle": "みんながインストールしているプラグイン", + "marketplace.home.trendingView": "表示", + "marketplace.languages": "言語フィルタ", + "marketplace.loadError": "読み込みに失敗しました。もう一度お試しください。", "marketplace.moreFrom": "マーケットプレイスからのさらなる情報", "marketplace.noPluginFound": "インテグレーションが見つかりません", "marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています", "marketplace.pluginsHeroSubtitle": "コミュニティ製のインテグレーションを活用して、AI 開発を強化しましょう。", "marketplace.pluginsHeroTitle": "発見する。拡張する。構築する。", "marketplace.pluginsResult": "{{num}} 件の結果", + "marketplace.searchFilterLanguage": "言語を検索", "marketplace.sortBy": "並べ替え", "marketplace.sortOption.firstReleased": "リリース順", "marketplace.sortOption.mostPopular": "人気順", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 56fcb1e5ef9..788bc27b70c 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "{{count}}일 후에 만료", "license.unlimited": "무제한", "loading": "로딩 중", + "mainNav.help.creatorCenter": "크리에이터 센터", "mainNav.help.docs": "문서", "mainNav.help.learnDify": "Dify 배우기", "mainNav.help.openMenu": "도움말 메뉴 열기", @@ -670,6 +671,7 @@ "userProfile.about": "Dify 소개", "userProfile.compliance": "컴플라이언스", "userProfile.contactUs": "문의하기", + "userProfile.discord": "Discord", "userProfile.emailSupport": "이메일 지원", "userProfile.github": "깃허브", "userProfile.helpCenter": "도움말 센터", diff --git a/web/i18n/ko-KR/plugin.json b/web/i18n/ko-KR/plugin.json index ef484a92dab..65634700c22 100644 --- a/web/i18n/ko-KR/plugin.json +++ b/web/i18n/ko-KR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "모든 플러그인", "marketplace.and": "그리고", "marketplace.becomePartner": "파트너 되기", + "marketplace.by": "저자", + "marketplace.carousel.goToPage": "{{page}}페이지로 이동", + "marketplace.carousel.scrollNext": "다음 페이지", + "marketplace.carousel.scrollPrevious": "이전 페이지", + "marketplace.creatorProfile.breadcrumbLabel": "탐색 경로", + "marketplace.creatorProfile.creations": "작품", + "marketplace.creatorProfile.empty": "아직 작품이 없습니다.", + "marketplace.creatorProfile.home": "Marketplace 홈", + "marketplace.creatorProfile.loadMore": "더 보기", + "marketplace.creatorProfile.loadMoreFailed": "작품을 더 불러오지 못했습니다.", + "marketplace.creatorProfile.onTheWeb": "웹에서", + "marketplace.creatorProfile.organization": "조직", + "marketplace.creatorProfile.searchPlaceholder": "플러그인 및 템플릿 검색", + "marketplace.creatorProfile.sort.asc": "오름차순 정렬", + "marketplace.creatorProfile.sort.createdAt": "최근 생성", + "marketplace.creatorProfile.sort.desc": "내림차순 정렬", + "marketplace.creatorProfile.sort.popularity": "인기순", + "marketplace.creatorProfile.sort.updatedAt": "최근 업데이트", + "marketplace.creatorProfile.sortBy": "정렬 기준", + "marketplace.creatorProfile.title": "크리에이터 프로필", + "marketplace.creatorProfile.type.plugin": "플러그인", + "marketplace.creatorProfile.type.template": "템플릿", "marketplace.difyMarketplace": "Dify 마켓플레이스", "marketplace.discover": "발견하다", "marketplace.empower": "AI 개발 역량 강화", + "marketplace.home.creatorCenter": "크리에이터 센터", + "marketplace.home.guide": "가이드", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "발견하고, 확장하고, 구축하세요", + "marketplace.home.plugins": "플러그인", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "템플릿", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "일시정지", + "marketplace.home.trendingPlay": "재생", + "marketplace.home.trendingReadMore": "더 알아보기", + "marketplace.home.trendingReadMoreAbout": "{{title}}에 대해 더 알아보기", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "보기", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "불러오지 못했습니다. 다시 시도해 주세요.", "marketplace.moreFrom": "Marketplace 에서 더 보기", "marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.", "marketplace.partnerTip": "Dify 파트너에 의해 확인됨", "marketplace.pluginsHeroSubtitle": "커뮤니티에서 제작한 플러그인을 사용하여 AI 개발을 강화하세요.", "marketplace.pluginsHeroTitle": "발견하고. 확장하고. 구축하세요.", "marketplace.pluginsResult": "{{num}} 결과", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "정렬", "marketplace.sortOption.firstReleased": "첫 출시", "marketplace.sortOption.mostPopular": "가장 인기 있는", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index c9331276ba2..0727087aea2 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "ຈະໝົດອາຍຸໃນອີກ {{count}} ມື້", "license.unlimited": "ບໍ່ຈຳກັດ", "loading": "ກຳລັງໂຫຼດ", + "mainNav.help.creatorCenter": "ສູນຜູ້ສ້າງ", "mainNav.help.docs": "ເອກະສານປະກອບ", "mainNav.help.learnDify": "ຮຽນຮູ້ Dify", "mainNav.help.openMenu": "ເປີດເມນູຊ່ວຍເຫຼືອ", @@ -670,6 +671,7 @@ "userProfile.about": "ກ່ຽວກັບ", "userProfile.compliance": "ການປະຕິບັດຕາມກົດລະບຽບ", "userProfile.contactUs": "ຕິດຕໍ່ພວກເຮົາ", + "userProfile.discord": "Discord", "userProfile.emailSupport": "ການຊ່ວຍເຫຼືອຜ່ານອີເມວ", "userProfile.github": "GitHub", "userProfile.helpCenter": "ເບິ່ງເອກະສານ", diff --git a/web/i18n/lo-LA/plugin.json b/web/i18n/lo-LA/plugin.json index 6737b98ac14..a7223e1380b 100644 --- a/web/i18n/lo-LA/plugin.json +++ b/web/i18n/lo-LA/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "ການເຊື່ອມຕໍ່ທັງໝົດ", "marketplace.and": "ແລະ", "marketplace.becomePartner": "ເຂົ້າຮ່ວມເປັນພັດທະນາມິດ", + "marketplace.by": "ໂດຍ", + "marketplace.carousel.goToPage": "ໄປທີ່ໜ້າ {{page}}", + "marketplace.carousel.scrollNext": "ໜ້າຕໍ່ໄປ", + "marketplace.carousel.scrollPrevious": "ໜ້າກ່ອນໜ້າ", + "marketplace.creatorProfile.breadcrumbLabel": "ເສັ້ນທາງນຳທາງ", + "marketplace.creatorProfile.creations": "ຜົນງານ", + "marketplace.creatorProfile.empty": "ຍັງບໍ່ມີຜົນງານ.", + "marketplace.creatorProfile.home": "ໜ້າຫຼັກ Marketplace", + "marketplace.creatorProfile.loadMore": "ໂຫຼດເພີ່ມເຕີມ", + "marketplace.creatorProfile.loadMoreFailed": "ໂຫຼດຜົນງານເພີ່ມເຕີມບໍ່ສຳເລັດ.", + "marketplace.creatorProfile.onTheWeb": "ເທິງເວັບ", + "marketplace.creatorProfile.organization": "ອົງກອນ", + "marketplace.creatorProfile.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ", + "marketplace.creatorProfile.sort.asc": "ຮຽງໜ້ອຍໄປຫຼາຍ", + "marketplace.creatorProfile.sort.createdAt": "ສ້າງລ່າສຸດ", + "marketplace.creatorProfile.sort.desc": "ຮຽງຫຼາຍໄປຫາໜ້ອຍ", + "marketplace.creatorProfile.sort.popularity": "ຄວາມນິຍົມ", + "marketplace.creatorProfile.sort.updatedAt": "ອັບເດດລ່າສຸດ", + "marketplace.creatorProfile.sortBy": "ຮຽງຕາມ", + "marketplace.creatorProfile.title": "ໂປຣໄຟລ໌ຜູ້ສ້າງ", + "marketplace.creatorProfile.type.plugin": "ປລັກອິນ", + "marketplace.creatorProfile.type.template": "ແມ່ແບບ", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "ຄົ້ນຫາ", "marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ", + "marketplace.home.creatorCenter": "ສູນຜູ້ສ້າງ", + "marketplace.home.guide": "ຄູ່ມື", + "marketplace.home.heroSubtitle": "ສ້າງດ້ວຍປລັກອິນທີ່ປອດໄພ ແລະ ເຊື່ອຖືໄດ້ຫຼາຍຂຶ້ນຈາກ Dify Marketplace.", + "marketplace.home.heroTitle": "ຄົ້ນຫາ. ຕໍ່ຍອດ. ສ້າງສັນ", + "marketplace.home.plugins": "ປລັກອິນ", + "marketplace.home.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ຫຼື ແມ່ແບບ", + "marketplace.home.templates": "ແມ່ແບບ", + "marketplace.home.trendingByCreator": "ໂດຍ {{creator}}", + "marketplace.home.trendingDescription": "ຄັດເລືອກຈາກການນຳໃຊ້ຕົວຈິງ, ອັບເດດທຸກໆສອງອາທິດ. ຈັດອັນດັບຕາມການເອີ້ນໃຊ້ຕົວຈິງໃນທົ່ວທຸກ workspace — ບໍ່ມີການຈ່າຍເງິນເພື່ອໂຄສະນາ ຫຼື ການຄັດເລືອກໂດຍທີມງານ.", + "marketplace.home.trendingPaginationLabel": "ໜ້າກຳລັງນິຍົມ", + "marketplace.home.trendingPause": "ຢຸດຊົ່ວຄາວ", + "marketplace.home.trendingPlay": "ຫຼິ້ນ", + "marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ", + "marketplace.home.trendingReadMoreAbout": "ອ່ານເພີ່ມເຕີມກ່ຽວກັບ {{title}}", + "marketplace.home.trendingTitle": "ປລັກອິນທີ່ທຸກຄົນກຳລັງຕິດຕັ້ງ", + "marketplace.home.trendingView": "ເບິ່ງ", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "ໂຫຼດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ.", "marketplace.moreFrom": "ເພີ່ມເຕີມຈາກ Marketplace", "marketplace.noPluginFound": "ບໍ່ພົບການເຊື່ອມຕໍ່", "marketplace.partnerTip": "ໄດ້ຮັບການຢືນຢັນໂດຍພັດທະນາມິດຂອງ Dify", "marketplace.pluginsHeroSubtitle": "ນຳໃຊ້ການເຊື່ອມຕໍ່ທີ່ສ້າງໂດຍຊຸມຊົນເພື່ອຂັບເຄື່ອນການພັດທະນາ AI ຂອງທ່ານ.", "marketplace.pluginsHeroTitle": "ຄົ້ນຫາ. ຕໍ່ຍອດ. ສ້າງສັນ.", "marketplace.pluginsResult": "{{num}} ຜົນລາຍການ", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "ຈັດລຽງໂດຍ", "marketplace.sortOption.firstReleased": "ປ່ອຍທຳອິດ", "marketplace.sortOption.mostPopular": "ໄດ້ຮັບຄວາມນິຍົມສູງສຸດ", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index d4a45e17ac1..a066a08717f 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expiring in {{count}} days", "license.unlimited": "Unlimited", "loading": "Loading", + "mainNav.help.creatorCenter": "Creatorcentrum", "mainNav.help.docs": "Documentatie", "mainNav.help.learnDify": "Leer Dify kennen", "mainNav.help.openMenu": "Helpmenu openen", @@ -670,6 +671,7 @@ "userProfile.about": "About", "userProfile.compliance": "Compliance", "userProfile.contactUs": "Contact Us", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Email Support", "userProfile.github": "GitHub", "userProfile.helpCenter": "View Docs", diff --git a/web/i18n/nl-NL/plugin.json b/web/i18n/nl-NL/plugin.json index 4d65218e103..6635ad425cb 100644 --- a/web/i18n/nl-NL/plugin.json +++ b/web/i18n/nl-NL/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Alle plugins", "marketplace.and": "and", "marketplace.becomePartner": "Word partner", + "marketplace.by": "Door", + "marketplace.carousel.goToPage": "Ga naar pagina {{page}}", + "marketplace.carousel.scrollNext": "Volgende pagina", + "marketplace.carousel.scrollPrevious": "Vorige pagina", + "marketplace.creatorProfile.breadcrumbLabel": "Broodkruimelnavigatie", + "marketplace.creatorProfile.creations": "Creaties", + "marketplace.creatorProfile.empty": "Nog geen creaties.", + "marketplace.creatorProfile.home": "Marketplace-startpagina", + "marketplace.creatorProfile.loadMore": "Meer laden", + "marketplace.creatorProfile.loadMoreFailed": "Kon geen extra creaties laden.", + "marketplace.creatorProfile.onTheWeb": "Op het web", + "marketplace.creatorProfile.organization": "Organisatie", + "marketplace.creatorProfile.searchPlaceholder": "Zoek plugins en sjablonen", + "marketplace.creatorProfile.sort.asc": "Oplopend sorteren", + "marketplace.creatorProfile.sort.createdAt": "Recent gemaakt", + "marketplace.creatorProfile.sort.desc": "Aflopend sorteren", + "marketplace.creatorProfile.sort.popularity": "Populariteit", + "marketplace.creatorProfile.sort.updatedAt": "Recent bijgewerkt", + "marketplace.creatorProfile.sortBy": "Sorteren op", + "marketplace.creatorProfile.title": "Makerprofiel", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Sjabloon", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Discover", "marketplace.empower": "Empower your AI development", + "marketplace.home.creatorCenter": "Creatorcentrum", + "marketplace.home.guide": "Handleiding", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Ontdek. Breid uit. Bouw", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Sjablonen", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pauzeren", + "marketplace.home.trendingPlay": "Afspelen", + "marketplace.home.trendingReadMore": "Lees meer", + "marketplace.home.trendingReadMoreAbout": "Lees meer over {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Bekijken", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Laden mislukt. Probeer het opnieuw.", "marketplace.moreFrom": "More from Marketplace", "marketplace.noPluginFound": "Geen plugin gevonden", "marketplace.partnerTip": "Verified by a Dify partner", "marketplace.pluginsHeroSubtitle": "Gebruik door de community gebouwde plugins om je AI-ontwikkeling te versterken.", "marketplace.pluginsHeroTitle": "Ontdek. Breid uit. Bouw.", "marketplace.pluginsResult": "{{num}} results", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sort by", "marketplace.sortOption.firstReleased": "First Released", "marketplace.sortOption.mostPopular": "Most Popular", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index f95132beafe..ad7c068017a 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Wygasa za {{count}} dni", "license.unlimited": "Nieograniczony", "loading": "Ładowanie", + "mainNav.help.creatorCenter": "Centrum twórców", "mainNav.help.docs": "Dokumentacja", "mainNav.help.learnDify": "Poznaj Dify", "mainNav.help.openMenu": "Otwórz menu pomocy", @@ -670,6 +671,7 @@ "userProfile.about": "O", "userProfile.compliance": "Zgodność", "userProfile.contactUs": "Skontaktuj się z nami", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Wsparcie e-mail", "userProfile.github": "GitHub", "userProfile.helpCenter": "Pomoc", diff --git a/web/i18n/pl-PL/plugin.json b/web/i18n/pl-PL/plugin.json index 1443bc0f79e..f053575dd93 100644 --- a/web/i18n/pl-PL/plugin.json +++ b/web/i18n/pl-PL/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Wszystkie integracje", "marketplace.and": "i", "marketplace.becomePartner": "Zostań partnerem", + "marketplace.by": "Przez", + "marketplace.carousel.goToPage": "Przejdź do strony {{page}}", + "marketplace.carousel.scrollNext": "Następna strona", + "marketplace.carousel.scrollPrevious": "Poprzednia strona", + "marketplace.creatorProfile.breadcrumbLabel": "Ścieżka nawigacji", + "marketplace.creatorProfile.creations": "Twórczość", + "marketplace.creatorProfile.empty": "Brak prac.", + "marketplace.creatorProfile.home": "Strona główna Marketplace", + "marketplace.creatorProfile.loadMore": "Załaduj więcej", + "marketplace.creatorProfile.loadMoreFailed": "Nie udało się załadować kolejnych prac.", + "marketplace.creatorProfile.onTheWeb": "W sieci", + "marketplace.creatorProfile.organization": "Organizacja", + "marketplace.creatorProfile.searchPlaceholder": "Szukaj wtyczek i szablonów", + "marketplace.creatorProfile.sort.asc": "Sortuj rosnąco", + "marketplace.creatorProfile.sort.createdAt": "Ostatnio utworzone", + "marketplace.creatorProfile.sort.desc": "Sortuj malejąco", + "marketplace.creatorProfile.sort.popularity": "Popularność", + "marketplace.creatorProfile.sort.updatedAt": "Ostatnio zaktualizowane", + "marketplace.creatorProfile.sortBy": "Sortuj według", + "marketplace.creatorProfile.title": "Profil twórcy", + "marketplace.creatorProfile.type.plugin": "Wtyczka", + "marketplace.creatorProfile.type.template": "Szablon", "marketplace.difyMarketplace": "Rynek Dify", "marketplace.discover": "Odkryć", "marketplace.empower": "Zwiększ możliwości rozwoju sztucznej inteligencji", + "marketplace.home.creatorCenter": "Centrum twórców", + "marketplace.home.guide": "Przewodnik", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Odkrywaj. Rozszerzaj. Twórz", + "marketplace.home.plugins": "Integracje", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Szablony", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Wstrzymaj", + "marketplace.home.trendingPlay": "Odtwórz", + "marketplace.home.trendingReadMore": "Czytaj więcej", + "marketplace.home.trendingReadMoreAbout": "Czytaj więcej o {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Zobacz", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Nie udało się załadować. Spróbuj ponownie.", "marketplace.moreFrom": "Więcej z Marketplace", "marketplace.noPluginFound": "Nie znaleziono integracji", "marketplace.partnerTip": "Zweryfikowane przez partnera Dify", "marketplace.pluginsHeroSubtitle": "Korzystaj z integracji tworzonych przez społeczność, aby wspierać rozwój swojej sztucznej inteligencji.", "marketplace.pluginsHeroTitle": "Odkrywaj. Rozszerzaj. Twórz.", "marketplace.pluginsResult": "{{num}} wyniki", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Czarne miasto", "marketplace.sortOption.firstReleased": "Po raz pierwszy wydany", "marketplace.sortOption.mostPopular": "Najpopularniejsze", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index 1b84bd37e5c..19d4f1a554a 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expirando em {{count}} dias", "license.unlimited": "Ilimitado", "loading": "Carregando", + "mainNav.help.creatorCenter": "Central do criador", "mainNav.help.docs": "Documentação", "mainNav.help.learnDify": "Aprenda Dify", "mainNav.help.openMenu": "Abrir menu de ajuda", @@ -670,6 +671,7 @@ "userProfile.about": "Sobre", "userProfile.compliance": "Conformidade", "userProfile.contactUs": "Contate-Nos", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Suporte por e-mail", "userProfile.github": "GitHub", "userProfile.helpCenter": "Ajuda", diff --git a/web/i18n/pt-BR/plugin.json b/web/i18n/pt-BR/plugin.json index 83d0c8053f9..e9c921a6e2c 100644 --- a/web/i18n/pt-BR/plugin.json +++ b/web/i18n/pt-BR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Todas as integrações", "marketplace.and": "e", "marketplace.becomePartner": "Torne-se um parceiro", + "marketplace.by": "Por", + "marketplace.carousel.goToPage": "Ir para a página {{page}}", + "marketplace.carousel.scrollNext": "Próxima página", + "marketplace.carousel.scrollPrevious": "Página anterior", + "marketplace.creatorProfile.breadcrumbLabel": "Navegação estrutural", + "marketplace.creatorProfile.creations": "Criações", + "marketplace.creatorProfile.empty": "Nenhuma criação ainda.", + "marketplace.creatorProfile.home": "Página inicial do Marketplace", + "marketplace.creatorProfile.loadMore": "Carregar mais", + "marketplace.creatorProfile.loadMoreFailed": "Não foi possível carregar mais criações.", + "marketplace.creatorProfile.onTheWeb": "Na web", + "marketplace.creatorProfile.organization": "Organização", + "marketplace.creatorProfile.searchPlaceholder": "Pesquisar plugins e modelos", + "marketplace.creatorProfile.sort.asc": "Ordenar crescente", + "marketplace.creatorProfile.sort.createdAt": "Criado recentemente", + "marketplace.creatorProfile.sort.desc": "Ordenar decrescente", + "marketplace.creatorProfile.sort.popularity": "Popularidade", + "marketplace.creatorProfile.sort.updatedAt": "Atualizado recentemente", + "marketplace.creatorProfile.sortBy": "Ordenar por", + "marketplace.creatorProfile.title": "Perfil do criador", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Modelo", "marketplace.difyMarketplace": "Mercado Dify", "marketplace.discover": "Descobrir", "marketplace.empower": "Capacite seu desenvolvimento de IA", + "marketplace.home.creatorCenter": "Central do criador", + "marketplace.home.guide": "Guia", + "marketplace.home.heroSubtitle": "Crie com plugins mais seguros e confiáveis do Dify Marketplace.", + "marketplace.home.heroTitle": "Descubra. Expanda. Crie", + "marketplace.home.plugins": "Plugins", + "marketplace.home.searchPlaceholder": "Buscar plugins ou modelos", + "marketplace.home.templates": "Modelos", + "marketplace.home.trendingByCreator": "por {{creator}}", + "marketplace.home.trendingDescription": "Destaques por uso real, atualizados a cada duas semanas. Classificados pelas execuções reais nos espaços de trabalho — sem promoção paga ou seleção editorial.", + "marketplace.home.trendingPaginationLabel": "Páginas em alta", + "marketplace.home.trendingPause": "Pausar", + "marketplace.home.trendingPlay": "Reproduzir", + "marketplace.home.trendingReadMore": "Leia mais", + "marketplace.home.trendingReadMoreAbout": "Leia mais sobre {{title}}", + "marketplace.home.trendingTitle": "Os plugins que todos estão instalando", + "marketplace.home.trendingView": "Ver", + "marketplace.languages": "Idiomas", + "marketplace.loadError": "Falha ao carregar. Tente novamente.", "marketplace.moreFrom": "Mais do Marketplace", "marketplace.noPluginFound": "Nenhuma integração encontrada", "marketplace.partnerTip": "Verificado por um parceiro da Dify", "marketplace.pluginsHeroSubtitle": "Use integrações criadas pela comunidade para impulsionar seu desenvolvimento de IA.", "marketplace.pluginsHeroTitle": "Descubra. Estenda. Construa.", "marketplace.pluginsResult": "{{num}} resultados", + "marketplace.searchFilterLanguage": "Pesquisar idioma", "marketplace.sortBy": "Ordenar por", "marketplace.sortOption.firstReleased": "Lançado pela primeira vez", "marketplace.sortOption.mostPopular": "Mais popular", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index d5969e7ab76..b920afc5b2f 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Expiră în {{count}} zile", "license.unlimited": "Nelimitat", "loading": "Se încarcă", + "mainNav.help.creatorCenter": "Centrul creatorilor", "mainNav.help.docs": "Documentație", "mainNav.help.learnDify": "Învață Dify", "mainNav.help.openMenu": "Deschide meniul de ajutor", @@ -670,6 +671,7 @@ "userProfile.about": "Despre", "userProfile.compliance": "Conformitate", "userProfile.contactUs": "Contactați-ne", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Suport prin email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Ajutor", diff --git a/web/i18n/ro-RO/plugin.json b/web/i18n/ro-RO/plugin.json index 7c4911e1e2d..0cd34a81faa 100644 --- a/web/i18n/ro-RO/plugin.json +++ b/web/i18n/ro-RO/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Toate pluginurile", "marketplace.and": "și", "marketplace.becomePartner": "Deveniți partener", + "marketplace.by": "De", + "marketplace.carousel.goToPage": "Mergi la pagina {{page}}", + "marketplace.carousel.scrollNext": "Pagina următoare", + "marketplace.carousel.scrollPrevious": "Pagina anterioară", + "marketplace.creatorProfile.breadcrumbLabel": "Fir de navigare", + "marketplace.creatorProfile.creations": "Creații", + "marketplace.creatorProfile.empty": "Nicio creație încă.", + "marketplace.creatorProfile.home": "Pagina principală Marketplace", + "marketplace.creatorProfile.loadMore": "Încarcă mai multe", + "marketplace.creatorProfile.loadMoreFailed": "Nu s-au putut încărca mai multe creații.", + "marketplace.creatorProfile.onTheWeb": "Pe web", + "marketplace.creatorProfile.organization": "Organizație", + "marketplace.creatorProfile.searchPlaceholder": "Caută pluginuri și șabloane", + "marketplace.creatorProfile.sort.asc": "Sortare crescătoare", + "marketplace.creatorProfile.sort.createdAt": "Create recent", + "marketplace.creatorProfile.sort.desc": "Sortare descrescătoare", + "marketplace.creatorProfile.sort.popularity": "Popularitate", + "marketplace.creatorProfile.sort.updatedAt": "Actualizate recent", + "marketplace.creatorProfile.sortBy": "Sortează după", + "marketplace.creatorProfile.title": "Profilul creatorului", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Șablon", "marketplace.difyMarketplace": "Piața Dify", "marketplace.discover": "Descoperi", "marketplace.empower": "Îmbunătățește-ți dezvoltarea AI", + "marketplace.home.creatorCenter": "Centrul creatorilor", + "marketplace.home.guide": "Ghid", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Descoperă. Extinde. Construiește", + "marketplace.home.plugins": "Plugin-uri", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Șabloane", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Pauză", + "marketplace.home.trendingPlay": "Redare", + "marketplace.home.trendingReadMore": "Citește mai mult", + "marketplace.home.trendingReadMoreAbout": "Citește mai mult despre {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Vezi", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Încărcarea a eșuat. Încercați din nou.", "marketplace.moreFrom": "Mai multe din Marketplace", "marketplace.noPluginFound": "Nu s-a găsit niciun plugin", "marketplace.partnerTip": "Verificat de un partener Dify", "marketplace.pluginsHeroSubtitle": "Folosiți pluginuri create de comunitate pentru a vă alimenta dezvoltarea AI.", "marketplace.pluginsHeroTitle": "Descoperă. Extinde. Construiește.", "marketplace.pluginsResult": "{{num}} rezultate", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sortează după", "marketplace.sortOption.firstReleased": "Prima lansare", "marketplace.sortOption.mostPopular": "Cele mai populare", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index ac071704d7f..150f549514d 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Срок действия истекает через {{count}} дней", "license.unlimited": "Неограниченный", "loading": "Загрузка", + "mainNav.help.creatorCenter": "Центр авторов", "mainNav.help.docs": "Документация", "mainNav.help.learnDify": "Изучить Dify", "mainNav.help.openMenu": "Открыть меню помощи", @@ -670,6 +671,7 @@ "userProfile.about": "О нас", "userProfile.compliance": "Соблюдение", "userProfile.contactUs": "Свяжитесь с нами", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Поддержка по электронной почте", "userProfile.github": "ГитХаб", "userProfile.helpCenter": "Помощь", diff --git a/web/i18n/ru-RU/plugin.json b/web/i18n/ru-RU/plugin.json index 484b33cffcf..12cb07aaa05 100644 --- a/web/i18n/ru-RU/plugin.json +++ b/web/i18n/ru-RU/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Все плагины", "marketplace.and": "и", "marketplace.becomePartner": "Стать партнёром", + "marketplace.by": "Автор", + "marketplace.carousel.goToPage": "Перейти на страницу {{page}}", + "marketplace.carousel.scrollNext": "Следующая страница", + "marketplace.carousel.scrollPrevious": "Предыдущая страница", + "marketplace.creatorProfile.breadcrumbLabel": "Навигационная цепочка", + "marketplace.creatorProfile.creations": "Работы", + "marketplace.creatorProfile.empty": "Пока нет работ.", + "marketplace.creatorProfile.home": "Главная Marketplace", + "marketplace.creatorProfile.loadMore": "Загрузить ещё", + "marketplace.creatorProfile.loadMoreFailed": "Не удалось загрузить больше работ.", + "marketplace.creatorProfile.onTheWeb": "В интернете", + "marketplace.creatorProfile.organization": "Организация", + "marketplace.creatorProfile.searchPlaceholder": "Поиск плагинов и шаблонов", + "marketplace.creatorProfile.sort.asc": "По возрастанию", + "marketplace.creatorProfile.sort.createdAt": "Недавно создано", + "marketplace.creatorProfile.sort.desc": "По убыванию", + "marketplace.creatorProfile.sort.popularity": "Популярность", + "marketplace.creatorProfile.sort.updatedAt": "Недавно обновлено", + "marketplace.creatorProfile.sortBy": "Сортировать", + "marketplace.creatorProfile.title": "Профиль автора", + "marketplace.creatorProfile.type.plugin": "Плагин", + "marketplace.creatorProfile.type.template": "Шаблон", "marketplace.difyMarketplace": "Торговая площадка Dify", "marketplace.discover": "Обнаруживать", "marketplace.empower": "Расширьте возможности разработки ИИ", + "marketplace.home.creatorCenter": "Центр авторов", + "marketplace.home.guide": "Руководство", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Открывайте. Расширяйте. Создавайте", + "marketplace.home.plugins": "Интеграции", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Шаблоны", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Пауза", + "marketplace.home.trendingPlay": "Воспроизвести", + "marketplace.home.trendingReadMore": "Читать далее", + "marketplace.home.trendingReadMoreAbout": "Подробнее о {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Открыть", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Не удалось загрузить. Повторите попытку.", "marketplace.moreFrom": "Больше из Marketplace", "marketplace.noPluginFound": "Плагин не найден", "marketplace.partnerTip": "Подтверждено партнером Dify", "marketplace.pluginsHeroSubtitle": "Используйте плагины, созданные сообществом, чтобы ускорить разработку ИИ.", "marketplace.pluginsHeroTitle": "Открывайте. Расширяйте. Создавайте.", "marketplace.pluginsResult": "Результаты {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Черный город", "marketplace.sortOption.firstReleased": "Впервые выпущен", "marketplace.sortOption.mostPopular": "Самые популярные", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 2db7fc57bf6..95b4fd6ccc5 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Poteče v {{count}} dneh", "license.unlimited": "Brez omejitev", "loading": "Nalaganje", + "mainNav.help.creatorCenter": "Središče za ustvarjalce", "mainNav.help.docs": "Dokumentacija", "mainNav.help.learnDify": "Spoznajte Dify", "mainNav.help.openMenu": "Odpri meni pomoči", @@ -670,6 +671,7 @@ "userProfile.about": "O nas", "userProfile.compliance": "Skladnost", "userProfile.contactUs": "Kontaktirajte nas", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Podpora po e-pošti", "userProfile.github": "GitHub", "userProfile.helpCenter": "Pomoč", diff --git a/web/i18n/sl-SI/plugin.json b/web/i18n/sl-SI/plugin.json index 7e79ef2ecea..19f3882fb60 100644 --- a/web/i18n/sl-SI/plugin.json +++ b/web/i18n/sl-SI/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Vsi vtičniki", "marketplace.and": "in", "marketplace.becomePartner": "Postanite partner", + "marketplace.by": "Avtor", + "marketplace.carousel.goToPage": "Pojdi na stran {{page}}", + "marketplace.carousel.scrollNext": "Naslednja stran", + "marketplace.carousel.scrollPrevious": "Prejšnja stran", + "marketplace.creatorProfile.breadcrumbLabel": "Drobtinice", + "marketplace.creatorProfile.creations": "Stvaritve", + "marketplace.creatorProfile.empty": "Še ni stvaritev.", + "marketplace.creatorProfile.home": "Domov Marketplace", + "marketplace.creatorProfile.loadMore": "Naloži več", + "marketplace.creatorProfile.loadMoreFailed": "Ni bilo mogoče naložiti več stvaritev.", + "marketplace.creatorProfile.onTheWeb": "Na spletu", + "marketplace.creatorProfile.organization": "Organizacija", + "marketplace.creatorProfile.searchPlaceholder": "Iskanje vtičnikov in predlog", + "marketplace.creatorProfile.sort.asc": "Razvrsti naraščajoče", + "marketplace.creatorProfile.sort.createdAt": "Nedavno ustvarjeno", + "marketplace.creatorProfile.sort.desc": "Razvrsti padajoče", + "marketplace.creatorProfile.sort.popularity": "Priljubljenost", + "marketplace.creatorProfile.sort.updatedAt": "Nedavno posodobljeno", + "marketplace.creatorProfile.sortBy": "Razvrsti po", + "marketplace.creatorProfile.title": "Profil ustvarjalca", + "marketplace.creatorProfile.type.plugin": "Vtičnik", + "marketplace.creatorProfile.type.template": "Predloga", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Odkrijte", "marketplace.empower": "Okrepite svoj razvoj AI", + "marketplace.home.creatorCenter": "Središče za ustvarjalce", + "marketplace.home.guide": "Vodnik", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Odkrijte. Razširite. Ustvarite", + "marketplace.home.plugins": "Integracije", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Predloge", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Premor", + "marketplace.home.trendingPlay": "Predvajaj", + "marketplace.home.trendingReadMore": "Preberi več", + "marketplace.home.trendingReadMoreAbout": "Preberi več o {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Ogled", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Nalaganje ni uspelo. Poskusite znova.", "marketplace.moreFrom": "Več iz tržnice", "marketplace.noPluginFound": "Nobenega vtičnika ni bilo najti.", "marketplace.partnerTip": "Potrjeno s strani partnerja Dify", "marketplace.pluginsHeroSubtitle": "Uporabite vtičnike, ki jih je ustvarila skupnost, za pospešitev vašega razvoja AI.", "marketplace.pluginsHeroTitle": "Odkrijte. Razširite. Gradite.", "marketplace.pluginsResult": "{{num}} rezultati", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Razvrsti po", "marketplace.sortOption.firstReleased": "Prvič izdan", "marketplace.sortOption.mostPopular": "Najbolj priljubljeno", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 1318ab13927..64f2f9e3d88 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "หมดอายุใน {{count}} วัน", "license.unlimited": "ไม่มีขีดจำกัด", "loading": "กำลังโหลด", + "mainNav.help.creatorCenter": "ศูนย์ครีเอเตอร์", "mainNav.help.docs": "เอกสาร", "mainNav.help.learnDify": "เรียนรู้ Dify", "mainNav.help.openMenu": "เปิดเมนูช่วยเหลือ", @@ -670,6 +671,7 @@ "userProfile.about": "ประมาณ", "userProfile.compliance": "การปฏิบัติตามข้อกำหนด", "userProfile.contactUs": "ติดต่อเรา", + "userProfile.discord": "Discord", "userProfile.emailSupport": "การสนับสนุนทางอีเมล", "userProfile.github": "GitHub", "userProfile.helpCenter": "วิธีใช้", diff --git a/web/i18n/th-TH/plugin.json b/web/i18n/th-TH/plugin.json index f8655669d0f..fb7918c0e6d 100644 --- a/web/i18n/th-TH/plugin.json +++ b/web/i18n/th-TH/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "ปลั๊กอินทั้งหมด", "marketplace.and": "และ", "marketplace.becomePartner": "เป็นพันธมิตร", + "marketplace.by": "โดย", + "marketplace.carousel.goToPage": "ไปที่หน้า {{page}}", + "marketplace.carousel.scrollNext": "หน้าถัดไป", + "marketplace.carousel.scrollPrevious": "หน้าก่อนหน้า", + "marketplace.creatorProfile.breadcrumbLabel": "เส้นทางนำทาง", + "marketplace.creatorProfile.creations": "ผลงาน", + "marketplace.creatorProfile.empty": "ยังไม่มีผลงาน", + "marketplace.creatorProfile.home": "หน้าแรก Marketplace", + "marketplace.creatorProfile.loadMore": "โหลดเพิ่มเติม", + "marketplace.creatorProfile.loadMoreFailed": "ไม่สามารถโหลดผลงานเพิ่มเติมได้", + "marketplace.creatorProfile.onTheWeb": "บนเว็บ", + "marketplace.creatorProfile.organization": "องค์กร", + "marketplace.creatorProfile.searchPlaceholder": "ค้นหาปลั๊กอินและเทมเพลต", + "marketplace.creatorProfile.sort.asc": "เรียงจากน้อยไปมาก", + "marketplace.creatorProfile.sort.createdAt": "สร้างล่าสุด", + "marketplace.creatorProfile.sort.desc": "เรียงจากมากไปน้อย", + "marketplace.creatorProfile.sort.popularity": "ความนิยม", + "marketplace.creatorProfile.sort.updatedAt": "อัปเดตล่าสุด", + "marketplace.creatorProfile.sortBy": "เรียงตาม", + "marketplace.creatorProfile.title": "โปรไฟล์ครีเอเตอร์", + "marketplace.creatorProfile.type.plugin": "ปลั๊กอิน", + "marketplace.creatorProfile.type.template": "เทมเพลต", "marketplace.difyMarketplace": "ตลาด Dify", "marketplace.discover": "ค้นพบ", "marketplace.empower": "เพิ่มศักยภาพในการพัฒนา AI ของคุณ", + "marketplace.home.creatorCenter": "ศูนย์ครีเอเตอร์", + "marketplace.home.guide": "คู่มือ", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "ค้นพบ ขยาย และสร้าง", + "marketplace.home.plugins": "ปลั๊กอิน", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "เทมเพลต", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "หยุดชั่วคราว", + "marketplace.home.trendingPlay": "เล่น", + "marketplace.home.trendingReadMore": "อ่านเพิ่มเติม", + "marketplace.home.trendingReadMoreAbout": "อ่านเพิ่มเติมเกี่ยวกับ {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "ดู", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "โหลดไม่สำเร็จ โปรดลองอีกครั้ง", "marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace", "marketplace.noPluginFound": "ไม่พบปลั๊กอิน", "marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify", "marketplace.pluginsHeroSubtitle": "ใช้ปลั๊กอินที่สร้างโดยชุมชนเพื่อเสริมพลังการพัฒนา AI ของคุณ", "marketplace.pluginsHeroTitle": "ค้นพบ ขยาย สร้าง", "marketplace.pluginsResult": "{{num}} ผลลัพธ์", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "เมืองสีดํา", "marketplace.sortOption.firstReleased": "เปิดตัวครั้งแรก", "marketplace.sortOption.mostPopular": "แห่ง", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 2e11f9626da..a0cc36eddfe 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "{{count}} gün içinde sona eriyor", "license.unlimited": "Sınırsız", "loading": "Yükleniyor", + "mainNav.help.creatorCenter": "İçerik Üretici Merkezi", "mainNav.help.docs": "Belgeler", "mainNav.help.learnDify": "Dify’ı öğrenin", "mainNav.help.openMenu": "Yardım menüsünü aç", @@ -670,6 +671,7 @@ "userProfile.about": "Hakkında", "userProfile.compliance": "Uygunluk", "userProfile.contactUs": "Bize Ulaşın", + "userProfile.discord": "Discord", "userProfile.emailSupport": "E-posta Desteği", "userProfile.github": "GitHub", "userProfile.helpCenter": "Yardım", diff --git a/web/i18n/tr-TR/plugin.json b/web/i18n/tr-TR/plugin.json index a17fd883143..305c6412fc0 100644 --- a/web/i18n/tr-TR/plugin.json +++ b/web/i18n/tr-TR/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Tüm eklentiler", "marketplace.and": "ve", "marketplace.becomePartner": "Partner Olun", + "marketplace.by": "Tarafından", + "marketplace.carousel.goToPage": "{{page}}. sayfaya git", + "marketplace.carousel.scrollNext": "Sonraki sayfa", + "marketplace.carousel.scrollPrevious": "Önceki sayfa", + "marketplace.creatorProfile.breadcrumbLabel": "Sayfa yolu", + "marketplace.creatorProfile.creations": "Çalışmalar", + "marketplace.creatorProfile.empty": "Henüz çalışma yok.", + "marketplace.creatorProfile.home": "Marketplace ana sayfası", + "marketplace.creatorProfile.loadMore": "Daha fazla yükle", + "marketplace.creatorProfile.loadMoreFailed": "Daha fazla çalışma yüklenemedi.", + "marketplace.creatorProfile.onTheWeb": "Web'de", + "marketplace.creatorProfile.organization": "Organizasyon", + "marketplace.creatorProfile.searchPlaceholder": "Eklenti ve şablon ara", + "marketplace.creatorProfile.sort.asc": "Artan sırala", + "marketplace.creatorProfile.sort.createdAt": "Son oluşturulan", + "marketplace.creatorProfile.sort.desc": "Azalan sırala", + "marketplace.creatorProfile.sort.popularity": "Popülerlik", + "marketplace.creatorProfile.sort.updatedAt": "Son güncellenen", + "marketplace.creatorProfile.sortBy": "Sırala", + "marketplace.creatorProfile.title": "Üretici profili", + "marketplace.creatorProfile.type.plugin": "Eklenti", + "marketplace.creatorProfile.type.template": "Şablon", "marketplace.difyMarketplace": "Dify Pazar Yeri", "marketplace.discover": "Keşfet", "marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin", + "marketplace.home.creatorCenter": "İçerik Üretici Merkezi", + "marketplace.home.guide": "Kılavuz", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Keşfet. Genişlet. Oluştur", + "marketplace.home.plugins": "Eklentiler", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Şablonlar", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Duraklat", + "marketplace.home.trendingPlay": "Oynat", + "marketplace.home.trendingReadMore": "Devamını oku", + "marketplace.home.trendingReadMoreAbout": "{{title}} hakkında devamını oku", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Görüntüle", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Yüklenemedi. Lütfen tekrar deneyin.", "marketplace.moreFrom": "Pazar Yeri'nden daha fazlası", "marketplace.noPluginFound": "Eklenti bulunamadı", "marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı", "marketplace.pluginsHeroSubtitle": "Yapay zeka geliştirmenizi güçlendirmek için topluluk tarafından oluşturulan eklentileri kullanın.", "marketplace.pluginsHeroTitle": "Keşfet. Genişlet. Oluştur.", "marketplace.pluginsResult": "{{num}} sonuç", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Sırala", "marketplace.sortOption.firstReleased": "İlk Çıkanlar", "marketplace.sortOption.mostPopular": "En popüler", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index 388e89620b1..b198a13d192 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Термін дії закінчується за {{count}} днів", "license.unlimited": "Безмежний", "loading": "Завантаження", + "mainNav.help.creatorCenter": "Центр авторів", "mainNav.help.docs": "Документація", "mainNav.help.learnDify": "Вивчити Dify", "mainNav.help.openMenu": "Відкрити меню довідки", @@ -670,6 +671,7 @@ "userProfile.about": "Про нас", "userProfile.compliance": "Відповідність", "userProfile.contactUs": "Зв’яжіться з нами", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Підтримка по електронній пошті", "userProfile.github": "Гітхаб", "userProfile.helpCenter": "Довідковий центр", diff --git a/web/i18n/uk-UA/plugin.json b/web/i18n/uk-UA/plugin.json index 1aca4b26eec..8510376cca9 100644 --- a/web/i18n/uk-UA/plugin.json +++ b/web/i18n/uk-UA/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Всі плагіни", "marketplace.and": "і", "marketplace.becomePartner": "Стати партнером", + "marketplace.by": "Автор", + "marketplace.carousel.goToPage": "Перейти на сторінку {{page}}", + "marketplace.carousel.scrollNext": "Наступна сторінка", + "marketplace.carousel.scrollPrevious": "Попередня сторінка", + "marketplace.creatorProfile.breadcrumbLabel": "Навігаційний ланцюжок", + "marketplace.creatorProfile.creations": "Роботи", + "marketplace.creatorProfile.empty": "Поки немає робіт.", + "marketplace.creatorProfile.home": "Головна Marketplace", + "marketplace.creatorProfile.loadMore": "Завантажити ще", + "marketplace.creatorProfile.loadMoreFailed": "Не вдалося завантажити більше робіт.", + "marketplace.creatorProfile.onTheWeb": "В інтернеті", + "marketplace.creatorProfile.organization": "Організація", + "marketplace.creatorProfile.searchPlaceholder": "Пошук плагінів і шаблонів", + "marketplace.creatorProfile.sort.asc": "За зростанням", + "marketplace.creatorProfile.sort.createdAt": "Нещодавно створено", + "marketplace.creatorProfile.sort.desc": "За спаданням", + "marketplace.creatorProfile.sort.popularity": "Популярність", + "marketplace.creatorProfile.sort.updatedAt": "Нещодавно оновлено", + "marketplace.creatorProfile.sortBy": "Сортувати", + "marketplace.creatorProfile.title": "Профіль автора", + "marketplace.creatorProfile.type.plugin": "Плагін", + "marketplace.creatorProfile.type.template": "Шаблон", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "Виявити", "marketplace.empower": "Розширюйте можливості розробки штучного інтелекту", + "marketplace.home.creatorCenter": "Центр авторів", + "marketplace.home.guide": "Посібник", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Відкривайте. Розширюйте. Створюйте", + "marketplace.home.plugins": "Інтеграції", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Шаблони", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Пауза", + "marketplace.home.trendingPlay": "Відтворити", + "marketplace.home.trendingReadMore": "Читати далі", + "marketplace.home.trendingReadMoreAbout": "Дізнатися більше про {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Переглянути", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Не вдалося завантажити. Спробуйте ще раз.", "marketplace.moreFrom": "Більше від Marketplace", "marketplace.noPluginFound": "Плагін не знайдено", "marketplace.partnerTip": "Перевірено партнером Dify", "marketplace.pluginsHeroSubtitle": "Використовуйте створені спільнотою плагіни для розвитку вашої розробки штучного інтелекту.", "marketplace.pluginsHeroTitle": "Відкривайте. Розширюйте. Створюйте.", "marketplace.pluginsResult": "Результати {{num}}", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Чорне місто", "marketplace.sortOption.firstReleased": "Перший реліз", "marketplace.sortOption.mostPopular": "Найпопулярніших", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 2d662d037bd..2b450a6d015 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "Hết hạn sau {{count}} ngày", "license.unlimited": "Vô hạn", "loading": "Đang tải", + "mainNav.help.creatorCenter": "Trung tâm nhà sáng tạo", "mainNav.help.docs": "Tài liệu", "mainNav.help.learnDify": "Tìm hiểu Dify", "mainNav.help.openMenu": "Mở menu trợ giúp", @@ -670,6 +671,7 @@ "userProfile.about": "Về chúng tôi", "userProfile.compliance": "Tuân thủ", "userProfile.contactUs": "Liên hệ với chúng tôi", + "userProfile.discord": "Discord", "userProfile.emailSupport": "Hỗ trợ qua Email", "userProfile.github": "GitHub", "userProfile.helpCenter": "Trung tâm trợ giúp", diff --git a/web/i18n/vi-VN/plugin.json b/web/i18n/vi-VN/plugin.json index 50eab03483e..d16fbf7ddf8 100644 --- a/web/i18n/vi-VN/plugin.json +++ b/web/i18n/vi-VN/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "Tất cả plugin", "marketplace.and": "và", "marketplace.becomePartner": "Trở thành đối tác", + "marketplace.by": "Tác giả", + "marketplace.carousel.goToPage": "Đi tới trang {{page}}", + "marketplace.carousel.scrollNext": "Trang sau", + "marketplace.carousel.scrollPrevious": "Trang trước", + "marketplace.creatorProfile.breadcrumbLabel": "Đường dẫn điều hướng", + "marketplace.creatorProfile.creations": "Tác phẩm", + "marketplace.creatorProfile.empty": "Chưa có tác phẩm nào.", + "marketplace.creatorProfile.home": "Trang chủ Marketplace", + "marketplace.creatorProfile.loadMore": "Tải thêm", + "marketplace.creatorProfile.loadMoreFailed": "Không thể tải thêm tác phẩm.", + "marketplace.creatorProfile.onTheWeb": "Trên web", + "marketplace.creatorProfile.organization": "Tổ chức", + "marketplace.creatorProfile.searchPlaceholder": "Tìm plugin và mẫu", + "marketplace.creatorProfile.sort.asc": "Sắp xếp tăng dần", + "marketplace.creatorProfile.sort.createdAt": "Tạo gần đây", + "marketplace.creatorProfile.sort.desc": "Sắp xếp giảm dần", + "marketplace.creatorProfile.sort.popularity": "Phổ biến", + "marketplace.creatorProfile.sort.updatedAt": "Cập nhật gần đây", + "marketplace.creatorProfile.sortBy": "Sắp xếp theo", + "marketplace.creatorProfile.title": "Hồ sơ nhà sáng tạo", + "marketplace.creatorProfile.type.plugin": "Plugin", + "marketplace.creatorProfile.type.template": "Mẫu", "marketplace.difyMarketplace": "Thị trường Dify", "marketplace.discover": "Khám phá", "marketplace.empower": "Hỗ trợ phát triển AI của bạn", + "marketplace.home.creatorCenter": "Trung tâm nhà sáng tạo", + "marketplace.home.guide": "Hướng dẫn", + "marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.", + "marketplace.home.heroTitle": "Khám phá. Mở rộng. Xây dựng", + "marketplace.home.plugins": "Plugin", + "marketplace.home.searchPlaceholder": "Search plugins or templates", + "marketplace.home.templates": "Mẫu", + "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.trendingPaginationLabel": "Trending pages", + "marketplace.home.trendingPause": "Tạm dừng", + "marketplace.home.trendingPlay": "Phát", + "marketplace.home.trendingReadMore": "Đọc thêm", + "marketplace.home.trendingReadMoreAbout": "Đọc thêm về {{title}}", + "marketplace.home.trendingTitle": "The plugins everyone is installing", + "marketplace.home.trendingView": "Xem", + "marketplace.languages": "Filter by Languages", + "marketplace.loadError": "Tải không thành công. Vui lòng thử lại.", "marketplace.moreFrom": "Các ứng dụng khác từ Marketplace", "marketplace.noPluginFound": "Không tìm thấy plugin nào", "marketplace.partnerTip": "Được xác nhận bởi một đối tác của Dify", "marketplace.pluginsHeroSubtitle": "Sử dụng các plugin do cộng đồng xây dựng để hỗ trợ phát triển AI của bạn.", "marketplace.pluginsHeroTitle": "Khám phá. Mở rộng. Xây dựng.", "marketplace.pluginsResult": "{{num}} kết quả", + "marketplace.searchFilterLanguage": "Search language", "marketplace.sortBy": "Thành phố đen", "marketplace.sortOption.firstReleased": "Phát hành lần đầu tiên", "marketplace.sortOption.mostPopular": "Phổ biến nhất", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index b54d8e5293f..72df809d2b9 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "许可证还有 {{count}} 天到期", "license.unlimited": "无限制", "loading": "加载中", + "mainNav.help.creatorCenter": "创作者中心", "mainNav.help.docs": "文档", "mainNav.help.learnDify": "了解 Dify", "mainNav.help.openMenu": "打开帮助菜单", @@ -670,6 +671,7 @@ "userProfile.about": "关于", "userProfile.compliance": "合规", "userProfile.contactUs": "联系我们", + "userProfile.discord": "Discord", "userProfile.emailSupport": "邮件支持", "userProfile.github": "GitHub", "userProfile.helpCenter": "查看帮助文档", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index bffed30d1bd..6e016f22a64 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -202,15 +202,56 @@ "marketplace.allPlugins": "所有集成", "marketplace.and": "和", "marketplace.becomePartner": "成为合作伙伴", + "marketplace.by": "作者", + "marketplace.carousel.goToPage": "转到第 {{page}} 页", + "marketplace.carousel.scrollNext": "下一页", + "marketplace.carousel.scrollPrevious": "上一页", + "marketplace.creatorProfile.breadcrumbLabel": "面包屑导航", + "marketplace.creatorProfile.creations": "作品", + "marketplace.creatorProfile.empty": "暂无作品。", + "marketplace.creatorProfile.home": "Marketplace 首页", + "marketplace.creatorProfile.loadMore": "加载更多", + "marketplace.creatorProfile.loadMoreFailed": "无法加载更多作品。", + "marketplace.creatorProfile.onTheWeb": "社交主页", + "marketplace.creatorProfile.organization": "组织", + "marketplace.creatorProfile.searchPlaceholder": "搜索插件和模板", + "marketplace.creatorProfile.sort.asc": "升序排列", + "marketplace.creatorProfile.sort.createdAt": "创建时间", + "marketplace.creatorProfile.sort.desc": "降序排列", + "marketplace.creatorProfile.sort.popularity": "热度", + "marketplace.creatorProfile.sort.updatedAt": "更新时间", + "marketplace.creatorProfile.sortBy": "排序", + "marketplace.creatorProfile.title": "创作者主页", + "marketplace.creatorProfile.type.plugin": "插件", + "marketplace.creatorProfile.type.template": "模板", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "探索", "marketplace.empower": "助力您的 AI 开发", + "marketplace.home.creatorCenter": "创作者中心", + "marketplace.home.guide": "指南", + "marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的插件。", + "marketplace.home.heroTitle": "发现。扩展。构建", + "marketplace.home.plugins": "插件", + "marketplace.home.searchPlaceholder": "搜索插件或模板", + "marketplace.home.templates": "模板", + "marketplace.home.trendingByCreator": "由 {{creator}} 发布", + "marketplace.home.trendingDescription": "基于真实使用情况选出的热门插件,每两周更新一次。榜单按各工作区的实际运行次数排序,不含付费推广或编辑推荐。", + "marketplace.home.trendingPaginationLabel": "热门推荐页码", + "marketplace.home.trendingPause": "暂停", + "marketplace.home.trendingPlay": "播放", + "marketplace.home.trendingReadMore": "阅读更多", + "marketplace.home.trendingReadMoreAbout": "阅读更多关于 {{title}} 的内容", + "marketplace.home.trendingTitle": "大家都在安装的插件", + "marketplace.home.trendingView": "查看", + "marketplace.languages": "按语言筛选", + "marketplace.loadError": "加载失败,请重试。", "marketplace.moreFrom": "来自 Marketplace 的更多内容", "marketplace.noPluginFound": "未找到集成", "marketplace.partnerTip": "此插件由 Dify 合作伙伴认证", "marketplace.pluginsHeroSubtitle": "使用社区构建的集成助力您的 AI 开发。", "marketplace.pluginsHeroTitle": "探索 · 扩展 · 构建", "marketplace.pluginsResult": "{{num}} 个插件结果", + "marketplace.searchFilterLanguage": "搜索语言", "marketplace.sortBy": "排序方式", "marketplace.sortOption.firstReleased": "首次发布", "marketplace.sortOption.mostPopular": "最受欢迎", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index ac5fb952f34..1559004d997 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -197,6 +197,7 @@ "license.expiring_plural": "將在 {{count}} 天后過期", "license.unlimited": "無限制", "loading": "載入中", + "mainNav.help.creatorCenter": "創作者中心", "mainNav.help.docs": "文件", "mainNav.help.learnDify": "學習 Dify", "mainNav.help.openMenu": "開啟幫助選單", @@ -670,6 +671,7 @@ "userProfile.about": "關於", "userProfile.compliance": "合規", "userProfile.contactUs": "聯絡我們", + "userProfile.discord": "Discord", "userProfile.emailSupport": "電子郵件支援", "userProfile.github": "GitHub", "userProfile.helpCenter": "查看幫助文件", diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json index 207d5f47bba..90a936619cb 100644 --- a/web/i18n/zh-Hant/plugin.json +++ b/web/i18n/zh-Hant/plugin.json @@ -70,19 +70,19 @@ "autoUpdate.upgradeMode.partial": "僅選擇", "autoUpdate.upgradeModePlaceholder.exclude": "選定的插件將不會自動更新", "autoUpdate.upgradeModePlaceholder.partial": "只有選定的插件會自動更新。目前未選定任何插件,因此不會自動更新任何插件。", - "category.agents": "代理策略", - "category.all": "都", - "category.bundles": "束", + "category.agents": "Agent 策略", + "category.all": "全部", + "category.bundles": "整合包", "category.datasources": "資料來源", - "category.extensions": "擴展", + "category.extensions": "擴充功能", "category.models": "模型", "category.tools": "工具", - "category.triggers": "觸發因素", - "categorySingle.agent": "代理策略", - "categorySingle.bundle": "捆", + "category.triggers": "觸發器", + "categorySingle.agent": "Agent 策略", + "categorySingle.bundle": "整合包", "categorySingle.datasource": "資料來源", - "categorySingle.extension": "外延", - "categorySingle.model": "型", + "categorySingle.extension": "擴充功能", + "categorySingle.model": "模型", "categorySingle.tool": "工具", "categorySingle.trigger": "觸發器", "clearSearch": "清空{{label}}", @@ -202,15 +202,56 @@ "marketplace.allPlugins": "所有集成", "marketplace.and": "和", "marketplace.becomePartner": "成為合作夥伴", + "marketplace.by": "作者", + "marketplace.carousel.goToPage": "轉到第 {{page}} 頁", + "marketplace.carousel.scrollNext": "下一頁", + "marketplace.carousel.scrollPrevious": "上一頁", + "marketplace.creatorProfile.breadcrumbLabel": "麵包屑導航", + "marketplace.creatorProfile.creations": "作品", + "marketplace.creatorProfile.empty": "尚無作品。", + "marketplace.creatorProfile.home": "Marketplace 首頁", + "marketplace.creatorProfile.loadMore": "載入更多", + "marketplace.creatorProfile.loadMoreFailed": "無法載入更多作品。", + "marketplace.creatorProfile.onTheWeb": "社交主頁", + "marketplace.creatorProfile.organization": "組織", + "marketplace.creatorProfile.searchPlaceholder": "搜尋外掛和模板", + "marketplace.creatorProfile.sort.asc": "升序排列", + "marketplace.creatorProfile.sort.createdAt": "建立時間", + "marketplace.creatorProfile.sort.desc": "降序排列", + "marketplace.creatorProfile.sort.popularity": "熱度", + "marketplace.creatorProfile.sort.updatedAt": "更新時間", + "marketplace.creatorProfile.sortBy": "排序", + "marketplace.creatorProfile.title": "創作者主頁", + "marketplace.creatorProfile.type.plugin": "外掛", + "marketplace.creatorProfile.type.template": "模板", "marketplace.difyMarketplace": "Dify Marketplace", "marketplace.discover": "發現", "marketplace.empower": "為您的 AI 開發提供支援", + "marketplace.home.creatorCenter": "創作者中心", + "marketplace.home.guide": "指南", + "marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的外掛程式。", + "marketplace.home.heroTitle": "探索。擴展。建構", + "marketplace.home.plugins": "外掛", + "marketplace.home.searchPlaceholder": "搜尋外掛程式或範本", + "marketplace.home.templates": "範本", + "marketplace.home.trendingByCreator": "由 {{creator}} 發布", + "marketplace.home.trendingDescription": "根據真實使用情況選出的熱門外掛程式,每兩週更新一次。榜單按各工作區的實際執行次數排序,不含付費推廣或編輯推薦。", + "marketplace.home.trendingPaginationLabel": "熱門推薦頁碼", + "marketplace.home.trendingPause": "暫停", + "marketplace.home.trendingPlay": "播放", + "marketplace.home.trendingReadMore": "閱讀更多", + "marketplace.home.trendingReadMoreAbout": "閱讀更多關於 {{title}} 的內容", + "marketplace.home.trendingTitle": "大家都在安裝的外掛程式", + "marketplace.home.trendingView": "查看", + "marketplace.languages": "按語言篩選", + "marketplace.loadError": "載入失敗,請重試。", "marketplace.moreFrom": "來自 Marketplace 的更多內容", "marketplace.noPluginFound": "未找到集成", "marketplace.partnerTip": "由 Dify 合作夥伴驗證", "marketplace.pluginsHeroSubtitle": "使用社群構建的集成來助力您的 AI 開發。", "marketplace.pluginsHeroTitle": "發現。擴展。構建。", "marketplace.pluginsResult": "{{num}} 個結果", + "marketplace.searchFilterLanguage": "搜尋語言", "marketplace.sortBy": "排序方式", "marketplace.sortOption.firstReleased": "首次發佈", "marketplace.sortOption.mostPopular": "最受歡迎", diff --git a/web/proxy.ts b/web/proxy.ts index 16e9289630a..ee4d4399c69 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -19,15 +19,35 @@ const EMBEDDABLE_PATH_SEGMENTS = [ '/workflow', ] const NON_EMBEDDABLE_PATH_SEGMENTS = ['/device'] -const FRAME_ANCESTORS_NONE = "frame-ancestors 'none';" +const FRAME_ANCESTORS_NONE = "'none'" const LEGACY_EDUCATION_ACTION = 'getEducationVerify' +const getHttpOrigin = (value: string | undefined) => { + if (!value) return '' + + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : '' + } catch { + return '' + } +} + const matchesPathSegment = (pathname: string, segments: string[]) => segments.some((segment) => pathname === segment || pathname.startsWith(`${segment}/`)) export const canEmbedPath = (pathname: string) => matchesPathSegment(pathname, EMBEDDABLE_PATH_SEGMENTS) +const appendFrameAncestors = (response: NextResponse, frameOrigin: string) => { + const existingCsp = response.headers.get('Content-Security-Policy') + if (existingCsp?.includes('frame-ancestors')) return + response.headers.set( + 'Content-Security-Policy', + `${existingCsp ? `${existingCsp} ` : ''}frame-ancestors ${frameOrigin};`, + ) +} + const wrapResponseWithFrameProtection = (response: NextResponse, pathname: string) => { // Published app routes are intentionally embeddable; all other routes default to clickjacking protection. const preventEmbedding = @@ -36,13 +56,7 @@ const wrapResponseWithFrameProtection = (response: NextResponse, pathname: strin if (preventEmbedding) { response.headers.set('X-Frame-Options', 'DENY') - const contentSecurityPolicy = response.headers.get('Content-Security-Policy') - response.headers.set( - 'Content-Security-Policy', - contentSecurityPolicy - ? `${contentSecurityPolicy} ${FRAME_ANCESTORS_NONE}` - : FRAME_ANCESTORS_NONE, - ) + appendFrameAncestors(response, FRAME_ANCESTORS_NONE) } return response @@ -81,6 +95,8 @@ export function proxy(request: NextRequest) { ? ' https://challenges.cloudflare.com' : '' const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}${turnstileOrigin}` + const marketplaceFrameOrigin = getHttpOrigin(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX) + const marketplaceFrameSrc = marketplaceFrameOrigin ? ` ${marketplaceFrameOrigin}` : '' const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const csp = `'nonce-${nonce}'` @@ -93,6 +109,7 @@ export function proxy(request: NextRequest) { style-src 'self' 'unsafe-inline' ${scheme_source} ${whiteList}; worker-src 'self' ${scheme_source} ${csp} ${whiteList}; media-src 'self' ${scheme_source} ${csp} ${whiteList}; + frame-src 'self' ${scheme_source} ${whiteList}${marketplaceFrameSrc}; img-src * data: blob:; font-src 'self'; object-src 'none'; diff --git a/web/public/marketplace/dify-marketplace-logo-dark.svg b/web/public/marketplace/dify-marketplace-logo-dark.svg new file mode 100644 index 00000000000..377525a94a4 --- /dev/null +++ b/web/public/marketplace/dify-marketplace-logo-dark.svg @@ -0,0 +1,19 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Vector"> +<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#E8E8E8"/> +<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#E8E8E8"/> +<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="#E8E8E8"/> +<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="#E8E8E8"/> +<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="#E8E8E8"/> +<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="#E8E8E8"/> +<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="#E8E8E8"/> +<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="#E8E8E8"/> +<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="#E8E8E8"/> +<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="#E8E8E8"/> +<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="#E8E8E8"/> +<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="#E8E8E8"/> +<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="#E8E8E8"/> +<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="#E8E8E8"/> +<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="#E8E8E8"/> +</g> +</svg> diff --git a/web/public/marketplace/dify-marketplace-logo.svg b/web/public/marketplace/dify-marketplace-logo.svg new file mode 100644 index 00000000000..bff6718c2af --- /dev/null +++ b/web/public/marketplace/dify-marketplace-logo.svg @@ -0,0 +1,19 @@ +<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="Vector"> +<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#0033FF"/> +<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#0033FF"/> +<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="black"/> +<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="black"/> +<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="black"/> +<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="black"/> +<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="black"/> +<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="black"/> +<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="black"/> +<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="black"/> +<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="black"/> +<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="black"/> +<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="black"/> +<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="black"/> +<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="black"/> +</g> +</svg> diff --git a/web/service/client.ts b/web/service/client.ts index a5a4a6a684c..41ced571a8e 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -41,6 +41,32 @@ function getMarketplaceHeaders() { }) } +// 15s deadline so a stalled Marketplace fetch can error/retry. +const MARKETPLACE_REQUEST_TIMEOUT_MS = 15_000 + +// Combine the caller's abort with the deadline; AbortSignal.any is too new. +function withRequestDeadline(callerSignal: AbortSignal | null | undefined): AbortSignal { + const deadline = AbortSignal.timeout(MARKETPLACE_REQUEST_TIMEOUT_MS) + if (!callerSignal) return deadline + if (callerSignal.aborted) return callerSignal + + const controller = new AbortController() + callerSignal.addEventListener('abort', () => controller.abort(callerSignal.reason), { + once: true, + }) + deadline.addEventListener('abort', () => controller.abort(deadline.reason), { once: true }) + return controller.signal +} + +function isMarketplacePackageDownload(input: Request | URL | string): boolean { + const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + try { + return new URL(href).pathname.endsWith('/download') + } catch { + return false + } +} + function isURL(path: string) { try { // oxlint-disable-next-line no-new @@ -104,9 +130,14 @@ const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { url: MARKETPLACE_API_PREFIX, headers: () => getMarketplaceHeaders(), fetch: (request, init) => { + const requestInit = init as RequestInit | undefined + const callerSignal = requestInit?.signal ?? request.signal return globalThis.fetch(request, { - ...init, + ...requestInit, cache: 'no-store', + signal: isMarketplacePackageDownload(request) + ? callerSignal + : withRequestDeadline(callerSignal), }) }, interceptors: [ diff --git a/web/service/marketplace-template-discovery.spec.ts b/web/service/marketplace-template-discovery.spec.ts new file mode 100644 index 00000000000..e2c910aab94 --- /dev/null +++ b/web/service/marketplace-template-discovery.spec.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' + +const mocks = vi.hoisted(() => ({ + templateCollections: vi.fn(), + templateCollectionTemplates: vi.fn(), + templateSearch: vi.fn(), +})) + +vi.mock('./client', () => ({ + marketplaceClient: { + templateCollections: (...args: unknown[]) => mocks.templateCollections(...args), + templateCollectionTemplates: (...args: unknown[]) => mocks.templateCollectionTemplates(...args), + templateSearch: (...args: unknown[]) => mocks.templateSearch(...args), + }, +})) + +// The collections helper keeps a module-level cache, so import a fresh copy +// per test to keep them isolated. +const importDiscovery = async () => { + vi.resetModules() + return import('./marketplace-template-discovery') +} + +describe('marketplace template discovery', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads each template collection and isolates a failed collection', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [ + { name: 'featured', label: {}, description: {}, priority: 1 }, + { name: 'partners', label: {}, description: {}, priority: 2 }, + ], + }, + }) + mocks.templateCollectionTemplates + .mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } }) + .mockRejectedValueOnce(new Error('Unavailable')) + + const result = await getMarketplaceTemplateCollectionsAndTemplates() + + expect(mocks.templateCollections).toHaveBeenCalledWith( + { + query: { page: 1, page_size: 100 }, + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith( + 1, + { + params: { collectionName: 'featured' }, + body: { limit: 20 }, + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(result.ok).toBe(false) + expect(result.templatesByCollection).toEqual({ + featured: [{ id: 'template-1' }], + partners: [], + }) + }) + + it('does not cache a partial collection failure', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [ + { name: 'featured', label: {}, description: {}, priority: 1 }, + { name: 'partners', label: {}, description: {}, priority: 2 }, + ], + }, + }) + mocks.templateCollectionTemplates + .mockRejectedValueOnce(new Error('Unavailable')) + .mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } }) + .mockResolvedValue({ data: { templates: [{ id: 'template-2' }] } }) + + const failed = await getMarketplaceTemplateCollectionsAndTemplates() + expect(failed.ok).toBe(false) + + const recovered = await getMarketplaceTemplateCollectionsAndTemplates() + expect(recovered.ok).toBe(true) + expect(recovered.templatesByCollection).toEqual({ + featured: [{ id: 'template-2' }], + partners: [{ id: 'template-2' }], + }) + }) + + it('serves collections from the cache instead of refetching every render', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }], + }, + }) + mocks.templateCollectionTemplates.mockResolvedValue({ + data: { templates: [{ id: 'template-1' }] }, + }) + + const [first, second] = await Promise.all([ + getMarketplaceTemplateCollectionsAndTemplates(), + getMarketplaceTemplateCollectionsAndTemplates(), + ]) + const third = await getMarketplaceTemplateCollectionsAndTemplates() + + expect(mocks.templateCollections).toHaveBeenCalledOnce() + expect(mocks.templateCollectionTemplates).toHaveBeenCalledOnce() + expect(second).toBe(first) + expect(third).toBe(first) + }) + + it('does not cache a failed collections fetch', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockRejectedValueOnce(new Error('Unavailable')) + + const failed = await getMarketplaceTemplateCollectionsAndTemplates() + expect(failed).toEqual({ collections: [], templatesByCollection: {}, ok: false }) + + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }], + }, + }) + mocks.templateCollectionTemplates.mockResolvedValue({ + data: { templates: [{ id: 'template-1' }] }, + }) + + const recovered = await getMarketplaceTemplateCollectionsAndTemplates() + expect(recovered.ok).toBe(true) + expect(recovered.templatesByCollection).toEqual({ featured: [{ id: 'template-1' }] }) + }) + + it('sends category searches through the Marketplace contract', async () => { + const { searchMarketplaceTemplates } = await importDiscovery() + mocks.templateSearch.mockResolvedValue({ + data: { + templates: [{ id: 'template-1' }], + total: 1, + }, + }) + + const result = await searchMarketplaceTemplates({ + category: 'marketing', + page: 2, + query: 'campaign', + }) + + expect(mocks.templateSearch).toHaveBeenCalledWith({ + body: { + page: 2, + page_size: 40, + query: 'campaign', + sort_by: 'usage_count', + sort_order: 'DESC', + categories: ['marketing'], + }, + }) + expect(result).toEqual({ ok: true, page: 2, templates: [{ id: 'template-1' }], total: 1 }) + }) + + it('strips list-unused template fields before they can enter the RSC payload', async () => { + const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery() + mocks.templateCollections.mockResolvedValue({ + data: { + collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }], + }, + }) + mocks.templateCollectionTemplates.mockResolvedValue({ + data: { + templates: [ + { + id: 'template-1', + template_name: 'Inbox', + readme: '# long', + review_comment: 'ship it', + dsl_file_key: 'dsl.yml', + partner_link: 'https://example.com', + asset_files: [{ name: 'a' }], + asset_tree_nodes: [{ path: '/' }], + dsl_raw_file_key: 'raw.yml', + }, + ], + }, + }) + + const result = await getMarketplaceTemplateCollectionsAndTemplates() + const [template] = result.templatesByCollection.featured ?? [] + + expect(template).toMatchObject({ id: 'template-1', template_name: 'Inbox' }) + expect(template).not.toHaveProperty('readme') + expect(template).not.toHaveProperty('review_comment') + expect(template).not.toHaveProperty('dsl_file_key') + expect(template).not.toHaveProperty('partner_link') + expect(template).not.toHaveProperty('asset_files') + expect(template).not.toHaveProperty('asset_tree_nodes') + expect(template).not.toHaveProperty('dsl_raw_file_key') + }) + + it('marks a failed template search instead of reporting an empty result', async () => { + const { searchMarketplaceTemplates } = await importDiscovery() + mocks.templateSearch.mockRejectedValueOnce(new Error('Unavailable')) + + const result = await searchMarketplaceTemplates({ + category: 'all', + query: 'campaign', + }) + + expect(result).toEqual({ ok: false, page: 1, templates: [], total: 0 }) + }) +}) diff --git a/web/service/marketplace-template-discovery.ts b/web/service/marketplace-template-discovery.ts new file mode 100644 index 00000000000..55e194f8ea4 --- /dev/null +++ b/web/service/marketplace-template-discovery.ts @@ -0,0 +1,196 @@ +import type { + MarketplaceTemplate, + MarketplaceTemplateCollection, +} from '@dify/contracts/marketplace' +import { SERVER_PREFETCH_BUDGET_MS } from '@/app/components/plugins/marketplace/server-budget' +import { marketplaceClient } from './client' + +export type MarketplaceTemplateCollectionsResult = { + collections: MarketplaceTemplateCollection[] + templatesByCollection: Record<string, MarketplaceTemplate[]> + /** + * False when the Marketplace API request failed, so the UI can render an + * error state instead of claiming the catalog is empty. + */ + ok: boolean +} + +export const TEMPLATE_SEARCH_PAGE_SIZE = 40 + +type SearchMarketplaceTemplatesOptions = { + category: string + languages?: string[] + page?: number + query: string + sortBy?: string + sortOrder?: string +} + +const FAILED_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = { + collections: [], + templatesByCollection: {}, + ok: false, +} + +const COLLECTION_PREVIEW_TEMPLATE_LIMIT = 20 + +type MarketplaceTemplateListExtras = { + asset_files?: unknown + asset_tree_nodes?: unknown + dsl_file_key?: unknown + dsl_raw_file_key?: unknown + partner_link?: unknown + readme?: unknown + review_comment?: unknown +} + +export const toListTemplate = (template: MarketplaceTemplate): MarketplaceTemplate => { + const { + asset_files: _assetFiles, + asset_tree_nodes: _assetTreeNodes, + dsl_file_key: _dslFileKey, + dsl_raw_file_key: _dslRawFileKey, + partner_link: _partnerLink, + readme: _readme, + review_comment: _reviewComment, + ...listFields + } = template as MarketplaceTemplate & MarketplaceTemplateListExtras + + return listFields +} +const COLLECTION_FETCH_BATCH_SIZE = 5 +const COLLECTIONS_CACHE_TTL_MS = 5 * 60 * 1000 + +let collectionsCache: { + expiresAt: number + result: MarketplaceTemplateCollectionsResult +} | null = null +let collectionsInFlight: Promise<MarketplaceTemplateCollectionsResult> | null = null + +async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> { + const budget = AbortSignal.timeout(SERVER_PREFETCH_BUDGET_MS) + + try { + const response = await marketplaceClient.templateCollections( + { + query: { + page: 1, + page_size: 100, + }, + }, + { signal: budget }, + ) + const collections = response.data?.collections ?? [] + const entries: (readonly [string, MarketplaceTemplate[]])[] = [] + let hadCollectionFailure = false + + // Bounded fan-out: fetch collection previews in small batches instead of + // firing one uncached request per collection all at once. The route budget + // aborts leftover work so `/templates` cannot wait N batches × 15s. + for ( + let batchStart = 0; + batchStart < collections.length; + batchStart += COLLECTION_FETCH_BATCH_SIZE + ) { + if (budget.aborted) return FAILED_COLLECTIONS_RESULT + + const batch = collections.slice(batchStart, batchStart + COLLECTION_FETCH_BATCH_SIZE) + entries.push( + ...(await Promise.all( + batch.map(async (collection) => { + try { + const collectionResponse = await marketplaceClient.templateCollectionTemplates( + { + params: { collectionName: collection.name }, + body: { limit: COLLECTION_PREVIEW_TEMPLATE_LIMIT }, + }, + { signal: budget }, + ) + + return [ + collection.name, + (collectionResponse.data?.templates ?? []).map(toListTemplate), + ] as const + } catch (error) { + if (budget.aborted) throw error + hadCollectionFailure = true + return [collection.name, [] as MarketplaceTemplate[]] as const + } + }), + )), + ) + } + + return { + collections, + templatesByCollection: Object.fromEntries(entries), + ok: !hadCollectionFailure, + } + } catch (error) { + if (budget.aborted) return FAILED_COLLECTIONS_RESULT + throw error + } +} + +/** + * Server-side cached view of the template collections and their previews. + * `marketplaceClient` opts out of the framework fetch cache (`no-store`), so + * without this cache every server render of /templates would fan out to up to + * 1 + N external requests. Successful results are reused for a few minutes and + * concurrent renders share a single in-flight fetch; failures are not cached. + */ +export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> { + if (collectionsCache && collectionsCache.expiresAt > Date.now()) return collectionsCache.result + if (collectionsInFlight) return collectionsInFlight + + collectionsInFlight = fetchCollectionsAndTemplates() + .then((result) => { + if (result.ok) collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result } + return result + }) + .catch(() => FAILED_COLLECTIONS_RESULT) + .finally(() => { + collectionsInFlight = null + }) + + return collectionsInFlight +} + +export async function searchMarketplaceTemplates({ + category, + languages, + page = 1, + query, + sortBy = 'usage_count', + sortOrder = 'DESC', +}: SearchMarketplaceTemplatesOptions) { + try { + const response = await marketplaceClient.templateSearch({ + body: { + page, + page_size: TEMPLATE_SEARCH_PAGE_SIZE, + query, + sort_by: sortBy, + sort_order: sortOrder, + ...(category === 'all' ? {} : { categories: [category] }), + ...(languages?.length ? { languages } : {}), + }, + }) + + return { + ok: true, + page, + templates: (response.data?.templates ?? []).map(toListTemplate), + total: response.data?.total ?? 0, + } + } catch { + // Marked as failed so callers can distinguish an API outage from a + // genuinely empty search result. + return { + ok: false, + page, + templates: [], + total: 0, + } + } +} diff --git a/web/types/assets.d.ts b/web/types/assets.d.ts index 6afed58b48d..fbdbcc6e762 100644 --- a/web/types/assets.d.ts +++ b/web/types/assets.d.ts @@ -24,3 +24,8 @@ declare module '*.gif' { const value: any export default value } + +declare module '*.webp' { + const value: any + export default value +} diff --git a/web/utils/__tests__/marketplace-site-track.spec.ts b/web/utils/__tests__/marketplace-site-track.spec.ts new file mode 100644 index 00000000000..db6c228ce56 --- /dev/null +++ b/web/utils/__tests__/marketplace-site-track.spec.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + markMarketplaceSiteFilter, + markMarketplaceSiteSearch, + trackMarketplaceSiteCardClick, + trackMarketplaceSiteEvent, +} from '../marketplace-site-track' + +describe('marketplace site track bridge', () => { + afterEach(() => { + document.body.removeAttribute('data-is-marketplace') + delete window.__marketplaceTracking__ + }) + + it('does not forward events outside the standalone marketplace', () => { + const track = vi.fn() + window.__marketplaceTracking__ = { track } as never + + trackMarketplaceSiteEvent('marketplace_card_click', { click_target: 'card' }) + + expect(track).not.toHaveBeenCalled() + }) + + it('forwards events and card clicks on the standalone marketplace', () => { + const track = vi.fn() + const rememberReferrer = vi.fn() + document.body.setAttribute('data-is-marketplace', '') + window.__marketplaceTracking__ = { + track, + rememberReferrer, + markSearch: vi.fn(), + flushSearch: vi.fn(), + markFilter: vi.fn(), + flushFilter: vi.fn(), + } + + trackMarketplaceSiteEvent('marketplace_creator_partner_click', { + click_target: 'creator_center', + }) + trackMarketplaceSiteCardClick({ + itemId: 'org/name', + itemType: 'plugin', + itemName: 'OpenAI', + section: 'partners', + }) + markMarketplaceSiteSearch('openai') + markMarketplaceSiteFilter({ + filter_type: 'type_tab', + selection_mode: 'single', + filter_value: 'tool', + selected_values: ['tool'], + }) + + expect(track).toHaveBeenNthCalledWith(1, 'marketplace_creator_partner_click', { + click_target: 'creator_center', + }) + expect(rememberReferrer).toHaveBeenCalledWith('org/name', 'list') + expect(track).toHaveBeenNthCalledWith(2, 'marketplace_card_click', { + click_target: 'card', + item_id: 'org/name', + item_type: 'plugin', + item_name: 'OpenAI', + section: 'partners', + }) + }) +}) diff --git a/web/utils/marketplace-site-track.ts b/web/utils/marketplace-site-track.ts new file mode 100644 index 00000000000..56c7b7f6a22 --- /dev/null +++ b/web/utils/marketplace-site-track.ts @@ -0,0 +1,87 @@ +/** + * Standalone Marketplace host contract. + * + * The Dify console never stamps `data-is-marketplace` or assigns + * `window.__marketplaceTracking__`, so every helper here is a no-op in a Dify + * build. The standalone marketplace (dify-marketplace) owns the producer: it + * sets `data-is-marketplace` on `<body>` and injects `__marketplaceTracking__` + * from its analytics runtime. Shared Marketplace UI calls these helpers; the + * host implements the bridge. + */ +type MarketplaceSiteReferrerSection = 'banner' | 'search' | 'list' | 'direct' + +type MarketplaceSiteFilter = { + filter_type: 'type_tab' | 'category' | 'language' + selection_mode: 'single' | 'multi' + filter_value: string + selected_values: string[] +} + +const isMarketplaceSite = () => + typeof globalThis.document !== 'undefined' && + globalThis.document.body?.hasAttribute('data-is-marketplace') + +const marketplaceTracking = () => globalThis.window.__marketplaceTracking__ + +export const trackMarketplaceSiteEvent = ( + eventName: string, + properties?: Record<string, unknown>, +) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.track(eventName, properties) +} + +export const rememberMarketplaceSiteReferrer = ( + itemId: string, + section: MarketplaceSiteReferrerSection, +) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.rememberReferrer(itemId, section) +} + +export const markMarketplaceSiteSearch = (query: string) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.markSearch(query) +} + +export const flushMarketplaceSiteSearch = (resultCount: number) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.flushSearch(resultCount) +} + +export const markMarketplaceSiteFilter = (filter: MarketplaceSiteFilter) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.markFilter(filter) +} + +export const flushMarketplaceSiteFilter = (resultCount: number) => { + if (!isMarketplaceSite()) return + + marketplaceTracking()?.flushFilter(resultCount) +} + +export const trackMarketplaceSiteCardClick = ({ + itemId, + itemType, + itemName, + section, +}: { + itemId: string + itemType: 'plugin' | 'template' + itemName: string + section: string +}) => { + rememberMarketplaceSiteReferrer(itemId, section === 'search' ? 'search' : 'list') + trackMarketplaceSiteEvent('marketplace_card_click', { + click_target: 'card', + item_id: itemId, + item_type: itemType, + item_name: itemName, + section, + }) +} diff --git a/web/utils/var.spec.ts b/web/utils/var.spec.ts index c871eea6762..67bc8334cde 100644 --- a/web/utils/var.spec.ts +++ b/web/utils/var.spec.ts @@ -219,6 +219,18 @@ describe('Variable Utilities', () => { expect(url).not.toContain('source=https%253A%252F%252Fexample.com') }) + it('should let params replace the default source without duplicating it', () => { + const url = getMarketplaceUrl( + '/plugins', + { source: 'http://localhost:3001', language: 'en-US' }, + { source: 'http://localhost:3000' }, + ) + const searchParams = new URL(url, 'https://marketplace.dify.ai').searchParams + + expect(searchParams.getAll('source')).toEqual(['http://localhost:3001']) + expect(searchParams.get('language')).toBe('en-US') + }) + it('should not access window during server render', () => { const originalWindow = window vi.stubGlobal('window', undefined) diff --git a/web/utils/var.ts b/web/utils/var.ts index 0a6a1a586b1..acccd7211ce 100644 --- a/web/utils/var.ts +++ b/web/utils/var.ts @@ -171,7 +171,7 @@ export function getMarketplaceUrl( if (params) { Object.keys(params).forEach((key) => { const value = params[key] - if (value !== undefined && value !== null) searchParams.append(key, value) + if (value !== undefined && value !== null) searchParams.set(key, value) }) }