// 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 = { '#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 ``; } 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 ` ${b.customText ? ` ` : p.discountText ? ` ` : ''}
${escapeHtml(b.customText)}
${escapeHtml(p.badgeText || 'zľava')} ${escapeHtml(p.discountText)}
${escapeHtml(p.priceMain)}${escapeHtml(p.priceSub)}
`; } // Predvolená vernostná cenovka (Bez karty / S kartou) function renderDualSticker(p: CardProduct): string { return `
Bez karty
zľava ${escapeHtml(p.noCardDiscount)}
${escapeHtml(p.noCardPriceMain)}${escapeHtml(p.noCardPriceSub)}
S kartou
zľava ${escapeHtml(p.cardDiscount)}
${escapeHtml(p.cardPriceMain)}${escapeHtml(p.cardPriceSub)}
${escapeHtml(p.noCardUnitPrice)} ${escapeHtml(p.cardUnitPrice)}
`; } // Š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) => `
${halo ? '' : alt}
`; return `
${tags.map((x) => row(x, false)).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 ?? 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): 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 ``; } // 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 `
${escapeWithBreaks(l.text)}
`; } // 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(); 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 `
${photoStack(p) .map((id) => { if (id === MAIN_PHOTO) { return p.imageUrl ? `` : ''; } const f = (p.extraPhotos || []).find((x) => x.id === id); return f ? renderExtraPhoto(f, ctx.mode) : ''; }) .join('')} ${(p.labels || []).map(renderLabel).join('')}
${stores ? `
${stores}
` : ''} ${renderBadge(p, ctx, bw)}
`; } // Š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 `${escapeHtml(alt)}`; } const bw = badgeWidthFor(p, ctx.variant); return `
${escapeHtml(alt)}
${renderBadge(p, ctx, bw)}
`; } // 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) => `
${escapeWithBreaks(text)}
`; return `

${escapeWithBreaks(p.title)}${p.quantity ? ` ${escapeWithBreaks(p.quantity)}` : ''}

${ unitPrice ? `
${escapeWithBreaks(unitPrice)}
` : ''}${ 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 `
${visual}
${renderTexts(p, ctx)}
`; } // 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 ? ' ' : ''; const cells = row .map( (i, idx) => ` ${renderProductCard(products[i], cardContextFor(m, i, mode))} ` ) .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 ? '' : ''; return `${spacer} ${cells}${filler}
`; }) .join(''); }