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:
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
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user