Implement WYSIWYG newsletter editor with live preview, import, Supabase auto-save and Brevo export

This commit is contained in:
Jakub_Hostacny committed 2026-09-24 11:04:13 +02:00
1 parent 8763c5bace
commit 538f2ccae3
10 files changed
+2252 -62

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
import { NewsletterCampaign } from '@/types/newsletter';
export const SUPABASE_ASSETS_BASE =
'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/';
export const DEFAULT_CAMPAIGN: NewsletterCampaign = {
id: '',
title: 'COOP Jednota Newsletter',
preheader: 'Vyhrajte 3 autá KIA Sportage. Mliečne novinky, fantasticky nízke ceny, grilovanie aj vernostné zľavy s COOP Klubom.',
status: 'draft',
modules: [
{
id: 'mod-top-announcement',
type: 'announcement',
title: 'Top oznamovacia lišta',
data: {
tipBadge: 'TIP',
text: 'Vyhrajte auto s COOP Jednotou',
linkText: 'Chcem vyhrať ›',
linkUrl: 'https://www.coop.sk',
bgColor: '#e2001a'
}
},
{
id: 'mod-header',
type: 'header',
title: 'Hlavička s logom',
data: {
logoUrl: `${SUPABASE_ASSETS_BASE}logo_coop.png`,
logoLink: 'https://www.coop.sk',
navLinks: [
{ label: 'Letáky', url: 'https://www.coop.sk/sk/letaky', variant: 'outline' },
{ label: 'COOP Klub', url: 'https://www.coopklub.sk', variant: 'filled' }
]
}
},
{
id: 'mod-banner-1',
type: 'banner',
title: 'Hlavný Banner - Súťaž KIA',
data: {
imageUrl: `${SUPABASE_ASSETS_BASE}nws_podklady-velka_noc.jpg`,
linkUrl: 'https://www.coop.sk',
altText: 'Súťaž o autá',
title: 'Hrajte o 3 autá a ďalšie skvelé ceny!',
description: 'Nakúpte aspoň za 25 eur a hrajte o 3 autá KIA Sportage a tisíce okamžitých výhier.',
buttonText: 'Chcem vyhrať',
buttonUrl: 'https://www.coop.sk',
buttonBgColor: '#e2001a'
}
},
{
id: 'mod-banner-2',
type: 'banner',
title: 'Banner - Mliečne výrobky',
data: {
imageUrl: `${SUPABASE_ASSETS_BASE}nws_podklady-nove_obaly.jpg`,
linkUrl: 'https://www.coop.sk',
altText: 'Mliečne výrobky',
title: 'Tradične kvalitné mliečne výrobky v novom balení',
description: '',
buttonText: 'Nechajte sa ohúriť',
buttonUrl: 'https://www.coop.sk',
buttonBgColor: '#003399'
}
},
{
id: 'mod-products-fantasticky',
type: 'products_grid',
title: 'Fantasticky nízke ceny',
data: {
sectionTitle: 'Fantasticky nízke ceny!',
globalValidity: '2. 5. – 15. 5. 2026',
products: [
{
id: 'p1',
title: "Hellmann's Originál majonéza 405 ml",
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_1.png`,
priceMain: '1',
priceSub: '99',
unitPrice: '1kg = 4,91 €',
discountText: '23%',
badgeText: 'zľava',
cardBgColor: '#feeaea',
showSlovakFlag: false,
stores: { potraviny: true, supermarket: true, tempo: true }
},
{
id: 'p2',
title: 'Amálka šunka najvyššej kvality 100 g',
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_2.png`,
priceMain: '0',
priceSub: '89',
unitPrice: '1kg = 8,90 €',
discountText: '36%',
badgeText: 'zľava',
cardBgColor: '#feeaea',
showSlovakFlag: true,
stores: { potraviny: true, supermarket: true, tempo: true }
}
]
}
},
{
id: 'mod-products-domace',
type: 'products_grid',
title: 'Domáce a vždy čerstvé',
data: {
sectionTitle: 'DOMÁCE A VŽDY ČERSTVÉ',
globalValidity: '2. 5. – 15. 5. 2026',
products: [
{
id: 'p3',
title: 'Zemiaky žlté prané neskoré 2 kg 1 bal.',
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_3.png`,
priceMain: '1',
priceSub: '05',
unitPrice: '1kg = 0,53 €',
discountText: '34%',
badgeText: 'zľava',
cardBgColor: '#fef0c7',
showSlovakFlag: true,
stores: { potraviny: false, supermarket: true, tempo: true }
},
{
id: 'p4',
title: 'Cibuľa žltá neskorá praná 1 kg',
imageUrl: `${SUPABASE_ASSETS_BASE}produkt_4.png`,
priceMain: '0',
priceSub: '79',
unitPrice: '1kg = 0,79 €',
discountText: '27%',
badgeText: 'zľava',
cardBgColor: '#fef0c7',
showSlovakFlag: true,
stores: { potraviny: false, supermarket: true, tempo: true }
}
]
}
},
{
id: 'mod-footer',
type: 'footer',
title: 'Pätička',
data: {
disclaimer: 'Tento newsletter má len informatívny charakter. Ceny a ponuka tovaru sa môžu na jednotlivých predajniach líšiť. Ponuka platí do vypredania zásob.',
unsubscribeText: 'Ak si už neželáte dostávať náš newsletter, môžete sa odhlásiť tu.',
unsubscribeUrl: '{{ unsubscribe }}',
socials: {
facebook: 'https://www.facebook.com/coopjednotaslovensko',
instagram: 'https://www.instagram.com/coopjednotaslovensko',
tiktok: 'https://www.tiktok.com/@coopjednota',
youtube: 'https://www.youtube.com/c/COOPJednotaSlovensko'
},
appStoreUrl: 'https://apps.apple.com/sk/app/coop-jednota/id1527814838',
googlePlayUrl: 'https://play.google.com/store/apps/details?id=sk.coop.app',
foundationUrl: 'https://www.nadaciacoop.sk'
}
}
]
};
+314
View File
@@ -0,0 +1,314 @@
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
export function renderEmailHtml(campaign: NewsletterCampaign): string {
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n');
return `<!DOCTYPE html>
<html lang="sk" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light only">
<meta name="supported-color-schemes" content="light">
<title>${escapeHtml(campaign.title)}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<style>
:root { color-scheme: light only; supported-color-schemes: light; }
html, body {
margin: 0 auto !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
font-family: 'Poppins', Helvetica, Arial, sans-serif !important;
background-color: #001a33;
color-scheme: light only;
}
* { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt !important; mso-table-rspace: 0pt !important; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; max-width: 100%; }
a { text-decoration: none; }
@media screen and (max-width: 600px) {
.email-container { width: 100% !important; margin: auto !important; padding: 0 10px !important; box-sizing: border-box; }
.stack-column, .stack-column-center { display: block !important; width: 100% !important; max-width: 100% !important; direction: ltr !important; padding-left: 0 !important; padding-right: 0 !important; }
.stack-column-center { text-align: center !important; }
.mobile-padding { padding-left: 15px !important; padding-right: 15px !important; }
.logo-img { width: 90px !important; }
.header-link { display: block !important; margin: 3px 0 3px auto !important; padding: 4px 10px !important; width: 75px !important; font-size: 9px !important; text-align: center !important; margin-right: 0 !important; }
.glass-product { margin-bottom: 15px !important; }
}
</style>
</head>
<body width="100%" bgcolor="#001a33" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #001a33;">
<!-- Preheader text -->
<div style="display: none; max-height: 0; max-width: 0; overflow: hidden; opacity: 0; font-size: 1px; line-height: 1px; color: #fefefe; mso-hide: all;">
${escapeHtml(campaign.preheader || '')}
</div>
<center style="width: 100%; background-color: #001a33;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#001a33" style="background-color: #001a33; background-image: linear-gradient(135deg, #005bb5 0%, #000d1a 100%);">
<tr>
<td align="center" valign="top">
<!--[if mso | IE]>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" align="center" style="width:600px;"><tr><td align="center" valign="top" style="padding-top:15px; padding-bottom:30px;">
<![endif]-->
<div style="max-width: 600px; margin: 0 auto; padding-top: 15px; padding-bottom: 30px;" class="email-container">
${renderedModules}
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</table>
</center>
</body>
</html>`;
}
function renderModule(m: any, campaign: NewsletterCampaign): string {
switch (m.type) {
case 'announcement':
return `
<!-- TOP OZNAMOVACIA LIŠTA -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 15px 0 20px 0;">
<tr>
<td align="center">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="${m.data.bgColor || '#e2001a'}" style="background-color: ${m.data.bgColor || '#e2001a'}; background: linear-gradient(to right, #ff3d4f, #e2001a); border-radius: 12px; box-shadow: 0 10px 25px rgba(226, 0, 26, 0.3); border: 1px solid rgba(255,255,255,0.2);">
<tr>
<td style="padding: 10px 20px; text-align: center;">
<div style="display: inline-block; vertical-align: middle;">
<span style="background-color: #fefefe; color: ${m.data.bgColor || '#e2001a'}; font-size: 10px; font-weight: 800; padding: 2px 8px; border-radius: 4px; margin: 4px 5px; vertical-align: middle; display: inline-block; letter-spacing: 1px;">${escapeHtml(m.data.tipBadge || 'TIP')}</span>
</div>
<div style="display: inline-block; vertical-align: middle;">
<span style="color: #fefefe; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; vertical-align: middle; display: inline-block; padding: 5px 10px;">${escapeHtml(m.data.text || '')}</span>
</div>
${m.data.linkText ? `
<div style="display: inline-block; vertical-align: middle;">
<a href="${escapeHtml(m.data.linkUrl || '#')}" style="color: #ffde00; font-size: 11px; font-weight: 800; text-decoration: none; text-transform: uppercase; display: inline-block; padding: 5px 10px;">${escapeHtml(m.data.linkText)}</a>
</div>` : ''}
</td>
</tr>
</table>
</td>
</tr>
</table>`;
case 'header':
const navLinksHtml = (m.data.navLinks || [])
.map((link: any) => {
if (link.variant === 'filled') {
return `<a href="${escapeHtml(link.url)}" style="color: #fefefe; font-size: 12px; font-weight: 700; text-decoration: none; text-transform: uppercase; padding: 10px 16px; background-color: #003399; border-radius: 20px; display: inline-block; letter-spacing: 0.5px; box-shadow: 0 4px 10px rgba(0, 51, 153, 0.2);">${escapeHtml(link.label)}</a>`;
}
return `<a href="${escapeHtml(link.url)}" style="color: #e2001a; font-size: 12px; font-weight: 700; text-decoration: none; text-transform: uppercase; padding: 8px 16px; border: 2px solid #e2001a; border-radius: 20px; margin-right: 6px; display: inline-block; letter-spacing: 0.5px;">${escapeHtml(link.label)}</a>`;
})
.join('\n');
return `
<!-- HLAVIČKA -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#fefefe" style="background-color: #fefefe; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); overflow: hidden; margin-bottom: 25px;">
<tr>
<td style="padding: 15px 20px;" class="mobile-padding">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="35%" valign="middle" style="text-align: left;">
<a href="${escapeHtml(m.data.logoLink || 'https://www.coop.sk')}" target="_blank">
<img src="${escapeHtml(m.data.logoUrl)}" width="130" alt="COOP Jednota" style="display: inline-block; border: 0; border-radius: 8px;" class="logo-img">
</a>
</td>
<td width="65%" valign="middle" style="text-align: right;">
${navLinksHtml}
</td>
</tr>
</table>
</td>
</tr>
</table>`;
case 'banner':
return `
<!-- BANNER -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#fefefe" style="background-color: #fefefe; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); overflow: hidden; margin-bottom: 25px;">
<tr>
<td>
<a href="${escapeHtml(m.data.linkUrl || '#')}">
<img src="${escapeHtml(m.data.imageUrl)}" width="600" alt="${escapeHtml(m.data.altText || '')}" style="display: block; border: 0; width: 100%; max-width: 600px; border-top-left-radius: 16px; border-top-right-radius: 16px;">
</a>
</td>
</tr>
${m.data.title || m.data.description || m.data.buttonText ? `
<tr>
<td style="padding: 25px 20px; text-align: center;" class="mobile-padding">
${m.data.title ? `<h2 style="margin: 0 0 15px 0; font-size: 22px; color: #1a1a1a; font-weight: 700;">${escapeHtml(m.data.title)}</h2>` : ''}
${m.data.description ? `<p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.5; color: #555555;">${escapeHtml(m.data.description)}</p>` : ''}
${m.data.buttonText ? `
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td style="text-align: center;">
<a href="${escapeHtml(m.data.buttonUrl || '#')}" style="background-color: ${m.data.buttonBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 13px; font-weight: 800; text-decoration: none; padding: 12px 30px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(226, 0, 26, 0.35);">${escapeHtml(m.data.buttonText)}</a>
</td>
</tr>
</table>` : ''}
</td>
</tr>` : ''}
</table>`;
case 'products_grid':
const globalValidity = m.data.globalValidity || '';
const products: ProductItem[] = m.data.products || [];
const rows: ProductItem[][] = [];
for (let i = 0; i < products.length; i += 2) {
rows.push(products.slice(i, i + 2));
}
const rowsHtml = rows
.map(
(row) => `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
${row
.map(
(p, idx) => `
<td width="50%" valign="top" style="padding-${idx === 0 ? 'right: 6px' : 'left: 6px'};">
${renderProductCard(p, globalValidity)}
</td>`
)
.join('\n')}
${row.length === 1 ? '<td width="50%"></td>' : ''}
</tr>
</table>`
)
.join('\n');
return `
<!-- PRODUKTOVÁ SEKCIA -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 25px;">
${m.data.sectionTitle ? `
<tr>
<td style="text-align: center; padding-top: 15px; padding-bottom: 20px;">
<h2 style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeHtml(m.data.sectionTitle)}</h2>
</td>
</tr>` : ''}
<tr>
<td>
${rowsHtml}
</td>
</tr>
</table>`;
case 'footer':
return `
<!-- PÄTIČKA -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" bgcolor="#001a33" style="background-color: #001a33; padding: 25px 15px; text-align: center; color: #a0aec0; font-size: 11px; line-height: 1.6;">
<tr>
<td align="center">
<!-- Sociálne siete -->
<div style="margin-bottom: 15px;">
<a href="${escapeHtml(m.data.socials?.facebook || '#')}" style="margin: 0 6px; display: inline-block;">
<img src="${SUPABASE_ASSETS_BASE}ikona_fb.png" width="28" height="28" alt="Facebook" style="display: block;">
</a>
<a href="${escapeHtml(m.data.socials?.instagram || '#')}" style="margin: 0 6px; display: inline-block;">
<img src="${SUPABASE_ASSETS_BASE}ikona_ig.png" width="28" height="28" alt="Instagram" style="display: block;">
</a>
<a href="${escapeHtml(m.data.socials?.tiktok || '#')}" style="margin: 0 6px; display: inline-block;">
<img src="${SUPABASE_ASSETS_BASE}ikona_tt.png" width="28" height="28" alt="TikTok" style="display: block;">
</a>
<a href="${escapeHtml(m.data.socials?.youtube || '#')}" style="margin: 0 6px; display: inline-block;">
<img src="${SUPABASE_ASSETS_BASE}ikona_yt.png" width="28" height="28" alt="YouTube" style="display: block;">
</a>
</div>
<!-- Disclaimer -->
<p style="margin: 0 0 15px 0; color: #718096; font-size: 10px;">
${escapeHtml(m.data.disclaimer || '')}
</p>
<!-- Unsubscribe -->
<p style="margin: 0; color: #a0aec0;">
<a href="${escapeHtml(m.data.unsubscribeUrl || '{{ unsubscribe }}')}" style="color: #cbd5e0; text-decoration: underline;">${escapeHtml(m.data.unsubscribeText || 'Odhlásiť sa')}</a>
</p>
</td>
</tr>
</table>`;
default:
return '';
}
}
function renderProductCard(p: ProductItem, globalValidity: string): string {
const validity = p.validityOverride || globalValidity;
const bgColor = p.cardBgColor || '#feeaea';
const storesHtml = `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right">
${p.stores?.potraviny ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 1px solid #fefefe; background-color: #e2001a; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_potraviny.png" height="10" alt="P" style="display: block; height: 10px; width: auto;"></td></tr></table></td></tr>` : ''}
${p.stores?.supermarket ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 1px solid #fefefe; background-color: #f9890e; padding: 0; line-height: 0; border-top: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_supermarket.png" height="10" alt="S" style="display: block; height: 10px; width: auto;"></td></tr></table></td></tr>` : ''}
${p.stores?.tempo ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 1px solid #fefefe; background-color: #005239; padding: 0; line-height: 0; border-top: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_tempo.png" height="10" alt="T" style="display: block; height: 10px; width: auto;"></td></tr></table></td></tr>` : ''}
</table>`;
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" bgcolor="${bgColor}" style="background-color: ${bgColor}; border: 1px solid rgba(0,0,0,0.08); border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);">
<tr>
<td style="padding: 15px 15px 0 15px;" valign="top">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
<td align="right">${storesHtml}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(p.title)}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
</td>
</tr>
<tr>
<td style="padding: 0 8px 10px 8px; text-align: center; height: 50px;" valign="top">
<h3 style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600;">${escapeHtml(p.title)}</h3>
</td>
</tr>
<tr height="1">
<td style="padding: 0 8px 12px 8px; text-align: center;" valign="bottom">
<!-- Cenovka -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="width: 95px; margin: 0 auto;">
<tr>
<td align="center">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: #fefefe; border: 2px solid #fefefe; border-radius: 12px 0 0 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.18); width: 85px; overflow: hidden;">
${p.discountText ? `
<tr>
<td style="padding: 2px 4px; text-align: right; background-color: #fefefe;">
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 9px; color: #000000; font-weight: 700; text-transform: lowercase;">${escapeHtml(p.badgeText || 'zľava')}</span>
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 20px; color: #000000; font-weight: 900; line-height: 1; margin-left: 2px;">${escapeHtml(p.discountText)}</span>
</td>
</tr>` : ''}
<tr>
<td bgcolor="#e2001a" style="background-color: #e2001a; padding: 4px 2px 2px 2px; font-family: 'Arial Narrow', Arial, sans-serif; color: #fefefe; font-weight: 900; line-height: 1; text-align: center; border-radius: 0 0 0 8px;">
<span style="font-size: 52px;">${escapeHtml(p.priceMain)}</span><sup style="font-size: 26px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.priceSub)}</sup>
</td>
</tr>
</table>
</td>
</tr>
${p.showSlovakFlag ? `
<tr>
<td align="right" style="line-height: 0; padding: 0;">
<img src="${SUPABASE_ASSETS_BASE}vlajka_srdce.png" width="40" alt="SK" style="display: block; margin-top: -26px; margin-right: -10px; padding: 4px;">
</td>
</tr>` : ''}
</table>
${p.unitPrice ? `<div style="font-size: 9px; color: #666666; margin-top: 4px; font-weight: 500; font-family: Arial, sans-serif;">${escapeHtml(p.unitPrice)}</div>` : ''}
</td>
</tr>
</table>`;
}
function escapeHtml(str: string): string {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}