fix: resolve external media dimensions, gallery theme race condition, and add email color customization
- Fix dimension repair for external media by using photoResolver instead of hardcoded paths - Extract photo dimensions via Sharp during external media import - Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition - Pass event welcome_message to Story layout footer for per-event customization - Add email_primary_color/email_secondary_color settings with admin UI color pickers - Add i18n keys for email branding in all 4 locales (en, de, ru, pt)
This commit is contained in:
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const sharp = require('sharp');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -108,6 +109,18 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
.first();
|
||||
if (exists) { skipped++; continue; }
|
||||
const stats = await fs.stat(f.full);
|
||||
|
||||
// Extract dimensions via Sharp
|
||||
let width = null;
|
||||
let height = null;
|
||||
try {
|
||||
const metadata = await sharp(f.full).metadata();
|
||||
width = metadata.width || null;
|
||||
height = metadata.height || null;
|
||||
} catch (dimErr) {
|
||||
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
|
||||
}
|
||||
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
@@ -117,6 +130,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
thumbnail_path: null,
|
||||
type,
|
||||
size_bytes: stats.size,
|
||||
width,
|
||||
height,
|
||||
source_origin: 'external',
|
||||
external_relpath: f.rel
|
||||
})
|
||||
|
||||
@@ -3,11 +3,10 @@ const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
// Module-level progress state
|
||||
let repairProgress = {
|
||||
@@ -23,13 +22,18 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
||||
}
|
||||
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where(function () {
|
||||
this.whereNull('width').orWhereNull('height');
|
||||
this.whereNull('photos.width').orWhereNull('photos.height');
|
||||
})
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
|
||||
})
|
||||
.select('id', 'path', 'filename');
|
||||
.select(
|
||||
'photos.id', 'photos.path', 'photos.filename',
|
||||
'photos.source_origin', 'photos.external_relpath', 'photos.event_id',
|
||||
'events.source_mode', 'events.external_path', 'events.slug'
|
||||
);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
||||
@@ -61,15 +65,16 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
if (!photo.path) {
|
||||
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
|
||||
const event = { source_mode: photo.source_mode, external_path: photo.external_path, slug: photo.slug };
|
||||
let fullPath;
|
||||
try {
|
||||
fullPath = resolvePhotoFilePath(event, photo);
|
||||
} catch (err) {
|
||||
logger.warn(`Photo ${photo.id} has no resolvable path, skipping dimension repair: ${err.message}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch (err) {
|
||||
@@ -85,8 +90,7 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
updated_at: db.fn.now()
|
||||
height: metadata.height
|
||||
});
|
||||
successCount++;
|
||||
|
||||
|
||||
@@ -111,35 +111,48 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
// Darken a hex color by a percentage (0-1)
|
||||
function darkenColor(hex, amount = 0.15) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xFF) * (1 - amount)));
|
||||
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xFF) * (1 - amount)));
|
||||
const b = Math.max(0, Math.min(255, (num & 0xFF) * (1 - amount)));
|
||||
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
// 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
|
||||
// Get branding settings for logo and email colors
|
||||
let logoUrl = '';
|
||||
let companyName = 'PicPeak';
|
||||
let primaryColor = '#5C8762';
|
||||
let secondaryColor = '#f9f9f9';
|
||||
try {
|
||||
const brandingSettings = await db('app_settings')
|
||||
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_url', 'branding_company_name',
|
||||
'email_primary_color', 'email_secondary_color'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
brandingSettings.forEach(setting => {
|
||||
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
|
||||
try {
|
||||
logoUrl = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
logoUrl = setting.setting_value;
|
||||
}
|
||||
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
|
||||
try {
|
||||
companyName = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
companyName = setting.setting_value;
|
||||
}
|
||||
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; }
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching branding settings:', error);
|
||||
}
|
||||
|
||||
const hoverColor = darkenColor(primaryColor, 0.15);
|
||||
|
||||
// If no custom logo, use default PicPeak logo
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
const logoFullUrl = `${frontendUrl}${logoUrl || '/picpeak-logo-transparent.png'}`;
|
||||
@@ -172,7 +185,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #5C8762;
|
||||
background-color: ${primaryColor};
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -185,7 +198,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.email-content h2 {
|
||||
color: #5C8762;
|
||||
color: ${primaryColor};
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
@@ -206,7 +219,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background-color: #5C8762;
|
||||
background-color: ${primaryColor};
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
@@ -214,10 +227,10 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #4a6f4f;
|
||||
background-color: ${hoverColor};
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f9f9f9;
|
||||
background-color: ${secondaryColor};
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eee;
|
||||
@@ -234,11 +247,11 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
margin: 5px 0;
|
||||
}
|
||||
a {
|
||||
color: #5C8762;
|
||||
color: ${primaryColor};
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
color: #4a6f4f;
|
||||
color: ${hoverColor};
|
||||
}
|
||||
strong {
|
||||
color: #333;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { UserPhotoUpload } from './UserPhotoUpload';
|
||||
import type { FilterType } from './GalleryFilter';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
@@ -44,7 +43,7 @@ interface GalleryViewProps {
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||
@@ -275,62 +274,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
setStaticHeroPhoto(defaultHeroPhoto);
|
||||
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
|
||||
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settingsData && data?.event) {
|
||||
let themeToApply = null;
|
||||
const fullEvent = data.event; // Use the full event data from API
|
||||
|
||||
if (fullEvent.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
if (fullEvent.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(fullEvent.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
// Unknown theme name, fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid theme format - use default
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
} else if (settingsData.theme_config) {
|
||||
// No event theme, use global theme
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Apply theme with a small delay to ensure it overrides any global theme
|
||||
if (themeToApply) {
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||
// Apply hero photo ID to existing gallery settings
|
||||
} else if (fullEvent.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
||||
// Create gallery settings with hero photo ID
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||
|
||||
// Calculate days until expiration (null means never expires)
|
||||
const daysUntilExpiration = event.expires_at
|
||||
? differenceInDays(parseISO(event.expires_at), new Date())
|
||||
@@ -619,6 +562,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
onLogout={logout}
|
||||
/>
|
||||
|
||||
@@ -794,6 +738,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ interface PhotoGridWithLayoutsProps {
|
||||
heroDividerStyle?: HeroDividerStyle;
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
heroImageAnchor?: string;
|
||||
// Welcome message (per-event) for layouts that display it
|
||||
welcomeMessage?: string;
|
||||
// Logout callback for full-page layouts
|
||||
onLogout?: () => void;
|
||||
}
|
||||
@@ -97,6 +99,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
headerStyle,
|
||||
heroDividerStyle = 'wave',
|
||||
heroImageAnchor = 'center',
|
||||
welcomeMessage,
|
||||
onLogout
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -226,6 +229,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
heroLogoVisible,
|
||||
heroLogoSize,
|
||||
heroLogoPosition,
|
||||
welcomeMessage,
|
||||
onLogout,
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ interface CategoryScene {
|
||||
|
||||
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
|
||||
heroPhotoOverride?: Photo | null;
|
||||
welcomeMessage?: string;
|
||||
}
|
||||
|
||||
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
@@ -55,6 +56,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
heroPhotoOverride,
|
||||
welcomeMessage,
|
||||
onLogout
|
||||
}) => {
|
||||
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
|
||||
@@ -348,7 +350,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||
<footer className="story-footer">
|
||||
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
|
||||
<p className="story-footer-text">
|
||||
{t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
|
||||
{welcomeMessage || t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
|
||||
</p>
|
||||
{allowDownloads && (
|
||||
<button className="story-footer-btn" onClick={handleDownloadAll}>
|
||||
|
||||
@@ -1963,6 +1963,13 @@
|
||||
"required": "erforderlich",
|
||||
"ignoreSslErrors": "SSL/TLS-Zertifikatfehler ignorieren",
|
||||
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen.",
|
||||
"brandingTitle": "E-Mail-Branding",
|
||||
"brandingDescription": "Passen Sie die Farben in E-Mail-Vorlagen an. Änderungen gelten für Kopfzeile, Schaltflächen, Links und Fußzeilen-Hintergrund.",
|
||||
"primaryColor": "Primärfarbe",
|
||||
"primaryColorHint": "Wird für Kopfzeile, Schaltflächen und Links verwendet",
|
||||
"secondaryColor": "Fußzeilen-Hintergrund",
|
||||
"secondaryColorHint": "Wird für den Hintergrund der Fußzeile verwendet",
|
||||
"saveEmailColors": "E-Mail-Farben speichern",
|
||||
"editor": {
|
||||
"bold": "Fett",
|
||||
"italic": "Kursiv",
|
||||
|
||||
@@ -1660,6 +1660,13 @@
|
||||
"required": "required",
|
||||
"ignoreSslErrors": "Ignore SSL/TLS certificate errors",
|
||||
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications.",
|
||||
"brandingTitle": "Email Branding",
|
||||
"brandingDescription": "Customize the colors used in email templates. Changes apply to the header bar, buttons, links, and footer background.",
|
||||
"primaryColor": "Primary Color",
|
||||
"primaryColorHint": "Used for header, buttons, and links",
|
||||
"secondaryColor": "Footer Background",
|
||||
"secondaryColorHint": "Used for footer section background",
|
||||
"saveEmailColors": "Save Email Colors",
|
||||
"editor": {
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
|
||||
@@ -1636,6 +1636,13 @@
|
||||
"required": "obrigatorio",
|
||||
"ignoreSslErrors": "Ignorar erros de certificado SSL/TLS",
|
||||
"ignoreSslWarning": "Aviso: Desabilitar verificacao de certificado torna a conexao vulneravel a ataques man-in-the-middle. Ative somente se voce confia no servidor SMTP e entende as implicacoes de seguranca.",
|
||||
"brandingTitle": "Identidade Visual do E-mail",
|
||||
"brandingDescription": "Personalize as cores dos modelos de e-mail. As alteracoes se aplicam ao cabecalho, botoes, links e fundo do rodape.",
|
||||
"primaryColor": "Cor Primaria",
|
||||
"primaryColorHint": "Usada no cabecalho, botoes e links",
|
||||
"secondaryColor": "Fundo do Rodape",
|
||||
"secondaryColorHint": "Usada no fundo da secao de rodape",
|
||||
"saveEmailColors": "Salvar Cores do E-mail",
|
||||
"editor": {
|
||||
"bold": "Negrito",
|
||||
"italic": "Italico",
|
||||
|
||||
@@ -1636,6 +1636,13 @@
|
||||
"required": "обязательно",
|
||||
"ignoreSslErrors": "Игнорировать ошибки SSL/TLS-сертификата",
|
||||
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски.",
|
||||
"brandingTitle": "Оформление писем",
|
||||
"brandingDescription": "Настройте цвета в шаблонах писем. Изменения применяются к шапке, кнопкам, ссылкам и фону подвала.",
|
||||
"primaryColor": "Основной цвет",
|
||||
"primaryColorHint": "Используется для шапки, кнопок и ссылок",
|
||||
"secondaryColor": "Фон подвала",
|
||||
"secondaryColorHint": "Используется для фона подвала письма",
|
||||
"saveEmailColors": "Сохранить цвета",
|
||||
"editor": {
|
||||
"bold": "Жирный",
|
||||
"italic": "Курсив",
|
||||
|
||||
@@ -156,6 +156,15 @@ export const GalleryPage: React.FC = () => {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Inject hero photo ID into theme gallery settings
|
||||
if (themeToApply && galleryInfo.hero_photo_id) {
|
||||
if (themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = galleryInfo.hero_photo_id;
|
||||
} else {
|
||||
themeToApply.gallerySettings = { heroImageId: galleryInfo.hero_photo_id };
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
if (themeToApply) {
|
||||
setTheme(themeToApply);
|
||||
|
||||
@@ -17,8 +17,10 @@ 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const defaultTemplateKeys = [
|
||||
@@ -99,6 +101,8 @@ export const EmailConfigPage: React.FC = () => {
|
||||
htmlContent: '',
|
||||
textContent: ''
|
||||
});
|
||||
const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762');
|
||||
const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// SMTP Configuration state
|
||||
@@ -119,6 +123,19 @@ export const EmailConfigPage: React.FC = () => {
|
||||
queryFn: () => emailService.getConfig(),
|
||||
});
|
||||
|
||||
// Fetch email branding colors from app settings
|
||||
const { data: allSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (allSettings) {
|
||||
if (allSettings.email_primary_color) setEmailPrimaryColor(allSettings.email_primary_color);
|
||||
if (allSettings.email_secondary_color) setEmailSecondaryColor(allSettings.email_secondary_color);
|
||||
}
|
||||
}, [allSettings]);
|
||||
|
||||
// Fetch email templates
|
||||
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ['email-templates'],
|
||||
@@ -186,6 +203,25 @@ export const EmailConfigPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const saveEmailColorsMutation = useMutation({
|
||||
mutationFn: (colors: { email_primary_color: string; email_secondary_color: string }) =>
|
||||
settingsService.updateSettings(colors),
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.saveSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleSaveEmailColors = () => {
|
||||
saveEmailColorsMutation.mutate({
|
||||
email_primary_color: emailPrimaryColor,
|
||||
email_secondary_color: emailSecondaryColor,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveSmtp = () => {
|
||||
// Validate SMTP config
|
||||
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
|
||||
@@ -498,6 +534,76 @@ export const EmailConfigPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Branding - below SMTP settings */}
|
||||
{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>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">{t('email.brandingDescription')}</p>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</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">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveEmailColors}
|
||||
isLoading={saveEmailColorsMutation.isPending}
|
||||
leftIcon={<Save className="w-5 h-5" />}
|
||||
>
|
||||
{t('email.saveEmailColors')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Templates Tab */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -242,6 +242,8 @@ export const settingsService = {
|
||||
endpoint = '/admin/settings/branding';
|
||||
} else if (firstKey?.startsWith('seo_')) {
|
||||
endpoint = '/admin/settings/seo';
|
||||
} else if (firstKey?.startsWith('email_')) {
|
||||
endpoint = '/admin/settings/general';
|
||||
}
|
||||
|
||||
await api.put(endpoint, settings);
|
||||
|
||||
Reference in New Issue
Block a user