diff --git a/src/app/page.tsx b/src/app/page.tsx index bb4731c..f351710 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 = {}; + 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); diff --git a/src/components/editor/ProductImportModal.tsx b/src/components/editor/ProductImportModal.tsx index 5152144..71f4997 100644 --- a/src/components/editor/ProductImportModal.tsx +++ b/src/components/editor/ProductImportModal.tsx @@ -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 = ({ const [presetName, setPresetName] = useState(''); const [savedPresets, setSavedPresets] = useState([]); + // 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(null); + if (!isOpen) return null; const handleFileUpload = (e: React.ChangeEvent) => { @@ -59,6 +65,19 @@ export const ProductImportModal: React.FC = ({ 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 = ({ 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 = ({
= ({
+ {/* ZIP s podkladmi: náhľad produktov */} + {zip && ( +
+
+

Produkty v ZIP-e ({zip.products.length})

+ {zip.info && {zip.info}} +
+
+ {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 ( +
+ + {p.thumb ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + bez fotky + )} + +
+

{p.title}

+

+ EAN {p.ean} + {p.unitPrice && ` · ${p.unitPrice}`} + {p.deposit && ` · záloha ${p.deposit} €`} + {stores && ` · ${stores}`} +

+

+ {p.badge ? ( + cenovka: {p.badge.name} + ) : ( + + {p.badgeLabel ? `cenovka „${p.badgeLabel}“ nie je medzi šablónami – použije sa predvolená` : 'bez cenovky – použije sa predvolená'} + + )} +

+
+
+

{price ? `${price.main},${price.sub} €` : '—'}

+ {p.discount &&

-{p.discount}

} +
+
+ ); + })} +
+

+ 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. +

+
+ )} + {/* Mapping Table */} {columns.length > 0 && (
@@ -363,11 +470,11 @@ export const ProductImportModal: React.FC = ({
diff --git a/src/lib/podkladyZip.ts b/src/lib/podkladyZip.ts new file mode 100644 index 0000000..5c6df64 --- /dev/null +++ b/src/lib/podkladyZip.ts @@ -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 /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 { + 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: /web/… (prípadne hocijaký obrázok v priečinku /) + 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 + }; +}