From 2b4d3643cb9cd5ee3c49fbbc4f5e822440c7a4ee Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Thu, 24 Sep 2026 11:42:45 +0200 Subject: [PATCH] feat: anchor header/footer, editable header buttons, campaign management, image uploads, badge font customization, and brevo export fix --- src/app/api/brevo/export/route.ts | 7 +- src/app/api/upload/route.ts | 44 ++ src/app/page.tsx | 354 ++++++++-- src/components/editor/BadgeEditorModal.tsx | 232 ++++++- src/components/editor/ConfigPanel.tsx | 646 +++++++++++++++++- src/components/editor/ImageUploadInput.tsx | 112 +++ src/components/editor/SavedCampaignsModal.tsx | 364 ++++++++++ src/components/editor/TopBar.tsx | 169 +++-- src/lib/emailRenderer.ts | 66 +- src/types/newsletter.ts | 3 + 10 files changed, 1819 insertions(+), 178 deletions(-) create mode 100644 src/app/api/upload/route.ts create mode 100644 src/components/editor/ImageUploadInput.tsx create mode 100644 src/components/editor/SavedCampaignsModal.tsx diff --git a/src/app/api/brevo/export/route.ts b/src/app/api/brevo/export/route.ts index 7704e80..2a8b8d2 100644 --- a/src/app/api/brevo/export/route.ts +++ b/src/app/api/brevo/export/route.ts @@ -12,7 +12,7 @@ export async function POST(req: NextRequest) { } const apiKey = process.env.BREVO_API_KEY; - const senderName = process.env.BREVO_SENDER_NAME || 'Jandl'; + const senderName = process.env.BREVO_SENDER_NAME || 'Jakub Hostačný (COOP Newsletter)'; const senderEmail = process.env.BREVO_SENDER_EMAIL || 'tomas.rafay@jandl.sk'; if (!apiKey) { @@ -58,11 +58,12 @@ export async function POST(req: NextRequest) { ); } - // Úspech - vrátime campaignId a URL na správu v Brevo + // Úspech - vrátime campaignId a priame URL na editáciu a prehľad kampaní v novom Brevo dashboarde return NextResponse.json({ success: true, campaignId: data.id, - brevoUrl: `https://app.brevo.com/camp/detail/email/${data.id}`, + brevoUrl: `https://app.brevo.com/campaigns/email/edit/${data.id}`, + brevoListingUrl: 'https://app.brevo.com/campaigns/listing', message: 'Kampaň bola úspešne vytvorená v Brevo!' }); } catch (error: any) { diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..5fc99a5 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase'; + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const file = formData.get('file') as File | null; + + if (!file) { + return NextResponse.json({ error: 'Súbor nebol poskytnutý.' }, { status: 400 }); + } + + const admin = getSupabaseAdmin(); + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const path = `uploads/${Date.now()}-${safeName}`; + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const { error: uploadError } = await admin.storage + .from('newsletter-assets') + .upload(path, buffer, { + contentType: file.type || 'image/png', + upsert: true + }); + + if (uploadError) { + console.error('Upload to Supabase Storage error:', uploadError); + return NextResponse.json({ error: uploadError.message }, { status: 500 }); + } + + const { data: pubData } = admin.storage + .from('newsletter-assets') + .getPublicUrl(path); + + return NextResponse.json({ + success: true, + url: pubData.publicUrl + }); + } catch (err: any) { + console.error('API /upload error:', err); + return NextResponse.json({ error: err.message || 'Chyba servera pri nahrávaní súboru' }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index ee21eb1..b88d2cd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,9 +9,10 @@ import { TopBar } from '@/components/editor/TopBar'; import { ConfigPanel } from '@/components/editor/ConfigPanel'; import { ProductImportModal } from '@/components/editor/ProductImportModal'; import { BadgeEditorModal } from '@/components/editor/BadgeEditorModal'; +import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal'; import JSZip from 'jszip'; import { saveAs } from 'file-saver'; -import { ChevronUp, ChevronDown, Copy, Trash2, Edit3 } from 'lucide-react'; +import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react'; export default function Home() { const [campaign, setCampaign] = useState(DEFAULT_CAMPAIGN); @@ -23,9 +24,30 @@ export default function Home() { // Modály const [isImportOpen, setIsImportOpen] = useState(false); const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false); + const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false); const [targetProductIdForBadge, setTargetProductIdForBadge] = useState(null); - // Načítanie existujúceho draftu z databázy + // Pomocné funkcie pre fixné ukotvenie hlavičky a pätičky + const isTopModule = (type: string) => type === 'header' || type === 'announcement'; + const isBottomModule = (type: string) => type === 'footer'; + const isAnchoredModule = (type: string) => isTopModule(type) || isBottomModule(type); + + const enforceAnchors = (mods: NewsletterModule[]): NewsletterModule[] => { + const topMods = mods.filter((m) => isTopModule(m.type)); + const bottomMods = mods.filter((m) => isBottomModule(m.type)); + const middleMods = mods.filter((m) => !isAnchoredModule(m.type)); + + // announcement je pred headerom, ak existujú oba + topMods.sort((a, b) => { + if (a.type === 'announcement' && b.type === 'header') return -1; + if (a.type === 'header' && b.type === 'announcement') return 1; + return 0; + }); + + return [...topMods, ...middleMods, ...bottomMods]; + }; + + // Načítanie existujúceho draftu z databázy pri štarte useEffect(() => { async function loadInitial() { try { @@ -44,10 +66,10 @@ export default function Home() { preheader: dbItem.data.preheader || DEFAULT_CAMPAIGN.preheader, status: dbItem.status, brevoCampaignId: dbItem.brevo_campaign_id, - modules: dbItem.data.modules + modules: enforceAnchors(dbItem.data.modules) }); if (dbItem.brevo_campaign_id) { - setBrevoUrl(`https://app.brevo.com/camp/detail/email/${dbItem.brevo_campaign_id}`); + setBrevoUrl(`https://app.brevo.com/campaigns/email/edit/${dbItem.brevo_campaign_id}`); } } } @@ -102,7 +124,7 @@ export default function Home() { }, 1000); return () => clearTimeout(timer); - }, [campaign.title, campaign.preheader, campaign.modules]); + }, [campaign.title, campaign.preheader, campaign.modules, campaign.id]); const selectedModule = campaign.modules.find((m) => m.id === selectedModuleId) || null; @@ -113,39 +135,56 @@ export default function Home() { })); }; + // Posun modulu: Hlavička a pätička sú ukotvené a bežné moduly sa nemôžu dostať pred hlavičku ani za pätičku 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 currentMod = campaign.modules[index]; + if (!currentMod || isAnchoredModule(currentMod.type)) return; + const topCount = campaign.modules.filter((m) => isTopModule(m.type)).length; + const footerIndex = campaign.modules.findIndex((m) => isBottomModule(m.type)); + const maxAllowedIndex = footerIndex !== -1 ? footerIndex - 1 : campaign.modules.length - 1; + + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < topCount || targetIndex > maxAllowedIndex) return; + + const newModules = [...campaign.modules]; const temp = newModules[index]; newModules[index] = newModules[targetIndex]; newModules[targetIndex] = temp; - setCampaign((prev) => ({ ...prev, modules: newModules })); + setCampaign((prev) => ({ ...prev, modules: enforceAnchors(newModules) })); }; + // Duplikácia: Hlavička a pätička sa nedajú duplikovať const duplicateModule = (index: number) => { const original = campaign.modules[index]; + if (isAnchoredModule(original.type)) return; + 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 })); + setCampaign((prev) => ({ ...prev, modules: enforceAnchors(newModules) })); setSelectedModuleId(clone.id); }; + // Zmazanie: Hlavička a pätička sa nedajú vymazať const deleteModule = (index: number) => { + const mod = campaign.modules[index]; + if (isAnchoredModule(mod.type)) return; if (campaign.modules.length <= 1) return; + const newModules = campaign.modules.filter((_, i) => i !== index); - setCampaign((prev) => ({ ...prev, modules: newModules })); + setCampaign((prev) => ({ ...prev, modules: enforceAnchors(newModules) })); setSelectedModuleId(newModules[0]?.id || null); }; + // Pridanie modulu: Vloží sa vždy PRED pätičku (pätička ostáva pevne na spodku) const handleAddModule = (type: any) => { let newMod: NewsletterModule; if (type === 'banner') { @@ -196,10 +235,16 @@ export default function Home() { }; } - setCampaign((prev) => ({ - ...prev, - modules: [...prev.modules, newMod] - })); + setCampaign((prev) => { + const footerIdx = prev.modules.findIndex((m) => isBottomModule(m.type)); + const newModules = [...prev.modules]; + if (footerIdx !== -1) { + newModules.splice(footerIdx, 0, newMod); + } else { + newModules.push(newMod); + } + return { ...prev, modules: enforceAnchors(newModules) }; + }); setSelectedModuleId(newMod.id); }; @@ -221,6 +266,89 @@ export default function Home() { handleUpdateModule(updated); }; + // Manuálne uloženie kampane + const handleManualSave = async () => { + setIsSaving(true); + try { + if (!campaign.id) { + const { data, error } = await supabase + .from('templates') + .insert([ + { + title: campaign.title, + status: campaign.status, + data: { + preheader: campaign.preheader, + modules: campaign.modules + } + } + ]) + .select(); + + if (error) throw error; + if (data && data.length > 0) { + setCampaign((prev) => ({ ...prev, id: data[0].id })); + } + } else { + const { error } = await supabase + .from('templates') + .update({ + title: campaign.title, + data: { + preheader: campaign.preheader, + modules: campaign.modules + }, + updated_at: new Date().toISOString() + }) + .eq('id', campaign.id); + + if (error) throw error; + } + alert('Kampaň bola úspešne uložená do databázy!'); + } catch (err: any) { + alert(`Chyba pri ukladaní: ${err.message}`); + } finally { + setIsSaving(false); + } + }; + + // Duplikácia aktuálnej kampane v Supabase + const handleDuplicateCurrentCampaign = async () => { + setIsSaving(true); + try { + const newTitle = `${campaign.title} (kópia)`; + const { data, error } = await supabase + .from('templates') + .insert([ + { + title: newTitle, + status: 'draft', + data: { + preheader: campaign.preheader, + modules: campaign.modules + } + } + ]) + .select(); + + if (error) throw error; + if (data && data.length > 0) { + setCampaign({ + id: data[0].id, + title: newTitle, + preheader: campaign.preheader, + status: 'draft', + modules: campaign.modules + }); + alert(`Kampaň bola zduplikovaná ako "${newTitle}"!`); + } + } catch (err: any) { + alert(`Chyba pri duplikovaní: ${err.message}`); + } finally { + setIsSaving(false); + } + }; + const handleExportZip = async () => { const rawHtml = renderEmailHtml(campaign); let finalHtml = rawHtml; @@ -319,17 +447,24 @@ export default function Home() { }); }; + const topCount = campaign.modules.filter((m) => isTopModule(m.type)).length; + const footerIdx = campaign.modules.findIndex((m) => isBottomModule(m.type)); + return (
{/* 1. Horná lišta */} {}} + onSave={handleManualSave} + onUpdateTitle={(title) => setCampaign((prev) => ({ ...prev, title }))} + onOpenSavedCampaigns={() => setIsSavedCampaignsOpen(true)} + onDuplicateCampaign={handleDuplicateCurrentCampaign} onExportZip={handleExportZip} onExportBrevo={handleExportBrevo} isExportingBrevo={isExportingBrevo} brevoCampaignUrl={brevoUrl} + brevoListingUrl="https://app.brevo.com/campaigns/listing" onAddModule={handleAddModule} /> @@ -347,75 +482,112 @@ export default function Home() {
{campaign.modules.map((mod, idx) => { const isSelected = mod.id === selectedModuleId; + const isAnchored = isAnchoredModule(mod.type); + const isTop = isTopModule(mod.type); + const isBottom = isBottomModule(mod.type); + + const canMoveUp = !isAnchored && idx > topCount; + const canMoveDown = !isAnchored && idx < (footerIdx !== -1 ? footerIdx - 1 : campaign.modules.length - 1); return (
setSelectedModuleId(mod.id)} - className={`relative cursor-pointer transition-all duration-150 ${ + className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${ isSelected - ? 'outline-2 outline-offset-4 outline-red-500 rounded-2xl shadow-2xl' - : 'hover:outline-1 hover:outline-offset-2 hover:outline-white/30 rounded-2xl' + ? 'ring-2 ring-offset-4 ring-offset-[#001a33] ring-red-500 shadow-2xl' + : 'hover:ring-1 hover:ring-offset-2 hover:ring-offset-[#001a33] hover:ring-white/30' }`} > {/* Lišta s nástrojmi pre označený modul */} {isSelected && ( -
- +
+ {mod.title} - - - - + + {/* Ak je modul ukotvený (Hlavička / Pätička), nezobrazujeme tlačidlá na presun a zmazanie */} + {isAnchored ? ( + + + {isBottom ? 'Ukotvená pätička' : 'Ukotvená hlavička'} + + ) : ( + <> + + + + + + )}
)} - {/* Vykreslenie čistého modulu bez obaľovacieho gradientu na každom bloku */} -
+ {/* Vykreslenie modulu: ak je loyalty_club vypnutý, zobrazíme prívetivý placeholder */} + {mod.type === 'loyalty_club' && mod.data.enabled === false ? ( +
+

+ Sekcia Vernostné zľavy (Vypnutá) +

+

+ Táto sekcia je v odosielanom e-maile skrytá. Kliknutím sem ju môžete aktivovať. +

+ +
+ ) : ( +
+ )}
); })} @@ -453,6 +625,48 @@ export default function Home() { currentPriceSub={targetProduct?.priceSub || '99'} currentUnitPrice={targetProduct?.unitPrice || '1kg = 4,91 €'} /> + + {/* Modál Moje newslettre (Ukladanie, duplikácia, vytváranie a prepínanie) */} + setIsSavedCampaignsOpen(false)} + currentCampaign={campaign} + onLoadCampaign={(selected: NewsletterCampaign) => { + setCampaign({ ...selected, modules: enforceAnchors(selected.modules) }); + setSelectedModuleId(selected.modules[1]?.id || selected.modules[0]?.id || null); + if (selected.brevoCampaignId) { + setBrevoUrl(`https://app.brevo.com/campaigns/email/edit/${selected.brevoCampaignId}`); + } else { + setBrevoUrl(null); + } + }} + onSaveCurrentAsCopy={async (newTitle: string) => { + const { data, error } = await supabase + .from('templates') + .insert([ + { + title: newTitle, + status: 'draft', + data: { + preheader: campaign.preheader, + modules: campaign.modules + } + } + ]) + .select(); + + if (error) throw error; + if (data && data[0]) { + setCampaign({ + id: data[0].id, + title: newTitle, + preheader: campaign.preheader, + status: 'draft', + modules: campaign.modules + }); + } + }} + />
); } diff --git a/src/components/editor/BadgeEditorModal.tsx b/src/components/editor/BadgeEditorModal.tsx index 27df8fd..85b9754 100644 --- a/src/components/editor/BadgeEditorModal.tsx +++ b/src/components/editor/BadgeEditorModal.tsx @@ -25,6 +25,46 @@ export const BadgeEditorModal: React.FC = ({ currentPriceSub = '99', currentUnitPrice = '1kg = 4,91 €' }) => { + const POPULAR_FONTS = [ + { name: 'Poppins', source: 'google' as const }, + { name: 'Montserrat', source: 'google' as const }, + { name: 'Oswald', source: 'google' as const }, + { name: 'Bebas Neue', source: 'google' as const }, + { name: 'Anton', source: 'google' as const }, + { name: 'Roboto Condensed', source: 'google' as const }, + { name: 'Barlow Semi Condensed', source: 'google' as const }, + { name: 'Inter', source: 'google' as const }, + { name: 'Open Sans', source: 'google' as const }, + { name: 'Arial Narrow', source: 'system' as const }, + { name: 'Impact', source: 'system' as const } + ]; + + const loadFontDynamically = (fontFamily: string, source: 'system' | 'google' | 'adobe' = 'google', url?: string) => { + if (typeof document === 'undefined') return; + + if (source === 'adobe' && url) { + const existing = document.querySelector(`link[data-adobe-font="${url}"]`); + if (!existing) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.setAttribute('data-adobe-font', url); + document.head.appendChild(link); + } + } else if (source === 'google' && fontFamily) { + const clean = fontFamily.trim().replace(/\s+/g, '+'); + const href = `https://fonts.googleapis.com/css2?family=${clean}:wght@400;600;700;800;900&display=swap`; + const existing = document.querySelector(`link[data-google-font="${clean}"]`); + if (!existing) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.setAttribute('data-google-font', clean); + document.head.appendChild(link); + } + } + }; + const [badges, setBadges] = useState([]); const [activeBadge, setActiveBadge] = useState(null); const [selectedLayerId, setSelectedLayerId] = useState(null); @@ -32,6 +72,17 @@ export const BadgeEditorModal: React.FC = ({ const [isExportingPng, setIsExportingPng] = useState(false); const [isSavingDb, setIsSavingDb] = useState(false); + // Načítanie písiem pre všetky vrstvy aktívnej cenovky + useEffect(() => { + if (activeBadge?.layersConfig) { + activeBadge.layersConfig.forEach((l) => { + if (l.fontFamily) { + loadFontDynamically(l.fontFamily, l.fontSource || 'google', l.fontUrl); + } + }); + } + }, [activeBadge]); + // Načítanie existujúcich šablón zo Supabase useEffect(() => { if (isOpen) { @@ -366,9 +417,11 @@ export const BadgeEditorModal: React.FC = ({ top: `${layer.y}px`, transform: 'translate(-50%, -50%)', fontSize: `${layer.fontSize}px`, - fontWeight: layer.fontWeight as any, + fontWeight: (layer.fontWeight || '700') as any, color: layer.fill, - fontFamily: 'Poppins, Arial, sans-serif', + fontFamily: layer.fontFamily + ? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` + : 'Poppins, Arial, sans-serif', whiteSpace: 'nowrap', cursor: 'pointer' }} @@ -530,6 +583,181 @@ export const BadgeEditorModal: React.FC = ({
+ {/* Font nastavenie */} +
+ + + {/* Zdroj písma */} +
+ + + +
+ + {/* Google Fonts voľba */} + {(activeLayer.fontSource || 'google') === 'google' && ( +
+
+ + +
+ + {(!POPULAR_FONTS.some((f) => f.name === (activeLayer.fontFamily || 'Poppins')) || + activeLayer.fontFamily === 'custom') && ( +
+ + { + const val = e.target.value; + loadFontDynamically(val, 'google'); + updateActiveLayer({ fontFamily: val, fontSource: 'google' }); + }} + className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white" + /> +
+ )} +
+ )} + + {/* Adobe Fonts voľba */} + {activeLayer.fontSource === 'adobe' && ( +
+
+ + { + const url = e.target.value; + if (activeLayer.fontFamily) { + loadFontDynamically(activeLayer.fontFamily, 'adobe', url); + } + updateActiveLayer({ fontUrl: url, fontSource: 'adobe' }); + }} + className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-mono text-[10px]" + /> +
+
+ + { + const family = e.target.value; + if (activeLayer.fontUrl) { + loadFontDynamically(family, 'adobe', activeLayer.fontUrl); + } + updateActiveLayer({ fontFamily: family, fontSource: 'adobe' }); + }} + className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white" + /> +
+
+ )} + + {/* Systémové písma */} + {activeLayer.fontSource === 'system' && ( +
+ + +
+ )} + + {/* Hrúbka fontu */} +
+ + +
+
+
diff --git a/src/components/editor/ConfigPanel.tsx b/src/components/editor/ConfigPanel.tsx index 5bc667a..afe1edf 100644 --- a/src/components/editor/ConfigPanel.tsx +++ b/src/components/editor/ConfigPanel.tsx @@ -4,6 +4,7 @@ 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'; +import { ImageUploadInput } from './ImageUploadInput'; interface ConfigPanelProps { module: NewsletterModule | null; @@ -85,41 +86,136 @@ export const ConfigPanel: React.FC = ({ )} {/* HEADER */} - {module.type === 'header' && ( -
-
- - { + const navLinks = module.data.navLinks || [ + { label: 'Letáky', url: 'https://www.coop.sk/sk/letaky', variant: 'outline' }, + { label: 'COOP Klub', url: 'https://www.coopklub.sk', variant: 'filled' } + ]; + + const updateNavLink = (index: number, patch: Partial) => { + const updated = [...navLinks]; + updated[index] = { ...updated[index], ...patch }; + updateData({ navLinks: updated }); + }; + + return ( +
+ updateData({ logoUrl: e.target.value })} - className="w-full text-sm border border-gray-300 rounded-lg p-2 font-mono text-xs" + onChange={(url) => updateData({ logoUrl: url })} /> +
+ + updateData({ logoLink: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + placeholder="https://www.coop.sk" + /> +
+ + {/* Tlačidlá v hlavičke */} +
+ + Tlačidlá v hlavičke + + + {/* Tlačidlo 1 (Letáky) */} +
+
+ Tlačidlo 1 (Letáky) + + {navLinks[0]?.variant === 'filled' ? 'Plné' : 'Obrys'} + +
+
+ + updateNavLink(0, { label: e.target.value })} + className="w-full text-xs font-bold border border-gray-300 rounded-lg p-2 bg-white" + placeholder="Letáky" + /> +
+
+ + updateNavLink(0, { url: e.target.value })} + className="w-full text-xs border border-gray-300 rounded-lg p-2 bg-white" + placeholder="https://www.coop.sk/sk/letaky" + /> +
+
+ + +
+
+ + {/* Tlačidlo 2 (COOP Klub) */} +
+
+ Tlačidlo 2 (COOP Klub) + + {navLinks[1]?.variant === 'filled' ? 'Plné modré' : 'Obrys'} + +
+
+ + updateNavLink(1, { label: e.target.value })} + className="w-full text-xs font-bold border border-gray-300 rounded-lg p-2 bg-white" + placeholder="COOP Klub" + /> +
+
+ + updateNavLink(1, { url: e.target.value })} + className="w-full text-xs border border-gray-300 rounded-lg p-2 bg-white" + placeholder="https://www.coopklub.sk" + /> +
+
+ + +
+
+
-
- - 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({ imageUrl: url })} + />
= ({ />
+ { + const products = [...module.data.products]; + products[pIdx].imageUrl = url; + updateData({ products }); + }} + placeholder="https://...produkt.png alebo nahrajte súbor" + /> + +
+ + { + const products = [...module.data.products]; + products[pIdx].validityOverride = e.target.value; + updateData({ products }); + }} + placeholder={`Predvolená: ${module.data.globalValidity || '2. 5. – 15. 5. 2026'}`} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white placeholder:text-gray-400" + /> +
+
@@ -407,6 +529,450 @@ export const ConfigPanel: React.FC = ({
)} + {/* LOYALTY CLUB (VERNOSTNÉ ZĽAVY) */} + {module.type === 'loyalty_club' && ( +
+ {/* Toggle zapnuté / vypnuté */} +
+
+ Zobraziť v e-maile + Sekcia Vernostné zľavy +
+ +
+ +
+ + updateData({ sectionTitle: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2 font-bold" + /> +
+ + updateData({ headerImageUrl: url })} + /> + +
+ + updateData({ headerLink: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+ +
+ + updateData({ globalValidity: e.target.value })} + placeholder="napr. 2. 5. – 15. 5. 2026" + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+ + {/* 2 Vernostné produkty */} +
+ + Vernostné produkty ({module.data.products?.length || 0}) + + + {(module.data.products || []).map((p: any, pIdx: number) => ( +
+ + {p.title || `Vernostný produkt ${pIdx + 1}`} + + +
+ + { + const products = [...module.data.products]; + products[pIdx].title = e.target.value; + updateData({ products }); + }} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white font-medium" + /> +
+ + { + const products = [...module.data.products]; + products[pIdx].imageUrl = url; + updateData({ products }); + }} + placeholder="https://...produkt.png alebo nahrajte súbor" + /> + +
+ + { + const products = [...module.data.products]; + products[pIdx].validityOverride = e.target.value; + updateData({ products }); + }} + placeholder={`Predvolená: ${module.data.globalValidity || '2. 5. – 15. 5. 2026'}`} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white placeholder:text-gray-400" + /> +
+ + {/* Sekcia Bez karty */} +
+ Bez karty +
+
+ + { + const products = [...module.data.products]; + products[pIdx].noCardDiscount = e.target.value; + updateData({ products }); + }} + placeholder="21%" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold" + /> +
+
+ + { + const products = [...module.data.products]; + products[pIdx].noCardPriceMain = e.target.value; + updateData({ products }); + }} + placeholder="0" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold" + /> +
+
+ + { + const products = [...module.data.products]; + products[pIdx].noCardPriceSub = e.target.value; + updateData({ products }); + }} + placeholder="79" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold" + /> +
+
+
+ + { + const products = [...module.data.products]; + products[pIdx].noCardUnitPrice = e.target.value; + updateData({ products }); + }} + placeholder="1kg = 9,88 €" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white" + /> +
+
+ + {/* Sekcia S kartou */} +
+ S kartou (Klub) +
+
+ + { + const products = [...module.data.products]; + products[pIdx].cardDiscount = e.target.value; + updateData({ products }); + }} + placeholder="38%" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold" + /> +
+
+ + { + const products = [...module.data.products]; + products[pIdx].cardPriceMain = e.target.value; + updateData({ products }); + }} + placeholder="0" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold" + /> +
+
+ + { + const products = [...module.data.products]; + products[pIdx].cardPriceSub = e.target.value; + updateData({ products }); + }} + placeholder="62" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white font-bold" + /> +
+
+
+ + { + const products = [...module.data.products]; + products[pIdx].cardUnitPrice = e.target.value; + updateData({ products }); + }} + placeholder="1kg = 7,75 €" + className="w-full text-xs border border-gray-300 rounded p-1 bg-white" + /> +
+
+ + {/* Predajne */} +
+ +
+ + + +
+
+ + {/* Vlajočka SR */} +
+ { + const products = [...module.data.products]; + products[pIdx].showSlovakFlag = e.target.checked; + updateData({ products }); + }} + className="rounded text-red-600" + /> + +
+
+ ))} +
+
+ )} + + {/* ARTICLES ROW (LETÁKY ALEBO ČLÁNKY) */} + {module.type === 'articles_row' && ( +
+ {module.data.item1?.btnText ? ( + /* AK SÚ TO LETÁKY */ +
+
+ + Leták 1: {module.data.item1?.title || 'Aktuálne letáky'} + +

Farby, texty a rozloženie sú fixne podľa šablóny. Môžete upraviť odkaz a náhľadový obrázok.

+
+
+ + updateData({ + item1: { ...module.data.item1, linkUrl: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white" + /> +
+ updateData({ + item1: { ...module.data.item1, imageUrl: url } + })} + /> +
+
+ +
+ + Leták 2: {module.data.item2?.title || 'Rozšírený leták'} + +

Farby, texty a rozloženie sú fixne podľa šablóny. Môžete upraviť odkaz a náhľadový obrázok.

+
+
+ + updateData({ + item2: { ...module.data.item2, linkUrl: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white" + /> +
+ updateData({ + item2: { ...module.data.item2, imageUrl: url } + })} + /> +
+
+
+ ) : ( + /* AK SÚ TO ČLÁNKY (VEĽKONOČNÝ BARANČEK / VAJÍČKO AMULET) */ +
+
+ + Článok 1 (Recepty) + +

Logá a gradienty sú na pevno. Môžete upraviť text, odkaz a obrázok v pozadí.

+
+
+ + updateData({ + item1: { ...module.data.item1, title: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white font-bold" + /> +
+
+ + updateData({ + item1: { ...module.data.item1, linkUrl: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white" + /> +
+ updateData({ + item1: { ...module.data.item1, imageUrl: url } + })} + /> +
+
+ +
+ + Článok 2 (Časopis Jednota) + +

Logá a gradienty sú na pevno. Môžete upraviť text, odkaz a obrázok v pozadí.

+
+
+ + updateData({ + item2: { ...module.data.item2, title: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white font-bold" + /> +
+
+ + updateData({ + item2: { ...module.data.item2, linkUrl: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white" + /> +
+ updateData({ + item2: { ...module.data.item2, imageUrl: url } + })} + /> +
+
+
+ )} +
+ )} + {/* FOOTER */} {module.type === 'footer' && (
@@ -419,11 +985,29 @@ export const ConfigPanel: React.FC = ({ />
- + updateData({ unsubscribeText: e.target.value })} + value={module.data.unsubscribeUrl || ''} + onChange={(e) => updateData({ unsubscribeUrl: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ + updateData({ googlePlayUrl: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-lg p-2" + /> +
+
+ + updateData({ appStoreUrl: e.target.value })} className="w-full text-sm border border-gray-300 rounded-lg p-2" />
diff --git a/src/components/editor/ImageUploadInput.tsx b/src/components/editor/ImageUploadInput.tsx new file mode 100644 index 0000000..161c1f5 --- /dev/null +++ b/src/components/editor/ImageUploadInput.tsx @@ -0,0 +1,112 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { Upload, Loader2, Image as ImageIcon } from 'lucide-react'; + +interface ImageUploadInputProps { + label?: string; + value: string; + onChange: (url: string) => void; + placeholder?: string; +} + +export const ImageUploadInput: React.FC = ({ + label = 'Obrázok', + value, + onChange, + placeholder = 'https://... alebo nahrajte súbor' +}) => { + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + try { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + const data = await res.json(); + if (data.success && data.url) { + onChange(data.url); + } else { + alert(`Chyba pri nahrávaní obrázka: ${data.error || 'Neznáma chyba'}`); + } + } catch (err: any) { + alert(`Chyba: ${err.message}`); + } finally { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + return ( +
+ {label && } + +
+ {/* Náhľad miniatúry */} +
+ {value ? ( + Náhľad { + (e.target as HTMLElement).style.display = 'none'; + }} + /> + ) : ( + + )} +
+ + {/* Textové pole s URL */} + onChange(e.target.value)} + placeholder={placeholder} + className="flex-1 text-xs font-mono border border-gray-300 rounded-lg p-2 bg-white" + /> + + {/* Tlačidlo nahrávania */} + + +
+
+ ); +}; diff --git a/src/components/editor/SavedCampaignsModal.tsx b/src/components/editor/SavedCampaignsModal.tsx new file mode 100644 index 0000000..dd6a737 --- /dev/null +++ b/src/components/editor/SavedCampaignsModal.tsx @@ -0,0 +1,364 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabase'; +import { NewsletterCampaign } from '@/types/newsletter'; +import { DEFAULT_CAMPAIGN } from '@/lib/defaultCampaign'; +import { + FolderOpen, + Plus, + Copy, + Trash2, + X, + ExternalLink, + Clock, + CheckCircle2, + Layers, + Search, + Sparkles +} from 'lucide-react'; + +interface SavedCampaignsModalProps { + isOpen: boolean; + onClose: () => void; + currentCampaign: NewsletterCampaign; + onLoadCampaign: (campaign: NewsletterCampaign) => void; + onSaveCurrentAsCopy: (newTitle: string) => Promise; +} + +export const SavedCampaignsModal: React.FC = ({ + isOpen, + onClose, + currentCampaign, + onLoadCampaign, + onSaveCurrentAsCopy +}) => { + const [campaigns, setCampaigns] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [isCreatingNew, setIsCreatingNew] = useState(false); + const [newTitle, setNewTitle] = useState(''); + + const fetchCampaigns = async () => { + setLoading(true); + try { + const { data, error } = await supabase + .from('templates') + .select('*') + .order('updated_at', { ascending: false }); + + if (error) throw error; + setCampaigns(data || []); + } catch (err: any) { + console.error('Error fetching campaigns:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isOpen) { + fetchCampaigns(); + setIsCreatingNew(false); + setNewTitle(''); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleDuplicate = async (camp: any) => { + try { + const clonedTitle = `Kópia - ${camp.title}`; + const { data, error } = await supabase + .from('templates') + .insert([ + { + title: clonedTitle, + status: 'draft', + data: camp.data + } + ]) + .select(); + + if (error) throw error; + if (data && data[0]) { + await fetchCampaigns(); + onLoadCampaign({ + id: data[0].id, + title: data[0].title, + preheader: data[0].data?.preheader || '', + status: 'draft', + modules: data[0].data?.modules || [] + }); + onClose(); + } + } catch (err: any) { + alert(`Chyba pri duplikovaní: ${err.message}`); + } + }; + + const handleDelete = async (id: string, title: string) => { + if (!confirm(`Naozaj chcete vymazať newsletter "${title}"?`)) return; + + try { + const { error } = await supabase.from('templates').delete().eq('id', id); + if (error) throw error; + setCampaigns((prev) => prev.filter((c) => c.id !== id)); + if (currentCampaign.id === id) { + // Ak sme vymazali práve otvorený, resetneme ID + onLoadCampaign({ ...currentCampaign, id: '' }); + } + } catch (err: any) { + alert(`Chyba pri mazaní: ${err.message}`); + } + }; + + const handleCreateNew = async () => { + const title = newTitle.trim() || `COOP Newsletter - ${new Date().toLocaleDateString('sk-SK')}`; + try { + const { data, error } = await supabase + .from('templates') + .insert([ + { + title, + status: 'draft', + data: { + preheader: DEFAULT_CAMPAIGN.preheader, + modules: JSON.parse(JSON.stringify(DEFAULT_CAMPAIGN.modules)) + } + } + ]) + .select(); + + if (error) throw error; + if (data && data[0]) { + onLoadCampaign({ + id: data[0].id, + title: data[0].title, + preheader: data[0].data?.preheader || '', + status: 'draft', + modules: data[0].data?.modules || [] + }); + onClose(); + } + } catch (err: any) { + alert(`Chyba pri vytváraní: ${err.message}`); + } + }; + + const handleSaveCurrentCopy = async () => { + const title = newTitle.trim() || `Kópia - ${currentCampaign.title}`; + await onSaveCurrentAsCopy(title); + await fetchCampaigns(); + setIsCreatingNew(false); + setNewTitle(''); + onClose(); + }; + + const filtered = campaigns.filter((c) => + (c.title || '').toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
+ {/* Horná lišta */} +
+
+
+ +
+
+

Moje Newslettre

+

Spravujte, otvárajte a duplikujte uložené šablóny e-mailov

+
+
+ +
+ + +
+
+ + {/* Panel na vytvorenie / uloženie kópie */} + {isCreatingNew && ( +
+
+ setNewTitle(e.target.value)} + placeholder="Zadajte názov nového newslettera (napr. Kampaň Veľká Noc 2026)" + className="w-full text-xs border border-gray-300 rounded-xl p-2.5 bg-white font-medium focus:ring-2 focus:ring-red-500 focus:outline-none" + autoFocus + /> +
+
+ + +
+
+ )} + + {/* Vyhľadávanie */} +
+
+ + setSearch(e.target.value)} + placeholder="Vyhľadávať medzi uloženými newslettrami..." + className="w-full pl-9 pr-4 py-2 text-xs border border-gray-200 rounded-xl bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-red-500" + /> +
+
+ + {/* Zoznam kampaní */} +
+ {loading ? ( +
Načítavam uložené newslettre...
+ ) : filtered.length === 0 ? ( +
+ +

Nenašli sa žiadne uložené newslettre

+

Vytvorte nový kliknutím na tlačidlo vyššie.

+
+ ) : ( + filtered.map((camp) => { + const isCurrent = camp.id === currentCampaign.id; + const moduleCount = camp.data?.modules?.length || 0; + const isExported = camp.status === 'exported' || !!camp.brevo_campaign_id; + const updatedDate = camp.updated_at + ? new Date(camp.updated_at).toLocaleString('sk-SK', { + day: 'numeric', + month: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : 'Neznámy dátum'; + + return ( +
+
+
+ {camp.title} + {isCurrent && ( + + Práve otvorený + + )} + {isExported && ( + + Vytvorené v Brevo + + )} +
+ +
+ + {updatedDate} + + + {moduleCount} modulov + + {camp.brevo_campaign_id && ( + e.stopPropagation()} + > + Brevo #{camp.brevo_campaign_id} + + + )} +
+
+ + {/* Akčné tlačidlá */} +
+ + + + + +
+
+ ); + }) + )} +
+ + {/* Pätička */} +
+ Celkovo uložených: {campaigns.length} newsletterov + +
+
+
+ ); +}; diff --git a/src/components/editor/TopBar.tsx b/src/components/editor/TopBar.tsx index d142a50..9a3cae0 100644 --- a/src/components/editor/TopBar.tsx +++ b/src/components/editor/TopBar.tsx @@ -1,12 +1,16 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import { Download, Send, ExternalLink, CheckCircle2, - Plus + Plus, + FolderOpen, + Save, + Copy, + Edit2 } from 'lucide-react'; import { NewsletterCampaign } from '@/types/newsletter'; @@ -14,10 +18,14 @@ interface TopBarProps { campaign: NewsletterCampaign; isSaving: boolean; onSave: () => void; + onUpdateTitle: (title: string) => void; + onOpenSavedCampaigns: () => void; + onDuplicateCampaign: () => void; onExportZip: () => void; onExportBrevo: () => void; isExportingBrevo: boolean; brevoCampaignUrl?: string | null; + brevoListingUrl?: string | null; onAddModule: (type: any) => void; } @@ -25,81 +33,156 @@ export const TopBar: React.FC = ({ campaign, isSaving, onSave, + onUpdateTitle, + onOpenSavedCampaigns, + onDuplicateCampaign, onExportZip, onExportBrevo, isExportingBrevo, brevoCampaignUrl, + brevoListingUrl = 'https://app.brevo.com/campaigns/listing', onAddModule }) => { + const [isEditingTitle, setIsEditingTitle] = useState(false); + return ( -
- {/* Ľavá časť: Logo a stav */} -
-
-
- COOP -
-
-

- {campaign.title || 'COOP Jednota Newsletter'} -

-
- - {isSaving ? ( - Ukladám do Supabase... - ) : ( - - Uložené v databáze (Auto-save) - - )} - +
+ {/* Ľavá časť: Logo, Moje newslettre, Titulok a stav */} +
+
+ COOP +
+ + {/* Tlačidlo Moje newslettre */} + + +
+ + {/* Názov kampane */} +
+ {isEditingTitle ? ( + onUpdateTitle(e.target.value)} + onBlur={() => setIsEditingTitle(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') setIsEditingTitle(false); + }} + className="text-xs font-bold text-gray-900 border border-blue-400 rounded px-2 py-0.5 outline-none bg-blue-50/50 w-56" + /> + ) : ( +
setIsEditingTitle(true)} + className="group flex items-center gap-1.5 cursor-pointer" + title="Kliknite pre úpravu názvu" + > +

+ {campaign.title || 'COOP Jednota Newsletter'} +

+
+ )} + +
+ + {isSaving ? ( + Ukladám... + ) : ( + + Uložené + + )} +
{/* Pridanie sekcie */} -
+
- {/* Pravá časť: Export do ZIP a Brevo */} -
+ {/* Pravá časť: Uložiť, Duplikovať, Export do ZIP a Brevo */} +
+ {/* Tlačidlo manuálneho uloženia */} + + + {/* Tlačidlo duplikovania celej kampane */} + + {brevoCampaignUrl ? ( - - Otvoriť v Brevo - + ) : ( )}
diff --git a/src/lib/emailRenderer.ts b/src/lib/emailRenderer.ts index fb9c3b0..d28665a 100644 --- a/src/lib/emailRenderer.ts +++ b/src/lib/emailRenderer.ts @@ -209,12 +209,12 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
- + `; case 'header': - const navLinks = (m.data.navLinks || []).map((link: any) => { + const rawNav = m.data.navLinks && m.data.navLinks.length > 0 ? m.data.navLinks : [ + { label: 'Letáky', url: 'https://www.coop.sk/sk/letaky', variant: 'outline' }, + { label: 'COOP Klub', url: 'https://www.coopklub.sk', variant: 'filled' } + ]; + const navLinks = rawNav.map((link: any) => { if (link.variant === 'filled') { - return `${escapeHtml(link.label)}`; + return `${escapeHtml(link.label || 'Odkaz')}`; } - return `${escapeHtml(link.label)}`; + return `${escapeHtml(link.label || 'Odkaz')}`; }).join('\n'); return ` - +