- 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>
986 lines
44 KiB
TypeScript
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>
|
|
);
|
|
};
|