feat: stitky predajni pri hornej hrane cenovky s nastavitelnym posunom

- stitky Potraviny/Supermarket/Tempo su nad cenovkou, zarovnane k jej
  pravemu okraju a posuvaju sa spolu s nou (pod/vedla obrazka, zarovnanie, sirka)
- posuvniky Vodorovne / Zvisle pre celu sekciu (aj vernostne zlavy);
  zaporny zvisly posun prekryje horny okraj cenovky
- spolocny helper na vykreslenie stitkov namiesto dvoch kopii

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-19 16:46:20 +02:00
1 parent 98fdad535d
commit ea2a9d85d0
3 files changed
+129 -27

No files matched your search

+58 -1
View File
@@ -6,7 +6,7 @@ import { Plus, Trash2, Image as ImageIcon, Tag, Store, Globe, Palette, Check, Co
import { SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign'; import { SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
import { ImageUploadInput } from './ImageUploadInput'; import { ImageUploadInput } from './ImageUploadInput';
import { pickAlt } from '@/lib/altText'; import { pickAlt } from '@/lib/altText';
import { renderGeneratedModuleHtml, CUSTOM_HTML_MODULE_TYPES } from '@/lib/emailRenderer'; import { renderGeneratedModuleHtml, CUSTOM_HTML_MODULE_TYPES, DEFAULT_STORES_OFFSET_Y } from '@/lib/emailRenderer';
const PRODUCT_BG_PRESETS = [ const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' }, { label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
@@ -29,6 +29,49 @@ interface ConfigPanelProps {
availableBadges?: BadgeTemplate[]; availableBadges?: BadgeTemplate[];
} }
// Posun štítkov predajní voči hornej hrane cenovky (pre celú sekciu)
const StoresOffsetControl: React.FC<{
x: number;
y: number;
onChange: (patch: { storesOffsetX?: number; storesOffsetY?: number }) => void;
}> = ({ x, y, onChange }) => {
const row = (label: string, value: number, min: number, max: number, key: 'storesOffsetX' | 'storesOffsetY', hint: string) => (
<div className="grid grid-cols-[72px_1fr_40px] items-center gap-2">
<span className="text-[10px] text-gray-600 font-semibold" title={hint}>{label}</span>
<input
type="range"
min={min}
max={max}
step={1}
value={value}
onChange={(e) => onChange({ [key]: 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">{value} px</span>
</div>
);
const isChanged = x !== 0 || y !== DEFAULT_STORES_OFFSET_Y;
return (
<div className="p-2.5 rounded-xl border border-gray-200 bg-gray-50 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-gray-700 uppercase tracking-wider">Štítky predajní pri cenovke</span>
{isChanged && (
<button
type="button"
onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined })}
className="text-[10px] font-bold text-gray-500 hover:text-red-600"
>
Obnoviť
</button>
)}
</div>
{row('Vodorovne', x, -80, 80, 'storesOffsetX', '+ doprava, − doľava')}
{row('Zvisle', y, -40, 40, 'storesOffsetY', '+ vyššie nad cenovku, − nižšie cez horný okraj cenovky')}
<p className="text-[10px] text-gray-500">Štítky sú pri hornej hrane cenovky a posúvajú sa spolu s ňou. Platí pre celú sekciu.</p>
</div>
);
};
// Pomer strán obrázka (výška / šírka); pri chybe načítania 1 // Pomer strán obrázka (výška / šírka); pri chybe načítania 1
const loadImageAspect = (url: string): Promise<number> => const loadImageAspect = (url: string): Promise<number> =>
new Promise((resolve) => { new Promise((resolve) => {
@@ -569,6 +612,13 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
</label> </label>
</div> </div>
{/* Štítky predajní (Potraviny / Supermarket / Tempo) nad hornou hranou cenovky */}
<StoresOffsetControl
x={module.data.storesOffsetX || 0}
y={module.data.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y}
onChange={(patch) => updateData(patch)}
/>
{/* Rozloženie produktov (Počet produktov na riadok) */} {/* Rozloženie produktov (Počet produktov na riadok) */}
<div> <div>
<label className="block text-xs font-semibold text-gray-700 mb-1"> <label className="block text-xs font-semibold text-gray-700 mb-1">
@@ -1283,6 +1333,13 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
</label> </label>
</div> </div>
{/* Štítky predajní (Potraviny / Supermarket / Tempo) nad hornou hranou cenovky */}
<StoresOffsetControl
x={module.data.storesOffsetX || 0}
y={module.data.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y}
onChange={(patch) => updateData(patch)}
/>
{/* Farba pozadia vernostných kariet */} {/* Farba pozadia vernostných kariet */}
<div className="bg-gray-50 p-3.5 rounded-xl border border-gray-200 space-y-2.5"> <div className="bg-gray-50 p-3.5 rounded-xl border border-gray-200 space-y-2.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
+69 -26
View File
@@ -347,6 +347,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
// Platnosť na kartách je predvolene skrytá (zobrazí sa v Lište platnosti akcie); // Platnosť na kartách je predvolene skrytá (zobrazí sa v Lište platnosti akcie);
// produkty s vlastnou platnosťou (validityOverride) ju zobrazia vždy // produkty s vlastnou platnosťou (validityOverride) ju zobrazia vždy
const globalValidity = m.data.showCardValidity === true ? (m.data.globalValidity || '') : ''; 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 products: ProductItem[] = m.data.products || [];
const cardBg = m.data.cardBgColor || '#feeaea'; const cardBg = m.data.cardBgColor || '#feeaea';
const cardBorder = m.data.cardBorderColor || '#f5d4d4'; const cardBorder = m.data.cardBorderColor || '#f5d4d4';
@@ -369,7 +370,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
<tr> <tr>
<td width="100%" valign="top"> <td width="100%" valign="top">
${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle, 600)} ${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle, 600, storesOffset)}
</td> </td>
</tr> </tr>
</table>`; </table>`;
@@ -380,7 +381,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
<tr> <tr>
${row.map((p, idx) => ` ${row.map((p, idx) => `
<td width="50%" valign="top" height="100%" style="padding-${idx === 0 ? 'right: 6px' : 'left: 6px'}; padding-bottom: 0; height: 100%;"> <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)} ${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle, 294, storesOffset)}
</td>`).join('\n')} </td>`).join('\n')}
${row.length === 1 ? '<td width="50%" valign="top" height="100%" style="height: 100%;"></td>' : ''} ${row.length === 1 ? '<td width="50%" valign="top" height="100%" style="height: 100%;"></td>' : ''}
</tr> </tr>
@@ -438,7 +439,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
<tr> <tr>
${lProducts.map((p: any, idx: number) => ` ${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%;"> <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') : '')} ${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')} </td>`).join('\n')}
</tr> </tr>
</table> </table>
@@ -712,6 +713,56 @@ function renderStackedImageRow(p: ProductItem, imgHtml: string, cellStyle: strin
<!--<![endif]-->`; <!--<![endif]-->`;
} }
// Štítky dostupnosti v predajniach (Potraviny / Supermarket / Tempo); prázdne, ak nie je zvolená žiadna
function renderStoresHtml(p: any): 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( function renderExactProductCard(
p: ProductItem, p: ProductItem,
globalValidity: string, globalValidity: string,
@@ -723,22 +774,22 @@ function renderExactProductCard(
badgePrcColor: string, badgePrcColor: string,
badgeCustomTxt: string | null, badgeCustomTxt: string | null,
sectionTitle?: string, sectionTitle?: string,
cardWidth: number = 294 // nominálna šírka karty v px (2 stĺpce = 294, 1 stĺpec = 600) cardWidth: number = 294, // nominálna šírka karty v px (2 stĺpce = 294, 1 stĺpec = 600)
storesOffset: StoresOffset = {}
): string { ): string {
const validity = p.validityOverride || globalValidity; const validity = p.validityOverride || globalValidity;
const effectiveCardBg = p.cardBgColor || cardBg || '#feeaea'; const effectiveCardBg = p.cardBgColor || cardBg || '#feeaea';
const storesHtml = ` const storesHtml = renderStoresHtml(p);
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right">
${p.stores?.potraviny ? `<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: #e2001a; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_potraviny.png" height="10" alt="Potraviny" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
${p.stores?.supermarket ? `<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: #f9890e; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_supermarket.png" height="10" alt="Supermarket" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
${p.stores?.tempo ? `<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: #005239; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_tempo.png" height="10" alt="Tempo" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
</table>`;
const badgeLayout = p.badgeLayout || 'below'; 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, storesOffset);
// Pomocná funkcia na render samotnej cenovky (bez obalového <td>) // Pomocná funkcia na render samotnej cenovky (bez obalového <td>)
const renderBadgeContent = (bWidth: number, bAlign: string, padTop: number, padBottom: number, marginStyle: string) => { 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 // Vlastná PNG cenovka (výnimka) má prednosť pred šablónou; po jej odstránení sa vráti šablóna
if (p.customBadgePngUrl) { if (p.customBadgePngUrl) {
return ` return `
@@ -804,7 +855,6 @@ function renderExactProductCard(
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td> <td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
<td align="right">${storesHtml}</td>
</tr> </tr>
</table> </table>
</td> </td>
@@ -855,7 +905,6 @@ function renderExactProductCard(
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td> <td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
<td align="right">${storesHtml}</td>
</tr> </tr>
</table> </table>
</td> </td>
@@ -898,14 +947,9 @@ function renderExactProductCard(
</table>`; </table>`;
} }
function renderExactLoyaltyCard(p: any, validity: string): string { function renderExactLoyaltyCard(p: any, validity: string, storesOffset: StoresOffset = {}): string {
const cardValidity = p.validityOverride || validity; const cardValidity = p.validityOverride || validity;
const storesHtml = ` const storesHtml = renderStoresHtml(p);
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right">
${p.stores?.potraviny ? `<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: #e2001a; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_potraviny.png" height="10" alt="Potraviny" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
${p.stores?.supermarket ? `<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: #f9890e; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_supermarket.png" height="10" alt="Supermarket" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
${p.stores?.tempo ? `<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: #005239; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_tempo.png" height="10" alt="Tempo" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
</table>`;
const loyaltyBg = p.cardBgColor || '#e6e1d9'; const loyaltyBg = p.cardBgColor || '#e6e1d9';
return ` return `
@@ -915,7 +959,6 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(cardValidity)}</td> <td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(cardValidity)}</td>
<td align="right">${storesHtml}</td>
</tr> </tr>
</table> </table>
</td> </td>
@@ -967,8 +1010,8 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
cardDiscount: p.cardDiscount cardDiscount: p.cardDiscount
}; };
return ` return attachStoresToBadge(`
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-bottom: ${padBottom}px;">
<tr> <tr>
<td align="${bAlign}"> <td align="${bAlign}">
${p.customBadgePngUrl ${p.customBadgePngUrl
@@ -976,11 +1019,11 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
: renderCleanBadgeSvg(p.badgeCustomTemplate, loyaltyProductData, bWidth)} : renderCleanBadgeSvg(p.badgeCustomTemplate, loyaltyProductData, bWidth)}
</td> </td>
</tr> </tr>
</table>`; </table>`, storesHtml, bWidth, bAlign, marginStyle, padTop, storesOffset);
} }
// 2. Predvolený Dual Sticker Block (Bez karty / S kartou) 1:1 // 2. Predvolený Dual Sticker Block (Bez karty / S kartou) 1:1
return ` return attachStoresToBadge(`
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto; width: 140px;"> <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto; width: 140px;">
<tr> <tr>
<td colspan="2"> <td colspan="2">
@@ -1033,7 +1076,7 @@ function renderExactLoyaltyCard(p: any, validity: string): string {
<div style="font-size: 9px; color: #e2001a; font-weight: 800; font-family: Arial, sans-serif;">${escapeHtml(p.cardUnitPrice)}</div> <div style="font-size: 9px; color: #e2001a; font-weight: 800; font-family: Arial, sans-serif;">${escapeHtml(p.cardUnitPrice)}</div>
</td> </td>
</tr> </tr>
</table>`; </table>`, storesHtml, 140, 'center', 'margin: 0 auto;', 0, storesOffset);
})()} })()}
</td> </td>
</tr> </tr>
+2
View File
@@ -112,6 +112,8 @@ export interface ProductsGridModuleData {
cardBorderColor?: string; cardBorderColor?: string;
columns?: 1 | 2; columns?: 1 | 2;
showCardValidity?: boolean; // platnosť na kartách produktov (predvolene skrytá) showCardValidity?: boolean; // platnosť na kartách produktov (predvolene skrytá)
storesOffsetX?: number; // posun štítkov predajní voči cenovke (+ doprava)
storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie)
} }
export interface LoyaltyClubModuleData { export interface LoyaltyClubModuleData {