feat: import ZIP s podkladmi aj do sekcie Vernostne zlavy (bez zmeny farby pozadia)

S kartou = Cena s vernostnou kartou (inak akciova cena), bez karty = akciova
(inak bezna) cena; jednotkova cena bez karty sa prepocita.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 16:36:01 +02:00
1 parent 8660f92b5a
commit 5cbe981430
4 files changed
+77 -13

No files matched your search

+6 -2
View File
@@ -358,7 +358,7 @@ export default function Home() {
const handleImportProducts = (imported: ProductItem[], options?: { replace?: boolean }) => { const handleImportProducts = (imported: ProductItem[], options?: { replace?: boolean }) => {
if (!selectedModuleId) return; if (!selectedModuleId) return;
const target = campaign.modules.find((m) => m.id === selectedModuleId); const target = campaign.modules.find((m) => m.id === selectedModuleId);
if (!target || target.type !== 'products_grid') { if (!target || (target.type !== 'products_grid' && target.type !== 'loyalty_club')) {
alert('Pre import prosím najskôr označte produktovú sekciu.'); alert('Pre import prosím najskôr označte produktovú sekciu.');
return; return;
} }
@@ -372,7 +372,10 @@ export default function Home() {
const withLayout = imported.map((p) => { const withLayout = imported.map((p) => {
if (!first) return p; if (!first) return p;
const inherited: Partial<ProductItem> = {}; const inherited: Partial<ProductItem> = {};
for (const k of layoutKeys) if (p[k] === undefined && first[k] !== undefined) Object.assign(inherited, { [k]: first[k] }); for (const k of layoutKeys) {
if (k === 'cardBgColor' && target.type === 'loyalty_club') continue; // vernostné zľavy: farba pozadia ostáva
if (p[k] === undefined && first[k] !== undefined) Object.assign(inherited, { [k]: first[k] });
}
return { ...inherited, ...p }; return { ...inherited, ...p };
}); });
@@ -1015,6 +1018,7 @@ export default function Home() {
onClose={() => setIsImportOpen(false)} onClose={() => setIsImportOpen(false)}
onImportProducts={handleImportProducts} onImportProducts={handleImportProducts}
availableBadges={availableBadges} availableBadges={availableBadges}
variant={campaign.modules.find((m) => m.id === selectedModuleId)?.type === 'loyalty_club' ? 'loyalty' : 'standard'}
/> />
{/* Modál Editora cenoviek (SVG -> PNG) */} {/* Modál Editora cenoviek (SVG -> PNG) */}
+8
View File
@@ -876,6 +876,14 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
<span className="text-xs font-bold text-gray-700"> <span className="text-xs font-bold text-gray-700">
Vernostné produkty ({module.data.products?.length || 0}) Vernostné produkty ({module.data.products?.length || 0})
</span> </span>
{onOpenImport && (
<button
onClick={onOpenImport}
className="ml-auto mr-1.5 text-xs font-bold text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-2.5 py-1 rounded-lg transition-colors"
>
Import ZIP
</button>
)}
<button <button
onClick={() => { onClick={() => {
const newP: CardProduct = { const newP: CardProduct = {
+25 -10
View File
@@ -7,7 +7,7 @@ import { BadgeTemplate, ProductItem } from '@/types/newsletter';
import { findBadgeByCode, badgeCode } from '@/lib/badgeUtils'; import { findBadgeByCode, badgeCode } from '@/lib/badgeUtils';
import { Upload, Check, AlertCircle, FileText, X } from 'lucide-react'; import { Upload, Check, AlertCircle, FileText, X } from 'lucide-react';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { PodkladyProduct, podkladyToProductItem, readPodkladyZip, splitPrice } from '@/lib/podkladyZip'; import { PodkladyProduct, podkladyToLoyaltyItem, podkladyToProductItem, readPodkladyZip, splitPrice } from '@/lib/podkladyZip';
import { CARD_BG_BY_BADGE_COLOR } from '@/lib/productCard'; import { CARD_BG_BY_BADGE_COLOR } from '@/lib/productCard';
import { shrinkImage, uploadFile } from '@/lib/upload'; import { shrinkImage, uploadFile } from '@/lib/upload';
@@ -16,14 +16,17 @@ interface ImportModalProps {
onClose: () => void; onClose: () => void;
onImportProducts: (products: ProductItem[], options?: { replace?: boolean }) => void; onImportProducts: (products: ProductItem[], options?: { replace?: boolean }) => void;
availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť
variant?: 'standard' | 'loyalty'; // vernostné zľavy: len ZIP s podkladmi, ceny s kartou / bez karty
} }
export const ProductImportModal: React.FC<ImportModalProps> = ({ export const ProductImportModal: React.FC<ImportModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onImportProducts, onImportProducts,
availableBadges = [] availableBadges = [],
variant = 'standard'
}) => { }) => {
const isLoyalty = variant === 'loyalty';
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv'); const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
const [parsedRows, setParsedRows] = useState<any[]>([]); const [parsedRows, setParsedRows] = useState<any[]>([]);
@@ -156,6 +159,8 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
setMapping(newMapping); setMapping(newMapping);
}; };
const eur = (n: number | null) => (n != null ? `${n.toFixed(2).replace('.', ',')} €` : '—');
// Import zo ZIP-u: fotky sa zmenšia (max 1000 px) a nahrajú, potom sa produkty pridajú do sekcie // Import zo ZIP-u: fotky sa zmenšia (max 1000 px) a nahrajú, potom sa produkty pridajú do sekcie
const applyZipImport = async () => { const applyZipImport = async () => {
if (!zip) return; if (!zip) return;
@@ -169,7 +174,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
const small = await shrinkImage(new File([p.photo], p.photoName || `${p.ean}.png`, { type: p.photo.type || 'image/png' })); const small = await shrinkImage(new File([p.photo], p.photoName || `${p.ean}.png`, { type: p.photo.type || 'image/png' }));
imageUrl = await uploadFile(small, small.name); imageUrl = await uploadFile(small, small.name);
} }
items.push(podkladyToProductItem(p, imageUrl, i)); items.push(isLoyalty ? podkladyToLoyaltyItem(p, imageUrl, i) : podkladyToProductItem(p, imageUrl, i));
} }
} catch (err) { } catch (err) {
alert(`Import sa nepodaril: ${(err as Error).message}`); alert(`Import sa nepodaril: ${(err as Error).message}`);
@@ -264,7 +269,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<div className="border-2 border-dashed border-gray-300 hover:border-red-500 rounded-xl p-6 text-center cursor-pointer transition-colors bg-gray-50/50"> <div className="border-2 border-dashed border-gray-300 hover:border-red-500 rounded-xl p-6 text-center cursor-pointer transition-colors bg-gray-50/50">
<input <input
type="file" type="file"
accept=".csv,.json,.xml,.zip" accept={isLoyalty ? '.zip' : '.csv,.json,.xml,.zip'}
onChange={handleFileUpload} onChange={handleFileUpload}
className="hidden" className="hidden"
id="file-upload-input" id="file-upload-input"
@@ -272,10 +277,12 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<label htmlFor="file-upload-input" className="cursor-pointer"> <label htmlFor="file-upload-input" className="cursor-pointer">
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" /> <FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
<p className="font-semibold text-gray-700"> <p className="font-semibold text-gray-700">
{file ? file.name : 'Kliknite sem a vyberte ZIP s podkladmi, CSV, JSON alebo XML'} {file ? file.name : isLoyalty ? 'Kliknite sem a vyberte ZIP s podkladmi' : 'Kliknite sem a vyberte ZIP s podkladmi, CSV, JSON alebo XML'}
</p> </p>
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-gray-400 mt-1">
Podporované formáty: .zip (podklady z Databázy produktov – tabuľka aj fotky), .csv, .json, .xml {isLoyalty
? 'Vernostné zľavy: .zip (podklady z Databázy produktov – tabuľka aj fotky)'
: 'Podporované formáty: .zip (podklady z Databázy produktov – tabuľka aj fotky), .csv, .json, .xml'}
</p> </p>
</label> </label>
</div> </div>
@@ -295,7 +302,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<div key={p.ean} className="flex items-center gap-3 px-3 py-2 bg-white"> <div key={p.ean} className="flex items-center gap-3 px-3 py-2 bg-white">
<span <span
className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shrink-0" className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shrink-0"
style={p.color ? { backgroundColor: CARD_BG_BY_BADGE_COLOR[p.color] } : undefined} style={p.color && !isLoyalty ? { backgroundColor: CARD_BG_BY_BADGE_COLOR[p.color] } : undefined}
title={p.color ? 'Pozadie karty podľa farby cenovky' : undefined} title={p.color ? 'Pozadie karty podľa farby cenovky' : undefined}
> >
{p.thumb ? ( {p.thumb ? (
@@ -337,7 +344,14 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
</p> </p>
</div> </div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<p className="text-sm font-black text-red-600">{price ? `${price.main},${price.sub} €` : '—'}</p> {isLoyalty ? (
<>
<p className="text-sm font-black text-red-600">{eur(p.cardPrice ?? p.price)} <span className="text-[10px] font-semibold">s kartou</span></p>
<p className="text-[11px] font-bold text-gray-500">{eur(p.cardPrice != null ? p.price : p.regularPrice)} bez karty</p>
</>
) : (
<p className="text-sm font-black text-red-600">{price ? `${price.main},${price.sub} €` : '—'}</p>
)}
{p.discount && <p className="text-[11px] font-bold text-gray-600">-{p.discount}</p>} {p.discount && <p className="text-[11px] font-bold text-gray-600">-{p.discount}</p>}
</div> </div>
</div> </div>
@@ -349,8 +363,9 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
Vymazať doterajšie produkty v sekcii (nahradiť novými zo ZIP-u) Vymazať doterajšie produkty v sekcii (nahradiť novými zo ZIP-u)
</label> </label>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Fotky sa pri importe zmenšia na najviac 1000 px, pozadie karty sa nastaví podľa farby cenovky. Rozloženie karty (veľkosť a poloha {isLoyalty
cenovky, štítky predajní) sa prevezme z prvého produktu v sekcii. ? 'Fotky sa pri importe zmenšia na najviac 1000 px. Cena s kartou = Cena s vernostnou kartou (inak akciová cena), bez karty = akciová (inak bežná) cena. Farba pozadia sa nemení, rozloženie karty sa prevezme z prvého produktu v sekcii.'
: 'Fotky sa pri importe zmenšia na najviac 1000 px, pozadie karty sa nastaví podľa farby cenovky. Rozloženie karty (veľkosť a poloha cenovky, štítky predajní) sa prevezme z prvého produktu v sekcii.'}
</p> </p>
</div> </div>
)} )}
+38 -1
View File
@@ -1,7 +1,7 @@
// Import ZIP-u s podkladmi z Databázy produktov: tabuľka podklady_*.xlsx (jeden riadok = jeden produkt) // Import ZIP-u s podkladmi z Databázy produktov: tabuľka podklady_*.xlsx (jeden riadok = jeden produkt)
// a fotky v priečinkoch <EAN>/web/*.png. // a fotky v priečinkoch <EAN>/web/*.png.
import JSZip from 'jszip'; import JSZip from 'jszip';
import { BADGE_IMPORT_TYPES, BadgeColor, BadgeTemplate, ProductItem, ProductLabel } from '@/types/newsletter'; import { BADGE_IMPORT_TYPES, BadgeColor, BadgeTemplate, CardProduct, ProductItem, ProductLabel } from '@/types/newsletter';
import { findBadgeByCode } from './badgeUtils'; import { findBadgeByCode } from './badgeUtils';
import { CARD_BG_BY_BADGE_COLOR } from './productCard'; import { CARD_BG_BY_BADGE_COLOR } from './productCard';
@@ -9,6 +9,7 @@ export interface PodkladyProduct {
ean: string; ean: string;
title: string; title: string;
price: number | null; // akciová cena price: number | null; // akciová cena
regularPrice: number | null; // bežná cena
cardPrice: number | null; // cena s vernostnou kartou cardPrice: number | null; // cena s vernostnou kartou
appPrice: number | null; // cena pre appkáčov appPrice: number | null; // cena pre appkáčov
unitPrice: string; // napr. "jednotková cena 5,39 EUR/kg" unitPrice: string; // napr. "jednotková cena 5,39 EUR/kg"
@@ -215,6 +216,7 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
qty: col('Množstvo'), qty: col('Množstvo'),
qtyUnit: col('Množstvo MJ'), qtyUnit: col('Množstvo MJ'),
price: col('Akciová cena (leták)', 'Cena (xlsx podklady)'), price: col('Akciová cena (leták)', 'Cena (xlsx podklady)'),
regularPrice: col('Bežná cena'),
cardPrice: col('Cena s vernostnou kartou'), cardPrice: col('Cena s vernostnou kartou'),
appPrice: col('Cena pre appkáčov'), appPrice: col('Cena pre appkáčov'),
extras: col('Doplnkové grafiky'), extras: col('Doplnkové grafiky'),
@@ -260,6 +262,7 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
ean, ean,
...splitQuantity(get(row, c.title) || get(row, c.titleAlt) || ean, get(row, c.qty), get(row, c.qtyUnit)), ...splitQuantity(get(row, c.title) || get(row, c.titleAlt) || ean, get(row, c.qty), get(row, c.qtyUnit)),
price: toNumber(get(row, c.price)), price: toNumber(get(row, c.price)),
regularPrice: toNumber(get(row, c.regularPrice)),
cardPrice: toNumber(get(row, c.cardPrice)), cardPrice: toNumber(get(row, c.cardPrice)),
appPrice, appPrice,
unitPrice: formatUnitPrice(get(row, c.unitPrice), get(row, c.qtyUnit)), unitPrice: formatUnitPrice(get(row, c.unitPrice), get(row, c.qtyUnit)),
@@ -285,6 +288,40 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
return { products, info }; return { products, info };
} }
// Jednotková cena prepočítaná na inú cenu (napr. bez karty): "jednotková cena 5,39 EUR/kg" × 6,49 / 5,39
function scaleUnitPrice(unitPrice: string, from: number, to: number): string {
const m = unitPrice.match(/^(jednotková cena )([\d.,]+)( EUR.*)$/);
const n = m ? toNumber(m[2]) : null;
return m && n != null && from > 0 ? `${m[1]}${formatEur((n * to) / from)}${m[3]}` : '';
}
// Vernostný produkt (sekcia Vernostné zľavy): s kartou = Cena s vernostnou kartou, inak akciová cena z letáku;
// bez karty = akciová cena (ak je zvlášť cena s kartou), inak bežná cena. Farba pozadia sa nemení.
export function podkladyToLoyaltyItem(p: PodkladyProduct, imageUrl: string, idx: number): CardProduct {
const base = podkladyToProductItem(p, imageUrl, idx);
delete base.cardBgColor;
const card = p.cardPrice ?? p.price;
const noCard = p.cardPrice != null ? p.price : p.regularPrice;
const c = card != null ? splitPrice(card) : null;
const n = noCard != null ? splitPrice(noCard) : null;
return {
...base,
id: `zip-lp-${Date.now()}-${idx}`,
priceMain: '',
priceSub: '',
unitPrice: '',
discountText: '',
cardPriceMain: c?.main || '',
cardPriceSub: c?.sub || '',
cardDiscount: p.discount,
cardUnitPrice: p.unitPrice,
noCardPriceMain: n?.main || '',
noCardPriceSub: n?.sub || '',
noCardDiscount: '',
noCardUnitPrice: card != null && noCard != null ? scaleUnitPrice(p.unitPrice, card, noCard) : ''
};
}
// Produkt pre kampaň (fotka už nahratá – imageUrl) // Produkt pre kampaň (fotka už nahratá – imageUrl)
export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx: number): ProductItem { export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx: number): ProductItem {
const price = splitPrice(p.price ?? 0); const price = splitPrice(p.price ?? 0);