feat(settings): manage public header banner in admin

This commit is contained in:
2026-02-12 20:18:00 +01:00
parent 39178c2d8d
commit d1face36c5
5 changed files with 292 additions and 5 deletions

View File

@@ -160,7 +160,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [~] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [~] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] Header banner management (message, CTA, active window)
- [x] [P1] Header banner management (message, CTA, active window)
- [~] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting)
- [~] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata)
@@ -174,7 +174,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow
- [ ] [P1] Header banner render logic and fallbacks
- [x] [P1] Header banner render logic and fallbacks
- [ ] [P1] Announcement render slots (homepage + optional global/top banner position)
### News / Blog (Secondary Track)
@@ -282,6 +282,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Added `e2e/happy-paths.pw.ts` covering admin login, page publish/public rendering, announcement rendering, media upload, and commission status transition.
- [2026-02-12] Expanded unit coverage for content/domain schemas and post service behavior (`packages/content/src/domain-schemas.test.ts`, `packages/db/src/posts.test.ts`).
- [2026-02-12] Added auth flow integration tests for `/login`, `/register`, `/welcome` to validate registration allow/deny and owner bootstrap redirects.
- [2026-02-12] Admin settings now manage public header banner (enabled/message/CTA), backed by `system_setting` and consumed by public layout rendering.
## How We Use This File

View File

