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
+1005 -1752

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
// Proxy obrázkov pre náhľad produktových kariet: obrázky z iných domén idú cez rovnaký pôvod,
// aby sa vizuál karty dal pri exporte uložiť ako obrázok (canvas nesmie byť "tainted" cez CORS).
const MAX_BYTES = 15 * 1024 * 1024;
export async function GET(request: Request) {
const target = new URL(request.url).searchParams.get('url');
if (!target || !/^https?:\/\//i.test(target)) {
return Response.json({ error: 'Neplatná adresa obrázka.' }, { status: 400 });
}
try {
const res = await fetch(target, { redirect: 'follow' });
const contentType = res.headers.get('content-type') || '';
if (!res.ok || !contentType.startsWith('image/')) {
return Response.json({ error: `Obrázok sa nepodarilo načítať (${res.status}).` }, { status: 502 });
}
const body = await res.arrayBuffer();
if (body.byteLength > MAX_BYTES) {
return Response.json({ error: 'Obrázok je príliš veľký.' }, { status: 413 });
}
return new Response(body, {
headers: {
'content-type': contentType,
'cache-control': 'public, max-age=86400'
}
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Chyba pri načítaní obrázka';
return Response.json({ error: message }, { status: 502 });
}
}
+15 -2
View File
@@ -13,6 +13,7 @@ import { ProductImportModal } from '@/components/editor/ProductImportModal';
import { BadgeEditorModal } from '@/components/editor/BadgeEditorModal'; import { BadgeEditorModal } from '@/components/editor/BadgeEditorModal';
import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal'; import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { prepareCardImages } from '@/lib/cardImages';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react'; import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react';
@@ -432,8 +433,19 @@ export default function Home() {
} }
}; };
// Pred exportom sa vizuály produktových kariet uložia ako obrázky (nezmenené sa nenahrávajú znova)
const prepareForExport = async (): Promise<NewsletterCampaign> => {
const { campaign: prepared, failed } = await prepareCardImages(campaign);
setCampaign((prev) => ({ ...prev, modules: prepared.modules }));
if (failed > 0) {
alert(`${failed} kariet produktov sa nepodarilo uložiť ako obrázok – v e-maile budú v jednoduchšej podobe.`);
}
return prepared;
};
const handleExportZip = async () => { const handleExportZip = async () => {
const rawHtml = renderEmailHtml(campaign); const prepared = await prepareForExport();
const rawHtml = renderEmailHtml(prepared);
let finalHtml = rawHtml; let finalHtml = rawHtml;
try { try {
const res = await fetch('/api/inline-css', { const res = await fetch('/api/inline-css', {
@@ -456,7 +468,8 @@ export default function Home() {
const handleExportBrevo = async (updateExisting = false) => { const handleExportBrevo = async (updateExisting = false) => {
setIsExportingBrevo(true); setIsExportingBrevo(true);
try { try {
const rawHtml = renderEmailHtml(campaign); const prepared = await prepareForExport();
const rawHtml = renderEmailHtml(prepared);
let finalHtml = rawHtml; let finalHtml = rawHtml;
try { try {
const res = await fetch('/api/inline-css', { const res = await fetch('/api/inline-css', {
File diff suppressed because it is too large. Load diff
+410
View File
@@ -0,0 +1,410 @@
'use client';
// Editor jedného produktu (bežné aj vernostné produkty) – zbalený po produktoch, len to, čo treba:
// texty, fotka (zoom/posun), cenovka (šablóna, veľkosť, ceny), dostupnosť a ďalšie možnosti.
import React, { useState } from 'react';
import { BadgeTemplate, CardProduct } from '@/types/newsletter';
import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react';
import { ImageUploadInput } from './ImageUploadInput';
import { pickAlt } from '@/lib/altText';
import { DEFAULT_STORES_OFFSET_Y, badgeWidthFor, productCardAlt } from '@/lib/productCard';
export const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
{ 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' }
];
type Variant = 'standard' | 'loyalty';
// Textové polia cien produktu (bežné aj vernostné)
type TextKey =
| 'priceMain' | 'priceSub' | 'discountText'
| 'cardPriceMain' | 'cardPriceSub' | 'cardDiscount' | 'cardUnitPrice'
| 'noCardPriceMain' | 'noCardPriceSub' | 'noCardDiscount' | 'noCardUnitPrice';
type Patch = Record<string, unknown>;
interface ProductEditorProps {
product: CardProduct;
index: number;
variant: Variant;
availableBadges: BadgeTemplate[];
defaultOpen?: boolean;
onChange: (patch: Patch) => void;
onDelete?: () => void;
onOpenBadgeEditor?: () => void;
onApplyBadgeToSection?: () => void;
onApplyBadgeToAll?: () => void;
}
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
const Field: React.FC<{ label: string; children: React.ReactNode; className?: string }> = ({ label, children, className }) => (
<label className={`block ${className || ''}`}>
<span className="text-[10px] text-gray-500 font-semibold block mb-0.5">{label}</span>
{children}
</label>
);
const TextInput: React.FC<{ value?: string; onChange: (v: string) => void; placeholder?: string; bold?: boolean }> = ({
value,
onChange,
placeholder,
bold
}) => (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`${inputCls} ${bold ? 'font-bold' : ''}`}
/>
);
const Slider: React.FC<{
label: string;
value: number;
display: string;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
}> = ({ label, value, display, min, max, step, onChange }) => (
<div className="grid grid-cols-[64px_1fr_44px] items-center gap-2">
<span className="text-[10px] text-gray-600 font-semibold">{label}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1 accent-red-600 cursor-pointer"
/>
<span className="text-[10px] font-mono text-gray-700 text-right">{display}</span>
</div>
);
const Block: React.FC<{ title: string; action?: React.ReactNode; children: React.ReactNode }> = ({ title, action, children }) => (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-gray-700 uppercase tracking-wider">{title}</span>
{action}
</div>
{children}
</div>
);
const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
<button type="button" onClick={onClick} className="text-[10px] font-bold text-gray-400 hover:text-red-600">
Obnoviť
</button>
);
// Šablóna používa ceny s kartou → zobrazíme aj polia s kartou
const usesCardPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('_s_kartou');
export const ProductEditor: React.FC<ProductEditorProps> = ({
product: p,
index,
variant,
availableBadges,
defaultOpen = false,
onChange,
onDelete,
onOpenBadgeEditor,
onApplyBadgeToSection,
onApplyBadgeToAll
}) => {
const [open, setOpen] = useState(defaultOpen);
const isLoyalty = variant === 'loyalty';
const set = (key: string) => (v: unknown) => onChange({ [key]: v });
const scale = p.imageScale ?? 1;
const offsetX = p.imageOffsetX || 0;
const offsetY = p.imageOffsetY || 0;
const storesX = p.storesOffsetX || 0;
const storesY = p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y;
const hasStores = !!(p.stores?.potraviny || p.stores?.supermarket || p.stores?.tempo);
const badgeWidth = badgeWidthFor(p, variant);
const selectBadge = (id: string) => {
if (id === 'default') {
onChange({ badgeCustomTemplate: undefined, badgeId: undefined, customBadgePngUrl: undefined, badgeWidth: undefined });
return;
}
const found = availableBadges.find((b) => b.id === id);
if (found) {
onChange({ badgeCustomTemplate: found, badgeId: found.id, badgeWidth: found.width ?? 110, customBadgePngUrl: undefined });
}
};
const text = (key: TextKey) => p[key] as string | undefined;
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey: TextKey, unitKey?: TextKey) => (
<div className="space-y-1">
<span className="text-[10px] font-semibold text-gray-600">{label}</span>
<div className={`grid ${unitKey ? 'grid-cols-[1fr_1fr_1fr]' : 'grid-cols-3'} gap-1.5`}>
<Field label="€">
<TextInput value={text(mainKey)} onChange={set(mainKey)} bold />
</Field>
<Field label="Centy">
<TextInput value={text(subKey)} onChange={set(subKey)} bold />
</Field>
<Field label="Zľava">
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
</Field>
</div>
{unitKey && (
<Field label="Jednotková cena">
<TextInput value={text(unitKey)} onChange={set(unitKey)} placeholder="jednotková cena 9,43 EUR/kg" />
</Field>
)}
</div>
);
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 overflow-hidden">
{/* Hlavička produktu – klik rozbalí / zbalí */}
<div className="flex items-center gap-2 px-2.5 py-2 bg-white">
<button type="button" onClick={() => setOpen(!open)} className="flex-1 flex items-center gap-2 min-w-0 text-left">
<span className="w-8 h-8 rounded-md bg-gray-100 border border-gray-200 shrink-0 overflow-hidden flex items-center justify-center">
{p.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={p.imageUrl} alt="" className="w-full h-full object-contain" />
)}
</span>
<span className="text-xs font-bold text-gray-900 truncate">{pickAlt(p.title) || `Produkt ${index + 1}`}</span>
<ChevronDown className={`w-4 h-4 text-gray-400 shrink-0 ml-auto transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{onDelete && (
<button
type="button"
onClick={() => {
if (confirm(`Zmazať produkt "${pickAlt(p.title) || index + 1}"?`)) onDelete();
}}
className="text-gray-300 hover:text-red-600 p-1"
title="Zmazať produkt"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{open && (
<div className="p-3 space-y-4 border-t border-gray-200">
{/* TEXTY */}
<Block title="Texty">
<Field label="Názov (Enter = nový riadok)">
<textarea
rows={2}
value={p.title || ''}
onChange={(e) => onChange({ title: e.target.value })}
className={`${inputCls} font-bold resize-y`}
/>
</Field>
<div className="grid grid-cols-2 gap-1.5">
<Field label="Gramáž / množstvo">
<TextInput value={p.quantity} onChange={set('quantity')} placeholder="35 g" />
</Field>
{isLoyalty ? (
<Field label="Poznámka">
<TextInput value={p.note} onChange={set('note')} placeholder="Ilustračné foto" />
</Field>
) : (
<Field label="Jednotková cena">
<TextInput value={p.unitPrice} onChange={set('unitPrice')} placeholder="jednotková cena 9,43 EUR/kg" />
</Field>
)}
</div>
{!isLoyalty && (
<Field label="Poznámka">
<TextInput value={p.note} onChange={set('note')} placeholder="Ilustračné foto" />
</Field>
)}
</Block>
{/* FOTKA */}
<Block
title="Fotka"
action={(scale !== 1 || offsetX !== 0 || offsetY !== 0) && (
<ResetLink onClick={() => onChange({ imageScale: undefined, imageOffsetX: undefined, imageOffsetY: undefined })} />
)}
>
<ImageUploadInput
label=""
value={p.imageUrl || ''}
onChange={set('imageUrl')}
altValue={p.imageAlt}
onAltChange={set('imageAlt')}
altPlaceholder={productCardAlt({ ...p, imageAlt: undefined }, variant)}
/>
<Slider label="Veľkosť" value={scale} display={`${Math.round(scale * 100)} %`} min={0.4} max={2.5} step={0.05} onChange={set('imageScale')} />
<Slider label="Vodorovne" value={offsetX} display={`${offsetX}`} min={-150} max={150} step={1} onChange={set('imageOffsetX')} />
<Slider label="Zvisle" value={offsetY} display={`${offsetY}`} min={-120} max={120} step={1} onChange={set('imageOffsetY')} />
</Block>
{/* CENOVKA */}
<Block title="Cenovka">
<div className="flex items-center gap-1.5">
<select
value={p.customBadgePngUrl ? 'png' : p.badgeCustomTemplate?.id || 'default'}
onChange={(e) => selectBadge(e.target.value)}
className={`${inputCls} font-medium`}
>
<option value="default">{isLoyalty ? 'Predvolená (bez karty / s kartou)' : 'Predvolená COOP cenovka'}</option>
{availableBadges.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
{p.customBadgePngUrl && <option value="png">Vlastná PNG cenovka</option>}
</select>
{onOpenBadgeEditor && (
<button
type="button"
onClick={onOpenBadgeEditor}
className="shrink-0 p-1.5 rounded-lg border border-gray-300 bg-white text-gray-600 hover:text-red-600 hover:border-red-300"
title="Editor cenoviek (vytvoriť / upraviť šablóny)"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
</div>
<Slider label="Veľkosť" value={badgeWidth} display={`${badgeWidth} px`} min={60} max={200} step={1} onChange={set('badgeWidth')} />
{isLoyalty ? (
<>
{priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount', 'cardUnitPrice')}
{priceRow('Bez karty', 'noCardPriceMain', 'noCardPriceSub', 'noCardDiscount', 'noCardUnitPrice')}
</>
) : (
<>
{priceRow('Cena', 'priceMain', 'priceSub', 'discountText')}
{usesCardPrice(p.badgeCustomTemplate) && priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount')}
</>
)}
{p.badgeCustomTemplate && (onApplyBadgeToSection || onApplyBadgeToAll) && (
<div className="flex flex-wrap gap-1.5 pt-1">
{onApplyBadgeToSection && (
<button
type="button"
onClick={onApplyBadgeToSection}
className="flex items-center gap-1 text-[10px] font-bold text-gray-700 bg-white border border-gray-300 hover:bg-gray-100 px-2 py-1 rounded-lg"
>
<Layers className="w-3 h-3" /> Použiť v celej sekcii
</button>
)}
{onApplyBadgeToAll && (
<button
type="button"
onClick={onApplyBadgeToAll}
className="flex items-center gap-1 text-[10px] font-bold text-red-700 bg-red-50 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
>
<Globe className="w-3 h-3" /> Použiť v celom newsletteri
</button>
)}
</div>
)}
</Block>
{/* DOSTUPNOSŤ */}
<Block
title="Dostupnosť"
action={hasStores && (storesX !== 0 || storesY !== DEFAULT_STORES_OFFSET_Y) && (
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined })} />
)}
>
<div className="grid grid-cols-3 gap-1.5">
{(
[
['potraviny', 'Potraviny', 'bg-[#e2001a]'],
['supermarket', 'Supermarket', 'bg-[#f9890e]'],
['tempo', 'Tempo', 'bg-[#005239]']
] as const
).map(([key, label, color]) => {
const on = !!p.stores?.[key];
return (
<button
key={key}
type="button"
onClick={() => onChange({ stores: { ...(p.stores || {}), [key]: !on } })}
className={`text-[10px] font-bold py-1.5 rounded-lg border transition-colors ${
on ? `${color} text-white border-transparent` : 'bg-white text-gray-400 border-gray-200'
}`}
>
{label}
</button>
);
})}
</div>
{hasStores && (
<>
<Slider label="Štítky ↔" value={storesX} display={`${storesX}`} min={-80} max={80} step={1} onChange={set('storesOffsetX')} />
<Slider label="Štítky ↕" value={storesY} display={`${storesY}`} min={-40} max={60} step={1} onChange={set('storesOffsetY')} />
</>
)}
</Block>
{/* ĎALŠIE MOŽNOSTI */}
<details className="group">
<summary className="text-[10px] font-bold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-800">
Ďalšie možnosti
</summary>
<div className="pt-3 space-y-3">
<Field label="Farba karty">
<div className="flex items-center gap-1.5 flex-wrap">
{PRODUCT_BG_PRESETS.map((preset) => (
<button
key={preset.color}
type="button"
title={preset.label}
onClick={() => onChange({ cardBgColor: preset.color })}
className={`w-6 h-6 rounded-full border ${
(p.cardBgColor || '').toLowerCase() === preset.color ? 'ring-2 ring-red-500 border-white' : 'border-black/15'
}`}
style={{ backgroundColor: preset.color }}
/>
))}
<input
type="color"
value={p.cardBgColor || '#feeaea'}
onChange={(e) => onChange({ cardBgColor: e.target.value })}
className="w-6 h-6 rounded cursor-pointer border border-gray-300 p-0 bg-white"
title="Vlastná farba"
/>
</div>
</Field>
<Field label="Vlastná platnosť (len pre tento produkt)">
<TextInput value={p.validityOverride} onChange={set('validityOverride')} placeholder="napr. 2. 5. – 9. 5. 2026" />
</Field>
<div className="space-y-1.5">
<ImageUploadInput
label="Vlastná PNG cenovka (výnimka)"
value={p.customBadgePngUrl || ''}
onChange={(url) => onChange({ customBadgePngUrl: url || undefined })}
placeholder="Celá cenovka ako PNG – prekryje šablónu"
/>
{p.customBadgePngUrl && (
<button
type="button"
onClick={() => onChange({ customBadgePngUrl: undefined })}
className="w-full py-1 text-[10px] font-bold text-amber-800 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-lg"
>
Odstrániť PNG a vrátiť šablónu
</button>
)}
</div>
</div>
</details>
</div>
)}
</div>
);
};
+71
View File
@@ -0,0 +1,71 @@
// Príprava obrázkov produktových kariet pre export (ZIP / Brevo):
// vizuál karty z náhľadu v editore (fotka + cenovka + štítky) sa uloží ako JPG, nahrá do úložiska
// a jeho adresa sa zapamätá pri produkte (cardImage). Pri ďalšom exporte sa nezmenené karty nenahrávajú znova.
import { toJpeg } from 'html-to-image';
import { CardProduct, NewsletterCampaign } from '@/types/newsletter';
import { cardContextForModule, cardVisualKey, cardVisualHeight } from './productCard';
async function uploadJpeg(dataUrl: string, fileName: string): Promise<string> {
const blob = await (await fetch(dataUrl)).blob();
const formData = new FormData();
formData.append('file', new File([blob], fileName, { type: 'image/jpeg' }));
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!data.success || !data.url) {
throw new Error(data.error || 'Nahratie obrázka karty zlyhalo');
}
return data.url;
}
export async function prepareCardImages(
campaign: NewsletterCampaign,
onProgress?: (done: number, total: number) => void
): Promise<{ campaign: NewsletterCampaign; failed: number }> {
// Karty, ktorých obrázok chýba alebo je zastaraný
const jobs: Array<{ mIdx: number; pIdx: number; key: string; bg: string; width: number }> = [];
campaign.modules.forEach((m, mIdx) => {
const isCards = m.type === 'products_grid' || (m.type === 'loyalty_club' && m.data?.enabled !== false);
if (!isCards) return;
const ctx = cardContextForModule(m, 'preview');
(m.data.products || []).forEach((p: CardProduct, pIdx: number) => {
const key = cardVisualKey(p, ctx);
if (p.cardImage?.key !== key) {
jobs.push({ mIdx, pIdx, key, bg: p.cardBgColor || ctx.cardBg, width: ctx.cardWidth });
}
});
});
const modules = campaign.modules.map((m) => ({ ...m, data: { ...m.data, products: m.data.products ? [...m.data.products] : m.data.products } }));
let failed = 0;
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
onProgress?.(i, jobs.length);
const node = document.querySelector<HTMLElement>(`[data-card-visual="${job.key}"]`);
if (!node) {
failed++;
continue;
}
try {
// Presná veľkosť karty v e-maile (náhľad môže byť v užšom okne)
const height = cardVisualHeight(job.width);
const dataUrl = await toJpeg(node, {
pixelRatio: 2,
quality: 0.9,
backgroundColor: job.bg,
width: job.width,
height,
style: { width: `${job.width}px`, height: `${height}px` },
// obrázky idú cez /api/img?url=… – bez parametrov by si html-to-image pomýlil rôzne obrázky
includeQueryParams: true
});
const product = modules[job.mIdx].data.products[job.pIdx];
const url = await uploadJpeg(dataUrl, `karta-${product.id}.jpg`);
modules[job.mIdx].data.products[job.pIdx] = { ...product, cardImage: { key: job.key, url } };
} catch (err) {
console.warn('Obrázok karty sa nepodarilo pripraviť:', err);
failed++;
}
}
onProgress?.(jobs.length, jobs.length);
return { campaign: { ...campaign, modules }, failed };
}
+44 -533
View File
@@ -1,10 +1,12 @@
import { NewsletterCampaign, ProductItem } from '@/types/newsletter'; import { NewsletterCampaign } from '@/types/newsletter';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign'; import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { renderCleanBadgeSvg } from './badgeUtils';
import { pickAlt } from './altText'; import { pickAlt } from './altText';
import { escapeHtml, escapeWithBreaks } from './html';
import { RenderMode, cardContextForModule, renderProductGrid } from './productCard';
// E-mail na export (ZIP/Brevo): produktové karty použijú obrázky pripravené cez prepareCardImages()
export function renderEmailHtml(campaign: NewsletterCampaign): string { export function renderEmailHtml(campaign: NewsletterCampaign): string {
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n'); const renderedModules = campaign.modules.map((m) => renderModule(m, campaign, 'export')).join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="sk" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> <html lang="sk" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
@@ -119,6 +121,11 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
.top-msg { font-size: 11px !important; line-height: 1.4 !important; } .top-msg { font-size: 11px !important; line-height: 1.4 !important; }
.top-tip { font-size: 10px !important; padding: 1px 6px !important; } .top-tip { font-size: 10px !important; padding: 1px 6px !important; }
.top-link { font-size: 11px !important; } .top-link { font-size: 11px !important; }
/* Produktové karty */
.prod-pad { padding-left: 10px !important; padding-right: 10px !important; }
.prod-name { font-size: 13px !important; }
.prod-unit { font-size: 10px !important; }
} }
/* ====== DARK MODE OVERRIDE — Outlook.com / O365 web / dark-mode klienty ====== */ /* ====== DARK MODE OVERRIDE — Outlook.com / O365 web / dark-mode klienty ====== */
@@ -127,6 +134,7 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
[data-ogsc] .card-fantasticky, [data-ogsb] .card-fantasticky { background-color: #feeaea !important; } [data-ogsc] .card-fantasticky, [data-ogsb] .card-fantasticky { background-color: #feeaea !important; }
[data-ogsc] .card-domace, [data-ogsb] .card-domace { background-color: #fef0c7 !important; } [data-ogsc] .card-domace, [data-ogsb] .card-domace { background-color: #fef0c7 !important; }
[data-ogsc] .card-grill, [data-ogsb] .card-grill { background-color: #dcb085 !important; } [data-ogsc] .card-grill, [data-ogsb] .card-grill { background-color: #dcb085 !important; }
[data-ogsc] .card-zelena, [data-ogsb] .card-zelena { background-color: #e3f0e4 !important; }
[data-ogsc] .card-vernostna, [data-ogsb] .card-vernostna { background-color: #e6e1d9 !important; } [data-ogsc] .card-vernostna, [data-ogsb] .card-vernostna { background-color: #e6e1d9 !important; }
[data-ogsc] .card-white-bg, [data-ogsb] .card-white-bg { background-color: #fefefe !important; } [data-ogsc] .card-white-bg, [data-ogsb] .card-white-bg { background-color: #fefefe !important; }
[data-ogsc] .force-text-dark, [data-ogsb] .force-text-dark { color: #1a1a1a !important; } [data-ogsc] .force-text-dark, [data-ogsb] .force-text-dark { color: #1a1a1a !important; }
@@ -147,6 +155,7 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
.card-fantasticky { background-color: #feeaea !important; } .card-fantasticky { background-color: #feeaea !important; }
.card-domace { background-color: #fef0c7 !important; } .card-domace { background-color: #fef0c7 !important; }
.card-grill { background-color: #dcb085 !important; } .card-grill { background-color: #dcb085 !important; }
.card-zelena { background-color: #e3f0e4 !important; }
.card-vernostna { background-color: #e6e1d9 !important; } .card-vernostna { background-color: #e6e1d9 !important; }
.card-white-bg { background-color: #fefefe !important; } .card-white-bg { background-color: #fefefe !important; }
.force-text-dark { color: #1a1a1a !important; } .force-text-dark { color: #1a1a1a !important; }
@@ -160,6 +169,26 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
.force-bg-green { background-color: #005239 !important; } .force-bg-green { background-color: #005239 !important; }
.force-bg-articleblue { background-color: #5c85a8 !important; } .force-bg-articleblue { background-color: #5c85a8 !important; }
} }
/* ====== Apple Mail / iOS Mail dark mode extra protection ====== */
u + .body .force-text-white,
u + .body .force-bg-red,
u + .body .force-bg-blue,
u + .body .force-bg-darkblue,
u + .body .force-bg-yellow,
u + .body .force-bg-yellow2,
u + .body .force-bg-orange,
u + .body .force-bg-green,
u + .body .force-bg-articleblue,
u + .body .card-white-bg,
u + .body .card-fantasticky,
u + .body .card-domace,
u + .body .card-grill,
u + .body .card-zelena,
u + .body .card-vernostna {
color: revert !important;
background-color: revert !important;
}
</style> </style>
<!--[if mso]> <!--[if mso]>
<style type="text/css"> <style type="text/css">
@@ -199,8 +228,9 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
</html>`; </html>`;
} }
// Náhľad modulu v editore (produktové karty sa skladajú živo z HTML/CSS)
export function renderSingleModuleHtml(m: any, campaign: NewsletterCampaign): string { export function renderSingleModuleHtml(m: any, campaign: NewsletterCampaign): string {
return renderModule(m, campaign); return renderModule(m, campaign, 'preview');
} }
// Globálna platnosť akcie kampane (zdieľaná všetkými produktovými sekciami) // Globálna platnosť akcie kampane (zdieľaná všetkými produktovými sekciami)
@@ -220,7 +250,7 @@ export function renderGeneratedModuleHtml(m: any): string {
return renderModule({ ...m, data: { ...m.data, useCustomHtml: false } }, {} as NewsletterCampaign).trim(); return renderModule({ ...m, data: { ...m.data, useCustomHtml: false } }, {} as NewsletterCampaign).trim();
} }
function renderModule(m: any, campaign: NewsletterCampaign): string { function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = 'preview'): string {
// Vlastné HTML hlavičky / pätičky má prednosť pred vygenerovaným // Vlastné HTML hlavičky / pätičky má prednosť pred vygenerovaným
if (CUSTOM_HTML_MODULE_TYPES.includes(m.type) && m.data?.useCustomHtml && m.data?.customHtml) { if (CUSTOM_HTML_MODULE_TYPES.includes(m.type) && m.data?.useCustomHtml && m.data?.customHtml) {
return m.data.customHtml; return m.data.customHtml;
@@ -344,83 +374,34 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;">&nbsp;</td></tr></table>`; <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;">&nbsp;</td></tr></table>`;
case 'products_grid': case 'products_grid':
// Platnosť na kartách je predvolene skrytá (zobrazí sa v Lište platnosti akcie);
// produkty s vlastnou platnosťou (validityOverride) ju zobrazia vždy
const globalValidity = m.data.showCardValidity === true ? (m.data.globalValidity || '') : '';
const storesOffset: StoresOffset = { x: m.data.storesOffsetX, y: m.data.storesOffsetY };
const products: ProductItem[] = m.data.products || [];
const cardBg = m.data.cardBgColor || '#feeaea';
const cardBorder = m.data.cardBorderColor || '#f5d4d4';
const badgeHdrBg = m.data.badgeHeaderBg || '#fefefe';
const badgeHdrColor = m.data.badgeHeaderColor || '#000000';
const badgePrcBg = m.data.badgePriceBg || '#e2001a';
const badgePrcColor = m.data.badgePriceColor || '#fefefe';
const badgeCustomTxt = m.data.badgeHeaderCustomText || null;
const cols = m.data.columns === 1 ? 1 : 2;
const rows: ProductItem[][] = [];
for (let i = 0; i < products.length; i += cols) {
rows.push(products.slice(i, i + cols));
}
const rowsHtml = rows.map((row) => {
if (cols === 1) {
const p = row[0];
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr>
<td width="100%" valign="top">
${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle, 600, storesOffset)}
</td>
</tr>
</table>`;
}
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="1" style="margin-bottom: 12px;">
<tr>
${row.map((p, idx) => `
<td width="50%" valign="top" height="100%" style="padding-${idx === 0 ? 'right: 6px' : 'left: 6px'}; padding-bottom: 0; height: 100%;">
${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle, 294, storesOffset)}
</td>`).join('\n')}
${row.length === 1 ? '<td width="50%" valign="top" height="100%" style="height: 100%;"></td>' : ''}
</tr>
</table>
`;
}).join('\n');
return ` return `
<!-- PRODUKTOVÁ SEKCIA: ${escapeHtml(m.data.sectionTitle || '')} --> <!-- PRODUKTOVÁ SEKCIA: ${escapeHtml(m.data.sectionTitle || '')} -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
${m.data.sectionTitle ? ` ${m.data.sectionTitle ? `
<tr> <tr>
<td style="text-align: center; padding-top: 25px; padding-bottom: 25px;"> <td style="text-align: center; padding-top: 25px; padding-bottom: 15px;">
<h2 class="force-text-white" style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeHtml(m.data.sectionTitle)}</h2> <h2 class="force-text-white" style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeWithBreaks(m.data.sectionTitle)}</h2>
</td> </td>
</tr>` : ''} </tr>` : ''}
<tr> ${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
<td> </table>
${rowsHtml} <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="12" style="font-size: 12px; line-height: 12px;">&nbsp;</td></tr></table>`;
</td>
</tr>
</table>`;
case 'loyalty_club': case 'loyalty_club':
if (m.data.enabled === false) { if (m.data.enabled === false) {
return ''; return '';
} }
const lProducts = m.data.products || [];
return ` return `
<!-- 7. VERNOSTNÉ ZĽAVY (COOP KLUB) --> <!-- 7. VERNOSTNÉ ZĽAVY (COOP KLUB) -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="text-align: center; padding-top: 25px; padding-bottom: 25px;"> <td style="text-align: center; padding-top: 25px; padding-bottom: 15px;">
<h2 class="force-text-white" style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeHtml(m.data.sectionTitle || 'Vernostné zľavy')}</h2> <h2 class="force-text-white" style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeHtml(m.data.sectionTitle || 'Vernostné zľavy')}</h2>
</td> </td>
</tr> </tr>
<!-- Banner karta --> <!-- Banner karta -->
<tr> <tr>
<td style="padding-bottom: 20px;"> <td style="padding-bottom: 15px;">
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" class="card-white card-vernostna" bgcolor="#e6e1d9" style="background-color: #e6e1d9; border: 0; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); overflow: hidden;"> <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" class="card-white card-vernostna" bgcolor="#e6e1d9" style="background-color: #e6e1d9; border: 0; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); overflow: hidden;">
<tr> <tr>
<td> <td>
@@ -432,19 +413,8 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
</table> </table>
</td> </td>
</tr> </tr>
<!-- Produktové karty - 2 vedľa seba --> <!-- Produktové karty -->
<tr> ${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
<td>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
${lProducts.map((p: any, idx: number) => `
<td width="50%" valign="top" height="100%" style="padding-${idx === 0 ? 'right: 6px' : 'left: 6px'}; padding-bottom: 0; height: 100%;">
${renderExactLoyaltyCard(p, m.data.showCardValidity === true ? (m.data.globalValidity || '2. 5. – 15. 5. 2026') : '', { x: m.data.storesOffsetX, y: m.data.storesOffsetY })}
</td>`).join('\n')}
</tr>
</table>
</td>
</tr>
</table> </table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;">&nbsp;</td></tr></table>`; <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;">&nbsp;</td></tr></table>`;
@@ -662,462 +632,3 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
return ''; return '';
} }
} }
// ---------------------------------------------------------------------------
// Úprava produktovej fotky (zoom / posun / orezanie)
// Upravená fotka sa vykreslí ako pozadie bunky (background-size / background-position
// fungujú aj v Gmaile, ktorý transform/position odstraňuje); čo presahuje rámik, sa oreže.
// Outlook pre Windows (mso) pozadia nevie, dostane pôvodný <img>.
// ---------------------------------------------------------------------------
function hasImageAdjust(p: ProductItem): boolean {
return (p.imageScale ?? 1) !== 1 || (p.imageOffsetX || 0) !== 0 || (p.imageOffsetY || 0) !== 0;
}
// Obsah vkladaný do podmieneného komentára pre Outlook nesmie obsahovať vlastné komentáre (napr. zo SVG cenovky)
function stripHtmlComments(html: string): string {
return html.replace(/<!--[\s\S]*?-->/g, '');
}
// Štýl pozadia: fotka so základnou šírkou baseW, vycentrovaná na (centerX, centerY) rámika, plus zoom a posun
function adjustedImageBgStyle(p: ProductItem, baseW: number, centerX: number, centerY: number): string {
const scale = p.imageScale ?? 1;
const aspect = p.imageAspect || 1;
const w = Math.round(baseW * scale);
const h = Math.round(w * aspect);
const x = Math.round(centerX - w / 2 + (p.imageOffsetX || 0));
const y = Math.round(centerY - h / 2 + (p.imageOffsetY || 0));
return `background-image: url('${escapeHtml(p.imageUrl)}'); background-repeat: no-repeat; background-size: ${w}px ${h}px; background-position: ${x}px ${y}px;`;
}
// Riadok s fotkou pod sebou (rozloženie "pod obrázkom" a vernostné karty)
function renderStackedImageRow(p: ProductItem, imgHtml: string, cellStyle: string, cardWidth: number, maxImgW: number, alt: string): string {
if (!hasImageAdjust(p)) {
return `
<tr height="160">
<td height="160" style="${cellStyle}" valign="middle">
${imgHtml}
</td>
</tr>`;
}
const baseW = Math.min(maxImgW, cardWidth - 16);
const baseH = Math.round(baseW * (p.imageAspect || 1));
const frameH = Math.max(160, baseH + 15);
return `
<!--[if mso]>
<tr><td height="160" style="${cellStyle}" valign="middle">${imgHtml}</td></tr>
<![endif]-->
<!--[if !mso]><!-->
<tr height="${frameH}">
<td height="${frameH}" role="img" aria-label="${escapeHtml(alt)}" style="height: ${frameH}px; line-height: ${frameH}px; font-size: 0; ${adjustedImageBgStyle(p, baseW, cardWidth / 2, baseH / 2)}">&nbsp;</td>
</tr>
<!--<![endif]-->`;
}
// Popis produktu podľa vzoru coop_nws_009: 3 štýly textu (+ voliteľná poznámka)
// názov 15px/700 · gramáž 15px/400 (v tom istom riadku) · jednotková cena 11px #555 · poznámka 10px #888
const PRODUCT_TEXT_FONT = "font-family: 'Poppins', Helvetica, Arial, sans-serif;";
function renderProductTextBlock(p: { title?: string; quantity?: string; note?: string }, unitPrice?: string): string {
return `<h3 class="prod-name force-text-dark" style="margin: 0; ${PRODUCT_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; ${PRODUCT_TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeHtml(unitPrice)}</div>` : ''}${
p.note ? `
<div class="prod-unit force-text-dark" style="margin-top: 2px; ${PRODUCT_TEXT_FONT} font-size: 10px; line-height: 1.3; color: #888888;">${escapeHtml(p.note)}</div>` : ''}`;
}
// Štítky dostupnosti v predajniach (Potraviny / Supermarket / Tempo); prázdne, ak nie je zvolená žiadna
function renderStoresHtml(p: { stores?: Partial<ProductItem["stores"]> }): string {
const tag = (color: string, file: string, alt: string) =>
`<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 0; background-color: ${color}; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}${file}" height="10" alt="${alt}" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>`;
const rows = [
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('');
return rows ? `<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right">${rows}</table>` : '';
}
// Posun štítkov predajní voči hornej hrane cenovky (nastavenie sekcie)
export interface StoresOffset {
x?: number; // + doprava, − doľava
y?: number; // + vyššie nad cenovku, − nižšie (prekryje horný okraj cenovky)
}
export const DEFAULT_STORES_OFFSET_Y = 2;
// Štítky predajní "prilepené" k hornej hrane cenovky (zarovnané k jej pravému okraju),
// takže sa posúvajú spolu s cenovkou; padTop = odsadenie celej cenovky zhora
function attachStoresToBadge(
badgeHtml: string,
storesHtml: string,
bWidth: number,
bAlign: string,
marginStyle: string,
padTop: number,
offset: StoresOffset = {}
): string {
if (!storesHtml) {
return `<div style="padding-top: ${padTop}px;">${badgeHtml}</div>`;
}
const x = Math.round(offset.x || 0);
const y = Math.round(offset.y ?? DEFAULT_STORES_OFFSET_Y);
const cellPadding = `padding: ${padTop}px ${x < 0 ? -x : 0}px ${y > 0 ? y : 0}px 0;`;
const storesStyle = `margin-right: ${x > 0 ? -x : 0}px; margin-bottom: ${y < 0 ? y : 0}px; position: relative; z-index: 2;`;
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px;">
<tr>
<td align="right" style="line-height: 0; font-size: 0; ${cellPadding}">
<div style="display: inline-block; ${storesStyle}">${storesHtml}</div>
</td>
</tr>
<tr>
<td align="${bAlign}">${badgeHtml}</td>
</tr>
</table>`;
}
function renderExactProductCard(
p: ProductItem,
globalValidity: string,
cardBg: string,
cardBorder: string,
badgeHdrBg: string,
badgeHdrColor: string,
badgePrcBg: string,
badgePrcColor: string,
badgeCustomTxt: string | null,
sectionTitle?: string,
cardWidth: number = 294, // nominálna šírka karty v px (2 stĺpce = 294, 1 stĺpec = 600)
storesOffset: StoresOffset = {}
): string {
const validity = p.validityOverride || globalValidity;
const effectiveCardBg = p.cardBgColor || cardBg || '#feeaea';
const storesHtml = renderStoresHtml(p);
// Posun štítkov je nastavenie produktu (staršie kampane mohli mať hodnotu na sekcii)
const productStoresOffset: StoresOffset = {
x: p.storesOffsetX ?? storesOffset.x,
y: p.storesOffsetY ?? storesOffset.y
};
const badgeLayout = p.badgeLayout || 'below';
// Cenovka so štítkami predajní nad hornou hranou (bez obalového <td>)
const renderBadgeContent = (bWidth: number, bAlign: string, padTop: number, padBottom: number, marginStyle: string) =>
attachStoresToBadge(renderBadgeOnly(bWidth, bAlign, 0, padBottom, marginStyle), storesHtml, bWidth, bAlign, marginStyle, padTop, productStoresOffset);
// Pomocná funkcia na render samotnej cenovky (bez obalového <td>)
const renderBadgeOnly = (bWidth: number, bAlign: string, padTop: number, padBottom: number, marginStyle: string) => {
// Vlastná PNG cenovka (výnimka) má prednosť pred šablónou; po jej odstránení sa vráti šablóna
if (p.customBadgePngUrl) {
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
<tr>
<td align="${bAlign}">
<img src="${escapeHtml(p.customBadgePngUrl)}" width="${bWidth}" alt="${escapeHtml(pickAlt(p.title, 'Cenovka'))}" style="display: block; width: 100%; max-width: ${bWidth}px; margin: 0 auto;">
</td>
</tr>
</table>`;
}
if (p.badgeCustomTemplate) {
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
<tr>
<td align="${bAlign}">
${renderCleanBadgeSvg(p.badgeCustomTemplate, p, bWidth)}
</td>
</tr>
</table>`;
}
const mainFontSize = Math.max(28, Math.round(bWidth * 0.54));
const subFontSize = Math.max(14, Math.round(bWidth * 0.27));
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
<tr>
<td align="${bAlign}">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: ${badgeHdrBg}; border: 0; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.18); width: ${bWidth}px; overflow: hidden;">
${badgeCustomTxt ? `
<tr>
<td bgcolor="${badgeHdrBg}" style="padding: 4px 4px; text-align: center; background-color: ${badgeHdrBg};">
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 10px; color: ${badgeHdrColor}; font-weight: 800; text-transform: uppercase;">${escapeHtml(badgeCustomTxt)}</span>
</td>
</tr>` : (p.discountText ? `
<tr>
<td style="padding: 2px 4px; text-align: right; background-color: ${badgeHdrBg};">
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 9px; color: ${badgeHdrColor}; 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: ${badgeHdrColor}; font-weight: 900; line-height: 1; margin-left: 2px;">${escapeHtml(p.discountText)}</span>
</td>
</tr>` : '')}
<tr>
<td class="force-text-white" bgcolor="${badgePrcBg}" style="background-color: ${badgePrcBg}; padding: 4px 2px 2px 2px; font-family: 'Arial Narrow', Arial, sans-serif; color: ${badgePrcColor}; 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>
</td>
</tr>
</table>`;
};
if (badgeLayout === 'side') {
// Rozloženie: obrázok vľavo | cenovka vpravo
const bWidth = p.badgeWidth || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.width || 110) : 95);
const bAlign = 'center';
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate?.paddingTop ?? 0);
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate?.paddingBottom ?? 0);
const marginStyle = 'margin: 0 auto;';
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product" bgcolor="${effectiveCardBg}" style="background-color: ${effectiveCardBg}; border: 0; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); height: 100%; min-height: 100%;">
<tr height="1">
<td colspan="2" style="padding: 15px 15px 0 15px; height: 1px;" valign="top">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
</tr>
</table>
</td>
</tr>
${(() => {
const alt = pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt');
const imgCell = `<td width="58%" height="160" style="padding: 8px 4px 8px 8px; text-align: center; height: 160px;" valign="middle">
<img src="${escapeHtml(p.imageUrl)}" width="180" alt="${escapeHtml(alt)}" style="display: block; width: 100%; max-width: 180px; margin: 0 auto; border-radius: 8px;">
</td>`;
const badgeCell = `<td width="42%" style="padding: 8px 8px 8px 4px; text-align: center;" valign="middle">
${renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle)}
</td>`;
if (!hasImageAdjust(p)) {
return `<tr>${imgCell}${badgeCell}</tr>`;
}
// Upravená fotka: pozadie celého riadku, cenovka je nad ňou (produkt sa dá zasunúť pod cenovku)
const baseW = Math.min(180, Math.round(cardWidth * 0.58) - 12);
const frameH = Math.max(160, Math.round(baseW * (p.imageAspect || 1)) + 16);
return `
<!--[if mso]><tr>${imgCell}${stripHtmlComments(badgeCell)}</tr><![endif]-->
<!--[if !mso]><!-->
<tr>
<td colspan="2" height="${frameH}" role="img" aria-label="${escapeHtml(alt)}" style="height: ${frameH}px; ${adjustedImageBgStyle(p, baseW, Math.round(cardWidth * 0.29), frameH / 2)}">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="${frameH}">
<tr>
<td width="58%" height="${frameH}" style="font-size: 0; line-height: 0;">&nbsp;</td>
${badgeCell}
</tr>
</table>
</td>
</tr>
<!--<![endif]-->`;
})()}
<tr>
<td colspan="2" class="prod-pad" height="54" style="padding: 2px 14px 12px 14px; text-align: left; height: 54px; min-height: 54px;" valign="top">
${renderProductTextBlock(p, p.unitPrice)}
</td>
</tr>
</table>`;
}
// Predvolené rozloženie: cenovka pod obrázkom
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product" bgcolor="${effectiveCardBg}" style="background-color: ${effectiveCardBg}; border: 0; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); height: 100%; min-height: 100%;">
<tr height="1">
<td style="padding: 15px 15px 0 15px; height: 1px;" valign="top">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
</tr>
</table>
</td>
</tr>
${renderStackedImageRow(
p,
`<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt'))}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">`,
'padding: 0 8px 15px 8px; text-align: center; height: 160px;',
cardWidth,
220,
pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt')
)}
<tr>
<td class="prod-pad" height="54" style="padding: 2px 14px 12px 14px; text-align: left; height: 54px; min-height: 54px;" valign="top">
${renderProductTextBlock(p, p.unitPrice)}
</td>
</tr>
<tr height="1">
<td style="padding: 0 8px 4px 8px; text-align: center;" valign="bottom">
${(() => {
const bWidth = p.badgeWidth || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.width || 110) : 95);
const bAlign = p.badgeAlign || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.align || 'center') : 'center');
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate?.paddingTop ?? 0);
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate?.paddingBottom ?? 0);
let marginStyle = 'margin: 0 auto;';
if (bAlign === 'left') {
marginStyle = 'margin: 0 auto 0 0; margin-right: auto; margin-left: 0;';
} else if (bAlign === 'right') {
marginStyle = 'margin: 0 0 0 auto; margin-left: auto; margin-right: 0;';
}
return renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle);
})()}
</td>
</tr>
<tr height="1">
<td style="padding: 0 0 12px 0; height: 1px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>`;
}
function renderExactLoyaltyCard(p: any, validity: string, storesOffset: StoresOffset = {}): string {
const cardValidity = p.validityOverride || validity;
const storesHtml = renderStoresHtml(p);
// Posun štítkov je nastavenie produktu (staršie kampane mohli mať hodnotu na sekcii)
const productStoresOffset: StoresOffset = {
x: p.storesOffsetX ?? storesOffset.x,
y: p.storesOffsetY ?? storesOffset.y
};
const loyaltyBg = p.cardBgColor || '#e6e1d9';
return `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product card-vernostna" bgcolor="${loyaltyBg}" style="background-color: ${loyaltyBg}; border: 0; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); height: 100%; min-height: 100%;">
<tr height="1">
<td style="padding: 15px 15px 0 15px; height: 1px;" valign="top">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(cardValidity)}</td>
</tr>
</table>
</td>
</tr>
${hasImageAdjust(p)
? renderStackedImageRow(
p,
`<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(pickAlt(p.imageAlt, p.title, 'Produkt'))}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">`,
'padding: 0 8px 15px 8px; text-align: center; height: 160px;',
294,
220,
pickAlt(p.imageAlt, p.title, 'Produkt')
)
: `
<tr height="1">
<td style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(pickAlt(p.imageAlt, p.title, 'Produkt'))}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
</td>
</tr>`}
<tr>
<td class="prod-pad" style="padding: 2px 14px 12px 14px; text-align: left; height: 100%; min-height: 44px;" valign="top">
${renderProductTextBlock(p, p.badgeCustomTemplate || p.customBadgePngUrl ? p.cardUnitPrice || p.unitPrice : '')}
</td>
</tr>
<tr height="1">
<td style="padding: 0 8px ${p.badgeCustomTemplate || p.customBadgePngUrl ? '4px' : '12px'} 8px; text-align: center; height: 1px;" valign="bottom">
${(() => {
// 1. Ak má vernostný produkt vlastnú PNG cenovku (prednosť) alebo dynamickú šablónu cenovky
if (p.customBadgePngUrl || p.badgeCustomTemplate) {
const bWidth = p.badgeWidth || p.badgeCustomTemplate?.width || 120;
const bAlign = p.badgeAlign || p.badgeCustomTemplate?.align || 'center';
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate?.paddingTop ?? 0);
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate?.paddingBottom ?? 0);
let marginStyle = 'margin: 0 auto;';
if (bAlign === 'left') {
marginStyle = 'margin: 0 auto 0 0; margin-right: auto; margin-left: 0;';
} else if (bAlign === 'right') {
marginStyle = 'margin: 0 0 0 auto; margin-left: auto; margin-right: 0;';
}
const loyaltyProductData = {
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 attachStoresToBadge(`
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-bottom: ${padBottom}px;">
<tr>
<td align="${bAlign}">
${p.customBadgePngUrl
? `<img src="${escapeHtml(p.customBadgePngUrl)}" width="${bWidth}" alt="${escapeHtml(pickAlt(p.title, 'Cenovka'))}" style="display: block; width: 100%; max-width: ${bWidth}px; margin: 0 auto;">`
: renderCleanBadgeSvg(p.badgeCustomTemplate, loyaltyProductData, bWidth)}
</td>
</tr>
</table>`, storesHtml, bWidth, bAlign, marginStyle, padTop, productStoresOffset);
}
// 2. Predvolený Dual Sticker Block (Bez karty / S kartou) 1:1
return attachStoresToBadge(`
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto; width: 140px;">
<tr>
<td colspan="2">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width: 140px; margin: 0 auto;">
<tr>
<!-- Bez karty -->
<td width="42%" valign="bottom" style="padding-right: 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border: 0; border-radius: 10px 0 0 10px; box-shadow: -2px 4px 8px rgba(0,0,0,0.12); overflow: hidden;">
<tr>
<td class="force-bg-red" 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>
<!-- S kartou -->
<td width="58%" valign="top">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border: 0; 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 class="force-bg-red force-text-white" 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>
</table>
</td>
</tr>
<tr>
<td width="42%" align="center" style="padding-top: 6px;">
<div style="font-size: 8px; color: #666666; opacity: 0.7; font-weight: 500; font-family: Arial, sans-serif;">${escapeHtml(p.noCardUnitPrice)}</div>
</td>
<td width="58%" align="center" style="padding-top: 6px;">
<div style="font-size: 9px; color: #e2001a; font-weight: 800; font-family: Arial, sans-serif;">${escapeHtml(p.cardUnitPrice)}</div>
</td>
</tr>
</table>`, storesHtml, 140, 'center', 'margin: 0 auto;', 0, productStoresOffset);
})()}
</td>
</tr>
${p.badgeCustomTemplate || p.customBadgePngUrl ? `
<tr height="1">
<td style="padding: 0 0 12px 0; height: 1px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>` : ''}
</table>`;
}
// Text nadpisu so zalomením riadkov: Enter (nový riadok) alebo <br> v texte = <br>, inak escapované
function escapeWithBreaks(str: string): string {
return escapeHtml(str)
.replace(/&lt;br\s*\/?&gt;/gi, '<br>')
.replace(/\r?\n/g, '<br>');
}
function escapeHtml(str: string): string {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
+18
View File
@@ -0,0 +1,18 @@
// Escapovanie textu pre HTML e-mailu
export function escapeHtml(str: string | null | undefined): string {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Text nadpisu so zalomením riadkov: Enter (nový riadok) alebo <br> v texte = <br>, inak escapované
export function escapeWithBreaks(str: string | null | undefined): string {
return escapeHtml(str)
.replace(/&lt;br\s*\/?&gt;/gi, '<br>')
.replace(/\r?\n/g, '<br>');
}
+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('');
}
+11 -1
View File
@@ -52,10 +52,20 @@ export interface ProductItem {
imageScale?: number; // Zoom fotky (1 = 100 %) imageScale?: number; // Zoom fotky (1 = 100 %)
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava) imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol) imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol)
imageAspect?: number; // Pomer strán fotky výška/šírka (pre výpočet pozadia) imageAspect?: number; // (staršie) pomer strán fotky
cardImage?: { key: string; url: string }; // obrázok vizuálu karty pripravený pri exporte
badgeLayout?: 'below' | 'side'; // Poloha cenovky: 'below' = pod obrázkom (default), 'side' = vedľa obrázka badgeLayout?: 'below' | 'side'; // Poloha cenovky: 'below' = pod obrázkom (default), 'side' = vedľa obrázka
} }
// Produkt na karte: bežný produkt + ceny vernostného produktu (bez karty / s kartou)
export interface CardProduct extends ProductItem {
cardUnitPrice?: string;
noCardPriceMain?: string;
noCardPriceSub?: string;
noCardDiscount?: string;
noCardUnitPrice?: string;
}
export interface AnnouncementModuleData { export interface AnnouncementModuleData {
tipBadge: string; tipBadge: string;
text: string; text: string;