493 lines
28 KiB
TypeScript
493 lines
28 KiB
TypeScript
// 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;"> </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('');
|
||
}
|