feat: tagy sablon cenoviek pre import podkladov, pozadie karty podla farby cenovky

Sablona ma tagy Typ (SUPER CENA / SUPER VYHODNE), Farba (cervena / oranzova /
zelena) a Povod (SK / bez povodu); import ZIP-u podla nich vyberie cenovku.
SK produkt bez SK sablony dostane variant bez povodu, opacne nie.
Pribudla oranzova predvolba pozadia karty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 14:04:48 +02:00
1 parent 9b1f7b60cf
commit 08fb233da0
8 files changed
+135 -26

No files matched your search

+42 -2
View File
@@ -5,7 +5,7 @@
// v súradniciach viewBoxu. Klávesnica: šípky posun (Shift = viac), Delete zmaže vrstvu, Ctrl+Z / Ctrl+Y späť / znova,
// Ctrl+S uloží.
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
import { BadgeTemplate, BadgeLayer, BadgeImportTags, BadgeColor } from '@/types/newsletter';
import { supabase } from '@/lib/supabase';
import { renderCleanBadgeSvg, BadgeProductData, BadgeRow, badgeFromRow, badgeCode } from '@/lib/badgeUtils';
import {
@@ -48,7 +48,8 @@ function toBadgeDbPayload(badge: BadgeTemplate) {
width: badge.width || 110,
align: badge.align || 'center',
paddingTop: badge.paddingTop ?? 4,
paddingBottom: badge.paddingBottom ?? 4
paddingBottom: badge.paddingBottom ?? 4,
importTags: badge.importTags
}
};
}
@@ -147,6 +148,41 @@ function loadFont(layer: BadgeLayer) {
// Malé UI prvky
// ---------------------------------------------------------------------------
// Tagy šablóny pre import podkladov: ktorým riadkom tabuľky (Zľava grafická + Farba cenovky + Pôvod) patrí
const BADGE_TYPES = ['SUPER CENA', 'SUPER VÝHODNÉ'];
const ImportTagsEditor: React.FC<{ tags: BadgeImportTags; onChange: (tags: BadgeImportTags) => void }> = ({ tags, onChange }) => {
const cls = 'w-full text-[11px] border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
return (
<div className="space-y-1.5">
<div className="grid grid-cols-[60px_1fr] items-center gap-x-2 gap-y-1.5">
<span className="text-[10px] font-semibold text-gray-600">Typ</span>
<select value={tags.type || ''} onChange={(e) => onChange({ ...tags, type: e.target.value || undefined })} className={cls}>
<option value="">— nepoužiť pri importe —</option>
{BADGE_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
{tags.type && !BADGE_TYPES.includes(tags.type) && <option value={tags.type}>{tags.type}</option>}
</select>
<span className="text-[10px] font-semibold text-gray-600">Farba</span>
<select value={tags.color || ''} onChange={(e) => onChange({ ...tags, color: (e.target.value || undefined) as BadgeColor | undefined })} className={cls}>
<option value="">— hocijaká —</option>
<option value="cervena">Červená</option>
<option value="oranzova">Oranžová</option>
<option value="zelena">Zelená</option>
</select>
<span className="text-[10px] font-semibold text-gray-600">Pôvod</span>
<select value={tags.origin || ''} onChange={(e) => onChange({ ...tags, origin: e.target.value as 'SK' | '' })} className={cls}>
<option value="">Bez pôvodu</option>
<option value="SK">SK (slovenský produkt)</option>
</select>
</div>
<p className="text-[10px] text-gray-400 leading-snug">
Pri importe ZIP-u sa šablóna priradí produktom so stĺpcami Zľava grafická, Farba cenovky a Pôvod podľa týchto tagov.
</p>
</div>
);
};
const Section: React.FC<{ title: string; action?: React.ReactNode; children: React.ReactNode }> = ({ title, action, children }) => (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -776,6 +812,10 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
<div className="border-l border-gray-100 min-h-0 overflow-y-auto p-4 space-y-5">
{badge && (
<>
<Section title="Import podkladov (ZIP)">
<ImportTagsEditor tags={badge.importTags || {}} onChange={(importTags) => commit({ ...badge, importTags })} />
</Section>
<Section title="Pozadie">
<input ref={fileRef} type="file" accept=".svg,image/svg+xml,image/png,image/jpeg,image/webp" className="hidden" onChange={uploadBackground} />
<button type="button" onClick={() => fileRef.current?.click()} className="w-full flex items-center justify-center gap-1.5 text-[11px] font-bold text-gray-700 border border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 rounded-lg py-2">
+2 -2
View File
@@ -580,7 +580,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
</div>
{/* Rýchle predvoľby */}
<div className="grid grid-cols-3 gap-1.5">
<div className="grid grid-cols-4 gap-1.5">
{PRODUCT_BG_PRESETS.map((preset) => {
const currentBg = (module.data.cardBgColor || '#feeaea').toLowerCase();
const isSelected = currentBg === preset.color.toLowerCase();
@@ -792,7 +792,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
</div>
{/* Rýchle predvoľby */}
<div className="grid grid-cols-3 gap-1.5">
<div className="grid grid-cols-4 gap-1.5">
{PRODUCT_BG_PRESETS.map((preset) => {
const currentBg = (module.data.cardBgColor || '#e6e1d9').toLowerCase();
const isSelected = currentBg === preset.color.toLowerCase();
+2 -9
View File
@@ -7,16 +7,9 @@ import { BadgeTemplate, CardProduct, ProductLabel, ProductPhoto } from '@/types/
import { ChevronDown, ChevronUp, Trash2, Pencil, Globe, Layers } from 'lucide-react';
import { ImageUploadInput } from './ImageUploadInput';
import { pickAlt } from '@/lib/altText';
import { DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, MAIN_PHOTO, badgeWidthFor, photoStack, productCardAlt } from '@/lib/productCard';
import { DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, MAIN_PHOTO, PRODUCT_BG_PRESETS, badgeWidthFor, photoStack, productCardAlt } from '@/lib/productCard';
export const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
];
export { PRODUCT_BG_PRESETS };
type Variant = 'standard' | 'loyalty';
// Textové polia cien produktu (bežné aj vernostné)
+13 -4
View File
@@ -8,6 +8,7 @@ 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 { CARD_BG_BY_BADGE_COLOR } from '@/lib/productCard';
import { shrinkImage, uploadFile } from '@/lib/upload';
interface ImportModalProps {
@@ -291,7 +292,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
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">
<span
className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shrink-0"
style={p.color ? { backgroundColor: CARD_BG_BY_BADGE_COLOR[p.color] } : undefined}
title={p.color ? 'Pozadie karty podľa farby cenovky' : undefined}
>
{p.thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={p.thumb} alt="" className="w-full h-full object-contain" />
@@ -308,7 +313,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
{stores && ` · ${stores}`}
</p>
<p className="text-[11px] truncate">
{p.badge ? (
{p.badge && p.badgeMatch === 'origin' ? (
<span className="text-amber-700">
cenovka: {p.badge.name} – šablóna „{p.badgeLabel}“ so SK chýba, použije sa variant bez pôvodu
</span>
) : p.badge ? (
<span className="text-emerald-700">cenovka: {p.badge.name}</span>
) : (
<span className="text-amber-700">
@@ -326,8 +335,8 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
})}
</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.
Fotky sa pri importe zmenšia na najviac 1000 px, pozadie karty sa nastaví podľa farby cenovky. Rozloženie karty (veľkosť a poloha
cenovky, štítky predajní) sa prevezme z prvého produktu v sekcii.
</p>
</div>
)}
+4 -2
View File
@@ -1,4 +1,4 @@
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
import { BadgeTemplate, BadgeLayer, BadgeImportTags } 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[] = [];
@@ -19,6 +19,7 @@ export interface BadgeRow {
align?: BadgeTemplate['align'];
paddingTop?: number;
paddingBottom?: number;
importTags?: BadgeImportTags;
}
| null;
}
@@ -34,7 +35,8 @@ export function badgeFromRow(d: BadgeRow): BadgeTemplate {
width: cfg?.width || 110,
align: cfg?.align || 'center',
paddingTop: cfg?.paddingTop ?? 4,
paddingBottom: cfg?.paddingBottom ?? 4
paddingBottom: cfg?.paddingBottom ?? 4,
importTags: cfg?.importTags
};
}
+45 -7
View File
@@ -1,8 +1,9 @@
// 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 { BadgeColor, BadgeTemplate, ProductItem } from '@/types/newsletter';
import { findBadgeByCode } from './badgeUtils';
import { CARD_BG_BY_BADGE_COLOR } from './productCard';
export interface PodkladyProduct {
ean: string;
@@ -13,6 +14,8 @@ export interface PodkladyProduct {
discount: string; // napr. "30%"
badgeLabel: string; // napr. "SUPER CENA | červená | SK" – z čoho sa hľadá šablóna cenovky
badge?: BadgeTemplate;
badgeMatch?: 'exact' | 'origin'; // origin = našla sa šablóna s iným pôvodom (napr. bez SK)
color?: BadgeColor; // farba cenovky → pozadie karty
stores: ProductItem['stores'];
deposit: string; // napr. "0,15"
photo?: Blob;
@@ -27,6 +30,36 @@ const norm = (s: string) =>
.replace(/\s+/g, ' ')
.trim();
// „červená“ → cervena
function toBadgeColor(value: string): BadgeColor | undefined {
const n = norm(value);
if (n.startsWith('cerven')) return 'cervena';
if (n.startsWith('oranz')) return 'oranzova';
if (n.startsWith('zelen')) return 'zelena';
return undefined;
}
// Šablóna cenovky podľa tagov (Typ + Farba + Pôvod). SK produkt bez SK šablóny dostane variant bez pôvodu,
// opačne nie – zahraničný produkt nesmie dostať cenovku so slovenskou vlajkou.
function findBadgeByTags(
badges: BadgeTemplate[],
graphic: string,
color: BadgeColor | undefined,
origin: string
): { badge: BadgeTemplate; match: 'exact' | 'origin' } | undefined {
if (!graphic) return undefined;
const sk = norm(origin) === 'sk' ? 'SK' : '';
const candidates = badges.filter(
(b) => b.importTags?.type && norm(b.importTags.type) === norm(graphic) && (!b.importTags.color || b.importTags.color === color)
);
// presná farba má prednosť pred „hocijakou“
const rank = (b: BadgeTemplate) => (b.importTags?.color ? 0 : 1);
const exact = candidates.filter((b) => (b.importTags?.origin || '') === sk).sort((a, b) => rank(a) - rank(b))[0];
if (exact) return { badge: exact, match: 'exact' };
const other = sk ? candidates.sort((a, b) => rank(a) - rank(b))[0] : undefined;
return other ? { badge: other, match: 'origin' } : undefined;
}
// ---------- jednoduché čítanie .xlsx (prvý hárok) ----------
function colIndex(ref: string): number {
const letters = ref.replace(/\d+/g, '');
@@ -125,12 +158,14 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
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
// šablóna cenovky: podľa tagov šablóny, inak podľa názvu „Super cena | Červená | SK“
const graphic = get(row, c.badge);
const color = get(row, c.color);
const colorText = get(row, c.color);
const color = toBadgeColor(colorText);
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 tries = [[graphic, colorText, origin], [graphic, colorText], [graphic]].map((parts) => parts.filter(Boolean).join(' | '));
const byTags = findBadgeByTags(badges, graphic, color, origin);
const byName = !byTags && graphic ? tries.map((t) => findBadgeByCode(badges, t)).find(Boolean) : undefined;
const formats = norm(get(row, c.formats));
const deposit = toNumber(get(row, c.deposit));
@@ -144,7 +179,9 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
unitPrice: formatUnitPrice(get(row, c.unitPrice)),
discount: discount ? `${Math.round(discount)}%` : '',
badgeLabel: tries[0],
badge,
badge: byTags?.badge || byName,
badgeMatch: byTags ? byTags.match : byName ? 'exact' : undefined,
color,
stores: formats
? { potraviny: formats.includes('potraviny'), supermarket: formats.includes('supermarket'), tempo: formats.includes('tempo') }
: { potraviny: true, supermarket: true, tempo: true },
@@ -172,6 +209,7 @@ export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx:
...(card ? { cardPriceMain: card.main, cardPriceSub: card.sub } : {}),
note: p.deposit ? `+ záloha ${p.deposit} €` : '',
badgeText: 'zľava',
stores: p.stores
stores: p.stores,
...(p.color ? { cardBgColor: CARD_BG_BY_BADGE_COLOR[p.color] } : {})
};
}
+18
View File
@@ -224,6 +224,24 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
// Poradie fotiek v karte odzadu dopredu ('main' = hlavná fotka). Uložené poradie sa doplní o nové fotky
// (dopredu) a očistí o zmazané; bez uloženého poradia platí staršie nastavenie „za hlavnou fotkou“.
export const MAIN_PHOTO = 'main';
// Predvoľby farby pozadia karty produktu (oranžová, červená a zelená zodpovedajú farbám cenoviek v podkladoch)
export const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
{ label: 'Oranžová', sub: 'Super výhodné', color: '#fdebd8', border: '#f3d2ae' },
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
];
// Farba cenovky z podkladov → pozadie karty
export const CARD_BG_BY_BADGE_COLOR: Record<'cervena' | 'oranzova' | 'zelena', string> = {
cervena: '#feeaea',
oranzova: '#fdebd8',
zelena: '#e3f0e4'
};
export function photoStack(p: Pick<CardProduct, 'extraPhotos' | 'photoOrder'>): string[] {
const extras = p.extraPhotos || [];
const ids = new Set([MAIN_PHOTO, ...extras.map((f) => f.id)]);
+9
View File
@@ -240,4 +240,13 @@ export interface BadgeTemplate {
align?: 'left' | 'center' | 'right'; // Zarovnanie (vľavo, stred, vpravo)
paddingTop?: number;
paddingBottom?: number;
importTags?: BadgeImportTags; // podľa čoho sa šablóna vyberie pri importe podkladov (ZIP)
}
// Tagy šablóny cenovky pre import podkladov – zodpovedajú stĺpcom tabuľky z Databázy produktov
export type BadgeColor = 'cervena' | 'oranzova' | 'zelena';
export interface BadgeImportTags {
type?: string; // „Zľava grafická“, napr. SUPER CENA, SUPER VÝHODNÉ
color?: BadgeColor; // „Farba cenovky“
origin?: 'SK' | ''; // „Pôvod“: SK alebo bez pôvodu
}