From 5add629bb500d29405aa8800628e7fe432f85402 Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Wed, 21 Oct 2026 11:57:35 +0200 Subject: [PATCH] feat: novy editor cenoviek - skutocna cenovka na platne, pohodlnejsia praca - platno ukazuje skutocne SVG cenovky (rovnake ako v e-maile); vrstvy sa vyberaju a tahaju priamo na nej v suradniciach cenovky, s prichytenim na stred - klavesnica: sipky posun (Shift = viac), Delete, Ctrl+Z / Ctrl+Y, Ctrl+S - historia krokov (spat / znova), indikator neulozenych zmien, potvrdenie pri zatvoreni alebo prepnuti sablony - prehladny panel: pozadie a predvolena sirka, vrstvy s rychlym pridanim, vybrana vrstva (text s klikatelnymi premennymi, pismo vratane Google/Adobe, rez, velkost, COOP farby, zarovnanie, X/Y, centy) - skusobna cena na overenie dlhsich cien; Ulozit a pouzit na produkt - odstranene nastavenia zarovnania a odsadenia (pri karte v style letaku nemaju vplyv) Co-Authored-By: Claude Opus 5.5 --- src/components/editor/BadgeEditorModal.tsx | 2100 ++++++++------------ src/lib/badgeUtils.ts | 5 +- 2 files changed, 815 insertions(+), 1290 deletions(-) diff --git a/src/components/editor/BadgeEditorModal.tsx b/src/components/editor/BadgeEditorModal.tsx index f9aa878..dcbbb60 100644 --- a/src/components/editor/BadgeEditorModal.tsx +++ b/src/components/editor/BadgeEditorModal.tsx @@ -1,15 +1,42 @@ 'use client'; -import React, { useState, useRef, useEffect } from 'react'; +// 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 { DEFAULT_BADGE_TEMPLATES, renderCleanBadgeSvg, svgToCleanPng, BADGE_PRICE_TOKEN_RE, badgePriceParts, replaceBadgeTextVars } from '@/lib/badgeUtils'; -import { Tag, Plus, Check, Trash2, X, Upload, Eye, Move, AlignLeft, AlignCenter, AlignRight, ZoomIn, ZoomOut, Copy } from 'lucide-react'; +import { renderCleanBadgeSvg, BadgeProductData } 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) -function newLocalBadgeId(): string { - return `badge-${Date.now()}`; -} +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) { @@ -26,10 +53,163 @@ function toBadgeDbPayload(badge: BadgeTemplate) { }; } -// 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 BadgeRow { + id: string; + name: string; + svg_raw: string | null; + layers_config: BadgeLayer[] | { layers?: BadgeLayer[]; width?: number; align?: BadgeTemplate['align']; paddingTop?: number; paddingBottom?: number } | null; +} + +function fromBadgeRow(d: BadgeRow): BadgeTemplate { + const cfg = d.layers_config && !Array.isArray(d.layers_config) ? d.layers_config : null; + return { + id: d.id, + name: d.name, + svgRaw: d.svg_raw || '', + layersConfig: cfg ? cfg.layers || [] : (d.layers_config as BadgeLayer[]) || [], + width: cfg?.width || 110, + align: cfg?.align || 'center', + paddingTop: cfg?.paddingTop ?? 4, + paddingBottom: cfg?.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 = ['#ffffff', '#000000', '#e2001a', '#ffde00', '#ffd100', '#003399', '#001a33', '#16a34a']; + +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 }) => ( +
+
+ {title} + {action} +
+ {children} +
+); + +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 }) => ( +
+ {label} + onChange(Number(e.target.value))} + className="w-full h-1 accent-red-600 cursor-pointer" + /> +
+ 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 && {suffix}} +
+
+); + +// --------------------------------------------------------------------------- +// Editor +// --------------------------------------------------------------------------- interface BadgeEditorModalProps { isOpen: boolean; @@ -44,6 +224,14 @@ interface BadgeEditorModalProps { currentCardDiscount?: string; } +interface DragState { + layerId: string; + startX: number; + startY: number; + layerX: number; + layerY: number; +} + export const BadgeEditorModal: React.FC = ({ isOpen, onClose, @@ -56,1370 +244,706 @@ export const BadgeEditorModal: React.FC = ({ currentCardPriceSub = '49', currentCardDiscount = '30%' }) => { - 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([]); - const [activeBadge, setActiveBadge] = useState(null); - const [selectedLayerId, setSelectedLayerId] = useState(null); - const [isExportingPng, setIsExportingPng] = useState(false); - const [isSavingDb, setIsSavingDb] = useState(false); + const [badge, setBadge] = useState(null); + const [savedJson, setSavedJson] = useState(''); + const [selectedId, setSelectedId] = useState(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>({}); + const [drag, setDrag] = useState(null); + const [snapX, setSnapX] = useState(false); + const canvasRef = useRef(null); + const fileRef = useRef(null); - // Priblíženie plátna (len pre editor, neovplyvňuje výslednú cenovku) - const [canvasZoom, setCanvasZoom] = useState(1); - const canvasAreaRef = useRef(null); - const hasActiveBadge = !!activeBadge; + const isDirty = !!badge && JSON.stringify(badge) !== savedJson; + 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 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 product: BadgeProductData = { + priceMain: testPrice?.main || currentPriceMain, + priceSub: testPrice?.sub || currentPriceSub, + discountText: currentDiscount, + unitPrice: currentUnitPrice, + cardPriceMain: testPrice?.main || currentCardPriceMain, + cardPriceSub: testPrice?.sub || currentCardPriceSub, + cardDiscount: currentCardDiscount }; - 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 - }); - }; + // --- 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 (!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); - + 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(fromBadgeRow); + } 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 () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleDragEnd); - window.removeEventListener('touchmove', handleTouchMove); - window.removeEventListener('touchend', handleDragEnd); + cancelled = true; }; - }, [dragState, canvasZoom]); + }, [isOpen, open]); - // Načítanie písiem pre všetky vrstvy aktívnej cenovky + // Písma vrstiev 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); - } + 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); }; - if (!isOpen) return null; + const updateLayer = (id: string, patch: Partial, record = true) => { + if (!badge) return; + commit({ ...badge, layersConfig: badge.layersConfig.map((l) => (l.id === id ? { ...l, ...patch } : l)) }, record); + }; - // Vytvorenie novej šablóny - const handleCreateNewTemplate = () => { - const newBadge: BadgeTemplate = { - id: `badge-${Date.now()}`, - name: 'Nová vlastná cenovka', - svgRaw: ` - - `, + 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 = {}; + root.querySelectorAll('[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 => { + 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: '', 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' - } + { 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([newBadge, ...badges]); - setActiveBadge(newBadge); - setSelectedLayerId(newBadge.layersConfig[0].id); + setBadges([b, ...badges]); + open(b); + setSavedJson(''); // nová šablóna je neuložená }; - // Upload vlastného SVG alebo PNG súboru ako podkladu - const handleBackgroundUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file || !activeBadge) return; + 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); + }; - 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(' { + 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) => { + 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(' { - const dataUrl = event.target?.result as string; + } 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; - const svgContent = ``; - setActiveBadge({ - ...activeBadge, - svgRaw: svgContent + commit({ + ...badge, + svgRaw: `` }); }; img.src = dataUrl; }; reader.readAsDataURL(file); } else { - alert('Podporované sú len grafické súbory vo formáte SVG alebo PNG.'); + alert('Podporované sú SVG a PNG/JPG obrázky.'); } }; - const activeLayer = - activeBadge?.layersConfig?.find((l) => l.id === selectedLayerId) || - activeBadge?.layersConfig?.[0] || - null; - - const updateActiveLayer = (updates: Partial) => { - 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()}-${Math.random().toString(36).slice(2, 7)}`, - type: 'text', - text: presetText, - x: 70, - y: 50, - fontSize: 16, - fontWeight: '800', - fill: '#ffffff', - align: 'center' + // --- 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 = { + 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); + } }; - setActiveBadge({ - ...activeBadge, - layersConfig: [...activeBadge.layersConfig, newL] - }); - setSelectedLayerId(newL.id); - }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); - const removeLayer = (id: string) => { - if (!activeBadge || activeBadge.layersConfig.length <= 1) return; - setActiveBadge({ - ...activeBadge, - layersConfig: activeBadge.layersConfig.filter((l) => l.id !== id) - }); - }; + if (!isOpen) return null; - // 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: newLocalBadgeId(), - 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 { - // bez databázy ostane kópia v prehliadači - } - - 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 { - // localStorage nemusí byť dostupný - } - }; - - 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 € - // Ceny produktu, pre ktorý sa cenovka upravuje (náhľad premenných) - const previewProduct = { - priceMain: currentPriceMain, - priceSub: currentPriceSub, - discountText: currentDiscount, - unitPrice: currentUnitPrice, - cardPriceMain: currentCardPriceMain, - cardPriceSub: currentCardPriceSub, - cardDiscount: currentCardDiscount - }; - - 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'; - - const priceMatch = raw.match(BADGE_PRICE_TOKEN_RE); - if (priceMatch) { - const [tokMain, tokSub] = badgePriceParts(priceMatch[1], previewProduct); - const idx = priceMatch.index || 0; - return ( - - {replaceBadgeTextVars(raw.slice(0, idx), previewProduct)} - {tokMain} - - {tokSub} - - {replaceBadgeTextVars(raw.slice(idx + priceMatch[0].length), previewProduct)} - - ); - } - - if (raw.includes('{centy}')) { - const parts = raw.split('{centy}'); - return ( - - {parts[0]} - - {currentPriceSub} - - {parts[1]} - - ); - } - - return replaceBadgeTextVars(raw, previewProduct); - }; - - // 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, - previewProduct, - 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); - } - }; + 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 ( -
-
- {/* Header */} -
-
-
- -
-
-

