- v paneli je otvoreny vzdy len jeden produkt, ostatne su zbalene - klik na kartu produktu v nahlade vyberie sekciu aj produkt, panel sa posunie na jeho editor a karta sa v nahlade zvyrazni - odkazy v nahlade sa klikom neotvaraju Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
982 lines
39 KiB
TypeScript
982 lines
39 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect, useRef } from 'react';
|
||
import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from '@/types/newsletter';
|
||
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign';
|
||
import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer';
|
||
import { supabase } from '@/lib/supabase';
|
||
import { DEFAULT_BADGE_TEMPLATES } from '@/lib/badgeUtils';
|
||
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||
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 { prepareCardImages } from '@/lib/cardImages';
|
||
import { saveAs } from 'file-saver';
|
||
import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react';
|
||
|
||
export default function Home() {
|
||
const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN);
|
||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky');
|
||
// Produkt otvorený v paneli (harmonika); nastaví sa aj klikom na produkt v náhľade
|
||
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isExportingBrevo, setIsExportingBrevo] = useState(false);
|
||
const [brevoUrl, setBrevoUrl] = useState<string | null>(null);
|
||
|
||
// Modály
|
||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||
const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false);
|
||
const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false);
|
||
const [targetProductIdForBadge, setTargetProductIdForBadge] = useState<string | null>(null);
|
||
const [availableBadges, setAvailableBadges] = useState<BadgeTemplate[]>(DEFAULT_BADGE_TEMPLATES);
|
||
|
||
// Načítanie dostupných šablón cenoviek pre opakované použitie
|
||
useEffect(() => {
|
||
const fetchBadges = async () => {
|
||
let loaded: BadgeTemplate[] = [];
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from('badges')
|
||
.select('*')
|
||
.order('created_at', { ascending: false });
|
||
|
||
if (data && !error && data.length > 0) {
|
||
loaded = data.map((d: any) => {
|
||
const isObj = d.layers_config && !Array.isArray(d.layers_config);
|
||
return {
|
||
id: d.id,
|
||
name: d.name,
|
||
svgRaw: d.svg_raw || '',
|
||
layersConfig: isObj ? (d.layers_config.layers || []) : (d.layers_config || []),
|
||
width: isObj ? (d.layers_config.width || 110) : 110,
|
||
align: isObj ? (d.layers_config.align || 'center') : 'center',
|
||
paddingTop: isObj ? (d.layers_config.paddingTop ?? 4) : 4,
|
||
paddingBottom: isObj ? (d.layers_config.paddingBottom ?? 4) : 4
|
||
};
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('Error fetching badges in page:', e);
|
||
}
|
||
|
||
try {
|
||
const local = localStorage.getItem('coop_custom_badges');
|
||
if (local) {
|
||
const parsed = JSON.parse(local);
|
||
if (Array.isArray(parsed)) {
|
||
const existingIds = new Set(loaded.map((b) => b.id));
|
||
for (const b of parsed) {
|
||
if (!existingIds.has(b.id)) {
|
||
loaded.push(b);
|
||
existingIds.add(b.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
const all = [...loaded];
|
||
const existingIds = new Set(all.map((b) => b.id));
|
||
for (const preset of DEFAULT_BADGE_TEMPLATES) {
|
||
if (!existingIds.has(preset.id)) {
|
||
all.push(preset);
|
||
}
|
||
}
|
||
setAvailableBadges(all);
|
||
};
|
||
fetchBadges();
|
||
}, [isBadgeEditorOpen]);
|
||
|
||
// Pomocné funkcie pre fixné ukotvenie hlavičky, spodných letákov, receptov a pätičky
|
||
const isTopModule = (m: NewsletterModule) => m.type === 'header' || m.type === 'announcement';
|
||
|
||
const isFlyersModule = (m: NewsletterModule) =>
|
||
m.id === 'mod-flyers-row' ||
|
||
(m.type === 'articles_row' && m.title?.toLowerCase().includes('leták'));
|
||
|
||
const isRecipesModule = (m: NewsletterModule) =>
|
||
m.id === 'mod-recipes-row' ||
|
||
(m.type === 'articles_row' &&
|
||
(m.title?.toLowerCase().includes('recept') || m.title?.toLowerCase().includes('článk')));
|
||
|
||
const isFooterModule = (m: NewsletterModule) => m.type === 'footer';
|
||
|
||
const isBottomModule = (m: NewsletterModule) =>
|
||
isFlyersModule(m) || isRecipesModule(m) || isFooterModule(m);
|
||
|
||
const isAnchoredModule = (m: NewsletterModule) => isTopModule(m) || isBottomModule(m);
|
||
|
||
const enforceAnchors = (mods: NewsletterModule[]): NewsletterModule[] => {
|
||
const topMods = mods.filter((m) => isTopModule(m));
|
||
const flyerMods = mods.filter((m) => isFlyersModule(m));
|
||
const recipeMods = mods.filter((m) => isRecipesModule(m));
|
||
const footerMods = mods.filter((m) => isFooterModule(m));
|
||
const middleMods = mods.filter((m) => !isAnchoredModule(m));
|
||
|
||
// 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, ...flyerMods, ...recipeMods, ...footerMods];
|
||
};
|
||
|
||
// Auto-save sa spustí až po načítaní kampane z databázy. Inak by sa pri pomalšom načítaní
|
||
// predvolená kampaň uložila ako nová kópia a ďalšie zmeny by sa ukladali do nej.
|
||
const [isLoaded, setIsLoaded] = useState(false);
|
||
// Prebiehajúce vloženie novej kampane – kým nemáme jej id, ďalšie vloženie by vytvorilo duplikát
|
||
const insertInFlight = useRef(false);
|
||
|
||
// Načítanie existujúceho draftu z databázy pri štarte
|
||
useEffect(() => {
|
||
async function loadInitial() {
|
||
try {
|
||
const { data } = await supabase
|
||
.from('templates')
|
||
.select('*')
|
||
.order('updated_at', { ascending: false })
|
||
.limit(1);
|
||
|
||
if (data && data.length > 0) {
|
||
const dbItem = data[0];
|
||
if (Array.isArray(dbItem.data?.modules) && dbItem.data.modules.length > 0) {
|
||
setCampaign({
|
||
id: dbItem.id,
|
||
title: dbItem.title,
|
||
preheader: dbItem.data.preheader || DEFAULT_CAMPAIGN.preheader,
|
||
status: dbItem.status,
|
||
brevoCampaignId: dbItem.brevo_campaign_id,
|
||
modules: enforceAnchors(ensureValidityBar(dbItem.data.modules))
|
||
});
|
||
if (dbItem.brevo_campaign_id) {
|
||
setBrevoUrl(getBrevoCampaignUrl(dbItem.brevo_campaign_id));
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Error loading template from Supabase:', err);
|
||
} finally {
|
||
setIsLoaded(true);
|
||
}
|
||
}
|
||
loadInitial();
|
||
}, []);
|
||
|
||
// Auto-save s debounce (1 sekunda)
|
||
useEffect(() => {
|
||
if (!isLoaded) return;
|
||
const timer = setTimeout(async () => {
|
||
if (!campaign.id && insertInFlight.current) return;
|
||
setIsSaving(true);
|
||
try {
|
||
if (!campaign.id) {
|
||
insertInFlight.current = true;
|
||
const { data } = await supabase
|
||
.from('templates')
|
||
.insert([
|
||
{
|
||
title: campaign.title,
|
||
status: campaign.status,
|
||
data: {
|
||
preheader: campaign.preheader,
|
||
modules: campaign.modules
|
||
}
|
||
}
|
||
])
|
||
.select();
|
||
|
||
if (data && data.length > 0) {
|
||
// id nastavíme len ak medzitým nebola otvorená iná kampaň
|
||
setCampaign((prev) => (prev.id ? prev : { ...prev, id: data[0].id }));
|
||
}
|
||
} else {
|
||
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 {
|
||
insertInFlight.current = false;
|
||
setIsSaving(false);
|
||
}
|
||
}, 1000);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [isLoaded, campaign.title, campaign.preheader, campaign.modules, campaign.id]);
|
||
|
||
const selectedModule = campaign.modules.find((m) => m.id === selectedModuleId) || null;
|
||
|
||
const handleUpdateModule = (updated: NewsletterModule) => {
|
||
setCampaign((prev) => ({
|
||
...prev,
|
||
modules: prev.modules.map((m) => (m.id === updated.id ? updated : m))
|
||
}));
|
||
};
|
||
|
||
// Posun modulu: Hlavička, letáky, recepty a pätička sú ukotvené a bežné moduly sa nemôžu dostať mimo vyhradený stred
|
||
const moveModule = (index: number, direction: 'up' | 'down') => {
|
||
const currentMod = campaign.modules[index];
|
||
if (!currentMod || isAnchoredModule(currentMod)) return;
|
||
|
||
const topCount = campaign.modules.filter((m) => isTopModule(m)).length;
|
||
const firstBottomIndex = campaign.modules.findIndex((m) => isBottomModule(m));
|
||
const maxAllowedIndex = firstBottomIndex !== -1 ? firstBottomIndex - 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: enforceAnchors(newModules) }));
|
||
};
|
||
|
||
// Duplikácia: Ukotvené moduly sa nedajú duplikovať
|
||
const duplicateModule = (index: number) => {
|
||
const original = campaign.modules[index];
|
||
if (isAnchoredModule(original) || original.type === 'validity_bar') 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: enforceAnchors(newModules) }));
|
||
setSelectedModuleId(clone.id);
|
||
};
|
||
|
||
// Zmazanie: Ukotvené moduly (hlavička, letáky, články/recepty, pätička) sa nedajú vymazať
|
||
const deleteModule = (index: number) => {
|
||
const mod = campaign.modules[index];
|
||
if (isAnchoredModule(mod)) {
|
||
alert('Hlavičku, letáky, články/recepty ani pätičku nie je možné vymazať.');
|
||
return;
|
||
}
|
||
if (mod.type === 'validity_bar') {
|
||
alert('Lištu platnosti akcie nie je možné vymazať, dá sa len skryť.');
|
||
return;
|
||
}
|
||
if (campaign.modules.length <= 1) return;
|
||
|
||
const newModules = campaign.modules.filter((_, i) => i !== index);
|
||
setCampaign((prev) => ({ ...prev, modules: enforceAnchors(newModules) }));
|
||
setSelectedModuleId(newModules[0]?.id || null);
|
||
};
|
||
|
||
// Pridanie modulu: Vloží sa vždy PRED spodné bloky (letáky, recepty a pätičku)
|
||
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: '#',
|
||
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: '#',
|
||
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',
|
||
cardBgColor: '#feeaea',
|
||
cardBorderColor: '#f5d4d4',
|
||
badgeHeaderBg: '#fefefe',
|
||
badgeHeaderColor: '#000000',
|
||
badgePriceBg: '#e2001a',
|
||
badgePriceColor: '#fefefe',
|
||
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) => {
|
||
const firstBottomIdx = prev.modules.findIndex((m) => isBottomModule(m));
|
||
const newModules = [...prev.modules];
|
||
if (firstBottomIdx !== -1) {
|
||
newModules.splice(firstBottomIdx, 0, newMod);
|
||
} else {
|
||
newModules.push(newMod);
|
||
}
|
||
return { ...prev, modules: enforceAnchors(newModules) };
|
||
});
|
||
setSelectedModuleId(newMod.id);
|
||
};
|
||
|
||
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);
|
||
};
|
||
|
||
// 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);
|
||
}
|
||
};
|
||
|
||
// Pred exportom sa vizuály produktových kariet uložia ako obrázky (nezmenené sa nenahrávajú znova)
|
||
const prepareForExport = async (): Promise<NewsletterCampaign> => {
|
||
const { campaign: prepared, failed } = await prepareCardImages(campaign);
|
||
setCampaign((prev) => ({ ...prev, modules: prepared.modules }));
|
||
if (failed > 0) {
|
||
alert(`${failed} kariet produktov sa nepodarilo uložiť ako obrázok – v e-maile budú v jednoduchšej podobe.`);
|
||
}
|
||
return prepared;
|
||
};
|
||
|
||
const handleExportZip = async () => {
|
||
const prepared = await prepareForExport();
|
||
const rawHtml = renderEmailHtml(prepared);
|
||
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:', e);
|
||
}
|
||
|
||
const zip = new JSZip();
|
||
zip.file('index.html', finalHtml);
|
||
const blob = await zip.generateAsync({ type: 'blob' });
|
||
saveAs(blob, `coop-newsletter-${new Date().toISOString().split('T')[0]}.zip`);
|
||
};
|
||
|
||
const handleExportBrevo = async (updateExisting = false) => {
|
||
setIsExportingBrevo(true);
|
||
try {
|
||
const prepared = await prepareForExport();
|
||
const rawHtml = renderEmailHtml(prepared);
|
||
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,
|
||
updateExisting: updateExisting,
|
||
existingCampaignId: campaign.brevoCampaignId
|
||
})
|
||
});
|
||
|
||
const resData = await response.json();
|
||
|
||
if (resData.success && resData.campaignId) {
|
||
setCampaign((prev) => ({
|
||
...prev,
|
||
brevoCampaignId: resData.campaignId,
|
||
status: 'exported'
|
||
}));
|
||
setBrevoUrl(resData.brevoUrl);
|
||
|
||
if (campaign.id) {
|
||
await supabase
|
||
.from('templates')
|
||
.update({
|
||
status: 'exported',
|
||
brevo_campaign_id: resData.campaignId
|
||
})
|
||
.eq('id', campaign.id);
|
||
}
|
||
alert(
|
||
resData.message ||
|
||
(updateExisting
|
||
? 'Kampaň bola v Brevo úspešne aktualizovaná!'
|
||
: '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);
|
||
}
|
||
};
|
||
|
||
const handleClearBrevoId = async () => {
|
||
setCampaign((prev) => ({ ...prev, brevoCampaignId: undefined }));
|
||
setBrevoUrl(null);
|
||
if (campaign.id) {
|
||
await supabase
|
||
.from('templates')
|
||
.update({ brevo_campaign_id: null })
|
||
.eq('id', campaign.id);
|
||
}
|
||
};
|
||
|
||
// Vybraný produkt pre editor cenoviek
|
||
let targetProduct: any = null;
|
||
if (targetProductIdForBadge && selectedModule?.data?.products) {
|
||
targetProduct = selectedModule.data.products.find((p: any) => p.id === targetProductIdForBadge) || null;
|
||
}
|
||
|
||
const handleApplyBadgeToProduct = (badge: BadgeTemplate, renderedPngUrl?: string) => {
|
||
if (!targetProductIdForBadge || !selectedModule) return;
|
||
const updatedProducts = (selectedModule.data.products || []).map((p: any) => {
|
||
if (p.id === targetProductIdForBadge) {
|
||
return {
|
||
...p,
|
||
badgeId: badge.id,
|
||
badgeCustomTemplate: badge,
|
||
badgeWidth: badge.width ?? p.badgeWidth ?? 110,
|
||
badgeAlign: badge.align ?? p.badgeAlign ?? 'center',
|
||
badgePaddingTop: badge.paddingTop ?? p.badgePaddingTop ?? 4,
|
||
badgePaddingBottom: badge.paddingBottom ?? p.badgePaddingBottom ?? 4,
|
||
customBadgePngUrl: undefined // Používame živý dynamický SVG vektor, žiadna statická fotka
|
||
};
|
||
}
|
||
return p;
|
||
});
|
||
|
||
handleUpdateModule({
|
||
...selectedModule,
|
||
data: {
|
||
...selectedModule.data,
|
||
products: updatedProducts
|
||
}
|
||
});
|
||
|
||
setAvailableBadges((prev) => {
|
||
if (prev.some((b) => b.id === badge.id)) {
|
||
return prev.map((b) => (b.id === badge.id ? badge : b));
|
||
}
|
||
return [badge, ...prev];
|
||
});
|
||
};
|
||
|
||
// Hromadná aplikácia cenovky na všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
||
const handleApplyBadgeToAll = (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => {
|
||
setCampaign((prev) => ({
|
||
...prev,
|
||
modules: prev.modules.map((m) => {
|
||
if (m.type === 'products_grid' || m.type === 'loyalty_club') {
|
||
return {
|
||
...m,
|
||
data: {
|
||
...m.data,
|
||
// Produkty s vlastnou PNG cenovkou (výnimky) sa neprepisujú
|
||
products: (m.data.products || []).map((prod: any) => prod.customBadgePngUrl ? prod : ({
|
||
...prod,
|
||
badgeId: badge.id,
|
||
badgeCustomTemplate: badge,
|
||
badgeWidth: badge.width ?? prod.badgeWidth ?? 110,
|
||
badgeAlign: badge.align ?? prod.badgeAlign ?? 'center',
|
||
badgePaddingTop: badge.paddingTop ?? prod.badgePaddingTop ?? 4,
|
||
badgePaddingBottom: badge.paddingBottom ?? prod.badgePaddingBottom ?? 4,
|
||
badgeLayout: badgeLayout ?? prod.badgeLayout
|
||
}))
|
||
}
|
||
};
|
||
}
|
||
return m;
|
||
})
|
||
}));
|
||
};
|
||
|
||
// Spoločné nastavenia (globálna platnosť akcie, zobrazenie platnosti na kartách) sa prepíšu
|
||
// do všetkých produktových sekcií aj vernostných zliav
|
||
// (produkty s vlastnou platnosťou validityOverride si ju ponechajú)
|
||
const handleUpdateAllProductSections = (patch: Record<string, unknown>) => {
|
||
setCampaign((prev) => ({
|
||
...prev,
|
||
modules: prev.modules.map((m) =>
|
||
m.type === 'products_grid' || m.type === 'loyalty_club'
|
||
? { ...m, data: { ...m.data, ...patch } }
|
||
: m
|
||
)
|
||
}));
|
||
};
|
||
|
||
const topCount = campaign.modules.filter((m) => isTopModule(m)).length;
|
||
const firstBottomIdx = campaign.modules.findIndex((m) => isBottomModule(m));
|
||
|
||
return (
|
||
<div className="flex flex-col h-screen overflow-hidden bg-slate-950 font-sans">
|
||
{/* 1. Horná lišta */}
|
||
<TopBar
|
||
campaign={campaign}
|
||
isSaving={isSaving}
|
||
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"
|
||
onClearBrevoId={handleClearBrevoId}
|
||
onAddModule={handleAddModule}
|
||
/>
|
||
|
||
{/* 2. Hlavný pracovný priestor */}
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Stredné plátno - Presný originálny gradient a čisté rozloženie */}
|
||
<main
|
||
className="flex-1 overflow-y-auto p-4 md:p-8 flex items-start justify-center"
|
||
style={{
|
||
backgroundColor: '#001a33',
|
||
backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)'
|
||
}}
|
||
>
|
||
{/* Zvýraznenie produktu otvoreného v paneli */}
|
||
{selectedProductId && (
|
||
<style>{`main [data-product-id="${selectedProductId.replace(/["\\]/g, '')}"] { outline: 3px solid #ef4444; outline-offset: 3px; }`}</style>
|
||
)}
|
||
{/* E-mail Container 1:1 k originálu */}
|
||
<div className="w-full max-w-[600px] my-4">
|
||
{campaign.modules.map((mod, idx) => {
|
||
const isSelected = mod.id === selectedModuleId;
|
||
const isAnchored = isAnchoredModule(mod);
|
||
const isTop = isTopModule(mod);
|
||
const isFlyers = isFlyersModule(mod);
|
||
const isRecipes = isRecipesModule(mod);
|
||
const isFooter = isFooterModule(mod);
|
||
|
||
const canMoveUp = !isAnchored && idx > topCount;
|
||
const canMoveDown = !isAnchored && idx < (firstBottomIdx !== -1 ? firstBottomIdx - 1 : campaign.modules.length - 1);
|
||
|
||
return (
|
||
<div
|
||
key={mod.id}
|
||
onClick={(e) => {
|
||
// Klik na kartu produktu otvorí v paneli práve tento produkt
|
||
const card = (e.target as HTMLElement).closest('[data-product-id]');
|
||
if (card) {
|
||
setSelectedProductId(card.getAttribute('data-product-id'));
|
||
} else if (mod.id !== selectedModuleId) {
|
||
setSelectedProductId(null);
|
||
}
|
||
setSelectedModuleId(mod.id);
|
||
}}
|
||
className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${
|
||
isSelected
|
||
? '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 && (
|
||
<div className="absolute -top-3 right-4 z-40 flex items-center gap-1.5 bg-[#001a33] text-white px-3 py-1.5 rounded-full text-xs shadow-2xl border border-white/20 backdrop-blur-md">
|
||
<span className="text-[10px] font-bold text-gray-300 uppercase tracking-wider">
|
||
{mod.title}
|
||
</span>
|
||
|
||
{/* Ak je modul ukotvený (Hlavička / Letáky / Recepty / Pätička / Oznamovacia lišta) */}
|
||
{isAnchored ? (
|
||
<div className="flex items-center gap-1.5">
|
||
{mod.type === 'announcement' && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleUpdateModule({
|
||
...mod,
|
||
data: { ...mod.data, enabled: mod.data.enabled === false ? true : false }
|
||
});
|
||
}}
|
||
className={`text-[9px] font-bold px-2 py-0.5 rounded-full border transition-all flex items-center gap-1 ${
|
||
mod.data.enabled === false
|
||
? 'bg-red-500/20 text-red-300 border-red-400/30 hover:bg-red-500/30'
|
||
: 'bg-emerald-500/20 text-emerald-300 border-emerald-400/30 hover:bg-emerald-500/30'
|
||
}`}
|
||
title={mod.data.enabled === false ? 'Aktivovať lištu v e-maile' : 'Skryť lištu v e-maile'}
|
||
>
|
||
{mod.data.enabled === false ? 'Skrytá (zapnúť)' : 'Zobrazená'}
|
||
</button>
|
||
)}
|
||
<span className="text-[9px] font-bold bg-white/10 text-amber-300 px-2 py-0.5 rounded-full border border-white/10 uppercase flex items-center gap-1">
|
||
<Lock className="w-2.5 h-2.5" />
|
||
{isFooter
|
||
? 'Ukotvená pätička'
|
||
: isFlyers
|
||
? 'Ukotvené letáky'
|
||
: isRecipes
|
||
? 'Ukotvené recepty'
|
||
: mod.type === 'announcement'
|
||
? 'Oznamovacia lišta'
|
||
: 'Ukotvená hlavička'}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{mod.type === 'validity_bar' && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleUpdateModule({
|
||
...mod,
|
||
data: { ...mod.data, enabled: mod.data.enabled === false }
|
||
});
|
||
}}
|
||
className={`text-[9px] font-bold px-2 py-0.5 rounded-full border transition-all mr-1 ${
|
||
mod.data.enabled === false
|
||
? 'bg-red-500/20 text-red-300 border-red-400/30 hover:bg-red-500/30'
|
||
: 'bg-emerald-500/20 text-emerald-300 border-emerald-400/30 hover:bg-emerald-500/30'
|
||
}`}
|
||
title={mod.data.enabled === false ? 'Zobraziť lištu v e-maile' : 'Skryť lištu v e-maile'}
|
||
>
|
||
{mod.data.enabled === false ? 'Skrytá (zapnúť)' : 'Zobrazená'}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
moveModule(idx, 'up');
|
||
}}
|
||
disabled={!canMoveUp}
|
||
className="hover:text-red-400 disabled:opacity-30 p-1"
|
||
title={canMoveUp ? 'Posunúť vyššie' : 'Nemožno posunúť nad hlavičku'}
|
||
>
|
||
<ChevronUp className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
moveModule(idx, 'down');
|
||
}}
|
||
disabled={!canMoveDown}
|
||
className="hover:text-red-400 disabled:opacity-30 p-1"
|
||
title={canMoveDown ? 'Posunúť nižšie' : 'Nemožno posunúť pod pätičku'}
|
||
>
|
||
<ChevronDown className="w-3.5 h-3.5" />
|
||
</button>
|
||
{mod.type !== 'validity_bar' && (
|
||
<>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
duplicateModule(idx);
|
||
}}
|
||
className="hover:text-blue-400 p-1 ml-1"
|
||
title="Duplikovať sekciu"
|
||
>
|
||
<Copy className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
deleteModule(idx);
|
||
}}
|
||
className="hover:text-red-400 p-1 ml-1"
|
||
title="Zmazať sekciu"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Vykreslenie modulu: ak je loyalty_club alebo announcement vypnutý, zobrazíme prívetivý placeholder */}
|
||
{mod.type === 'loyalty_club' && mod.data.enabled === false ? (
|
||
<div className="p-8 my-4 border-2 border-dashed border-red-500/40 bg-red-950/30 rounded-2xl text-center">
|
||
<p className="text-xs font-bold text-red-300 uppercase tracking-wider">
|
||
Sekcia Vernostné zľavy (Vypnutá)
|
||
</p>
|
||
<p className="text-[11px] text-gray-300 mt-1">
|
||
Táto sekcia je v odosielanom e-maile skrytá. Kliknutím sem ju môžete aktivovať.
|
||
</p>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleUpdateModule({ ...mod, data: { ...mod.data, enabled: true } });
|
||
}}
|
||
className="mt-3 px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs font-bold rounded-lg transition-colors shadow-sm"
|
||
>
|
||
Aktivovať vernostné zľavy
|
||
</button>
|
||
</div>
|
||
) : mod.type === 'validity_bar' && mod.data.enabled === false ? (
|
||
<div className="p-4 my-2 border-2 border-dashed border-red-500/40 bg-red-950/30 rounded-xl text-center">
|
||
<p className="text-xs font-bold text-red-300 uppercase tracking-wider">
|
||
Lišta platnosti akcie (Skrytá)
|
||
</p>
|
||
<p className="text-[11px] text-gray-300 mt-0.5">
|
||
Táto lišta je v e-maile skrytá. Kliknutím sem ju môžete aktivovať.
|
||
</p>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleUpdateModule({ ...mod, data: { ...mod.data, enabled: true } });
|
||
}}
|
||
className="mt-2 px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs font-bold rounded-lg transition-colors shadow-sm"
|
||
>
|
||
Aktivovať lištu platnosti
|
||
</button>
|
||
</div>
|
||
) : mod.type === 'announcement' && mod.data.enabled === false ? (
|
||
<div className="p-4 my-2 border-2 border-dashed border-red-500/40 bg-red-950/30 rounded-xl text-center">
|
||
<p className="text-xs font-bold text-red-300 uppercase tracking-wider">
|
||
Top oznamovacia lišta (Vypnutá / Skrytá)
|
||
</p>
|
||
<p className="text-[11px] text-gray-300 mt-0.5">
|
||
Táto lišta je v e-maile skrytá. Kliknutím sem ju môžete aktivovať.
|
||
</p>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleUpdateModule({ ...mod, data: { ...mod.data, enabled: true } });
|
||
}}
|
||
className="mt-2 px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs font-bold rounded-lg transition-colors shadow-sm"
|
||
>
|
||
Aktivovať oznamovaciu lištu
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
dangerouslySetInnerHTML={{
|
||
__html: renderSingleModuleHtml(mod, campaign)
|
||
}}
|
||
onClick={(e) => {
|
||
// odkazy v náhľade neotvárame, klik slúži na výber modulu / produktu
|
||
if ((e.target as HTMLElement).closest('a')) e.preventDefault();
|
||
}}
|
||
className="[&_[data-product-id]]:cursor-pointer"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</main>
|
||
|
||
{/* Pravý konfiguračný panel */}
|
||
<aside className="w-96 flex-shrink-0 h-full bg-white shadow-2xl z-20">
|
||
<ConfigPanel
|
||
module={selectedModule}
|
||
selectedProductId={selectedProductId}
|
||
onSelectProduct={setSelectedProductId}
|
||
onUpdateModule={handleUpdateModule}
|
||
onApplyBadgeToAll={handleApplyBadgeToAll}
|
||
onUpdateAllProductSections={handleUpdateAllProductSections}
|
||
campaignValidity={getCampaignValidity(campaign)}
|
||
availableBadges={availableBadges}
|
||
onOpenBadgeManager={(pId) => {
|
||
setTargetProductIdForBadge(pId);
|
||
setIsBadgeEditorOpen(true);
|
||
}}
|
||
onOpenImport={() => setIsImportOpen(true)}
|
||
/>
|
||
</aside>
|
||
</div>
|
||
|
||
{/* Modál Hromadného importu */}
|
||
<ProductImportModal
|
||
isOpen={isImportOpen}
|
||
onClose={() => setIsImportOpen(false)}
|
||
onImportProducts={handleImportProducts}
|
||
/>
|
||
|
||
{/* Modál Editora cenoviek (SVG -> PNG) */}
|
||
<BadgeEditorModal
|
||
isOpen={isBadgeEditorOpen}
|
||
onClose={() => setIsBadgeEditorOpen(false)}
|
||
onSelectBadge={handleApplyBadgeToProduct}
|
||
currentDiscount={targetProduct?.discountText || targetProduct?.cardDiscount || '25%'}
|
||
currentPriceMain={targetProduct?.priceMain || targetProduct?.cardPriceMain || '1'}
|
||
currentPriceSub={targetProduct?.priceSub || targetProduct?.cardPriceSub || '99'}
|
||
currentUnitPrice={targetProduct?.unitPrice || targetProduct?.cardUnitPrice || '1kg = 4,91 €'}
|
||
currentCardPriceMain={targetProduct?.cardPriceMain || undefined}
|
||
currentCardPriceSub={targetProduct?.cardPriceSub || undefined}
|
||
currentCardDiscount={targetProduct?.cardDiscount || undefined}
|
||
/>
|
||
|
||
{/* Modál Moje newslettre (Ukladanie, duplikácia, vytváranie a prepínanie) */}
|
||
<SavedCampaignsModal
|
||
isOpen={isSavedCampaignsOpen}
|
||
onClose={() => setIsSavedCampaignsOpen(false)}
|
||
currentCampaign={campaign}
|
||
onLoadCampaign={(selected: NewsletterCampaign) => {
|
||
setCampaign({ ...selected, modules: enforceAnchors(ensureValidityBar(selected.modules)) });
|
||
setSelectedModuleId(selected.modules[1]?.id || selected.modules[0]?.id || null);
|
||
if (selected.brevoCampaignId) {
|
||
setBrevoUrl(getBrevoCampaignUrl(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
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|