feat: produktove karty v style letaku a zjednoduseny panel produktu

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

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

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
// Príprava obrázkov produktových kariet pre export (ZIP / Brevo):
// vizuál karty z náhľadu v editore (fotka + cenovka + štítky) sa uloží ako JPG, nahrá do úložiska
// a jeho adresa sa zapamätá pri produkte (cardImage). Pri ďalšom exporte sa nezmenené karty nenahrávajú znova.
import { toJpeg } from 'html-to-image';
import { CardProduct, NewsletterCampaign } from '@/types/newsletter';
import { cardContextForModule, cardVisualKey, cardVisualHeight } from './productCard';
async function uploadJpeg(dataUrl: string, fileName: string): Promise<string> {
const blob = await (await fetch(dataUrl)).blob();
const formData = new FormData();
formData.append('file', new File([blob], fileName, { type: 'image/jpeg' }));
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!data.success || !data.url) {
throw new Error(data.error || 'Nahratie obrázka karty zlyhalo');
}
return data.url;
}
export async function prepareCardImages(
campaign: NewsletterCampaign,
onProgress?: (done: number, total: number) => void
): Promise<{ campaign: NewsletterCampaign; failed: number }> {
// Karty, ktorých obrázok chýba alebo je zastaraný
const jobs: Array<{ mIdx: number; pIdx: number; key: string; bg: string; width: number }> = [];
campaign.modules.forEach((m, mIdx) => {
const isCards = m.type === 'products_grid' || (m.type === 'loyalty_club' && m.data?.enabled !== false);
if (!isCards) return;
const ctx = cardContextForModule(m, 'preview');
(m.data.products || []).forEach((p: CardProduct, pIdx: number) => {
const key = cardVisualKey(p, ctx);
if (p.cardImage?.key !== key) {
jobs.push({ mIdx, pIdx, key, bg: p.cardBgColor || ctx.cardBg, width: ctx.cardWidth });
}
});
});
const modules = campaign.modules.map((m) => ({ ...m, data: { ...m.data, products: m.data.products ? [...m.data.products] : m.data.products } }));
let failed = 0;
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
onProgress?.(i, jobs.length);
const node = document.querySelector<HTMLElement>(`[data-card-visual="${job.key}"]`);
if (!node) {
failed++;
continue;
}
try {
// Presná veľkosť karty v e-maile (náhľad môže byť v užšom okne)
const height = cardVisualHeight(job.width);
const dataUrl = await toJpeg(node, {
pixelRatio: 2,
quality: 0.9,
backgroundColor: job.bg,
width: job.width,
height,
style: { width: `${job.width}px`, height: `${height}px` },
// obrázky idú cez /api/img?url=… – bez parametrov by si html-to-image pomýlil rôzne obrázky
includeQueryParams: true
});
const product = modules[job.mIdx].data.products[job.pIdx];
const url = await uploadJpeg(dataUrl, `karta-${product.id}.jpg`);
modules[job.mIdx].data.products[job.pIdx] = { ...product, cardImage: { key: job.key, url } };
} catch (err) {
console.warn('Obrázok karty sa nepodarilo pripraviť:', err);
failed++;
}
}
onProgress?.(jobs.length, jobs.length);
return { campaign: { ...campaign, modules }, failed };
}