- Editor grafických cenoviek (Opakovane použiteľné šablóny) -

-

- Nastavte rozmery, zarovnanie a rozmiestnenie vrstiev. Cena a centy sú dynamické a nezamŕzajú v rastri. -

-
+
+
+ {/* Hlavička */} +
+
+ +
+
+ Editor cenoviek + {badge && ( + 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" + /> + )} + {isDirty && Neuložené} + {savedFlash && Uložené} +
+
+ + +
-
- {/* Content Body */} -
- {/* 1. Stĺpec: Šablóny a Vytvorenie novej */} -
-
- - Šablóny ({badges.length}) - -
- -
+
{badges.map((b) => (
{ - setActiveBadge(b); - if (b.layersConfig?.[0]) setSelectedLayerId(b.layersConfig[0].id); - }} - className={`w-full text-left p-3 rounded-2xl border transition-all cursor-pointer relative group ${ - activeBadge?.id === b.id - ? 'bg-white border-[#e2001a] shadow-md ring-2 ring-red-500/10' - : 'bg-white/80 border-gray-200 hover:border-gray-300' - }`} + 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'}`} > -
-

{b.name}

-
- - {/* Zmazať sa dá každá šablóna, aj staršie predvolené (badge-preset-*) uložené v prehliadači */} - -
-
))} + {!badges.length &&

Zatiaľ žiadne šablóny.

}
- {/* 2. Stĺpec: Vektorový Canvas & Náhľad s Drag & Drop */} -
- {/* Lišta plátna: názov + ovládanie priblíženia */} -
-
- - Náhľad na plátne - - - Drag & Drop - -
+ {/* Plátno */} +
+
+ + setZoom(Number(e.target.value))} className="w-24 h-1 accent-blue-400" /> + + +
+ Skúšobná cena + { + 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" + /> +
- {activeBadge && ( -
- - setCanvasZoom(Number(e.target.value))} - className="w-24 h-1 accent-blue-400 cursor-pointer" - title="Priblíženie plátna" - /> - - +
setSelectedId(null)}> + {badge ? ( +
+
+
+ {/* Výber a ťahanie vrstiev (súradnice viewBoxu) */} + + {snapX && } + {badge.layersConfig.map((l) => { + const b = boxes[l.id]; + if (!b) return null; + const selected = l.id === selectedId; + const pad = 2 * unitsPerPx; + return ( + onLayerPointerDown(e, l)} + /> + ); + })} + +
+ ) : ( +

Vyberte alebo vytvorte šablónu.

)}
- - {activeBadge ? ( - <> - {/* Priblížiteľná a posúvateľná oblasť plátna (vyplní zvyšnú výšku stĺpca) */} -
- {/* Rezerva okolo cenovky, aby vrstvy a popisky presahujúce podklad neboli orezané */} -
- {/* Obal simulujúci veľkosť, zarovnanie a odsadenie */} -
-
- {/* SVG Podklad */} -
- - {/* Dynamické textové vrstvy nad SVG podkladom (Drag & Drop) */} - {activeBadge.layersConfig.map((layer) => { - const isSelected = layer.id === selectedLayerId; - const isDraggingThis = dragState?.layerId === layer.id; - return ( -
handleLayerMouseDown(e, layer)} - onTouchStart={(e) => handleLayerTouchStart(e, layer)} - onClick={(e) => { - e.stopPropagation(); - setSelectedLayerId(layer.id); - }} - style={{ - position: 'absolute', - left: `${layer.x}px`, - top: `${layer.y}px`, - transform: 'translate(-50%, -50%)', - fontSize: `${layer.fontSize}px`, - fontWeight: (layer.fontWeight || '700') as any, - color: layer.fill, - fontFamily: layer.fontFamily - ? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` - : 'Poppins, Arial, sans-serif', - whiteSpace: 'nowrap', - cursor: isDraggingThis ? 'grabbing' : 'grab', - userSelect: 'none' - }} - className={`px-1.5 py-0.5 rounded transition-all select-none ${ - isSelected - ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-slate-900 bg-blue-500/20 shadow-lg' - : 'hover:outline hover:outline-1 hover:outline-white/60 hover:bg-white/10' - }`} - title="Ťahajte myšou pre zmenu pozície" - > - {renderLayerVisualContent(layer)} - - {isSelected && ( - - {layer.x}px, {layer.y}px - - )} -
- ); - })} -
-
-
-
- -
- {/* Info o premenných a centoch */} -
- Dostupné dynamické značky: -
- - {'{cena}'} → {currentPriceMain} - {currentPriceSub} (bez €) - - - {'{zlava}'} → {currentDiscount.replace('-', '')} - - - {'{cena_s_kartou}'} → {currentCardPriceMain} - {currentCardPriceSub} - - - {'{zlava_s_kartou}'} → {currentCardDiscount.replace('-', '')} - - - {'{mena}'} → € - -
-
-
- - ) : ( -

Vyberte alebo vytvorte novú šablónu cenovky.

- )} +

+ Ťahaním posuniete vrstvu · šípky = jemný posun (Shift = väčší) · Delete = zmazať vrstvu · Ctrl+Z / Ctrl+Y · Ctrl+S = uložiť +

- {/* 3. Stĺpec: Konfigurácia rozmerov, zarovnania a vrstiev */} -
- {activeBadge ? ( + {/* Vlastnosti */} +
+ {badge && ( <> - {/* Názov šablóny */} -
- - setActiveBadge({ ...activeBadge, name: e.target.value })} - className="w-full text-xs font-bold border border-gray-300 rounded-xl p-2.5" - /> -
+
+ + + commit({ ...badge, width: v }, false)} /> +
- {/* Sekcia: Rozmery, zarovnanie a odsadenie cenovky */} -
- - Rozmery a Zarovnanie cenovky - - - {/* Šírka cenovky */} -
-
- - - {activeBadge.width || 110} px - -
- setActiveBadge({ ...activeBadge, width: Number(e.target.value) })} - className="w-full accent-[#e2001a]" - /> -
- - {/* Zarovnanie na karte produktu */} -
- -
- - - -
-
- - {/* Odsadenie (Padding) */} -
-
- - setActiveBadge({ ...activeBadge, paddingTop: Number(e.target.value) })} - className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium" - /> -
-
- - setActiveBadge({ ...activeBadge, paddingBottom: Number(e.target.value) })} - className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-medium" - /> -
-
-
- - {/* Upload vlastného SVG alebo PNG podkladu */} -
- - -

- Podporuje SVG vektorové tvary aj priehľadné PNG obrázky. -

-
- - {/* Rýchle pridanie vrstiev */} -
- - Pridať vrstvu - -
- - - - - - - -
-
- - {/* Zoznam a nastavenie vrstiev */} -
- - Vrstvy ({activeBadge.layersConfig.length}) - -
- {activeBadge.layersConfig.map((l, idx) => ( - ))}
- - {activeLayer && ( -
-
- - updateActiveLayer({ text: e.target.value })} - className="w-full text-xs font-bold border border-gray-300 rounded-lg p-2 bg-white" - /> -
- -
-
- - updateActiveLayer({ fontSize: Number(e.target.value) })} - className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white" - /> -
-
- -
- updateActiveLayer({ fill: e.target.value })} - className="w-7 h-7 rounded cursor-pointer border-0" - /> - {activeLayer.fill} -
-
-
- - {/* Font výber */} -
- - -
- - - -
- - {(activeLayer.fontSource || 'google') === 'google' && ( -
- - - {(!POPULAR_FONTS.some((f) => f.name === (activeLayer.fontFamily || 'Poppins')) || - activeLayer.fontFamily === 'custom') && ( - { - const val = e.target.value; - loadFontDynamically(val, 'google'); - updateActiveLayer({ fontFamily: val, fontSource: 'google' }); - }} - className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white" - /> - )} -
- )} - - {activeLayer.fontSource === 'adobe' && ( -
- { - const url = e.target.value; - if (activeLayer.fontFamily) { - loadFontDynamically(activeLayer.fontFamily, 'adobe', url); - } - updateActiveLayer({ fontUrl: url, fontSource: 'adobe' }); - }} - className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white font-mono text-[10px]" - /> - { - const family = e.target.value; - if (activeLayer.fontUrl) { - loadFontDynamically(family, 'adobe', activeLayer.fontUrl); - } - updateActiveLayer({ fontFamily: family, fontSource: 'adobe' }); - }} - className="w-full text-xs border border-gray-300 rounded-lg p-1.5 bg-white" - /> -
- )} - - {activeLayer.fontSource === 'system' && ( - - )} - -
- - -
-
- -
-
- - updateActiveLayer({ x: Number(e.target.value) })} - className="w-full" - /> -
-
- - updateActiveLayer({ y: Number(e.target.value) })} - className="w-full" - /> -
-
- - {/* Nastavenie veľkosti a pozície horného indexu (centov) */} - {(activeLayer.text?.includes('{cena}') || activeLayer.text?.includes('{centy}')) && ( -
-
- - Nastavenie horného indexu (Centov) - - - {activeLayer.indexFontSize ?? Math.max(10, Math.round((activeLayer.fontSize || 16) * 0.55))}px - -
- - {/* Veľkosť indexu */} -
-
- Veľkosť písma indexu - - {activeLayer.indexFontSize ?? Math.max(10, Math.round((activeLayer.fontSize || 16) * 0.55))} px - -
- updateActiveLayer({ indexFontSize: Number(e.target.value) })} - className="w-full accent-blue-600" - /> -
- - {/* Vertikálny posun (Hore / Dole) */} -
-
- Vertikálna pozícia (Posun Hore / Dole) - - {activeLayer.indexOffsetY ?? -10} px - -
- updateActiveLayer({ indexOffsetY: Number(e.target.value) })} - className="w-full accent-blue-600" - /> -
- - {/* Horizontálna medzera (Vľavo / Vpravo) */} -
-
- Horizontálna medzera (Vľavo / Vpravo) - - {activeLayer.indexOffsetX ?? 2} px - -
- updateActiveLayer({ indexOffsetX: Number(e.target.value) })} - className="w-full accent-blue-600" - /> -
-
- )} - -
-
-
- )} -
+ ))} +
+ - {/* Tlačidlo: Uložiť šablónu do DB */} -
- + } > - - {isSavingDb ? 'Ukladám do Supabase...' : 'Uložiť túto šablónu (pre opakované použitie)'} - -
+ 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" + /> +
+ {TOKENS.map((t) => ( + + ))} +
+ +
+ + +
+ {!isKnownFont && ( +
+
+ {(['google', 'adobe'] as const).map((src) => ( + + ))} +
+ 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' && ( + 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" + /> + )} +
+ )} + + updateLayer(layer.id, { fontSize: v })} /> + +
+ {COLORS.map((c) => ( +
+ +
+ {([ + ['left', AlignLeft, 'Vľavo'], + ['center', AlignCenter, 'Na stred'], + ['right', AlignRight, 'Vpravo'] + ] as const).map(([a, Icon, title]) => ( + + ))} +
+ +
+ {(['x', 'y'] as const).map((k) => ( + + ))} +
+ + {hasCents && ( +
+ Centy (horný index) + updateLayer(layer.id, { indexFontSize: v })} /> + updateLayer(layer.id, { indexOffsetY: v })} /> + updateLayer(layer.id, { indexOffsetX: v })} /> +
+ )} + + ) : ( +

Kliknite na vrstvu na plátne alebo v zozname.

+ )} - ) : null} + )}
- {/* Footer */} -
- +
diff --git a/src/lib/badgeUtils.ts b/src/lib/badgeUtils.ts index cf51779..b4513e5 100644 --- a/src/lib/badgeUtils.ts +++ b/src/lib/badgeUtils.ts @@ -90,7 +90,7 @@ export function renderCleanBadgeSvg( const displayHeight = Math.round((displayWidth / vbWidth) * vbHeight); // Zostavenie textových vrstiev - const textLayersSvg = (template.layersConfig || []).map((layer) => { + const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => { const rawText = layer.text || ''; const fontSize = layer.fontSize || 16; const subFontSize = layer.indexFontSize || Math.max(10, Math.round(fontSize * 0.55)); @@ -125,7 +125,8 @@ export function renderCleanBadgeSvg( contentSvg = escapeXml(replaceBadgeTextVars(rawText, product)); } - return `${contentSvg}`; + // data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne + return `${contentSvg}`; }).join('\n '); return `