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:
1 parent
9b1f7b60cf
commit
08fb233da0
8 files changed
+135
-26
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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é)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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] } : {})
|
||||
};
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user