feat: cena pre appkacov ({cena_app}), tag cenovky pre appkacov, doplnkove grafiky ako stitky

- premenna {cena_app} v cenovke a pole Pre appkacov v produkte (ked ju sablona pouziva)
- tag Appka v sablone; import da cenovku pre appkacov produktom s Cenou pre appkacov,
  bezny produkt ju nikdy nedostane
- stlpec Doplnkove grafiky sa pri importe zmeni na stitky na fotke

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 15:48:25 +02:00
1 parent 11392722e2
commit bd67fc7020
8 files changed
+114 -34

No files matched your search

+2
View File
@@ -1023,6 +1023,8 @@ export default function Home() {
currentCardPriceMain={targetProduct?.cardPriceMain || undefined}
currentCardPriceSub={targetProduct?.cardPriceSub || undefined}
currentCardDiscount={targetProduct?.cardDiscount || undefined}
currentAppPriceMain={targetProduct?.appPriceMain || undefined}
currentAppPriceSub={targetProduct?.appPriceSub || undefined}
/>
{/* Modál Moje newslettre (Ukladanie, duplikácia, vytváranie a prepínanie) */}
+17 -4
View File
@@ -108,6 +108,7 @@ const TOKENS: Array<{ token: string; label: string }> = [
{ token: '{zlava}', label: 'Zľava' },
{ token: '{cena_s_kartou}', label: 'Cena s kartou' },
{ token: '{zlava_s_kartou}', label: 'Zľava s kartou' },
{ token: '{cena_app}', label: 'Cena v appke' },
{ token: '{jednotkova_cena}', label: 'Jedn. cena' },
{ token: '{mena}', label: '€' }
];
@@ -118,6 +119,7 @@ const QUICK_LAYERS: Array<{ label: string; text: string; fontSize: number }> = [
{ label: 'Zľava', text: '-{zlava}', fontSize: 14 },
{ label: 'Cena s kartou', text: '{cena_s_kartou}', fontSize: 40 },
{ label: 'Zľava s kartou', text: '-{zlava_s_kartou}', fontSize: 14 },
{ label: 'Cena v appke', text: '{cena_app}', fontSize: 40 },
{ label: 'Text', text: 'AKCIA', fontSize: 16 }
];
@@ -174,9 +176,14 @@ const ImportTagsEditor: React.FC<{ tags: BadgeImportTags; onChange: (tags: Badge
<option value="">Bez pôvodu</option>
<option value="SK">SK (slovenský produkt)</option>
</select>
<span className="text-[10px] font-semibold text-gray-600">Appka</span>
<select value={tags.app ? 'app' : ''} onChange={(e) => onChange({ ...tags, app: e.target.value === 'app' || undefined })} className={cls}>
<option value="">Bežná cenovka</option>
<option value="app">Pre appkáčov (cena v aplikácii)</option>
</select>
</div>
<p className="text-[10px] text-gray-400 leading-snug">
Pri importe ZIP-u sa šablóna priradí produktom podľa stĺpcov Zľava grafická (prázdna = základná, s výkričníkom podľa stĺpca Výkričník), Farba cenovky a Pôvod.
Pri importe ZIP-u sa šablóna priradí produktom podľa stĺpcov Zľava grafická (prázdna = základná, s výkričníkom podľa stĺpca Výkričník), Farba cenovky a Pôvod. Cenovka pre appkáčov sa dá produktom, ktoré majú vyplnenú Cenu pre appkáčov.
</p>
</div>
);
@@ -270,6 +277,8 @@ interface BadgeEditorModalProps {
currentCardPriceMain?: string;
currentCardPriceSub?: string;
currentCardDiscount?: string;
currentAppPriceMain?: string;
currentAppPriceSub?: string;
}
interface DragState {
@@ -290,7 +299,9 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
currentUnitPrice = '1kg = 4,91 €',
currentCardPriceMain = '1',
currentCardPriceSub = '49',
currentCardDiscount = '30%'
currentCardDiscount = '30%',
currentAppPriceMain,
currentAppPriceSub
}) => {
const [badges, setBadges] = useState<BadgeTemplate[]>([]);
const [badge, setBadge] = useState<BadgeTemplate | null>(null);
@@ -324,7 +335,9 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
unitPrice: currentUnitPrice,
cardPriceMain: testPrice?.main || currentCardPriceMain,
cardPriceSub: testPrice?.sub || currentCardPriceSub,
cardDiscount: currentCardDiscount
cardDiscount: currentCardDiscount,
appPriceMain: testPrice?.main || currentAppPriceMain || currentPriceMain,
appPriceSub: testPrice?.sub || currentAppPriceSub || currentPriceSub
};
// --- načítanie šablón -----------------------------------------------------
@@ -659,7 +672,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
if (!isOpen) return null;
const hasCents = !!layer && /\{(cena|cena_s_kartou|centy)\}/.test(layer.text || '');
const hasCents = !!layer && /\{(cena|cena_s_kartou|cena_app|centy)\}/.test(layer.text || '');
const fontName = layer?.fontFamily || 'Arial Narrow';
const isKnownFont = FONTS.some((f) => f.name === fontName);
+9 -5
View File
@@ -7,7 +7,7 @@ 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, PRODUCT_BG_PRESETS, 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, usesAppPrice } from '@/lib/productCard';
export { PRODUCT_BG_PRESETS };
@@ -16,6 +16,7 @@ type Variant = 'standard' | 'loyalty';
type TextKey =
| 'priceMain' | 'priceSub' | 'discountText'
| 'cardPriceMain' | 'cardPriceSub' | 'cardDiscount' | 'cardUnitPrice'
| 'appPriceMain' | 'appPriceSub'
| 'noCardPriceMain' | 'noCardPriceSub' | 'noCardDiscount' | 'noCardUnitPrice';
type Patch = Record<string, unknown>;
@@ -274,7 +275,7 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
const text = (key: TextKey) => p[key] as string | undefined;
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey: TextKey, unitKey?: TextKey) => (
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey?: TextKey, unitKey?: TextKey) => (
<div className="space-y-1">
<span className="text-[10px] font-semibold text-gray-600">{label}</span>
<div className={`grid ${unitKey ? 'grid-cols-[1fr_1fr_1fr]' : 'grid-cols-3'} gap-1.5`}>
@@ -284,9 +285,11 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
<Field label="Centy">
<TextInput value={text(subKey)} onChange={set(subKey)} bold />
</Field>
<Field label="Zľava">
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
</Field>
{discountKey && (
<Field label="Zľava">
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
</Field>
)}
</div>
{unitKey && (
<Field label="Jednotková cena">
@@ -450,6 +453,7 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
<>
{priceRow('Cena', 'priceMain', 'priceSub', 'discountText')}
{usesCardPrice(p.badgeCustomTemplate) && priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount')}
{usesAppPrice(p.badgeCustomTemplate) && priceRow('Pre appkáčov', 'appPriceMain', 'appPriceSub')}
</>
)}
+8 -2
View File
@@ -310,12 +310,18 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
EAN {p.ean}
{p.unitPrice && ` · ${p.unitPrice}`}
{p.deposit && ` · záloha ${p.deposit} €`}
{p.appPrice != null && ` · v appke ${p.appPrice.toFixed(2).replace('.', ',')} €`}
{stores && ` · ${stores}`}
</p>
{p.extras.length > 0 && (
<p className="text-[11px] text-gray-500 truncate">
štítok na fotke: <span className="font-bold text-red-700">{p.extras.join(' · ')}</span>
</p>
)}
<p className="text-[11px] truncate">
{p.badge && p.badgeMatch === 'origin' ? (
{p.badge && p.badgeMissing.length ? (
<span className="text-amber-700">
cenovka: {p.badge.name} – šablóna „{p.badgeLabel}“ so SK chýba, použije sa variant bez pôvodu
cenovka: {p.badge.name} – chýba {p.badgeMissing.join(' a ')} pre „{p.badgeLabel}“
</span>
) : p.badge ? (
<span className="text-emerald-700">cenovka: {p.badge.name}</span>
+7 -2
View File
@@ -69,10 +69,12 @@ export interface BadgeProductData {
cardPriceMain?: string; // cena s kartou → {cena_s_kartou}
cardPriceSub?: string;
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
appPriceMain?: string; // cena pre appkáčov → {cena_app}
appPriceSub?: string;
}
// Premenné ceny s centami v hornom indexe: {cena} a {cena_s_kartou}
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou)\}/;
// Premenné ceny s centami v hornom indexe: {cena}, {cena_s_kartou} a {cena_app}
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou|cena_app)\}/;
// Nahradenie jednoduchých textových premenných (bez horného indexu)
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
@@ -89,6 +91,9 @@ export function badgePriceParts(token: string, product: BadgeProductData): [stri
if (token === 'cena_s_kartou') {
return [product.cardPriceMain || '', product.cardPriceSub || ''];
}
if (token === 'cena_app') {
return [product.appPriceMain || '', product.appPriceSub || ''];
}
return [product.priceMain || '0', product.priceSub || '00'];
}
+61 -19
View File
@@ -1,7 +1,7 @@
// 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 { BADGE_IMPORT_TYPES, BadgeColor, BadgeTemplate, ProductItem } from '@/types/newsletter';
import { BADGE_IMPORT_TYPES, BadgeColor, BadgeTemplate, ProductItem, ProductLabel } from '@/types/newsletter';
import { findBadgeByCode } from './badgeUtils';
import { CARD_BG_BY_BADGE_COLOR } from './productCard';
@@ -10,14 +10,16 @@ export interface PodkladyProduct {
title: string;
price: number | null; // akciová cena
cardPrice: number | null; // cena s vernostnou kartou
appPrice: number | null; // cena pre appkáčov
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;
badgeMatch?: 'exact' | 'origin'; // origin = našla sa šablóna s iným pôvodom (napr. bez SK)
badgeMissing: string[]; // čo šablóne chýba voči riadku (napr. „SK variant“, „variant pre appkáčov“)
color?: BadgeColor; // farba cenovky → pozadie karty
stores: ProductItem['stores'];
deposit: string; // napr. "0,15"
extras: string[]; // doplnkové grafiky → štítky na fotke (napr. „PODIEL MÄSA 90 %“)
photo?: Blob;
photoName: string;
}
@@ -39,24 +41,51 @@ function toBadgeColor(value: string): BadgeColor | undefined {
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.
// Šablóna cenovky podľa tagov (Typ + Farba + Pôvod + Appka). Náhradný variant sa berie len smerom,
// ktorý nič nepridá: SK produkt môže dostať cenovku bez vlajky a produkt s cenou pre appkáčov bežnú cenovku,
// ale zahraničný produkt nikdy cenovku so slovenskou vlajkou a bežný produkt nikdy cenovku pre appkáčov.
function findBadgeByTags(
badges: BadgeTemplate[],
type: string,
color: BadgeColor | undefined,
origin: string
): { badge: BadgeTemplate; match: 'exact' | 'origin' } | undefined {
const sk = norm(origin) === 'sk' ? 'SK' : '';
const candidates = badges.filter(
(b) => b.importTags?.type && norm(b.importTags.type) === norm(type) && (!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;
origin: string,
app: boolean
): { badge: BadgeTemplate; missing: string[] } | undefined {
const sk = norm(origin) === 'sk';
const candidates = badges.filter((b) => {
const t = b.importTags;
if (!t?.type || norm(t.type) !== norm(type)) return false;
if (t.color && t.color !== color) return false;
if (t.origin === 'SK' && !sk) return false;
if (t.app && !app) return false;
return true;
});
// najlepšia zhoda: rovnaký pôvod, rovnaká appka, presná farba
const score = (b: BadgeTemplate) =>
((b.importTags?.origin === 'SK') === sk ? 0 : 4) + (!!b.importTags?.app === app ? 0 : 2) + (b.importTags?.color ? 0 : 1);
const best = candidates.sort((a, b) => score(a) - score(b))[0];
if (!best) return undefined;
const missing = [
sk && best.importTags?.origin !== 'SK' ? 'SK variant' : '',
app && !best.importTags?.app ? 'variant pre appkáčov' : ''
].filter(Boolean);
return { badge: best, missing };
}
// Doplnková grafika → štítok na fotke; dlhší text sa zalomí na dva riadky
function extraLabel(text: string, idx: number): ProductLabel {
const words = text.split(' ');
let label = text;
if (text.length > 16 && words.length > 1) {
let best = 1;
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(' ').length;
const bestA = words.slice(0, best).join(' ').length;
if (Math.abs(a - text.length / 2) < Math.abs(bestA - text.length / 2)) best = i;
}
label = `${words.slice(0, best).join(' ')}<br>${words.slice(best).join(' ')}`;
}
return { id: `lbl-${Date.now()}-${idx}`, text: label, x: 28, y: 14 + idx * 14, size: 11, bg: '#e2001a', color: '#ffffff', rotate: 0 };
}
// ---------- jednoduché čítanie .xlsx (prvý hárok) ----------
@@ -136,6 +165,8 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
titleAlt: col('Názov produktu'),
price: col('Akciová cena (leták)', 'Cena (xlsx podklady)'),
cardPrice: col('Cena s vernostnou kartou'),
appPrice: col('Cena pre appkáčov'),
extras: col('Doplnkové grafiky'),
unitPrice: col('Jednotková cena'),
discount: col('Zľava %'),
badge: col('Zľava grafická'),
@@ -166,7 +197,8 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
// prázdna „Zľava grafická“ = základná cenovka (s výkričníkom podľa stĺpca Výkričník)
const type = graphic || (norm(get(row, c.exclaim)) === 'ano' ? 'ZÁKLADNÁ !' : 'ZÁKLADNÁ');
const tries = [[graphic, colorText, origin], [graphic, colorText], [graphic]].map((parts) => parts.filter(Boolean).join(' | '));
const byTags = findBadgeByTags(badges, type, color, origin);
const appPrice = toNumber(get(row, c.appPrice));
const byTags = findBadgeByTags(badges, type, color, origin, appPrice != null);
const byName = !byTags && graphic ? tries.map((t) => findBadgeByCode(badges, t)).find(Boolean) : undefined;
const formats = norm(get(row, c.formats));
@@ -178,16 +210,23 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
title: get(row, c.title) || get(row, c.titleAlt) || ean,
price: toNumber(get(row, c.price)),
cardPrice: toNumber(get(row, c.cardPrice)),
appPrice,
unitPrice: formatUnitPrice(get(row, c.unitPrice)),
discount: discount ? `${Math.round(discount)}%` : '',
badgeLabel: [BADGE_IMPORT_TYPES.find((t) => t.value === type)?.label || type, colorText, origin].filter(Boolean).join(' | '),
badgeLabel: [BADGE_IMPORT_TYPES.find((t) => t.value === type)?.label || type, colorText, origin, appPrice != null ? 'appka' : '']
.filter(Boolean)
.join(' | '),
badge: byTags?.badge || byName,
badgeMatch: byTags ? byTags.match : byName ? 'exact' : undefined,
badgeMissing: byTags?.missing || [],
color,
stores: formats
? { potraviny: formats.includes('potraviny'), supermarket: formats.includes('supermarket'), tempo: formats.includes('tempo') }
: { potraviny: true, supermarket: true, tempo: true },
deposit: deposit ? formatEur(deposit) : '',
extras: get(row, c.extras)
.split(/[;\n]/)
.map((s) => s.trim())
.filter(Boolean),
photo: photoFile ? await photoFile.async('blob') : undefined,
photoName: photoFile ? photoFile.name.split('/').pop()! : ''
});
@@ -199,6 +238,7 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
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;
const app = p.appPrice != null ? splitPrice(p.appPrice) : null;
return {
...(p.badge ? { badgeCustomTemplate: p.badge, badgeId: p.badge.id } : {}),
id: `zip-${Date.now()}-${idx}`,
@@ -209,6 +249,8 @@ export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx:
unitPrice: p.unitPrice,
discountText: p.discount,
...(card ? { cardPriceMain: card.main, cardPriceSub: card.sub } : {}),
...(app ? { appPriceMain: app.main, appPriceSub: app.sub } : {}),
...(p.extras.length ? { labels: p.extras.map(extraLabel) } : {}),
note: p.deposit ? `+ záloha ${p.deposit} €` : '',
badgeText: 'zľava',
stores: p.stores,
+7 -2
View File
@@ -205,7 +205,7 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
t ? [t.svgRaw, t.layersConfig, t.width] : null,
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice,
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice, p.appPriceMain, p.appPriceSub,
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE,
p.labels || [], p.extraPhotos || [], photoStack(p)
@@ -339,6 +339,9 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
</div>`;
}
// Šablóna cenovky zobrazuje cenu pre appkáčov
export const usesAppPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('{cena_app}');
// Alt text obrázka karty: názov, gramáž a cena (ako v šablóne); ručne zadaný alt má prednosť
export function productCardAlt(p: CardProduct, variant: CardVariant): string {
const name = [pickAlt(p.title), p.quantity].filter(Boolean).join(' ');
@@ -355,9 +358,11 @@ export function productCardAlt(p: CardProduct, variant: CardVariant): string {
} else {
const main = price(p.priceMain, p.priceSub);
const card = price(p.cardPriceMain, p.cardPriceSub);
const app = usesAppPrice(p.badgeCustomTemplate) ? price(p.appPriceMain, p.appPriceSub) : '';
priceText = [
main ? `${main}${p.discountText ? `, zľava ${pct(p.discountText)}` : ''}` : '',
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : ''
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : '',
app ? `v aplikácii ${app}` : ''
].filter(Boolean).join(', ');
}
return pickAlt(p.imageAlt, [name, priceText].filter(Boolean).join(' – '), 'Produkt');
+3
View File
@@ -30,6 +30,8 @@ export interface ProductItem {
cardPriceMain?: string; // cena s kartou (vernostná), celé eurá
cardPriceSub?: string; // cena s kartou, centy
cardDiscount?: string; // zľava s kartou, napr. "30%"
appPriceMain?: string; // cena pre appkáčov (v aplikácii), celé eurá → {cena_app}
appPriceSub?: string; // cena pre appkáčov, centy
badgeText: string; // napr. "AKCIA" alebo "SUPER CENA"
badgeId?: string; // priradená cenovka z databázy
badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny)
@@ -257,4 +259,5 @@ export interface BadgeImportTags {
type?: string; // druh cenovky – jeden z BADGE_IMPORT_TYPES (SUPER CENA, SUPER VÝHODNÉ, ZÁKLADNÁ, ZÁKLADNÁ !)
color?: BadgeColor; // „Farba cenovky“
origin?: 'SK' | ''; // „Pôvod“: SK alebo bez pôvodu
app?: boolean; // cenovka pre appkáčov – pre produkty s „Cenou pre appkáčov“
}