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
+76 -12

No files matched your search

+6 -2
View File
@@ -358,7 +358,7 @@ export default function Home() {
const handleImportProducts = (imported: ProductItem[], options?: { replace?: boolean }) => {
if (!selectedModuleId) return;
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.');
return;
}
@@ -372,7 +372,10 @@ export default function Home() {
const withLayout = imported.map((p) => {
if (!first) return p;
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 };
});
@@ -1015,6 +1018,7 @@ export default function Home() {
onClose={() => setIsImportOpen(false)}
onImportProducts={handleImportProducts}
availableBadges={availableBadges}
variant={campaign.modules.find((m) => m.id === selectedModuleId)?.type === 'loyalty_club' ? 'loyalty' : 'standard'}
/>
{/* 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">
Vernostné produkty ({module.data.products?.length || 0})
</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
onClick={() => {
const newP: CardProduct = {
+24 -9
View File
@@ -7,7 +7,7 @@ import { BadgeTemplate, ProductItem } from '@/types/newsletter';
import { findBadgeByCode, badgeCode } from '@/lib/badgeUtils';
import { Upload, Check, AlertCircle, FileText, X } from 'lucide-react';
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 { shrinkImage, uploadFile } from '@/lib/upload';
@@ -16,14 +16,17 @@ interface ImportModalProps {
onClose: () => void;
onImportProducts: (products: ProductItem[], options?: { replace?: boolean }) => void;
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> = ({
isOpen,
onClose,
onImportProducts,
availableBadges = []
availableBadges = [],
variant = 'standard'
}) => {
const isLoyalty = variant === 'loyalty';
const [file, setFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
const [parsedRows, setParsedRows] = useState<any[]>([]);
@@ -156,6 +159,8 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
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
const applyZipImport = async () => {
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' }));
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) {
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">
<input
type="file"
accept=".csv,.json,.xml,.zip"
accept={isLoyalty ? '.zip' : '.csv,.json,.xml,.zip'}
onChange={handleFileUpload}
className="hidden"
id="file-upload-input"
@@ -272,10 +277,12 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<label htmlFor="file-upload-input" className="cursor-pointer">
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
<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 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>
</label>
</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">
<span
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}
>
{p.thumb ? (
@@ -337,7 +344,14 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
</p>
</div>
<div className="text-right shrink-0">
{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>}
</div>
</div>
@@ -349,8 +363,9 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
Vymazať doterajšie produkty v sekcii (nahradiť novými zo ZIP-u)
</label>
<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
cenovky, štítky predajní) sa prevezme z prvého produktu v sekcii.
{isLoyalty
? '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>
</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)
// a fotky v priečinkoch <EAN>/web/*.png.
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 { CARD_BG_BY_BADGE_COLOR } from './productCard';
@@ -9,6 +9,7 @@ export interface PodkladyProduct {
ean: string;
title: string;
price: number | null; // akciová cena
regularPrice: number | null; // bežná cena
cardPrice: number | null; // cena s vernostnou kartou
appPrice: number | null; // cena pre appkáčov
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'),
qtyUnit: col('Množstvo MJ'),
price: col('Akciová cena (leták)', 'Cena (xlsx podklady)'),
regularPrice: col('Bežná cena'),
cardPrice: col('Cena s vernostnou kartou'),
appPrice: col('Cena pre appkáčov'),
extras: col('Doplnkové grafiky'),
@@ -260,6 +262,7 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
ean,
...splitQuantity(get(row, c.title) || get(row, c.titleAlt) || ean, get(row, c.qty), get(row, c.qtyUnit)),
price: toNumber(get(row, c.price)),
regularPrice: toNumber(get(row, c.regularPrice)),
cardPrice: toNumber(get(row, c.cardPrice)),
appPrice,
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 };
}
// 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)
export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx: number): ProductItem {
const price = splitPrice(p.price ?? 0);