diff --git a/backend/src/__tests__/publicSiteService.test.js b/backend/src/__tests__/publicSiteService.test.js index 18da9424..80daeef7 100644 --- a/backend/src/__tests__/publicSiteService.test.js +++ b/backend/src/__tests__/publicSiteService.test.js @@ -104,4 +104,59 @@ describe('publicSiteService', () => { expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png'); expect(payload.branding.colors.primary).toBe('#5C8762'); }); + + it('exposes the 8-token CI palette through branding.colors', async () => { + const publicSiteRows = buildPublicSiteRows({}); + const brandingRows = buildBrandingRows({ + themeConfig: { + // LBM CI palette (charcoal + teal). + primaryColor: '#014E4E', + accentColor: '#017C7C', + accentDarkColor: '#014E4E', + backgroundColor: '#0D0D0D', + surfaceColor: '#111414', + elevatedColor: '#182222', + surfaceBorderColor: '#1E2E2E', + textColor: '#EBEBEB', + mutedTextColor: '#4A6060' + } + }); + + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) })); + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) })); + + const payload = await getPublicSitePayload({ bypassCache: true }); + + // Legacy 4 colors still mapped. + expect(payload.branding.colors.primary).toBe('#014E4E'); + expect(payload.branding.colors.accent).toBe('#017C7C'); + expect(payload.branding.colors.background).toBe('#0D0D0D'); + expect(payload.branding.colors.text).toBe('#EBEBEB'); + // 8-token CI palette additions. + expect(payload.branding.colors.accentDark).toBe('#014E4E'); + expect(payload.branding.colors.surface).toBe('#111414'); + expect(payload.branding.colors.elevated).toBe('#182222'); + expect(payload.branding.colors.border).toBe('#1E2E2E'); + expect(payload.branding.colors.mutedText).toBe('#4A6060'); + }); + + it('falls back accentDark to legacy primaryColor when the new key is absent', async () => { + const publicSiteRows = buildPublicSiteRows({}); + const brandingRows = buildBrandingRows({ + themeConfig: { + primaryColor: '#5C8762', + accentColor: '#22c55e', + backgroundColor: '#fafafa', + textColor: '#171717' + // accentDarkColor intentionally omitted to simulate a legacy theme. + } + }); + + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) })); + db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) })); + + const payload = await getPublicSitePayload({ bypassCache: true }); + + expect(payload.branding.colors.accentDark).toBe('#5C8762'); + }); }); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 125b341a..7b832a18 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -219,9 +219,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_header, logo_display_hero, logo_display_mode, - hide_powered_by + hide_powered_by, + force_color_mode } = req.body; + // Normalize force_color_mode: only 'dark' | 'light' | null are valid. + const normalizedForceColorMode = force_color_mode === 'dark' + ? 'dark' + : force_color_mode === 'light' + ? 'light' + : null; + // Get current watermark settings hash for change detection const oldSettingsHash = await watermarkService.getSettingsHash(); @@ -243,7 +251,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_header, logo_display_hero, logo_display_mode, - hide_powered_by + hide_powered_by, + force_color_mode: normalizedForceColorMode }; // Handle favicon deletion if empty string or null is provided diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 1d689b3d..4c6c4fa8 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -65,6 +65,14 @@ router.get('/', async (req, res) => { branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false, branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text', branding_hide_powered_by: settingsObject.branding_hide_powered_by === true, + // Force a specific color mode site-wide. When set, the user toggle + // is hidden and the value overrides per-theme/system preference. + // Allowed values: 'dark' | 'light' | null (null = no force). + branding_force_color_mode: settingsObject.branding_force_color_mode === 'dark' + ? 'dark' + : settingsObject.branding_force_color_mode === 'light' + ? 'light' + : null, theme_config: settingsObject.theme_config || null, default_language: settingsObject.general_default_language || 'en', enable_analytics: settingsObject.general_enable_analytics !== false, diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index ea4537d0..5e07f517 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -162,29 +162,52 @@ function darkenColor(hex, amount = 0.15) { // Wrap HTML body in the styled email template with header, footer, and logo async function wrapEmailHtml(htmlBody, subject, language = 'en') { - // Get branding settings for logo and email colors + // Email colour palette. The two original settings (email_primary_color and + // email_secondary_color) keep their existing semantics so emails sent by + // upgraded instances render byte-for-byte identically until an admin + // touches the new fields. The six new tokens unlock full email theming + // (body bg, container card, list panel, body text, muted text, button text) + // and default to the previously hard-coded literals when absent. let logoUrl = ''; let companyName = 'PicPeak'; let primaryColor = '#5C8762'; let secondaryColor = '#f9f9f9'; + let bodyBgColor = '#f5f5f5'; // outer wrapper + body background + let containerBgColor = '#ffffff'; // email card + let listBgColor = '#f9f9f9'; //