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:
1 parent
8660f92b5a
commit
5cbe981430
4 files changed
+76
-12
No files matched your search
+6
-2
@@ -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) */}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user