feat: nahradenie produktov pri importe ZIP, mensia cena od 10 EUR v cenovke, prevod SVG cenoviek
- import ZIP: volba Vymazat doterajsie produkty v sekcii - vrstva ceny: longPriceScale (Cena od 10 EUR) zmensi dvojciferne eura, uciaria ostava - skript na prevod SVG z Illustratora na pozadia sablon (texty na krivky, jedinecne triedy a id, zarovnanie tela cenovky) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd67fc7020
commit
fa824c541e
6 files changed
+173
-11
No files matched your search
@@ -0,0 +1,137 @@
|
|||||||
|
# Prevod testovacích SVG cenoviek z Illustratora na pozadia šablón:
|
||||||
|
# - ceny, zľava % a „zľava“ sa odstránia (budú z nich vrstvy editora),
|
||||||
|
# - statické nápisy (SUPER / CENA / VÝHODNÉ, písmo Gilroy-Heavy) sa prevedú na krivky,
|
||||||
|
# - triedy a id dostanú jedinečnú predponu (inak by sa viaceré cenovky na stránke prebíjali),
|
||||||
|
# - obsah sa posunie tak, aby telo cenovky bolo vo všetkých šablónach na rovnakom mieste.
|
||||||
|
#
|
||||||
|
# Použitie: pip install fonttools; python convert-illustrator-svg.py <priečinok so SVG> <výstup.json>
|
||||||
|
# (Gilroy-Heavy musí byť nainštalovaný; priradenie súborov k typu/farbe/pôvodu je v ASSETS nižšie.)
|
||||||
|
# Výstup obsahuje vyčistené SVG a namerané polohy textov – z nich sa vyrobili šablóny v tabuľke badges.
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fontTools.pens.svgPathPen import SVGPathPen
|
||||||
|
from fontTools.pens.transformPen import TransformPen
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
|
||||||
|
SRC = sys.argv[1]
|
||||||
|
OUT = sys.argv[2]
|
||||||
|
GILROY = r'C:\Users\jakub\AppData\Local\Microsoft\Windows\Fonts\Gilroy-Heavy.ttf'
|
||||||
|
|
||||||
|
font = TTFont(GILROY)
|
||||||
|
glyphs = font.getGlyphSet()
|
||||||
|
cmap = font.getBestCmap()
|
||||||
|
upem = font['head'].unitsPerEm
|
||||||
|
|
||||||
|
# referenčná poloha tela cenovky (zhora ľavý roh rámika s cenou)
|
||||||
|
REF_X = 27.98
|
||||||
|
|
||||||
|
ASSETS = {
|
||||||
|
'Asset 2.svg': ('SUPER CENA', 'cervena', 'SK'),
|
||||||
|
'Asset 3.svg': ('SUPER VÝHODNÉ', 'cervena', 'SK'),
|
||||||
|
'Asset 4.svg': ('SUPER CENA', 'oranzova', 'SK'),
|
||||||
|
'Asset 5.svg': ('SUPER CENA', 'zelena', 'SK'),
|
||||||
|
'Asset 6.svg': ('SUPER CENA', 'cervena', ''),
|
||||||
|
'Asset 7.svg': ('SUPER VÝHODNÉ', 'cervena', ''),
|
||||||
|
'Asset 8.svg': ('SUPER CENA', 'oranzova', ''),
|
||||||
|
'Asset 9.svg': ('SUPER VÝHODNÉ', 'zelena', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_css(svg):
|
||||||
|
style = re.search(r'<style>(.*?)</style>', svg, re.S).group(1)
|
||||||
|
props = {}
|
||||||
|
for sel, body in re.findall(r'([^{}]+)\{([^}]*)\}', style):
|
||||||
|
decl = dict(
|
||||||
|
(k.strip(), v.strip()) for k, v in (d.split(':', 1) for d in body.split(';') if ':' in d)
|
||||||
|
)
|
||||||
|
for cls in sel.split(','):
|
||||||
|
props.setdefault(cls.strip().lstrip('.'), {}).update(decl)
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
def text_to_path(transform, runs, size, fill):
|
||||||
|
"""runs: [(x, text, letter_spacing_em)] → <path> s tou istou transformáciou ako pôvodný <text>."""
|
||||||
|
s = size / upem
|
||||||
|
pen = SVGPathPen(glyphs)
|
||||||
|
for x, txt, ls in runs:
|
||||||
|
cursor = x
|
||||||
|
for ch in txt:
|
||||||
|
name = cmap.get(ord(ch))
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
glyphs[name].draw(TransformPen(pen, (s, 0, 0, -s, cursor, 0)))
|
||||||
|
cursor += glyphs[name].width * s + ls * size
|
||||||
|
d = pen.getCommands()
|
||||||
|
return f'<path transform="{transform}" fill="{fill}" d="{d}"/>' if d else ''
|
||||||
|
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for fname, (btype, color, origin) in ASSETS.items():
|
||||||
|
svg = open(f'{SRC}/{fname}', encoding='utf-8').read()
|
||||||
|
key = 'b' + re.sub(r'\D', '', fname)
|
||||||
|
css = parse_css(svg)
|
||||||
|
|
||||||
|
texts = {} # rola → údaje
|
||||||
|
def replace_text(m):
|
||||||
|
classes = m.group(1).split()
|
||||||
|
transform = m.group(2)
|
||||||
|
body = m.group(3)
|
||||||
|
p = {}
|
||||||
|
for c in classes:
|
||||||
|
p.update(css.get(c, {}))
|
||||||
|
size = float(p.get('font-size', '0').replace('px', ''))
|
||||||
|
family = p.get('font-family', '')
|
||||||
|
fill = p.get('fill', '#000')
|
||||||
|
runs = []
|
||||||
|
for tcls, x, txt in re.findall(r'<tspan(?: class="([^"]*)")? x="([\d.\-]+)" y="[\d.\-]+">([^<]*)</tspan>', body):
|
||||||
|
ls = css.get(tcls, {}).get('letter-spacing', '0em').replace('em', '') if tcls else '0'
|
||||||
|
runs.append((float(x), txt, float(ls or 0)))
|
||||||
|
content = ''.join(r[1] for r in runs)
|
||||||
|
tr = re.match(r'translate\(([\d.\-]+) ([\d.\-]+)\)', transform)
|
||||||
|
tx, ty = float(tr.group(1)), float(tr.group(2))
|
||||||
|
if not content.strip():
|
||||||
|
return ''
|
||||||
|
if 'Gilroy' in family:
|
||||||
|
return text_to_path(transform, runs, size, fill)
|
||||||
|
# Aptly: ceny, zľava % a „zľava“ → vrstvy editora
|
||||||
|
if '%' in content:
|
||||||
|
role = 'discount'
|
||||||
|
elif content.isdigit() and size >= 25:
|
||||||
|
role = 'main'
|
||||||
|
elif content.isdigit():
|
||||||
|
role = 'cents'
|
||||||
|
else:
|
||||||
|
role = 'label'
|
||||||
|
texts[role] = {'text': content, 'x': tx, 'y': ty, 'size': size, 'fill': fill, 'runs': runs}
|
||||||
|
return ''
|
||||||
|
|
||||||
|
svg = re.sub(r'<text class="([^"]+)" transform="([^"]+)">(.*?)</text>', replace_text, svg, flags=re.S)
|
||||||
|
|
||||||
|
# posun obsahu: rámik s cenou vždy na REF_X a jeho horná hrana na rovnakej výške v rámci druhu cenovky
|
||||||
|
rects = re.findall(r'<rect class="(cls-\d+)" x="([\d.]+)" y="([\d.]+)" width="48.53" height="41.94"/>', svg)
|
||||||
|
box_x, box_y = float(rects[0][1]), float(rects[0][2])
|
||||||
|
dx = REF_X - box_x
|
||||||
|
dy = (92.67 if btype == 'SUPER CENA' else 44.98) - box_y
|
||||||
|
|
||||||
|
# jedinečné triedy a id
|
||||||
|
svg = re.sub(r'\bcls-(\d+)\b', rf'{key}-c\1', svg)
|
||||||
|
for ident in set(re.findall(r'id="([^"]+)"', svg)):
|
||||||
|
svg = re.sub(rf'(id="|url\(#|xlink:href="#){re.escape(ident)}(["\)])', rf'\1{key}-{ident}\2', svg)
|
||||||
|
|
||||||
|
inner = re.sub(r'^.*?<svg[^>]*>', '', svg, flags=re.S)
|
||||||
|
inner = re.sub(r'</svg>\s*$', '', inner)
|
||||||
|
defs = re.search(r'<defs>.*?</defs>', inner, re.S).group(0)
|
||||||
|
body = inner.replace(defs, '')
|
||||||
|
vb = [float(v) for v in re.search(r'viewBox="([^"]+)"', svg).group(1).split()]
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
'file': fname, 'key': key, 'type': btype, 'color': color, 'origin': origin,
|
||||||
|
'dx': dx, 'dy': dy, 'boxY': box_y, 'vb': vb, 'texts': texts,
|
||||||
|
'svg': f'{defs}<g transform="translate({dx:.2f} {dy:.2f})">{body.strip()}</g>',
|
||||||
|
})
|
||||||
|
|
||||||
|
json.dump(result, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
||||||
|
for r in result:
|
||||||
|
print(r['file'], r['type'], r['color'], r['origin'], 'dx', round(r['dx'], 2), 'dy', round(r['dy'], 2), 'boxY', r['boxY'], 'texts', {k: v['text'] for k, v in r['texts'].items()}, len(r['svg']))
|
||||||
+2
-2
@@ -349,7 +349,7 @@ export default function Home() {
|
|||||||
setSelectedModuleId(newMod.id);
|
setSelectedModuleId(newMod.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportProducts = (imported: ProductItem[]) => {
|
const handleImportProducts = (imported: ProductItem[], options?: { replace?: boolean }) => {
|
||||||
if (!selectedModuleId) return;
|
if (!selectedModuleId) return;
|
||||||
const target = campaign.modules.find((m) => m.id === selectedModuleId);
|
const target = campaign.modules.find((m) => m.id === selectedModuleId);
|
||||||
if (!target || target.type !== 'products_grid') {
|
if (!target || target.type !== 'products_grid') {
|
||||||
@@ -374,7 +374,7 @@ export default function Home() {
|
|||||||
...target,
|
...target,
|
||||||
data: {
|
data: {
|
||||||
...target.data,
|
...target.data,
|
||||||
products: [...(target.data.products || []), ...withLayout]
|
products: options?.replace ? withLayout : [...(target.data.products || []), ...withLayout]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
handleUpdateModule(updated);
|
handleUpdateModule(updated);
|
||||||
|
|||||||
@@ -991,6 +991,14 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
|||||||
<NumberSlider label="Veľkosť" value={layer.indexFontSize || Math.max(10, Math.round((layer.fontSize || 16) * 0.55))} min={4} max={80} onChange={(v) => updateLayer(layer.id, { indexFontSize: v })} />
|
<NumberSlider label="Veľkosť" value={layer.indexFontSize || Math.max(10, Math.round((layer.fontSize || 16) * 0.55))} min={4} max={80} onChange={(v) => updateLayer(layer.id, { indexFontSize: v })} />
|
||||||
<NumberSlider label="Hore / dole" value={layer.indexOffsetY ?? -Math.round((layer.fontSize || 16) * 0.45)} min={-60} max={30} onChange={(v) => updateLayer(layer.id, { indexOffsetY: v })} />
|
<NumberSlider label="Hore / dole" value={layer.indexOffsetY ?? -Math.round((layer.fontSize || 16) * 0.45)} min={-60} max={30} onChange={(v) => updateLayer(layer.id, { indexOffsetY: v })} />
|
||||||
<NumberSlider label="Medzera" value={layer.indexOffsetX ?? 1} min={-20} max={30} onChange={(v) => updateLayer(layer.id, { indexOffsetX: v })} />
|
<NumberSlider label="Medzera" value={layer.indexOffsetX ?? 1} min={-20} max={30} onChange={(v) => updateLayer(layer.id, { indexOffsetX: v })} />
|
||||||
|
<NumberSlider
|
||||||
|
label="Cena od 10 €"
|
||||||
|
value={Math.round((layer.longPriceScale ?? 1) * 100)}
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
suffix=" %"
|
||||||
|
onChange={(v) => updateLayer(layer.id, { longPriceScale: v >= 100 ? undefined : v / 100 })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { shrinkImage, uploadFile } from '@/lib/upload';
|
|||||||
interface ImportModalProps {
|
interface ImportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImportProducts: (products: ProductItem[]) => void;
|
onImportProducts: (products: ProductItem[], options?: { replace?: boolean }) => void;
|
||||||
availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť
|
availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
// ZIP s podkladmi z Databázy produktov (tabuľka + fotky) – pevný formát, bez mapovania stĺpcov
|
// ZIP s podkladmi z Databázy produktov (tabuľka + fotky) – pevný formát, bez mapovania stĺpcov
|
||||||
const [zip, setZip] = useState<{ products: (PodkladyProduct & { thumb: string })[]; info: string } | null>(null);
|
const [zip, setZip] = useState<{ products: (PodkladyProduct & { thumb: string })[]; info: string } | null>(null);
|
||||||
const [zipProgress, setZipProgress] = useState<string | null>(null);
|
const [zipProgress, setZipProgress] = useState<string | null>(null);
|
||||||
|
const [replaceExisting, setReplaceExisting] = useState(false); // vymazať doterajšie produkty sekcie
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
zip.products.forEach((p) => p.thumb && URL.revokeObjectURL(p.thumb));
|
zip.products.forEach((p) => p.thumb && URL.revokeObjectURL(p.thumb));
|
||||||
setZip(null);
|
setZip(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
onImportProducts(items);
|
onImportProducts(items, { replace: replaceExisting });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -340,6 +341,10 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs font-semibold text-gray-700 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={replaceExisting} onChange={(e) => setReplaceExisting(e.target.checked)} className="w-3.5 h-3.5 accent-red-600" />
|
||||||
|
Vymazať doterajšie produkty v sekcii (nahradiť novými zo ZIP-u)
|
||||||
|
</label>
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-gray-500">
|
||||||
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
|
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.
|
cenovky, štítky predajní) sa prevezme z prvého produktu v sekcii.
|
||||||
|
|||||||
+18
-7
@@ -97,6 +97,8 @@ export function badgePriceParts(token: string, product: BadgeProductData): [stri
|
|||||||
return [product.priceMain || '0', product.priceSub || '00'];
|
return [product.priceMain || '0', product.priceSub || '00'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek
|
* Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek
|
||||||
* editovacích rámčekov, tooltippov či ovládacích prvkov.
|
* editovacích rámčekov, tooltippov či ovládacích prvkov.
|
||||||
@@ -155,11 +157,21 @@ export function renderCleanBadgeSvg(
|
|||||||
// Zostavenie textových vrstiev
|
// Zostavenie textových vrstiev
|
||||||
const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => {
|
const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => {
|
||||||
const rawText = layer.text || '';
|
const rawText = layer.text || '';
|
||||||
const fontSize = layer.fontSize || 16;
|
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
||||||
const subFontSize = layer.indexFontSize || Math.max(10, Math.round(fontSize * 0.55));
|
// Cena od 10 € (dvojciferné eurá): menšie písmo, účiaria eur aj centov ostáva na mieste
|
||||||
const subDy = layer.indexOffsetY !== undefined ? `${layer.indexOffsetY}px` : '-0.45em';
|
// (0.43 ≈ vzdialenosť stredu písma od účiarie v em – vrstvy sa kreslia so strednou účiariou)
|
||||||
const subDx = layer.indexOffsetX !== undefined ? `${layer.indexOffsetX}px` : '1px';
|
const baseSize = layer.fontSize || 16;
|
||||||
const revertDy = layer.indexOffsetY !== undefined ? `${-layer.indexOffsetY}px` : '0.45em';
|
const baseSub = layer.indexFontSize || Math.max(10, Math.round(baseSize * 0.55));
|
||||||
|
const long = !!priceMatch && !!layer.longPriceScale && layer.longPriceScale < 1 && badgePriceParts(priceMatch[1], product)[0].length >= 2;
|
||||||
|
const k = long ? layer.longPriceScale! : 1;
|
||||||
|
const fontSize = round2(baseSize * k);
|
||||||
|
const subFontSize = round2(baseSub * k);
|
||||||
|
const layerY = long ? round2(layer.y + 0.43 * baseSize * (1 - k)) : layer.y;
|
||||||
|
const offY = layer.indexOffsetY !== undefined ? round2(layer.indexOffsetY - (long ? 0.43 * (1 - k) * (baseSize - baseSub) : 0)) : undefined;
|
||||||
|
const offX = layer.indexOffsetX !== undefined ? round2(layer.indexOffsetX * k) : undefined;
|
||||||
|
const subDy = offY !== undefined ? `${offY}px` : '-0.45em';
|
||||||
|
const subDx = offX !== undefined ? `${offX}px` : '1px';
|
||||||
|
const revertDy = offY !== undefined ? `${-offY}px` : '0.45em';
|
||||||
const fontWeight = layer.fontWeight || '700';
|
const fontWeight = layer.fontWeight || '700';
|
||||||
const fill = layer.fill || '#ffffff';
|
const fill = layer.fill || '#ffffff';
|
||||||
const fontFamily = layer.fontFamily
|
const fontFamily = layer.fontFamily
|
||||||
@@ -172,7 +184,6 @@ export function renderCleanBadgeSvg(
|
|||||||
|
|
||||||
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
||||||
let contentSvg = '';
|
let contentSvg = '';
|
||||||
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
|
||||||
if (priceMatch) {
|
if (priceMatch) {
|
||||||
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
||||||
const idx = priceMatch.index || 0;
|
const idx = priceMatch.index || 0;
|
||||||
@@ -189,7 +200,7 @@ export function renderCleanBadgeSvg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne
|
// data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne
|
||||||
return `<text data-layer-index="${layerIndex}" x="${layer.x}" y="${layer.y}" text-anchor="${anchor}" dominant-baseline="central" font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
return `<text data-layer-index="${layerIndex}" x="${layer.x}" y="${layerY}" text-anchor="${anchor}" dominant-baseline="central" font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export interface BadgeLayer {
|
|||||||
indexFontSize?: number; // Veľkosť horného indexu v px
|
indexFontSize?: number; // Veľkosť horného indexu v px
|
||||||
indexOffsetY?: number; // Vertikálny posun horného indexu v px (posun hore / dole)
|
indexOffsetY?: number; // Vertikálny posun horného indexu v px (posun hore / dole)
|
||||||
indexOffsetX?: number; // Horizontálny posun horného indexu v px (medzera)
|
indexOffsetX?: number; // Horizontálny posun horného indexu v px (medzera)
|
||||||
|
longPriceScale?: number; // zmenšenie ceny od 10 € (dvojciferné eurá), napr. 0.8; účiaria ostáva
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BadgeTemplate {
|
export interface BadgeTemplate {
|
||||||
|
|||||||
Reference in new issue
Block a user