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:
1 parent
139e3569d0
commit
25771afc79
9 files changed
+1005
-1752
No files matched your search
@@ -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
@@ -13,6 +13,7 @@ import { ProductImportModal } from '@/components/editor/ProductImportModal';
|
||||
import { BadgeEditorModal } from '@/components/editor/BadgeEditorModal';
|
||||
import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal';
|
||||
import JSZip from 'jszip';
|
||||
import { prepareCardImages } from '@/lib/cardImages';
|
||||
import { saveAs } from 'file-saver';
|
||||
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 rawHtml = renderEmailHtml(campaign);
|
||||
const prepared = await prepareForExport();
|
||||
const rawHtml = renderEmailHtml(prepared);
|
||||
let finalHtml = rawHtml;
|
||||
try {
|
||||
const res = await fetch('/api/inline-css', {
|
||||
@@ -456,7 +468,8 @@ export default function Home() {
|
||||
const handleExportBrevo = async (updateExisting = false) => {
|
||||
setIsExportingBrevo(true);
|
||||
try {
|
||||
const rawHtml = renderEmailHtml(campaign);
|
||||
const prepared = await prepareForExport();
|
||||
const rawHtml = renderEmailHtml(prepared);
|
||||
let finalHtml = rawHtml;
|
||||
try {
|
||||
const res = await fetch('/api/inline-css', {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -1,10 +1,12 @@
|
||||
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||
import { renderCleanBadgeSvg } from './badgeUtils';
|
||||
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 {
|
||||
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>
|
||||
<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-tip { font-size: 10px !important; padding: 1px 6px !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 ====== */
|
||||
@@ -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-domace, [data-ogsb] .card-domace { background-color: #fef0c7 !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-white-bg, [data-ogsb] .card-white-bg { background-color: #fefefe !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-domace { background-color: #fef0c7 !important; }
|
||||
.card-grill { background-color: #dcb085 !important; }
|
||||
.card-zelena { background-color: #e3f0e4 !important; }
|
||||
.card-vernostna { background-color: #e6e1d9 !important; }
|
||||
.card-white-bg { background-color: #fefefe !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-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>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
@@ -199,8 +228,9 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Náhľad modulu v editore (produktové karty sa skladajú živo z HTML/CSS)
|
||||
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)
|
||||
@@ -220,7 +250,7 @@ export function renderGeneratedModuleHtml(m: any): string {
|
||||
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
|
||||
if (CUSTOM_HTML_MODULE_TYPES.includes(m.type) && m.data?.useCustomHtml && 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;"> </td></tr></table>`;
|
||||
|
||||
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 `
|
||||
<!-- PRODUKTOVÁ SEKCIA: ${escapeHtml(m.data.sectionTitle || '')} -->
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
${m.data.sectionTitle ? `
|
||||
<tr>
|
||||
<td style="text-align: center; padding-top: 25px; padding-bottom: 25px;">
|
||||
<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>
|
||||
<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);">${escapeWithBreaks(m.data.sectionTitle)}</h2>
|
||||
</td>
|
||||
</tr>` : ''}
|
||||
<tr>
|
||||
<td>
|
||||
${rowsHtml}
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
|
||||
</table>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="12" style="font-size: 12px; line-height: 12px;"> </td></tr></table>`;
|
||||
|
||||
case 'loyalty_club':
|
||||
if (m.data.enabled === false) {
|
||||
return '';
|
||||
}
|
||||
const lProducts = m.data.products || [];
|
||||
return `
|
||||
<!-- 7. VERNOSTNÉ ZĽAVY (COOP KLUB) -->
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Banner karta -->
|
||||
<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;">
|
||||
<tr>
|
||||
<td>
|
||||
@@ -432,19 +413,8 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Produktové karty - 2 vedľa seba -->
|
||||
<tr>
|
||||
<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>
|
||||
<!-- Produktové karty -->
|
||||
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
|
||||
</table>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;"> </td></tr></table>`;
|
||||
|
||||
@@ -662,462 +632,3 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
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)}"> </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;"> </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;"> </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;"> </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(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/\r?\n/g, '<br>');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/\r?\n/g, '<br>');
|
||||
}
|
||||
@@ -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;"> </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
@@ -52,10 +52,20 @@ export interface ProductItem {
|
||||
imageScale?: number; // Zoom fotky (1 = 100 %)
|
||||
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
tipBadge: string;
|
||||
text: string;
|
||||
|
||||
Reference in new issue
Block a user