feat: reusable badge templates, dynamic price and superscript cents, size/align/padding controls, fix saving

This commit is contained in:
Jakub_Hostacny committed 2026-09-24 12:07:52 +02:00
1 parent f9aa2297ba
commit 5a6788b593
6 files changed
+1196 -359

No files matched your search

+324
View File
@@ -0,0 +1,324 @@
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
export const DEFAULT_BADGE_TEMPLATES: BadgeTemplate[] = [
{
id: 'badge-preset-yellow-akcia',
name: 'COOP Akcia (Žltá)',
svgRaw: `<svg width="140" height="95" viewBox="0 0 140 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="140" height="95" rx="14" fill="#ffd100"/>
<rect x="15" y="8" width="110" height="24" rx="6" fill="#e2001a"/>
</svg>`,
width: 110,
align: 'center',
paddingTop: 4,
paddingBottom: 4,
layersConfig: [
{
id: 'l-akcia-1',
type: 'text',
text: 'AKCIA',
x: 70,
y: 20,
fontSize: 14,
fontWeight: '900',
fontFamily: 'Poppins',
fill: '#ffffff',
align: 'center'
},
{
id: 'l-akcia-2',
type: 'text',
text: '{cena}',
x: 70,
y: 62,
fontSize: 42,
fontWeight: '900',
fontFamily: 'Arial Narrow',
fill: '#001a33',
align: 'center'
}
]
},
{
id: 'badge-preset-red-zlava',
name: 'COOP Zľava (Červená)',
svgRaw: `<svg width="140" height="95" viewBox="0 0 140 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="140" height="95" rx="14" fill="#e2001a"/>
<rect x="12" y="8" width="116" height="22" rx="5" fill="#ffffff" fill-opacity="0.2"/>
</svg>`,
width: 110,
align: 'center',
paddingTop: 4,
paddingBottom: 4,
layersConfig: [
{
id: 'l-zlava-1',
type: 'text',
text: 'ZĽAVA -{zlava}',
x: 70,
y: 19,
fontSize: 13,
fontWeight: '900',
fontFamily: 'Poppins',
fill: '#ffffff',
align: 'center'
},
{
id: 'l-zlava-2',
type: 'text',
text: '{cena}',
x: 70,
y: 62,
fontSize: 42,
fontWeight: '900',
fontFamily: 'Arial Narrow',
fill: '#ffd100',
align: 'center'
}
]
},
{
id: 'badge-preset-blue-supercena',
name: 'Super Cena (Tmavomodrá)',
svgRaw: `<svg width="140" height="95" viewBox="0 0 140 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="140" height="95" rx="14" fill="#001a33"/>
<rect x="0" y="0" width="140" height="30" rx="14" fill="#003399"/>
</svg>`,
width: 110,
align: 'center',
paddingTop: 4,
paddingBottom: 4,
layersConfig: [
{
id: 'l-super-1',
type: 'text',
text: 'SUPER CENA',
x: 70,
y: 16,
fontSize: 13,
fontWeight: '900',
fontFamily: 'Poppins',
fill: '#ffffff',
align: 'center'
},
{
id: 'l-super-2',
type: 'text',
text: '{cena}',
x: 70,
y: 62,
fontSize: 42,
fontWeight: '900',
fontFamily: 'Arial Narrow',
fill: '#ffd100',
align: 'center'
}
]
},
{
id: 'badge-preset-green-cerstve',
name: 'Bio & Čerstvé (Zelená)',
svgRaw: `<svg width="140" height="95" viewBox="0 0 140 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="140" height="95" rx="14" fill="#005239"/>
<rect x="15" y="8" width="110" height="22" rx="6" fill="#003d2b"/>
</svg>`,
width: 110,
align: 'center',
paddingTop: 4,
paddingBottom: 4,
layersConfig: [
{
id: 'l-green-1',
type: 'text',
text: 'ČERSTVÉ',
x: 70,
y: 19,
fontSize: 13,
fontWeight: '900',
fontFamily: 'Poppins',
fill: '#a3e635',
align: 'center'
},
{
id: 'l-green-2',
type: 'text',
text: '{cena}',
x: 70,
y: 62,
fontSize: 42,
fontWeight: '900',
fontFamily: 'Arial Narrow',
fill: '#ffffff',
align: 'center'
}
]
}
];
export interface BadgeProductData {
priceMain: string;
priceSub: string;
discountText?: string;
unitPrice?: string;
}
/**
* 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 pMain = product.priceMain || '0';
const pSub = product.priceSub || '00';
const discount = (product.discountText || '').replace('-', '');
const unitPrice = product.unitPrice || '';
// 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) => {
const rawText = layer.text || '';
const fontSize = layer.fontSize || 16;
const subFontSize = Math.max(10, Math.round(fontSize * 0.55));
const fontWeight = layer.fontWeight || '700';
const fill = layer.fill || '#ffffff';
const fontFamily = layer.fontFamily
? `'${layer.fontFamily}', -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}, centy vložíme do <tspan> ako horný index
let contentSvg = '';
if (rawText.includes('{cena}')) {
const parts = rawText.split('{cena}');
const before = escapeXml(parts[0] || '');
const after = escapeXml(parts[1] || '');
contentSvg = `${before}${escapeXml(pMain)}<tspan font-size="${subFontSize}px" dy="-0.45em" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan><tspan dy="0.45em">${after}</tspan>`;
} else {
let replaced = rawText
.replace(/{cela_suma}/g, pMain)
.replace(/{centy}/g, pSub)
.replace(/{zlava}/g, discount)
.replace(/{jednotkova_cena}/g, unitPrice)
.replace(/{mena}/g, '€');
contentSvg = escapeXml(replaced);
}
return `<text x="${layer.x}" y="${layer.y}" text-anchor="${anchor}" dominant-baseline="central" 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" 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)}`);
}
});
}
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;');
}