Enhance email templates with clickable links, branding, and improved styling

- Add clickable gallery links in all email templates
- Include application logo in email header and footer (custom or PicPeak default)
- Redesign emails with professional styling matching gallery login page
  - Gray background with white content box
  - PicPeak green header with centered logo
  - Clean typography and proper spacing
  - Responsive design for mobile devices
  - Styled call-to-action buttons
  - Footer with branding and copyright
- Update email processor to fetch branding settings dynamically
- Use proper API URLs for logo images in emails

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-10 22:19:15 +02:00
parent 6438374258
commit 5328b4f73a
52 changed files with 3237 additions and 683 deletions
+60 -12
View File
@@ -8,6 +8,7 @@ const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const { archiveEvent } = require('../services/archiveService');
const { formatDate } = require('../utils/dateFormatter');
// Create new event
router.post('/', adminAuth, [
@@ -20,12 +21,15 @@ router.post('/', adminAuth, [
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
body('welcome_message').optional().trim(),
body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean(),
body('upload_category_id').optional().isInt()
body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('host_name').notEmpty().trim()
], async (req, res) => {
try {
console.log('Create event request body:', req.body);
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.error('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
@@ -33,6 +37,7 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
admin_email,
password,
@@ -76,6 +81,7 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
admin_email,
password_hash,
@@ -96,17 +102,20 @@ router.post('/', adminAuth, [
);
// Queue creation email
// Determine language based on email domain
const emailLang = host_email.endsWith('.de') ? 'de' : 'en';
await db('email_queue').insert({
event_id: eventId,
recipient_email: host_email,
email_type: 'gallery_created',
email_data: JSON.stringify({
host_name: host_email.split('@')[0], // Extract name from email
host_name: host_name,
event_name,
event_date: new Date(event_date).toLocaleDateString(),
event_date: await formatDate(event_date, emailLang),
gallery_link: shareLink,
gallery_password: password,
expiry_date: expires_at.toLocaleDateString()
expiry_date: await formatDate(expires_at, emailLang)
})
// scheduled_at will use default value
});
@@ -245,10 +254,28 @@ router.get('/:id', adminAuth, async (req, res) => {
.limit(10)
.select('filename', 'type', 'size_bytes', 'uploaded_at');
// Get view and download statistics
const [{ totalViews }] = await db('access_logs')
.where('event_id', id)
.where('action', 'view')
.count('* as totalViews');
const [{ totalDownloads }] = await db('access_logs')
.where('event_id', id)
.where('action', 'download')
.count('* as totalDownloads');
const [{ uniqueVisitors }] = await db('access_logs')
.where('event_id', id)
.countDistinct('ip_address as uniqueVisitors');
res.json({
...event,
photo_count: parseInt(photoCount) || 0,
total_size: parseInt(totalSize) || 0,
total_views: parseInt(totalViews) || 0,
total_downloads: parseInt(totalDownloads) || 0,
unique_visitors: parseInt(uniqueVisitors) || 0,
recent_photos: recentPhotos
});
} catch (error) {
@@ -263,20 +290,44 @@ router.put('/:id', adminAuth, [
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
body('expires_at').optional().isISO8601(),
body('welcome_message').optional().trim(),
body('color_theme').optional().trim(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
body('upload_category_id').optional().isInt()
body('host_name').optional().trim().notEmpty(),
body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('upload_category_id must be an integer or null'),
body('hero_photo_id').optional().custom((value) => {
// Accept null, undefined, or numeric values
if (value === null || value === undefined) return true;
// Check if it's a number or can be converted to a valid integer
const num = Number(value);
return !isNaN(num) && Number.isInteger(num);
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
console.log('Request body:', req.body);
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = req.body;
// Log the update request for debugging
console.log('Update event request:', {
id,
updates,
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
color_theme_type: typeof updates.color_theme,
hero_photo_id: updates.hero_photo_id,
hero_photo_id_type: typeof updates.hero_photo_id
});
// Check if event exists
const event = await db('events').where('id', id).first();
if (!event) {
@@ -286,10 +337,7 @@ router.put('/:id', adminAuth, [
// Update event
await db('events')
.where('id', id)
.update({
...updates,
updated_at: new Date()
});
.update(updates);
// Log activity
await logActivity('event_updated',