Files
Coop-NL/src/components/editor/BadgeEditorModal.tsx
T
Jakub_HostacnyandClaude Opus 5.5 332ab833e9 feat: kod sablony cenovky pre hromadny import
- sablona cenovky ma kod (napr. SUPER_CENA), nastavuje sa v hlavicke editora
  cenoviek; kontrola jedinecnosti, ulozi sa v layers_config (bez zmeny DB)
- import CSV/JSON/XML: novy stlpec Cenovka (kod sablony), automaticky
  rozpoznany (cenovka/badge/sablona); pouzije sa kod alebo nazov sablony,
  bez ohladu na velke pismena a diakritiku
- nahlad importu ukazuje priradenu sablonu, neznamy kod nahlasi upozornenie
- spolocne nacitanie sablon z DB (badgeFromRow) pre editor aj stranku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:41:39 +02:00

986 lines
44 KiB
TypeScript

'use client';
// Editor šablón cenoviek.
// Plátno ukazuje SKUTOČNÚ cenovku (to isté SVG ako v e-maile); vrstvy sa vyberajú a ťahajú priamo na nej
// v súradniciach viewBoxu. Klávesnica: šípky posun (Shift = viac), Delete zmaže vrstvu, Ctrl+Z / Ctrl+Y späť / znova,
// Ctrl+S uloží.
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
import { supabase } from '@/lib/supabase';
import { renderCleanBadgeSvg, BadgeProductData, BadgeRow, badgeFromRow, normalizeBadgeCode } from '@/lib/badgeUtils';
import {
Tag,
Plus,
Trash2,
X,
Upload,
Copy,
Undo2,
Redo2,
Save,
Check,
AlignLeft,
AlignCenter,
AlignRight,
ZoomIn,
ZoomOut,
Crosshair
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Pomocné funkcie a konštanty
// ---------------------------------------------------------------------------
const LOCAL_KEY = 'coop_custom_badges';
const HISTORY_LIMIT = 60;
// Dočasné ID šablóny pred uložením do databázy (prefix badge- = ešte nie je v Supabase)
const newLocalBadgeId = () => `badge-${Date.now()}`;
const newLayerId = () => `l-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
// 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,
code: badge.code ? normalizeBadgeCode(badge.code) : null,
width: badge.width || 110,
align: badge.align || 'center',
paddingTop: badge.paddingTop ?? 4,
paddingBottom: badge.paddingBottom ?? 4
}
};
}
function saveLocal(list: BadgeTemplate[]) {
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(list));
} catch {
// localStorage nemusí byť dostupný
}
}
// viewBox šablóny (rovnako ako renderCleanBadgeSvg)
function viewBoxOf(svgRaw?: string): { x: number; y: number; w: number; h: number } {
const vb = svgRaw?.match(/viewBox=["']([^"']+)["']/i)?.[1].trim().split(/[\s,]+/).map(Number);
if (vb && vb.length === 4 && vb[2] > 0 && vb[3] > 0) return { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
const w = parseFloat(svgRaw?.match(/width=["']([^"']+)["']/i)?.[1] || '');
const h = parseFloat(svgRaw?.match(/height=["']([^"']+)["']/i)?.[1] || '');
return w > 0 && h > 0 ? { x: 0, y: 0, w, h } : { x: 0, y: 0, w: 140, h: 95 };
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const FONTS: Array<{ name: string; source: 'google' | 'system' }> = [
{ name: 'Poppins', source: 'google' },
{ name: 'Montserrat', source: 'google' },
{ name: 'Oswald', source: 'google' },
{ name: 'Bebas Neue', source: 'google' },
{ name: 'Anton', source: 'google' },
{ name: 'Roboto Condensed', source: 'google' },
{ name: 'Barlow Semi Condensed', source: 'google' },
{ name: 'Barlow Condensed', source: 'google' },
{ name: 'Inter', source: 'google' },
{ name: 'Arial Narrow', source: 'system' },
{ name: 'Arial', source: 'system' },
{ name: 'Impact', source: 'system' }
];
const COLORS: Array<[string, string]> = [
['#000000', 'Čierna'],
['#ffffff', 'Biela']
];
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
const WEIGHTS = [
['400', 'Normal'],
['600', 'SemiBold'],
['700', 'Bold'],
['800', 'ExtraBold'],
['900', 'Black']
];
// Premenné v texte vrstvy
const TOKENS: Array<{ token: string; label: string }> = [
{ token: '{cena}', label: 'Cena' },
{ token: '{zlava}', label: 'Zľava' },
{ token: '{cena_s_kartou}', label: 'Cena s kartou' },
{ token: '{zlava_s_kartou}', label: 'Zľava s kartou' },
{ token: '{jednotkova_cena}', label: 'Jedn. cena' },
{ token: '{mena}', label: '€' }
];
// Rýchle pridanie vrstiev
const QUICK_LAYERS: Array<{ label: string; text: string; fontSize: number }> = [
{ label: 'Cena', text: '{cena}', fontSize: 40 },
{ label: 'Zľava', text: '-{zlava}', fontSize: 14 },
{ label: 'Cena s kartou', text: '{cena_s_kartou}', fontSize: 40 },
{ label: 'Zľava s kartou', text: '-{zlava_s_kartou}', fontSize: 14 },
{ label: 'Text', text: 'AKCIA', fontSize: 16 }
];
function loadFont(layer: BadgeLayer) {
if (typeof document === 'undefined' || !layer.fontFamily) return;
if (layer.fontSource === 'adobe' && layer.fontUrl) {
if (!document.querySelector(`link[data-adobe-font="${layer.fontUrl}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = layer.fontUrl;
link.setAttribute('data-adobe-font', layer.fontUrl);
document.head.appendChild(link);
}
return;
}
if (layer.fontSource === 'system') return;
const clean = layer.fontFamily.trim().replace(/\s+/g, '+');
if (!document.querySelector(`link[data-google-font="${clean}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${clean}:wght@400;600;700;800;900&display=swap`;
link.setAttribute('data-google-font', clean);
document.head.appendChild(link);
}
}
// ---------------------------------------------------------------------------
// Malé UI prvky
// ---------------------------------------------------------------------------
const Section: React.FC<{ title: string; action?: React.ReactNode; children: React.ReactNode }> = ({ title, action, children }) => (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wider">{title}</span>
{action}
</div>
{children}
</div>
);
const NumberSlider: React.FC<{
label: string;
value: number;
min: number;
max: number;
step?: number;
suffix?: string;
onChange: (v: number) => void;
}> = ({ label, value, min, max, step = 1, suffix = '', onChange }) => (
<div className="grid grid-cols-[70px_1fr_52px] items-center gap-2">
<span className="text-[10px] font-semibold text-gray-600">{label}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1 accent-red-600 cursor-pointer"
/>
<div className="flex items-center">
<input
type="number"
value={value}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full text-[10px] font-mono border border-gray-200 rounded px-1 py-0.5 text-right"
/>
{suffix && <span className="text-[9px] text-gray-400 ml-0.5">{suffix}</span>}
</div>
</div>
);
// Vlastná farba ako HEX kód (#rrggbb); uloží sa až platná hodnota
const HexColorInput: React.FC<{ value: string; onChange: (hex: string) => void }> = ({ value, onChange }) => {
const [text, setText] = useState(value);
// farba zmenená zvonka (čierna/biela, krok späť) → prepíše text v poli
const [prevValue, setPrevValue] = useState(value);
if (value !== prevValue) {
setPrevValue(value);
setText(value);
}
const valid = HEX_RE.test(text);
return (
<div className="flex items-center gap-1 flex-1 min-w-0">
<span className="w-6 h-6 rounded border border-black/15 shrink-0" style={{ backgroundColor: valid ? text : value }} />
<input
value={text}
onChange={(e) => {
const v = e.target.value.trim();
const hex = v.startsWith('#') ? v : `#${v}`;
setText(hex);
if (HEX_RE.test(hex)) onChange(hex.toLowerCase());
}}
placeholder="#e2001a"
maxLength={7}
className={`w-full min-w-0 text-[11px] font-mono uppercase border rounded-lg px-1.5 py-1 ${valid ? 'border-gray-300' : 'border-red-300 bg-red-50'}`}
title="Vlastná farba ako HEX kód, napr. #E2001A"
/>
</div>
);
};
// ---------------------------------------------------------------------------
// Editor
// ---------------------------------------------------------------------------
interface BadgeEditorModalProps {
isOpen: boolean;
onClose: () => void;
onSelectBadge: (badge: BadgeTemplate, renderedPngUrl?: string) => void;
currentDiscount?: string;
currentPriceMain?: string;
currentPriceSub?: string;
currentUnitPrice?: string;
currentCardPriceMain?: string;
currentCardPriceSub?: string;
currentCardDiscount?: string;
}
interface DragState {
layerId: string;
startX: number;
startY: number;
layerX: number;
layerY: number;
}
export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
isOpen,
onClose,
onSelectBadge,
currentDiscount = '25%',
currentPriceMain = '1',
currentPriceSub = '99',
currentUnitPrice = '1kg = 4,91 €',
currentCardPriceMain = '1',
currentCardPriceSub = '49',
currentCardDiscount = '30%'
}) => {
const [badges, setBadges] = useState<BadgeTemplate[]>([]);
const [badge, setBadge] = useState<BadgeTemplate | null>(null);
const [savedJson, setSavedJson] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [history, setHistory] = useState<{ past: BadgeTemplate[]; future: BadgeTemplate[] }>({ past: [], future: [] });
const [zoom, setZoom] = useState(1);
const [saving, setSaving] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
const [testPrice, setTestPrice] = useState<{ main: string; sub: string } | null>(null);
const [boxes, setBoxes] = useState<Record<string, { x: number; y: number; w: number; h: number }>>({});
const [drag, setDrag] = useState<DragState | null>(null);
const [snapX, setSnapX] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const isDirty = !!badge && JSON.stringify(badge) !== savedJson;
// Kód pre import musí byť jedinečný
const codeTaken =
!!badge?.code &&
badges.some((b) => b.id !== badge.id && b.code && normalizeBadgeCode(b.code) === normalizeBadgeCode(badge.code));
const layer = badge?.layersConfig.find((l) => l.id === selectedId) || null;
const vb = useMemo(() => viewBoxOf(badge?.svgRaw), [badge?.svgRaw]);
const canvasWidth = Math.round(300 * zoom);
const unitsPerPx = vb.w / canvasWidth;
const product: BadgeProductData = {
priceMain: testPrice?.main || currentPriceMain,
priceSub: testPrice?.sub || currentPriceSub,
discountText: currentDiscount,
unitPrice: currentUnitPrice,
cardPriceMain: testPrice?.main || currentCardPriceMain,
cardPriceSub: testPrice?.sub || currentCardPriceSub,
cardDiscount: currentCardDiscount
};
// --- načítanie šablón -----------------------------------------------------
const open = useCallback((b: BadgeTemplate | null) => {
setBadge(b);
setSavedJson(b ? JSON.stringify(b) : '');
setSelectedId(b?.layersConfig[0]?.id ?? null);
setHistory({ past: [], future: [] });
}, []);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
(async () => {
let loaded: BadgeTemplate[] = [];
try {
const { data } = await supabase.from('badges').select('*').order('created_at', { ascending: false });
loaded = ((data as BadgeRow[]) || []).map(badgeFromRow);
} catch {
// bez databázy použijeme kópiu v prehliadači
}
try {
const local = JSON.parse(localStorage.getItem(LOCAL_KEY) || '[]');
if (Array.isArray(local)) {
const ids = new Set(loaded.map((b) => b.id));
for (const b of local as BadgeTemplate[]) if (!ids.has(b.id)) loaded.push(b);
}
} catch {
// localStorage nemusí byť dostupný
}
if (cancelled) return;
setBadges(loaded);
open(loaded[0] || null);
})();
return () => {
cancelled = true;
};
}, [isOpen, open]);
// Písma vrstiev
useEffect(() => {
badge?.layersConfig.forEach(loadFont);
}, [badge?.layersConfig]);
// --- úpravy s históriou ---------------------------------------------------
// record = zapísať predchádzajúci stav do histórie (počas ťahania sa zapisuje len na začiatku)
const commit = (next: BadgeTemplate, record = true) => {
if (record && badge) {
const prev = badge;
setHistory((h) => ({ past: [...h.past, prev].slice(-HISTORY_LIMIT), future: [] }));
}
setBadge(next);
};
const updateLayer = (id: string, patch: Partial<BadgeLayer>, record = true) => {
if (!badge) return;
commit({ ...badge, layersConfig: badge.layersConfig.map((l) => (l.id === id ? { ...l, ...patch } : l)) }, record);
};
const undo = useCallback(() => {
setHistory((h) => {
if (!h.past.length || !badge) return h;
const prev = h.past[h.past.length - 1];
setBadge(prev);
return { past: h.past.slice(0, -1), future: [badge, ...h.future] };
});
}, [badge]);
const redo = useCallback(() => {
setHistory((h) => {
if (!h.future.length || !badge) return h;
const next = h.future[0];
setBadge(next);
return { past: [...h.past, badge], future: h.future.slice(1) };
});
}, [badge]);
const addLayer = (text: string, fontSize: number) => {
if (!badge) return;
const l: BadgeLayer = {
id: newLayerId(),
type: 'text',
text,
x: round1(vb.x + vb.w / 2),
y: round1(vb.y + vb.h / 2),
fontSize,
fontWeight: '900',
fill: '#ffffff',
align: 'center'
};
commit({ ...badge, layersConfig: [...badge.layersConfig, l] });
setSelectedId(l.id);
};
const removeLayer = (id: string) => {
if (!badge) return;
commit({ ...badge, layersConfig: badge.layersConfig.filter((l) => l.id !== id) });
if (selectedId === id) setSelectedId(null);
};
const insertToken = (token: string) => {
if (!layer) return;
updateLayer(layer.id, { text: `${layer.text || ''}${token}` });
};
// --- meranie vrstiev na plátne (bounding box v súradniciach viewBoxu) -----
const svgHtml = badge ? renderCleanBadgeSvg(badge, product, canvasWidth) : '';
useLayoutEffect(() => {
const root = canvasRef.current;
if (!root || !badge) return;
const measure = () => {
const next: Record<string, { x: number; y: number; w: number; h: number }> = {};
root.querySelectorAll<SVGTextElement>('[data-layer-index]').forEach((t) => {
const l = badge.layersConfig[Number(t.dataset.layerIndex)];
if (!l) return;
try {
const b = t.getBBox();
next[l.id] = { x: b.x, y: b.y, w: b.width, h: b.height };
} catch {
// prvok ešte nie je vykreslený
}
});
setBoxes(next);
};
measure();
document.fonts?.ready.then(measure).catch(() => undefined);
}, [svgHtml, badge]);
// --- ťahanie ----------------------------------------------------------------
const onLayerPointerDown = (e: React.PointerEvent, l: BadgeLayer) => {
e.stopPropagation();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
setSelectedId(l.id);
if (badge) setHistory((h) => ({ past: [...h.past, badge].slice(-HISTORY_LIMIT), future: [] }));
setDrag({ layerId: l.id, startX: e.clientX, startY: e.clientY, layerX: l.x, layerY: l.y });
};
const onPointerMove = (e: React.PointerEvent) => {
if (!drag || !badge) return;
let x = drag.layerX + (e.clientX - drag.startX) * unitsPerPx;
const y = drag.layerY + (e.clientY - drag.startY) * unitsPerPx;
const center = vb.x + vb.w / 2;
const snapped = Math.abs(x - center) < vb.w * 0.02;
if (snapped) x = center;
setSnapX(snapped);
updateLayer(drag.layerId, { x: round1(x), y: round1(y) }, false);
};
const endDrag = () => {
setDrag(null);
setSnapX(false);
};
// --- uloženie ---------------------------------------------------------------
const save = useCallback(async (): Promise<BadgeTemplate | null> => {
if (!badge) return null;
setSaving(true);
let saved: BadgeTemplate = badge;
try {
const payload = toBadgeDbPayload(badge);
const isDbRow = !badge.id.startsWith('badge-');
const { data, error } = isDbRow
? await supabase.from('badges').update(payload).eq('id', badge.id).select()
: await supabase.from('badges').insert([payload]).select();
if (error) throw error;
if (data?.[0]) saved = { ...badge, id: (data[0] as BadgeRow).id };
} catch (err) {
console.warn('Šablóna sa uložila len v prehliadači:', err);
}
const list = badges.some((b) => b.id === badge.id)
? badges.map((b) => (b.id === badge.id ? saved : b))
: [saved, ...badges];
setBadges(list);
saveLocal(list);
setBadge(saved);
setSavedJson(JSON.stringify(saved));
setSaving(false);
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1500);
return saved;
}, [badge, badges]);
const confirmDiscard = () => !isDirty || confirm('Máte neuložené zmeny v šablóne. Zahodiť ich?');
const close = () => {
if (confirmDiscard()) onClose();
};
const selectTemplate = (b: BadgeTemplate) => {
if (b.id === badge?.id || !confirmDiscard()) return;
open(b);
};
const createTemplate = () => {
if (!confirmDiscard()) return;
const b: BadgeTemplate = {
id: newLocalBadgeId(),
name: 'Nová 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="#e2001a"/></svg>',
width: 110,
align: 'center',
paddingTop: 4,
paddingBottom: 4,
layersConfig: [
{ id: newLayerId(), type: 'text', text: 'AKCIA', x: 70, y: 22, fontSize: 16, fontWeight: '900', fill: '#ffde00', align: 'center' },
{ id: newLayerId(), type: 'text', text: '{cena}', x: 70, y: 60, fontSize: 42, fontWeight: '900', fill: '#ffffff', align: 'center' }
]
};
setBadges([b, ...badges]);
open(b);
setSavedJson(''); // nová šablóna je neuložená
};
const duplicateTemplate = async (b: BadgeTemplate) => {
if (!confirmDiscard()) return;
const copy: BadgeTemplate = { ...JSON.parse(JSON.stringify(b)), id: newLocalBadgeId(), name: `${b.name} (kópia)` };
let saved = copy;
try {
const { data, error } = await supabase.from('badges').insert([toBadgeDbPayload(copy)]).select();
if (!error && data?.[0]) saved = { ...copy, id: (data[0] as BadgeRow).id };
} catch {
// bez databázy ostane kópia v prehliadači
}
const list = [...badges];
list.splice(list.findIndex((x) => x.id === b.id) + 1, 0, saved);
setBadges(list);
saveLocal(list);
open(saved);
};
const deleteTemplate = async (b: BadgeTemplate) => {
if (!confirm(`Zmazať šablónu „${b.name}“? Produkty, ktoré ju už používajú, si ju ponechajú.`)) return;
if (!b.id.startsWith('badge-')) {
try {
await supabase.from('badges').delete().eq('id', b.id);
} catch {
// šablóna mohla byť len v prehliadači
}
}
const list = badges.filter((x) => x.id !== b.id);
setBadges(list);
saveLocal(list);
if (badge?.id === b.id) open(list[0] || null);
};
const applyToProduct = async () => {
const saved = isDirty ? await save() : badge;
if (!saved) return;
onSelectBadge(saved);
onClose();
};
// --- pozadie ----------------------------------------------------------------
const uploadBackground = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !badge) return;
const isSvg = file.type.includes('svg') || file.name.toLowerCase().endsWith('.svg');
const reader = new FileReader();
if (isSvg) {
reader.onload = () => {
const content = String(reader.result || '');
if (!content.includes('<svg')) return alert('Súbor nie je platné SVG.');
commit({ ...badge, svgRaw: content });
};
reader.readAsText(file);
} else if (file.type.startsWith('image/')) {
reader.onload = () => {
const dataUrl = String(reader.result || '');
const img = new Image();
img.onload = () => {
const w = img.naturalWidth || 140;
const h = img.naturalHeight || 95;
commit({
...badge,
svgRaw: `<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>`
});
};
img.src = dataUrl;
};
reader.readAsDataURL(file);
} else {
alert('Podporované sú SVG a PNG/JPG obrázky.');
}
};
// --- klávesnica -------------------------------------------------------------
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault();
save();
return;
}
if (typing) return;
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
return;
}
if (mod && e.key.toLowerCase() === 'y') {
e.preventDefault();
redo();
return;
}
if (!layer) return;
const step = e.shiftKey ? 5 : 0.5;
const moves: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step]
};
if (moves[e.key]) {
e.preventDefault();
updateLayer(layer.id, { x: round1(layer.x + moves[e.key][0]), y: round1(layer.y + moves[e.key][1]) });
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
removeLayer(layer.id);
} else if (e.key === 'Escape') {
setSelectedId(null);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
if (!isOpen) return null;
const hasCents = !!layer && /\{(cena|cena_s_kartou|centy)\}/.test(layer.text || '');
const fontName = layer?.fontFamily || 'Arial Narrow';
const isKnownFont = FONTS.some((f) => f.name === fontName);
return (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl h-[90vh] overflow-hidden flex flex-col">
{/* Hlavička */}
<div className="px-4 py-3 border-b border-gray-100 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[#e2001a] flex items-center justify-center text-white shrink-0">
<Tag className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-bold text-gray-900 shrink-0">Editor cenoviek</span>
{badge && (
<input
value={badge.name}
onChange={(e) => commit({ ...badge, name: e.target.value }, false)}
className="min-w-0 flex-1 max-w-xs text-sm font-semibold border border-transparent hover:border-gray-200 focus:border-gray-300 rounded-lg px-2 py-1"
title="Názov šablóny"
/>
)}
{badge && (
<label className="flex items-center gap-1 shrink-0" title="Kód pre hromadný import: v stĺpci cenovky v CSV/JSON/XML zadajte tento kód">
<span className="text-[10px] font-bold text-gray-500">Kód</span>
<input
value={badge.code || ''}
onChange={(e) => commit({ ...badge, code: e.target.value.toUpperCase() }, false)}
onBlur={() => badge.code && commit({ ...badge, code: normalizeBadgeCode(badge.code) }, false)}
placeholder={normalizeBadgeCode(badge.name) || 'KOD'}
className={`w-32 text-[11px] font-mono uppercase border rounded-lg px-1.5 py-1 ${
codeTaken ? 'border-red-400 bg-red-50' : 'border-gray-200'
}`}
/>
{codeTaken && <span className="text-[10px] font-bold text-red-600">už používa iná šablóna</span>}
</label>
)}
{isDirty && <span className="text-[10px] font-bold text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">Neuložené</span>}
{savedFlash && <span className="text-[10px] font-bold text-emerald-600 flex items-center gap-1"><Check className="w-3 h-3" /> Uložené</span>}
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={undo} disabled={!history.past.length} className="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 disabled:opacity-30" title="Späť (Ctrl+Z)">
<Undo2 className="w-4 h-4" />
</button>
<button type="button" onClick={redo} disabled={!history.future.length} className="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 disabled:opacity-30" title="Znova (Ctrl+Y)">
<Redo2 className="w-4 h-4" />
</button>
<button type="button" onClick={close} className="p-1.5 rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 ml-2" title="Zavrieť">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr_300px]">
{/* Šablóny */}
<div className="border-r border-gray-100 bg-gray-50 flex flex-col min-h-0">
<div className="p-3 flex items-center justify-between">
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wider">Šablóny ({badges.length})</span>
<button type="button" onClick={createTemplate} className="flex items-center gap-1 text-[11px] font-bold text-white bg-[#e2001a] hover:bg-red-700 px-2 py-1 rounded-lg">
<Plus className="w-3 h-3" /> Nová
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-3 space-y-1.5">
{badges.map((b) => (
<div
key={b.id}
role="button"
tabIndex={0}
onClick={() => selectTemplate(b)}
className={`group rounded-xl border p-2 cursor-pointer bg-white ${b.id === badge?.id ? 'border-[#e2001a] ring-2 ring-red-500/10' : 'border-gray-200 hover:border-gray-300'}`}
>
<div className="h-16 flex items-center justify-center pointer-events-none [&>svg]:h-full! [&>svg]:w-auto! [&>svg]:max-w-full!" dangerouslySetInnerHTML={{ __html: renderCleanBadgeSvg(b.id === badge?.id && badge ? badge : b, product, 80) }} />
<div className="mt-1 flex items-center gap-1">
<span className="flex-1 text-[11px] font-semibold text-gray-800 truncate">{b.id === badge?.id && badge ? badge.name : b.name}</span>
<button type="button" onClick={(e) => { e.stopPropagation(); duplicateTemplate(b); }} className="p-0.5 text-gray-300 hover:text-blue-600 opacity-0 group-hover:opacity-100" title="Duplikovať šablónu">
<Copy className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); deleteTemplate(b); }} className="p-0.5 text-gray-300 hover:text-red-600 opacity-0 group-hover:opacity-100" title="Zmazať šablónu">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{!badges.length && <p className="text-[11px] text-gray-400 text-center py-6">Zatiaľ žiadne šablóny.</p>}
</div>
</div>
{/* Plátno */}
<div className="min-h-0 flex flex-col bg-slate-900">
<div className="px-3 py-2 flex items-center gap-2 text-slate-300 border-b border-slate-800">
<button type="button" onClick={() => setZoom((z) => Math.max(0.5, round1(z - 0.25)))} className="p-1 rounded hover:bg-slate-700" title="Oddialiť">
<ZoomOut className="w-3.5 h-3.5" />
</button>
<input type="range" min={0.5} max={3} step={0.05} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} className="w-24 h-1 accent-blue-400" />
<button type="button" onClick={() => setZoom((z) => Math.min(3, round1(z + 0.25)))} className="p-1 rounded hover:bg-slate-700" title="Priblížiť">
<ZoomIn className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={() => setZoom(1)} className="text-[10px] font-mono w-10 hover:text-white" title="100 %">
{Math.round(zoom * 100)} %
</button>
<div className="flex-1" />
<span className="text-[10px] text-slate-400">Skúšobná cena</span>
<input
value={testPrice ? `${testPrice.main},${testPrice.sub}` : ''}
onChange={(e) => {
const [main, sub] = e.target.value.split(/[,.]/);
setTestPrice(e.target.value ? { main: main || '0', sub: (sub || '').slice(0, 2) } : null);
}}
placeholder={`${currentPriceMain},${currentPriceSub}`}
className="w-16 text-[11px] font-mono bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-white placeholder:text-slate-500"
title="Vyskúšajte, ako cenovka vyzerá s inou (napr. dlhšou) cenou"
/>
</div>
<div className="flex-1 min-h-0 overflow-auto flex" onPointerDown={() => setSelectedId(null)}>
{badge ? (
<div className="m-auto p-10">
<div className="relative" style={{ width: canvasWidth }} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
<div ref={canvasRef} className="pointer-events-none [&>svg]:max-w-none!" dangerouslySetInnerHTML={{ __html: svgHtml }} />
{/* Výber a ťahanie vrstiev (súradnice viewBoxu) */}
<svg className="absolute inset-0 w-full h-full overflow-visible" viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}>
{snapX && <line x1={vb.x + vb.w / 2} x2={vb.x + vb.w / 2} y1={vb.y - vb.h} y2={vb.y + vb.h * 2} stroke="#38bdf8" strokeWidth={unitsPerPx} strokeDasharray={`${4 * unitsPerPx} ${3 * unitsPerPx}`} />}
{badge.layersConfig.map((l) => {
const b = boxes[l.id];
if (!b) return null;
const selected = l.id === selectedId;
const pad = 2 * unitsPerPx;
return (
<rect
key={l.id}
x={b.x - pad}
y={b.y - pad}
width={b.w + pad * 2}
height={b.h + pad * 2}
fill="transparent"
stroke={selected ? '#38bdf8' : 'transparent'}
strokeWidth={1.5 * unitsPerPx}
className={`hover:stroke-white/60 ${drag?.layerId === l.id ? 'cursor-grabbing' : 'cursor-grab'}`}
onPointerDown={(e) => onLayerPointerDown(e, l)}
/>
);
})}
</svg>
</div>
</div>
) : (
<p className="m-auto text-sm text-slate-500">Vyberte alebo vytvorte šablónu.</p>
)}
</div>
<p className="px-3 py-1.5 text-[10px] text-slate-500 border-t border-slate-800">
Ťahaním posuniete vrstvu · šípky = jemný posun (Shift = väčší) · Delete = zmazať vrstvu · Ctrl+Z / Ctrl+Y · Ctrl+S = uložiť
</p>
</div>
{/* Vlastnosti */}
<div className="border-l border-gray-100 min-h-0 overflow-y-auto p-4 space-y-5">
{badge && (
<>
<Section title="Pozadie">
<input ref={fileRef} type="file" accept=".svg,image/svg+xml,image/png,image/jpeg,image/webp" className="hidden" onChange={uploadBackground} />
<button type="button" onClick={() => fileRef.current?.click()} className="w-full flex items-center justify-center gap-1.5 text-[11px] font-bold text-gray-700 border border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 rounded-lg py-2">
<Upload className="w-3.5 h-3.5" /> Nahrať pozadie (SVG alebo PNG)
</button>
</Section>
<Section title={`Vrstvy (${badge.layersConfig.length})`}>
<div className="flex flex-wrap gap-1">
{QUICK_LAYERS.map((q) => (
<button key={q.label} type="button" onClick={() => addLayer(q.text, q.fontSize)} className="text-[10px] font-bold text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md px-2 py-1">
+ {q.label}
</button>
))}
</div>
<div className="space-y-1">
{badge.layersConfig.map((l) => (
<div
key={l.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(l.id)}
className={`group flex items-center gap-2 rounded-lg border px-2 py-1.5 cursor-pointer ${l.id === selectedId ? 'border-sky-400 bg-sky-50' : 'border-gray-200 hover:border-gray-300'}`}
>
<span className="w-3 h-3 rounded-sm border border-black/10 shrink-0" style={{ backgroundColor: l.fill }} />
<span className="flex-1 text-[11px] font-mono text-gray-800 truncate">{l.text || '(prázdne)'}</span>
<button type="button" onClick={(e) => { e.stopPropagation(); removeLayer(l.id); }} className="text-gray-300 hover:text-red-600 opacity-0 group-hover:opacity-100" title="Zmazať vrstvu">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</Section>
{layer ? (
<Section
title="Vybraná vrstva"
action={
<button type="button" onClick={() => updateLayer(layer.id, { x: round1(vb.x + vb.w / 2), align: 'center' })} className="flex items-center gap-1 text-[10px] font-bold text-gray-500 hover:text-gray-900" title="Vycentrovať vodorovne">
<Crosshair className="w-3 h-3" /> Na stred
</button>
}
>
<input
value={layer.text || ''}
onChange={(e) => updateLayer(layer.id, { text: e.target.value })}
className="w-full text-xs font-mono border border-gray-300 rounded-lg px-2 py-1.5"
/>
<div className="flex flex-wrap gap-1">
{TOKENS.map((t) => (
<button key={t.token} type="button" onClick={() => insertToken(t.token)} className="text-[10px] font-semibold text-sky-700 bg-sky-50 hover:bg-sky-100 rounded px-1.5 py-0.5" title={`Vložiť ${t.token}`}>
{t.label}
</button>
))}
</div>
<div className="grid grid-cols-[1fr_96px] gap-1.5">
<select
value={isKnownFont ? fontName : '__custom'}
onChange={(e) => {
if (e.target.value === '__custom') return updateLayer(layer.id, { fontFamily: '', fontSource: 'google' });
const f = FONTS.find((x) => x.name === e.target.value)!;
updateLayer(layer.id, { fontFamily: f.name, fontSource: f.source });
}}
className="text-xs border border-gray-300 rounded-lg px-1.5 py-1.5 bg-white"
style={{ fontFamily: fontName }}
>
{FONTS.map((f) => (
<option key={f.name} value={f.name} style={{ fontFamily: f.name }}>
{f.name}
</option>
))}
<option value="__custom">Iný Google font…</option>
</select>
<select value={layer.fontWeight || '700'} onChange={(e) => updateLayer(layer.id, { fontWeight: e.target.value })} className="text-xs border border-gray-300 rounded-lg px-1.5 py-1.5 bg-white">
{WEIGHTS.map(([w, n]) => (
<option key={w} value={w}>
{w} {n}
</option>
))}
</select>
</div>
{!isKnownFont && (
<div className="space-y-1.5 rounded-lg border border-gray-200 p-2">
<div className="grid grid-cols-2 gap-1 bg-gray-100 p-0.5 rounded-md">
{(['google', 'adobe'] as const).map((src) => (
<button
key={src}
type="button"
onClick={() => updateLayer(layer.id, { fontSource: src })}
className={`text-[10px] font-bold py-0.5 rounded ${(layer.fontSource || 'google') === src ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500'}`}
>
{src === 'google' ? 'Google Fonts' : 'Adobe Fonts'}
</button>
))}
</div>
<input
value={layer.fontFamily || ''}
onChange={(e) => updateLayer(layer.id, { fontFamily: e.target.value })}
placeholder={layer.fontSource === 'adobe' ? 'Názov písma v Adobe, napr. aptly' : 'Názov Google fontu, napr. Archivo Black'}
className="w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5"
/>
{layer.fontSource === 'adobe' && (
<input
value={layer.fontUrl || ''}
onChange={(e) => updateLayer(layer.id, { fontUrl: e.target.value })}
placeholder="Odkaz na CSS z Adobe Fonts (https://use.typekit.net/xxxx.css)"
className="w-full text-[10px] font-mono border border-gray-300 rounded-lg px-2 py-1.5"
/>
)}
</div>
)}
<NumberSlider label="Veľkosť" value={layer.fontSize || 16} min={4} max={120} onChange={(v) => updateLayer(layer.id, { fontSize: v })} />
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-semibold text-gray-600 w-[70px]">Farba textu</span>
{COLORS.map(([c, name]) => (
<button
key={c}
type="button"
onClick={() => updateLayer(layer.id, { fill: c })}
className={`w-6 h-6 rounded-full border ${layer.fill?.toLowerCase() === c ? 'ring-2 ring-sky-400 border-white' : 'border-black/20'}`}
style={{ backgroundColor: c }}
title={name}
/>
))}
<HexColorInput key={layer.id} value={layer.fill || '#ffffff'} onChange={(fill) => updateLayer(layer.id, { fill })} />
</div>
<div className="grid grid-cols-3 gap-1 bg-gray-100 p-0.5 rounded-lg">
{([
['left', AlignLeft, 'Vľavo'],
['center', AlignCenter, 'Na stred'],
['right', AlignRight, 'Vpravo']
] as const).map(([a, Icon, title]) => (
<button
key={a}
type="button"
title={`Zarovnanie textu: ${title}`}
onClick={() => updateLayer(layer.id, { align: a })}
className={`flex justify-center py-1 rounded ${(layer.align || 'center') === a ? 'bg-white shadow-sm text-gray-900' : 'text-gray-500'}`}
>
<Icon className="w-3.5 h-3.5" />
</button>
))}
</div>
<div className="grid grid-cols-2 gap-1.5">
{(['x', 'y'] as const).map((k) => (
<label key={k} className="flex items-center gap-1.5 text-[10px] font-semibold text-gray-600">
{k.toUpperCase()}
<input type="number" step={0.5} value={layer[k]} onChange={(e) => updateLayer(layer.id, { [k]: Number(e.target.value) })} className="w-full text-[11px] font-mono border border-gray-300 rounded px-1.5 py-1" />
</label>
))}
</div>
{hasCents && (
<div className="rounded-lg bg-sky-50 border border-sky-100 p-2 space-y-1.5">
<span className="text-[10px] font-bold text-sky-900">Centy (horný index)</span>
<NumberSlider label="Veľkosť" value={layer.indexFontSize || Math.max(10, Math.round((layer.fontSize || 16) * 0.55))} min={4} max={80} onChange={(v) => updateLayer(layer.id, { indexFontSize: v })} />
<NumberSlider label="Hore / dole" value={layer.indexOffsetY ?? -Math.round((layer.fontSize || 16) * 0.45)} min={-60} max={30} onChange={(v) => updateLayer(layer.id, { indexOffsetY: v })} />
<NumberSlider label="Medzera" value={layer.indexOffsetX ?? 1} min={-20} max={30} onChange={(v) => updateLayer(layer.id, { indexOffsetX: v })} />
</div>
)}
</Section>
) : (
<p className="text-[11px] text-gray-400">Kliknite na vrstvu na plátne alebo v zozname.</p>
)}
</>
)}
</div>
</div>
{/* Pätička */}
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-end gap-2 bg-gray-50">
<button type="button" onClick={close} className="text-xs font-bold text-gray-600 hover:text-gray-900 px-3 py-2">
Zavrieť
</button>
<button
type="button"
onClick={() => save()}
disabled={!badge || saving || !isDirty}
className="flex items-center gap-1.5 text-xs font-bold text-gray-800 bg-white border border-gray-300 hover:bg-gray-100 disabled:opacity-40 px-3 py-2 rounded-lg"
>
<Save className="w-3.5 h-3.5" /> {saving ? 'Ukladám…' : 'Uložiť šablónu'}
</button>
<button
type="button"
onClick={applyToProduct}
disabled={!badge || saving}
className="flex items-center gap-1.5 text-xs font-bold text-white bg-[#e2001a] hover:bg-red-700 disabled:opacity-40 px-4 py-2 rounded-lg"
>
<Check className="w-3.5 h-3.5" /> {isDirty ? 'Uložiť a použiť na produkt' : 'Použiť na produkt'}
</button>
</div>
</div>
</div>
);
};