Files
Coop-NL/src/components/editor/BadgeEditorModal.tsx
T
Jakub_HostacnyandClaude Opus 5.5 23ac4dc830 feat: duplikovanie sablon cenoviek v editore (uloha 11)
Ikona Duplikovat pri sablone vytvori kopiu "(kopia)", hned ju ulozi
a otvori na upravu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:23:33 +02:00

1398 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
import { supabase } from '@/lib/supabase';
import { DEFAULT_BADGE_TEMPLATES, renderCleanBadgeSvg, svgToCleanPng } from '@/lib/badgeUtils';
import { Tag, Plus, Check, Trash2, X, Upload, Eye, Move, AlignLeft, AlignCenter, AlignRight, ZoomIn, ZoomOut, Copy } from 'lucide-react';
// Riadok tabuľky badges v Supabase pre danú šablónu
function toBadgeDbPayload(badge: BadgeTemplate) {
return {
name: badge.name,
svg_raw: badge.svgRaw,
layers_config: {
layers: badge.layersConfig,
width: badge.width || 110,
align: badge.align || 'center',
paddingTop: badge.paddingTop ?? 4,
paddingBottom: badge.paddingBottom ?? 4
}
};
}
// Priblíženie plátna v editore: rozsah posuvníka a kroky tlačidiel +/−
const CANVAS_ZOOM_MIN = 0.5;
const CANVAS_ZOOM_MAX = 4;
const CANVAS_ZOOM_STEPS = [0.5, 0.75, 1, 1.5, 2, 3, 4];
interface BadgeEditorModalProps {
isOpen: boolean;
onClose: () => void;
onSelectBadge: (badge: BadgeTemplate, renderedPngUrl?: string) => void;
currentDiscount?: string;
currentPriceMain?: string;
currentPriceSub?: string;
currentUnitPrice?: string;
}
export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
isOpen,
onClose,
onSelectBadge,
currentDiscount = '25%',
currentPriceMain = '1',
currentPriceSub = '99',
currentUnitPrice = '1kg = 4,91 €'
}) => {
const POPULAR_FONTS = [
{ name: 'Poppins', source: 'google' as const },
{ name: 'Montserrat', source: 'google' as const },
{ name: 'Oswald', source: 'google' as const },
{ name: 'Bebas Neue', source: 'google' as const },
{ name: 'Anton', source: 'google' as const },
{ name: 'Roboto Condensed', source: 'google' as const },
{ name: 'Barlow Semi Condensed', source: 'google' as const },
{ name: 'Inter', source: 'google' as const },
{ name: 'Open Sans', source: 'google' as const },
{ name: 'Arial Narrow', source: 'system' as const },
{ name: 'Impact', source: 'system' as const }
];
const loadFontDynamically = (fontFamily: string, source: 'system' | 'google' | 'adobe' = 'google', url?: string) => {
if (typeof document === 'undefined') return;
if (source === 'adobe' && url) {
const existing = document.querySelector(`link[data-adobe-font="${url}"]`);
if (!existing) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.setAttribute('data-adobe-font', url);
document.head.appendChild(link);
}
} else if (source === 'google' && fontFamily) {
const clean = fontFamily.trim().replace(/\s+/g, '+');
const href = `https://fonts.googleapis.com/css2?family=${clean}:wght@400;600;700;800;900&display=swap`;
const existing = document.querySelector(`link[data-google-font="${clean}"]`);
if (!existing) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.setAttribute('data-google-font', clean);
document.head.appendChild(link);
}
}
};
const [badges, setBadges] = useState<BadgeTemplate[]>([]);
const [activeBadge, setActiveBadge] = useState<BadgeTemplate | null>(null);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [isExportingPng, setIsExportingPng] = useState(false);
const [isSavingDb, setIsSavingDb] = useState(false);
// Priblíženie plátna (len pre editor, neovplyvňuje výslednú cenovku)
const [canvasZoom, setCanvasZoom] = useState(1);
const canvasAreaRef = useRef<HTMLDivElement>(null);
const hasActiveBadge = !!activeBadge;
const zoomIn = () =>
setCanvasZoom((z) => CANVAS_ZOOM_STEPS.find((s) => s > z) ?? z);
const zoomOut = () =>
setCanvasZoom((z) => [...CANVAS_ZOOM_STEPS].reverse().find((s) => s < z) ?? z);
// Ctrl/Cmd + koliesko myši na plátne = priblíženie / oddialenie
useEffect(() => {
const el = canvasAreaRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setCanvasZoom((z) =>
e.deltaY < 0
? CANVAS_ZOOM_STEPS.find((s) => s > z) ?? z
: [...CANVAS_ZOOM_STEPS].reverse().find((s) => s < z) ?? z
);
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [isOpen, hasActiveBadge]);
// Stav pre priamy Drag & Drop vrstiev na plátne
const [dragState, setDragState] = useState<{
layerId: string;
startX: number;
startY: number;
layerStartX: number;
layerStartY: number;
} | null>(null);
const handleLayerMouseDown = (e: React.MouseEvent, layer: BadgeLayer) => {
e.stopPropagation();
e.preventDefault();
setSelectedLayerId(layer.id);
setDragState({
layerId: layer.id,
startX: e.clientX,
startY: e.clientY,
layerStartX: layer.x,
layerStartY: layer.y
});
};
const handleLayerTouchStart = (e: React.TouchEvent, layer: BadgeLayer) => {
e.stopPropagation();
const touch = e.touches[0];
if (!touch) return;
setSelectedLayerId(layer.id);
setDragState({
layerId: layer.id,
startX: touch.clientX,
startY: touch.clientY,
layerStartX: layer.x,
layerStartY: layer.y
});
};
useEffect(() => {
if (!dragState) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = (e.clientX - dragState.startX) / canvasZoom;
const deltaY = (e.clientY - dragState.startY) / canvasZoom;
const newX = Math.round(dragState.layerStartX + deltaX);
const newY = Math.round(dragState.layerStartY + deltaY);
setActiveBadge((prev) => {
if (!prev) return prev;
return {
...prev,
layersConfig: prev.layersConfig.map((l) =>
l.id === dragState.layerId ? { ...l, x: newX, y: newY } : l
)
};
});
};
const handleTouchMove = (e: TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
const deltaX = (touch.clientX - dragState.startX) / canvasZoom;
const deltaY = (touch.clientY - dragState.startY) / canvasZoom;
const newX = Math.round(dragState.layerStartX + deltaX);
const newY = Math.round(dragState.layerStartY + deltaY);
setActiveBadge((prev) => {
if (!prev) return prev;
return {
...prev,
layersConfig: prev.layersConfig.map((l) =>
l.id === dragState.layerId ? { ...l, x: newX, y: newY } : l
)
};
});
};
const handleDragEnd = () => {
setDragState(null);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleDragEnd);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleDragEnd);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleDragEnd);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleDragEnd);
};
}, [dragState, canvasZoom]);
// Načítanie písiem pre všetky vrstvy aktívnej cenovky
useEffect(() => {
if (activeBadge?.layersConfig) {
activeBadge.layersConfig.forEach((l) => {
if (l.fontFamily) {
loadFontDynamically(l.fontFamily, l.fontSource || 'google', l.fontUrl);
}
});
}
}, [activeBadge]);
// Načítanie existujúcich šablón zo Supabase + LocalStorage + Default Presets
useEffect(() => {
if (isOpen) {
loadBadgesFromSupabase();
}
}, [isOpen]);
const loadBadgesFromSupabase = 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 (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) {}
setBadges(loaded);
if (!activeBadge && loaded.length > 0) {
setActiveBadge(loaded[0]);
if (loaded[0].layersConfig?.[0]) {
setSelectedLayerId(loaded[0].layersConfig[0].id);
}
}
};
if (!isOpen) return null;
// Vytvorenie novej šablóny
const handleCreateNewTemplate = () => {
const newBadge: BadgeTemplate = {
id: `badge-${Date.now()}`,
name: 'Nová vlastná cenovka',
svgRaw: `<svg width="140" height="95" viewBox="0 0 140 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="140" height="95" rx="14" fill="#ffd100"/>
</svg>`,
width: 110,
align: 'center',
paddingTop: 4,
paddingBottom: 4,
layersConfig: [
{
id: `l-${Date.now()}-1`,
type: 'text',
text: 'AKCIA',
x: 70,
y: 24,
fontSize: 16,
fontWeight: '900',
fill: '#e2001a',
align: 'center'
},
{
id: `l-${Date.now()}-2`,
type: 'text',
text: '{cena}',
x: 70,
y: 62,
fontSize: 42,
fontWeight: '900',
fill: '#001a33',
align: 'center'
}
]
};
setBadges([newBadge, ...badges]);
setActiveBadge(newBadge);
setSelectedLayerId(newBadge.layersConfig[0].id);
};
// Upload vlastného SVG alebo PNG súboru ako podkladu
const handleBackgroundUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !activeBadge) return;
if (file.type.includes('svg') || file.name.toLowerCase().endsWith('.svg')) {
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
if (content.includes('<svg')) {
setActiveBadge({
...activeBadge,
svgRaw: content
});
} else {
alert('Nahraný súbor nie je platný formát SVG.');
}
};
reader.readAsText(file);
} else if (
file.type.includes('png') ||
file.type.includes('image/') ||
file.name.toLowerCase().endsWith('.png') ||
file.name.toLowerCase().endsWith('.jpg') ||
file.name.toLowerCase().endsWith('.jpeg') ||
file.name.toLowerCase().endsWith('.webp')
) {
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
const img = new Image();
img.onload = () => {
const w = img.naturalWidth || 140;
const h = img.naturalHeight || 95;
const svgContent = `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image href="${dataUrl}" xlink:href="${dataUrl}" width="${w}" height="${h}" preserveAspectRatio="xMidYMid meet"/></svg>`;
setActiveBadge({
...activeBadge,
svgRaw: svgContent
});
};
img.src = dataUrl;
};
reader.readAsDataURL(file);
} else {
alert('Podporované sú len grafické súbory vo formáte SVG alebo PNG.');
}
};
const activeLayer =
activeBadge?.layersConfig?.find((l) => l.id === selectedLayerId) ||
activeBadge?.layersConfig?.[0] ||
null;
const updateActiveLayer = (updates: Partial<BadgeLayer>) => {
if (!activeBadge || !activeLayer) return;
const updatedLayers = activeBadge.layersConfig.map((l) =>
l.id === activeLayer.id ? { ...l, ...updates } : l
);
setActiveBadge({ ...activeBadge, layersConfig: updatedLayers });
};
const addTextLayer = (presetText: string = 'Text') => {
if (!activeBadge) return;
const newL: BadgeLayer = {
id: `l-${Date.now()}`,
type: 'text',
text: presetText,
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 || activeBadge.layersConfig.length <= 1) return;
setActiveBadge({
...activeBadge,
layersConfig: activeBadge.layersConfig.filter((l) => l.id !== id)
});
};
// Duplikovanie šablóny: kópia sa hneď uloží (Supabase, inak localStorage) a otvorí na úpravu
const duplicateBadgeTemplate = async (badge: BadgeTemplate) => {
const copy: BadgeTemplate = {
...JSON.parse(JSON.stringify(badge)),
id: `badge-${Date.now()}`,
name: `${badge.name} (kópia)`
};
let saved = copy;
try {
const { data, error } = await supabase
.from('badges')
.insert([toBadgeDbPayload(copy)])
.select();
if (!error && data && data[0]) {
saved = { ...copy, id: data[0].id };
}
} catch (e) {}
const list = [...badges];
const idx = list.findIndex((b) => b.id === badge.id);
list.splice(idx + 1, 0, saved);
setBadges(list);
setActiveBadge(saved);
setSelectedLayerId(saved.layersConfig?.[0]?.id ?? null);
try {
localStorage.setItem('coop_custom_badges', JSON.stringify(list));
} catch (e) {}
};
const deleteBadgeTemplate = async (badgeId: string) => {
if (confirm('Naozaj chcete zmazať túto šablónu cenovky?')) {
// Predvolené šablóny existujú len v prehliadači (localStorage), v databáze nie sú
if (!badgeId.startsWith('badge-preset-')) {
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) {}
}
};
// Uloženie šablóny do Supabase + LocalStorage (garantované uchovanie)
const handleSaveToSupabase = async () => {
if (!activeBadge) return;
setIsSavingDb(true);
try {
const payload = toBadgeDbPayload(activeBadge);
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) {
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);
}
};
// Vykreslenie vrstvy v React náhľade s centami ako horný index (sup) a BEZ znaku €
const renderLayerVisualContent = (layer: BadgeLayer) => {
const raw = layer.text || '';
const baseFontSize = layer.fontSize || 16;
const subFontSize = layer.indexFontSize ? `${layer.indexFontSize}px` : `${Math.max(10, Math.round(baseFontSize * 0.55))}px`;
const subY = layer.indexOffsetY !== undefined ? `${layer.indexOffsetY}px` : '-10px';
const subX = layer.indexOffsetX !== undefined ? `${layer.indexOffsetX}px` : '2px';
if (raw.includes('{cena}')) {
const parts = raw.split('{cena}');
return (
<span>
{parts[0]}
<span>{currentPriceMain}</span>
<span
style={{
display: 'inline-block',
fontSize: subFontSize,
transform: `translate(${subX}, ${subY})`,
fontWeight: layer.fontWeight || '700',
lineHeight: 1
}}
>
{currentPriceSub}
</span>
{parts[1]}
</span>
);
}
if (raw.includes('{centy}')) {
const parts = raw.split('{centy}');
return (
<span>
{parts[0]}
<span
style={{
display: 'inline-block',
fontSize: subFontSize,
transform: `translate(${subX}, ${subY})`,
fontWeight: layer.fontWeight || '700',
lineHeight: 1
}}
>
{currentPriceSub}
</span>
{parts[1]}
</span>
);
}
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 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('Apply badge error:', err);
onSelectBadge(activeBadge);
onClose();
} finally {
setIsExportingPng(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-6xl w-full max-h-[94vh] 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-3">
<div className="w-9 h-9 rounded-xl bg-[#e2001a] flex items-center justify-center text-white shadow-sm">
<Tag className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-bold text-gray-900">
Editor grafických cenoviek (Opakovane použiteľné šablóny)
</h2>
<p className="text-xs text-gray-500">
Nastavte rozmery, zarovnanie a rozmiestnenie vrstiev. Cena a centy sú dynamické a nezamŕzajú v rastri.
</p>
</div>
</div>
<button onClick={onClose} className="p-2 text-gray-400 hover:text-gray-700 rounded-xl hover:bg-gray-200/50">
<X className="w-5 h-5" />
</button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-hidden grid grid-cols-12 divide-x divide-gray-100">
{/* 1. Stĺpec: Šablóny a Vytvorenie novej */}
<div className="col-span-3 p-4 overflow-y-auto space-y-3 bg-gray-50/50">
<div className="flex items-center justify-between px-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-gray-400">
Šablóny ({badges.length})
</span>
<button
onClick={handleCreateNewTemplate}
className="text-xs font-bold text-[#e2001a] hover:text-red-700 flex items-center gap-1 bg-red-50 hover:bg-red-100 px-2 py-1 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Nová
</button>
</div>
<div className="space-y-2">
{badges.map((b) => (
<div
key={b.id}
onClick={() => {
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'
}`}
>
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-bold text-gray-800 truncate">{b.name}</p>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
duplicateBadgeTemplate(b);
}}
className="text-gray-300 hover:text-blue-600 p-1 rounded transition-colors"
title="Duplikovať šablónu"
>
<Copy className="w-3.5 h-3.5" />
</button>
{/* Zmazať sa dá každá šablóna, aj staršie predvolené (badge-preset-*) uložené v prehliadači */}
<button
onClick={(e) => {
e.stopPropagation();
deleteBadgeTemplate(b.id);
}}
className="text-gray-300 hover:text-red-600 p-1 rounded transition-colors"
title="Zmazať šablónu"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div
className="w-full h-12 flex items-center justify-center transform scale-75 origin-center overflow-hidden pointer-events-none"
dangerouslySetInnerHTML={{
__html: renderCleanBadgeSvg(
b,
{
priceMain: currentPriceMain,
priceSub: currentPriceSub,
discountText: currentDiscount,
unitPrice: currentUnitPrice
},
90
)
}}
/>
</div>
))}
</div>
</div>
{/* 2. Stĺpec: Vektorový Canvas & Náhľad s Drag & Drop */}
<div className="col-span-5 min-h-0 flex flex-col bg-slate-950">
{/* Lišta plátna: názov + ovládanie priblíženia */}
<div className="shrink-0 flex items-center justify-between gap-2 px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-400 flex items-center gap-1.5">
<Eye className="w-3.5 h-3.5" /> Náhľad na plátne
</span>
<span className="text-[10px] text-blue-400 bg-blue-950/60 border border-blue-800/60 px-2.5 py-0.5 rounded-full flex items-center gap-1">
<Move className="w-3 h-3" /> Drag & Drop
</span>
</div>
{activeBadge && (
<div className="flex items-center gap-1 bg-slate-900/90 border border-slate-700 rounded-full px-1.5 py-1 text-slate-300">
<button
type="button"
onClick={zoomOut}
disabled={canvasZoom <= CANVAS_ZOOM_MIN}
className="p-1 rounded-full hover:bg-slate-700 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent"
title="Oddialiť (Ctrl + koliesko)"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<input
type="range"
min={CANVAS_ZOOM_MIN}
max={CANVAS_ZOOM_MAX}
step={0.05}
value={canvasZoom}
onChange={(e) => setCanvasZoom(Number(e.target.value))}
className="w-24 h-1 accent-blue-400 cursor-pointer"
title="Priblíženie plátna"
/>
<button
type="button"
onClick={zoomIn}
disabled={canvasZoom >= CANVAS_ZOOM_MAX}
className="p-1 rounded-full hover:bg-slate-700 hover:text-white disabled:opacity-30 disabled:hover:bg-transparent"
title="Priblížiť (Ctrl + koliesko)"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setCanvasZoom(1)}
className="min-w-[44px] text-[10px] font-mono font-bold hover:text-white"
title="Obnoviť na 100 %"
>
{Math.round(canvasZoom * 100)} %
</button>
</div>
)}
</div>
{activeBadge ? (
<>
{/* Priblížiteľná a posúvateľná oblasť plátna (vyplní zvyšnú výšku stĺpca) */}
<div ref={canvasAreaRef} className="flex-1 min-h-0 overflow-auto flex">
{/* Rezerva okolo cenovky, aby vrstvy a popisky presahujúce podklad neboli orezané */}
<div className="m-auto shrink-0 p-10">
{/* Obal simulujúci veľkosť, zarovnanie a odsadenie */}
<div
className="flex"
style={{
zoom: canvasZoom,
// Obal má na obrazovke stále ~320 px (simulácia zarovnania), pri veľkom priblížení sa rozšíri na celú cenovku
width: `${Math.max(activeBadge.width || 110, 320 / canvasZoom)}px`,
justifyContent:
activeBadge.align === 'left'
? 'flex-start'
: activeBadge.align === 'right'
? 'flex-end'
: 'center',
paddingTop: `${activeBadge.paddingTop || 0}px`,
paddingBottom: `${activeBadge.paddingBottom || 0}px`
}}
>
<div
className="relative select-none"
style={{
width: `${activeBadge.width || 110}px`
}}
>
{/* SVG Podklad */}
<div
className="block pointer-events-none w-full"
dangerouslySetInnerHTML={{ __html: activeBadge.svgRaw || '' }}
/>
{/* Dynamické textové vrstvy nad SVG podkladom (Drag & Drop) */}
{activeBadge.layersConfig.map((layer) => {
const isSelected = layer.id === selectedLayerId;
const isDraggingThis = dragState?.layerId === layer.id;
return (
<div
key={layer.id}
onMouseDown={(e) => 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)}
{isSelected && (
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[9px] font-mono font-bold bg-slate-900 text-blue-300 px-1.5 py-0.5 rounded shadow border border-slate-700 pointer-events-none whitespace-nowrap flex items-center gap-1 z-20">
<Move className="w-2.5 h-2.5" /> {layer.x}px, {layer.y}px
</span>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
<div className="shrink-0 flex justify-center px-4 pb-4 pt-2">
{/* Info o premenných a centoch */}
<div className="bg-slate-900/80 border border-slate-800 rounded-xl p-3 text-[11px] text-slate-300 space-y-1.5 max-w-sm">
<span className="font-bold text-gray-400 block mb-0.5">Dostupné dynamické značky:</span>
<div className="flex flex-wrap gap-1.5 text-[10px]">
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-amber-300">
{'{cena}'} &rarr; {currentPriceMain}
<sup>{currentPriceSub}</sup> (bez €)
</span>
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-emerald-300">
{'{zlava}'} &rarr; {currentDiscount.replace('-', '')}
</span>
<span className="bg-slate-800 px-2 py-0.5 rounded font-mono text-blue-300">
{'{mena}'} &rarr; €
</span>
</div>
</div>
</div>
</>
) : (
<p className="m-auto text-sm text-slate-500">Vyberte alebo vytvorte novú šablónu cenovky.</p>
)}
</div>
{/* 3. Stĺpec: Konfigurácia rozmerov, zarovnania a vrstiev */}
<div className="col-span-4 p-5 overflow-y-auto space-y-5 bg-white">
{activeBadge ? (
<>
{/* Názov šablóny */}
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Názov šablóny</label>
<input
type="text"
value={activeBadge.name}
onChange={(e) => setActiveBadge({ ...activeBadge, name: e.target.value })}
className="w-full text-xs font-bold border border-gray-300 rounded-xl p-2.5"
/>
</div>
{/* Sekcia: Rozmery, zarovnanie a odsadenie cenovky */}
<div className="bg-gray-50 p-3.5 rounded-2xl border border-gray-200 space-y-3">
<span className="text-xs font-bold text-gray-800 uppercase tracking-wider block">
Rozmery a Zarovnanie cenovky
</span>
{/* Šírka cenovky */}
<div>
<div className="flex justify-between items-center mb-1">
<label className="text-[11px] font-semibold text-gray-700">Šírka cenovky</label>
<span className="text-[11px] font-bold text-gray-900 bg-white px-2 py-0.5 rounded border border-gray-200">
{activeBadge.width || 110} px
</span>
</div>
<input
type="range"
min="70"
max="180"
step="2"
value={activeBadge.width || 110}
onChange={(e) => setActiveBadge({ ...activeBadge, width: Number(e.target.value) })}
className="w-full accent-[#e2001a]"
/>
</div>
{/* Zarovnanie na karte produktu */}
<div>
<label className="block text-[11px] font-semibold text-gray-700 mb-1">
Zarovnanie cenovky na produkte
</label>
<div className="grid grid-cols-3 gap-1 bg-gray-200 p-0.5 rounded-lg text-xs font-bold">
<button
type="button"
onClick={() => setActiveBadge({ ...activeBadge, align: 'left' })}
className={`py-1.5 flex items-center justify-center gap-1 rounded transition-all ${
(activeBadge.align || 'center') === 'left'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<AlignLeft className="w-3.5 h-3.5" /> Vľavo
</button>
<button
type="button"
onClick={() => setActiveBadge({ ...activeBadge, align: 'center' })}
className={`py-1.5 flex items-center justify-center gap-1 rounded transition-all ${
(activeBadge.align || 'center') === 'center'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<AlignCenter className="w-3.5 h-3.5" /> Na stred
</button>
<button
type="button"
onClick={() => setActiveBadge({ ...activeBadge, align: 'right' })}
className={`py-1.5 flex items-center justify-center gap-1 rounded transition-all ${
(activeBadge.align || 'center') === 'right'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<AlignRight className="w-3.5 h-3.5" /> Vpravo
</button>
</div>
</div>
{/* Odsadenie (Padding) */}
<div className="grid grid-cols-2 gap-2 pt-1">
<div>
<label className="block text-[10px] text-gray-600 mb-0.5">Odsadenie zhora (px)</label>
<input
type="number"
min="0"
max="40"
value={activeBadge.paddingTop ?? 4}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[10px] text-gray-600 mb-0.5">Odsadenie zdola (px)</label>
<input
type="number"
min="0"
max="40"
value={activeBadge.paddingBottom ?? 4}
onChange={(e) => 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"
/>
</div>
</div>
</div>
{/* Upload vlastného SVG alebo PNG podkladu */}
<div className="border border-dashed border-gray-300 rounded-2xl p-3 bg-gray-50/60">
<label className="block text-xs font-bold text-gray-800 mb-1 flex items-center gap-1.5">
<Upload className="w-3.5 h-3.5 text-[#e2001a]" />
Nahrať vlastný podklad cenovky (SVG alebo PNG)
</label>
<input
type="file"
accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
onChange={handleBackgroundUpload}
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"
/>
<p className="text-[10px] text-gray-500 mt-1">
Podporuje SVG vektorové tvary aj priehľadné PNG obrázky.
</p>
</div>
{/* Rýchle pridanie vrstiev */}
<div>
<span className="text-xs font-bold text-gray-800 uppercase tracking-wider block mb-2">
Pridať vrstvu
</span>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => addTextLayer('{cena}')}
className="text-[11px] font-bold bg-amber-50 text-amber-800 border border-amber-200 hover:bg-amber-100 px-2 py-1 rounded-lg"
>
+ Cena bez € ({'{cena}'})
</button>
<button
onClick={() => addTextLayer('-{zlava}')}
className="text-[11px] font-bold bg-red-50 text-red-800 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
>
+ Zľava %
</button>
<button
onClick={() => addTextLayer('€')}
className="text-[11px] font-bold bg-blue-50 text-blue-800 border border-blue-200 hover:bg-blue-100 px-2 py-1 rounded-lg"
>
+ Znak €
</button>
<button
onClick={() => addTextLayer('{jednotkova_cena}')}
className="text-[11px] font-bold bg-emerald-50 text-emerald-800 border border-emerald-200 hover:bg-emerald-100 px-2 py-1 rounded-lg"
>
+ Merná cena
</button>
<button
onClick={() => addTextLayer('AKCIA')}
className="text-[11px] font-bold bg-gray-100 text-gray-800 border border-gray-200 hover:bg-gray-200 px-2 py-1 rounded-lg"
>
+ Vlastný text
</button>
</div>
</div>
{/* Zoznam a nastavenie vrstiev */}
<div className="space-y-3 pt-2 border-t border-gray-100">
<span className="text-xs font-bold text-gray-800 uppercase tracking-wider block">
Vrstvy ({activeBadge.layersConfig.length})
</span>
<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-2.5 py-1 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>
{activeLayer && (
<div className="space-y-3 bg-gray-50 p-3.5 rounded-2xl border border-gray-200">
<div>
<label className="block text-[11px] font-semibold text-gray-700 mb-1">
Text / Premenná
</label>
<input
type="text"
value={activeLayer.text || ''}
onChange={(e) => updateActiveLayer({ text: e.target.value })}
className="w-full text-xs font-bold border border-gray-300 rounded-lg p-2 bg-white"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Veľkosť fontu (px)</label>
<input
type="number"
value={activeLayer.fontSize || 16}
onChange={(e) => updateActiveLayer({ fontSize: Number(e.target.value) })}
className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white"
/>
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Farba</label>
<div className="flex items-center gap-1.5">
<input
type="color"
value={activeLayer.fill || '#ffffff'}
onChange={(e) => updateActiveLayer({ fill: e.target.value })}
className="w-7 h-7 rounded cursor-pointer border-0"
/>
<span className="text-[10px] font-mono">{activeLayer.fill}</span>
</div>
</div>
</div>
{/* Font výber */}
<div className="space-y-2 pt-2 border-t border-gray-200">
<label className="block text-[11px] font-semibold text-gray-700">
Písmo (Font) a Hrúbka
</label>
<div className="grid grid-cols-3 gap-1 bg-gray-200 p-0.5 rounded-lg text-[10px] font-bold text-center">
<button
type="button"
onClick={() => updateActiveLayer({ fontSource: 'google' })}
className={`py-1 rounded ${
(activeLayer.fontSource || 'google') === 'google'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Google
</button>
<button
type="button"
onClick={() => updateActiveLayer({ fontSource: 'adobe' })}
className={`py-1 rounded ${
activeLayer.fontSource === 'adobe'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Adobe
</button>
<button
type="button"
onClick={() => updateActiveLayer({ fontSource: 'system' })}
className={`py-1 rounded ${
activeLayer.fontSource === 'system'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Systém
</button>
</div>
{(activeLayer.fontSource || 'google') === 'google' && (
<div className="space-y-2">
<select
value={
POPULAR_FONTS.some((f) => 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' });
}
}}
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) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
<option value="custom">Iný Google Font...</option>
</select>
{(!POPULAR_FONTS.some((f) => f.name === (activeLayer.fontFamily || 'Poppins')) ||
activeLayer.fontFamily === 'custom') && (
<input
type="text"
placeholder="Názov Google fontu (napr. Montserrat)"
value={activeLayer.fontFamily === 'custom' ? '' : activeLayer.fontFamily || ''}
onChange={(e) => {
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"
/>
)}
</div>
)}
{activeLayer.fontSource === 'adobe' && (
<div className="space-y-2">
<input
type="text"
placeholder="Adobe Typekit CSS URL"
value={activeLayer.fontUrl || ''}
onChange={(e) => {
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]"
/>
<input
type="text"
placeholder="Font Family (napr. futura-pt)"
value={activeLayer.fontFamily || ''}
onChange={(e) => {
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"
/>
</div>
)}
{activeLayer.fontSource === 'system' && (
<select
value={activeLayer.fontFamily || 'Arial Narrow'}
onChange={(e) => updateActiveLayer({ fontFamily: e.target.value, fontSource: 'system' })}
className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium"
>
<option value="Arial Narrow">Arial Narrow</option>
<option value="Arial">Arial</option>
<option value="Impact">Impact</option>
<option value="Trebuchet MS">Trebuchet MS</option>
<option value="Helvetica">Helvetica</option>
</select>
)}
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Hrúbka písma (Weight)</label>
<select
value={activeLayer.fontWeight || '700'}
onChange={(e) => updateActiveLayer({ fontWeight: e.target.value })}
className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium"
>
<option value="400">400 - Normal</option>
<option value="600">600 - SemiBold</option>
<option value="700">700 - Bold</option>
<option value="800">800 - ExtraBold</option>
<option value="900">900 - Black</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Pozícia X ({activeLayer.x}px)</label>
<input
type="range"
min="0"
max="140"
value={activeLayer.x}
onChange={(e) => updateActiveLayer({ x: Number(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Pozícia Y ({activeLayer.y}px)</label>
<input
type="range"
min="0"
max="110"
value={activeLayer.y}
onChange={(e) => updateActiveLayer({ y: Number(e.target.value) })}
className="w-full"
/>
</div>
</div>
{/* Nastavenie veľkosti a pozície horného indexu (centov) */}
{(activeLayer.text?.includes('{cena}') || activeLayer.text?.includes('{centy}')) && (
<div className="pt-2.5 pb-1 border-t border-blue-200/80 bg-blue-50/70 p-3 rounded-xl border border-blue-100 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-blue-900">
Nastavenie horného indexu (Centov)
</span>
<span className="text-[10px] text-blue-700 bg-blue-100 px-1.5 py-0.5 rounded font-mono font-bold">
{activeLayer.indexFontSize ?? Math.max(10, Math.round((activeLayer.fontSize || 16) * 0.55))}px
</span>
</div>
{/* Veľkosť indexu */}
<div>
<div className="flex justify-between items-center text-[10px] text-gray-600 mb-0.5">
<span>Veľkosť písma indexu</span>
<span className="font-bold text-gray-800">
{activeLayer.indexFontSize ?? Math.max(10, Math.round((activeLayer.fontSize || 16) * 0.55))} px
</span>
</div>
<input
type="range"
min="8"
max="40"
value={activeLayer.indexFontSize ?? Math.max(10, Math.round((activeLayer.fontSize || 16) * 0.55))}
onChange={(e) => updateActiveLayer({ indexFontSize: Number(e.target.value) })}
className="w-full accent-blue-600"
/>
</div>
{/* Vertikálny posun (Hore / Dole) */}
<div>
<div className="flex justify-between items-center text-[10px] text-gray-600 mb-0.5">
<span>Vertikálna pozícia (Posun Hore / Dole)</span>
<span className="font-bold text-gray-800">
{activeLayer.indexOffsetY ?? -10} px
</span>
</div>
<input
type="range"
min="-30"
max="20"
value={activeLayer.indexOffsetY ?? -10}
onChange={(e) => updateActiveLayer({ indexOffsetY: Number(e.target.value) })}
className="w-full accent-blue-600"
/>
</div>
{/* Horizontálna medzera (Vľavo / Vpravo) */}
<div>
<div className="flex justify-between items-center text-[10px] text-gray-600 mb-0.5">
<span>Horizontálna medzera (Vľavo / Vpravo)</span>
<span className="font-bold text-gray-800">
{activeLayer.indexOffsetX ?? 2} px
</span>
</div>
<input
type="range"
min="-10"
max="25"
value={activeLayer.indexOffsetX ?? 2}
onChange={(e) => updateActiveLayer({ indexOffsetX: Number(e.target.value) })}
className="w-full accent-blue-600"
/>
</div>
</div>
)}
<div className="pt-1 flex justify-between items-center">
<button
onClick={() => removeLayer(activeLayer.id)}
className="text-[11px] text-red-600 hover:text-red-700 font-semibold flex items-center gap-1"
>
<Trash2 className="w-3 h-3" /> Zmazať vrstvu
</button>
</div>
</div>
)}
</div>
{/* Tlačidlo: Uložiť šablónu do DB */}
<div className="pt-2">
<button
onClick={handleSaveToSupabase}
disabled={isSavingDb}
className="w-full py-2.5 bg-gray-900 hover:bg-black text-white text-xs font-bold rounded-xl transition-colors shadow-sm flex items-center justify-center gap-1.5"
>
<Check className="w-4 h-4 text-emerald-400" />
{isSavingDb ? 'Ukladám do Supabase...' : 'Uložiť túto šablónu (pre opakované použitie)'}
</button>
</div>
</>
) : null}
</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 || !activeBadge}
className="px-6 py-2.5 text-sm font-bold bg-[#e2001a] text-white rounded-xl hover:bg-red-700 disabled:opacity-50 transition-colors shadow-md flex items-center gap-2"
>
<Check className="w-4 h-4" />
{isExportingPng ? 'Pripravujem cenovku...' : 'Aplikovať cenovku na produkt'}
</button>
</div>
</div>
</div>
);
};