fix: stitky predajni ukotvene k pravemu hornemu rohu pozadia cenovky

Pozadie cenovky moze mat v SVG priehladny okraj; jeho skutocne hranice sa
odmeraju v prehliadaci (getBBox) a stitky sa ukotvia k jeho pravemu
hornemu rohu namiesto rohu celeho SVG. Posun stitkov sa pocita od tohto rohu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 10:53:40 +02:00
1 parent c426145c48
commit b0a050bd12
1 file changed
+45 -2
+45 -2
View File
@@ -5,7 +5,7 @@
// V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu // V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu
// uloží ako JPG (src/lib/cardImages.ts) a do e-mailu ide ako jeden obrázok – rovnako ako karta_produkt_*.jpg // uloží ako JPG (src/lib/cardImages.ts) a do e-mailu ide ako jeden obrázok – rovnako ako karta_produkt_*.jpg
// v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil). // v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
import { CardProduct, NewsletterModule } from '@/types/newsletter'; import { BadgeTemplate, CardProduct, NewsletterModule } from '@/types/newsletter';
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils'; import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign'; import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { pickAlt } from './altText'; import { pickAlt } from './altText';
@@ -217,6 +217,45 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
} }
// Vizuál karty v náhľade: fotka (zoom/posun), cenovka vpravo dole, štítky nad cenovkou // Vizuál karty v náhľade: fotka (zoom/posun), cenovka vpravo dole, štítky nad cenovkou
// Viditeľné pozadie cenovky v SVG môže mať okolo seba priehľadný okraj. Štítky predajní ukotvujeme
// k pravému hornému rohu samotného pozadia, preto jeho hranice odmeriame v prehliadači (getBBox).
// Výsledok je podiel rozmerov viewBoxu; na serveri (bez DOM) sú okraje 0.
interface BadgeBgBox {
top: number; // okraj zhora / výška
right: number; // okraj sprava / šírka
aspect: number; // výška / šírka SVG
}
const badgeBgCache = new Map<string, BadgeBgBox>();
function badgeBackgroundBox(t: BadgeTemplate): BadgeBgBox | null {
if (!t.svgRaw || typeof document === 'undefined') return null;
const cached = badgeBgCache.get(t.svgRaw);
if (cached) return cached;
let box: BadgeBgBox | null = null;
const holder = document.createElement('div');
holder.style.cssText = 'position: absolute; left: -10000px; top: 0; visibility: hidden;';
try {
holder.innerHTML = renderCleanBadgeSvg({ ...t, layersConfig: [] }, { priceMain: '', priceSub: '' }, 200);
document.body.appendChild(holder);
const svg = holder.querySelector('svg');
const vb = svg?.viewBox.baseVal;
const bb = svg?.getBBox();
if (vb && bb && vb.width && vb.height && bb.width && bb.height) {
box = {
top: Math.max(0, (bb.y - vb.y) / vb.height),
right: Math.max(0, (vb.x + vb.width - (bb.x + bb.width)) / vb.width),
aspect: vb.height / vb.width
};
}
} catch {
box = null;
} finally {
holder.remove();
}
if (box) badgeBgCache.set(t.svgRaw, box);
return box;
}
function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): string { function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): string {
const height = cardVisualHeight(ctx.cardWidth); const height = cardVisualHeight(ctx.cardWidth);
const bg = p.cardBgColor || ctx.cardBg; const bg = p.cardBgColor || ctx.cardBg;
@@ -227,11 +266,15 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
const sx = Math.round(p.storesOffsetX || 0); const sx = Math.round(p.storesOffsetX || 0);
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y); const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
const stores = renderStores(p, ctx.mode, Math.max(10, Math.round(bw * 0.12))); const stores = renderStores(p, ctx.mode, Math.max(10, Math.round(bw * 0.12)));
// Pravý horný roh viditeľného pozadia cenovky (pri PNG a predvolenej cenovke = roh cenovky)
const bgBox = p.badgeCustomTemplate && !p.customBadgePngUrl ? badgeBackgroundBox(p.badgeCustomTemplate) : null;
const insetTop = bgBox ? Math.round(bgBox.top * bw * bgBox.aspect) : 0;
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
return ` return `
<div data-card-visual="${key}" style="position: relative; width: 100%; height: ${height}px; overflow: hidden; background-color: ${bg}; border-radius: 16px 16px 0 0; line-height: normal; font-size: 12px;"> <div data-card-visual="${key}" style="position: relative; width: 100%; height: ${height}px; overflow: hidden; background-color: ${bg}; border-radius: 16px 16px 0 0; line-height: normal; font-size: 12px;">
${p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : ''} ${p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : ''}
<div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;"> <div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;">
${stores ? `<div style="position: absolute; right: ${-sx}px; bottom: calc(100% + ${sy}px);">${stores}</div>` : ''} ${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''}
${renderBadge(p, ctx, bw)} ${renderBadge(p, ctx, bw)}
</div> </div>
</div>`; </div>`;