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

File diff suppressed because it is too large. Load diff
+410
View File
@@ -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>
);
};