dify/web/app/components/plugins/marketplace/home/banners.ts
Coding On Star cb6c04637b
feat(web): add marketplace creator profiles and homepage redesign (#41538)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: 姜涵煦 <hanxujiang@jianghanxudeMacBook-Pro-2.local>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-09-04 08:09:45 +00:00

155 lines
4.6 KiB
TypeScript

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)
}