feat: add configurable homepage promo banner
This commit is contained in:
parent
31567af44e
commit
98c8dd20ee
@ -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>
|
||||
</>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
51
components/home/lovart/lovart-promo-banner.tsx
Normal file
51
components/home/lovart/lovart-promo-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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(
|
||||
|
||||
2
lib/db/migrations/0094_add_banner_action_text.sql
Normal file
2
lib/db/migrations/0094_add_banner_action_text.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "bannerActionText" text DEFAULT '立即查看';
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -4680,6 +4680,7 @@ export async function getSiteSettings() {
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
bannerActionText: "立即查看",
|
||||
toolbox1Name: "工具箱",
|
||||
toolbox1Enabled: true,
|
||||
toolbox2Name: "工具箱2",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user