- sablona moze mat layout (posun cenovky, posun a velkost stitkov); produkt bez vlastneho nastavenia ho preberie - tlacidlo v produkte ulozi aktualne rozlozenie ako predvolene pre vsetky sablony rovnakeho druhu a produkty v newsletteri ho prevezmu - Zakladna: cenovka -14 / -14, stitky -4 / +8, 14 px Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
import { BadgeTemplate, BadgeLayer, BadgeImportTags, BadgeLayout } from '@/types/newsletter';
|
||
|
||
// Vymazané všetky preddefinované šablóny — v systéme sú výhradne vlastné cenovky vytvorené používateľom
|
||
export const DEFAULT_BADGE_TEMPLATES: BadgeTemplate[] = [];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Šablóny v databáze (tabuľka badges) a kód šablóny pre hromadný import
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface BadgeRow {
|
||
id: string;
|
||
name: string;
|
||
svg_raw: string | null;
|
||
layers_config:
|
||
| BadgeLayer[]
|
||
| {
|
||
layers?: BadgeLayer[];
|
||
width?: number;
|
||
align?: BadgeTemplate['align'];
|
||
paddingTop?: number;
|
||
paddingBottom?: number;
|
||
importTags?: BadgeImportTags;
|
||
isDefault?: boolean;
|
||
layout?: BadgeLayout;
|
||
}
|
||
| null;
|
||
}
|
||
|
||
// Riadok tabuľky badges → šablóna (staršie riadky majú v layers_config len pole vrstiev)
|
||
export function badgeFromRow(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,
|
||
importTags: cfg?.importTags,
|
||
isDefault: cfg?.isDefault || undefined,
|
||
layout: cfg?.layout
|
||
};
|
||
}
|
||
|
||
// Kód šablóny pre hromadný import = názov šablóny upravený na kód:
|
||
// bez diakritiky, veľké písmená, medzery a interpunkcia → _ (napr. „Super cena – 1+1“ → SUPER_CENA_1_1)
|
||
export function normalizeBadgeCode(value: string | null | undefined): string {
|
||
return String(value || '')
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.toUpperCase()
|
||
.trim()
|
||
.replace(/[^A-Z0-9]+/g, '_')
|
||
.replace(/^_+|_+$/g, '');
|
||
}
|
||
|
||
export const badgeCode = (b: Pick<BadgeTemplate, 'name'>) => normalizeBadgeCode(b.name);
|
||
|
||
// Šablóna podľa hodnoty zo súboru (kód alebo názov – oboje sa porovná po úprave na kód)
|
||
export function findBadgeByCode(badges: BadgeTemplate[], value: string | null | undefined): BadgeTemplate | undefined {
|
||
const wanted = normalizeBadgeCode(value);
|
||
if (!wanted) return undefined;
|
||
return badges.find((b) => badgeCode(b) === wanted);
|
||
}
|
||
|
||
export interface BadgeProductData {
|
||
priceMain: string;
|
||
priceSub: string;
|
||
discountText?: string;
|
||
unitPrice?: string;
|
||
cardPriceMain?: string; // cena s kartou → {cena_s_kartou}
|
||
cardPriceSub?: string;
|
||
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
|
||
appPriceMain?: string; // cena pre appkáčov → {cena_app}
|
||
appPriceSub?: string;
|
||
appDiscount?: string; // → {zlava_app}
|
||
appUnitPrice?: string; // → {jednotkova_cena_app}
|
||
cardUnitPrice?: string; // → {jednotkova_cena_s_kartou}
|
||
noCardPriceMain?: string; // cena bez vernostnej karty → {cena_bez_karty}
|
||
noCardPriceSub?: string;
|
||
noCardDiscount?: string; // → {zlava_bez_karty}
|
||
noCardUnitPrice?: string; // → {jednotkova_cena_bez_karty}
|
||
}
|
||
|
||
// Premenné ceny s centami v hornom indexe: {cena}, {cena_s_kartou}, {cena_bez_karty} a {cena_app}
|
||
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou|cena_bez_karty|cena_app)\}/;
|
||
|
||
// Nahradenie jednoduchých textových premenných (bez horného indexu)
|
||
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
|
||
return text
|
||
.replace(/{cela_suma}/g, product.priceMain || '0')
|
||
.replace(/{zlava_s_kartou}/g, (product.cardDiscount || '').replace('-', ''))
|
||
.replace(/{zlava_bez_karty}/g, (product.noCardDiscount || '').replace('-', ''))
|
||
.replace(/{zlava_app}/g, (product.appDiscount || '').replace('-', ''))
|
||
.replace(/{jednotkova_cena_s_kartou}/g, product.cardUnitPrice || product.unitPrice || '')
|
||
.replace(/{jednotkova_cena_bez_karty}/g, product.noCardUnitPrice || '')
|
||
.replace(/{jednotkova_cena_app}/g, product.appUnitPrice || '')
|
||
.replace(/{zlava}/g, (product.discountText || '').replace('-', ''))
|
||
.replace(/{jednotkova_cena}/g, product.unitPrice || '')
|
||
.replace(/{mena}/g, '€');
|
||
}
|
||
|
||
// Celé eurá a centy pre danú cenovú premennú
|
||
export function badgePriceParts(token: string, product: BadgeProductData): [string, string] {
|
||
if (token === 'cena_s_kartou') {
|
||
return [product.cardPriceMain || '', product.cardPriceSub || ''];
|
||
}
|
||
if (token === 'cena_app') {
|
||
return [product.appPriceMain || '', product.appPriceSub || ''];
|
||
}
|
||
if (token === 'cena_bez_karty') {
|
||
return [product.noCardPriceMain || '', product.noCardPriceSub || ''];
|
||
}
|
||
return [product.priceMain || '0', product.priceSub || '00'];
|
||
}
|
||
|
||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||
|
||
/**
|
||
* Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek
|
||
* editovacích rámčekov, tooltippov či ovládacích prvkov.
|
||
* Centy sú naformátované ako horný index (tspan dy="-0.45em").
|
||
* Cena je predvolene bez znaku €.
|
||
*/
|
||
export function renderCleanBadgeSvg(
|
||
template: BadgeTemplate,
|
||
product: BadgeProductData,
|
||
overrideWidth?: number
|
||
): string {
|
||
const pSub = product.priceSub || '00';
|
||
|
||
// Získanie viewBox alebo predvolených rozmerov
|
||
let vb = '0 0 140 95';
|
||
let vbWidth = 140;
|
||
let vbHeight = 95;
|
||
|
||
if (template.svgRaw) {
|
||
const vbMatch = template.svgRaw.match(/viewBox=["']([^"']+)["']/i);
|
||
if (vbMatch) {
|
||
vb = vbMatch[1];
|
||
const parts = vb.trim().split(/\s+/).map(Number);
|
||
if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) {
|
||
vbWidth = parts[2];
|
||
vbHeight = parts[3];
|
||
}
|
||
} else {
|
||
const wMatch = template.svgRaw.match(/width=["']([^"']+)["']/i);
|
||
const hMatch = template.svgRaw.match(/height=["']([^"']+)["']/i);
|
||
if (wMatch && hMatch) {
|
||
const w = parseFloat(wMatch[1]);
|
||
const h = parseFloat(hMatch[1]);
|
||
if (w > 0 && h > 0) {
|
||
vbWidth = w;
|
||
vbHeight = h;
|
||
vb = `0 0 ${w} ${h}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extrahovanie vnútorného obsahu SVG (medzi <svg...> a </svg>)
|
||
let innerBg = '';
|
||
if (template.svgRaw) {
|
||
const inside = template.svgRaw.replace(/<\/?svg[^>]*>/gi, '').trim();
|
||
innerBg = inside;
|
||
} else {
|
||
innerBg = `<rect width="${vbWidth}" height="${vbHeight}" rx="14" fill="#ffd100"/>`;
|
||
}
|
||
|
||
// Šírka výsledného SVG
|
||
const displayWidth = overrideWidth || template.width || 110;
|
||
const displayHeight = Math.round((displayWidth / vbWidth) * vbHeight);
|
||
|
||
// Zostavenie textových vrstiev
|
||
const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => {
|
||
// napr. nápis „zľava“ len vtedy, keď má produkt zľavu
|
||
if (layer.showIfFilled && !replaceBadgeTextVars(layer.showIfFilled, product).trim()) return '';
|
||
const rawText = layer.text || '';
|
||
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
||
// Cena od 10 € (dvojciferné eurá): menšie písmo, účiaria eur aj centov ostáva na mieste
|
||
// (0.43 ≈ vzdialenosť stredu písma od účiarie v em – vrstvy sa kreslia so strednou účiariou)
|
||
const baseSize = layer.fontSize || 16;
|
||
const baseSub = layer.indexFontSize || Math.max(10, Math.round(baseSize * 0.55));
|
||
const long = !!priceMatch && !!layer.longPriceScale && layer.longPriceScale < 1 && badgePriceParts(priceMatch[1], product)[0].length >= 2;
|
||
const k = long ? layer.longPriceScale! : 1;
|
||
const fontSize = round2(baseSize * k);
|
||
const subFontSize = round2(baseSub * k);
|
||
const layerY = long ? round2(layer.y + 0.43 * baseSize * (1 - k)) : layer.y;
|
||
const offY = layer.indexOffsetY !== undefined ? round2(layer.indexOffsetY - (long ? 0.43 * (1 - k) * (baseSize - baseSub) : 0)) : undefined;
|
||
const offX = layer.indexOffsetX !== undefined ? round2(layer.indexOffsetX * k) : undefined;
|
||
const subDy = offY !== undefined ? `${offY}px` : '-0.45em';
|
||
const subDx = offX !== undefined ? `${offX}px` : '1px';
|
||
const revertDy = offY !== undefined ? `${-offY}px` : '0.45em';
|
||
const fontWeight = layer.fontWeight || '700';
|
||
const fill = layer.fill || '#ffffff';
|
||
// písmo môže mať viac názvov oddelených čiarkou (napr. názov v Adobe Fonts a názov nainštalovaného písma)
|
||
const fontFamily = layer.fontFamily
|
||
? `${layer.fontFamily
|
||
.split(',')
|
||
.map((f) => `'${f.trim().replace(/'/g, '')}'`)
|
||
.join(', ')}, -apple-system, BlinkMacSystemFont, Arial, sans-serif`
|
||
: `'Arial Narrow', Arial, sans-serif`;
|
||
|
||
let anchor = 'middle';
|
||
if (layer.align === 'left') anchor = 'start';
|
||
if (layer.align === 'right') anchor = 'end';
|
||
|
||
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
||
let contentSvg = '';
|
||
let caronDx = ''; // posuny písmen za ľ/ť/ď (atribút dx na <text>, bez vnorených tspan)
|
||
if (priceMatch) {
|
||
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
||
const idx = priceMatch.index || 0;
|
||
const before = escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product));
|
||
const after = escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product));
|
||
contentSvg = `${before}${escapeXml(tokMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(tokSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||
} else if (rawText.includes('{centy}')) {
|
||
const parts = rawText.split('{centy}');
|
||
const before = escapeXml(parts[0] || '');
|
||
const after = escapeXml(parts[1] || '');
|
||
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||
} else {
|
||
const plain = replaceBadgeTextVars(rawText, product);
|
||
contentSvg = escapeXml(plain);
|
||
caronDx = caronOffsets(plain, fontSize);
|
||
}
|
||
|
||
// data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne
|
||
return `<text data-layer-index="${layerIndex}" x="${layer.x}" y="${layerY}" text-anchor="${anchor}" dominant-baseline="central"${
|
||
layer.scaleY && layer.scaleY !== 1 ? ` transform="translate(${layer.x} ${layerY}) scale(1 ${layer.scaleY}) translate(${-layer.x} ${-layerY})"` : ''
|
||
}${caronDx ? ` dx="${caronDx}"` : ''}${layer.letterSpacing ? ` letter-spacing="${layer.letterSpacing}em"` : ''} font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||
}).join('\n ');
|
||
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
||
${innerBg}
|
||
${textLayersSvg}
|
||
</svg>`;
|
||
}
|
||
|
||
/**
|
||
* Skonvertuje SVG reťazec do čistého PNG Data URL v prehliadači
|
||
* (bez akýchkoľvek DOM prvkov alebo modrých drag & drop rámčekov).
|
||
*/
|
||
export async function svgToCleanPng(
|
||
svgString: string,
|
||
width: number,
|
||
height: number
|
||
): Promise<string> {
|
||
if (typeof window === 'undefined') {
|
||
return `data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`;
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
try {
|
||
const img = new Image();
|
||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
||
const url = URL.createObjectURL(svgBlob);
|
||
|
||
img.onload = () => {
|
||
try {
|
||
const canvas = document.createElement('canvas');
|
||
const scale = 2; // Vysoké rozlíšenie Retina
|
||
canvas.width = width * scale;
|
||
canvas.height = height * scale;
|
||
const ctx = canvas.getContext('2d');
|
||
if (ctx) {
|
||
ctx.scale(scale, scale);
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
URL.revokeObjectURL(url);
|
||
resolve(canvas.toDataURL('image/png'));
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.warn('Canvas export fallback to SVG data URL', e);
|
||
}
|
||
URL.revokeObjectURL(url);
|
||
resolve(`data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`);
|
||
};
|
||
|
||
img.onerror = () => {
|
||
URL.revokeObjectURL(url);
|
||
resolve(`data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`);
|
||
};
|
||
|
||
img.src = url;
|
||
} catch (err) {
|
||
resolve(`data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Mäkčeň pri ľ, ť, ď je v písme široký – písmeno za ním trochu pritiahneme (ako v návrhoch z Illustratora).
|
||
// Posun ide do zoznamu dx priamo na <text> (po znakoch), nie do vnoreného <tspan> – ten pri prevode karty
|
||
// na obrázok strácal zarovnanie na strednú účiaru a písmeno vyskočilo z riadku.
|
||
function caronOffsets(text: string, fontSize: number): string {
|
||
const chars = [...text];
|
||
const shift = -Math.round(fontSize * 0.05 * 100) / 100;
|
||
const dx = chars.map((c, i) => (i > 0 && /[ľťď]/.test(chars[i - 1]) && !/\s/.test(c) ? shift : 0));
|
||
return dx.some(Boolean) ? dx.join(' ') : '';
|
||
}
|
||
|
||
function escapeXml(unsafe: string): string {
|
||
if (!unsafe) return '';
|
||
return unsafe
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|