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'; //
${ci18n.desc}
- ${ci18n.link} + ${ci18n.link}
${ci18n.pin}: ${processedVariables.client_password}
⚠️ ${ci18n.warning}
diff --git a/backend/src/services/publicSiteService.js b/backend/src/services/publicSiteService.js index d51332f0..75cfa92e 100644 --- a/backend/src/services/publicSiteService.js +++ b/backend/src/services/publicSiteService.js @@ -87,11 +87,19 @@ async function fetchBrandingContext() { supportEmail: null, logoUrl: null, footerText: null, + // 8-token CI palette mirrored from frontend ThemeConfig. + // primary/accent are kept as legacy aliases (primary == accent-dark); + // new tokens are surface, elevated, border, mutedText, accentDark. colors: { primary: '#16a34a', accent: '#0f766e', + accentDark: '#16a34a', background: '#f4fbf6', - text: '#0f172a' + surface: '#ffffff', + elevated: '#f5f5f5', + border: '#e5e5e5', + text: '#0f172a', + mutedText: '#737373' } }; @@ -117,10 +125,17 @@ async function fetchBrandingContext() { try { const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed; if (themeConfig && typeof themeConfig === 'object') { + // Legacy 4 colors context.colors.primary = themeConfig.primaryColor || context.colors.primary; context.colors.accent = themeConfig.accentColor || context.colors.accent; context.colors.background = themeConfig.backgroundColor || context.colors.background; context.colors.text = themeConfig.textColor || context.colors.text; + // 8-token CI palette additions + context.colors.accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor || context.colors.accentDark; + context.colors.surface = themeConfig.surfaceColor || context.colors.surface; + context.colors.elevated = themeConfig.elevatedColor || context.colors.elevated; + context.colors.border = themeConfig.surfaceBorderColor || context.colors.border; + context.colors.mutedText = themeConfig.mutedTextColor || context.colors.mutedText; } } catch (error) { logger.warn('Failed to parse theme configuration for public site', { error: error.message }); diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx index ee32207e..b2a38280 100644 --- a/frontend/src/components/GlobalThemeProvider.tsx +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -18,6 +18,7 @@ export const GlobalThemeProvider: React.FC