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 { 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
@@ -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 { 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;"> </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;"> </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;"> </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;"> </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;"> </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)}"> </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 %)
|
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;
|
||||||
|
|||||||
Reference in new issue
Block a user