Fix canvas background to exact COOP gradient, remove module borders/gaps, add interactive Badge Editor SVG to PNG

This commit is contained in:
Jakub_Hostacny committed 2026-09-24 11:15:54 +02:00
1 parent 65f37a74b8
commit ab74922b93
5 files changed
+537 -99

No files matched your search

+379
View File
@@ -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<BadgeEditorModalProps> = ({
isOpen,
onClose,
onSelectBadge,
currentDiscount = '25%',
currentPriceMain = '1',
currentPriceSub = '99'
}) => {
const [badges, setBadges] = useState<BadgeTemplate[]>([
{
id: 'badge-akcia',
name: 'Klasik Žltá AKCIA',
svgRaw: `<svg width="140" height="90" viewBox="0 0 140 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="140" height="90" rx="12" fill="#ffd100"/>
<rect x="4" y="4" width="132" height="82" rx="10" stroke="#e0001b" stroke-width="3" stroke-dasharray="4 2"/>
</svg>`,
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: `<svg width="140" height="90" viewBox="0 0 140 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="140" height="90" rx="14" fill="#e2001a"/>
<circle cx="20" cy="20" r="10" fill="#ffde00" opacity="0.3"/>
<circle cx="120" cy="70" r="15" fill="#ffde00" opacity="0.2"/>
</svg>`,
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: `<svg width="140" height="110" viewBox="0 0 140 110" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M70 5L85 38L120 28L105 60L135 80L98 88L90 120L70 95L50 120L42 88L5 80L35 60L20 28L55 38L70 5Z" fill="#ffde00" stroke="#e2001a" stroke-width="4"/>
</svg>`,
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: `<svg width="110" height="110" viewBox="0 0 110 110" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="55" cy="55" r="50" fill="#e2001a" stroke="#ffd100" stroke-width="5"/>
</svg>`,
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<BadgeTemplate>(badges[0]);
const [selectedLayerId, setSelectedLayerId] = useState<string>('2');
const previewRef = useRef<HTMLDivElement>(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<BadgeLayer>) => {
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 (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-md flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-4xl w-full max-h-[92vh] overflow-hidden flex flex-col border border-gray-100">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-slate-50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-red-600 flex items-center justify-center text-white">
<Tag className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-gray-900">
Editor grafických cenoviek (SVG &rarr; PNG Pipeline)
</h2>
<p className="text-xs text-gray-500">
Upravte texty, premenné a pozície. Pri uložení sa cenovka automaticky vyrenderuje do orezaného PNG pre e-mail.
</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 text-gray-400 hover:text-gray-700 rounded-xl hover:bg-gray-200/50">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden grid grid-cols-12 divide-x divide-gray-100">
{/* Ľavý stĺpec: Šablóny */}
<div className="col-span-3 p-4 overflow-y-auto space-y-3 bg-gray-50/50">
<span className="text-[11px] font-bold uppercase tracking-wider text-gray-400 block px-1">
Katalóg cenoviek
</span>
<div className="space-y-2">
{badges.map((b) => (
<button
key={b.id}
onClick={() => setActiveBadge(b)}
className={`w-full text-left p-3 rounded-2xl border transition-all ${
activeBadge.id === b.id
? 'bg-white border-red-500 shadow-md ring-2 ring-red-500/10'
: 'bg-white/80 border-gray-200 hover:border-gray-300'
}`}
>
<p className="text-xs font-bold text-gray-800 truncate mb-1">{b.name}</p>
<div
className="w-full h-14 flex items-center justify-center transform scale-75 origin-center overflow-hidden"
dangerouslySetInnerHTML={{ __html: b.svgRaw || '' }}
/>
</button>
))}
</div>
</div>
{/* Stredný stĺpec: Interaktívny Canvas / Náhľad */}
<div className="col-span-5 p-6 flex flex-col items-center justify-center bg-slate-900/95 relative">
<span className="absolute top-4 left-4 text-xs font-semibold text-slate-400 flex items-center gap-1.5">
<Eye className="w-3.5 h-3.5" /> Živý vektorový náhľad
</span>
{/* Plátno cenovky */}
<div
ref={previewRef}
className="relative flex items-center justify-center p-8 select-none"
style={{ minWidth: 200, minHeight: 160 }}
>
<div
className="relative"
dangerouslySetInnerHTML={{ __html: activeBadge.svgRaw || '' }}
/>
{/* Textové vrstvy nad SVG podkladom */}
{activeBadge.layersConfig.map((layer) => {
const isSelected = layer.id === selectedLayerId;
return (
<div
key={layer.id}
onClick={() => 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 || '')}
</div>
);
})}
</div>
<p className="text-[11px] text-slate-400 mt-4 text-center">
Dostupné premenné: <code className="text-amber-300 font-mono">{`{zlava}`}</code> (hodnota: {currentDiscount}), <code className="text-amber-300 font-mono">{`{cena}`}</code>
</p>
</div>
{/* Pravý stĺpec: Konfigurácia vrstiev */}
<div className="col-span-4 p-5 overflow-y-auto space-y-5 bg-white">
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<span className="text-xs font-bold text-gray-800 uppercase tracking-wider">
Vrstvy textov ({activeBadge.layersConfig.length})
</span>
<button
onClick={addTextLayer}
className="text-xs font-bold text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-2.5 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Pridať text
</button>
</div>
{/* Zoznam vrstiev */}
<div className="flex gap-1.5 overflow-x-auto pb-1">
{activeBadge.layersConfig.map((l, idx) => (
<button
key={l.id}
onClick={() => setSelectedLayerId(l.id)}
className={`text-xs px-3 py-1.5 rounded-lg font-bold border transition-all whitespace-nowrap ${
selectedLayerId === l.id
? 'bg-slate-900 text-white border-slate-900'
: 'bg-gray-100 text-gray-600 border-gray-200'
}`}
>
Vrstva {idx + 1}
</button>
))}
</div>
{/* Nastavenie aktívnej vrstvy */}
{activeLayer && (
<div className="space-y-4 pt-1">
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">
Text vrstvy (podporuje premennú {'{zlava}'})
</label>
<input
type="text"
value={activeLayer.text || ''}
onChange={(e) => updateActiveLayer({ text: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-xl p-2.5 font-medium"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Veľkosť písma (px)</label>
<input
type="number"
value={activeLayer.fontSize || 16}
onChange={(e) => updateActiveLayer({ fontSize: Number(e.target.value) })}
className="w-full text-sm border border-gray-300 rounded-xl p-2"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Farba písma</label>
<div className="flex items-center gap-2">
<input
type="color"
value={activeLayer.fill || '#ffffff'}
onChange={(e) => updateActiveLayer({ fill: e.target.value })}
className="w-8 h-8 rounded-lg cursor-pointer border-0"
/>
<span className="text-xs font-mono">{activeLayer.fill}</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Pozícia X (px)</label>
<input
type="range"
min="10"
max="130"
value={activeLayer.x}
onChange={(e) => updateActiveLayer({ x: Number(e.target.value) })}
className="w-full"
/>
<span className="text-[10px] text-gray-400">{activeLayer.x} px</span>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Pozícia Y (px)</label>
<input
type="range"
min="10"
max="100"
value={activeLayer.y}
onChange={(e) => updateActiveLayer({ y: Number(e.target.value) })}
className="w-full"
/>
<span className="text-[10px] text-gray-400">{activeLayer.y} px</span>
</div>
</div>
<div className="pt-2">
<button
onClick={() => removeLayer(activeLayer.id)}
className="text-xs text-red-600 hover:text-red-700 font-semibold flex items-center gap-1"
>
<Trash2 className="w-3.5 h-3.5" /> Odstrániť túto vrstvu
</button>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex items-center justify-between">
<button onClick={onClose} className="px-4 py-2 text-sm font-semibold text-gray-600 hover:text-gray-900">
Zrušiť
</button>
<button
onClick={handleApplyBadge}
disabled={isExportingPng}
className="px-6 py-2.5 text-sm font-bold bg-[#e2001a] text-white rounded-xl hover:bg-red-700 transition-colors shadow-md flex items-center gap-2"
>
<Check className="w-4 h-4" />
{isExportingPng ? 'Generujem PNG cenovku...' : 'Aplikovať cenovku na produkt'}
</button>
</div>
</div>
</div>
);
};
+14
View File
@@ -387,6 +387,20 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
Zobraziť slovenskú vlajku / srdce
</label>
</div>
{/* Grafická cenovka - Editor cenoviek */}
<div className="pt-2 border-t border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5 text-red-600" />
<span className="text-xs font-semibold text-gray-700">Grafická cenovka</span>
</div>
<button
onClick={() => onOpenBadgeManager && onOpenBadgeManager(p.id)}
className="text-xs font-bold text-red-600 hover:text-white hover:bg-red-600 border border-red-200 px-2.5 py-1 rounded-lg transition-all"
>
{p.customBadgePngUrl ? 'Upraviť v editore' : 'Otvoriť Editor cenoviek'}
</button>
</div>
</div>
))}
</div>