diff --git a/scripts/badges/README.md b/scripts/badges/README.md new file mode 100644 index 0000000..79badac --- /dev/null +++ b/scripts/badges/README.md @@ -0,0 +1,15 @@ +# Šablóny cenoviek z Illustratora + +1. **Prevod SVG** – `python scripts/badges/convert-illustrator-svg.py 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. diff --git a/scripts/badges/build-templates.ts b/scripts/badges/build-templates.ts new file mode 100644 index 0000000..b40e52d --- /dev/null +++ b/scripts/badges/build-templates.ts @@ -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: `${c.inner}`, + 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 = '
'; +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 += `
${renderCleanBadgeSvg(b, sample as any, px)}
${r.name} · ${r.layers_config.width}px
`; +} +fs.writeFileSync('public/__svg/compare.html', html + '
'); +console.log(rows.length, 'ok'); diff --git a/scripts/badges/convert-illustrator-svg.py b/scripts/badges/convert-illustrator-svg.py index fe3bd4c..3508c27 100644 --- a/scripts/badges/convert-illustrator-svg.py +++ b/scripts/badges/convert-illustrator-svg.py @@ -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 -# (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'', 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'([^<]*)', 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'(.*?)', replace_text, svg, flags=re.S) + clean = re.sub(r'(.*?)', 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'', 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, flags=re.S) - inner = re.sub(r'\s*$', '', inner) - defs = re.search(r'.*?', inner, re.S).group(0) - body = inner.replace(defs, '') + clean, original = uniq(clean), uniq(original) + inner = re.sub(r'\s*$', '', re.sub(r'^.*?]*>', '', 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}{body.strip()}', - }) - -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']]) diff --git a/scripts/badges/layers.json b/scripts/badges/layers.json new file mode 100644 index 0000000..32ed814 --- /dev/null +++ b/scripts/badges/layers.json @@ -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,72.99,32,"#1d1d1b","A",18.66,-4.66,-0.21,"",""],["{zlava}","r",138.69,66.18,16,"w","A","","","","",""],["{zlava_app}","r",138.68,10.43,16,"#1d1d1b","A","","","","",""],["zľava","l",95.8,70.15,7,"w","A","","","",-0.03,""],["zľava","l",95.79,14.4,7,"#1d1d1b","A","","","",-0.03,""],["{jednotkova_cena}","l",55.82,94.43,8.1,"#1d1d1b","K300","","","","",1.11],["Cena bez kupónu","l",84.48,83.58,9,"#1d1d1b","K700","","","","",1.11],["{jednotkova_cena_app}","l",54.13,47.35,8.1,"w","K300","","","","",1.11],["pre Appkáčov*","l",89.89,36.71,9,"w","K700","","","","",1.11]]],["Asset 36.svg","Appkáči",{"type":"ZÁKLADNÁ","origin":"","app":true},[3.5,-0.3,136.4,100.4],221,[["{cena_app}","l",32.96,21.11,46,"w","A",26.82,-7.32,-0.49,"",""],["{cena}","l",51.17,71.62,32,"#1d1d1b","A",18.66,-4.66,-0.21,"",""],["{zlava}","r",137.4,64.81,16,"w","A","","","","",""],["{zlava_app}","r",137.39,9.06,16,"#1d1d1b","A","","","","",""],["zľava","l",94.51,68.78,7,"w","A","","","",-0.03,""],["zľava","l",94.5,13.03,7,"#1d1d1b","A","","","",-0.03,""],["{jednotkova_cena}","l",54.53,93.06,8.1,"#1d1d1b","K300","","","","",1.11],["Cena bez kupónu","l",83.19,82.21,9,"#1d1d1b","K700","","","","",1.11],["{jednotkova_cena_app}","l",52.84,45.98,8.1,"w","K300","","","","",1.11],["pre Appkáčov*","l",88.6,35.34,9,"w","K700","","","","",1.11]]],["Asset 34.svg","Vernostná karta",{"loyalty":true},[18.2,42.7,113.4,104.5],184,[["{cena_s_kartou}","l",22.85,66.53,46,"w","A",26.82,-7.31,-0.49,"",""],["{cena_bez_karty}","l",41.06,117.31,32,"#1d1d1b","A",18.66,-4.66,-0.21,"",""],["{zlava_bez_karty}","r",126.9,109.12,13,"w","A","","","","",""],["{zlava_s_kartou}","r",127.29,54.63,16,"#1d1d1b","A","","","","",""],["zľava","l",91.72,111.95,6,"w","A","","","",-0.03,""],["zľava","l",84.4,58.61,7,"#1d1d1b","A","","","",-0.03,""],["{jednotkova_cena_bez_karty}","l",44.42,138.75,8.1,"#1d1d1b","K300","","","","",1.11],["bez vernostnej karty","l",61.41,127.91,9,"#1d1d1b","K700","","","","",1.11],["{jednotkova_cena_s_kartou}","l",39.78,91.05,9,"w","K300","","","","",1.11],["s vernostnou kartou","l",51.78,77.64,10.8,"w","K700","","","","",1.11]]],["Asset 35.svg","Vernostná karta | bez zľavy",{"loyalty":true},[18.2,35.7,113.6,104.7],184,[["{cena_s_kartou}","l",22.85,59.53,46,"w","A",26.82,-7.31,-0.49,"",""],["{cena_bez_karty}","l",41.06,110.31,32,"#1d1d1b","A",18.66,-4.66,-0.21,"",""],["{jednotkova_cena_s_kartou}","l",39.78,84.05,9,"w","K300","","","","",1.11],["s vernostnou kartou","l",51.78,70.64,10.8,"w","K700","","","","",1.11],["{jednotkova_cena_bez_karty}","l",44.42,131.75,8.1,"#1d1d1b","K300","","","","",1.11],["bez vernostnej karty","l",61.41,120.91,9,"#1d1d1b","K700","","","","",1.11]]]] diff --git a/scripts/badges/measure.js b/scripts/badges/measure.js new file mode 100644 index 0000000..b7f308a --- /dev/null +++ b/scripts/badges/measure.js @@ -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 = `${c.inner}`; + 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]*?( { + 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; +}; diff --git a/src/app/page.tsx b/src/app/page.tsx index d8e5c8f..444efec 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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'; diff --git a/src/components/editor/BadgeEditorModal.tsx b/src/components/editor/BadgeEditorModal.tsx index 5155134..bf53494 100644 --- a/src/components/editor/BadgeEditorModal.tsx +++ b/src/components/editor/BadgeEditorModal.tsx @@ -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 = ({ 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 = ({ 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); diff --git a/src/components/editor/ProductEditor.tsx b/src/components/editor/ProductEditor.tsx index 88de2bb..b6f69c5 100644 --- a/src/components/editor/ProductEditor.tsx +++ b/src/components/editor/ProductEditor.tsx @@ -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; @@ -330,11 +330,18 @@ export const ProductEditor: React.FC = ({ 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 = ({ {/* CENOVKA */} - +