feat: viac fotiek v jednej produktovej karte (bundle)

- pri produkte + Pridat dalsiu fotku: obrazok, poloha X/Y, velkost a volba
  pred / za hlavnou fotkou; lubovolny pocet fotiek
- fotky su sucastou obrazka karty pri exporte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 13:12:42 +02:00
1 parent 23f23c8903
commit 533a856303
3 files changed
+72 -3

No files matched your search

+51 -1
View File
@@ -3,7 +3,7 @@
// Editor jedného produktu (bežné aj vernostné produkty) – zbalený po produktoch, len to, čo treba: // 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. // texty, fotka (zoom/posun), cenovka (šablóna, veľkosť, ceny), dostupnosť a ďalšie možnosti.
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { BadgeTemplate, CardProduct, ProductLabel } from '@/types/newsletter'; import { BadgeTemplate, CardProduct, ProductLabel, ProductPhoto } from '@/types/newsletter';
import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react'; import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react';
import { ImageUploadInput } from './ImageUploadInput'; import { ImageUploadInput } from './ImageUploadInput';
import { pickAlt } from '@/lib/altText'; import { pickAlt } from '@/lib/altText';
@@ -105,6 +105,31 @@ const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
</button> </button>
); );
// Ďalšia fotka v karte (bundle): obrázok, poloha, veľkosť, pred/za hlavnou fotkou
const ExtraPhotoEditor: React.FC<{
index: number;
photo: ProductPhoto;
onChange: (patch: Partial<ProductPhoto>) => void;
onRemove: () => void;
}> = ({ index, photo, onChange, onRemove }) => (
<div className="rounded-lg border border-gray-200 bg-white p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-gray-600">Fotka {index + 2}</span>
<button type="button" onClick={onRemove} className="text-gray-300 hover:text-red-600 p-0.5" title="Odstrániť fotku">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<ImageUploadInput label="" value={photo.url} onChange={(url) => onChange({ url })} />
<Slider label="Vodorovne" value={photo.x} display={`${photo.x} %`} min={0} max={100} step={1} onChange={(v) => onChange({ x: v })} />
<Slider label="Zvisle" value={photo.y} display={`${photo.y} %`} min={0} max={100} step={1} onChange={(v) => onChange({ y: v })} />
<Slider label="Veľkosť" value={photo.scale} display={`${Math.round(photo.scale * 100)} %`} min={0.2} max={2} step={0.05} onChange={(v) => onChange({ scale: v })} />
<label className="flex items-center gap-1.5 text-[10px] text-gray-600 cursor-pointer">
<input type="checkbox" checked={!!photo.behind} onChange={(e) => onChange({ behind: e.target.checked })} className="w-3 h-3 accent-red-600" />
Za hlavnou fotkou
</label>
</div>
);
// Nastavenie jedného vlastného štítka na fotke // Nastavenie jedného vlastného štítka na fotke
const LABEL_COLORS: Array<[string, string]> = [ const LABEL_COLORS: Array<[string, string]> = [
['#e2001a', 'Červená'], ['#e2001a', 'Červená'],
@@ -322,6 +347,31 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
<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="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="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')} /> <Slider label="Zvisle" value={offsetY} display={`${offsetY}`} min={-120} max={120} step={1} onChange={set('imageOffsetY')} />
{/* Ďalšie fotky (bundle) */}
{(p.extraPhotos || []).map((f, i) => (
<ExtraPhotoEditor
key={f.id}
index={i}
photo={f}
onChange={(patch) => onChange({ extraPhotos: (p.extraPhotos || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
onRemove={() => onChange({ extraPhotos: (p.extraPhotos || []).filter((_, j) => j !== i) })}
/>
))}
<button
type="button"
onClick={() =>
onChange({
extraPhotos: [
...(p.extraPhotos || []),
{ id: `ph-${Date.now()}`, url: '', x: 62, y: 52, scale: 0.75, behind: false }
]
})
}
className="w-full text-[10px] font-bold text-gray-600 border border-dashed border-gray-300 hover:border-gray-400 hover:bg-white rounded-lg py-1.5"
>
+ Pridať ďalšiu fotku (bundle)
</button>
</Block> </Block>
{/* CENOVKA */} {/* CENOVKA */}
+10 -2
View File
@@ -5,7 +5,7 @@
// V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu // 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 // 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). // v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
import { BadgeTemplate, CardProduct, NewsletterModule, ProductLabel } from '@/types/newsletter'; import { BadgeTemplate, CardProduct, NewsletterModule, ProductLabel, ProductPhoto } from '@/types/newsletter';
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils'; import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign'; import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { pickAlt } from './altText'; import { pickAlt } from './altText';
@@ -208,7 +208,7 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice, p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice,
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice, p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE, p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE,
p.labels || [] p.labels || [], p.extraPhotos || []
]; ];
const str = JSON.stringify(relevant); const str = JSON.stringify(relevant);
let h1 = 5381; let h1 = 5381;
@@ -221,6 +221,12 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
return `cv-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`; return `cv-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
} }
// Ďalšia fotka v karte (bundle): poloha v % vizuálu, veľkosť voči hlavnej fotke
function renderExtraPhoto(f: ProductPhoto, mode: RenderMode): string {
if (!f.url) return '';
return `<img src="${escapeHtml(assetUrl(f.url, mode))}" alt="" style="position: absolute; left: ${f.x}%; top: ${f.y}%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) scale(${f.scale || 1}); border: 0;">`;
}
// Vlastný štítok na fotke (nad fotkou, pod cenovkou) – štýl ako „PODIEL MÄSA 92 %“ z letáku, písmo Aptly Bold (Adobe Fonts) // Vlastný štítok na fotke (nad fotkou, pod cenovkou) – štýl ako „PODIEL MÄSA 92 %“ z letáku, písmo Aptly Bold (Adobe Fonts)
function renderLabel(l: ProductLabel): string { function renderLabel(l: ProductLabel): string {
if (!l.text) return ''; if (!l.text) return '';
@@ -284,7 +290,9 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0; const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
return ` 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;"> <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.extraPhotos || []).filter((f) => f.behind).map((f) => renderExtraPhoto(f, ctx.mode)).join('')}
${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;">` : ''} ${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;">` : ''}
${(p.extraPhotos || []).filter((f) => !f.behind).map((f) => renderExtraPhoto(f, ctx.mode)).join('')}
${(p.labels || []).map(renderLabel).join('')} ${(p.labels || []).map(renderLabel).join('')}
<div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;"> <div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;">
${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''} ${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''}
+11
View File
@@ -51,6 +51,7 @@ export interface ProductItem {
storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie) storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie)
storesSize?: number; // výška jedného štítka predajne v px (nezávislá od veľkosti cenovky) storesSize?: number; // výška jedného štítka predajne v px (nezávislá od veľkosti cenovky)
labels?: ProductLabel[]; // vlastné štítky na fotke (napr. „PODIEL MÄSA 92 %“) labels?: ProductLabel[]; // vlastné štítky na fotke (napr. „PODIEL MÄSA 92 %“)
extraPhotos?: ProductPhoto[]; // ďalšie fotky v karte (bundle) popri hlavnej fotke
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)
@@ -68,6 +69,16 @@ export interface CardProduct extends ProductItem {
noCardUnitPrice?: string; noCardUnitPrice?: string;
} }
// Ďalšia fotka v karte produktu (napr. bundle 2 produktov)
export interface ProductPhoto {
id: string;
url: string;
x: number; // stred fotky, % šírky vizuálu (0–100)
y: number; // stred fotky, % výšky vizuálu (0–100)
scale: number; // 1 = rovnaká veľkosť ako hlavná fotka bez zoomu
behind?: boolean; // za hlavnou fotkou (inak pred ňou)
}
// Vlastný štítok na fotke produktu // Vlastný štítok na fotke produktu
export interface ProductLabel { export interface ProductLabel {
id: string; id: string;