From 9c2fb60fd6abad50deedd50c31721a3c51cafd2b Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Mon, 19 Oct 2026 10:36:30 +0200 Subject: [PATCH] feat: uprava produktovej fotky - zoom, posun a orezanie - posuvniky Velkost / Vodorovne / Zvisle pri produkte aj vernostnom produkte - upravena fotka sa vykresli ako pozadie bunky (background-size/position, funguje aj v Gmaile), co presahuje ramik sa oreze - pri cenovke vedla obrazka je fotka pozadim celeho riadku a da sa zasunut pod cenovku; Outlook (mso) dostane povodny obrazok - neupravene produkty sa vykresluju presne ako doteraz Co-Authored-By: Claude Opus 5.5 --- src/components/editor/ConfigPanel.tsx | 86 ++++++++++++++++++++ src/lib/emailRenderer.ts | 111 +++++++++++++++++++++++--- src/types/newsletter.ts | 4 + 3 files changed, 188 insertions(+), 13 deletions(-) diff --git a/src/components/editor/ConfigPanel.tsx b/src/components/editor/ConfigPanel.tsx index e00795d..3c550f2 100644 --- a/src/components/editor/ConfigPanel.tsx +++ b/src/components/editor/ConfigPanel.tsx @@ -27,6 +27,72 @@ interface ConfigPanelProps { availableBadges?: BadgeTemplate[]; } +// Pomer strán obrázka (výška / šírka); pri chybe načítania 1 +const loadImageAspect = (url: string): Promise => + new Promise((resolve) => { + if (!url) return resolve(1); + const img = new window.Image(); + img.onload = () => resolve(img.naturalWidth ? img.naturalHeight / img.naturalWidth : 1); + img.onerror = () => resolve(1); + img.src = url; + }); + +// Úprava produktovej fotky: zoom a posun (čo presahuje rámik, sa oreže) +const ProductImageAdjust: React.FC<{ + product: ProductItem; + onChange: (patch: Partial) => void; +}> = ({ product, onChange }) => { + const scale = product.imageScale ?? 1; + const offsetX = product.imageOffsetX || 0; + const offsetY = product.imageOffsetY || 0; + const isAdjusted = scale !== 1 || offsetX !== 0 || offsetY !== 0; + + const update = async (patch: Partial) => { + // Pomer strán fotky potrebujeme pre presný výpočet pozadia; zistíme ho pri prvej úprave + if (!product.imageAspect && product.imageUrl) { + patch = { ...patch, imageAspect: await loadImageAspect(product.imageUrl) }; + } + onChange(patch); + }; + + const slider = (label: string, value: number, display: string, min: number, max: number, step: number, key: keyof ProductItem) => ( +
+ {label} + update({ [key]: Number(e.target.value) } as Partial)} + className="w-full h-1 accent-red-600 cursor-pointer" + /> + {display} +
+ ); + + return ( +
+
+ Úprava fotky + {isAdjusted && ( + + )} +
+ {slider('Veľkosť', scale, `${Math.round(scale * 100)} %`, 0.5, 2.5, 0.05, 'imageScale')} + {slider('Vodorovne', offsetX, `${offsetX} px`, -150, 150, 1, 'imageOffsetX')} + {slider('Zvisle', offsetY, `${offsetY} px`, -120, 120, 1, 'imageOffsetY')} +

Čo presahuje rámik, sa oreže. Pri polohe cenovky „vedľa obrázka“ sa dá produkt zasunúť pod cenovku.

+
+ ); +}; + // Jednoduchý HTML editor: vlastné HTML nahradí modul vygenerovaný z formulára const CustomHtmlEditor: React.FC<{ module: NewsletterModule; @@ -640,6 +706,7 @@ export const ConfigPanel: React.FC = ({ onChange={(url) => { const products = [...module.data.products]; products[pIdx].imageUrl = url; + products[pIdx].imageAspect = undefined; // nová fotka = nový pomer strán updateData({ products }); }} altValue={p.imageAlt} @@ -652,6 +719,15 @@ export const ConfigPanel: React.FC = ({ placeholder="https://...produkt.png alebo nahrajte súbor" /> + { + const products = [...module.data.products]; + products[pIdx] = { ...products[pIdx], ...patch }; + updateData({ products }); + }} + /> +
= ({ onChange={(url) => { const products = [...module.data.products]; products[pIdx].imageUrl = url; + products[pIdx].imageAspect = undefined; // nová fotka = nový pomer strán updateData({ products }); }} altValue={p.imageAlt} @@ -1282,6 +1359,15 @@ export const ConfigPanel: React.FC = ({ placeholder="https://...produkt.png alebo nahrajte súbor" /> + { + const products = [...module.data.products]; + products[pIdx] = { ...products[pIdx], ...patch }; + updateData({ products }); + }} + /> +
- ${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle)} + ${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle, 600)} `; @@ -630,6 +630,56 @@ function renderModule(m: any, campaign: NewsletterCampaign): string { } } +// --------------------------------------------------------------------------- +// Ú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ý . +// --------------------------------------------------------------------------- +function hasImageAdjust(p: any): 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(//g, ''); +} + +// Štýl pozadia: fotka so základnou šírkou baseW, vycentrovaná na (centerX, centerY) rámika, plus zoom a posun +function adjustedImageBgStyle(p: any, 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: any, imgHtml: string, cellStyle: string, cardWidth: number, maxImgW: number, alt: string): string { + if (!hasImageAdjust(p)) { + return ` + + + ${imgHtml} + + `; + } + const baseW = Math.min(maxImgW, cardWidth - 16); + const baseH = Math.round(baseW * (p.imageAspect || 1)); + const frameH = Math.max(160, baseH + 15); + return ` + + + +   + + `; +} + function renderExactProductCard( p: ProductItem, globalValidity: string, @@ -640,7 +690,8 @@ function renderExactProductCard( badgePrcBg: string, badgePrcColor: string, badgeCustomTxt: string | null, - sectionTitle?: string + sectionTitle?: string, + cardWidth: number = 294 // nominálna šírka karty v px (2 stĺpce = 294, 1 stĺpec = 600) ): string { const validity = p.validityOverride || globalValidity; const effectiveCardBg = p.cardBgColor || cardBg || '#feeaea'; @@ -726,15 +777,36 @@ function renderExactProductCard( - - - ${escapeHtml(pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt'))} - - + ${(() => { + const alt = pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt'); + const imgCell = ` + ${escapeHtml(alt)} + `; + const badgeCell = ` ${renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle)} ${p.unitPrice ? `
${escapeHtml(p.unitPrice)}
` : ''} + `; + if (!hasImageAdjust(p)) { + return `${imgCell}${badgeCell}`; + } + // 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 ` + + + + + + + + ${badgeCell} + +
 
+ `; + })()}

${escapeHtml(p.title)}

@@ -756,11 +828,14 @@ function renderExactProductCard( - - - ${escapeHtml(pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt'))} - - + ${renderStackedImageRow( + p, + `${escapeHtml(pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt'))}`, + 'padding: 0 8px 15px 8px; text-align: center; height: 160px;', + cardWidth, + 220, + pickAlt(p.imageAlt, p.title, sectionTitle, 'Produkt') + )}

${escapeHtml(p.title)}

@@ -813,11 +888,21 @@ function renderExactLoyaltyCard(p: any, validity: string): string { + ${hasImageAdjust(p) + ? renderStackedImageRow( + p, + `${escapeHtml(pickAlt(p.imageAlt, p.title, 'Produkt'))}`, + 'padding: 0 8px 15px 8px; text-align: center; height: 160px;', + 294, + 220, + pickAlt(p.imageAlt, p.title, 'Produkt') + ) + : ` ${escapeHtml(pickAlt(p.imageAlt, p.title, 'Produkt'))} - + `}

${escapeHtml(p.title)}

diff --git a/src/types/newsletter.ts b/src/types/newsletter.ts index 5ec0b48..4f2dc6f 100644 --- a/src/types/newsletter.ts +++ b/src/types/newsletter.ts @@ -44,6 +44,10 @@ export interface ProductItem { showSlovakFlag?: boolean; customBadgePngUrl?: string; // Voliteľná statická PNG cenovka imageAlt?: string; // Ručný alt text fotky (inak názov produktu) + imageScale?: number; // Zoom fotky (1 = 100 %) + imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava) + imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol) + imageAspect?: number; // Pomer strán fotky výška/šírka (pre výpočet pozadia) badgeLayout?: 'below' | 'side'; // Poloha cenovky: 'below' = pod obrázkom (default), 'side' = vedľa obrázka }