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:
1 parent
6d73660cac
commit
332ab833e9
5 files changed
+138
-41
No files matched your search
+3
-14
@@ -5,7 +5,7 @@ import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from
|
||||
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign';
|
||||
import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer';
|
||||
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 { TopBar } from '@/components/editor/TopBar';
|
||||
import { ConfigPanel } from '@/components/editor/ConfigPanel';
|
||||
@@ -56,19 +56,7 @@ export default function Home() {
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (data && !error && data.length > 0) {
|
||||
loaded = data.map((d: any) => {
|
||||
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
|
||||
};
|
||||
});
|
||||
loaded = (data as BadgeRow[]).map(badgeFromRow);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error fetching badges in page:', e);
|
||||
@@ -1006,6 +994,7 @@ export default function Home() {
|
||||
isOpen={isImportOpen}
|
||||
onClose={() => setIsImportOpen(false)}
|
||||
onImportProducts={handleImportProducts}
|
||||
availableBadges={availableBadges}
|
||||
/>
|
||||
|
||||
{/* Modál Editora cenoviek (SVG -> PNG) */}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { renderCleanBadgeSvg, BadgeProductData } from '@/lib/badgeUtils';
|
||||
import { renderCleanBadgeSvg, BadgeProductData, BadgeRow, badgeFromRow, normalizeBadgeCode } from '@/lib/badgeUtils';
|
||||
import {
|
||||
Tag,
|
||||
Plus,
|
||||
@@ -45,6 +45,7 @@ function toBadgeDbPayload(badge: BadgeTemplate) {
|
||||
svg_raw: badge.svgRaw,
|
||||
layers_config: {
|
||||
layers: badge.layersConfig,
|
||||
code: badge.code ? normalizeBadgeCode(badge.code) : null,
|
||||
width: badge.width || 110,
|
||||
align: badge.align || 'center',
|
||||
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[]) {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(list));
|
||||
@@ -294,6 +274,10 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
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 vb = useMemo(() => viewBoxOf(badge?.svgRaw), [badge?.svgRaw]);
|
||||
const canvasWidth = Math.round(300 * zoom);
|
||||
@@ -324,7 +308,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
||||
let loaded: BadgeTemplate[] = [];
|
||||
try {
|
||||
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 {
|
||||
// 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"
|
||||
/>
|
||||
)}
|
||||
{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>}
|
||||
{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>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import Papa from 'papaparse';
|
||||
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 { supabase } from '@/lib/supabase';
|
||||
|
||||
@@ -11,12 +12,14 @@ interface ImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => 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> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onImportProducts
|
||||
onImportProducts,
|
||||
availableBadges = []
|
||||
}) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
|
||||
@@ -32,6 +35,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
discountText: string;
|
||||
imageUrl: string;
|
||||
validity: string;
|
||||
badgeCode: string;
|
||||
}>({
|
||||
title: '',
|
||||
priceMain: '',
|
||||
@@ -39,7 +43,8 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
unitPrice: '',
|
||||
discountText: '',
|
||||
imageUrl: '',
|
||||
validity: ''
|
||||
validity: '',
|
||||
badgeCode: ''
|
||||
});
|
||||
|
||||
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('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = 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);
|
||||
};
|
||||
@@ -148,7 +154,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
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 {
|
||||
...(badge ? { badgeCustomTemplate: badge, badgeId: badge.id, badgeWidth: badge.width ?? 110 } : {}),
|
||||
id: `imported-${Date.now()}-${idx}`,
|
||||
title: String(row[mapping.title] || 'Nový produkt'),
|
||||
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);
|
||||
onClose();
|
||||
};
|
||||
@@ -268,6 +292,25 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
</select>
|
||||
</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>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
|
||||
<select
|
||||
@@ -290,7 +333,14 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
<div className="p-3 text-xs space-y-2 bg-white">
|
||||
{parsedRows.slice(0, 2).map((row, idx) => (
|
||||
<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">
|
||||
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
|
||||
</span>
|
||||
|
||||
@@ -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
|
||||
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 {
|
||||
priceMain: string;
|
||||
priceSub: string;
|
||||
|
||||
@@ -206,6 +206,7 @@ export interface BadgeLayer {
|
||||
export interface BadgeTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string; // kód pre hromadný import (napr. SUPER_CENA) – stĺpec v CSV/JSON/XML určí šablónu
|
||||
svgBackgroundUrl?: string;
|
||||
svgRaw?: string;
|
||||
layersConfig: BadgeLayer[];
|
||||
|
||||
Reference in new issue
Block a user