'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 }) => (
);
// 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('