From ab74922b93698a7ad8ece863563ab9b516db421f Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Thu, 24 Sep 2026 11:15:54 +0200 Subject: [PATCH] Fix canvas background to exact COOP gradient, remove module borders/gaps, add interactive Badge Editor SVG to PNG --- src/app/page.tsx | 230 +++++++------ src/components/editor/BadgeEditorModal.tsx | 379 +++++++++++++++++++++ src/components/editor/ConfigPanel.tsx | 14 + src/lib/emailRenderer.ts | 12 +- src/types/newsletter.ts | 1 + 5 files changed, 537 insertions(+), 99 deletions(-) create mode 100644 src/components/editor/BadgeEditorModal.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index b2585c8..a9f6ad1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,17 +1,17 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { NewsletterCampaign, NewsletterModule, ProductItem } from '@/types/newsletter'; +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 { BadgeEditorModal } from '@/components/editor/BadgeEditorModal'; import JSZip from 'jszip'; import { saveAs } from 'file-saver'; -import { ChevronUp, ChevronDown, Copy, Trash2 } from 'lucide-react'; +import { ChevronUp, ChevronDown, Copy, Trash2, Edit3 } from 'lucide-react'; export default function Home() { const [campaign, setCampaign] = useState(DEFAULT_CAMPAIGN); @@ -22,13 +22,14 @@ export default function Home() { // Modály const [isImportOpen, setIsImportOpen] = useState(false); - const [isBadgeModalOpen, setIsBadgeModalOpen] = useState(false); + const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false); + const [targetProductIdForBadge, setTargetProductIdForBadge] = useState(null); // Načítanie existujúceho draftu z databázy useEffect(() => { async function loadInitial() { try { - const { data, error } = await supabase + const { data } = await supabase .from('templates') .select('*') .order('updated_at', { ascending: false }) @@ -36,8 +37,7 @@ export default function Home() { if (data && data.length > 0) { const dbItem = data[0]; - // Ak má databáza rovnakú štruktúru, načítame ju, inak necháme DEFAULT_CAMPAIGN 1:1 - if (dbItem.data && dbItem.data.modules && dbItem.data.modules.length >= 6) { + if (dbItem.data && dbItem.data.modules && dbItem.data.modules.length >= 7) { setCampaign({ id: dbItem.id, title: dbItem.title, @@ -291,11 +291,36 @@ export default function Home() { } }; - // Kompilácia celého HTML pre responzívny náhľad v reálnom čase - const fullHtml = renderEmailHtml(campaign); + // Vybraný produkt pre editor cenoviek + let targetProduct: ProductItem | null = 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: ProductItem) => { + if (p.id === targetProductIdForBadge) { + return { + ...p, + badgeId: badge.id, + customBadgePngUrl: renderedPngUrl || p.customBadgePngUrl + }; + } + return p; + }); + + handleUpdateModule({ + ...selectedModule, + data: { + ...selectedModule.data, + products: updatedProducts + } + }); + }; return ( -
+
{/* 1. Horná lišta */} - {/* Stredné plátno - Živý responzívny e-mail v iframe pre 100% izoláciu CSS a médií */} -
-
- {/* Zoznam modulov pre priamu editáciu */} -
- {campaign.modules.map((mod, idx) => { - const isSelected = mod.id === selectedModuleId; + {/* Stredné plátno - Presný originálny gradient a čisté rozloženie */} +
+ {/* E-mail Container 1:1 k originálu */} +
+ {campaign.modules.map((mod, idx) => { + const isSelected = mod.id === selectedModuleId; - return ( + return ( +
setSelectedModuleId(mod.id)} + className={`relative cursor-pointer transition-all duration-150 ${ + 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' + }`} + > + {/* Lišta s nástrojmi pre označený modul */} + {isSelected && ( +
+ + {mod.title} + + + + + +
+ )} + + {/* Vykreslenie modulu bez vnútorných medzier navyše */}
setSelectedModuleId(mod.id)} - className={`relative group cursor-pointer transition-all duration-200 rounded-2xl border-2 ${ - isSelected - ? 'border-red-500 ring-4 ring-red-500/20' - : 'border-transparent hover:border-slate-700/60' - }`} - > - {/* Lišta s nástrojmi pre označený modul */} - {isSelected && ( -
- - {mod.title} - - - - - -
- )} - - {/* Vykreslenie modulu 1:1 */} -
-
- ); - })} -
+ dangerouslySetInnerHTML={{ + __html: renderEmailHtml({ + ...campaign, + modules: [mod] + }) + }} + className="pointer-events-none" + /> +
+ ); + })}
@@ -401,23 +430,30 @@ export default function Home() { setIsBadgeModalOpen(true)} + onOpenBadgeManager={(pId) => { + setTargetProductIdForBadge(pId); + setIsBadgeEditorOpen(true); + }} onOpenImport={() => setIsImportOpen(true)} />
- {/* Modály */} + {/* Modál Hromadného importu */} setIsImportOpen(false)} onImportProducts={handleImportProducts} /> - setIsBadgeModalOpen(false)} - onSelectBadge={() => {}} + {/* Modál Editora cenoviek (SVG -> PNG) */} + setIsBadgeEditorOpen(false)} + onSelectBadge={handleApplyBadgeToProduct} + currentDiscount={targetProduct?.discountText || '25%'} + currentPriceMain={targetProduct?.priceMain || '1'} + currentPriceSub={targetProduct?.priceSub || '99'} />
); diff --git a/src/components/editor/BadgeEditorModal.tsx b/src/components/editor/BadgeEditorModal.tsx new file mode 100644 index 0000000..2445122 --- /dev/null +++ b/src/components/editor/BadgeEditorModal.tsx @@ -0,0 +1,379 @@ +'use client'; + +import React, { useState, useRef } from 'react'; +import { BadgeTemplate, BadgeLayer } from '@/types/newsletter'; +import { supabase } from '@/lib/supabase'; +import { toPng } from 'html-to-image'; +import { Tag, Plus, Check, Trash2, X, Move, Type, Eye } from 'lucide-react'; + +interface BadgeEditorModalProps { + isOpen: boolean; + onClose: () => void; + onSelectBadge: (badge: BadgeTemplate, renderedPngUrl?: string) => void; + currentDiscount?: string; + currentPriceMain?: string; + currentPriceSub?: string; +} + +export const BadgeEditorModal: React.FC = ({ + isOpen, + onClose, + onSelectBadge, + currentDiscount = '25%', + currentPriceMain = '1', + currentPriceSub = '99' +}) => { + const [badges, setBadges] = useState([ + { + id: 'badge-akcia', + name: 'Klasik Žltá AKCIA', + svgRaw: ` + + + `, + layersConfig: [ + { id: '1', type: 'text', text: 'AKCIA', x: 70, y: 32, fontSize: 20, fontWeight: '900', fill: '#e0001b', align: 'center' }, + { id: '2', type: 'text', text: '-{zlava}', x: 70, y: 68, fontSize: 28, fontWeight: '900', fill: '#001a33', align: 'center' } + ] + }, + { + id: 'badge-super-cena', + name: 'Červená SUPER CENA', + svgRaw: ` + + + + `, + layersConfig: [ + { id: '1', type: 'text', text: 'SUPER CENA', x: 70, y: 32, fontSize: 16, fontWeight: '900', fill: '#ffffff', align: 'center' }, + { id: '2', type: 'text', text: '-{zlava}', x: 70, y: 68, fontSize: 30, fontWeight: '900', fill: '#ffd100', align: 'center' } + ] + }, + { + id: 'badge-hviezda', + name: 'Explózia zliav (Hviezda)', + svgRaw: ` + + `, + layersConfig: [ + { id: '1', type: 'text', text: 'TOP', x: 70, y: 46, fontSize: 14, fontWeight: '900', fill: '#e2001a', align: 'center' }, + { id: '2', type: 'text', text: '-{zlava}', x: 70, y: 76, fontSize: 24, fontWeight: '900', fill: '#001a33', align: 'center' } + ] + }, + { + id: 'badge-kruh', + name: 'Kruhová percentuálna zľava', + svgRaw: ` + + `, + layersConfig: [ + { id: '1', type: 'text', text: 'ZĽAVA', x: 55, y: 40, fontSize: 12, fontWeight: '800', fill: '#ffffff', align: 'center' }, + { id: '2', type: 'text', text: '-{zlava}', x: 55, y: 74, fontSize: 26, fontWeight: '900', fill: '#ffd100', align: 'center' } + ] + } + ]); + + const [activeBadge, setActiveBadge] = useState(badges[0]); + const [selectedLayerId, setSelectedLayerId] = useState('2'); + const previewRef = useRef(null); + const [isExportingPng, setIsExportingPng] = useState(false); + + if (!isOpen) return null; + + const activeLayer = activeBadge.layersConfig.find((l) => l.id === selectedLayerId) || activeBadge.layersConfig[0]; + + const updateActiveLayer = (updates: Partial) => { + const updatedLayers = activeBadge.layersConfig.map((l) => + l.id === activeLayer.id ? { ...l, ...updates } : l + ); + setActiveBadge({ ...activeBadge, layersConfig: updatedLayers }); + }; + + const addTextLayer = () => { + const newL: BadgeLayer = { + id: `l-${Date.now()}`, + type: 'text', + text: 'Nový text', + x: 70, + y: 50, + fontSize: 16, + fontWeight: '800', + fill: '#ffffff', + align: 'center' + }; + setActiveBadge({ + ...activeBadge, + layersConfig: [...activeBadge.layersConfig, newL] + }); + setSelectedLayerId(newL.id); + }; + + const removeLayer = (id: string) => { + if (activeBadge.layersConfig.length <= 1) return; + setActiveBadge({ + ...activeBadge, + layersConfig: activeBadge.layersConfig.filter((l) => l.id !== id) + }); + }; + + const replaceVariables = (txt: string) => { + return txt + .replace(/{zlava}/g, currentDiscount.replace('-', '')) + .replace(/{cena}/g, `${currentPriceMain},${currentPriceSub} €`); + }; + + const handleApplyBadge = async () => { + if (!previewRef.current) { + onSelectBadge(activeBadge); + onClose(); + return; + } + + setIsExportingPng(true); + try { + // SVG -> PNG render cez HTML5 Canvas/toPng + const dataUrl = await toPng(previewRef.current, { + backgroundColor: 'transparent', + pixelRatio: 2 + }); + onSelectBadge(activeBadge, dataUrl); + onClose(); + } catch (err) { + console.error('Render error:', err); + onSelectBadge(activeBadge); + onClose(); + } finally { + setIsExportingPng(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Editor grafických cenoviek (SVG → PNG Pipeline) +

+

+ Upravte texty, premenné a pozície. Pri uložení sa cenovka automaticky vyrenderuje do orezaného PNG pre e-mail. +

+
+
+ +
+ + {/* Content */} +
+ {/* Ľavý stĺpec: Šablóny */} +
+ + Katalóg cenoviek + +
+ {badges.map((b) => ( + + ))} +
+
+ + {/* Stredný stĺpec: Interaktívny Canvas / Náhľad */} +
+ + Živý vektorový náhľad + + + {/* Plátno cenovky */} +
+
+ + {/* Textové vrstvy nad SVG podkladom */} + {activeBadge.layersConfig.map((layer) => { + const isSelected = layer.id === selectedLayerId; + return ( +
setSelectedLayerId(layer.id)} + style={{ + position: 'absolute', + left: `${layer.x}px`, + top: `${layer.y}px`, + transform: 'translate(-50%, -50%)', + fontSize: `${layer.fontSize}px`, + fontWeight: layer.fontWeight as any, + color: layer.fill, + fontFamily: 'Poppins, Arial, sans-serif', + whiteSpace: 'nowrap', + cursor: 'pointer' + }} + className={`px-1 rounded transition-all ${ + isSelected ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-transparent' : '' + }`} + > + {replaceVariables(layer.text || '')} +
+ ); + })} +
+ +

+ Dostupné premenné: {`{zlava}`} (hodnota: {currentDiscount}), {`{cena}`} +

+
+ + {/* Pravý stĺpec: Konfigurácia vrstiev */} +
+
+ + Vrstvy textov ({activeBadge.layersConfig.length}) + + +
+ + {/* Zoznam vrstiev */} +
+ {activeBadge.layersConfig.map((l, idx) => ( + + ))} +
+ + {/* Nastavenie aktívnej vrstvy */} + {activeLayer && ( +
+
+ + updateActiveLayer({ text: e.target.value })} + className="w-full text-sm border border-gray-300 rounded-xl p-2.5 font-medium" + /> +
+ +
+
+ + updateActiveLayer({ fontSize: Number(e.target.value) })} + className="w-full text-sm border border-gray-300 rounded-xl p-2" + /> +
+
+ +
+ updateActiveLayer({ fill: e.target.value })} + className="w-8 h-8 rounded-lg cursor-pointer border-0" + /> + {activeLayer.fill} +
+
+
+ +
+
+ + updateActiveLayer({ x: Number(e.target.value) })} + className="w-full" + /> + {activeLayer.x} px +
+
+ + updateActiveLayer({ y: Number(e.target.value) })} + className="w-full" + /> + {activeLayer.y} px +
+
+ +
+ +
+
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/src/components/editor/ConfigPanel.tsx b/src/components/editor/ConfigPanel.tsx index bb59910..5bc667a 100644 --- a/src/components/editor/ConfigPanel.tsx +++ b/src/components/editor/ConfigPanel.tsx @@ -387,6 +387,20 @@ export const ConfigPanel: React.FC = ({ Zobraziť slovenskú vlajku / srdce
+ + {/* Grafická cenovka - Editor cenoviek */} +
+
+ + Grafická cenovka +
+ +
))}
diff --git a/src/lib/emailRenderer.ts b/src/lib/emailRenderer.ts index 3a34980..4a9eca2 100644 --- a/src/lib/emailRenderer.ts +++ b/src/lib/emailRenderer.ts @@ -609,7 +609,15 @@ function renderExactProductCard( - + + ${p.customBadgePngUrl ? ` + + + + +
+ Cenovka +
` : ` ` : ''} -
@@ -640,7 +648,7 @@ function renderExactProductCard( SK
+ `} ${p.unitPrice ? `
${escapeHtml(p.unitPrice)}
` : ''} diff --git a/src/types/newsletter.ts b/src/types/newsletter.ts index 85a958e..a8727c2 100644 --- a/src/types/newsletter.ts +++ b/src/types/newsletter.ts @@ -34,6 +34,7 @@ export interface ProductItem { }; cardBgColor?: string; // HEX napr. "#feeaea" (fantasticky), "#fef0c7" (domace), "#dcb085" (grill) showSlovakFlag?: boolean; + customBadgePngUrl?: string; // Vyrenderovaná vlastná PNG cenovka z editora } export interface AnnouncementModuleData {