@@ -1,4 +1,9 @@
import { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "@cms/db"
import {
getPublicHeaderBannerConfig,
isAdminSelfRegistrationEnabled,
setAdminSelfRegistrationEnabled,
setPublicHeaderBannerConfig,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import Link from "next/link"
@@ -79,6 +84,53 @@ async function updateRegistrationPolicyAction(formData: FormData) {
)
}
async function updatePublicHeaderBannerAction(formData: FormData) {
"use server"
await requireSettingsPermission()
const t = await getSettingsTranslator()
const enabled = formData.get("bannerEnabled") === "on"
const message = toSingleValue(formData.get("bannerMessage")?.toString())?.trim() ?? ""
const ctaLabel = toSingleValue(formData.get("bannerCtaLabel")?.toString())?.trim() ?? ""
const ctaHref = toSingleValue(formData.get("bannerCtaHref")?.toString())?.trim() ?? ""
if (enabled && message.length === 0) {
redirect(
`/settings?error=${encodeURIComponent(
t(
"settings.banner.errors.messageRequired",
"Banner message is required while banner is enabled.",
),
)}`,
)
}
try {
await setPublicHeaderBannerConfig({
enabled,
message,
ctaLabel: ctaLabel || null,
ctaHref: ctaHref || null,
})
} catch {
redirect(
`/settings?error=${encodeURIComponent(
t(
"settings.banner.errors.updateFailed",
"Saving banner settings failed. Ensure database migrations are applied.",
),
)}`,
)
}
revalidatePath("/settings")
redirect(
`/settings?notice=${encodeURIComponent(
t("settings.banner.success.updated", "Public header banner settings updated."),
)}`,
)
}
export default async function SettingsPage({ searchParams }: { searchParams: SearchParamsInput }) {
const role = await requirePermissionForRoute({
nextPath: "/settings",
@@ -86,10 +138,11 @@ export default async function SettingsPage({ searchParams }: { searchParams: Sea
scope: "global",
})
const [params, locale, isRegistrationEnabled] = await Promise.all([
const [params, locale, isRegistrationEnabled, publicBanner] = await Promise.all([
searchParams,
resolveAdminLocale(),
isAdminSelfRegistrationEnabled(),
getPublicHeaderBannerConfig(),
])
const messages = await getAdminMessages(locale)
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
@@ -175,6 +228,72 @@ export default async function SettingsPage({ searchParams }: { searchParams: Sea
</form>
</div>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-5">
<div className="space-y-2">
<h2 className="text-xl font-medium">
{t("settings.banner.title", "Public header banner")}
</h2>
<p className="text-sm text-neutral-600">
{t(
"settings.banner.description",
"Control the top banner shown on the public app header.",
)}
</p>
</div>
<form action={updatePublicHeaderBannerAction} className="space-y-4">
<label className="flex items-center gap-3 text-sm">
<input
type="checkbox"
name="bannerEnabled"
defaultChecked={publicBanner.enabled}
className="h-4 w-4 rounded border-neutral-300"
/>
<span>{t("settings.banner.enabledLabel", "Enable public header banner")}</span>
</label>
<label className="space-y-1 text-sm">
<span className="text-xs text-neutral-600">
{t("settings.banner.messageLabel", "Message")}
</span>
<input
name="bannerMessage"
defaultValue={publicBanner.message}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1 text-sm">
<span className="text-xs text-neutral-600">
{t("settings.banner.ctaLabelLabel", "CTA label (optional)")}
</span>
<input
name="bannerCtaLabel"
defaultValue={publicBanner.ctaLabel ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 text-sm">
<span className="text-xs text-neutral-600">
{t("settings.banner.ctaHrefLabel", "CTA URL (optional)")}
</span>
<input
name="bannerCtaHref"
defaultValue={publicBanner.ctaHref ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">
{t("settings.banner.actions.save", "Save banner settings")}
</Button>
</form>
</div>
</section>
</AdminShell>
)
}

View File

@@ -57,9 +57,11 @@ export {
registerPostCrudAuditHook,
updatePost,
} from "./posts"
export type { PublicHeaderBanner } from "./settings"
export type { PublicHeaderBanner, PublicHeaderBannerConfig } from "./settings"
export {
getPublicHeaderBanner,
getPublicHeaderBannerConfig,
isAdminSelfRegistrationEnabled,
setAdminSelfRegistrationEnabled,
setPublicHeaderBannerConfig,
} from "./settings"

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
systemSetting: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import {
getPublicHeaderBanner,
getPublicHeaderBannerConfig,
isAdminSelfRegistrationEnabled,
setPublicHeaderBannerConfig,
} from "./settings"
describe("settings service", () => {
const previousEnv = process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED
beforeEach(() => {
mockDb.systemSetting.findUnique.mockReset()
mockDb.systemSetting.upsert.mockReset()
process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED = previousEnv
})
it("falls back to env flag when registration setting is missing", async () => {
process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED = "true"
mockDb.systemSetting.findUnique.mockResolvedValue(null)
const enabled = await isAdminSelfRegistrationEnabled()
expect(enabled).toBe(true)
})
it("reads active public header banner payload", async () => {
mockDb.systemSetting.findUnique.mockResolvedValue({
value: JSON.stringify({
enabled: true,
message: "Commissions open",
ctaLabel: "Book now",
ctaHref: "/contact",
}),
})
const banner = await getPublicHeaderBanner()
expect(banner).toEqual({
message: "Commissions open",
ctaLabel: "Book now",
ctaHref: "/contact",
})
})
it("returns a disabled default config for invalid data", async () => {
mockDb.systemSetting.findUnique.mockResolvedValue({
value: "not-json",
})
const config = await getPublicHeaderBannerConfig()
expect(config).toEqual({
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
})
})
it("writes banner config to system settings", async () => {
mockDb.systemSetting.upsert.mockResolvedValue({})
await setPublicHeaderBannerConfig({
enabled: true,
message: "Holiday schedule",
ctaLabel: "Details",
ctaHref: "/news",
})
expect(mockDb.systemSetting.upsert).toHaveBeenCalledTimes(1)
expect(mockDb.systemSetting.upsert.mock.calls[0]?.[0]).toMatchObject({
where: {
key: "public.header_banner",
},
})
})
})

View File

@@ -16,6 +16,13 @@ export type PublicHeaderBanner = {
ctaHref?: string
}
export type PublicHeaderBannerConfig = {
enabled: boolean
message: string
ctaLabel: string | null
ctaHref: string | null
}
function resolveEnvFallback(): boolean {
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
}
@@ -114,3 +121,69 @@ export async function getPublicHeaderBanner(): Promise<PublicHeaderBanner | null
return null
}
}
export async function getPublicHeaderBannerConfig(): Promise<PublicHeaderBannerConfig> {
try {
const setting = await db.systemSetting.findUnique({
where: { key: PUBLIC_HEADER_BANNER_KEY },
select: { value: true },
})
if (!setting) {
return {
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
}
}
const parsed = parsePublicHeaderBanner(setting.value)
if (!parsed) {
return {
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
}
}
return {
enabled: parsed.enabled,
message: parsed.message,
ctaLabel: parsed.ctaLabel ?? null,
ctaHref: parsed.ctaHref ?? null,
}
} catch {
return {
enabled: false,
message: "",
ctaLabel: null,
ctaHref: null,
}
}
}
export async function setPublicHeaderBannerConfig(input: PublicHeaderBannerConfig): Promise<void> {
await db.systemSetting.upsert({
where: { key: PUBLIC_HEADER_BANNER_KEY },
create: {
key: PUBLIC_HEADER_BANNER_KEY,
value: JSON.stringify({
enabled: input.enabled,
message: input.message,
ctaLabel: input.ctaLabel ?? undefined,
ctaHref: input.ctaHref ?? undefined,
}),
},
update: {
value: JSON.stringify({
enabled: input.enabled,
message: input.message,
ctaLabel: input.ctaLabel ?? undefined,
ctaHref: input.ctaHref ?? undefined,
}),
},
})
}