feat(email): expand email palette to 8 tokens + Sync from Branding button
This commit is contained in:
@@ -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;">🔒 ${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;">⚠️ ${ci18n.warning}</p>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user