diff --git a/TODO.md b/TODO.md index 947bc48..406c8f4 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx index fda5ec3..e63ec0d 100644 --- a/apps/admin/src/app/settings/page.tsx +++ b/apps/admin/src/app/settings/page.tsx @@ -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 + +
+
+
+

+ {t("settings.banner.title", "Public header banner")} +

+

+ {t( + "settings.banner.description", + "Control the top banner shown on the public app header.", + )} +

+
+ +
+ + + + +
+ + +
+ + +
+
+
) } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 95a852e..9b9ba76 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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" diff --git a/packages/db/src/settings.test.ts b/packages/db/src/settings.test.ts new file mode 100644 index 0000000..4315626 --- /dev/null +++ b/packages/db/src/settings.test.ts @@ -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", + }, + }) + }) +}) diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index 5d4ae33..63b951f 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -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 { + 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 { + 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, + }), + }, + }) +}