feat: vsetky sablony cenoviek z novych SVG (zakladne, appkaci, vernostna karta)
- 22 sablon: Super cena / Super vyhodne / Zakladna v 3 farbach so SK aj bez,
Appkaci (SK / bez), Vernostna karta (so zlavou / bez); orezane na viditelny
obsah, rovnaka mierka (sirka = viewBox x 130 / 80,1)
- nove premenne {zlava_app}, {jednotkova_cena_app}, {cena_bez_karty},
{zlava_bez_karty}, {jednotkova_cena_s_kartou}, {jednotkova_cena_bez_karty};
zvisle natiahnutie textu (scaleY), viac nazvov pisma, napis zlava len pri zlave
- Vernostna karta je predvolena cenovka sekcie Vernostne zlavy
- produkty pri nacitani dostanu aktualnu verziu svojej sablony
- import: zlava pre appkacov a s vernostnou kartou z poznamky, jednotkova cena
pre appkacov prepocitana, sirka cenovky podla sablony
- okraj stitkov predajni podla mierky cenovky
- postup prevodu v scripts/badges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
50af94f954
commit
af35334688
13 files changed
+300
-107
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# Šablóny cenoviek z Illustratora
|
||||
|
||||
1. **Prevod SVG** – `python scripts/badges/convert-illustrator-svg.py <priečinok so SVG> public/__svg/conv.json`
|
||||
(vyžaduje `pip install fonttools` a nainštalovaný Gilroy-Heavy; súbory a ich tagy sú v `ASSETS`).
|
||||
Statické nápisy v Gilroy sa prevedú na krivky, texty v Aptly / Korolev sa vyberú na vrstvy, triedy a id
|
||||
dostanú jedinečnú predponu.
|
||||
2. **Meranie v prehliadači** – skopírovať SVG a `measure.js` do `public/__svg/`, otvoriť stránku na dev serveri
|
||||
a zavolať `await measureBadges()`: orezanie na viditeľný obsah (bez priehľadného okraja) a presné polohy
|
||||
textov s písmom Aptly z Adobe Fonts. Z výsledku vznikne `layers.json` (cena + centy → `{cena…}`,
|
||||
„%“ → `{zlava…}`, jednotková cena → `{jednotkova_cena…}`, ostatné texty ostanú pevné).
|
||||
3. **Zostavenie** – `npx tsx --tsconfig tsconfig.json scripts/badges/build-templates.ts` → `public/__svg/rows.json`
|
||||
a `compare.html` (porovnanie s originálmi), potom zápis riadkov do tabuľky `badges` (podľa názvu: existujúce
|
||||
sa aktualizujú, nové pribudnú).
|
||||
|
||||
Šírka šablóny v px = šírka orezaného viewBoxu × 130 / 80,1, takže všetky cenovky majú rovnakú mierku.
|
||||
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- pomocný skript nad JSON výstupmi z prevodu */
|
||||
// Zostavenie šablón cenoviek z výstupu convert-illustrator-svg.py (public/__svg/conv.json) a vrstiev
|
||||
// zmeraných v prehliadači (scripts/badges/layers.json, vytvára measure.js). Zapíše public/__svg/rows.json
|
||||
// (riadky tabuľky badges) a public/__svg/compare.html (porovnanie s originálmi).
|
||||
// Spustenie: npx tsx --tsconfig tsconfig.json scripts/badges/build-templates.ts [filter názvu] [mierka]
|
||||
import fs from 'fs';
|
||||
import { renderCleanBadgeSvg, badgeFromRow } from '../../src/lib/badgeUtils';
|
||||
const conv = JSON.parse(fs.readFileSync('public/__svg/conv.json', 'utf8'));
|
||||
const L = JSON.parse(fs.readFileSync('scripts/badges/layers.json', 'utf8'));
|
||||
const rows = L.map(([file, name, tags, crop, width, layers]: any) => {
|
||||
const c = conv.find((x: any) => x.file === file);
|
||||
return {
|
||||
file, name,
|
||||
svg_raw: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${crop.join(' ')}">${c.inner}</svg>`,
|
||||
layers_config: {
|
||||
width, align: 'center', paddingTop: 4, paddingBottom: 4, importTags: tags,
|
||||
layers: layers.map(([text, al, x, y, fontSize, fill, font, ifs, ioy, iox, ls, sy]: any, i: number) => ({
|
||||
id: `l-${c.key}-${i}`, type: 'text', text, align: { c: 'center', l: 'left', r: 'right' }[al as 'c'], x, y, fontSize,
|
||||
fill: fill === 'w' ? '#ffffff' : fill,
|
||||
fontFamily: font === 'A' ? 'Aptly' : 'korolev-condensed, Korolev Condensed', fontSource: 'adobe', fontWeight: font === 'A' ? '700' : font.slice(1),
|
||||
...(ifs !== '' ? { indexFontSize: ifs, indexOffsetY: ioy, indexOffsetX: iox, longPriceScale: 0.8 } : {}),
|
||||
...(ls !== '' ? { letterSpacing: ls } : {}), ...(sy !== '' ? { scaleY: sy } : {})
|
||||
})).map((l: any, _i: number, all: any[]) => {
|
||||
// nápis „zľava“ len pri vyplnenej zľave – patrí k najbližšej premennej {zlava…}
|
||||
if (l.text !== 'zľava') return l;
|
||||
const d = all.filter((x: any) => /^\{zlava/.test(x.text)).sort((a: any, b: any) => Math.abs(a.y - l.y) - Math.abs(b.y - l.y))[0];
|
||||
return d ? { ...l, showIfFilled: d.text } : l;
|
||||
})
|
||||
}
|
||||
};
|
||||
});
|
||||
fs.writeFileSync('public/__svg/rows.json', JSON.stringify(rows));
|
||||
const sample = { priceMain: '0', priceSub: '00', discountText: '00%', unitPrice: 'jednotková cena 000 EUR/kg',
|
||||
appPriceMain: '0', appPriceSub: '00', appDiscount: '00%', appUnitPrice: 'jednotková cena 000 EUR/kg',
|
||||
cardPriceMain: '0', cardPriceSub: '00', cardDiscount: '00%', cardUnitPrice: 'jednotková cena 000 EUR/kg',
|
||||
noCardPriceMain: '0', noCardPriceSub: '00', noCardDiscount: '00%', noCardUnitPrice: 'jednotková cena 000 EUR/kg' };
|
||||
const only = process.argv[2] ? new RegExp(process.argv[2]) : null;
|
||||
const scale = Number(process.argv[3] || 1);
|
||||
let html = '<!doctype html><html><head><link rel="stylesheet" href="https://use.typekit.net/loe7muj.css"></head><body style="background:#333;color:#fff;font:11px sans-serif;margin:8px"><div style="display:flex;flex-wrap:wrap;gap:12px">';
|
||||
for (const r of rows) {
|
||||
if (only && !only.test(r.name)) continue;
|
||||
const b = badgeFromRow({ id: r.file, name: r.name, svg_raw: r.svg_raw, layers_config: r.layers_config });
|
||||
const [x, y, w, h] = r.svg_raw.match(/viewBox="([^"]+)"/)![1].split(' ').map(Number);
|
||||
const px = Math.round(r.layers_config.width * scale);
|
||||
html += `<figure style="margin:0"><div style="display:flex;gap:4px;align-items:flex-start"><img src="/__svg/${r.file.replace(' ', '_')}#svgView(viewBox(${[x, y, w, h].join(',')}))" width="${px}" height="${Math.round(px * h / w)}" style="background:#555"><div style="width:${px}px;background:#555">${renderCleanBadgeSvg(b, sample as any, px)}</div></div><figcaption>${r.name} · ${r.layers_config.width}px</figcaption></figure>`;
|
||||
}
|
||||
fs.writeFileSync('public/__svg/compare.html', html + '</div></body></html>');
|
||||
console.log(rows.length, 'ok');
|
||||
@@ -1,12 +1,12 @@
|
||||
# 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.
|
||||
# Prevod SVG cenoviek z Illustratora na pozadia šablón cenoviek:
|
||||
# - texty v písmach Aptly / Korolev (ceny, zľavy, „zľava“, jednotkové ceny, popisy) sa z pozadia odstránia
|
||||
# a zapíšu sa do výstupu – vznikne z nich vrstva editora (premenná alebo pevný text),
|
||||
# - statické nápisy v písme Gilroy (SUPER / CENA / VÝHODNÉ) sa prevedú na krivky (Gilroy je nainštalovaný),
|
||||
# - triedy a id dostanú jedinečnú predponu (inak by sa viaceré cenovky na jednej stránke prebíjali).
|
||||
# Orezanie na viditeľný obsah (bez priehľadného okraja) a meranie polôh textov sa robí v prehliadači
|
||||
# (scripts/badges/README.md), lebo potrebuje vykreslenie s písmom Aptly.
|
||||
#
|
||||
# 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
|
||||
@@ -24,20 +24,30 @@ 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
|
||||
|
||||
# súbor → (názov šablóny, tagy pre import)
|
||||
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', ''),
|
||||
'Asset 10.svg': ('SUPER VÝHODNÉ', 'oranzova', 'SK'),
|
||||
'Asset 11.svg': ('SUPER VÝHODNÉ', 'oranzova', ''),
|
||||
'Asset 12.svg': ('Super cena | Červená | SK', {'type': 'SUPER CENA', 'color': 'cervena', 'origin': 'SK'}),
|
||||
'Asset 18.svg': ('Super cena | Červená', {'type': 'SUPER CENA', 'color': 'cervena', 'origin': ''}),
|
||||
'Asset 14.svg': ('Super cena | Oranžová | SK', {'type': 'SUPER CENA', 'color': 'oranzova', 'origin': 'SK'}),
|
||||
'Asset 20.svg': ('Super cena | Oranžová', {'type': 'SUPER CENA', 'color': 'oranzova', 'origin': ''}),
|
||||
'Asset 16.svg': ('Super cena | Zelená | SK', {'type': 'SUPER CENA', 'color': 'zelena', 'origin': 'SK'}),
|
||||
'Asset 22.svg': ('Super cena | Zelená', {'type': 'SUPER CENA', 'color': 'zelena', 'origin': ''}),
|
||||
'Asset 13.svg': ('Super výhodné | Červená | SK', {'type': 'SUPER VÝHODNÉ', 'color': 'cervena', 'origin': 'SK'}),
|
||||
'Asset 19.svg': ('Super výhodné | Červená', {'type': 'SUPER VÝHODNÉ', 'color': 'cervena', 'origin': ''}),
|
||||
'Asset 15.svg': ('Super výhodné | Oranžová | SK', {'type': 'SUPER VÝHODNÉ', 'color': 'oranzova', 'origin': 'SK'}),
|
||||
'Asset 21.svg': ('Super výhodné | Oranžová', {'type': 'SUPER VÝHODNÉ', 'color': 'oranzova', 'origin': ''}),
|
||||
'Asset 17.svg': ('Super výhodné | Zelená | SK', {'type': 'SUPER VÝHODNÉ', 'color': 'zelena', 'origin': 'SK'}),
|
||||
'Asset 23.svg': ('Super výhodné | Zelená', {'type': 'SUPER VÝHODNÉ', 'color': 'zelena', 'origin': ''}),
|
||||
'Asset 27.svg': ('Základná | Červená | SK', {'type': 'ZÁKLADNÁ', 'color': 'cervena', 'origin': 'SK'}),
|
||||
'Asset 28.svg': ('Základná | Červená', {'type': 'ZÁKLADNÁ', 'color': 'cervena', 'origin': ''}),
|
||||
'Asset 30.svg': ('Základná | Oranžová | SK', {'type': 'ZÁKLADNÁ', 'color': 'oranzova', 'origin': 'SK'}),
|
||||
'Asset 29.svg': ('Základná | Oranžová', {'type': 'ZÁKLADNÁ', 'color': 'oranzova', 'origin': ''}),
|
||||
'Asset 32.svg': ('Základná | Zelená | SK', {'type': 'ZÁKLADNÁ', 'color': 'zelena', 'origin': 'SK'}),
|
||||
'Asset 31.svg': ('Základná | Zelená', {'type': 'ZÁKLADNÁ', 'color': 'zelena', 'origin': ''}),
|
||||
'Asset 33.svg': ('Appkáči | SK', {'type': 'ZÁKLADNÁ', 'origin': 'SK', 'app': True}),
|
||||
'Asset 36.svg': ('Appkáči', {'type': 'ZÁKLADNÁ', 'origin': '', 'app': True}),
|
||||
'Asset 34.svg': ('Vernostná karta', {'loyalty': True}),
|
||||
'Asset 35.svg': ('Vernostná karta | bez zľavy', {'loyalty': True}),
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +55,7 @@ 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)
|
||||
)
|
||||
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
|
||||
@@ -70,70 +78,55 @@ def text_to_path(transform, runs, size, fill):
|
||||
|
||||
|
||||
result = []
|
||||
for fname, (btype, color, origin) in ASSETS.items():
|
||||
for fname, (name, tags) in ASSETS.items():
|
||||
svg = open(f'{SRC}/{fname}', encoding='utf-8').read()
|
||||
key = 'b' + re.sub(r'\D', '', fname)
|
||||
key = 'k' + re.sub(r'\D', '', fname)
|
||||
css = parse_css(svg)
|
||||
texts = []
|
||||
|
||||
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:
|
||||
for c in m.group(1).split():
|
||||
p.update(css.get(c, {}))
|
||||
transform, body = m.group(2), m.group(3)
|
||||
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)))
|
||||
if not runs:
|
||||
runs = [(0.0, re.sub(r'<[^>]+>', '', body), 0.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 text_to_path(transform, runs, size, p.get('fill', '#000'))
|
||||
tr = re.match(r'translate\(([\d.\-]+) ([\d.\-]+)\)(?: scale\(([\d.]+) ([\d.]+)\))?', transform)
|
||||
texts.append({
|
||||
'text': content, 'x': float(tr.group(1)), 'y': float(tr.group(2)),
|
||||
'scaleY': float(tr.group(4)) if tr.group(4) else 1,
|
||||
'size': size, 'fill': p.get('fill', '#000'), 'family': family,
|
||||
'weight': p.get('font-weight', ''), 'transform': transform, 'markup': m.group(0),
|
||||
})
|
||||
return ''
|
||||
|
||||
svg = re.sub(r'<text class="([^"]+)" transform="([^"]+)">(.*?)</text>', replace_text, svg, flags=re.S)
|
||||
clean = re.sub(r'<text class="([^"]+)" transform="([^"]+)">(.*?)</text>', replace_text, svg, flags=re.S)
|
||||
original = svg
|
||||
|
||||
# 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 (vo vyčistenom aj v pôvodnom SVG – pôvodné slúži na meranie textov)
|
||||
def uniq(s):
|
||||
s = re.sub(r'\bcls-(\d+)\b', rf'{key}-c\1', s)
|
||||
for ident in set(re.findall(r'id="([^"]+)"', s)):
|
||||
s = re.sub(rf'(id="|url\(#|xlink:href="#){re.escape(ident)}(["\)])', rf'\1{key}-{ident}\2', s)
|
||||
return s
|
||||
|
||||
# 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, '')
|
||||
clean, original = uniq(clean), uniq(original)
|
||||
inner = re.sub(r'</svg>\s*$', '', re.sub(r'^.*?<svg[^>]*>', '', clean, flags=re.S))
|
||||
vb = [float(v) for v in re.search(r'viewBox="([^"]+)"', svg).group(1).split()]
|
||||
result.append({'file': fname, 'key': key, 'name': name, 'tags': tags, 'vb': vb, 'texts': texts,
|
||||
'inner': inner.strip(), 'original': original})
|
||||
|
||||
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)
|
||||
json.dump(result, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False)
|
||||
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']))
|
||||
print(r['file'], r['name'], [t['text'] for t in r['texts']])
|
||||
@@ -0,0 +1 @@
|
||||
[["Asset 12.svg","Super cena | Červená | SK",{"type":"SUPER CENA","color":"cervena","origin":"SK"},[11.6,41.9,79.7,104.2],129,[["{cena}","c",52.26,115.4,38,"w","A",22.15,-5.66,-0.3,"",""],["{zlava}","r",75.09,53.3,14.5,"w","A","","","","",""],["zľava","l",30.17,56.57,8,"w","A","","","",-0.03,""]]],["Asset 18.svg","Super cena | Červená",{"type":"SUPER CENA","color":"cervena","origin":""},[11.6,41.9,79.7,95.6],129,[["{cena}","c",52.23,115.4,38,"w","A",22.15,-5.66,-0.23,"",""],["{zlava}","r",75.11,53.3,14.5,"w","A","","","","",""],["zľava","l",30.17,56.57,8,"w","A","","","",-0.03,""]]],["Asset 14.svg","Super cena | Oranžová | SK",{"type":"SUPER CENA","color":"oranzova","origin":"SK"},[11.6,41.9,79.7,104.2],129,[["{cena}","c",52.25,115.4,38,"w","A",22.15,-5.66,-0.02,"",""],["{zlava}","r",75.09,53.3,14.5,"w","A","","","","",""],["zľava","l",30.17,56.57,8,"w","A","","","",-0.03,""]]],["Asset 20.svg","Super cena | Oranžová",{"type":"SUPER CENA","color":"oranzova","origin":""},[11.6,41.9,79.7,95.8],129,[["{cena}","c",52.26,115.4,38,"w","A",22.15,-5.66,-0.3,"",""],["{zlava}","r",75.1,53.3,14.5,"w","A","","","","",""],["zľava","l",30.17,56.57,8,"w","A","","","",-0.03,""]]],["Asset 16.svg","Super cena | Zelená | SK",{"type":"SUPER CENA","color":"zelena","origin":"SK"},[19.6,41.9,79.7,104.1],129,[["{cena}","c",60.26,115.4,38,"w","A",22.15,-5.66,-0.3,"",""],["{zlava}","r",83.09,53.3,14.5,"w","A","","","","",""],["zľava","l",38.17,56.57,8,"w","A","","","",-0.03,""]]],["Asset 22.svg","Super cena | Zelená",{"type":"SUPER CENA","color":"zelena","origin":""},[11.6,41.9,79.7,95.7],129,[["{cena}","c",52.25,115.4,38,"w","A",22.15,-5.66,-0.33,"",""],["{zlava}","r",75.1,53.3,14.5,"w","A","","","","",""],["zľava","l",30.17,56.57,8,"w","A","","","",-0.03,""]]],["Asset 13.svg","Super výhodné | Červená | SK",{"type":"SUPER VÝHODNÉ","color":"cervena","origin":"SK"},[11.6,11.5,79.7,86.7],129,[["{cena}","c",52.25,67.7,38,"w","A",22.15,-5.65,-0.33,"",""]]],["Asset 19.svg","Super výhodné | Červená",{"type":"SUPER VÝHODNÉ","color":"cervena","origin":""},[3.6,4.5,79.7,78.2],129,[["{cena}","c",44.23,60.7,38,"w","A",22.15,-5.65,-0.23,"",""]]],["Asset 15.svg","Super výhodné | Oranžová | SK",{"type":"SUPER VÝHODNÉ","color":"oranzova","origin":"SK"},[11.6,11.5,79.7,86.6],129,[["{cena}","c",52.26,67.7,38,"w","A",22.15,-5.65,-0.27,"",""]]],["Asset 21.svg","Super výhodné | Oranžová",{"type":"SUPER VÝHODNÉ","color":"oranzova","origin":""},[3.6,4.5,79.7,78.3],129,[["{cena}","c",44.24,60.7,38,"w","A",22.15,-5.65,-0.13,"",""]]],["Asset 17.svg","Super výhodné | Zelená | SK",{"type":"SUPER VÝHODNÉ","color":"zelena","origin":"SK"},[11.6,11.5,79.7,86.9],129,[["{cena}","c",52.25,67.7,38,"w","A",22.15,-5.65,-0.33,"",""]]],["Asset 23.svg","Super výhodné | Zelená",{"type":"SUPER VÝHODNÉ","color":"zelena","origin":""},[3.6,4.5,79.7,78.6],129,[["{cena}","c",44.25,60.7,38,"w","A",22.15,-5.65,-0.3,"",""]]],["Asset 27.svg","Základná | Červená | SK",{"type":"ZÁKLADNÁ","color":"cervena","origin":"SK"},[16.7,34.9,54.5,68.2],88,[["{cena}","c",44.23,72.56,38,"w","A",22.15,-5.65,-0.33,"",""],["{zlava}","r",67.05,46.29,14.5,"#1d1d1b","A","","","","",""],["zľava","l",22.15,49.55,8,"#1d1d1b","A","","","",-0.03,""]]],["Asset 28.svg","Základná | Červená",{"type":"ZÁKLADNÁ","color":"cervena","origin":""},[16.7,34.9,54.8,59.5],89,[["{cena}","c",44.23,72.56,38,"w","A",22.15,-5.65,-0.33,"",""],["{zlava}","r",67.05,46.29,14.5,"#1d1d1b","A","","","","",""],["zľava","l",22.15,49.55,8,"#1d1d1b","A","","","",-0.03,""]]],["Asset 30.svg","Základná | Oranžová | SK",{"type":"ZÁKLADNÁ","color":"oranzova","origin":"SK"},[16.7,34.9,54.6,68.2],89,[["{cena}","c",44.23,72.56,38,"w","A",22.15,-5.65,-0.33,"",""],["{zlava}","r",67.05,46.29,14.5,"#1d1d1b","A","","","","",""],["zľava","l",22.15,49.55,8,"#1d1d1b","A","","","",-0.03,""]]],["Asset 29.svg","Základná | Oranžová",{"type":"ZÁKLADNÁ","color":"oranzova","origin":""},[16.7,34.9,54.7,59.4],89,[["{cena}","c",44.23,72.56,38,"w","A",22.15,-5.65,-0.33,"",""],["{zlava}","r",67.05,46.29,14.5,"#1d1d1b","A","","","","",""],["zľava","l",22.15,49.55,8,"#1d1d1b","A","","","",-0.03,""]]],["Asset 32.svg","Základná | Zelená | SK",{"type":"ZÁKLADNÁ","color":"zelena","origin":"SK"},[16.7,34.7,54.6,68.4],89,[["{cena}","c",44.23,72.56,38,"w","A",22.15,-5.65,-0.33,"",""],["{zlava}","r",67.05,46.29,14.5,"#1d1d1b","A","","","","",""],["zľava","l",22.15,49.55,8,"#1d1d1b","A","","","",-0.03,""]]],["Asset 31.svg","Základná | Zelená",{"type":"ZÁKLADNÁ","color":"zelena","origin":""},[16.7,34.7,54.8,59.8],89,[["{cena}","c",44.23,72.56,38,"w","A",22.15,-5.65,-0.33,"",""],["{zlava}","r",67.05,46.29,14.5,"#1d1d1b","A","","","","",""],["zľava","l",22.15,49.55,8,"#1d1d1b","A","","","",-0.03,""]]],["Asset 33.svg","Appkáči | SK",{"type":"ZÁKLADNÁ","origin":"SK","app":true},[4.8,1,136.4,100.5],221,[["{cena_app}","l",34.25,22.47,46,"w","A",26.82,-7.31,-0.49,"",""],["{cena}","l",52.46,7Line truncated
|
||||
@@ -0,0 +1,57 @@
|
||||
// Meranie šablón cenoviek v prehliadači (dočasný súbor): orezanie na viditeľný obsah a vrstvy z textov.
|
||||
window.measureBadges = async function () {
|
||||
await document.fonts.load('700 20px aptly');
|
||||
const C = await (await fetch('/__svg/conv.json?' + Date.now())).json();
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const out = [];
|
||||
for (const c of C) {
|
||||
// 1) viditeľný obsah pozadia (alfa kanál) → orezaný viewBox
|
||||
const [vx, vy, vw, vh] = c.vb;
|
||||
const S = 10;
|
||||
const bg = `<svg xmlns="${NS}" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vx} ${vy} ${vw} ${vh}" width="${vw * S}" height="${vh * S}">${c.inner}</svg>`;
|
||||
const img = new Image();
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(bg);
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.ceil(vw * S);
|
||||
cv.height = Math.ceil(vh * S);
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const a = ctx.getImageData(0, 0, cv.width, cv.height).data;
|
||||
let x0 = 1e9, y0 = 1e9, x1 = -1, y1 = -1;
|
||||
for (let y = 0; y < cv.height; y++)
|
||||
for (let x = 0; x < cv.width; x++)
|
||||
if (a[(y * cv.width + x) * 4 + 3] > 8) {
|
||||
if (x < x0) x0 = x;
|
||||
if (x > x1) x1 = x;
|
||||
if (y < y0) y0 = y;
|
||||
if (y > y1) y1 = y;
|
||||
}
|
||||
const pad = 0.3;
|
||||
const crop = [vx + x0 / S - pad, vy + y0 / S - pad, (x1 + 1 - x0) / S + 2 * pad, (y1 + 1 - y0) / S + 2 * pad].map((v) => Math.round(v * 100) / 100);
|
||||
|
||||
// 2) texty z pôvodného SVG zmerané s písmom Aptly
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'position:absolute;left:-5000px;top:0';
|
||||
host.innerHTML = c.original.replace(/^[\s\S]*?(<svg)/, '$1');
|
||||
document.body.appendChild(host);
|
||||
const els = [...host.querySelectorAll('text')];
|
||||
const m = c.texts.map((info) => {
|
||||
const t = els.find((x) => x.getAttribute('transform') === info.transform && x.textContent === info.text);
|
||||
const tr = t.transform.baseVal.consolidate()?.matrix || { e: 0, f: 0 };
|
||||
const n = t.getNumberOfChars();
|
||||
const e0 = t.getExtentOfChar(0);
|
||||
return {
|
||||
...info,
|
||||
start: tr.e + t.getStartPositionOfChar(0).x,
|
||||
end: tr.e + t.getEndPositionOfChar(n - 1).x,
|
||||
top: tr.f + e0.y,
|
||||
h: e0.height,
|
||||
aptly: /aptly/i.test(info.family)
|
||||
};
|
||||
});
|
||||
host.remove();
|
||||
out.push({ ...c, crop, m });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
+23
-17
@@ -79,33 +79,39 @@ export default function Home() {
|
||||
fetchBadges();
|
||||
}, [isBadgeEditorOpen]);
|
||||
|
||||
// Predvolená cenovka: produkty bez šablóny (nové, importované bez zhody, staršie s pôvodnou COOP cenovkou)
|
||||
// dostanú predvolenú šablónu z databázy
|
||||
const defaultBadge = availableBadges.find((b) => b.isDefault);
|
||||
// Cenovky produktov podľa šablón v databáze:
|
||||
// - produkty bez šablóny (nové, importované bez zhody, staršie s pôvodnou COOP cenovkou) dostanú predvolenú,
|
||||
// - produkty so šablónou, ktorá sa medzitým zmenila (napr. orezané pozadie), dostanú jej aktuálnu verziu;
|
||||
// šírku cenovky prevezmú, len ak ju používateľ ručne nezmenil (rovná sa šírke pôvodnej šablóny)
|
||||
const defaultBadge = availableBadges.find((b) => b.isDefault && !b.importTags?.loyalty);
|
||||
const loyaltyDefaultBadge = availableBadges.find((b) => b.isDefault && b.importTags?.loyalty);
|
||||
useEffect(() => {
|
||||
if (!defaultBadge) return;
|
||||
// jednorazové doplnenie chýbajúcej cenovky do dát (zmení stav len keď nejaký produkt šablónu nemá)
|
||||
if (!availableBadges.length) return;
|
||||
const byId = new Map(availableBadges.map((b) => [b.id, b]));
|
||||
const update = (p: ProductItem, def: BadgeTemplate | undefined): ProductItem => {
|
||||
if (p.customBadgePngUrl) return p;
|
||||
if (!p.badgeCustomTemplate) return def ? { ...p, badgeCustomTemplate: def, badgeId: def.id, badgeWidth: def.width } : p;
|
||||
const fresh = byId.get(p.badgeCustomTemplate.id);
|
||||
if (!fresh || JSON.stringify(fresh) === JSON.stringify(p.badgeCustomTemplate)) return p;
|
||||
const keepWidth = p.badgeWidth !== undefined && p.badgeWidth !== p.badgeCustomTemplate.width;
|
||||
return { ...p, badgeCustomTemplate: fresh, badgeId: fresh.id, badgeWidth: keepWidth ? p.badgeWidth : fresh.width };
|
||||
};
|
||||
// jednorazové doplnenie / obnovenie cenoviek v dátach (stav sa zmení, len keď je čo meniť)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCampaign((prev) => {
|
||||
let changed = false;
|
||||
const modules = prev.modules.map((m) => {
|
||||
if (m.type !== 'products_grid') return m;
|
||||
if (m.type !== 'products_grid' && m.type !== 'loyalty_club') return m;
|
||||
const def = m.type === 'products_grid' ? defaultBadge : loyaltyDefaultBadge;
|
||||
const products = m.data.products || [];
|
||||
if (!products.some((p: ProductItem) => !p.badgeCustomTemplate && !p.customBadgePngUrl)) return m;
|
||||
const next = products.map((p: ProductItem) => update(p, def));
|
||||
if (next.every((p: ProductItem, i: number) => p === products[i])) return m;
|
||||
changed = true;
|
||||
return {
|
||||
...m,
|
||||
data: {
|
||||
...m.data,
|
||||
products: products.map((p: ProductItem) =>
|
||||
p.badgeCustomTemplate || p.customBadgePngUrl ? p : { ...p, badgeCustomTemplate: defaultBadge, badgeId: defaultBadge.id }
|
||||
)
|
||||
}
|
||||
};
|
||||
return { ...m, data: { ...m.data, products: next } };
|
||||
});
|
||||
return changed ? { ...prev, modules } : prev;
|
||||
});
|
||||
}, [campaign.modules, defaultBadge]);
|
||||
}, [campaign.modules, availableBadges, defaultBadge, loyaltyDefaultBadge]);
|
||||
|
||||
// Pomocné funkcie pre fixné ukotvenie hlavičky, spodných letákov, receptov a pätičky
|
||||
const isTopModule = (m: NewsletterModule) => m.type === 'header' || m.type === 'announcement';
|
||||
|
||||
@@ -112,6 +112,12 @@ const TOKENS: Array<{ token: string; label: string }> = [
|
||||
{ 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: '{zlava_app}', label: 'Zľava v appke' },
|
||||
{ token: '{jednotkova_cena_app}', label: 'Jedn. cena v appke' },
|
||||
{ token: '{cena_bez_karty}', label: 'Cena bez karty' },
|
||||
{ token: '{zlava_bez_karty}', label: 'Zľava bez karty' },
|
||||
{ token: '{jednotkova_cena_s_kartou}', label: 'Jedn. cena s kartou' },
|
||||
{ token: '{jednotkova_cena_bez_karty}', label: 'Jedn. cena bez karty' },
|
||||
{ token: '{jednotkova_cena}', label: 'Jedn. cena' },
|
||||
{ token: '{mena}', label: '€' }
|
||||
];
|
||||
@@ -350,7 +356,14 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
||||
cardPriceSub: testPrice?.sub || currentCardPriceSub,
|
||||
cardDiscount: currentCardDiscount,
|
||||
appPriceMain: testPrice?.main || currentAppPriceMain || currentPriceMain,
|
||||
appPriceSub: testPrice?.sub || currentAppPriceSub || currentPriceSub
|
||||
appPriceSub: testPrice?.sub || currentAppPriceSub || currentPriceSub,
|
||||
appDiscount: currentDiscount,
|
||||
appUnitPrice: currentUnitPrice,
|
||||
cardUnitPrice: currentUnitPrice,
|
||||
noCardPriceMain: testPrice?.main || currentPriceMain,
|
||||
noCardPriceSub: testPrice?.sub || currentPriceSub,
|
||||
noCardDiscount: currentDiscount,
|
||||
noCardUnitPrice: currentUnitPrice
|
||||
};
|
||||
|
||||
// --- načítanie šablón -----------------------------------------------------
|
||||
@@ -682,7 +695,7 @@ export const BadgeEditorModal: React.FC<BadgeEditorModalProps> = ({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasCents = !!layer && /\{(cena|cena_s_kartou|cena_app|centy)\}/.test(layer.text || '');
|
||||
const hasCents = !!layer && /\{(cena|cena_s_kartou|cena_bez_karty|cena_app|centy)\}/.test(layer.text || '');
|
||||
const fontName = layer?.fontFamily || 'Arial Narrow';
|
||||
const isKnownFont = FONTS.some((f) => f.name === fontName);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type Variant = 'standard' | 'loyalty';
|
||||
type TextKey =
|
||||
| 'priceMain' | 'priceSub' | 'discountText'
|
||||
| 'cardPriceMain' | 'cardPriceSub' | 'cardDiscount' | 'cardUnitPrice'
|
||||
| 'appPriceMain' | 'appPriceSub'
|
||||
| 'appPriceMain' | 'appPriceSub' | 'appDiscount' | 'appUnitPrice'
|
||||
| 'noCardPriceMain' | 'noCardPriceSub' | 'noCardDiscount' | 'noCardUnitPrice';
|
||||
type Patch = Record<string, unknown>;
|
||||
|
||||
@@ -330,11 +330,18 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
lastBadgeFilter = f;
|
||||
setBadgeFilterState(f);
|
||||
};
|
||||
const filteredBadges = filterBadges(availableBadges, badgeFilter);
|
||||
useEffect(() => {
|
||||
if (open) rootRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
}, [open]);
|
||||
const isLoyalty = variant === 'loyalty';
|
||||
// vernostné produkty: najprv šablóny pre vernostnú kartu, potom ostatné; bežné produkty: bez vernostných
|
||||
const loyaltyBadges = availableBadges.filter((b) => b.importTags?.loyalty);
|
||||
const otherBadges = availableBadges.filter((b) => !b.importTags?.loyalty);
|
||||
const sectionDefault = (isLoyalty ? loyaltyBadges : otherBadges).find((b) => b.isDefault);
|
||||
const filteredBadges = isLoyalty
|
||||
? [...filterBadges(loyaltyBadges, EMPTY_BADGE_FILTER), ...filterBadges(otherBadges, badgeFilter)]
|
||||
: filterBadges(otherBadges, badgeFilter);
|
||||
const sectionBadges = isLoyalty ? availableBadges : otherBadges;
|
||||
const set = (key: string) => (v: unknown) => onChange({ [key]: v });
|
||||
|
||||
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
|
||||
@@ -500,15 +507,15 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
|
||||
{/* CENOVKA */}
|
||||
<Block title="Cenovka">
|
||||
<BadgeFilterBar filter={badgeFilter} onChange={setBadgeFilter} shown={filteredBadges.length} total={availableBadges.length} />
|
||||
<BadgeFilterBar filter={badgeFilter} onChange={setBadgeFilter} shown={filteredBadges.length} total={sectionBadges.length} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={p.customBadgePngUrl ? 'png' : p.badgeCustomTemplate?.id || (isLoyalty ? 'default' : availableBadges.find((b) => b.isDefault)?.id || '')}
|
||||
value={p.customBadgePngUrl ? 'png' : p.badgeCustomTemplate?.id || sectionDefault?.id || 'default'}
|
||||
onChange={(e) => selectBadge(e.target.value)}
|
||||
className={`${inputCls} font-medium`}
|
||||
>
|
||||
{/* bežné produkty: predvolená je šablóna označená ako predvolená (nastaví sa automaticky) */}
|
||||
{isLoyalty && <option value="default">Predvolená (bez karty / s kartou)</option>}
|
||||
{isLoyalty && !sectionDefault && <option value="default">Predvolená (bez karty / s kartou)</option>}
|
||||
{/* aktuálna šablóna ostáva v zozname, aj keď ju filter skryje alebo už bola zmazaná */}
|
||||
{p.badgeCustomTemplate && !p.customBadgePngUrl && !filteredBadges.some((b) => b.id === p.badgeCustomTemplate!.id) && (
|
||||
<option value={p.badgeCustomTemplate.id}>
|
||||
@@ -547,7 +554,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')}
|
||||
{usesAppPrice(p.badgeCustomTemplate) && priceRow('Pre appkáčov', 'appPriceMain', 'appPriceSub', 'appDiscount', 'appUnitPrice')}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BadgeTemplate, ProductItem } from '@/types/newsletter';
|
||||
import { findBadgeByCode, badgeCode } from '@/lib/badgeUtils';
|
||||
import { Upload, Check, AlertCircle, FileText, X } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { PodkladyProduct, podkladyToLoyaltyItem, podkladyToProductItem, readPodkladyZip, splitPrice } from '@/lib/podkladyZip';
|
||||
import { PodkladyProduct, pickLoyaltyBadge, podkladyToLoyaltyItem, podkladyToProductItem, readPodkladyZip, splitPrice } from '@/lib/podkladyZip';
|
||||
import { CARD_BG_BY_BADGE_COLOR } from '@/lib/productCard';
|
||||
import { shrinkImage, uploadFile } from '@/lib/upload';
|
||||
|
||||
@@ -174,7 +174,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
const small = await shrinkImage(new File([p.photo], p.photoName || `${p.ean}.png`, { type: p.photo.type || 'image/png' }));
|
||||
imageUrl = await uploadFile(small, small.name);
|
||||
}
|
||||
items.push(isLoyalty ? podkladyToLoyaltyItem(p, imageUrl, i) : podkladyToProductItem(p, imageUrl, i));
|
||||
items.push(isLoyalty ? podkladyToLoyaltyItem(p, imageUrl, i, availableBadges) : podkladyToProductItem(p, imageUrl, i));
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Import sa nepodaril: ${(err as Error).message}`);
|
||||
@@ -330,7 +330,9 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] truncate">
|
||||
{p.badge && p.badgeMissing.length ? (
|
||||
{isLoyalty ? (
|
||||
<span className="text-emerald-700">cenovka: {pickLoyaltyBadge(p, availableBadges)?.name || 'predvolená vernostná'}</span>
|
||||
) : p.badge && p.badgeMissing.length ? (
|
||||
<span className="text-amber-700">
|
||||
cenovka: {p.badge.name} – chýba {p.badgeMissing.join(' a ')} pre „{p.badgeLabel}“
|
||||
</span>
|
||||
|
||||
+27
-4
@@ -73,16 +73,28 @@ export interface BadgeProductData {
|
||||
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
|
||||
appPriceMain?: string; // cena pre appkáčov → {cena_app}
|
||||
appPriceSub?: string;
|
||||
appDiscount?: string; // → {zlava_app}
|
||||
appUnitPrice?: string; // → {jednotkova_cena_app}
|
||||
cardUnitPrice?: string; // → {jednotkova_cena_s_kartou}
|
||||
noCardPriceMain?: string; // cena bez vernostnej karty → {cena_bez_karty}
|
||||
noCardPriceSub?: string;
|
||||
noCardDiscount?: string; // → {zlava_bez_karty}
|
||||
noCardUnitPrice?: string; // → {jednotkova_cena_bez_karty}
|
||||
}
|
||||
|
||||
// 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)\}/;
|
||||
// Premenné ceny s centami v hornom indexe: {cena}, {cena_s_kartou}, {cena_bez_karty} a {cena_app}
|
||||
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou|cena_bez_karty|cena_app)\}/;
|
||||
|
||||
// Nahradenie jednoduchých textových premenných (bez horného indexu)
|
||||
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
|
||||
return text
|
||||
.replace(/{cela_suma}/g, product.priceMain || '0')
|
||||
.replace(/{zlava_s_kartou}/g, (product.cardDiscount || '').replace('-', ''))
|
||||
.replace(/{zlava_bez_karty}/g, (product.noCardDiscount || '').replace('-', ''))
|
||||
.replace(/{zlava_app}/g, (product.appDiscount || '').replace('-', ''))
|
||||
.replace(/{jednotkova_cena_s_kartou}/g, product.cardUnitPrice || product.unitPrice || '')
|
||||
.replace(/{jednotkova_cena_bez_karty}/g, product.noCardUnitPrice || '')
|
||||
.replace(/{jednotkova_cena_app}/g, product.appUnitPrice || '')
|
||||
.replace(/{zlava}/g, (product.discountText || '').replace('-', ''))
|
||||
.replace(/{jednotkova_cena}/g, product.unitPrice || '')
|
||||
.replace(/{mena}/g, '€');
|
||||
@@ -96,6 +108,9 @@ export function badgePriceParts(token: string, product: BadgeProductData): [stri
|
||||
if (token === 'cena_app') {
|
||||
return [product.appPriceMain || '', product.appPriceSub || ''];
|
||||
}
|
||||
if (token === 'cena_bez_karty') {
|
||||
return [product.noCardPriceMain || '', product.noCardPriceSub || ''];
|
||||
}
|
||||
return [product.priceMain || '0', product.priceSub || '00'];
|
||||
}
|
||||
|
||||
@@ -158,6 +173,8 @@ export function renderCleanBadgeSvg(
|
||||
|
||||
// Zostavenie textových vrstiev
|
||||
const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => {
|
||||
// napr. nápis „zľava“ len vtedy, keď má produkt zľavu
|
||||
if (layer.showIfFilled && !replaceBadgeTextVars(layer.showIfFilled, product).trim()) return '';
|
||||
const rawText = layer.text || '';
|
||||
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
||||
// Cena od 10 € (dvojciferné eurá): menšie písmo, účiaria eur aj centov ostáva na mieste
|
||||
@@ -176,8 +193,12 @@ export function renderCleanBadgeSvg(
|
||||
const revertDy = offY !== undefined ? `${-offY}px` : '0.45em';
|
||||
const fontWeight = layer.fontWeight || '700';
|
||||
const fill = layer.fill || '#ffffff';
|
||||
// písmo môže mať viac názvov oddelených čiarkou (napr. názov v Adobe Fonts a názov nainštalovaného písma)
|
||||
const fontFamily = layer.fontFamily
|
||||
? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, Arial, sans-serif`
|
||||
? `${layer.fontFamily
|
||||
.split(',')
|
||||
.map((f) => `'${f.trim().replace(/'/g, '')}'`)
|
||||
.join(', ')}, -apple-system, BlinkMacSystemFont, Arial, sans-serif`
|
||||
: `'Arial Narrow', Arial, sans-serif`;
|
||||
|
||||
let anchor = 'middle';
|
||||
@@ -205,7 +226,9 @@ export function renderCleanBadgeSvg(
|
||||
}
|
||||
|
||||
// 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="${layerY}" text-anchor="${anchor}" dominant-baseline="central"${caronDx ? ` dx="${caronDx}"` : ''}${layer.letterSpacing ? ` letter-spacing="${layer.letterSpacing}em"` : ''} 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"${
|
||||
layer.scaleY && layer.scaleY !== 1 ? ` transform="translate(${layer.x} ${layerY}) scale(1 ${layer.scaleY}) translate(${-layer.x} ${-layerY})"` : ''
|
||||
}${caronDx ? ` dx="${caronDx}"` : ''}${layer.letterSpacing ? ` letter-spacing="${layer.letterSpacing}em"` : ''} font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||||
}).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;">
|
||||
|
||||
+20
-4
@@ -12,6 +12,8 @@ export interface PodkladyProduct {
|
||||
regularPrice: number | null; // bežná cena
|
||||
cardPrice: number | null; // cena s vernostnou kartou
|
||||
appPrice: number | null; // cena pre appkáčov
|
||||
appDiscount: string; // zľava pri cene pre appkáčov (z poznámky), napr. "30%"
|
||||
cardNoteDiscount: string; // zľava pri cene s vernostnou kartou (z poznámky)
|
||||
unitPrice: string; // napr. "jednotková cena 5,39 EUR/kg"
|
||||
quantity: string; // gramáž / množstvo, napr. "1 kg"
|
||||
discount: string; // napr. "30%"
|
||||
@@ -227,6 +229,7 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
|
||||
origin: col('Pôvod'),
|
||||
formats: col('Formát'),
|
||||
deposit: col('Zálohovanie'),
|
||||
note: col('Poznámka'),
|
||||
exclaim: col('Výkričník')
|
||||
};
|
||||
if (c.ean < 0) throw new Error('Tabuľka nemá stĺpec EAN – nejde o podklady z Databázy produktov?');
|
||||
@@ -277,6 +280,9 @@ export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Prom
|
||||
? { potraviny: formats.includes('potraviny'), supermarket: formats.includes('supermarket'), tempo: formats.includes('tempo') }
|
||||
: { potraviny: true, supermarket: true, tempo: true },
|
||||
deposit: deposit ? formatEur(deposit) : '',
|
||||
// poznámka z podkladov: „zľava pri cene pre Appkáčov 30 %“, „zľava pri cene s vernostnou kartou 16 %“
|
||||
appDiscount: get(row, c.note).match(/zľava pri cene pre appkáčov\s*(\d+)\s*%/i)?.[1]?.concat('%') || '',
|
||||
cardNoteDiscount: get(row, c.note).match(/zľava pri cene s vernostnou kartou\s*(\d+)\s*%/i)?.[1]?.concat('%') || '',
|
||||
extras: get(row, c.extras)
|
||||
.split(/[;\n]/)
|
||||
.map((s) => s.trim())
|
||||
@@ -297,9 +303,19 @@ function scaleUnitPrice(unitPrice: string, from: number, to: number): string {
|
||||
|
||||
// Vernostný produkt (sekcia Vernostné zľavy): s kartou = Cena s vernostnou kartou, inak akciová cena z letáku;
|
||||
// bez karty = akciová cena (ak je zvlášť cena s kartou), inak bežná cena. Farba pozadia sa nemení.
|
||||
export function podkladyToLoyaltyItem(p: PodkladyProduct, imageUrl: string, idx: number): CardProduct {
|
||||
// Šablóna pre vernostný produkt: so zľavami, ak ich produkt má, inak bez zliav (predvolená ako záloha)
|
||||
export function pickLoyaltyBadge(p: PodkladyProduct, badges: BadgeTemplate[]): BadgeTemplate | undefined {
|
||||
const list = badges.filter((b) => b.importTags?.loyalty);
|
||||
const hasDiscount = !!(p.cardNoteDiscount || p.discount);
|
||||
const withZ = (b: BadgeTemplate) => JSON.stringify(b.layersConfig).includes('{zlava');
|
||||
return list.find((b) => withZ(b) === hasDiscount) || list.find((b) => b.isDefault) || list[0];
|
||||
}
|
||||
|
||||
export function podkladyToLoyaltyItem(p: PodkladyProduct, imageUrl: string, idx: number, badges: BadgeTemplate[] = []): CardProduct {
|
||||
const base = podkladyToProductItem(p, imageUrl, idx);
|
||||
delete base.cardBgColor;
|
||||
const lb = pickLoyaltyBadge(p, badges);
|
||||
if (lb) Object.assign(base, { badgeCustomTemplate: lb, badgeId: lb.id, badgeWidth: lb.width });
|
||||
const card = p.cardPrice ?? p.price;
|
||||
const noCard = p.cardPrice != null ? p.price : p.regularPrice;
|
||||
const c = card != null ? splitPrice(card) : null;
|
||||
@@ -313,7 +329,7 @@ export function podkladyToLoyaltyItem(p: PodkladyProduct, imageUrl: string, idx:
|
||||
discountText: '',
|
||||
cardPriceMain: c?.main || '',
|
||||
cardPriceSub: c?.sub || '',
|
||||
cardDiscount: p.discount,
|
||||
cardDiscount: p.cardNoteDiscount || p.discount,
|
||||
cardUnitPrice: p.unitPrice,
|
||||
noCardPriceMain: n?.main || '',
|
||||
noCardPriceSub: n?.sub || '',
|
||||
@@ -328,7 +344,7 @@ export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx:
|
||||
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 } : {}),
|
||||
...(p.badge ? { badgeCustomTemplate: p.badge, badgeId: p.badge.id, badgeWidth: p.badge.width } : {}),
|
||||
id: `zip-${Date.now()}-${idx}`,
|
||||
title: p.title,
|
||||
imageUrl,
|
||||
@@ -338,7 +354,7 @@ 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 } : {}),
|
||||
...(app ? { appPriceMain: app.main, appPriceSub: app.sub, appDiscount: p.appDiscount, appUnitPrice: p.price != null && p.appPrice != null ? scaleUnitPrice(p.unitPrice, p.price, p.appPrice) : '' } : {}),
|
||||
...(p.extras.length ? { labels: p.extras.map(extraLabel) } : {}),
|
||||
note: p.deposit ? `+ záloha ${p.deposit} €` : '',
|
||||
badgeText: 'zľava',
|
||||
|
||||
+11
-4
@@ -92,7 +92,12 @@ function badgeData(p: CardProduct, variant: CardVariant): BadgeProductData {
|
||||
unitPrice: p.cardUnitPrice || p.unitPrice || '',
|
||||
cardPriceMain: p.cardPriceMain,
|
||||
cardPriceSub: p.cardPriceSub,
|
||||
cardDiscount: p.cardDiscount
|
||||
cardDiscount: p.cardDiscount,
|
||||
cardUnitPrice: p.cardUnitPrice,
|
||||
noCardPriceMain: p.noCardPriceMain,
|
||||
noCardPriceSub: p.noCardPriceSub,
|
||||
noCardDiscount: p.noCardDiscount,
|
||||
noCardUnitPrice: p.noCardUnitPrice
|
||||
};
|
||||
}
|
||||
return p;
|
||||
@@ -190,9 +195,11 @@ function renderDualSticker(p: CardProduct): string {
|
||||
// Štítky predajní (Potraviny / Supermarket / Tempo) pod sebou; výška podľa veľkosti cenovky
|
||||
// Biely okraj okolo nich ako skupiny: pod štítkami je biela vrstva z rovnakých štítkov, každý o hrúbku okraja
|
||||
// väčší a so zaoblenými rohmi – kde sa štítky dotýkajú, biela splynie, takže linka ide okolo celej skupiny
|
||||
// a vonkajšie rohy sú zaoblené (polovica hrúbky linky okolo pozadia cenovky: 2,25 px pri cenovke širokej 130 px).
|
||||
// a vonkajšie rohy sú zaoblené (polovica hrúbky bielej linky okolo pozadia cenovky, podľa mierky cenovky).
|
||||
function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number, badgeWidth: number): string {
|
||||
const t = Math.round(((2.25 * badgeWidth) / 130) * 10) / 10;
|
||||
// mierka cenovky (px na jednotku SVG): biela linka pozadia má 2,78 jednotky, okraj štítkov polovicu
|
||||
const vbW = Number(p.badgeCustomTemplate?.svgRaw?.match(/viewBox=["'][^"']*?[\d.\-]+\s+[\d.\-]+\s+([\d.]+)/)?.[1]) || 80.1;
|
||||
const t = Math.round(((1.39 * badgeWidth) / vbW) * 10) / 10;
|
||||
const tags = [
|
||||
p.stores?.potraviny ? ['#e2001a', 'stit_potraviny.png', 'Potraviny'] : null,
|
||||
p.stores?.supermarket ? ['#f9890e', 'stit_supermarket.png', 'Supermarket'] : null,
|
||||
@@ -221,7 +228,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.appPriceMain, p.appPriceSub,
|
||||
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.labels || [], p.extraPhotos || [], photoStack(p)
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ProductItem {
|
||||
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
|
||||
appDiscount?: string; // zľava pri cene pre appkáčov, napr. "30%" → {zlava_app}
|
||||
appUnitPrice?: string; // jednotková cena pri cene pre appkáčov → {jednotkova_cena_app}
|
||||
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)
|
||||
@@ -231,6 +233,8 @@ export interface BadgeLayer {
|
||||
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
|
||||
letterSpacing?: number; // rozostup písmen v em (napr. -0.03 = tesnejšie)
|
||||
scaleY?: number; // zvislé natiahnutie textu (napr. 1.11 ako v návrhu z Illustratora)
|
||||
showIfFilled?: string; // vrstvu zobraziť, len ak je premenná vyplnená (napr. „zľava“ len pri {zlava})
|
||||
}
|
||||
|
||||
export interface BadgeTemplate {
|
||||
@@ -263,4 +267,5 @@ export interface BadgeImportTags {
|
||||
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“
|
||||
loyalty?: boolean; // cenovka pre sekciu Vernostné zľavy (s kartou / bez karty)
|
||||
}
|
||||
Reference in new issue
Block a user