feat: predvolene rozlozenie v sablone cenovky (poloha, velkost, stitky predajni)
- sablona moze mat layout (posun cenovky, posun a velkost stitkov); produkt bez vlastneho nastavenia ho preberie - tlacidlo v produkte ulozi aktualne rozlozenie ako predvolene pre vsetky sablony rovnakeho druhu a produkty v newsletteri ho prevezmu - Zakladna: cenovka -14 / -14, stitky -4 / +8, 14 px Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
944233f196
commit
b67db3cd49
7 files changed
+111
-16
No files matched your search
@@ -15,6 +15,7 @@ import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal';
|
||||
import { NameDialog } from '@/components/editor/NameDialog';
|
||||
import JSZip from 'jszip';
|
||||
import { prepareCardImages } from '@/lib/cardImages';
|
||||
import { cardLayout } from '@/lib/productCard';
|
||||
import { normalizeUnitPrice } from '@/lib/unitPrice';
|
||||
import { useCampaignReview } from '@/components/review/useCampaignReview';
|
||||
import { ReviewModal } from '@/components/review/ReviewModal';
|
||||
@@ -404,6 +405,7 @@ export default function Home() {
|
||||
const inherited: Partial<ProductItem> = {};
|
||||
for (const k of layoutKeys) {
|
||||
if (k === 'cardBgColor' && target.type === 'loyalty_club') continue; // vernostné zľavy: farba pozadia ostáva
|
||||
if (p.badgeCustomTemplate?.layout && k !== 'cardBgColor') continue; // šablóna má vlastné predvolené rozloženie
|
||||
if (p[k] === undefined && first[k] !== undefined) Object.assign(inherited, { [k]: first[k] });
|
||||
}
|
||||
return { ...inherited, ...p };
|
||||
@@ -648,6 +650,54 @@ export default function Home() {
|
||||
};
|
||||
|
||||
// Poloha štítkov predajní pre všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
||||
// Rozloženie produktu (poloha a veľkosť cenovky, poloha a veľkosť štítkov) ako predvolené pre všetky šablóny
|
||||
// rovnakého druhu (napr. všetky „Základná“); produkty s týmito šablónami v newsletteri ho prevezmú
|
||||
const handleSaveLayoutAsDefault = async (p: CardProduct) => {
|
||||
const t = p.badgeCustomTemplate;
|
||||
if (!t?.importTags?.type) return;
|
||||
const kind = (b: BadgeTemplate) =>
|
||||
b.importTags?.type === t.importTags?.type && !!b.importTags?.app === !!t.importTags?.app && !!b.importTags?.loyalty === !!t.importTags?.loyalty;
|
||||
const group = availableBadges.filter(kind);
|
||||
const L = cardLayout(p);
|
||||
const layout = { offsetX: L.offsetX, offsetY: L.offsetY, storesOffsetX: L.storesX, storesOffsetY: L.storesY, storesSize: L.storesSize };
|
||||
const width = p.badgeWidth || t.width;
|
||||
const label = t.name.split(' | ')[0];
|
||||
if (!confirm(`Uložiť polohu cenovky (${layout.offsetX} / ${layout.offsetY}), veľkosť (${width} px) a štítky (${layout.storesOffsetX} / ${layout.storesOffsetY}, ${layout.storesSize} px) ako predvolené pre ${group.length} šablón „${label}“?\n\nProdukty s týmito cenovkami v newsletteri ho prevezmú.`)) return;
|
||||
try {
|
||||
for (const b of group) {
|
||||
const { data, error } = await supabase.from('badges').select('layers_config').eq('id', b.id).single();
|
||||
if (error) throw error;
|
||||
const cfg = data.layers_config && !Array.isArray(data.layers_config) ? data.layers_config : { layers: data.layers_config || [] };
|
||||
const res = await supabase.from('badges').update({ layers_config: { ...cfg, layout, width } }).eq('id', b.id);
|
||||
if (res.error) throw res.error;
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Predvolené rozloženie sa nepodarilo uložiť: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
const updated = new Map(group.map((b) => [b.id, { ...b, layout, width }]));
|
||||
setAvailableBadges((prev) => prev.map((b) => updated.get(b.id) || b));
|
||||
setCampaign((prev) => ({
|
||||
...prev,
|
||||
modules: prev.modules.map((m) =>
|
||||
m.type === 'products_grid' || m.type === 'loyalty_club'
|
||||
? {
|
||||
...m,
|
||||
data: {
|
||||
...m.data,
|
||||
products: (m.data.products || []).map((pp: ProductItem) => {
|
||||
const u = pp.badgeCustomTemplate && !pp.customBadgePngUrl ? updated.get(pp.badgeCustomTemplate.id) : undefined;
|
||||
return u
|
||||
? { ...pp, badgeCustomTemplate: u, badgeWidth: undefined, badgeOffsetX: undefined, badgeOffsetY: undefined, storesOffsetX: undefined, storesOffsetY: undefined, storesSize: undefined }
|
||||
: pp;
|
||||
})
|
||||
}
|
||||
}
|
||||
: m
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApplyStoresOffsetToAll = (x: number, y: number, size: number) => {
|
||||
if (!confirm(`Použiť polohu (${x} / ${y}) a veľkosť (${size} px) štítkov pre všetky produkty v newsletteri?`)) return;
|
||||
setCampaign((prev) => ({
|
||||
@@ -1007,6 +1057,7 @@ export default function Home() {
|
||||
onUpdateModule={handleUpdateModule}
|
||||
onApplyBadgeToAll={handleApplyBadgeToAll}
|
||||
onApplyStoresOffsetToAll={handleApplyStoresOffsetToAll}
|
||||
onSaveLayoutAsDefault={handleSaveLayoutAsDefault}
|
||||
onUpdateAllProductSections={handleUpdateAllProductSections}
|
||||
campaignValidity={getCampaignValidity(campaign)}
|
||||
availableBadges={availableBadges}
|
||||
|
||||
@@ -50,7 +50,8 @@ function toBadgeDbPayload(badge: BadgeTemplate) {
|
||||
paddingTop: badge.paddingTop ?? 4,
|
||||
paddingBottom: badge.paddingBottom ?? 4,
|
||||
importTags: badge.importTags,
|
||||
isDefault: badge.isDefault || undefined
|
||||
isDefault: badge.isDefault || undefined,
|
||||
layout: badge.layout
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ConfigPanelProps {
|
||||
onUpdateModule: (updated: NewsletterModule) => void;
|
||||
onApplyBadgeToAll?: (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => void;
|
||||
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void;
|
||||
onSaveLayoutAsDefault?: (p: CardProduct) => void;
|
||||
// Zmena nastavení spoločných pre všetky produktové sekcie a vernostné zľavy (platnosť akcie...)
|
||||
onUpdateAllProductSections?: (patch: Record<string, unknown>) => void;
|
||||
campaignValidity?: string; // globálna platnosť akcie (pre Lištu platnosti)
|
||||
@@ -95,6 +96,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
onUpdateModule,
|
||||
onApplyBadgeToAll,
|
||||
onApplyStoresOffsetToAll,
|
||||
onSaveLayoutAsDefault,
|
||||
onUpdateAllProductSections,
|
||||
campaignValidity = '',
|
||||
onOpenBadgeManager,
|
||||
@@ -703,6 +705,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
onApplyBadgeToSection={() => applyBadgeToSection(p)}
|
||||
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'standard') : undefined}
|
||||
onApplyStoresOffsetToAll={onApplyStoresOffsetToAll}
|
||||
onSaveLayoutAsDefault={onSaveLayoutAsDefault}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -928,6 +931,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
onApplyBadgeToSection={() => applyBadgeToSection(p)}
|
||||
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'loyalty') : undefined}
|
||||
onApplyStoresOffsetToAll={onApplyStoresOffsetToAll}
|
||||
onSaveLayoutAsDefault={onSaveLayoutAsDefault}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BADGE_IMPORT_TYPES, BadgeTemplate, CardProduct, ProductLabel, ProductPh
|
||||
import { ChevronDown, ChevronUp, Trash2, Pencil, Globe, Layers } from 'lucide-react';
|
||||
import { ImageUploadInput } from './ImageUploadInput';
|
||||
import { pickAlt } from '@/lib/altText';
|
||||
import { DEFAULT_IMAGE_SCALE, DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, MAIN_PHOTO, PRODUCT_BG_PRESETS, badgeWidthFor, photoStack, productCardAlt, usesAppPrice } from '@/lib/productCard';
|
||||
import { DEFAULT_IMAGE_SCALE, DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, MAIN_PHOTO, PRODUCT_BG_PRESETS, badgeWidthFor, cardLayout, photoStack, productCardAlt, usesAppPrice } from '@/lib/productCard';
|
||||
|
||||
export { PRODUCT_BG_PRESETS };
|
||||
|
||||
@@ -33,6 +33,7 @@ interface ProductEditorProps {
|
||||
onApplyBadgeToSection?: () => void;
|
||||
onApplyBadgeToAll?: () => void;
|
||||
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void; // poloha a veľkosť štítkov pre všetky produkty
|
||||
onSaveLayoutAsDefault?: (p: CardProduct) => void; // rozloženie produktu ako predvolené pre šablóny rovnakého druhu
|
||||
}
|
||||
|
||||
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
|
||||
@@ -321,7 +322,8 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
onOpenBadgeEditor,
|
||||
onApplyBadgeToSection,
|
||||
onApplyBadgeToAll,
|
||||
onApplyStoresOffsetToAll
|
||||
onApplyStoresOffsetToAll,
|
||||
onSaveLayoutAsDefault
|
||||
}) => {
|
||||
// Po otvorení (aj klikom na produkt v náhľade) posunieme panel na tento produkt
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -347,9 +349,10 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
|
||||
const offsetX = p.imageOffsetX || 0;
|
||||
const offsetY = p.imageOffsetY || 0;
|
||||
const storesX = p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X;
|
||||
const storesY = p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y;
|
||||
const storesSize = p.storesSize ?? DEFAULT_STORES_SIZE;
|
||||
const lay = cardLayout(p);
|
||||
const storesX = lay.storesX;
|
||||
const storesY = lay.storesY;
|
||||
const storesSize = lay.storesSize;
|
||||
const hasStores = !!(p.stores?.potraviny || p.stores?.supermarket || p.stores?.tempo);
|
||||
const badgeWidth = badgeWidthFor(p, variant);
|
||||
|
||||
@@ -544,8 +547,8 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
</div>
|
||||
|
||||
<Slider label="Veľkosť" value={badgeWidth} display={`${badgeWidth} px`} min={60} max={200} step={1} onChange={set('badgeWidth')} />
|
||||
<Slider label="Vodorovne" value={p.badgeOffsetX || 0} display={`${p.badgeOffsetX || 0}`} min={-150} max={8} step={1} onChange={set('badgeOffsetX')} />
|
||||
<Slider label="Zvisle" value={p.badgeOffsetY || 0} display={`${p.badgeOffsetY || 0}`} min={-150} max={8} step={1} onChange={set('badgeOffsetY')} />
|
||||
<Slider label="Vodorovne" value={lay.offsetX} display={`${lay.offsetX}`} min={-150} max={8} step={1} onChange={set('badgeOffsetX')} />
|
||||
<Slider label="Zvisle" value={lay.offsetY} display={`${lay.offsetY}`} min={-150} max={8} step={1} onChange={set('badgeOffsetY')} />
|
||||
|
||||
{isLoyalty ? (
|
||||
<>
|
||||
@@ -582,6 +585,17 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.badgeCustomTemplate?.importTags?.type && onSaveLayoutAsDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSaveLayoutAsDefault(p)}
|
||||
className="w-full text-[10px] font-bold text-gray-700 bg-white border border-dashed border-gray-300 hover:border-gray-400 hover:bg-gray-50 rounded-lg py-1.5"
|
||||
title="Poloha cenovky, jej veľkosť a poloha a veľkosť štítkov predajní sa uložia do všetkých šablón tohto druhu"
|
||||
>
|
||||
Uložiť polohu a veľkosť ako predvolené pre všetky „{p.badgeCustomTemplate.name.split(' | ')[0]}“
|
||||
</button>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{/* DOSTUPNOSŤ */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadgeTemplate, BadgeLayer, BadgeImportTags } from '@/types/newsletter';
|
||||
import { BadgeTemplate, BadgeLayer, BadgeImportTags, BadgeLayout } 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[] = [];
|
||||
@@ -21,6 +21,7 @@ export interface BadgeRow {
|
||||
paddingBottom?: number;
|
||||
importTags?: BadgeImportTags;
|
||||
isDefault?: boolean;
|
||||
layout?: BadgeLayout;
|
||||
}
|
||||
| null;
|
||||
}
|
||||
@@ -38,7 +39,8 @@ export function badgeFromRow(d: BadgeRow): BadgeTemplate {
|
||||
paddingTop: cfg?.paddingTop ?? 4,
|
||||
paddingBottom: cfg?.paddingBottom ?? 4,
|
||||
importTags: cfg?.importTags,
|
||||
isDefault: cfg?.isDefault || undefined
|
||||
isDefault: cfg?.isDefault || undefined,
|
||||
layout: cfg?.layout
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+19
-6
@@ -103,6 +103,18 @@ function badgeData(p: CardProduct, variant: CardVariant): BadgeProductData {
|
||||
return p;
|
||||
}
|
||||
|
||||
// Poloha cenovky a štítkov predajní: vlastné nastavenie produktu, inak predvolené zo šablóny, inak pevné
|
||||
export function cardLayout(p: CardProduct) {
|
||||
const l = p.customBadgePngUrl ? undefined : p.badgeCustomTemplate?.layout;
|
||||
return {
|
||||
offsetX: p.badgeOffsetX ?? l?.offsetX ?? 0,
|
||||
offsetY: p.badgeOffsetY ?? l?.offsetY ?? 0,
|
||||
storesX: p.storesOffsetX ?? l?.storesOffsetX ?? DEFAULT_STORES_OFFSET_X,
|
||||
storesY: p.storesOffsetY ?? l?.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y,
|
||||
storesSize: p.storesSize ?? l?.storesSize ?? DEFAULT_STORES_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
export function badgeWidthFor(p: CardProduct, variant: CardVariant): number {
|
||||
if (p.badgeWidth) return p.badgeWidth;
|
||||
if (p.badgeCustomTemplate) return p.badgeCustomTemplate.width || 110;
|
||||
@@ -225,12 +237,12 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
||||
'v2', // verzia vykreslenia – zvýšiť, keď sa zmení spôsob prípravy obrázkov (v2: písma z Adobe Fonts)
|
||||
ctx.variant, ctx.cardWidth, p.cardBgColor || ctx.cardBg, ctx.variant === 'standard' ? ctx.defaultBadge : null,
|
||||
p.imageUrl, p.imageScale ?? DEFAULT_IMAGE_SCALE, p.imageOffsetX || 0, p.imageOffsetY || 0,
|
||||
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant), p.badgeOffsetX || 0, p.badgeOffsetY || 0,
|
||||
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant), cardLayout(p),
|
||||
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.appPriceMain, p.appPriceSub, p.appDiscount, p.appUnitPrice,
|
||||
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.stores,
|
||||
p.labels || [], p.extraPhotos || [], photoStack(p)
|
||||
];
|
||||
const str = JSON.stringify(relevant);
|
||||
@@ -336,9 +348,10 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
|
||||
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
|
||||
const ox = p.imageOffsetX || 0;
|
||||
const oy = p.imageOffsetY || 0;
|
||||
const sx = Math.round(p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X);
|
||||
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
|
||||
const stores = renderStores(p, ctx.mode, p.storesSize ?? DEFAULT_STORES_SIZE, bw);
|
||||
const lay = cardLayout(p);
|
||||
const sx = Math.round(lay.storesX);
|
||||
const sy = Math.round(lay.storesY);
|
||||
const stores = renderStores(p, ctx.mode, lay.storesSize, bw);
|
||||
// Pravý horný roh viditeľného pozadia cenovky (pri PNG a predvolenej cenovke = roh cenovky)
|
||||
const bgBox = p.badgeCustomTemplate && !p.customBadgePngUrl ? badgeBackgroundBox(p.badgeCustomTemplate) : null;
|
||||
const insetTop = bgBox ? Math.round(bgBox.top * bw * bgBox.aspect) : 0;
|
||||
@@ -355,7 +368,7 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
|
||||
})
|
||||
.join('')}
|
||||
${(p.labels || []).map(renderLabel).join('')}
|
||||
<div style="position: absolute; right: ${8 - (p.badgeOffsetX || 0)}px; bottom: ${8 - (p.badgeOffsetY || 0)}px; width: ${bw}px;">
|
||||
<div style="position: absolute; right: ${8 - lay.offsetX}px; bottom: ${8 - lay.offsetY}px; width: ${bw}px;">
|
||||
${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''}
|
||||
${renderBadge(p, ctx, bw)}
|
||||
</div>
|
||||
|
||||
@@ -252,6 +252,16 @@ export interface BadgeTemplate {
|
||||
paddingBottom?: number;
|
||||
importTags?: BadgeImportTags; // podľa čoho sa šablóna vyberie pri importe podkladov (ZIP)
|
||||
isDefault?: boolean; // predvolená cenovka produktov – nedá sa zmazať
|
||||
layout?: BadgeLayout; // predvolené rozloženie pre produkty s touto cenovkou (ak ho produkt nemá vlastné)
|
||||
}
|
||||
|
||||
// Predvolená poloha cenovky a štítkov predajní pre šablónu (napr. všetky „Základná“)
|
||||
export interface BadgeLayout {
|
||||
offsetX?: number; // posun cenovky vodorovne, + doprava
|
||||
offsetY?: number; // posun cenovky zvisle, + nadol
|
||||
storesOffsetX?: number; // štítky predajní ↔
|
||||
storesOffsetY?: number; // štítky predajní ↕
|
||||
storesSize?: number; // výška štítka predajne v px
|
||||
}
|
||||
|
||||
// Druhy cenoviek v podkladoch: „Zľava grafická“, a keď je prázdna, základná cenovka s výkričníkom alebo bez
|
||||
|
||||
Reference in new issue
Block a user