Files
Coop-NL/src/lib/productCard.ts
T
2026-10-21 16:55:06 +02:00

493 lines
28 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.
// Produktová karta v štýle letáku (vzor coop_nws_009):
// hore "vizuál" – fotka produktu cez celú kartu, cenovka cez fotku vpravo dole, štítky predajní nad cenovkou,
// dole texty – názov (tučne) + gramáž, jednotková cena, poznámka.
//
// 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
// v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
import { BadgeTemplate, CardProduct, NewsletterModule, ProductLabel, ProductPhoto } from '@/types/newsletter';
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { pickAlt } from './altText';
import { escapeHtml, escapeWithBreaks } from './html';
export type CardVariant = 'standard' | 'loyalty';
export type RenderMode = 'preview' | 'export';
export interface CardContext {
variant: CardVariant;
mode: RenderMode;
cardWidth: number; // nominálna šírka karty v px (2 stĺpce = 294, 1 stĺpec = 600)
cardBg: string; // predvolená farba karty v sekcii
validity: string; // platnosť zobrazená na karte ('' = skrytá)
sectionTitle?: string;
// Farby predvolenej (negenerovanej) cenovky bežných produktov
defaultBadge: { hdrBg: string; hdrColor: string; prcBg: string; prcColor: string; customText: string | null };
}
export interface StoresOffset {
x?: number; // + doprava, − doľava
y?: number; // + vyššie nad cenovku, − nižšie (cez horný okraj cenovky)
}
// Predvolená veľkosť fotky produktu (zoom), keď ju produkt nemá nastavenú
export const DEFAULT_IMAGE_SCALE = 0.95;
// Predvolená poloha štítkov voči pravému hornému rohu pozadia cenovky
export const DEFAULT_STORES_OFFSET_X = -23;
// Predvolená výška jedného štítka predajne (px), nezávislá od veľkosti cenovky
export const DEFAULT_STORES_SIZE = 14;
export const DEFAULT_STORES_OFFSET_Y = 8;
// Výška vizuálu: pomer ako v šablóne (294 × 245), pri 1 produkte na riadok max 300 px
export function cardVisualHeight(cardWidth: number): number {
return cardWidth <= 300 ? Math.round((cardWidth * 245) / 294) : 300;
}
// Kontext karty z nastavení sekcie
export function cardContextForModule(m: NewsletterModule, mode: RenderMode): CardContext {
const variant: CardVariant = m.type === 'loyalty_club' ? 'loyalty' : 'standard';
const showValidity = m.data.showCardValidity === true;
return {
variant,
mode,
cardWidth: variant === 'standard' && m.data.columns === 1 ? 600 : 294,
cardBg: m.data.cardBgColor || (variant === 'loyalty' ? '#e6e1d9' : '#feeaea'),
validity: showValidity ? m.data.globalValidity || '' : '',
sectionTitle: m.data.sectionTitle,
defaultBadge: {
hdrBg: m.data.badgeHeaderBg || '#fefefe',
hdrColor: m.data.badgeHeaderColor || '#000000',
prcBg: m.data.badgePriceBg || '#e2001a',
prcColor: m.data.badgePriceColor || '#fefefe',
customText: m.data.badgeHeaderCustomText || null
}
};
}
// Trieda karty pre ochranu farby v tmavom režime (podľa šablóny)
const CARD_COLOR_CLASSES: Record<string, string> = {
'#feeaea': 'card-fantasticky',
'#fef0c7': 'card-domace',
'#dcb085': 'card-grill',
'#e3f0e4': 'card-zelena',
'#e6e1d9': 'card-vernostna',
'#fefefe': 'card-white-bg'
};
// V náhľade idú cudzie obrázky cez /api/img (rovnaký pôvod), aby sa vizuál dal pri exporte uložiť ako obrázok
function assetUrl(url: string | undefined, mode: RenderMode): string {
if (!url) return '';
if (mode === 'preview' && /^https?:\/\//i.test(url)) {
return `/api/img?url=${encodeURIComponent(url)}`;
}
return url;
}
// Údaje pre premenné v šablóne cenovky
function badgeData(p: CardProduct, variant: CardVariant): BadgeProductData {
if (variant === 'loyalty') {
return {
priceMain: p.cardPriceMain || p.priceMain || '0',
priceSub: p.cardPriceSub || p.priceSub || '00',
discountText: p.cardDiscount || p.discountText || '',
unitPrice: p.cardUnitPrice || p.unitPrice || '',
cardPriceMain: p.cardPriceMain,
cardPriceSub: p.cardPriceSub,
cardDiscount: p.cardDiscount
};
}
return p;
}
export function badgeWidthFor(p: CardProduct, variant: CardVariant): number {
if (p.badgeWidth) return p.badgeWidth;
if (p.badgeCustomTemplate) return p.badgeCustomTemplate.width || 110;
if (p.customBadgePngUrl) return 110;
return variant === 'loyalty' ? 140 : 95;
}
// Samotná cenovka: vlastná PNG > šablóna > predvolená cenovka
function renderBadge(p: CardProduct, ctx: CardContext, width: number): string {
if (p.customBadgePngUrl) {
return `<img src="${escapeHtml(assetUrl(p.customBadgePngUrl, ctx.mode))}" width="${width}" alt="" style="display: block; width: ${width}px; height: auto; border: 0;">`;
}
if (p.badgeCustomTemplate) {
return renderCleanBadgeSvg(p.badgeCustomTemplate, badgeData(p, ctx.variant), width);
}
if (ctx.variant === 'loyalty') {
return renderDualSticker(p);
}
const b = ctx.defaultBadge;
const mainFontSize = Math.max(28, Math.round(width * 0.54));
const subFontSize = Math.max(14, Math.round(width * 0.27));
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: ${b.hdrBg}; border: 0; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.18); width: ${width}px; overflow: hidden;">
${b.customText ? `
<tr>
<td bgcolor="${b.hdrBg}" style="padding: 4px 4px; text-align: center; background-color: ${b.hdrBg};">
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 10px; color: ${b.hdrColor}; font-weight: 800; text-transform: uppercase;">${escapeHtml(b.customText)}</span>
</td>
</tr>` : p.discountText ? `
<tr>
<td style="padding: 2px 4px; text-align: right; background-color: ${b.hdrBg};">
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 9px; color: ${b.hdrColor}; font-weight: 700; text-transform: lowercase;">${escapeHtml(p.badgeText || 'zľava')}</span>
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 20px; color: ${b.hdrColor}; font-weight: 900; line-height: 1; margin-left: 2px;">${escapeHtml(p.discountText)}</span>
</td>
</tr>` : ''}
<tr>
<td class="force-text-white" bgcolor="${b.prcBg}" style="background-color: ${b.prcBg}; padding: 4px 2px 2px 2px; font-family: 'Arial Narrow', Arial, sans-serif; color: ${b.prcColor}; font-weight: 900; line-height: 1; text-align: center; border-radius: 0 0 12px 12px;">
<span style="font-size: ${mainFontSize}px;">${escapeHtml(p.priceMain)}</span><sup style="font-size: ${subFontSize}px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.priceSub)}</sup>
</td>
</tr>
</table>`;
}
// Predvolená vernostná cenovka (Bez karty / S kartou)
function renderDualSticker(p: CardProduct): string {
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width: 140px;">
<tr>
<td width="42%" valign="bottom">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border-radius: 10px 0 0 10px; box-shadow: -2px 4px 8px rgba(0,0,0,0.12); overflow: hidden;">
<tr>
<td bgcolor="#e2001a" style="background-color: #e2001a; padding: 4px 2px; text-align: center;">
<div style="font-size: 6px; color: #fefefe; font-weight: 700; text-transform: uppercase; line-height: 1; margin-bottom: 1px;">Bez karty</div>
<div style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 8px; color: #fefefe; font-weight: 900; line-height: 1;">zľava ${escapeHtml(p.noCardDiscount)}</div>
</td>
</tr>
<tr>
<td style="padding: 2px; text-align: center; color: #000000; font-family: 'Arial Narrow', Arial, sans-serif; font-weight: 900; line-height: 1;">
<span style="font-size: 20px;">${escapeHtml(p.noCardPriceMain)}</span><sup style="font-size: 10px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.noCardPriceSub)}</sup>
</td>
</tr>
</table>
</td>
<td width="58%" valign="top">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border-radius: 0 10px 10px 0; box-shadow: 4px 4px 12px rgba(0,0,0,0.15); overflow: hidden;">
<tr>
<td style="background-color: #fefefe; padding: 5px 4px 2px 4px; text-align: right;">
<div style="font-size: 7px; color: #e2001a; font-weight: 800; text-transform: uppercase; line-height: 1; text-align: center; margin-bottom: 1px;">S kartou</div>
<div style="text-align: right; line-height: 1;">
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 8px; color: #000000; font-weight: 700; text-transform: lowercase;">zľava</span>
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 18px; color: #000000; font-weight: 900; margin-left: 1px;">${escapeHtml(p.cardDiscount)}</span>
</div>
</td>
</tr>
<tr>
<td bgcolor="#e2001a" style="background-color: #e2001a; padding: 3px 2px; text-align: center; color: #fefefe; font-family: 'Arial Narrow', Arial, sans-serif; font-weight: 900; line-height: 1; border-radius: 0 0 10px 0;">
<span style="font-size: 48px;">${escapeHtml(p.cardPriceMain)}</span><sup style="font-size: 24px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.cardPriceSub)}</sup>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding-top: 4px; font-size: 8px; color: #666666; font-family: Arial, sans-serif;">${escapeHtml(p.noCardUnitPrice)}</td>
<td align="center" style="padding-top: 4px; font-size: 9px; color: #e2001a; font-weight: 800; font-family: Arial, sans-serif;">${escapeHtml(p.cardUnitPrice)}</td>
</tr>
</table>`;
}
// Štítky predajní (Potraviny / Supermarket / Tempo) pod sebou; výška podľa veľkosti cenovky
// Biely okraj okolo nich ako skupiny: pod štítkami je biela vrstva z rovnakých štítkov, každý o hrúbku okraja
// väčší a so zaoblenými rohmi – kde sa štítky dotýkajú, biela splynie, takže linka ide okolo celej skupiny
// a vonkajšie rohy sú zaoblené (polovica hrúbky linky okolo pozadia cenovky: 2,25 px pri cenovke širokej 130 px).
function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number, badgeWidth: number): string {
const t = Math.round(((2.25 * badgeWidth) / 130) * 10) / 10;
const tags = [
p.stores?.potraviny ? ['#e2001a', 'stit_potraviny.png', 'Potraviny'] : null,
p.stores?.supermarket ? ['#f9890e', 'stit_supermarket.png', 'Supermarket'] : null,
p.stores?.tempo ? ['#005239', 'stit_tempo.png', 'Tempo'] : null
].filter((x): x is string[] => !!x);
if (!tags.length) return '';
const row = ([color, file, alt]: string[], halo: boolean) =>
`<div style="text-align: right; line-height: 0;"><img src="${escapeHtml(assetUrl(SUPABASE_ASSETS_BASE + file, mode))}" height="${tagHeight}" alt="${halo ? '' : alt}" style="display: inline-block; height: ${tagHeight}px; width: auto; border: 0; ${
halo
? `background-color: #ffffff; filter: brightness(0) invert(1); padding: ${t}px; margin: -${t}px; border-radius: ${t}px; box-sizing: content-box;`
: `background-color: ${color};`
}"></div>`;
return `<div style="position: relative;">
<div style="position: absolute; inset: 0;" aria-hidden="true">${tags.map((x) => row(x, true)).join('')}</div>
<div style="position: relative;">${tags.map((x) => row(x, false)).join('')}</div>
</div>`;
}
// Jednoznačný kľúč vizuálu – pri zmene čohokoľvek, čo ovplyvní obrázok, sa pri exporte vytvorí nový
export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
const t = p.badgeCustomTemplate;
const relevant = [
ctx.variant, ctx.cardWidth, p.cardBgColor || ctx.cardBg, ctx.variant === 'standard' ? ctx.defaultBadge : null,
p.imageUrl, p.imageScale ?? DEFAULT_IMAGE_SCALE, p.imageOffsetX || 0, p.imageOffsetY || 0,
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
t ? [t.svgRaw, t.layersConfig, t.width] : null,
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice, p.appPriceMain, p.appPriceSub,
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE,
p.labels || [], p.extraPhotos || [], photoStack(p)
];
const str = JSON.stringify(relevant);
let h1 = 5381;
let h2 = 52711;
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
h1 = (h1 * 33) ^ c;
h2 = (h2 * 33) ^ c;
}
return `cv-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
}
// Poradie fotiek v karte odzadu dopredu ('main' = hlavná fotka). Uložené poradie sa doplní o nové fotky
// (dopredu) a očistí o zmazané; bez uloženého poradia platí staršie nastavenie „za hlavnou fotkou“.
export const MAIN_PHOTO = 'main';
// Predvoľby farby pozadia karty produktu (oranžová, červená a zelená zodpovedajú farbám cenoviek v podkladoch)
export const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
{ label: 'Oranžová', sub: 'Super výhodné', color: '#fdebd8', border: '#f3d2ae' },
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
];
// Farba cenovky z podkladov → pozadie karty
export const CARD_BG_BY_BADGE_COLOR: Record<'cervena' | 'oranzova' | 'zelena', string> = {
cervena: '#feeaea',
oranzova: '#fdebd8',
zelena: '#e3f0e4'
};
export function photoStack(p: Pick<CardProduct, 'extraPhotos' | 'photoOrder'>): string[] {
const extras = p.extraPhotos || [];
const ids = new Set([MAIN_PHOTO, ...extras.map((f) => f.id)]);
if (p.photoOrder?.length) {
const order = p.photoOrder.filter((id) => ids.has(id));
for (const id of ids) if (!order.includes(id)) order.push(id);
return order;
}
return [...extras.filter((f) => f.behind).map((f) => f.id), MAIN_PHOTO, ...extras.filter((f) => !f.behind).map((f) => f.id)];
}
// Ďalšia fotka v karte (bundle): poloha v % vizuálu, veľkosť voči hlavnej fotke
function renderExtraPhoto(f: ProductPhoto, mode: RenderMode): string {
if (!f.url) return '';
return `<img src="${escapeHtml(assetUrl(f.url, mode))}" alt="" style="position: absolute; left: ${f.x}%; top: ${f.y}%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) scale(${f.scale || 1}); border: 0;">`;
}
// Vlastný štítok na fotke (nad fotkou, pod cenovkou) – štýl ako „PODIEL MÄSA 92 %“ z letáku, písmo Aptly Bold (Adobe Fonts)
function renderLabel(l: ProductLabel): string {
if (!l.text) return '';
const size = l.size || 12;
return `<div data-label-id="${escapeHtml(l.id)}" style="position: absolute; left: ${l.x}%; top: ${l.y}%; transform: translate(-50%, -50%) rotate(${l.rotate || 0}deg); background-color: ${l.bg || '#e2001a'}; color: ${l.color || '#ffffff'}; font-family: 'Aptly', 'aptly', 'Arial Narrow', 'Roboto Condensed', Arial, sans-serif; font-weight: 700; font-size: ${size}px; line-height: 1.1; padding: ${Math.round(size * 0.3)}px ${Math.round(size * 0.5)}px; border-radius: ${Math.round(size * 0.25)}px; text-transform: uppercase; text-align: center; white-space: nowrap; letter-spacing: 0.2px;">${escapeWithBreaks(l.text)}</div>`;
}
// 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 {
const height = cardVisualHeight(ctx.cardWidth);
const bg = p.cardBgColor || ctx.cardBg;
const bw = badgeWidthFor(p, ctx.variant);
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
const ox = p.imageOffsetX || 0;
const oy = p.imageOffsetY || 0;
const sx = Math.round(p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X);
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
const stores = renderStores(p, ctx.mode, p.storesSize ?? DEFAULT_STORES_SIZE, bw);
// 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 `
<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;">
${photoStack(p)
.map((id) => {
if (id === MAIN_PHOTO) {
return 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;">` : '';
}
const f = (p.extraPhotos || []).find((x) => x.id === id);
return f ? renderExtraPhoto(f, ctx.mode) : '';
})
.join('')}
${(p.labels || []).map(renderLabel).join('')}
<div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;">
${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''}
${renderBadge(p, ctx, bw)}
</div>
</div>`;
}
// Šablóna cenovky zobrazuje cenu pre appkáčov
export const usesAppPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('{cena_app}');
// Alt text obrázka karty: názov, gramáž a cena (ako v šablóne); ručne zadaný alt má prednosť
export function productCardAlt(p: CardProduct, variant: CardVariant): string {
const name = [pickAlt(p.title), p.quantity].filter(Boolean).join(' ');
const price = (main?: string, sub?: string) => (main ? `${main}${sub ? `,${sub}` : ''} €` : '');
const pct = (d?: string) => (d ? d.replace('-', '').replace(/\s*%/, ' %') : '');
let priceText = '';
if (variant === 'loyalty') {
const card = price(p.cardPriceMain, p.cardPriceSub);
const noCard = price(p.noCardPriceMain, p.noCardPriceSub);
priceText = [
card ? `s vernostnou kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : '',
noCard ? `bez vernostnej karty ${noCard}${p.noCardDiscount ? ` (zľava ${pct(p.noCardDiscount)})` : ''}` : ''
].filter(Boolean).join(', ');
} else {
const main = price(p.priceMain, p.priceSub);
const card = price(p.cardPriceMain, p.cardPriceSub);
const app = usesAppPrice(p.badgeCustomTemplate) ? price(p.appPriceMain, p.appPriceSub) : '';
priceText = [
main ? `${main}${p.discountText ? `, zľava ${pct(p.discountText)}` : ''}` : '',
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : '',
app ? `v aplikácii ${app}` : ''
].filter(Boolean).join(', ');
}
return pickAlt(p.imageAlt, [name, priceText].filter(Boolean).join(' – '), 'Produkt');
}
// Vizuál karty v e-maile: uložený obrázok z exportu, inak jednoduchá záloha (fotka + cenovka pod ňou)
function renderVisualExport(p: CardProduct, ctx: CardContext, key: string): string {
const alt = productCardAlt(p, ctx.variant);
const w = ctx.cardWidth;
if (p.cardImage?.key === key && p.cardImage?.url) {
return `<img src="${escapeHtml(p.cardImage.url)}" width="${w}" alt="${escapeHtml(alt)}" style="display: block; width: 100%; max-width: ${w}px; height: auto; margin: 0 auto; border: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">`;
}
const bw = badgeWidthFor(p, ctx.variant);
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr><td style="padding: 12px 12px 0 12px; text-align: center; line-height: 0;"><img src="${escapeHtml(p.imageUrl)}" width="${Math.min(220, w - 24)}" alt="${escapeHtml(alt)}" style="display: block; width: 100%; max-width: ${Math.min(220, w - 24)}px; height: auto; margin: 0 auto; border: 0;"></td></tr>
<tr><td align="right" style="padding: 6px 12px 0 12px; line-height: normal; font-size: 12px;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right" style="width: ${bw}px;"><tr><td>${renderBadge(p, ctx, bw)}</td></tr></table></td></tr>
</table>`;
}
// Texty pod vizuálom: názov 15px/700 + gramáž 15px/400, jednotková cena 11px #555, poznámka a platnosť 10px #888
const TEXT_FONT = "font-family: 'Poppins', Helvetica, Arial, sans-serif;";
function renderTexts(p: CardProduct, ctx: CardContext): string {
// Pri predvolenej vernostnej cenovke sú jednotkové ceny priamo na cenovke
const unitPrice =
ctx.variant === 'loyalty'
? p.badgeCustomTemplate || p.customBadgePngUrl
? p.cardUnitPrice || p.unitPrice
: ''
: p.unitPrice;
const validity = p.validityOverride || ctx.validity;
const small = (text: string) =>
`<div class="prod-unit force-text-dark" style="margin-top: 2px; ${TEXT_FONT} font-size: 10px; line-height: 1.3; color: #888888;">${escapeWithBreaks(text)}</div>`;
return `<h3 class="prod-name force-text-dark" style="margin: 0; ${TEXT_FONT} font-size: 15px; line-height: 1.25; font-weight: 700; color: #1a1a1a;">${escapeWithBreaks(p.title)}${p.quantity ? ` <span style="font-weight: 400;">${escapeWithBreaks(p.quantity)}</span>` : ''}</h3>${
unitPrice ? `
<div class="prod-unit force-text-dark" style="margin-top: 3px; ${TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeWithBreaks(unitPrice)}</div>` : ''}${
p.note ? small(p.note) : ''}${validity ? small(validity) : ''}`;
}
// Celá karta produktu
export function renderProductCard(p: CardProduct, ctx: CardContext): string {
const bg = p.cardBgColor || ctx.cardBg;
const colorClass = CARD_COLOR_CLASSES[(bg || '').toLowerCase()] || '';
const key = cardVisualKey(p, ctx);
const visual = ctx.mode === 'preview' ? renderVisualPreview(p, ctx, key) : renderVisualExport(p, ctx, key);
return `
<table ${ctx.mode === 'preview' ? `data-product-id="${escapeHtml(p.id)}" ` : ''}role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product ${colorClass}" bgcolor="${bg}" style="background-color: ${bg}; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); overflow: hidden; height: 100%;">
<tr>
<td style="padding: 0; line-height: 0; font-size: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">
${visual}
</td>
</tr>
<tr>
<td class="prod-pad" valign="top" style="padding: 2px 14px 12px 14px; text-align: left; height: 100%;">
${renderTexts(p, ctx)}
</td>
</tr>
</table>`;
}
// Kontext konkrétnej karty: vo vernostných zľavách pri nepárnom počte je prvý produkt cez celý riadok
export function cardContextFor(m: NewsletterModule, index: number, mode: RenderMode): CardContext {
const base = cardContextForModule(m, mode);
const count = (m.data.products || []).length;
if (base.variant === 'loyalty' && count % 2 === 1 && index === 0) return { ...base, cardWidth: 600 };
return base;
}
// Riadky mriežky (indexy produktov): 1 na riadok, alebo po 2;
// vernostné zľavy s nepárnym počtom: prvý produkt sám cez celý riadok, ostatné po 2
function gridRows(m: NewsletterModule, count: number, oneColumn: boolean): number[][] {
const idx = Array.from({ length: count }, (_, i) => i);
if (oneColumn) return idx.map((i) => [i]);
const rows: number[][] = [];
let start = 0;
if (m.type === 'loyalty_club' && count % 2 === 1) {
rows.push([0]);
start = 1;
}
for (let i = start; i < count; i += 2) rows.push(idx.slice(i, i + 2));
return rows;
}
// Mriežka kariet s rovnakou výškou kariet v riadku
export function renderProductGrid(m: NewsletterModule, mode: RenderMode): string {
const products: CardProduct[] = m.data.products || [];
const oneColumn = cardContextForModule(m, mode).cardWidth > 300;
return gridRows(m, products.length, oneColumn)
.map((row, rIdx) => {
const spacer = rIdx > 0 ? '<tr><td height="12" style="font-size: 12px; line-height: 12px;">&nbsp;</td></tr>' : '';
const cells = row
.map(
(i, idx) => `
<td width="${row.length === 1 ? 100 : 50}%" valign="top" height="1" style="${row.length === 1 ? '' : idx === 0 ? 'padding-right: 6px;' : 'padding-left: 6px;'} height: 100%;">
${renderProductCard(products[i], cardContextFor(m, i, mode))}
</td>`
)
.join('');
// posledný riadok s 1 produktom v 2-stĺpcovej mriežke (bežné sekcie) – produkt ostane v ľavej polovici
const filler = !oneColumn && row.length === 1 && cardContextFor(m, row[0], mode).cardWidth <= 300 ? '<td width="50%" style="padding-left: 6px;"></td>' : '';
return `${spacer}
<tr>
<td>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="1">
<tr>${cells}${filler}
</tr>
</table>
</td>
</tr>`;
})
.join('');
}