From 538f2ccae313229dc0a0f485c0abbfe8bb9b4602 Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Thu, 24 Sep 2026 11:04:13 +0200 Subject: [PATCH] Implement WYSIWYG newsletter editor with live preview, import, Supabase auto-save and Brevo export --- src/app/api/brevo/export/route.ts | 75 +++ src/app/api/inline-css/route.ts | 24 + src/app/page.tsx | 500 ++++++++++++++++--- src/components/editor/BadgeManagerModal.tsx | 196 ++++++++ src/components/editor/ConfigPanel.tsx | 420 ++++++++++++++++ src/components/editor/ProductImportModal.tsx | 326 ++++++++++++ src/components/editor/TopBar.tsx | 139 ++++++ src/lib/defaultCampaign.ts | 161 ++++++ src/lib/emailRenderer.ts | 314 ++++++++++++ src/types/newsletter.ts | 159 ++++++ 10 files changed, 2252 insertions(+), 62 deletions(-) create mode 100644 src/app/api/brevo/export/route.ts create mode 100644 src/app/api/inline-css/route.ts create mode 100644 src/components/editor/BadgeManagerModal.tsx create mode 100644 src/components/editor/ConfigPanel.tsx create mode 100644 src/components/editor/ProductImportModal.tsx create mode 100644 src/components/editor/TopBar.tsx create mode 100644 src/lib/defaultCampaign.ts create mode 100644 src/lib/emailRenderer.ts create mode 100644 src/types/newsletter.ts diff --git a/src/app/api/brevo/export/route.ts b/src/app/api/brevo/export/route.ts new file mode 100644 index 0000000..7704e80 --- /dev/null +++ b/src/app/api/brevo/export/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + try { + const { campaignName, subject, htmlContent, scheduledAt } = await req.json(); + + if (!campaignName || !subject || !htmlContent) { + return NextResponse.json( + { error: 'Chýbajú povinné polia (campaignName, subject, htmlContent).' }, + { status: 400 } + ); + } + + const apiKey = process.env.BREVO_API_KEY; + const senderName = process.env.BREVO_SENDER_NAME || 'Jandl'; + const senderEmail = process.env.BREVO_SENDER_EMAIL || 'tomas.rafay@jandl.sk'; + + if (!apiKey) { + return NextResponse.json( + { error: 'BREVO_API_KEY nie je nakonfigurovaný na serveri.' }, + { status: 500 } + ); + } + + // Volanie Brevo API v3 + const payload: any = { + name: campaignName, + subject: subject, + sender: { + name: senderName, + email: senderEmail + }, + type: 'classic', + htmlContent: htmlContent + }; + + if (scheduledAt) { + payload.scheduledAt = scheduledAt; + } + + const brevoRes = await fetch('https://api.brevo.com/v3/emailCampaigns', { + method: 'POST', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-key': apiKey + }, + body: JSON.stringify(payload) + }); + + const data = await brevoRes.json(); + + if (!brevoRes.ok) { + console.error('Brevo API error:', data); + return NextResponse.json( + { error: data.message || 'Chyba pri vytváraní kampane v Brevo', details: data }, + { status: brevoRes.status } + ); + } + + // Úspech - vrátime campaignId a URL na správu v Brevo + return NextResponse.json({ + success: true, + campaignId: data.id, + brevoUrl: `https://app.brevo.com/camp/detail/email/${data.id}`, + message: 'Kampaň bola úspešne vytvorená v Brevo!' + }); + } catch (error: any) { + console.error('Export to Brevo error:', error); + return NextResponse.json( + { error: error.message || 'Interná chyba servera' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/inline-css/route.ts b/src/app/api/inline-css/route.ts new file mode 100644 index 0000000..e7e9060 --- /dev/null +++ b/src/app/api/inline-css/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import juice from 'juice'; + +export async function POST(req: NextRequest) { + try { + const { html } = await req.json(); + + if (!html) { + return NextResponse.json({ error: 'HTML obsah je povinný.' }, { status: 400 }); + } + + const inlined = juice(html, { + preserveMediaQueries: true, + removeStyleTags: false, + applyWidthAttributes: true, + applyHeightAttributes: true + }); + + return NextResponse.json({ html: inlined }); + } catch (err: any) { + console.error('Inlining error:', err); + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c887311..b6151bf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,69 +1,445 @@ -import Image from "next/image"; +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from '@/types/newsletter'; +import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign'; +import { renderEmailHtml } from '@/lib/emailRenderer'; +import { supabase } from '@/lib/supabase'; +import { TopBar } from '@/components/editor/TopBar'; +import { ConfigPanel } from '@/components/editor/ConfigPanel'; +import { ProductImportModal } from '@/components/editor/ProductImportModal'; +import { BadgeManagerModal } from '@/components/editor/BadgeManagerModal'; +import JSZip from 'jszip'; +import { saveAs } from 'file-saver'; +import { ChevronUp, ChevronDown, Copy, Trash2 } from 'lucide-react'; export default function Home() { + const [campaign, setCampaign] = useState(DEFAULT_CAMPAIGN); + const [selectedModuleId, setSelectedModuleId] = useState('mod-products-fantasticky'); + const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop'); + const [isSaving, setIsSaving] = useState(false); + const [isExportingBrevo, setIsExportingBrevo] = useState(false); + const [brevoUrl, setBrevoUrl] = useState(null); + + // Modály + const [isImportOpen, setIsImportOpen] = useState(false); + const [isBadgeModalOpen, setIsBadgeModalOpen] = useState(false); + const [activeProductIdForBadge, setActiveProductIdForBadge] = useState(null); + + // Načítanie existujúceho draftu z databázy alebo inicializácia + useEffect(() => { + async function loadInitial() { + try { + const { data, error } = await supabase + .from('templates') + .select('*') + .order('updated_at', { ascending: false }) + .limit(1); + + if (data && data.length > 0) { + const dbItem = data[0]; + setCampaign({ + id: dbItem.id, + title: dbItem.title, + preheader: dbItem.data.preheader || DEFAULT_CAMPAIGN.preheader, + status: dbItem.status, + brevoCampaignId: dbItem.brevo_campaign_id, + modules: dbItem.data.modules || DEFAULT_CAMPAIGN.modules + }); + if (dbItem.brevo_campaign_id) { + setBrevoUrl(`https://app.brevo.com/camp/detail/email/${dbItem.brevo_campaign_id}`); + } + } + } catch (err) { + console.error('Error loading template from Supabase:', err); + } + } + loadInitial(); + }, []); + + // Auto-save s debounce (1 sekunda) + useEffect(() => { + const timer = setTimeout(async () => { + setIsSaving(true); + try { + if (!campaign.id) { + // Vytvorenie nového záznamu + const { data, error } = await supabase + .from('templates') + .insert([ + { + title: campaign.title, + status: campaign.status, + data: { + preheader: campaign.preheader, + modules: campaign.modules + } + } + ]) + .select(); + + if (data && data.length > 0) { + setCampaign((prev) => ({ ...prev, id: data[0].id })); + } + } else { + // Update existujúceho + await supabase + .from('templates') + .update({ + title: campaign.title, + data: { + preheader: campaign.preheader, + modules: campaign.modules + }, + updated_at: new Date().toISOString() + }) + .eq('id', campaign.id); + } + } catch (err) { + console.error('Auto-save error:', err); + } finally { + setIsSaving(false); + } + }, 1000); + + return () => clearTimeout(timer); + }, [campaign.title, campaign.preheader, campaign.modules]); + + // Vybraný modul + const selectedModule = campaign.modules.find((m) => m.id === selectedModuleId) || null; + + // Aktualizácia modulu + const handleUpdateModule = (updated: NewsletterModule) => { + setCampaign((prev) => ({ + ...prev, + modules: prev.modules.map((m) => (m.id === updated.id ? updated : m)) + })); + }; + + // Posun modulu hore/dole + const moveModule = (index: number, direction: 'up' | 'down') => { + const newModules = [...campaign.modules]; + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= newModules.length) return; + + const temp = newModules[index]; + newModules[index] = newModules[targetIndex]; + newModules[targetIndex] = temp; + + setCampaign((prev) => ({ ...prev, modules: newModules })); + }; + + // Duplikácia modulu + const duplicateModule = (index: number) => { + const original = campaign.modules[index]; + const clone: NewsletterModule = { + ...original, + id: `mod-${Date.now()}`, + title: `${original.title} (kópia)`, + data: JSON.parse(JSON.stringify(original.data)) + }; + const newModules = [...campaign.modules]; + newModules.splice(index + 1, 0, clone); + setCampaign((prev) => ({ ...prev, modules: newModules })); + setSelectedModuleId(clone.id); + }; + + // Zmazanie modulu + const deleteModule = (index: number) => { + if (campaign.modules.length <= 1) return; + const newModules = campaign.modules.filter((_, i) => i !== index); + setCampaign((prev) => ({ ...prev, modules: newModules })); + setSelectedModuleId(newModules[0]?.id || null); + }; + + // Pridanie nového modulu + const handleAddModule = (type: any) => { + let newMod: NewsletterModule; + if (type === 'banner') { + newMod = { + id: `mod-banner-${Date.now()}`, + type: 'banner', + title: 'Nový Banner', + data: { + imageUrl: `${SUPABASE_ASSETS_BASE}aktualne_letaky.jpg`, + linkUrl: 'https://www.coop.sk', + altText: 'Aktuálne letáky', + title: 'Prelistujte si naše najnovšie letáky online', + description: 'Všetky akcie a zľavy na jednom mieste priamo vo vašom mobile.', + buttonText: 'Pozrieť letáky', + buttonUrl: 'https://www.coop.sk', + buttonBgColor: '#e2001a' + } + }; + } else { + newMod = { + id: `mod-products-${Date.now()}`, + type: 'products_grid', + title: 'Nová sekcia produktov', + data: { + sectionTitle: 'AKČNÁ PONUKA', + globalValidity: '2. 5. – 15. 5. 2026', + products: [ + { + id: `p-${Date.now()}-1`, + title: 'Nový produkt 1', + imageUrl: `${SUPABASE_ASSETS_BASE}produkt_5.png`, + priceMain: '2', + priceSub: '49', + unitPrice: '1kg = 9,90 €', + discountText: '15%', + badgeText: 'zľava', + cardBgColor: '#feeaea', + stores: { potraviny: true, supermarket: true, tempo: true } + } + ] + } + }; + } + + setCampaign((prev) => ({ + ...prev, + modules: [...prev.modules, newMod] + })); + setSelectedModuleId(newMod.id); + }; + + // Hromadný import produktov do aktuálne vybraného modulu + const handleImportProducts = (imported: ProductItem[]) => { + if (!selectedModuleId) return; + const target = campaign.modules.find((m) => m.id === selectedModuleId); + if (!target || target.type !== 'products_grid') { + alert('Pre import prosím najskôr označte produktovú sekciu.'); + return; + } + + const updated = { + ...target, + data: { + ...target.data, + products: [...(target.data.products || []), ...imported] + } + }; + handleUpdateModule(updated); + }; + + // Export do ZIP (JSZip) + const handleExportZip = async () => { + const rawHtml = renderEmailHtml(campaign); + + // Inlinovanie CSS pred zabalením + let finalHtml = rawHtml; + try { + const res = await fetch('/api/inline-css', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ html: rawHtml }) + }); + const data = await res.json(); + if (data.html) finalHtml = data.html; + } catch (e) { + console.warn('Inliner fallback to raw html:', e); + } + + const zip = new JSZip(); + zip.file('index.html', finalHtml); + + // Pridáme README návod + zip.file( + 'README.txt', + 'COOP Jednota Newsletter Export\n\nSúbor index.html obsahuje finálny skompletizovaný a inlinovaný HTML kód pripravený na odoslanie cez ľubovoľný e-mailový systém (Brevo, Mailchimp, Outlook).' + ); + + const blob = await zip.generateAsync({ type: 'blob' }); + saveAs(blob, `coop-newsletter-${new Date().toISOString().split('T')[0]}.zip`); + }; + + // Export do Brevo (API v3) + const handleExportBrevo = async () => { + setIsExportingBrevo(true); + try { + const rawHtml = renderEmailHtml(campaign); + + // Inlinovanie + let finalHtml = rawHtml; + try { + const res = await fetch('/api/inline-css', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ html: rawHtml }) + }); + const data = await res.json(); + if (data.html) finalHtml = data.html; + } catch (e) {} + + const response = await fetch('/api/brevo/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + campaignName: `${campaign.title} - ${new Date().toLocaleDateString('sk-SK')}`, + subject: campaign.title, + htmlContent: finalHtml + }) + }); + + const resData = await response.json(); + + if (resData.success && resData.campaignId) { + setBrevoUrl(resData.brevoUrl); + // Uloženie stavu do Supabase + if (campaign.id) { + await supabase + .from('templates') + .update({ + status: 'exported', + brevo_campaign_id: resData.campaignId + }) + .eq('id', campaign.id); + } + alert('Kampaň bola úspešne odoslaná do Brevo!'); + } else { + alert(`Chyba pri exporte do Brevo: ${resData.error || 'Neznáma chyba'}`); + } + } catch (err: any) { + alert(`Chyba: ${err.message}`); + } finally { + setIsExportingBrevo(false); + } + }; + return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- + {/* 1. Horná lišta */} + {}} + onExportZip={handleExportZip} + onExportBrevo={handleExportBrevo} + isExportingBrevo={isExportingBrevo} + brevoCampaignUrl={brevoUrl} + onAddModule={handleAddModule} + /> + + {/* 2. Hlavný pracovný priestor (2 zóny: Stredné plátno a Pravý panel) */} +
+ {/* Stredné plátno - Živý WYSIWYG náhľad */} +
+ -
+ {/* Zoznam modulov na plátne */} +
+ {campaign.modules.map((mod, idx) => { + const isSelected = mod.id === selectedModuleId; + + return ( +
setSelectedModuleId(mod.id)} + className={`relative group cursor-pointer transition-all duration-200 mb-3 rounded-xl border-2 ${ + isSelected + ? 'border-red-500 ring-4 ring-red-500/20' + : 'border-transparent hover:border-slate-600' + }`} + > + {/* Lišta s nástrojmi pre označený modul */} + {isSelected && ( +
+ + + + +
+ )} + + {/* Vykreslenie modulu cez iframe alebo dangerouslySetInnerHTML */} +
+
+ ); + })} +
+
+
+ + {/* Pravý konfiguračný panel */} + +
+ + {/* Modály */} + setIsImportOpen(false)} + onImportProducts={handleImportProducts} + /> + + setIsBadgeModalOpen(false)} + onSelectBadge={(badge) => { + // Priradiť cenovku k produktu + }} + /> ); } diff --git a/src/components/editor/BadgeManagerModal.tsx b/src/components/editor/BadgeManagerModal.tsx new file mode 100644 index 0000000..4c3483a --- /dev/null +++ b/src/components/editor/BadgeManagerModal.tsx @@ -0,0 +1,196 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { BadgeTemplate } from '@/types/newsletter'; +import { supabase } from '@/lib/supabase'; +import { Tag, Plus, Check, Trash2, X } from 'lucide-react'; + +interface BadgeManagerModalProps { + isOpen: boolean; + onClose: () => void; + onSelectBadge: (badge: BadgeTemplate) => void; +} + +export const BadgeManagerModal: React.FC = ({ + isOpen, + onClose, + onSelectBadge +}) => { + const [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [newBadgeName, setNewBadgeName] = useState(''); + const [newBadgeColor, setNewBadgeColor] = useState('#e2001a'); + const [newBadgeText, setNewBadgeText] = useState('SUPER CENA'); + + useEffect(() => { + if (isOpen) { + loadBadges(); + } + }, [isOpen]); + + const loadBadges = async () => { + setLoading(true); + try { + const { data, error } = await supabase.from('badges').select('*').order('created_at', { ascending: false }); + if (data && !error) { + setBadges( + data.map((d: any) => ({ + id: d.id, + name: d.name, + svgRaw: d.svg_raw, + layersConfig: d.layers_config || [] + })) + ); + } + } catch (err) { + console.error('Error loading badges:', err); + } finally { + setLoading(false); + } + }; + + const createBadge = async () => { + if (!newBadgeName) return; + + const svg = ``; + const layers = [ + { id: 'l1', type: 'text', text: newBadgeText, x: 80, y: 35, fontSize: 18, fontWeight: '900', fill: '#ffffff', align: 'center' }, + { id: 'l2', type: 'text', text: '{zlava}', x: 80, y: 65, fontSize: 24, fontWeight: '900', fill: '#ffd100', align: 'center' } + ]; + + try { + const { data, error } = await supabase.from('badges').insert([ + { + name: newBadgeName, + svg_raw: svg, + layers_config: layers + } + ]).select(); + + if (data && !error) { + loadBadges(); + setCreating(false); + setNewBadgeName(''); + } + } catch (err) { + console.error('Error creating badge:', err); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ +

Správca grafických cenoviek

+
+ +
+ + {/* Content */} +
+
+

+ Vyberte cenovku pre produkt alebo navrhnite vlastnú. +

+ +
+ + {/* Create Form */} + {creating && ( +
+

Vytvoriť cenovku

+
+
+ + setNewBadgeName(e.target.value)} + placeholder="Napr. Modrá novinka" + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ + setNewBadgeText(e.target.value)} + placeholder="Napr. EXTRA CENA" + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ +
+ setNewBadgeColor(e.target.value)} + className="w-8 h-8 rounded-md cursor-pointer border-0" + /> + {newBadgeColor} +
+
+
+
+ + +
+
+ )} + + {/* Badges List */} +
+ {badges.map((b) => ( +
+ {b.name} +
+ +
+ ))} +
+
+
+
+ ); +}; diff --git a/src/components/editor/ConfigPanel.tsx b/src/components/editor/ConfigPanel.tsx new file mode 100644 index 0000000..bb59910 --- /dev/null +++ b/src/components/editor/ConfigPanel.tsx @@ -0,0 +1,420 @@ +'use client'; + +import React from 'react'; +import { NewsletterModule, ProductItem } from '@/types/newsletter'; +import { Plus, Trash2, Image as ImageIcon, Tag, Store } from 'lucide-react'; +import { SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign'; + +interface ConfigPanelProps { + module: NewsletterModule | null; + onUpdateModule: (updated: NewsletterModule) => void; + onOpenBadgeManager?: (productId: string) => void; + onOpenImport?: () => void; +} + +export const ConfigPanel: React.FC = ({ + module, + onUpdateModule, + onOpenBadgeManager, + onOpenImport +}) => { + if (!module) { + return ( +
+

Vyberte modul v náhľade e-mailu pre úpravu jeho vlastností.

+
+ ); + } + + const updateData = (newData: any) => { + onUpdateModule({ + ...module, + data: { ...module.data, ...newData } + }); + }; + + return ( +
+
+ + {module.type} + +

{module.title}

+
+ + {/* TOP ANNOUNCEMENT */} + {module.type === 'announcement' && ( +
+
+ + updateData({ tipBadge: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ + updateData({ text: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ + updateData({ linkText: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ + updateData({ linkUrl: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ )} + + {/* HEADER */} + {module.type === 'header' && ( +
+
+ + updateData({ logoUrl: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2 font-mono text-xs" + /> +
+
+ + updateData({ logoLink: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ )} + + {/* BANNER */} + {module.type === 'banner' && ( +
+
+ + updateData({ imageUrl: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2 font-mono text-xs" + /> +
+
+ + updateData({ linkUrl: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ + updateData({ title: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ +