feat(email): expand email palette to 8 tokens + Sync from Branding button

This commit is contained in:
Luca
2026-05-06 01:41:45 +02:00
parent 565ae45ca7
commit 47b6b39f3a
2 changed files with 182 additions and 66 deletions
+64 -19
View File
@@ -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'; // <ul> info panel inside content
let bodyTextColor = '#333333'; // paragraph text + <strong>
let mutedTextColor = '#666666'; // footer text
let buttonTextColor = '#ffffff'; // CTA text on primary button
try {
const brandingSettings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_url', 'branding_company_name',
'email_primary_color', 'email_secondary_color'
'email_primary_color', 'email_secondary_color',
'email_body_bg_color', 'email_container_bg_color',
'email_list_bg_color', 'email_body_text_color',
'email_muted_text_color', 'email_button_text_color'
])
.select('setting_key', 'setting_value');
const readSetting = (val, fallback) => {
if (!val) return fallback;
try { return JSON.parse(val); } catch (e) { return val; }
};
brandingSettings.forEach(setting => {
const val = setting.setting_value;
if (setting.setting_key === 'branding_logo_url' && val) {
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; }
} else if (setting.setting_key === 'branding_company_name' && val) {
try { companyName = JSON.parse(val); } catch (e) { companyName = val; }
} else if (setting.setting_key === 'email_primary_color' && val) {
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; }
} else if (setting.setting_key === 'email_secondary_color' && val) {
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; }
switch (setting.setting_key) {
case 'branding_logo_url': logoUrl = readSetting(val, logoUrl); break;
case 'branding_company_name': companyName = readSetting(val, companyName); break;
case 'email_primary_color': primaryColor = readSetting(val, primaryColor); break;
case 'email_secondary_color': secondaryColor = readSetting(val, secondaryColor); break;
case 'email_body_bg_color': bodyBgColor = readSetting(val, bodyBgColor); break;
case 'email_container_bg_color': containerBgColor = readSetting(val, containerBgColor); break;
case 'email_list_bg_color': listBgColor = readSetting(val, listBgColor); break;
case 'email_body_text_color': bodyTextColor = readSetting(val, bodyTextColor); break;
case 'email_muted_text_color': mutedTextColor = readSetting(val, mutedTextColor); break;
case 'email_button_text_color': buttonTextColor = readSetting(val, buttonTextColor); break;
default: break;
}
});
} catch (error) {
@@ -211,17 +234,17 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
background-color: ${bodyBgColor};
color: ${bodyTextColor};
}
.email-wrapper {
background-color: #f5f5f5;
background-color: ${bodyBgColor};
padding: 40px 20px;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
background-color: ${containerBgColor};
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
@@ -250,7 +273,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
margin-bottom: 15px;
}
.email-content ul {
background-color: #f9f9f9;
background-color: ${listBgColor};
padding: 20px 20px 20px 40px;
border-radius: 5px;
margin: 20px 0;
@@ -262,7 +285,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
display: inline-block;
padding: 12px 30px;
background-color: ${primaryColor};
color: white !important;
color: ${buttonTextColor} !important;
text-decoration: none;
border-radius: 5px;
font-weight: 500;
@@ -284,7 +307,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
opacity: 0.8;
}
.email-footer p {
color: #666;
color: ${mutedTextColor};
font-size: 14px;
margin: 5px 0;
}
@@ -296,7 +319,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
color: ${hoverColor};
}
strong {
color: #333;
color: ${bodyTextColor};
}
@media only screen and (max-width: 600px) {
.email-wrapper {
@@ -565,12 +588,34 @@ async function processTemplate(template, variables, language = 'en') {
};
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
// The client-access CTA used to be a hard-coded #5C8762 fill; now it
// mirrors the configurable email_primary_color so the brand colour is
// consistent across every button in the email. Defaults match the
// historical literal so unchanged installs render identically.
let cli_primary = '#5C8762';
let cli_buttonText = '#ffffff';
try {
const rows = await db('app_settings')
.whereIn('setting_key', ['email_primary_color', 'email_button_text_color'])
.select('setting_key', 'setting_value');
rows.forEach((row) => {
if (!row.setting_value) return;
let parsed;
try { parsed = JSON.parse(row.setting_value); } catch (e) { parsed = row.setting_value; }
if (row.setting_key === 'email_primary_color') cli_primary = parsed || cli_primary;
if (row.setting_key === 'email_button_text_color') cli_buttonText = parsed || cli_buttonText;
});
} catch (e) {
// Non-fatal — fall back to literals so the email still renders.
logger.warn('Failed to read email colours for client-access block', { error: e.message });
}
htmlBody += `
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<strong style="font-size: 15px;">&#128274; ${ci18n.label}</strong>
<p style="margin: 10px 0 8px;">${ci18n.desc}</p>
<p style="margin: 8px 0;">
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${ci18n.link}</a>
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: ${cli_primary}; color: ${cli_buttonText}; text-decoration: none; border-radius: 6px; font-weight: 600;">${ci18n.link}</a>
</p>
<p style="margin: 8px 0;">${ci18n.pin}: <strong>${processedVariables.client_password}</strong></p>
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">&#9888;&#65039; ${ci18n.warning}</p>
+118 -47
View File
@@ -18,7 +18,7 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { Palette } from 'lucide-react';
import { Palette, RefreshCw, Info } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service';
@@ -110,8 +110,19 @@ export const EmailConfigPage: React.FC = () => {
htmlContent: '',
textContent: ''
});
// 8-token email palette. The first two are the historical settings —
// upgraded installs keep their saved values. The last six are new and
// default to the literals previously hard-coded into emailProcessor.js,
// which means an admin who never opens this card sees emails render
// exactly as before. Touching any picker enables full email theming.
const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762');
const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9');
const [emailBodyBgColor, setEmailBodyBgColor] = useState('#f5f5f5');
const [emailContainerBgColor, setEmailContainerBgColor] = useState('#ffffff');
const [emailListBgColor, setEmailListBgColor] = useState('#f9f9f9');
const [emailBodyTextColor, setEmailBodyTextColor] = useState('#333333');
const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666');
const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff');
const queryClient = useQueryClient();
// SMTP Configuration state
@@ -142,6 +153,12 @@ export const EmailConfigPage: React.FC = () => {
if (allSettings) {
if (allSettings.email_primary_color) setEmailPrimaryColor(allSettings.email_primary_color);
if (allSettings.email_secondary_color) setEmailSecondaryColor(allSettings.email_secondary_color);
if (allSettings.email_body_bg_color) setEmailBodyBgColor(allSettings.email_body_bg_color);
if (allSettings.email_container_bg_color) setEmailContainerBgColor(allSettings.email_container_bg_color);
if (allSettings.email_list_bg_color) setEmailListBgColor(allSettings.email_list_bg_color);
if (allSettings.email_body_text_color) setEmailBodyTextColor(allSettings.email_body_text_color);
if (allSettings.email_muted_text_color) setEmailMutedTextColor(allSettings.email_muted_text_color);
if (allSettings.email_button_text_color) setEmailButtonTextColor(allSettings.email_button_text_color);
}
}, [allSettings]);
@@ -213,7 +230,7 @@ export const EmailConfigPage: React.FC = () => {
});
const saveEmailColorsMutation = useMutation({
mutationFn: (colors: { email_primary_color: string; email_secondary_color: string }) =>
mutationFn: (colors: Record<string, string>) =>
settingsService.updateSettings(colors),
onSuccess: () => {
toast.success(t('toast.saveSuccess'));
@@ -228,9 +245,53 @@ export const EmailConfigPage: React.FC = () => {
saveEmailColorsMutation.mutate({
email_primary_color: emailPrimaryColor,
email_secondary_color: emailSecondaryColor,
email_body_bg_color: emailBodyBgColor,
email_container_bg_color: emailContainerBgColor,
email_list_bg_color: emailListBgColor,
email_body_text_color: emailBodyTextColor,
email_muted_text_color: emailMutedTextColor,
email_button_text_color: emailButtonTextColor,
});
};
/**
* Sync email colours from the active Branding theme so admins can hit one
* button and have email + site share an identical palette.
*
* Mapping (Branding token → email token):
* accentDarkColor → email_primary_color (header bg, H2, button bg, link)
* surfaceColor → email_secondary_color (footer bg)
* backgroundColor → email_body_bg_color (outer wrapper)
* surfaceColor → email_container_bg_color (email card)
* elevatedColor → email_list_bg_color (info <ul> panel)
* textColor → email_body_text_color
* mutedTextColor → email_muted_text_color
* (constant) → email_button_text_color (#ffffff — no Branding equivalent)
*
* Just updates local state — admin still has to click Save to persist.
* That two-step keeps the flow predictable and avoids surprise saves.
*/
const handleSyncFromBranding = () => {
const theme = allSettings?.theme_config || {};
const accentDark = theme.accentDarkColor || theme.primaryColor || '#5C8762';
const surface = theme.surfaceColor || '#ffffff';
const background = theme.backgroundColor || '#fafafa';
const elevated = theme.elevatedColor || '#f5f5f5';
const textColor = theme.textColor || '#171717';
const mutedText = theme.mutedTextColor || '#737373';
setEmailPrimaryColor(accentDark);
setEmailSecondaryColor(surface);
setEmailBodyBgColor(background);
setEmailContainerBgColor(surface);
setEmailListBgColor(elevated);
setEmailBodyTextColor(textColor);
setEmailMutedTextColor(mutedText);
// Button text stays #ffffff — needs to read on accent-dark fill regardless
// of branding accent choice. Admins can still override it manually.
toast.info(t('email.syncedFromBranding', 'Email colours synced from Branding. Click Save to apply.'));
};
const handleSaveSmtp = () => {
// Validate SMTP config
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
@@ -579,56 +640,66 @@ export const EmailConfigPage: React.FC = () => {
{activeTab === 'smtp' && (
<div className="mt-6">
<Card padding="md">
<div className="flex items-center gap-2 mb-4">
<Palette className="w-5 h-5 text-neutral-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.brandingTitle')}</h2>
<div className="flex items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-2">
<Palette className="w-5 h-5 text-neutral-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.brandingTitle')}</h2>
</div>
{/* One-click copy from Branding theme so email + site share an
identical palette. Just stages the values — admin still has
to click Save to persist (avoids surprise mass-saves). */}
<Button
variant="outline"
size="sm"
onClick={handleSyncFromBranding}
leftIcon={<RefreshCw className="w-4 h-4" />}
>
{t('email.syncFromBranding', 'Sync from Branding')}
</Button>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">{t('email.brandingDescription')}</p>
{/* 8 email colour pickers. Each row uses the same compact label
+ info-tooltip pattern as the gallery palette in
ThemeCustomizerEnhanced — keeps the two configurators visually
consistent without sharing the React component (the email
state is local to this page and saved through a different
endpoint, so reuse would be more friction than value). */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('email.primaryColor')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.primaryColorHint')}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={emailPrimaryColor}
onChange={(e) => setEmailPrimaryColor(e.target.value)}
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
type="text"
value={emailPrimaryColor}
onChange={(e) => setEmailPrimaryColor(e.target.value)}
className="w-32"
placeholder="#5C8762"
/>
{[
{ label: t('email.primaryColor', 'Primary'), help: t('email.primaryColorHelp', 'Header bar, H2 headings, button background, link colour. Maps to Branding → Accent (filled).'), value: emailPrimaryColor, setter: setEmailPrimaryColor, fallback: '#5C8762' },
{ label: t('email.secondaryColor', 'Footer background'), help: t('email.secondaryColorHelp', 'Footer bar background. Maps to Branding → Surface.'), value: emailSecondaryColor, setter: setEmailSecondaryColor, fallback: '#f9f9f9' },
{ label: t('email.bodyBgColor', 'Page background'), help: t('email.bodyBgColorHelp', 'The wrapper around the email card — what the recipient sees behind the email itself. Maps to Branding → Background.'), value: emailBodyBgColor, setter: setEmailBodyBgColor, fallback: '#f5f5f5' },
{ label: t('email.containerBgColor', 'Email card'), help: t('email.containerBgColorHelp', 'The white card that holds the email content. Maps to Branding → Surface.'), value: emailContainerBgColor, setter: setEmailContainerBgColor, fallback: '#ffffff' },
{ label: t('email.listBgColor', 'Info panel'), help: t('email.listBgColorHelp', 'Background of the bulleted info panels inside the email body. Maps to Branding → Elevated.'), value: emailListBgColor, setter: setEmailListBgColor, fallback: '#f9f9f9' },
{ label: t('email.bodyTextColor', 'Body text'), help: t('email.bodyTextColorHelp', 'Paragraph and bold text colour. Maps to Branding → Primary text.'), value: emailBodyTextColor, setter: setEmailBodyTextColor, fallback: '#333333' },
{ label: t('email.mutedTextColor', 'Footer text'), help: t('email.mutedTextColorHelp', 'Footer text and copyright line. Maps to Branding → Secondary text.'), value: emailMutedTextColor, setter: setEmailMutedTextColor, fallback: '#666666' },
{ label: t('email.buttonTextColor', 'Button text'), help: t('email.buttonTextColorHelp', 'Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white.'), value: emailButtonTextColor, setter: setEmailButtonTextColor, fallback: '#ffffff' },
].map(({ label, help, value, setter, fallback }) => (
<div key={label}>
<label className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{label}
<span className="info-tooltip text-neutral-400 dark:text-neutral-500" data-tooltip={help} tabIndex={0}>
<Info className="w-3.5 h-3.5" />
</span>
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={value}
onChange={(e) => setter(e.target.value)}
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
type="text"
value={value}
onChange={(e) => setter(e.target.value)}
className="w-32"
placeholder={fallback}
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('email.secondaryColor')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.secondaryColorHint')}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={emailSecondaryColor}
onChange={(e) => setEmailSecondaryColor(e.target.value)}
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
type="text"
value={emailSecondaryColor}
onChange={(e) => setEmailSecondaryColor(e.target.value)}
className="w-32"
placeholder="#f9f9f9"
/>
</div>
</div>
))}
</div>
<div className="mt-6">