Files
Coop-NL/src/lib/badgeUtils.ts
T
Jakub_HostacnyandClaude Opus 5.5 b67db3cd49 feat: predvolene rozlozenie v sablone cenovky (poloha, velkost, stitky predajni)
- 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>
2026-10-22 15:22:57 +02:00

313 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}