feat: hromadny import produktov zo ZIP-u s podkladmi (xlsx + fotky)

Tabulka z Databazy produktov sa precita bez mapovania stlpcov, fotky
<EAN>/web/*.png sa zmensia na 1000 px a nahraju. Cenovka sa hlada podla
Zlava graficka | Farba cenovky | Povod. Importovane produkty preberu
rozlozenie karty z prveho produktu v sekcii.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 13:57:16 +02:00
1 parent 48fd82a77d
commit 9b1f7b60cf
3 files changed
+303 -6

No files matched your search

+14 -1
View File
@@ -357,11 +357,24 @@ export default function Home() {
return;
}
// Rozloženie karty (cenovka, štítky predajní, farba) prevezmú nové produkty z prvého produktu v sekcii
const first: ProductItem | undefined = target.data.products?.[0];
const layoutKeys = [
'badgeWidth', 'badgeAlign', 'badgeLayout', 'badgePaddingTop', 'badgePaddingBottom',
'storesOffsetX', 'storesOffsetY', 'storesSize', 'cardBgColor'
] as const;
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] });
return { ...inherited, ...p };
});
const updated = {
...target,
data: {
...target.data,
products: [...(target.data.products || []), ...imported]
products: [...(target.data.products || []), ...withLayout]
}
};
handleUpdateModule(updated);
+112 -5
View File
@@ -7,6 +7,8 @@ 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 { shrinkImage, uploadFile } from '@/lib/upload';
interface ImportModalProps {
isOpen: boolean;
@@ -50,6 +52,10 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
const [presetName, setPresetName] = useState('');
const [savedPresets, setSavedPresets] = useState<any[]>([]);
// ZIP s podkladmi z Databázy produktov (tabuľka + fotky) – pevný formát, bez mapovania stĺpcov
const [zip, setZip] = useState<{ products: (PodkladyProduct & { thumb: string })[]; info: string } | null>(null);
const [zipProgress, setZipProgress] = useState<string | null>(null);
if (!isOpen) return null;
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -59,6 +65,19 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
const ext = uploaded.name.split('.').pop()?.toLowerCase();
const reader = new FileReader();
setZip(null);
if (ext === 'zip') {
setColumns([]);
setParsedRows([]);
readPodkladyZip(uploaded, availableBadges)
.then(({ products, info }) => {
if (!products.length) throw new Error('V tabuľke sa nenašiel žiadny produkt.');
setZip({ products: products.map((p) => ({ ...p, thumb: p.photo ? URL.createObjectURL(p.photo) : '' })), info });
})
.catch((err: Error) => alert(`ZIP sa nepodarilo načítať: ${err.message}`));
return;
}
if (ext === 'csv') {
setFileType('csv');
@@ -135,7 +154,39 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
setMapping(newMapping);
};
// 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;
const items: ProductItem[] = [];
try {
for (let i = 0; i < zip.products.length; i++) {
const p = zip.products[i];
setZipProgress(`Nahrávam fotky ${i + 1}/${zip.products.length}…`);
let imageUrl = '';
if (p.photo) {
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));
}
} catch (err) {
alert(`Import sa nepodaril: ${(err as Error).message}`);
setZipProgress(null);
return;
}
setZipProgress(null);
zip.products.forEach((p) => p.thumb && URL.revokeObjectURL(p.thumb));
setZip(null);
setFile(null);
onImportProducts(items);
onClose();
};
const applyImport = () => {
if (zip) {
applyZipImport();
return;
}
if (!mapping.title) {
alert('Prosím vyberte stĺpec pre Názov produktu.');
return;
@@ -211,7 +262,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"
accept=".csv,.json,.xml,.zip"
onChange={handleFileUpload}
className="hidden"
id="file-upload-input"
@@ -219,12 +270,68 @@ 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 alebo pretiahnite CSV, JSON alebo XML'}
{file ? file.name : '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
</p>
<p className="text-xs text-gray-400 mt-1">Podporované formáty: .csv, .json, .xml</p>
</label>
</div>
{/* ZIP s podkladmi: náhľad produktov */}
{zip && (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-bold text-gray-800 uppercase tracking-wider">Produkty v ZIP-e ({zip.products.length})</h3>
{zip.info && <span className="text-[11px] text-gray-500 truncate">{zip.info}</span>}
</div>
<div className="border border-gray-200 rounded-xl divide-y divide-gray-100 overflow-hidden">
{zip.products.map((p) => {
const price = p.price != null ? splitPrice(p.price) : null;
const stores = [p.stores.potraviny && 'P', p.stores.supermarket && 'S', p.stores.tempo && 'T'].filter(Boolean).join(' ');
return (
<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">
{p.thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={p.thumb} alt="" className="w-full h-full object-contain" />
) : (
<span className="text-[9px] text-red-600 font-bold text-center leading-tight">bez fotky</span>
)}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-gray-900 truncate">{p.title}</p>
<p className="text-[11px] text-gray-500 truncate">
EAN {p.ean}
{p.unitPrice && ` · ${p.unitPrice}`}
{p.deposit && ` · záloha ${p.deposit} €`}
{stores && ` · ${stores}`}
</p>
<p className="text-[11px] truncate">
{p.badge ? (
<span className="text-emerald-700">cenovka: {p.badge.name}</span>
) : (
<span className="text-amber-700">
{p.badgeLabel ? `cenovka „${p.badgeLabel}“ nie je medzi šablónami – použije sa predvolená` : 'bez cenovky – použije sa predvolená'}
</span>
)}
</p>
</div>
<div className="text-right shrink-0">
<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>
);
})}
</div>
<p className="text-[11px] text-gray-500">
Fotky sa pri importe zmenšia na najviac 1000 px. Rozloženie karty (veľkosť a poloha cenovky, štítky predajní) sa prevezme z prvého
produktu v sekcii.
</p>
</div>
)}
{/* Mapping Table */}
{columns.length > 0 && (
<div className="space-y-4">
@@ -363,11 +470,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
</button>
<button
onClick={applyImport}
disabled={parsedRows.length === 0}
disabled={(parsedRows.length === 0 && !zip) || !!zipProgress}
className="px-5 py-2 text-sm font-bold bg-red-600 text-white rounded-xl hover:bg-red-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Check className="w-4 h-4" />
Importovať do kampane
{zipProgress || (zip ? `Importovať ${zip.products.length} produktov` : 'Importovať do kampane')}
</button>
</div>
</div>
+177
View File
@@ -0,0 +1,177 @@
// 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 { BadgeTemplate, ProductItem } from '@/types/newsletter';
import { findBadgeByCode } from './badgeUtils';
export interface PodkladyProduct {
ean: string;
title: string;
price: number | null; // akciová cena
cardPrice: number | null; // cena s vernostnou kartou
unitPrice: string; // napr. "1kg = 5,39 €"
discount: string; // napr. "30%"
badgeLabel: string; // napr. "SUPER CENA | červená | SK" – z čoho sa hľadá šablóna cenovky
badge?: BadgeTemplate;
stores: ProductItem['stores'];
deposit: string; // napr. "0,15"
photo?: Blob;
photoName: string;
}
const norm = (s: string) =>
s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
// ---------- jednoduché čítanie .xlsx (prvý hárok) ----------
function colIndex(ref: string): number {
const letters = ref.replace(/\d+/g, '');
let n = 0;
for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64);
return n - 1;
}
async function readXlsx(data: ArrayBuffer): Promise<string[][]> {
const xlsx = await JSZip.loadAsync(data);
const parse = async (path: string) => {
const text = await xlsx.file(path)?.async('string');
return text ? new DOMParser().parseFromString(text, 'application/xml') : null;
};
const shared: string[] = [];
const sst = await parse('xl/sharedStrings.xml');
sst?.querySelectorAll('si').forEach((si) => {
shared.push([...si.getElementsByTagName('t')].map((t) => t.textContent || '').join(''));
});
const sheetPath =
xlsx.file('xl/worksheets/sheet1.xml') ? 'xl/worksheets/sheet1.xml' : Object.keys(xlsx.files).find((p) => /^xl\/worksheets\/[^/]+\.xml$/.test(p));
const sheet = sheetPath ? await parse(sheetPath) : null;
if (!sheet) throw new Error('V tabuľke sa nenašiel hárok.');
const rows: string[][] = [];
sheet.querySelectorAll('sheetData > row').forEach((row) => {
const cells: string[] = [];
row.querySelectorAll('c').forEach((c) => {
const type = c.getAttribute('t');
const v = c.getElementsByTagName('v')[0]?.textContent ?? '';
let value = v;
if (type === 's') value = shared[Number(v)] ?? '';
else if (type === 'inlineStr') value = [...c.getElementsByTagName('t')].map((t) => t.textContent || '').join('');
cells[colIndex(c.getAttribute('r') || 'A')] = value;
});
rows.push(Array.from(cells, (x) => x ?? ''));
});
return rows;
}
// ---------- prevody hodnôt ----------
function toNumber(value: string): number | null {
const n = parseFloat(String(value || '').replace(/\s/g, '').replace(',', '.'));
return Number.isFinite(n) ? n : null;
}
const formatEur = (n: number) => n.toFixed(2).replace('.', ',');
// "5,39 EUR/kg" → "1kg = 5,39 €" (formát ako v ostatných kartách)
function formatUnitPrice(value: string): string {
const m = value.trim().match(/^([\d.,]+)\s*(?:EUR|€)\s*\/\s*(\S+)$/i);
return m ? `1${m[2]} = ${m[1]} €` : value.trim();
}
export function splitPrice(n: number): { main: string; sub: string } {
const [main, sub] = n.toFixed(2).split('.');
return { main, sub };
}
// ---------- načítanie ZIP-u ----------
export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Promise<{ products: PodkladyProduct[]; info: string }> {
const zip = await JSZip.loadAsync(file);
const files = Object.values(zip.files).filter((f) => !f.dir && !f.name.startsWith('__MACOSX/'));
const table = files.find((f) => /\.xlsx$/i.test(f.name));
if (!table) throw new Error('V ZIP-e chýba tabuľka .xlsx s podkladmi.');
const info = (await files.find((f) => /_info\.txt$/i.test(f.name))?.async('string'))?.trim() || '';
const [header = [], ...rows] = await readXlsx(await table.async('arraybuffer'));
const col = (...names: string[]) => header.findIndex((h) => names.some((n) => norm(h) === norm(n)));
const c = {
ean: col('EAN'),
title: col('Názov v letáku'),
titleAlt: col('Názov produktu'),
price: col('Akciová cena (leták)', 'Cena (xlsx podklady)'),
cardPrice: col('Cena s vernostnou kartou'),
unitPrice: col('Jednotková cena'),
discount: col('Zľava %'),
badge: col('Zľava grafická'),
color: col('Farba cenovky'),
origin: col('Pôvod'),
formats: col('Formát'),
deposit: col('Zálohovanie')
};
if (c.ean < 0) throw new Error('Tabuľka nemá stĺpec EAN – nejde o podklady z Databázy produktov?');
const get = (row: string[], i: number) => (i >= 0 ? String(row[i] ?? '').trim() : '');
const images = files.filter((f) => /\.(png|jpe?g|webp)$/i.test(f.name));
const products: PodkladyProduct[] = [];
for (const row of rows) {
const ean = get(row, c.ean);
if (!ean) continue;
// fotka: <EAN>/web/… (prípadne hocijaký obrázok v priečinku <EAN>/)
const own = images.filter((f) => f.name.split('/')[0] === ean);
const photoFile = own.find((f) => f.name.includes('/web/')) || own[0];
// šablóna cenovky: "Super cena | Červená | SK" → skúsime s pôvodom, bez neho, len grafiku
const graphic = get(row, c.badge);
const color = get(row, c.color);
const origin = get(row, c.origin);
const tries = [[graphic, color, origin], [graphic, color], [graphic]].map((parts) => parts.filter(Boolean).join(' | '));
const badge = graphic ? tries.map((t) => findBadgeByCode(badges, t)).find(Boolean) : undefined;
const formats = norm(get(row, c.formats));
const deposit = toNumber(get(row, c.deposit));
const discount = toNumber(get(row, c.discount));
products.push({
ean,
title: get(row, c.title) || get(row, c.titleAlt) || ean,
price: toNumber(get(row, c.price)),
cardPrice: toNumber(get(row, c.cardPrice)),
unitPrice: formatUnitPrice(get(row, c.unitPrice)),
discount: discount ? `${Math.round(discount)}%` : '',
badgeLabel: tries[0],
badge,
stores: formats
? { potraviny: formats.includes('potraviny'), supermarket: formats.includes('supermarket'), tempo: formats.includes('tempo') }
: { potraviny: true, supermarket: true, tempo: true },
deposit: deposit ? formatEur(deposit) : '',
photo: photoFile ? await photoFile.async('blob') : undefined,
photoName: photoFile ? photoFile.name.split('/').pop()! : ''
});
}
return { products, info };
}
// Produkt pre kampaň (fotka už nahratá – imageUrl)
export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx: number): ProductItem {
const price = splitPrice(p.price ?? 0);
const card = p.cardPrice != null ? splitPrice(p.cardPrice) : null;
return {
...(p.badge ? { badgeCustomTemplate: p.badge, badgeId: p.badge.id } : {}),
id: `zip-${Date.now()}-${idx}`,
title: p.title,
imageUrl,
priceMain: price.main,
priceSub: price.sub,
unitPrice: p.unitPrice,
discountText: p.discount,
...(card ? { cardPriceMain: card.main, cardPriceSub: card.sub } : {}),
note: p.deposit ? `+ záloha ${p.deposit} €` : '',
badgeText: 'zľava',
stores: p.stores
};
}