diff --git a/src/app/page.tsx b/src/app/page.tsx index 6ce4467..8f0a49f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign'; import { renderEmailHtml, renderSingleModuleHtml } from '@/lib/emailRenderer'; import { supabase } from '@/lib/supabase'; +import { DEFAULT_BADGE_TEMPLATES } from '@/lib/badgeUtils'; import { TopBar } from '@/components/editor/TopBar'; import { ConfigPanel } from '@/components/editor/ConfigPanel'; import { ProductImportModal } from '@/components/editor/ProductImportModal'; @@ -26,6 +27,64 @@ export default function Home() { const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false); const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false); const [targetProductIdForBadge, setTargetProductIdForBadge] = useState(null); + const [availableBadges, setAvailableBadges] = useState(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'; @@ -475,7 +534,12 @@ export default function Home() { return { ...p, badgeId: badge.id, - customBadgePngUrl: renderedPngUrl || p.customBadgePngUrl + 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; @@ -488,6 +552,13 @@ export default function Home() { 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]; + }); }; const topCount = campaign.modules.filter((m) => isTopModule(m)).length; @@ -651,6 +722,7 @@ export default function Home() { { setTargetProductIdForBadge(pId); setIsBadgeEditorOpen(true); diff --git a/src/components/editor/BadgeEditorModal.tsx b/src/components/editor/BadgeEditorModal.tsx index c3dac3d..ee0206c 100644 --- a/src/components/editor/BadgeEditorModal.tsx +++ b/src/components/editor/BadgeEditorModal.tsx @@ -3,8 +3,8 @@ import React, { useState, useRef, useEffect } 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, Upload, Eye, FileCode, Move } from 'lucide-react'; +import { DEFAULT_BADGE_TEMPLATES, renderCleanBadgeSvg, svgToCleanPng } from '@/lib/badgeUtils'; +import { Tag, Plus, Check, Trash2, X, Upload, Eye, Move, AlignLeft, AlignCenter, AlignRight } from 'lucide-react'; interface BadgeEditorModalProps { isOpen: boolean; @@ -68,7 +68,6 @@ export const BadgeEditorModal: React.FC = ({ const [badges, setBadges] = useState([]); const [activeBadge, setActiveBadge] = useState(null); const [selectedLayerId, setSelectedLayerId] = useState(null); - const previewRef = useRef(null); const [isExportingPng, setIsExportingPng] = useState(false); const [isSavingDb, setIsSavingDb] = useState(false); @@ -175,7 +174,7 @@ export const BadgeEditorModal: React.FC = ({ } }, [activeBadge]); - // Načítanie existujúcich šablón zo Supabase + // Načítanie existujúcich šablón zo Supabase + LocalStorage + Default Presets useEffect(() => { if (isOpen) { loadBadgesFromSupabase(); @@ -183,6 +182,8 @@ export const BadgeEditorModal: React.FC = ({ }, [isOpen]); const loadBadgesFromSupabase = async () => { + let loaded: BadgeTemplate[] = []; + try { const { data, error } = await supabase .from('badges') @@ -190,45 +191,81 @@ export const BadgeEditorModal: React.FC = ({ .order('created_at', { ascending: false }); if (data && !error && data.length > 0) { - const loaded: BadgeTemplate[] = data.map((d: any) => ({ - id: d.id, - name: d.name, - svgRaw: d.svg_raw || '', - layersConfig: d.layers_config || [] - })); - setBadges(loaded); - setActiveBadge(loaded[0]); - if (loaded[0]?.layersConfig?.[0]) { - setSelectedLayerId(loaded[0].layersConfig[0].id); - } - } else { - // Ak je databáza prázdna, necháme čistý stav - setBadges([]); - setActiveBadge(null); + 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 (err) { - console.error('Error loading badges:', err); + console.warn('Error loading badges from Supabase, checking local storage:', err); + } + + // Doplnenie z localStorage + try { + const localStr = localStorage.getItem('coop_custom_badges'); + if (localStr) { + const localBadges = JSON.parse(localStr); + if (Array.isArray(localBadges)) { + const existingIds = new Set(loaded.map((b) => b.id)); + for (const b of localBadges) { + if (!existingIds.has(b.id)) { + loaded.push(b); + existingIds.add(b.id); + } + } + } + } + } catch (e) {} + + // Doplnenie predpripravených dizajnových šablón (DEFAULT_BADGE_TEMPLATES) + const allBadges = [...loaded]; + const existingIds = new Set(allBadges.map((b) => b.id)); + for (const preset of DEFAULT_BADGE_TEMPLATES) { + if (!existingIds.has(preset.id)) { + allBadges.push(preset); + } + } + + setBadges(allBadges); + if (!activeBadge && allBadges.length > 0) { + setActiveBadge(allBadges[0]); + if (allBadges[0].layersConfig?.[0]) { + setSelectedLayerId(allBadges[0].layersConfig[0].id); + } } }; if (!isOpen) return null; - // Vytvorenie úplne novej prázdnej šablóny cenovky + // Vytvorenie novej šablóny const handleCreateNewTemplate = () => { const newBadge: BadgeTemplate = { id: `badge-${Date.now()}`, name: 'Nová vlastná cenovka', - svgRaw: ` - + svgRaw: ` + `, + width: 110, + align: 'center', + paddingTop: 4, + paddingBottom: 4, layersConfig: [ { id: `l-${Date.now()}-1`, type: 'text', text: 'AKCIA', x: 70, - y: 30, - fontSize: 18, + y: 24, + fontSize: 16, fontWeight: '900', fill: '#e2001a', align: 'center' @@ -236,10 +273,10 @@ export const BadgeEditorModal: React.FC = ({ { id: `l-${Date.now()}-2`, type: 'text', - text: '-{zlava}', + text: '{cena}', x: 70, - y: 65, - fontSize: 26, + y: 62, + fontSize: 42, fontWeight: '900', fill: '#001a33', align: 'center' @@ -252,7 +289,7 @@ export const BadgeEditorModal: React.FC = ({ setSelectedLayerId(newBadge.layersConfig[0].id); }; - // Upload vlastného SVG súboru ako pozadia + // Upload vlastného SVG súboru const handleSvgUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !activeBadge) return; @@ -261,7 +298,6 @@ export const BadgeEditorModal: React.FC = ({ reader.onload = (event) => { const content = event.target?.result as string; if (content.includes(' = ({ }; const deleteBadgeTemplate = async (badgeId: string) => { - try { - await supabase.from('badges').delete().eq('id', badgeId); - } catch (e) {} - const remaining = badges.filter((b) => b.id !== badgeId); - setBadges(remaining); - setActiveBadge(remaining[0] || null); + if (confirm('Naozaj chcete zmazať túto šablónu cenovky?')) { + try { + await supabase.from('badges').delete().eq('id', badgeId); + } catch (e) {} + const remaining = badges.filter((b) => b.id !== badgeId); + setBadges(remaining); + setActiveBadge(remaining[0] || null); + try { + localStorage.setItem('coop_custom_badges', JSON.stringify(remaining)); + } catch (e) {} + } }; - // Dynamické nahradenie premenných: {zlava}, {cena}, {jednotkova_cena} - const replaceVariables = (txt: string) => { - if (!txt) return ''; - return txt - .replace(/{zlava}/g, currentDiscount.replace('-', '')) - .replace(/{cena}/g, `${currentPriceMain},${currentPriceSub} €`) - .replace(/{jednotkova_cena}/g, currentUnitPrice); - }; - - // Uloženie šablóny do Supabase + // Uloženie šablóny do Supabase + LocalStorage (garantované uchovanie) const handleSaveToSupabase = async () => { if (!activeBadge) return; setIsSavingDb(true); try { - const { data, error } = await supabase - .from('badges') - .upsert([ - { - id: activeBadge.id.startsWith('badge-') ? undefined : activeBadge.id, - name: activeBadge.name, - svg_raw: activeBadge.svgRaw, - layers_config: activeBadge.layersConfig - } - ]) - .select(); + const payload: any = { + name: activeBadge.name, + svg_raw: activeBadge.svgRaw, + layers_config: { + layers: activeBadge.layersConfig, + width: activeBadge.width || 110, + align: activeBadge.align || 'center', + paddingTop: activeBadge.paddingTop ?? 4, + paddingBottom: activeBadge.paddingBottom ?? 4 + } + }; - if (data && data.length > 0) { - alert('Šablóna cenovky bola úspešne uložená do databázy!'); - loadBadgesFromSupabase(); + let savedBadge: BadgeTemplate | null = null; + const isExistingUuid = activeBadge.id && !activeBadge.id.startsWith('badge-'); + + if (isExistingUuid) { + const { data, error } = await supabase + .from('badges') + .update(payload) + .eq('id', activeBadge.id) + .select(); + if (error) throw error; + if (data && data[0]) { + savedBadge = { + id: data[0].id, + name: data[0].name, + svgRaw: data[0].svg_raw, + layersConfig: activeBadge.layersConfig, + width: activeBadge.width, + align: activeBadge.align, + paddingTop: activeBadge.paddingTop, + paddingBottom: activeBadge.paddingBottom + }; + } + } else { + const { data, error } = await supabase + .from('badges') + .insert([payload]) + .select(); + if (error) throw error; + if (data && data[0]) { + savedBadge = { + id: data[0].id, + name: data[0].name, + svgRaw: data[0].svg_raw, + layersConfig: activeBadge.layersConfig, + width: activeBadge.width, + align: activeBadge.align, + paddingTop: activeBadge.paddingTop, + paddingBottom: activeBadge.paddingBottom + }; + } } + + const targetToSave = savedBadge || activeBadge; + const updatedList = badges.map((b) => (b.id === activeBadge.id ? targetToSave : b)); + if (!updatedList.some((b) => b.id === targetToSave.id)) { + updatedList.unshift(targetToSave); + } + setBadges(updatedList); + setActiveBadge(targetToSave); + try { + localStorage.setItem('coop_custom_badges', JSON.stringify(updatedList)); + } catch (e) {} + + alert('Šablóna cenovky bola úspešne uložená a je pripravená na opätovné použitie pre všetky produkty!'); } catch (err: any) { - alert(`Chyba pri ukladaní do databázy: ${err.message}`); + console.warn('Supabase save error, persisting to local cache:', err); + try { + const updatedList = badges.map((b) => (b.id === activeBadge.id ? activeBadge : b)); + localStorage.setItem('coop_custom_badges', JSON.stringify(updatedList)); + alert('Šablóna bola úspešne uložená v prehliadači.'); + } catch (e) { + alert(`Chyba pri ukladaní: ${err.message}`); + } } finally { setIsSavingDb(false); } }; - // Render do PNG a aplikovanie na produkt - const handleApplyBadge = async () => { - if (!activeBadge) return; - if (!previewRef.current) { - onSelectBadge(activeBadge); - onClose(); - return; + // Vykreslenie vrstvy v React náhľade s centami ako horný index (sup) a BEZ znaku € + const renderLayerVisualContent = (layer: BadgeLayer) => { + const raw = layer.text || ''; + if (raw.includes('{cena}')) { + const parts = raw.split('{cena}'); + return ( + + {parts[0]} + {currentPriceMain} + + {currentPriceSub} + + {parts[1]} + + ); } + if (raw.includes('{centy}')) { + const parts = raw.split('{centy}'); + return ( + + {parts[0]} + {currentPriceSub} + {parts[1]} + + ); + } + + const replaced = raw + .replace(/{cela_suma}/g, currentPriceMain) + .replace(/{zlava}/g, currentDiscount.replace('-', '')) + .replace(/{jednotkova_cena}/g, currentUnitPrice) + .replace(/{mena}/g, '€'); + + return replaced; + }; + + // Aplikovanie cenovky na produkt: čisté vektorové SVG a čistý PNG (BEZ akéhokoľvek screenshotu nástrojov/rámčekov) + const handleApplyBadge = async () => { + if (!activeBadge) return; setIsExportingPng(true); try { - const dataUrl = await toPng(previewRef.current, { - backgroundColor: 'transparent', - pixelRatio: 2 - }); - onSelectBadge(activeBadge, dataUrl); + const cleanSvg = renderCleanBadgeSvg( + activeBadge, + { + priceMain: currentPriceMain, + priceSub: currentPriceSub, + discountText: currentDiscount, + unitPrice: currentUnitPrice + }, + activeBadge.width || 110 + ); + + const cleanPng = await svgToCleanPng(cleanSvg, 140, 95); + onSelectBadge(activeBadge, cleanPng); onClose(); } catch (err) { - console.error('Render error:', err); + console.error('Apply badge error:', err); onSelectBadge(activeBadge); onClose(); } finally { @@ -388,7 +516,7 @@ export const BadgeEditorModal: React.FC = ({ return (
-
+
{/* Header */}
@@ -397,10 +525,10 @@ export const BadgeEditorModal: React.FC = ({

- Editor a generátor grafických cenoviek (SVG → PNG) + Editor grafických cenoviek (Opakovane použiteľné šablóny)

- Vytvorte vlastnú šablónu, nahrajte SVG tvar, pridajte dynamické premenné a vyrenderujte PNG pre e-mail. + Nastavte rozmery, zarovnanie a rozmiestnenie vrstiev. Cena a centy sú dynamické a nezamŕzajú v rastri.

@@ -415,7 +543,7 @@ export const BadgeEditorModal: React.FC = ({
- Vaše šablóny ({badges.length}) + Šablóny ({badges.length})
- {badges.length === 0 ? ( -
- -

Zatiaľ nemáte žiadne šablóny cenoviek.

- -
- ) : ( -
- {badges.map((b) => ( -
{ - setActiveBadge(b); - if (b.layersConfig?.[0]) setSelectedLayerId(b.layersConfig[0].id); - }} - className={`w-full text-left p-3 rounded-2xl border transition-all cursor-pointer relative group ${ - activeBadge?.id === b.id - ? 'bg-white border-[#e2001a] shadow-md ring-2 ring-red-500/10' - : 'bg-white/80 border-gray-200 hover:border-gray-300' - }`} - > -
-

{b.name}

+
+

{b.name}

+ {!b.id.startsWith('badge-preset-') && ( -
-
+ )}
- ))} -
- )} +
+
+ ))} +
- {/* 2. Stĺpec: Vektorový Canvas & Náhľad */} + {/* 2. Stĺpec: Vektorový Canvas & Náhľad s Drag & Drop */}
- Náhľad cenovky + Náhľad na plátne - Drag & Drop textových polí + Drag & Drop vrstiev
{activeBadge ? ( -
- {/* Vektorové plátno pre export do PNG */} +
+ {/* Obal simulujúci veľkosť, zarovnanie a odsadenie */}
- {/* SVG Podklad */}
+ className="relative select-none transition-all" + style={{ + width: `${activeBadge.width || 110}px` + }} + > + {/* SVG Podklad */} +
- {/* Dynamické textové vrstvy nad SVG podkladom (Drag & Drop) */} - {activeBadge.layersConfig.map((layer) => { - const isSelected = layer.id === selectedLayerId; - const isDraggingThis = dragState?.layerId === layer.id; - return ( -
handleLayerMouseDown(e, layer)} - onTouchStart={(e) => handleLayerTouchStart(e, layer)} - onClick={(e) => { - e.stopPropagation(); - 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 || '700') as any, - color: layer.fill, - fontFamily: layer.fontFamily - ? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` - : 'Poppins, Arial, sans-serif', - whiteSpace: 'nowrap', - cursor: isDraggingThis ? 'grabbing' : 'grab', - userSelect: 'none' - }} - className={`px-1.5 py-0.5 rounded transition-all select-none ${ - isSelected - ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-slate-900 bg-blue-500/20 shadow-lg' - : 'hover:outline hover:outline-1 hover:outline-white/60 hover:bg-white/10' - }`} - title="Kliknite a ťahajte myšou pre zmenu pozície (Drag & Drop)" - > - {replaceVariables(layer.text || '')} + {/* Dynamické textové vrstvy nad SVG podkladom (Drag & Drop) */} + {activeBadge.layersConfig.map((layer) => { + const isSelected = layer.id === selectedLayerId; + const isDraggingThis = dragState?.layerId === layer.id; + return ( +
handleLayerMouseDown(e, layer)} + onTouchStart={(e) => handleLayerTouchStart(e, layer)} + onClick={(e) => { + e.stopPropagation(); + 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 || '700') as any, + color: layer.fill, + fontFamily: layer.fontFamily + ? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` + : 'Poppins, Arial, sans-serif', + whiteSpace: 'nowrap', + cursor: isDraggingThis ? 'grabbing' : 'grab', + userSelect: 'none' + }} + className={`px-1.5 py-0.5 rounded transition-all select-none ${ + isSelected + ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-slate-900 bg-blue-500/20 shadow-lg' + : 'hover:outline hover:outline-1 hover:outline-white/60 hover:bg-white/10' + }`} + title="Ťahajte myšou pre zmenu pozície" + > + {renderLayerVisualContent(layer)} - {/* Indikátor pozície a uchopenia */} - {isSelected && ( - - {layer.x}px, {layer.y}px - - )} -
- ); - })} + {isSelected && ( + + {layer.x}px, {layer.y}px + + )} +
+ ); + })} +
- {/* Dostupné premenné */} -
- Dostupné dynamické premenné: -
+ {/* Info o premenných a centoch */} +
+ Dostupné dynamické značky: +
- {'{zlava}'} → {currentDiscount} + {'{cena}'} → {currentPriceMain} + {currentPriceSub} (bez €) - - {'{cena}'} → {currentPriceMain},{currentPriceSub} € + + {'{zlava}'} → {currentDiscount.replace('-', '')} - - {'{jednotkova_cena}'} → {currentUnitPrice} + + {'{mena}'} → €
@@ -567,7 +711,7 @@ export const BadgeEditorModal: React.FC = ({ )}
- {/* 3. Stĺpec: Konfigurácia SVG podkladu a Vrstiev */} + {/* 3. Stĺpec: Konfigurácia rozmerov, zarovnania a vrstiev */}
{activeBadge ? ( <> @@ -582,11 +726,105 @@ export const BadgeEditorModal: React.FC = ({ />
+ {/* Sekcia: Rozmery, zarovnanie a odsadenie cenovky */} +
+ + Rozmery a Zarovnanie cenovky + + + {/* Šírka cenovky */} +
+
+ + + {activeBadge.width || 110} px + +
+ setActiveBadge({ ...activeBadge, width: Number(e.target.value) })} + className="w-full accent-[#e2001a]" + /> +
+ + {/* Zarovnanie na karte produktu */} +
+ +
+ + + +
+
+ + {/* Odsadenie (Padding) */} +
+
+ + setActiveBadge({ ...activeBadge, paddingTop: Number(e.target.value) })} + className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium" + /> +
+
+ + setActiveBadge({ ...activeBadge, paddingBottom: Number(e.target.value) })} + className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium" + /> +
+
+
+ {/* Upload vlastného SVG podkladu */} -
+
= ({ onChange={handleSvgUpload} className="text-xs text-gray-500 file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-gray-200 file:text-gray-700 hover:file:bg-gray-300 cursor-pointer w-full" /> -

- Nahrajte ľubovoľný tvar: hviezdu, bublinu, štítok alebo percento. -

- {/* Rýchle pridanie premenných ako vrstvy */} + {/* Rýchle pridanie vrstiev */}
- Pridať vrstvu s premennou + Pridať vrstvu
+
- {/* Zoznam vrstiev */} + {/* Zoznam a nastavenie vrstiev */}
Vrstvy ({activeBadge.layersConfig.length}) @@ -653,7 +894,6 @@ export const BadgeEditorModal: React.FC = ({ ))}
- {/* Nastavenie aktívnej vrstvy */} {activeLayer && (
@@ -692,19 +932,16 @@ export const BadgeEditorModal: React.FC = ({
- {/* Font nastavenie */} + {/* Font výber */}
- - {/* Zdroj písma */} +
- {/* Google Fonts voľba */} {(activeLayer.fontSource || 'google') === 'google' && (
-
- - f.name === (activeLayer.fontFamily || 'Poppins')) + ? activeLayer.fontFamily || 'Poppins' + : 'custom' + } + onChange={(e) => { + if (e.target.value !== 'custom') { + loadFontDynamically(e.target.value, 'google'); + updateActiveLayer({ fontFamily: e.target.value, fontSource: 'google' }); } - onChange={(e) => { - if (e.target.value !== 'custom') { - loadFontDynamically(e.target.value, 'google'); - updateActiveLayer({ fontFamily: e.target.value, fontSource: 'google' }); - } - }} - className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium" - > - {POPULAR_FONTS.filter((f) => f.source === 'google').map((f) => ( - - ))} - - -
+ }} + className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium" + > + {POPULAR_FONTS.filter((f) => f.source === 'google').map((f) => ( + + ))} + + {(!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" - /> -
+ { + 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' && ( -
- - { + 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" - > - - - - - - - + />
)} - {/* Hrúbka fontu */} + {activeLayer.fontSource === 'system' && ( + + )} +
{ + const val = e.target.value; + const products = [...module.data.products]; + if (val === 'default_coop') { + products[pIdx].badgeCustomTemplate = undefined; + products[pIdx].customBadgePngUrl = undefined; + products[pIdx].badgeId = undefined; + } else { + const found = (availableBadges || []).find((b) => b.id === val); + if (found) { + products[pIdx].badgeCustomTemplate = found; + products[pIdx].badgeId = found.id; + products[pIdx].badgeWidth = found.width ?? products[pIdx].badgeWidth ?? 110; + products[pIdx].badgeAlign = found.align ?? products[pIdx].badgeAlign ?? 'center'; + products[pIdx].badgePaddingTop = found.paddingTop ?? products[pIdx].badgePaddingTop ?? 4; + products[pIdx].badgePaddingBottom = found.paddingBottom ?? products[pIdx].badgePaddingBottom ?? 4; + products[pIdx].customBadgePngUrl = undefined; + } + } + updateData({ products }); + }} + className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium" + > + + {(availableBadges || []).map((b) => ( + + ))} + +
+ + {/* Tlačidlo: Aplikovať túto cenovku na všetky produkty v sekcii */} + {p.badgeCustomTemplate && ( + + )} + + {/* Rýchle ovládanie veľkosti (Width), zarovnania a paddingu */} +
+ {/* Šírka */} +
+
+ Veľkosť cenovky (Šírka) + + {p.badgeWidth || (p.badgeCustomTemplate ? 110 : 95)} px + +
+ { + const products = [...module.data.products]; + products[pIdx].badgeWidth = Number(e.target.value); + updateData({ products }); + }} + className="w-full accent-red-600" + /> +
+ + {/* Zarovnanie */} +
+ Zarovnanie +
+ + + +
+
+ + {/* Padding zhora a zdola */} +
+
+ + { + const products = [...module.data.products]; + products[pIdx].badgePaddingTop = Number(e.target.value); + updateData({ products }); + }} + className="w-full text-xs border border-gray-300 rounded-lg p-1 bg-white font-medium" + /> +
+
+ + { + const products = [...module.data.products]; + products[pIdx].badgePaddingBottom = Number(e.target.value); + updateData({ products }); + }} + className="w-full text-xs border border-gray-300 rounded-lg p-1 bg-white font-medium" + /> +
+
-
))} diff --git a/src/lib/badgeUtils.ts b/src/lib/badgeUtils.ts new file mode 100644 index 0000000..7e4ca82 --- /dev/null +++ b/src/lib/badgeUtils.ts @@ -0,0 +1,324 @@ +import { BadgeTemplate, BadgeLayer } from '@/types/newsletter'; + +export const DEFAULT_BADGE_TEMPLATES: BadgeTemplate[] = [ + { + id: 'badge-preset-yellow-akcia', + name: 'COOP Akcia (Žltá)', + svgRaw: ` + + + `, + width: 110, + align: 'center', + paddingTop: 4, + paddingBottom: 4, + layersConfig: [ + { + id: 'l-akcia-1', + type: 'text', + text: 'AKCIA', + x: 70, + y: 20, + fontSize: 14, + fontWeight: '900', + fontFamily: 'Poppins', + fill: '#ffffff', + align: 'center' + }, + { + id: 'l-akcia-2', + type: 'text', + text: '{cena}', + x: 70, + y: 62, + fontSize: 42, + fontWeight: '900', + fontFamily: 'Arial Narrow', + fill: '#001a33', + align: 'center' + } + ] + }, + { + id: 'badge-preset-red-zlava', + name: 'COOP Zľava (Červená)', + svgRaw: ` + + + `, + width: 110, + align: 'center', + paddingTop: 4, + paddingBottom: 4, + layersConfig: [ + { + id: 'l-zlava-1', + type: 'text', + text: 'ZĽAVA -{zlava}', + x: 70, + y: 19, + fontSize: 13, + fontWeight: '900', + fontFamily: 'Poppins', + fill: '#ffffff', + align: 'center' + }, + { + id: 'l-zlava-2', + type: 'text', + text: '{cena}', + x: 70, + y: 62, + fontSize: 42, + fontWeight: '900', + fontFamily: 'Arial Narrow', + fill: '#ffd100', + align: 'center' + } + ] + }, + { + id: 'badge-preset-blue-supercena', + name: 'Super Cena (Tmavomodrá)', + svgRaw: ` + + + `, + width: 110, + align: 'center', + paddingTop: 4, + paddingBottom: 4, + layersConfig: [ + { + id: 'l-super-1', + type: 'text', + text: 'SUPER CENA', + x: 70, + y: 16, + fontSize: 13, + fontWeight: '900', + fontFamily: 'Poppins', + fill: '#ffffff', + align: 'center' + }, + { + id: 'l-super-2', + type: 'text', + text: '{cena}', + x: 70, + y: 62, + fontSize: 42, + fontWeight: '900', + fontFamily: 'Arial Narrow', + fill: '#ffd100', + align: 'center' + } + ] + }, + { + id: 'badge-preset-green-cerstve', + name: 'Bio & Čerstvé (Zelená)', + svgRaw: ` + + + `, + width: 110, + align: 'center', + paddingTop: 4, + paddingBottom: 4, + layersConfig: [ + { + id: 'l-green-1', + type: 'text', + text: 'ČERSTVÉ', + x: 70, + y: 19, + fontSize: 13, + fontWeight: '900', + fontFamily: 'Poppins', + fill: '#a3e635', + align: 'center' + }, + { + id: 'l-green-2', + type: 'text', + text: '{cena}', + x: 70, + y: 62, + fontSize: 42, + fontWeight: '900', + fontFamily: 'Arial Narrow', + fill: '#ffffff', + align: 'center' + } + ] + } +]; + +export interface BadgeProductData { + priceMain: string; + priceSub: string; + discountText?: string; + unitPrice?: string; +} + +/** + * Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek + * editovacích rámčekov, tooltippov či ovládacích prvkov. + * Centy sú naformátované ako horný index (tspan dy="-0.45em"). + * Cena je predvolene bez znaku €. + */ +export function renderCleanBadgeSvg( + template: BadgeTemplate, + product: BadgeProductData, + overrideWidth?: number +): string { + const pMain = product.priceMain || '0'; + const pSub = product.priceSub || '00'; + const discount = (product.discountText || '').replace('-', ''); + const unitPrice = product.unitPrice || ''; + + // Získanie viewBox alebo predvolených rozmerov + let vb = '0 0 140 95'; + let vbWidth = 140; + let vbHeight = 95; + + if (template.svgRaw) { + const vbMatch = template.svgRaw.match(/viewBox=["']([^"']+)["']/i); + if (vbMatch) { + vb = vbMatch[1]; + const parts = vb.trim().split(/\s+/).map(Number); + if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) { + vbWidth = parts[2]; + vbHeight = parts[3]; + } + } else { + const wMatch = template.svgRaw.match(/width=["']([^"']+)["']/i); + const hMatch = template.svgRaw.match(/height=["']([^"']+)["']/i); + if (wMatch && hMatch) { + const w = parseFloat(wMatch[1]); + const h = parseFloat(hMatch[1]); + if (w > 0 && h > 0) { + vbWidth = w; + vbHeight = h; + vb = `0 0 ${w} ${h}`; + } + } + } + } + + // Extrahovanie vnútorného obsahu SVG (medzi a ) + let innerBg = ''; + if (template.svgRaw) { + const inside = template.svgRaw.replace(/<\/?svg[^>]*>/gi, '').trim(); + innerBg = inside; + } else { + innerBg = ``; + } + + // Šírka výsledného SVG + const displayWidth = overrideWidth || template.width || 110; + const displayHeight = Math.round((displayWidth / vbWidth) * vbHeight); + + // Zostavenie textových vrstiev + const textLayersSvg = (template.layersConfig || []).map((layer) => { + const rawText = layer.text || ''; + const fontSize = layer.fontSize || 16; + const subFontSize = Math.max(10, Math.round(fontSize * 0.55)); + const fontWeight = layer.fontWeight || '700'; + const fill = layer.fill || '#ffffff'; + const fontFamily = layer.fontFamily + ? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, Arial, sans-serif` + : `'Arial Narrow', Arial, sans-serif`; + + let anchor = 'middle'; + if (layer.align === 'left') anchor = 'start'; + if (layer.align === 'right') anchor = 'end'; + + // Formátovanie textu: ak obsahuje {cena}, centy vložíme do ako horný index + let contentSvg = ''; + if (rawText.includes('{cena}')) { + const parts = rawText.split('{cena}'); + const before = escapeXml(parts[0] || ''); + const after = escapeXml(parts[1] || ''); + contentSvg = `${before}${escapeXml(pMain)}${escapeXml(pSub)}${after}`; + } else { + let replaced = rawText + .replace(/{cela_suma}/g, pMain) + .replace(/{centy}/g, pSub) + .replace(/{zlava}/g, discount) + .replace(/{jednotkova_cena}/g, unitPrice) + .replace(/{mena}/g, '€'); + contentSvg = escapeXml(replaced); + } + + return `${contentSvg}`; + }).join('\n '); + + return ` + ${innerBg} + ${textLayersSvg} + `; +} + +/** + * Skonvertuje SVG reťazec do čistého PNG Data URL v prehliadači + * (bez akýchkoľvek DOM prvkov alebo modrých drag & drop rámčekov). + */ +export async function svgToCleanPng( + svgString: string, + width: number, + height: number +): Promise { + if (typeof window === 'undefined') { + return `data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`; + } + + return new Promise((resolve) => { + try { + const img = new Image(); + const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(svgBlob); + + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + const scale = 2; // Vysoké rozlíšenie Retina + canvas.width = width * scale; + canvas.height = height * scale; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0, width, height); + URL.revokeObjectURL(url); + resolve(canvas.toDataURL('image/png')); + return; + } + } catch (e) { + console.warn('Canvas export fallback to SVG data URL', e); + } + URL.revokeObjectURL(url); + resolve(`data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + resolve(`data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`); + }; + + img.src = url; + } catch (err) { + resolve(`data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`); + } + }); +} + +function escapeXml(unsafe: string): string { + if (!unsafe) return ''; + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/lib/emailRenderer.ts b/src/lib/emailRenderer.ts index d28665a..d42d2ad 100644 --- a/src/lib/emailRenderer.ts +++ b/src/lib/emailRenderer.ts @@ -1,5 +1,6 @@ import { NewsletterCampaign, ProductItem } from '@/types/newsletter'; import { SUPABASE_ASSETS_BASE } from './defaultCampaign'; +import { renderCleanBadgeSvg } from './badgeUtils'; export function renderEmailHtml(campaign: NewsletterCampaign): string { const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n'); @@ -620,46 +621,86 @@ function renderExactProductCard( - - ${p.customBadgePngUrl ? ` - - - - -
- Cenovka -
` : ` - - - - - ${p.showSlovakFlag ? ` - - - ` : ''} -
- - ${badgeCustomTxt ? ` - - - ` : (p.discountText ? ` - - - ` : '')} - - - -
- ${escapeHtml(badgeCustomTxt)} -
- ${escapeHtml(p.badgeText || 'zľava')} - ${escapeHtml(p.discountText)} -
- ${escapeHtml(p.priceMain)}${escapeHtml(p.priceSub)} -
-
- SK -
`} + ${(() => { + const bWidth = p.badgeWidth || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.width || 110) : 95); + const bAlign = p.badgeAlign || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.align || 'center') : 'center'); + const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate?.paddingTop ?? 0); + const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate?.paddingBottom ?? 0); + + let marginStyle = 'margin: 0 auto;'; + if (bAlign === 'left') { + marginStyle = 'margin: 0 auto 0 0; margin-right: auto; margin-left: 0;'; + } else if (bAlign === 'right') { + marginStyle = 'margin: 0 0 0 auto; margin-left: auto; margin-right: 0;'; + } + + // 1. Ak produkt má vlastnú dynamickú šablónu cenovky (čistý SVG vektor bez editovacích prvkov a so živou cenou) + if (p.badgeCustomTemplate) { + return ` + + + + + ${p.showSlovakFlag ? ` + + + ` : ''} +
+ ${renderCleanBadgeSvg(p.badgeCustomTemplate, p, bWidth)} +
+ SK +
`; + } + + // 2. Ak je nahraná statická PNG cenovka + if (p.customBadgePngUrl) { + return ` + + + + +
+ Cenovka +
`; + } + + // 3. Predvolená COOP cenovka s plnou podporou šírky, zarovnania a odsadenia + const mainFontSize = Math.max(28, Math.round(bWidth * 0.54)); + const subFontSize = Math.max(14, Math.round(bWidth * 0.27)); + + return ` + + + + + ${p.showSlovakFlag ? ` + + + ` : ''} +
+ + ${badgeCustomTxt ? ` + + + ` : (p.discountText ? ` + + + ` : '')} + + + +
+ ${escapeHtml(badgeCustomTxt)} +
+ ${escapeHtml(p.badgeText || 'zľava')} + ${escapeHtml(p.discountText)} +
+ ${escapeHtml(p.priceMain)}${escapeHtml(p.priceSub)} +
+
+ SK +
`; + })()} ${p.unitPrice ? `
${escapeHtml(p.unitPrice)}
` : ''} diff --git a/src/types/newsletter.ts b/src/types/newsletter.ts index 8748abc..74a209c 100644 --- a/src/types/newsletter.ts +++ b/src/types/newsletter.ts @@ -26,6 +26,11 @@ export interface ProductItem { discountText: string; // napr. "-23%" alebo "23%" badgeText: string; // napr. "AKCIA" alebo "SUPER CENA" badgeId?: string; // priradená cenovka z databázy + badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny) + badgeWidth?: number; // Nastaviteľná šírka/veľkosť cenovky v px (napr. 70 až 180) + badgeAlign?: 'left' | 'center' | 'right'; // Zarovnanie cenovky (vľavo, stred, vpravo) + badgePaddingTop?: number; // Odsadenie zhora v px + badgePaddingBottom?: number; // Odsadenie zdola v px validityOverride?: string; // lokálny override napr. "2. 5. – 15. 5." stores: { potraviny: boolean; @@ -34,7 +39,7 @@ export interface ProductItem { }; cardBgColor?: string; // HEX napr. "#feeaea" (fantasticky), "#fef0c7" (domace), "#dcb085" (grill) showSlovakFlag?: boolean; - customBadgePngUrl?: string; // Vyrenderovaná vlastná PNG cenovka z editora + customBadgePngUrl?: string; // Voliteľná statická PNG cenovka } export interface AnnouncementModuleData { @@ -160,4 +165,8 @@ export interface BadgeTemplate { svgRaw?: string; layersConfig: BadgeLayer[]; previewImageUrl?: string; + width?: number; // Šírka cenovky v px (napr. 70 až 180) + align?: 'left' | 'center' | 'right'; // Zarovnanie (vľavo, stred, vpravo) + paddingTop?: number; + paddingBottom?: number; }