feat: produktove karty v style letaku a zjednoduseny panel produktu

Karta podla sablony coop_nws_009: fotka cez celu kartu, cenovka cez fotku
vpravo dole, stitky predajni nad cenovkou, pod tym texty.
- v editore sa vizual sklada zivo (HTML/CSS); pri exporte (ZIP/Brevo) sa ulozi
  ako JPG a do e-mailu ide ako jeden obrazok - funguje rovnako vo vsetkych
  klientoch; nezmenene karty sa znova nenahravaju
- /api/img: proxy obrazkov pre nahlad (inak by sa vizual nedal ulozit)
- novy ProductEditor pre bezne aj vernostne produkty: Texty, Fotka (zoom/posun),
  Cenovka (sablona, velkost, ceny), Dostupnost, Dalsie moznosti; zbalene po produktoch
- alt texty kariet s cenou a zlavou, mobilne styly textu, ochrana farieb
  v tmavom rezime (zelena karta, Apple Mail)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-19 17:06:48 +02:00
1 parent 139e3569d0
commit 25771afc79
9 files changed
+1009 -1756

No files matched your search

+347
View File
@@ -0,0 +1,347 @@
// 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 { CardProduct, NewsletterModule } 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)
}
export const DEFAULT_STORES_OFFSET_Y = 2;
// 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
function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number): string {
const tag = (color: string, file: string, alt: string) =>
`<div style="text-align: right; line-height: 0;"><img src="${escapeHtml(assetUrl(SUPABASE_ASSETS_BASE + file, mode))}" height="${tagHeight}" alt="${alt}" style="display: inline-block; height: ${tagHeight}px; width: auto; border: 0; background-color: ${color};"></div>`;
return [
p.stores?.potraviny ? tag('#e2001a', 'stit_potraviny.png', 'Potraviny') : '',
p.stores?.supermarket ? tag('#f9890e', 'stit_supermarket.png', 'Supermarket') : '',
p.stores?.tempo ? tag('#005239', 'stit_tempo.png', 'Tempo') : ''
].join('');
}
// 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 ?? 1, 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.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
p.stores, p.storesOffsetX || 0, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y
];
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)}`;
}
// Vizuál karty v náhľade: fotka (zoom/posun), cenovka vpravo dole, štítky nad cenovkou
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 ?? 1;
const ox = p.imageOffsetX || 0;
const oy = p.imageOffsetY || 0;
const sx = Math.round(p.storesOffsetX || 0);
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
const stores = renderStores(p, ctx.mode, Math.max(10, Math.round(bw * 0.12)));
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;">
${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;">
${stores ? `<div style="position: absolute; right: ${-sx}px; bottom: calc(100% + ${sy}px);">${stores}</div>` : ''}
${renderBadge(p, ctx, bw)}
</div>
</div>`;
}
// 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);
priceText = [
main ? `${main}${p.discountText ? `, zľava ${pct(p.discountText)}` : ''}` : '',
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : ''
].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;">${escapeHtml(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;">${escapeHtml(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;">${escapeHtml(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 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>`;
}
// Mriežka kariet (1 alebo 2 na riadok) s rovnakou výškou kariet v riadku
export function renderProductGrid(products: CardProduct[], ctx: CardContext): string {
const cols = ctx.cardWidth > 300 ? 1 : 2;
const rows: CardProduct[][] = [];
for (let i = 0; i < products.length; i += cols) rows.push(products.slice(i, i + cols));
return rows
.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(
(p, idx) => `
<td width="${cols === 1 ? 100 : 50}%" valign="top" height="1" style="${cols === 1 ? '' : idx === 0 ? 'padding-right: 6px;' : 'padding-left: 6px;'} height: 100%;">
${renderProductCard(p, ctx)}
</td>`
)
.join('');
const filler = cols === 2 && row.length === 1 ? '<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('');
}