feat: add configurable homepage promo banner

This commit is contained in:
134 2026-08-04 21:35:45 +08:00
parent 31567af44e
commit 98c8dd20ee
10 changed files with 200 additions and 3 deletions

View File

@ -1160,6 +1160,7 @@ type SiteSettings = {
bannerEnabled: boolean;
bannerText: string;
bannerLink: string | null;
bannerActionText: string;
toolbox1Name: string;
toolbox1Enabled: boolean;
toolbox2Name: string;
@ -2153,6 +2154,7 @@ export function AdminSettingsClient() {
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
bannerActionText: "立即查看",
toolbox1Name: "工具箱",
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
@ -2273,6 +2275,7 @@ export function AdminSettingsClient() {
bannerText:
siteSettingsData.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsData.bannerLink || null,
bannerActionText: siteSettingsData.bannerActionText || "立即查看",
toolbox1Name: siteSettingsData.toolbox1Name || "工具箱",
toolbox1Enabled: siteSettingsData.toolbox1Enabled ?? true,
toolbox2Name: siteSettingsData.toolbox2Name || "工具箱2",
@ -12254,7 +12257,7 @@ export function AdminSettingsClient() {
<div className="space-y-0.5">
<Label htmlFor="bannerEnabled">广告横幅</Label>
<div className="text-muted-foreground text-sm">
在无限画布等页面的聊天面板中显示广告横幅
在首页顶部和无限画布聊天面板中显示广告横幅
</div>
</div>
<Switch
@ -12284,6 +12287,21 @@ export function AdminSettingsClient() {
value={siteSettings.bannerText}
/>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor="bannerActionText">按钮文字</Label>
<Input
id="bannerActionText"
maxLength={32}
onChange={(e) =>
setSiteSettings({
...siteSettings,
bannerActionText: e.target.value,
})
}
placeholder="例如:立即升级"
value={siteSettings.bannerActionText}
/>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor="bannerLink">横幅链接(可选)</Label>
<Input
@ -12298,7 +12316,7 @@ export function AdminSettingsClient() {
value={siteSettings.bannerLink || ""}
/>
<div className="text-muted-foreground text-xs">
如果不填写链接,点击横幅将不会跳转
首页按钮和无限画布横幅将打开此链接;不填写时只展示文案
</div>
</div>
</>

View File

@ -579,6 +579,10 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
favicon: null,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
bannerActionText: "立即查看",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
surpriseCategories: ["全部", "精选"],
@ -897,6 +901,7 @@ async function getScopedPublicSiteSettings(
bannerText:
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: typedSettings?.bannerLink || null,
bannerActionText: typedSettings?.bannerActionText || "立即查看",
lovartComposerPrefix: normalizeLovartComposerPrefix(
valueByKey.get(LOVART_COMPOSER_PREFIX_KEY)
),
@ -939,6 +944,11 @@ async function getScopedPublicSiteSettings(
return createScopedSiteSettingsResponse(scope, {
...createBrandingSettings(typedSettings),
bannerEnabled: typedSettings?.bannerEnabled ?? true,
bannerText:
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: typedSettings?.bannerLink || null,
bannerActionText: typedSettings?.bannerActionText || "立即查看",
lovartPromoText:
typedSettings?.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
@ -1470,6 +1480,7 @@ export async function GET(request: NextRequest) {
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
bannerActionText: "立即查看",
toolbox1Name: "工具箱",
toolbox1Enabled: true,
toolbox2Name: "工具箱2",
@ -1651,6 +1662,7 @@ const updateSchema = z.object({
bannerEnabled: z.boolean().optional(),
bannerText: z.string().optional(),
bannerLink: z.string().nullable().optional(),
bannerActionText: z.string().max(32).optional(),
toolbox1Name: z.string().max(64).optional(),
toolbox1Enabled: z.boolean().optional(),
toolbox2Name: z.string().max(64).optional(),
@ -3111,6 +3123,7 @@ export async function PUT(request: NextRequest) {
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsPayload.bannerLink,
bannerActionText: siteSettingsPayload.bannerActionText ?? "立即查看",
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",

View File

@ -74,6 +74,12 @@ import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar";
import {
LOVART_PROMO_BANNER_HEIGHT,
LovartPromoBanner,
} from "@/components/home/lovart/lovart-promo-banner";
const HOME_PROMO_DISMISS_STORAGE_KEY = "lovart-home-promo-dismissed-v1";
const ImageAnnotatorLazy = dynamic(
() => import("@/components/image-annotator").then((module) => module.ImageAnnotator),
@ -447,6 +453,10 @@ export function LovartHome({
const [activeCategory, setActiveCategory] = useState("全部");
const [isProjectsOnlyView, setIsProjectsOnlyView] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [dismissedHomeBannerIdentity, setDismissedHomeBannerIdentity] =
useState<string | null>(null);
const [hasResolvedHomeBannerDismissal, setHasResolvedHomeBannerDismissal] =
useState(false);
const [accountCenterOpen, setAccountCenterOpen] = useState(false);
const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">(
"account"
@ -625,6 +635,10 @@ export function LovartHome({
siteLogoDark?: string | null;
lovartPromoText?: string | null;
lovartPromoActionText?: string | null;
bannerEnabled?: boolean;
bannerText?: string | null;
bannerLink?: string | null;
bannerActionText?: string | null;
lovartHeroTitle?: string | null;
lovartHeroSubtitle?: string | null;
surpriseCategories?: string[];
@ -664,6 +678,32 @@ export function LovartHome({
siteSettingsData?.lovartPromoText ?? initialHeroPromoText;
const heroPromoActionText =
siteSettingsData?.lovartPromoActionText ?? initialHeroPromoActionText;
const homeBannerText = String(siteSettingsData?.bannerText || "").trim();
const homeBannerActionText = String(
siteSettingsData?.bannerActionText || "立即查看"
).trim();
const rawHomeBannerLink = String(siteSettingsData?.bannerLink || "").trim();
const homeBannerLink =
/^(https?:\/\/|\/(?!\/))/i.test(rawHomeBannerLink)
? rawHomeBannerLink
: "";
const homeBannerIdentity = useMemo(
() =>
homeBannerText
? JSON.stringify({
actionText: homeBannerActionText,
link: homeBannerLink,
text: homeBannerText,
})
: "",
[homeBannerActionText, homeBannerLink, homeBannerText]
);
const shouldShowHomeBanner = Boolean(
siteSettingsData?.bannerEnabled === true &&
homeBannerIdentity &&
hasResolvedHomeBannerDismissal &&
dismissedHomeBannerIdentity !== homeBannerIdentity
);
const heroTitleText =
siteSettingsData?.lovartHeroTitle ?? initialHeroTitleText;
const heroSubtitleText =
@ -676,6 +716,48 @@ export function LovartHome({
? siteLogoDark || siteLogo || ""
: siteLogo || siteLogoDark || "";
useEffect(() => {
if (!homeBannerIdentity) {
setDismissedHomeBannerIdentity(null);
setHasResolvedHomeBannerDismissal(true);
return;
}
try {
const storedIdentity = window.localStorage.getItem(
HOME_PROMO_DISMISS_STORAGE_KEY
);
setDismissedHomeBannerIdentity(
storedIdentity === homeBannerIdentity ? homeBannerIdentity : null
);
} catch {
setDismissedHomeBannerIdentity(null);
}
setHasResolvedHomeBannerDismissal(true);
}, [homeBannerIdentity]);
const dismissHomeBanner = useCallback(() => {
if (!homeBannerIdentity) return;
try {
window.localStorage.setItem(
HOME_PROMO_DISMISS_STORAGE_KEY,
homeBannerIdentity
);
} catch {
// The banner still closes for this page when storage is unavailable.
}
setDismissedHomeBannerIdentity(homeBannerIdentity);
}, [homeBannerIdentity]);
const openHomeBannerLink = useCallback(() => {
if (!homeBannerLink) return;
if (homeBannerLink.startsWith("/")) {
router.push(homeBannerLink);
return;
}
window.open(homeBannerLink, "_blank", "noopener,noreferrer");
}, [homeBannerLink, router]);
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
? "/api/tutorial-videos"
@ -1623,9 +1705,19 @@ export function LovartHome({
return (
<div className="min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
{shouldShowHomeBanner ? (
<LovartPromoBanner
actionText={homeBannerActionText}
link={homeBannerLink}
onAction={openHomeBannerLink}
onDismiss={dismissHomeBanner}
text={homeBannerText}
/>
) : null}
<BrandTopBar
contentClassName="pl-[80px]"
logo={displayLogo}
offsetTop={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
showBrand={false}
siteName={siteName || "Lovart"}
@ -1878,6 +1970,7 @@ export function LovartHome({
}
siteLogo={displayLogo}
siteName={siteName || "Lovart"}
topOffset={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
toolbox1Links={toolbox1Links}
toolbox2Enabled={siteSettingsData?.toolbox2Enabled === true}
@ -1888,7 +1981,12 @@ export function LovartHome({
tone={effectiveResolvedTheme === "dark" ? "dark" : "light"}
/>
<main className="ml-[92px] mr-10 w-auto max-w-none pb-16 pt-[68px] max-lg:mx-5">
<main
className={cn(
"ml-[92px] mr-10 w-auto max-w-none pb-16 max-lg:mx-5",
shouldShowHomeBanner ? "pt-[113px]" : "pt-[68px]"
)}
>
{!isProjectsOnlyView && (
<LovartHeroComposer
composerPlaceholder={composerPlaceholder}

View File

@ -0,0 +1,51 @@
import { ArrowRight, Sparkles, X } from "lucide-react";
type LovartPromoBannerProps = {
actionText?: string | null;
link?: string | null;
onAction: () => void;
onDismiss: () => void;
text: string;
};
export const LOVART_PROMO_BANNER_HEIGHT = 45;
export function LovartPromoBanner({
actionText,
link,
onAction,
onDismiss,
text,
}: LovartPromoBannerProps) {
const showAction = Boolean(link?.trim() && actionText?.trim());
return (
<div className="fixed inset-x-0 top-0 z-[120] h-[45px] bg-[#ddff58] text-[#11130d]">
<div className="mx-auto flex h-full max-w-[1440px] items-center justify-center gap-3 px-11 sm:px-12">
<Sparkles className="hidden h-4 w-4 shrink-0 sm:block" />
<p className="min-w-0 truncate text-center text-[13px] font-medium sm:text-[15px]">
{text}
</p>
{showAction ? (
<button
className="inline-flex h-8 max-w-[42%] shrink-0 items-center gap-1 px-1 text-[13px] font-semibold underline decoration-1 underline-offset-4 transition-opacity hover:opacity-65 sm:max-w-none sm:text-[15px]"
onClick={onAction}
type="button"
>
<span className="truncate">{actionText}</span>
<ArrowRight className="h-3.5 w-3.5" />
</button>
) : null}
<button
aria-label="关闭首页广告"
className="absolute right-2 flex h-8 w-8 items-center justify-center text-black/65 transition hover:text-black sm:right-4"
onClick={onDismiss}
title="关闭"
type="button"
>
<X className="h-[18px] w-[18px]" />
</button>
</div>
</div>
);
}

View File

@ -48,6 +48,7 @@ type LovartSidebarProps = {
toolbox1Links: NavLink[];
toolbox2Enabled: boolean;
toolbox2Links: NavLink[];
topOffset?: number;
tutorialEnabled: boolean;
tutorialVideos: TutorialVideo[];
videoCanvasEnabled: boolean;
@ -76,6 +77,7 @@ export function LovartSidebar({
toolbox1Links,
toolbox2Enabled,
toolbox2Links,
topOffset = 0,
tutorialEnabled,
tutorialVideos,
videoCanvasEnabled,
@ -167,6 +169,7 @@ export function LovartSidebar({
? "border-white/10 bg-black"
: "border-[#e9e9e9] bg-white",
].join(" ")}
style={{ top: topOffset }}
>
<TooltipProvider delayDuration={120}>
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-3 pt-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">

View File

@ -10,6 +10,7 @@ type BrandTopBarProps = {
className?: string;
contentClassName?: string;
logo?: string | null;
offsetTop?: number;
onLogoClick?: () => void;
position?: "fixed" | "sticky" | "static";
showBrand?: boolean;
@ -22,6 +23,7 @@ export function BrandTopBar({
className,
contentClassName,
logo,
offsetTop = 0,
onLogoClick,
position = "fixed",
showBrand = true,
@ -42,6 +44,7 @@ export function BrandTopBar({
"h-14 bg-white/90 backdrop-blur dark:bg-[#0a0a0c]/90",
className
)}
style={position === "fixed" ? { top: offsetTop } : undefined}
>
<div
className={cn(

View File

@ -0,0 +1,2 @@
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "bannerActionText" text DEFAULT '立即查看';

View File

@ -568,6 +568,13 @@
"when": 1785196800000,
"tag": "0093_update_worldlabs_vip98k_base_url",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1785772800000,
"tag": "0094_add_banner_action_text",
"breakpoints": true
}
]
}

View File

@ -4680,6 +4680,7 @@ export async function getSiteSettings() {
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
bannerActionText: "立即查看",
toolbox1Name: "工具箱",
toolbox1Enabled: true,
toolbox2Name: "工具箱2",

View File

@ -479,6 +479,7 @@ export const siteSettings = pgTable("SiteSettings", {
bannerEnabled: boolean("bannerEnabled").notNull().default(true), // 是否显示广告横幅
bannerText: text("bannerText").default("限时特惠:升级会员最高立享 45% OFF!"), // 广告横幅文字
bannerLink: text("bannerLink"), // 广告横幅链接(可选)
bannerActionText: text("bannerActionText").default("立即查看"), // 广告横幅按钮文字
// 工具箱设置
toolbox1Name: varchar("toolbox1Name", { length: 64 }).default("工具箱"), // 工具箱1名称
toolbox1Enabled: boolean("toolbox1Enabled").notNull().default(true), // 是否显示工具箱1