From bd67fc702006bc9a0d295646bd0e3abf345db703 Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Wed, 21 Oct 2026 15:48:25 +0200 Subject: [PATCH] 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 --- src/app/page.tsx | 2 + src/components/editor/BadgeEditorModal.tsx | 21 ++++- src/components/editor/ProductEditor.tsx | 14 ++-- src/components/editor/ProductImportModal.tsx | 10 ++- src/lib/badgeUtils.ts | 9 ++- src/lib/podkladyZip.ts | 80 +++++++++++++++----- src/lib/productCard.ts | 9 ++- src/types/newsletter.ts | 3 + 8 files changed, 114 insertions(+), 34 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index f351710..4ab5d1c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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) */} diff --git a/src/components/editor/BadgeEditorModal.tsx b/src/components/editor/BadgeEditorModal.tsx index e8921b0..47f434c 100644 --- a/src/components/editor/BadgeEditorModal.tsx +++ b/src/components/editor/BadgeEditorModal.tsx @@ -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 + Appka +

- 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.

); @@ -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 = ({ currentUnitPrice = '1kg = 4,91 €', currentCardPriceMain = '1', currentCardPriceSub = '49', - currentCardDiscount = '30%' + currentCardDiscount = '30%', + currentAppPriceMain, + currentAppPriceSub }) => { const [badges, setBadges] = useState([]); const [badge, setBadge] = useState(null); @@ -324,7 +335,9 @@ export const BadgeEditorModal: React.FC = ({ 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 = ({ 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); diff --git a/src/components/editor/ProductEditor.tsx b/src/components/editor/ProductEditor.tsx index e6c3acb..eb7bfa8 100644 --- a/src/components/editor/ProductEditor.tsx +++ b/src/components/editor/ProductEditor.tsx @@ -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; @@ -274,7 +275,7 @@ export const ProductEditor: React.FC = ({ 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) => (
{label}
@@ -284,9 +285,11 @@ export const ProductEditor: React.FC = ({ - - - + {discountKey && ( + + + + )}
{unitKey && ( @@ -450,6 +453,7 @@ export const ProductEditor: React.FC = ({ <> {priceRow('Cena', 'priceMain', 'priceSub', 'discountText')} {usesCardPrice(p.badgeCustomTemplate) && priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount')} + {usesAppPrice(p.badgeCustomTemplate) && priceRow('Pre appkáčov', 'appPriceMain', 'appPriceSub')} )} diff --git a/src/components/editor/ProductImportModal.tsx b/src/components/editor/ProductImportModal.tsx index cf4396c..769d872 100644 --- a/src/components/editor/ProductImportModal.tsx +++ b/src/components/editor/ProductImportModal.tsx @@ -310,12 +310,18 @@ export const ProductImportModal: React.FC = ({ 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.extras.length > 0 && ( +

+ štítok na fotke: {p.extras.join(' · ')} +

+ )}

- {p.badge && p.badgeMatch === 'origin' ? ( + {p.badge && p.badgeMissing.length ? ( - 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}“ ) : p.badge ? ( cenovka: {p.badge.name} diff --git a/src/lib/badgeUtils.ts b/src/lib/badgeUtils.ts index 28602c5..10a2310 100644 --- a/src/lib/badgeUtils.ts +++ b/src/lib/badgeUtils.ts @@ -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']; } diff --git a/src/lib/podkladyZip.ts b/src/lib/podkladyZip.ts index a6d9faf..5cdd218 100644 --- a/src/lib/podkladyZip.ts +++ b/src/lib/podkladyZip.ts @@ -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 /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(' ')}
${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, diff --git a/src/lib/productCard.ts b/src/lib/productCard.ts index ad3d3e7..d53ea65 100644 --- a/src/lib/productCard.ts +++ b/src/lib/productCard.ts @@ -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

`; } +// Š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'); diff --git a/src/types/newsletter.ts b/src/types/newsletter.ts index 059641d..bd5172d 100644 --- a/src/types/newsletter.ts +++ b/src/types/newsletter.ts @@ -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“ }