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:
1 parent
48fd82a77d
commit
9b1f7b60cf
3 files changed
+303
-6
No files matched your search
+14
-1
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user