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
+77
-13
No files matched your search
+6
-2
@@ -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) */}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user