feat: kod sablony cenovky pre hromadny import

- sablona cenovky ma kod (napr. SUPER_CENA), nastavuje sa v hlavicke editora
  cenoviek; kontrola jedinecnosti, ulozi sa v layers_config (bez zmeny DB)
- import CSV/JSON/XML: novy stlpec Cenovka (kod sablony), automaticky
  rozpoznany (cenovka/badge/sablona); pouzije sa kod alebo nazov sablony,
  bez ohladu na velke pismena a diakritiku
- nahlad importu ukazuje priradenu sablonu, neznamy kod nahlasi upozornenie
- spolocne nacitanie sablon z DB (badgeFromRow) pre editor aj stranku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 12:41:39 +02:00
1 parent 6d73660cac
commit 332ab833e9
5 files changed
+138 -41

No files matched your search

+3 -14
View File
@@ -5,7 +5,7 @@ import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign'; import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign';
import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer'; import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { DEFAULT_BADGE_TEMPLATES } from '@/lib/badgeUtils'; import { DEFAULT_BADGE_TEMPLATES, BadgeRow, badgeFromRow } from '@/lib/badgeUtils';
import { getBrevoCampaignUrl } from '@/lib/brevo'; import { getBrevoCampaignUrl } from '@/lib/brevo';
import { TopBar } from '@/components/editor/TopBar'; import { TopBar } from '@/components/editor/TopBar';
import { ConfigPanel } from '@/components/editor/ConfigPanel'; import { ConfigPanel } from '@/components/editor/ConfigPanel';
@@ -56,19 +56,7 @@ export default function Home() {
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (data && !error && data.length > 0) { if (data && !error && data.length > 0) {
loaded = data.map((d: any) => { loaded = (data as BadgeRow[]).map(badgeFromRow);
const isObj = d.layers_config && !Array.isArray(d.layers_config);
return {
id: d.id,
name: d.name,
svgRaw: d.svg_raw || '',
layersConfig: isObj ? (d.layers_config.layers || []) : (d.layers_config || []),
width: isObj ? (d.layers_config.width || 110) : 110,
align: isObj ? (d.layers_config.align || 'center') : 'center',
paddingTop: isObj ? (d.layers_config.paddingTop ?? 4) : 4,
paddingBottom: isObj ? (d.layers_config.paddingBottom ?? 4) : 4
};
});
} }
} catch (e) { } catch (e) {
console.warn('Error fetching badges in page:', e); console.warn('Error fetching badges in page:', e);
@@ -1006,6 +994,7 @@ export default function Home() {
isOpen={isImportOpen} isOpen={isImportOpen}
onClose={() => setIsImportOpen(false)} onClose={() => setIsImportOpen(false)}
onImportProducts={handleImportProducts} onImportProducts={handleImportProducts}
availableBadges={availableBadges}
/> />
{/* Modál Editora cenoviek (SVG -> PNG) */} {/* Modál Editora cenoviek (SVG -> PNG) */}
+22 -23
View File
@@ -7,7 +7,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter'; import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { renderCleanBadgeSvg, BadgeProductData } from '@/lib/badgeUtils'; import { renderCleanBadgeSvg, BadgeProductData, BadgeRow, badgeFromRow, normalizeBadgeCode } from '@/lib/badgeUtils';
import { import {
Tag, Tag,
Plus, Plus,
@@ -45,6 +45,7 @@ function toBadgeDbPayload(badge: BadgeTemplate) {
svg_raw: badge.svgRaw, svg_raw: badge.svgRaw,
layers_config: { layers_config: {
layers: badge.layersConfig, layers: badge.layersConfig,
code: badge.code ? normalizeBadgeCode(badge.code) : null,
width: badge.width || 110, width: badge.width || 110,
align: badge.align || 'center', align: badge.align || 'center',
paddingTop: badge.paddingTop ?? 4, paddingTop: badge.paddingTop ?? 4,
@@ -53,27 +54,6 @@ function toBadgeDbPayload(badge: BadgeTemplate) {
}; };
} }
interface BadgeRow {
id: string;
name: string;
svg_raw: string | null;
layers_config: BadgeLayer[] | { layers?: BadgeLayer[]; width?: number; align?: BadgeTemplate['align']; paddingTop?: number; paddingBottom?: number } | null;
}
function fromBadgeRow(d: BadgeRow): BadgeTemplate {
const cfg = d.layers_config && !Array.isArray(d.layers_config) ? d.layers_config : null;
return {
id: d.id,
name: d.name,
svgRaw: d.svg_raw || '',
layersConfig: cfg ? cfg.layers || [] : (d.layers_config as BadgeLayer[]) || [],
width: cfg?.width || 110,
align: cfg?.align || 'center',
paddingTop: cfg?.paddingTop ?? 4,
paddingBottom: cfg?.paddingBottom ?? 4
};
}
function saveLocal(list: BadgeTemplate[]) { function saveLocal(list: BadgeTemplate[]) {
try { try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(list)); localStorage.setItem(LOCAL_KEY, JSON.stringify(list));
@@ -294,6 +274,10 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const isDirty = !!badge && JSON.stringify(badge) !== savedJson; const isDirty = !!badge && JSON.stringify(badge) !== savedJson;
// Kód pre import musí byť jedinečný
const codeTaken =
!!badge?.code &&
badges.some((b) => b.id !== badge.id && b.code && normalizeBadgeCode(b.code) === normalizeBadgeCode(badge.code));
const layer = badge?.layersConfig.find((l) => l.id === selectedId) || null; const layer = badge?.layersConfig.find((l) => l.id === selectedId) || null;
const vb = useMemo(() => viewBoxOf(badge?.svgRaw), [badge?.svgRaw]); const vb = useMemo(() => viewBoxOf(badge?.svgRaw), [badge?.svgRaw]);
const canvasWidth = Math.round(300 * zoom); const canvasWidth = Math.round(300 * zoom);
@@ -324,7 +308,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
let loaded: BadgeTemplate[] = []; let loaded: BadgeTemplate[] = [];
try { try {
const { data } = await supabase.from('badges').select('*').order('created_at', { ascending: false }); const { data } = await supabase.from('badges').select('*').order('created_at', { ascending: false });
loaded = ((data as BadgeRow[]) || []).map(fromBadgeRow); loaded = ((data as BadgeRow[]) || []).map(badgeFromRow);
} catch { } catch {
// bez databázy použijeme kópiu v prehliadači // bez databázy použijeme kópiu v prehliadači
} }
@@ -663,6 +647,21 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
title="Názov šablóny" title="Názov šablóny"
/> />
)} )}
{badge && (
<label className="flex items-center gap-1 shrink-0" title="Kód pre hromadný import: v stĺpci cenovky v CSV/JSON/XML zadajte tento kód">
<span className="text-[10px] font-bold text-gray-500">Kód</span>
<input
value={badge.code || ''}
onChange={(e) => commit({ ...badge, code: e.target.value.toUpperCase() }, false)}
onBlur={() => badge.code && commit({ ...badge, code: normalizeBadgeCode(badge.code) }, false)}
placeholder={normalizeBadgeCode(badge.name) || 'KOD'}
className={`w-32 text-[11px] font-mono uppercase border rounded-lg px-1.5 py-1 ${
codeTaken ? 'border-red-400 bg-red-50' : 'border-gray-200'
}`}
/>
{codeTaken && <span className="text-[10px] font-bold text-red-600">už používa iná šablóna</span>}
</label>
)}
{isDirty && <span className="text-[10px] font-bold text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">Neuložené</span>} {isDirty && <span className="text-[10px] font-bold text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">Neuložené</span>}
{savedFlash && <span className="text-[10px] font-bold text-emerald-600 flex items-center gap-1"><Check className="w-3 h-3" /> Uložené</span>} {savedFlash && <span className="text-[10px] font-bold text-emerald-600 flex items-center gap-1"><Check className="w-3 h-3" /> Uložené</span>}
</div> </div>
+54 -4
View File
@@ -3,7 +3,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Papa from 'papaparse'; import Papa from 'papaparse';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import { ProductItem } from '@/types/newsletter'; import { BadgeTemplate, ProductItem } from '@/types/newsletter';
import { findBadgeByCode, normalizeBadgeCode } 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';
@@ -11,12 +12,14 @@ interface ImportModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onImportProducts: (products: ProductItem[]) => void; onImportProducts: (products: ProductItem[]) => void;
availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť
} }
export const ProductImportModal: React.FC<ImportModalProps> = ({ export const ProductImportModal: React.FC<ImportModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onImportProducts onImportProducts,
availableBadges = []
}) => { }) => {
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');
@@ -32,6 +35,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
discountText: string; discountText: string;
imageUrl: string; imageUrl: string;
validity: string; validity: string;
badgeCode: string;
}>({ }>({
title: '', title: '',
priceMain: '', priceMain: '',
@@ -39,7 +43,8 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
unitPrice: '', unitPrice: '',
discountText: '', discountText: '',
imageUrl: '', imageUrl: '',
validity: '' validity: '',
badgeCode: ''
}); });
const [presetName, setPresetName] = useState(''); const [presetName, setPresetName] = useState('');
@@ -125,6 +130,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
if (lower.includes('zlava') || lower.includes('discount')) newMapping.discountText = h; if (lower.includes('zlava') || lower.includes('discount')) newMapping.discountText = h;
if (lower.includes('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = h; if (lower.includes('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = h;
if (lower.includes('platnost') || lower.includes('valid')) newMapping.validity = h; if (lower.includes('platnost') || lower.includes('valid')) newMapping.validity = h;
if (lower.includes('cenovk') || lower.includes('badge') || lower.includes('sablon') || lower.includes('template')) newMapping.badgeCode = h;
}); });
setMapping(newMapping); setMapping(newMapping);
}; };
@@ -148,7 +154,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
if (pSub.length === 1) pSub += '0'; if (pSub.length === 1) pSub += '0';
} }
// Šablóna cenovky podľa kódu (alebo názvu) zo súboru
const badge = mapping.badgeCode ? findBadgeByCode(availableBadges, row[mapping.badgeCode]) : undefined;
return { return {
...(badge ? { badgeCustomTemplate: badge, badgeId: badge.id, badgeWidth: badge.width ?? 110 } : {}),
id: `imported-${Date.now()}-${idx}`, id: `imported-${Date.now()}-${idx}`,
title: String(row[mapping.title] || 'Nový produkt'), title: String(row[mapping.title] || 'Nový produkt'),
imageUrl: row[mapping.imageUrl] || 'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/produkt_1.png', imageUrl: row[mapping.imageUrl] || 'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/produkt_1.png',
@@ -163,6 +173,20 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
}; };
}); });
// Kódy cenoviek, ku ktorým sa nenašla šablóna
if (mapping.badgeCode) {
const unknown = [
...new Set(
parsedRows
.map((row) => String(row[mapping.badgeCode] || '').trim())
.filter((v) => v && !findBadgeByCode(availableBadges, v))
)
];
if (unknown.length) {
alert(`Pre tieto kódy cenoviek sa nenašla šablóna (produkty dostanú predvolenú cenovku):\n${unknown.join(', ')}`);
}
}
onImportProducts(importedProducts); onImportProducts(importedProducts);
onClose(); onClose();
}; };
@@ -268,6 +292,25 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
</select> </select>
</div> </div>
<div className="col-span-2">
<label className="block text-xs font-semibold text-gray-600 mb-1">Cenovka (kód šablóny)</label>
<select
value={mapping.badgeCode}
onChange={(e) => setMapping({ ...mapping, badgeCode: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Bez cenovky zo súboru (predvolená) --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<p className="text-[11px] text-gray-500 mt-1">
Kódy šablón:{' '}
{availableBadges.length
? availableBadges.map((b) => normalizeBadgeCode(b.code || b.name)).join(', ')
: 'zatiaľ žiadne šablóny'}
{' '}(kód nastavíte v Editore cenoviek; funguje aj názov šablóny)
</p>
</div>
<div> <div>
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label> <label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
<select <select
@@ -290,7 +333,14 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<div className="p-3 text-xs space-y-2 bg-white"> <div className="p-3 text-xs space-y-2 bg-white">
{parsedRows.slice(0, 2).map((row, idx) => ( {parsedRows.slice(0, 2).map((row, idx) => (
<div key={idx} className="flex items-center justify-between border-b border-gray-100 pb-2"> <div key={idx} className="flex items-center justify-between border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-800">{row[mapping.title] || 'Bez názvu'}</span> <span className="font-semibold text-gray-800">
{row[mapping.title] || 'Bez názvu'}
{mapping.badgeCode && (
<span className="ml-2 text-[10px] font-medium text-gray-500">
cenovka: {findBadgeByCode(availableBadges, row[mapping.badgeCode])?.name || `? (${row[mapping.badgeCode] || 'prázdne'})`}
</span>
)}
</span>
<span className="text-red-600 font-bold"> <span className="text-red-600 font-bold">
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} € {row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
</span> </span>
+58
View File
@@ -3,6 +3,64 @@ import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
// Vymazané všetky preddefinované šablóny — v systéme sú výhradne vlastné cenovky vytvorené používateľom // Vymazané všetky preddefinované šablóny — v systéme sú výhradne vlastné cenovky vytvorené používateľom
export const DEFAULT_BADGE_TEMPLATES: BadgeTemplate[] = []; export const DEFAULT_BADGE_TEMPLATES: BadgeTemplate[] = [];
// ---------------------------------------------------------------------------
// Šablóny v databáze (tabuľka badges) a kód šablóny pre hromadný import
// ---------------------------------------------------------------------------
export interface BadgeRow {
id: string;
name: string;
svg_raw: string | null;
layers_config:
| BadgeLayer[]
| {
layers?: BadgeLayer[];
code?: string | null;
width?: number;
align?: BadgeTemplate['align'];
paddingTop?: number;
paddingBottom?: number;
}
| null;
}
// Riadok tabuľky badges → šablóna (staršie riadky majú v layers_config len pole vrstiev)
export function badgeFromRow(d: BadgeRow): BadgeTemplate {
const cfg = d.layers_config && !Array.isArray(d.layers_config) ? d.layers_config : null;
return {
id: d.id,
name: d.name,
code: cfg?.code || undefined,
svgRaw: d.svg_raw || '',
layersConfig: cfg ? cfg.layers || [] : (d.layers_config as BadgeLayer[]) || [],
width: cfg?.width || 110,
align: cfg?.align || 'center',
paddingTop: cfg?.paddingTop ?? 4,
paddingBottom: cfg?.paddingBottom ?? 4
};
}
// Kód šablóny: veľké písmená bez diakritiky, medzery a pomlčky → _ (napr. "Super cena" → SUPER_CENA)
export function normalizeBadgeCode(value: string | null | undefined): string {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.trim()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
// Šablóna podľa hodnoty zo súboru: najprv kód, potom názov šablóny
export function findBadgeByCode(badges: BadgeTemplate[], value: string | null | undefined): BadgeTemplate | undefined {
const wanted = normalizeBadgeCode(value);
if (!wanted) return undefined;
return (
badges.find((b) => b.code && normalizeBadgeCode(b.code) === wanted) ||
badges.find((b) => normalizeBadgeCode(b.name) === wanted)
);
}
export interface BadgeProductData { export interface BadgeProductData {
priceMain: string; priceMain: string;
priceSub: string; priceSub: string;
+1
View File
@@ -206,6 +206,7 @@ export interface BadgeLayer {
export interface BadgeTemplate { export interface BadgeTemplate {
id: string; id: string;
name: string; name: string;
code?: string; // kód pre hromadný import (napr. SUPER_CENA) – stĺpec v CSV/JSON/XML určí šablónu
svgBackgroundUrl?: string; svgBackgroundUrl?: string;
svgRaw?: string; svgRaw?: string;
layersConfig: BadgeLayer[]; layersConfig: BadgeLayer[];