'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 }) => (
{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}}
); // 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 (
{ 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" />
); }; // --------------------------------------------------------------------------- // 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 = ({ isOpen, onClose, onSelectBadge, currentDiscount = '25%', currentPriceMain = '1', currentPriceSub = '99', currentUnitPrice = '1kg = 4,91 €', currentCardPriceMain = '1', currentCardPriceSub = '49', currentCardDiscount = '30%' }) => { const [badges, setBadges] = useState([]); 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); 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, 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 = {}; 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: 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) => { 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 = String(reader.result || ''); const img = new Image(); img.onload = () => { const w = img.naturalWidth || 140; const h = img.naturalHeight || 95; commit({ ...badge, svgRaw: `` }); }; 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 = { 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 (
{/* 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" /> )} {badge && ( )} {isDirty && Neuložené} {savedFlash && Uložené}
{/* Šablóny */}
Šablóny ({badges.length})
{badges.map((b) => (
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.id === badge?.id && badge ? badge.name : b.name}
))} {!badges.length &&

Zatiaľ žiadne šablóny.

}
{/* 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" />
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.

)}

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

{/* Vlastnosti */}
{badge && ( <>
{QUICK_LAYERS.map((q) => ( ))}
{badge.layersConfig.map((l) => (
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'}`} > {l.text || '(prázdne)'}
))}
{layer ? (
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"> Na stred } > 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 })} />
Farba textu {COLORS.map(([c, name]) => (
{([ ['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.

)} )}
{/* Pätička */}
); };