Compare commits

...
37 Commits
Author SHA1 Message Date
Jakub_HostacnyandClaude Opus 5.5 d6465dc7b1 chore: odstraneny cerveny stvorec COOP z hornej listy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:07:08 +02:00
Jakub_HostacnyandClaude Opus 5.5 31b9d605cf feat: predvolena velkost fotky produktu 95 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:55:06 +02:00
Jakub_HostacnyandClaude Opus 5.5 afb8ae98c9 feat: predvolena poloha stitkov predajni -23 / 8, velkost 14 px
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:53:49 +02:00
Jakub_HostacnyandClaude Opus 5.5 d17c9277ff fix: sablony cenoviek len z databazy, stara kopia v prehliadaci sa zmaze
Neulozene alebo zmazane sablony z localStorage sa uz neukazuju v roletkach.
Ked sa sablonu nepodari ulozit do databazy, editor to ohlasi (predtym ju
potichu ulozil len do prehliadaca).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:53:24 +02:00
Jakub_HostacnyandClaude Opus 5.5 b747a3ba74 feat: predvolena cenovka je sablona Super vyhodne | Cervena (neda sa zmazat)
- volba Predvolena COOP cenovka v produkte zrusena; produkty bez sablony
  dostanu predvolenu sablonu automaticky
- sablony z prehliadaca (localStorage) sa pridavaju len ak este nie su
  ulozene v databaze, zmazane sablony sa uz nevracaju

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:49:27 +02:00
Jakub_HostacnyandClaude Opus 5.5 184e55ad5b chore: prazdny commit na spustenie nasadenia na Verceli
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:42:00 +02:00
Jakub_HostacnyandClaude Opus 5.5 c8543b4d9b feat: filter roletky cenoviek v produkte (typ, farba, povod, appka), zoradenie podla nazvu
Aktualna sablona ostava v zozname aj ked ju filter skryje alebo bola zmazana.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:38:24 +02:00
Jakub_HostacnyandClaude Opus 5.5 2a0b1f37ba feat: okraj stitkov predajni tensi (polovica linky cenovky) so zaoblenymi vonkajsimi rohmi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:37:05 +02:00
Jakub_HostacnyandClaude Opus 5.5 5cbe981430 feat: import ZIP s podkladmi aj do sekcie Vernostne zlavy (bez zmeny farby pozadia)
S kartou = Cena s vernostnou kartou (inak akciova cena), bez karty = akciova
(inak bezna) cena; jednotkova cena bez karty sa prepocita.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:01 +02:00
Jakub_HostacnyandClaude Opus 5.5 8660f92b5a feat: biely okraj okolo stitkov predajni ako skupiny, hrubka ako linka cenovky (4,5 px pri 130 px)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:33:43 +02:00
Jakub_HostacnyandClaude Opus 5.5 14358b228f feat: import podkladov - jednotkova cena v tvare 'jednotkova cena 8,56 EUR/l', gramaz oddelena z nazvu
Mnozstvo sa odoberie z konca nazvu v letaku (aj 'od 225 g do 250 g',
'3 x 50 g 1 bal.'); co je pred mnozstvom zo stlpca Mnozstvo, ostava v nazve.
Overene na 432 riadkoch podkladov.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:26:25 +02:00
Jakub_HostacnyandClaude Opus 5.5 e2e9a82094 feat: rozostup pismen vo vrstvach cenovky, tesnejsie pismeno za l/t/d s makcenom
Sablony Super cena: zlava a % podla navrhu (-0.03 / -0.02 em). Pridane
sablony Super vyhodne | Oranzova (| SK).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:20:44 +02:00
Jakub_HostacnyandClaude Opus 5.5 cec8764905 fix: nova produktova sekcia sa prida pod listu Akcia plati (za poslednu produktovu sekciu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:17:02 +02:00
Jakub_HostacnyandClaude Opus 5.5 fa824c541e 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>
2026-10-21 16:00:35 +02:00
Jakub_HostacnyandClaude Opus 5.5 bd67fc7020 feat: cena pre appkacov ({cena_app}), tag cenovky pre appkacov, doplnkove grafiky ako stitky
- premenna {cena_app} v cenovke a pole Pre appkacov v produkte (ked ju sablona pouziva)
- tag Appka v sablone; import da cenovku pre appkacov produktom s Cenou pre appkacov,
  bezny produkt ju nikdy nedostane
- stlpec Doplnkove grafiky sa pri importe zmeni na stitky na fotke

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:48:25 +02:00
Jakub_HostacnyandClaude Opus 5.5 11392722e2 feat: zakladne cenovky (s vykricnikom a bez) v tagoch sablon pre import
Prazdna Zlava graficka v podkladoch = zakladna cenovka; stlpec Vykricnik
rozlisi variant s vykricnikom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:12:55 +02:00
Jakub_HostacnyandClaude Opus 5.5 08fb233da0 feat: tagy sablon cenoviek pre import podkladov, pozadie karty podla farby cenovky
Sablona ma tagy Typ (SUPER CENA / SUPER VYHODNE), Farba (cervena / oranzova /
zelena) a Povod (SK / bez povodu); import ZIP-u podla nich vyberie cenovku.
SK produkt bez SK sablony dostane variant bez povodu, opacne nie.
Pribudla oranzova predvolba pozadia karty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:04:48 +02:00
Jakub_HostacnyandClaude Opus 5.5 9b1f7b60cf feat: hromadny import produktov zo ZIP-u s podkladmi (xlsx + fotky)
Tabulka z Databazy produktov sa precita bez mapovania stlpcov, fotky
<EAN>/web/*.png sa zmensia na 1000 px a nahraju. Cenovka sa hlada podla
Zlava graficka | Farba cenovky | Povod. Importovane produkty preberu
rozlozenie karty z prveho produktu v sekcii.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:57:16 +02:00
Jakub_HostacnyandClaude Opus 5.5 48fd82a77d feat: hlasenie pod polom s URL obrazka (zmensene / v poriadku / za prihlasenim)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:48:37 +02:00
Jakub_HostacnyandClaude Opus 5.5 6100a7af57 feat: obrazky sa zmensuju na 1000 px aj pri vlozeni URL a zo schranky
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:45:01 +02:00
Jakub_HostacnyandClaude Opus 5.5 e875b44f92 feat: premenovanie newslettera v zozname a vyber nazvu pri duplikovani
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:35:55 +02:00
Jakub_HostacnyandClaude Opus 5.5 17c852e2bf fix: nahravanie velkych obrazkov - priamo do Supabase cez podpisanu URL, zmensenie na 2000 px
Vercel odmietal poziadavky nad 4,5 MB (Request Entity Too Large) a bucket
berie najviac 10 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:29 +02:00
Jakub_HostacnyandClaude Opus 5.5 3ece5abf6a feat: poradie fotiek v karte ako zoznam vrstiev (hore = vpredu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:24:15 +02:00
Jakub_HostacnyandClaude Opus 5.5 533a856303 feat: viac fotiek v jednej produktovej karte (bundle)
- pri produkte + Pridat dalsiu fotku: obrazok, poloha X/Y, velkost a volba
  pred / za hlavnou fotkou; lubovolny pocet fotiek
- fotky su sucastou obrazka karty pri exporte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:12:42 +02:00
Jakub_HostacnyandClaude Opus 5.5 23f23c8903 feat: vernostne zlavy - pri neparnom pocte je prvy produkt cez cely riadok
Parny pocet: vsetky produkty po 2 v riadku. Neparny: prvy produkt sam cez
celu sirku (vacsi vizual), ostatne po 2. Export obrazkov kariet pouziva
rovnaku sirku karty ako nahlad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:03:35 +02:00
Jakub_HostacnyandClaude Opus 5.5 b13a447c64 feat: menitelny pocet produktov vo vernostnych zlavach
Tlacidlo Pridat produkt (novy produkt sa hned otvori) a mazanie produktu;
karty sa radia po 2 na riadok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:02:31 +02:00
Jakub_HostacnyandClaude Opus 5.5 2605b6281b feat: nastavitelna farba nadpisu pod bannerom
Cierna (predvolena), COOP modra, COOP cervena alebo vlastna farba.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:01:48 +02:00
Jakub_HostacnyandClaude Opus 5.5 905170f66e refactor: kod sablony cenovky vznika z nazvu
Kod pre hromadny import = nazov sablony bez diakritiky, velkymi pismenami,
medzery a interpunkcia -> _ (napr. Super cena so zlavou -> SUPER_CENA_SO_ZLAVOU).
Samostatne pole Kod v editore je nahradene zobrazenim kodu pri nazve
s upozornenim, ak rovnaky kod ma ina sablona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:53:25 +02:00
Jakub_HostacnyandClaude Opus 5.5 4510e4f999 fix: sirsi pravy panel, tlacidlo Nahrat sa neoreze
- pravy panel 440 px (predtym 384 px)
- pole s adresou obrazka sa vie zuzit, aby nevytlacalo tlacidlo Nahrat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:48:22 +02:00
Jakub_HostacnyandClaude Opus 5.5 293d9b953c feat: vlastne stitky na fotke produktu, Adobe Fonts pre celu aplikaciu
- pri produkte blok Stitky na fotke: text (<br> = novy riadok), poloha X/Y,
  velkost, natocenie, farba pozadia a textu; lubovolny pocet stitkov
- stitky su nad fotkou a pod cenovkou, pismo Aptly Bold; ulozia sa aj do
  obrazka karty pri exporte
- NEXT_PUBLIC_ADOBE_FONTS_URL: odkaz na web projekt Adobe Fonts sa nacita
  v celej aplikacii (Aptly pre cenovky a stitky)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:47:42 +02:00
Jakub_HostacnyandClaude Opus 5.5 332ab833e9 feat: kod sablony cenovky pre hromadny import
- sablona cenovky ma kod (napr. SUPER_CENA), nastavuje sa v hlavicke editora
  cenoviek; kontrola jedinecnosti, ulozi sa v layers_config (bez zmeny DB)
- import CSV/JSON/XML: novy stlpec Cenovka (kod sablony), automaticky
  rozpoznany (cenovka/badge/sablona); pouzije sa kod alebo nazov sablony,
  bez ohladu na velke pismena a diakritiku
- nahlad importu ukazuje priradenu sablonu, neznamy kod nahlasi upozornenie
- spolocne nacitanie sablon z DB (badgeFromRow) pre editor aj stranku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:41:39 +02:00
Jakub_HostacnyandClaude Opus 5.5 6d73660cac feat: velkost stitkov predajni nezavisla od velkosti cenovky
- novy posuvnik Velkost stitkov pri produkte (predvolene 14 px)
- Polohu a velkost stitkov pouzit na vsetky produkty prenesie aj velkost

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:36:48 +02:00
Jakub_HostacnyandClaude Opus 5.5 8e4ad03af9 fix: editor cenoviek - bez predvolenej sirky, farba textu ciernabiela/HEX
- odstraneny posuvnik Predvolena sirka (velkost cenovky sa nastavuje pri produkte)
- farba textu: ciernabiela alebo vlastny HEX kod; pole sleduje aj zmeny
  zvonka (krok spat, vyber ciernej/bielej)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:03:44 +02:00
Jakub_HostacnyandClaude Opus 5.5 5add629bb5 feat: novy editor cenoviek - skutocna cenovka na platne, pohodlnejsia praca
- platno ukazuje skutocne SVG cenovky (rovnake ako v e-maile); vrstvy sa
  vyberaju a tahaju priamo na nej v suradniciach cenovky, s prichytenim na stred
- klavesnica: sipky posun (Shift = viac), Delete, Ctrl+Z / Ctrl+Y, Ctrl+S
- historia krokov (spat / znova), indikator neulozenych zmien, potvrdenie
  pri zatvoreni alebo prepnuti sablony
- prehladny panel: pozadie a predvolena sirka, vrstvy s rychlym pridanim,
  vybrana vrstva (text s klikatelnymi premennymi, pismo vratane Google/Adobe,
  rez, velkost, COOP farby, zarovnanie, X/Y, centy)
- skusobna cena na overenie dlhsich cien; Ulozit a pouzit na produkt
- odstranene nastavenia zarovnania a odsadenia (pri karte v style letaku
  nemaju vplyv)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:57:35 +02:00
Jakub_HostacnyandClaude Opus 5.5 419ad580ca feat: ziara tlacidla sa prisposobi farbe tlacidla
Tien tlacidiel v banneroch a letakoch sa odvodi z nastavenej farby
tlacidla namiesto napevno cervenej / modrej.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:50:35 +02:00
Jakub_HostacnyandClaude Opus 5.5 44f5ea822e feat: zalomenie riadku <br> aj v jednotkovej cene, gramazi a poznamke
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:49:33 +02:00
Jakub_HostacnyandClaude Opus 5.5 061e647180 feat: mazanie pripomienok v editore aj na stranke s odkazom
- editor: tlacidlo Zmazat v bubline pripomienky na platne
- odkaz: Zmazat v bubline aj ikona kosa v zozname pripomienok
- pred zmazanim potvrdenie (zmizne pre vsetkych s odkazom)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:44:59 +02:00
22 changed files with 2739 additions and 1512 deletions

No files matched your search

+5
View File
@@ -6,6 +6,11 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# service_role kľúč – len na serveri (nahrávanie obrázkov), nikdy ho nedávajte do kódu pre prehliadač
SUPABASE_SERVICE_ROLE_KEY=
# Adobe Fonts (voliteľné): odkaz na CSS web projektu z fonts.adobe.com (Web Projects → Embed),
# napr. https://use.typekit.net/abc1234.css – načíta písma ako Aptly pre cenovky a štítky.
# V Adobe projekte treba povoliť domény (localhost aj produkčnú).
NEXT_PUBLIC_ADOBE_FONTS_URL=
# Brevo (SMTP & API → API Keys)
BREVO_API_KEY=
BREVO_SENDER_NAME=COOP Jednota
+139
View File
@@ -0,0 +1,139 @@
# 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', ''),
'Asset 10.svg': ('SUPER VÝHODNÉ', 'oranzova', 'SK'),
'Asset 11.svg': ('SUPER VÝHODNÉ', 'oranzova', ''),
}
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']))
+1 -1
View File
@@ -12,7 +12,7 @@ export async function GET(request: Request) {
const res = await fetch(target, { redirect: 'follow' });
const contentType = res.headers.get('content-type') || '';
if (!res.ok || !contentType.startsWith('image/')) {
return Response.json({ error: `Obrázok sa nepodarilo načítať (${res.status}).` }, { status: 502 });
return Response.json({ error: `Obrázok sa nepodarilo načítať (${res.status}).`, upstreamStatus: res.status }, { status: 502 });
}
const body = await res.arrayBuffer();
if (body.byteLength > MAX_BYTES) {
+14 -26
View File
@@ -1,42 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSupabaseAdmin } from '@/lib/supabase';
const BUCKET = 'newsletter-assets';
// Vydá podpísanú adresu na nahratie súboru priamo z prehliadača do Supabase Storage.
// Samotný súbor cez server nejde – Vercel by odmietol požiadavky nad 4,5 MB („Request Entity Too Large“).
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Súbor nebol poskytnutý.' }, { status: 400 });
const { name } = (await req.json()) as { name?: string };
if (!name) {
return NextResponse.json({ error: 'Chýba názov súboru.' }, { status: 400 });
}
const admin = getSupabaseAdmin();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_');
const path = `uploads/${Date.now()}-${safeName}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const { error: uploadError } = await admin.storage
.from('newsletter-assets')
.upload(path, buffer, {
contentType: file.type || 'image/png',
upsert: true
});
if (uploadError) {
console.error('Upload to Supabase Storage error:', uploadError);
return NextResponse.json({ error: uploadError.message }, { status: 500 });
const { data, error } = await admin.storage.from(BUCKET).createSignedUploadUrl(path);
if (error || !data) {
console.error('Signed upload URL error:', error);
return NextResponse.json({ error: error?.message || 'Nepodarilo sa pripraviť nahratie' }, { status: 500 });
}
const { data: pubData } = admin.storage
.from('newsletter-assets')
.getPublicUrl(path);
const { data: pubData } = admin.storage.from(BUCKET).getPublicUrl(path);
return NextResponse.json({
success: true,
url: pubData.publicUrl
});
return NextResponse.json({ success: true, path, token: data.token, url: pubData.publicUrl });
} catch (err: any) {
console.error('API /upload error:', err);
return NextResponse.json({ error: err.message || 'Chyba servera pri nahrávaní súboru' }, { status: 500 });
+2
View File
@@ -26,6 +26,8 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<head>
{/* Písmo e-mailu (Poppins) – náhľad v editore aj na stránke s pripomienkami */}
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800;900&display=swap" />
{/* Adobe Fonts (napr. Aptly pre cenovky a štítky) – odkaz na web projekt z fonts.adobe.com */}
{process.env.NEXT_PUBLIC_ADOBE_FONTS_URL && <link rel="stylesheet" href={process.env.NEXT_PUBLIC_ADOBE_FONTS_URL} />}
</head>
<body className="min-h-full flex flex-col">{children}</body>
</html>
+102 -48
View File
@@ -5,13 +5,14 @@ import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign';
import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer';
import { supabase } from '@/lib/supabase';
import { DEFAULT_BADGE_TEMPLATES } from '@/lib/badgeUtils';
import { DEFAULT_BADGE_TEMPLATES, BadgeRow, badgeFromRow } from '@/lib/badgeUtils';
import { getBrevoCampaignUrl } from '@/lib/brevo';
import { TopBar } from '@/components/editor/TopBar';
import { ConfigPanel } from '@/components/editor/ConfigPanel';
import { ProductImportModal } from '@/components/editor/ProductImportModal';
import { BadgeEditorModal } from '@/components/editor/BadgeEditorModal';
import { BadgeEditorModal, clearLocalBadges } from '@/components/editor/BadgeEditorModal';
import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal';
import { NameDialog } from '@/components/editor/NameDialog';
import JSZip from 'jszip';
import { prepareCardImages } from '@/lib/cardImages';
import { useCampaignReview } from '@/components/review/useCampaignReview';
@@ -42,6 +43,7 @@ export default function Home() {
const [isImportOpen, setIsImportOpen] = useState(false);
const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false);
const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false);
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
const [targetProductIdForBadge, setTargetProductIdForBadge] = useState<string | null>(null);
const [availableBadges, setAvailableBadges] = useState<BadgeTemplate[]>(DEFAULT_BADGE_TEMPLATES);
@@ -56,39 +58,14 @@ export default function Home() {
.order('created_at', { ascending: false });
if (data && !error && data.length > 0) {
loaded = data.map((d: any) => {
const isObj = d.layers_config && !Array.isArray(d.layers_config);
return {
id: d.id,
name: d.name,
svgRaw: d.svg_raw || '',
layersConfig: isObj ? (d.layers_config.layers || []) : (d.layers_config || []),
width: isObj ? (d.layers_config.width || 110) : 110,
align: isObj ? (d.layers_config.align || 'center') : 'center',
paddingTop: isObj ? (d.layers_config.paddingTop ?? 4) : 4,
paddingBottom: isObj ? (d.layers_config.paddingBottom ?? 4) : 4
};
});
loaded = (data as BadgeRow[]).map(badgeFromRow);
}
} catch (e) {
console.warn('Error fetching badges in page:', e);
}
try {
const local = localStorage.getItem('coop_custom_badges');
if (local) {
const parsed = JSON.parse(local);
if (Array.isArray(parsed)) {
const existingIds = new Set(loaded.map((b) => b.id));
for (const b of parsed) {
if (!existingIds.has(b.id)) {
loaded.push(b);
existingIds.add(b.id);
}
}
}
}
} catch (e) {}
// šablóny sú len v databáze; starú kópiu z prehliadača zmažeme
clearLocalBadges();
const all = [...loaded];
const existingIds = new Set(all.map((b) => b.id));
@@ -102,6 +79,34 @@ 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);
useEffect(() => {
if (!defaultBadge) return;
// jednorazové doplnenie chýbajúcej cenovky do dát (zmení stav len keď nejaký produkt šablónu nemá)
// 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;
const products = m.data.products || [];
if (!products.some((p: ProductItem) => !p.badgeCustomTemplate && !p.customBadgePngUrl)) 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 changed ? { ...prev, modules } : prev;
});
}, [campaign.modules, defaultBadge]);
// 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';
@@ -349,7 +354,13 @@ export default function Home() {
setCampaign((prev) => {
const firstBottomIdx = prev.modules.findIndex((m) => isBottomModule(m));
const newModules = [...prev.modules];
if (firstBottomIdx !== -1) {
// Produktová sekcia ide za poslednú existujúcu produktovú sekciu, a keď žiadna nie je, hneď pod lištu „Akcia platí“
const lastProductsIdx = prev.modules.map((m) => m.type).lastIndexOf('products_grid');
const validityIdx = prev.modules.findIndex((m) => m.type === 'validity_bar');
const productsAt = lastProductsIdx !== -1 ? lastProductsIdx + 1 : validityIdx !== -1 ? validityIdx + 1 : -1;
if (newMod.type === 'products_grid' && productsAt !== -1) {
newModules.splice(productsAt, 0, newMod);
} else if (firstBottomIdx !== -1) {
newModules.splice(firstBottomIdx, 0, newMod);
} else {
newModules.push(newMod);
@@ -359,19 +370,35 @@ export default function Home() {
setSelectedModuleId(newMod.id);
};
const handleImportProducts = (imported: ProductItem[]) => {
const handleImportProducts = (imported: ProductItem[], options?: { replace?: boolean }) => {
if (!selectedModuleId) return;
const target = campaign.modules.find((m) => m.id === selectedModuleId);
if (!target || target.type !== 'products_grid') {
if (!target || (target.type !== 'products_grid' && target.type !== 'loyalty_club')) {
alert('Pre import prosím najskôr označte produktovú sekciu.');
return;
}
// Rozloženie karty (cenovka, štítky predajní, farba) prevezmú nové produkty z prvého produktu v sekcii
const first: ProductItem | undefined = target.data.products?.[0];
const layoutKeys = [
'badgeWidth', 'badgeAlign', 'badgeLayout', 'badgePaddingTop', 'badgePaddingBottom',
'storesOffsetX', 'storesOffsetY', 'storesSize', 'cardBgColor'
] as const;
const withLayout = imported.map((p) => {
if (!first) return p;
const inherited: Partial<ProductItem> = {};
for (const k of layoutKeys) {
if (k === 'cardBgColor' && target.type === 'loyalty_club') continue; // vernostné zľavy: farba pozadia ostáva
if (p[k] === undefined && first[k] !== undefined) Object.assign(inherited, { [k]: first[k] });
}
return { ...inherited, ...p };
});
const updated = {
...target,
data: {
...target.data,
products: [...(target.data.products || []), ...imported]
products: options?.replace ? withLayout : [...(target.data.products || []), ...withLayout]
}
};
handleUpdateModule(updated);
@@ -424,10 +451,9 @@ export default function Home() {
};
// Duplikácia aktuálnej kampane v Supabase
const handleDuplicateCurrentCampaign = async () => {
const handleDuplicateCurrentCampaign = async (newTitle: string) => {
setIsSaving(true);
try {
const newTitle = `${campaign.title} (kópia)`;
const { data, error } = await supabase
.from('templates')
.insert([
@@ -451,7 +477,7 @@ export default function Home() {
status: 'draft',
modules: campaign.modules
});
alert(`Kampaň bola zduplikovaná ako "${newTitle}"!`);
setIsDuplicateOpen(false);
}
} catch (err: any) {
alert(`Chyba pri duplikovaní: ${err.message}`);
@@ -607,13 +633,13 @@ export default function Home() {
};
// Poloha štítkov predajní pre všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
const handleApplyStoresOffsetToAll = (x: number, y: number) => {
if (!confirm(`Použiť polohu štítkov (${x} / ${y}) pre všetky produkty v newsletteri?`)) return;
const handleApplyStoresOffsetToAll = (x: number, y: number, size: number) => {
if (!confirm(`Použiť polohu (${x} / ${y}) a veľkosť (${size} px) štítkov pre všetky produkty v newsletteri?`)) return;
setCampaign((prev) => ({
...prev,
modules: prev.modules.map((m) =>
m.type === 'products_grid' || m.type === 'loyalty_club'
? { ...m, data: { ...m.data, products: (m.data.products || []).map((p: ProductItem) => ({ ...p, storesOffsetX: x, storesOffsetY: y })) } }
? { ...m, data: { ...m.data, products: (m.data.products || []).map((p: ProductItem) => ({ ...p, storesOffsetX: x, storesOffsetY: y, storesSize: size })) } }
: m
)
}));
@@ -674,7 +700,7 @@ export default function Home() {
onSave={handleManualSave}
onUpdateTitle={(title) => setCampaign((prev) => ({ ...prev, title }))}
onOpenSavedCampaigns={() => setIsSavedCampaignsOpen(true)}
onDuplicateCampaign={handleDuplicateCurrentCampaign}
onDuplicateCampaign={() => setIsDuplicateOpen(true)}
onExportZip={handleExportZip}
onExportBrevo={handleExportBrevo}
isExportingBrevo={isExportingBrevo}
@@ -928,15 +954,29 @@ export default function Home() {
selectedId={selectedCommentId}
onSelect={setSelectedCommentId}
renderActions={(c: ReviewComment) => (
<button
<>
<button
type="button"
onClick={() => reviews.toggleDone(c)}
className={`text-[11px] font-bold px-2.5 py-1 rounded-lg ${
c.done ? 'bg-gray-100 text-gray-700' : 'bg-emerald-600 text-white hover:bg-emerald-700'
}`}
>
{c.done ? 'Vrátiť ako neurobené' : '✓ Označiť ako hotové'}
</button>
<button
type="button"
onClick={() => reviews.toggleDone(c)}
className={`text-[11px] font-bold px-2.5 py-1 rounded-lg ${
c.done ? 'bg-gray-100 text-gray-700' : 'bg-emerald-600 text-white hover:bg-emerald-700'
}`}
onClick={() => {
if (confirm('Zmazať túto pripomienku? Zmizne aj pre ostatných s odkazom.')) {
reviews.remove(c);
setSelectedCommentId(null);
}
}}
className="text-[11px] font-bold px-2.5 py-1 rounded-lg text-red-700 bg-red-50 hover:bg-red-100"
>
{c.done ? 'Vrátiť ako neurobené' : '✓ Označiť ako hotové'}
Zmazať
</button>
</>
)}
/>
)}
@@ -944,7 +984,7 @@ export default function Home() {
</main>
{/* Pravý konfiguračný panel */}
<aside className="w-96 flex-shrink-0 h-full bg-white shadow-2xl z-20">
<aside className="w-[440px] flex-shrink-0 h-full bg-white shadow-2xl z-20">
<ConfigPanel
module={selectedModule}
selectedProductId={selectedProductId}
@@ -992,6 +1032,8 @@ export default function Home() {
isOpen={isImportOpen}
onClose={() => setIsImportOpen(false)}
onImportProducts={handleImportProducts}
availableBadges={availableBadges}
variant={campaign.modules.find((m) => m.id === selectedModuleId)?.type === 'loyalty_club' ? 'loyalty' : 'standard'}
/>
{/* Modál Editora cenoviek (SVG -> PNG) */}
@@ -1006,11 +1048,23 @@ export default function Home() {
currentCardPriceMain={targetProduct?.cardPriceMain || undefined}
currentCardPriceSub={targetProduct?.cardPriceSub || undefined}
currentCardDiscount={targetProduct?.cardDiscount || undefined}
currentAppPriceMain={targetProduct?.appPriceMain || undefined}
currentAppPriceSub={targetProduct?.appPriceSub || undefined}
/>
{/* Modál Moje newslettre (Ukladanie, duplikácia, vytváranie a prepínanie) */}
<NameDialog
open={isDuplicateOpen}
title="Duplikovať newsletter"
initial={campaign.title || ''}
confirmText="Vytvoriť kópiu"
onConfirm={handleDuplicateCurrentCampaign}
onCancel={() => setIsDuplicateOpen(false)}
/>
<SavedCampaignsModal
isOpen={isSavedCampaignsOpen}
onRenameCurrent={(title) => setCampaign((prev) => ({ ...prev, title }))}
onClose={() => setIsSavedCampaignsOpen(false)}
currentCampaign={campaign}
onLoadCampaign={(selected: NewsletterCampaign) => {
+46 -7
View File
@@ -3,7 +3,7 @@
// Náhľad newslettra na pripomienky (zdieľaný odkaz). Nič sa tu nedá upravovať – len komentovať,
// písať text priamo do newslettra a kresliť. Pripomienky sa ukladajú k odkazu, vidí ich každý s odkazom.
import React, { use, useCallback, useEffect, useRef, useState } from 'react';
import { MessageCircle, Type, Pencil, MousePointer2, CheckCircle2, Loader2 } from 'lucide-react';
import { MessageCircle, Type, Pencil, MousePointer2, CheckCircle2, Loader2, Trash2 } from 'lucide-react';
import { renderSingleModuleHtml } from '@/lib/emailRenderer';
import {
Review,
@@ -12,7 +12,8 @@ import {
REVIEW_COLORS,
getReviewByToken,
listReviewComments,
addReviewComment
addReviewComment,
deleteReviewComment
} from '@/lib/reviews';
import { AnnotationLayer, pointToModuleCoords, useModuleLayout } from '@/components/review/AnnotationLayer';
import { NewsletterCampaign } from '@/types/newsletter';
@@ -124,6 +125,18 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
}
};
// Zmazanie pripomienky (zmizne pre všetkých s odkazom aj v editore)
const removeComment = async (c: ReviewComment) => {
if (!confirm('Zmazať túto pripomienku? Zmizne aj pre ostatných s odkazom.')) return;
try {
await deleteReviewComment(c.id);
setComments((prev) => prev.filter((x) => x.id !== c.id));
if (selectedId === c.id) setSelectedId(null);
} catch (e) {
alert(e instanceof Error ? e.message : 'Pripomienku sa nepodarilo zmazať.');
}
};
// Klik do newslettra: komentár / text → okienko na napísanie
const onCanvasClick = (e: React.MouseEvent) => {
if (tool !== 'comment' && tool !== 'text') return;
@@ -246,7 +259,21 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
/>
))}
<AnnotationLayer layout={layout} comments={comments} selectedId={selectedId} onSelect={setSelectedId} />
<AnnotationLayer
layout={layout}
comments={comments}
selectedId={selectedId}
onSelect={setSelectedId}
renderActions={(c) => (
<button
type="button"
onClick={() => removeComment(c)}
className="flex items-center gap-1 text-[11px] font-bold px-2.5 py-1 rounded-lg text-red-700 bg-red-50 hover:bg-red-100"
>
<Trash2 className="w-3 h-3" /> Zmazať
</button>
)}
/>
{/* Plocha na pridávanie pripomienok */}
{tool !== 'view' && (
@@ -338,11 +365,12 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
{comments.map((c) => {
const number = comments.filter((x) => x.kind === 'comment').indexOf(c) + 1;
return (
<button
<div
key={c.id}
type="button"
role="button"
tabIndex={0}
onClick={() => setSelectedId(c.id === selectedId ? null : c.id)}
className={`w-full text-left rounded-lg border p-2 transition-colors ${
className={`group w-full text-left rounded-lg border p-2 transition-colors cursor-pointer ${
c.id === selectedId ? 'border-gray-900 bg-gray-50' : 'border-gray-200 hover:border-gray-300'
} ${c.done ? 'opacity-60' : ''}`}
>
@@ -353,11 +381,22 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
{c.done && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-600 shrink-0" />}
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{new Date(c.created_at).toLocaleDateString('sk-SK')}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeComment(c);
}}
className="text-gray-300 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Zmazať pripomienku"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<p className="mt-1 text-xs text-gray-700 whitespace-pre-wrap line-clamp-3">
{c.text || (c.kind === 'drawing' ? 'Kresba' : '')}
</p>
</button>
</div>
);
})}
</div>
File diff suppressed because it is too large. Load diff
+73 -7
View File
@@ -15,7 +15,7 @@ interface ConfigPanelProps {
module: NewsletterModule | null;
onUpdateModule: (updated: NewsletterModule) => void;
onApplyBadgeToAll?: (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => void;
onApplyStoresOffsetToAll?: (x: number, y: number) => void;
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void;
// Zmena nastavení spoločných pre všetky produktové sekcie a vernostné zľavy (platnosť akcie...)
onUpdateAllProductSections?: (patch: Record<string, unknown>) => void;
campaignValidity?: string; // globálna platnosť akcie (pre Lištu platnosti)
@@ -429,6 +429,33 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
title="Enter = nový riadok (alebo <br>)"
className="w-full text-sm border border-gray-300 rounded-lg p-2 resize-y"
/>
{/* Farba nadpisu */}
<div className="mt-1.5 flex items-center gap-1.5">
<span className="text-[10px] font-semibold text-gray-500">Farba nadpisu</span>
{[
['#1a1a1a', 'Čierna (predvolená)'],
['#003399', 'COOP modrá'],
['#e2001a', 'COOP červená']
].map(([c, name]) => (
<button
key={c}
type="button"
title={name}
onClick={() => updateData({ titleColor: c === '#1a1a1a' ? undefined : c })}
className={`w-5 h-5 rounded-full border ${
(module.data.titleColor || '#1a1a1a').toLowerCase() === c ? 'ring-2 ring-red-500 border-white' : 'border-black/15'
}`}
style={{ backgroundColor: c }}
/>
))}
<input
type="color"
value={module.data.titleColor || '#1a1a1a'}
onChange={(e) => updateData({ titleColor: e.target.value })}
className="w-6 h-6 rounded cursor-pointer border border-gray-300 p-0"
title="Vlastná farba"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Popis</label>
@@ -553,7 +580,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
</div>
{/* Rýchle predvoľby */}
<div className="grid grid-cols-3 gap-1.5">
<div className="grid grid-cols-4 gap-1.5">
{PRODUCT_BG_PRESETS.map((preset) => {
const currentBg = (module.data.cardBgColor || '#feeaea').toLowerCase();
const isSelected = currentBg === preset.color.toLowerCase();
@@ -765,7 +792,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
</div>
{/* Rýchle predvoľby */}
<div className="grid grid-cols-3 gap-1.5">
<div className="grid grid-cols-4 gap-1.5">
{PRODUCT_BG_PRESETS.map((preset) => {
const currentBg = (module.data.cardBgColor || '#e6e1d9').toLowerCase();
const isSelected = currentBg === preset.color.toLowerCase();
@@ -843,11 +870,49 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
</div>
</div>
{/* Vernostné produkty */}
{/* Vernostné produkty (ľubovoľný počet, 2 na riadok) */}
<div className="space-y-2 pt-2">
<span className="text-xs font-bold text-gray-700 block">
Vernostné produkty ({module.data.products?.length || 0})
</span>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-700">
Vernostné produkty ({module.data.products?.length || 0})
</span>
{onOpenImport && (
<button
onClick={onOpenImport}
className="ml-auto mr-1.5 text-xs font-bold text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-2.5 py-1 rounded-lg transition-colors"
>
Import ZIP
</button>
)}
<button
onClick={() => {
const newP: CardProduct = {
id: `lp-${Date.now()}`,
title: 'Nový vernostný produkt',
imageUrl: `${SUPABASE_ASSETS_BASE}vernostna_karta-produkt_1.png`,
priceMain: '',
priceSub: '',
unitPrice: '',
discountText: '',
badgeText: 'zľava',
noCardDiscount: '20%',
noCardPriceMain: '0',
noCardPriceSub: '99',
noCardUnitPrice: '',
cardDiscount: '30%',
cardPriceMain: '0',
cardPriceSub: '79',
cardUnitPrice: '',
stores: { potraviny: true, supermarket: true, tempo: true }
};
updateData({ products: [...(module.data.products || []), newP] });
onSelectProduct?.(newP.id);
}}
className="text-xs font-bold text-white bg-gray-900 hover:bg-black px-2.5 py-1 rounded-lg flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Pridať produkt
</button>
</div>
{(module.data.products || []).map((p: CardProduct, pIdx: number) => (
<ProductEditor
key={p.id || pIdx}
@@ -858,6 +923,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
onToggle={() => onSelectProduct?.(selectedProductId === p.id ? null : p.id)}
availableBadges={availableBadges || []}
onChange={(patch) => updateProduct(pIdx, patch)}
onDelete={() => updateData({ products: (module.data.products || []).filter((_: CardProduct, i: number) => i !== pIdx) })}
onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined}
onApplyBadgeToSection={() => applyBadgeToSection(p)}
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'loyalty') : undefined}
+71 -22
View File
@@ -2,6 +2,7 @@
import React, { useRef, useState } from 'react';
import { Upload, Loader2, Image as ImageIcon } from 'lucide-react';
import { shrinkImage, shrinkRemoteImage, uploadFile } from '@/lib/upload';
interface ImageUploadInputProps {
label?: string;
@@ -26,28 +27,13 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const uploadImage = async (file: File) => {
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success && data.url) {
onChange(data.url);
} else {
alert(`Chyba pri nahrávaní obrázka: ${data.error || 'Neznáma chyba'}`);
}
} catch (err: any) {
alert(`Chyba: ${err.message}`);
const small = await shrinkImage(file);
onChange(await uploadFile(small, small.name));
} catch (err) {
alert(`Chyba pri nahrávaní obrázka: ${(err as Error).message}`);
} finally {
setIsUploading(false);
if (fileInputRef.current) {
@@ -56,6 +42,55 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadImage(file);
};
// Vložená URL: príliš veľký obrázok sa zmenší a uloží k nám (posledná spracovaná adresa, aby sa nerobilo dvakrát)
const lastCheckedUrl = useRef('');
const [urlNote, setUrlNote] = useState<{ tone: 'ok' | 'warn'; text: string } | null>(null);
const checkUrl = async (url: string) => {
url = url.trim();
if (!url || url === lastCheckedUrl.current) return;
lastCheckedUrl.current = url;
setIsUploading(true);
setUrlNote(null);
try {
const result = await shrinkRemoteImage(url);
if (result.status === 'shrunk') {
lastCheckedUrl.current = result.url;
onChange(result.url);
setUrlNote({ tone: 'ok', text: 'Obrázok bol väčší ako 1000 px – zmenšili sme ho a uložili k nám.' });
} else if (result.status === 'small') {
setUrlNote({ tone: 'ok', text: 'Obrázok je v poriadku (najviac 1000 px), adresa ostáva.' });
} else if (result.status === 'login') {
setUrlNote({
tone: 'warn',
text: 'Obrázok je za prihlásením – v e-maile sa nezobrazí. Otvorte ho v prehliadači, pravým klikom „Kopírovať obrázok“ a sem vložte Ctrl+V.'
});
} else if (result.status === 'failed') {
setUrlNote({ tone: 'warn', text: `Obrázok sa nepodarilo stiahnuť (${result.error}). Skontrolujte adresu, prípadne obrázok skopírujte a vložte Ctrl+V.` });
}
} catch (err) {
setUrlNote({ tone: 'warn', text: `Obrázok sa nepodarilo spracovať: ${(err as Error).message}` });
} finally {
setIsUploading(false);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
// obrázok zo schránky (Ctrl+V) → nahrá sa ako súbor
const file = [...e.clipboardData.files].find((f) => f.type.startsWith('image/'));
if (file) {
e.preventDefault();
uploadImage(file);
return;
}
const text = e.clipboardData.getData('text');
if (/^https?:\/\//i.test(text.trim())) setTimeout(() => checkUrl(text), 0);
};
return (
<div className="space-y-1.5">
{label && <label className="block text-[11px] font-semibold text-gray-700">{label}</label>}
@@ -81,9 +116,17 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
setUrlNote(null);
onChange(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') checkUrl(e.currentTarget.value);
}}
onPaste={handlePaste}
onBlur={(e) => checkUrl(e.target.value)}
placeholder={placeholder}
className="flex-1 text-xs font-mono border border-gray-300 rounded-lg p-2 bg-white"
className="flex-1 min-w-0 text-xs font-mono border border-gray-300 rounded-lg p-2 bg-white"
/>
{/* Tlačidlo nahrávania */}
@@ -115,6 +158,12 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
</button>
</div>
{urlNote && (
<p className={`text-[10px] leading-snug ${urlNote.tone === 'warn' ? 'text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-2 py-1' : 'text-emerald-700'}`}>
{urlNote.text}
</p>
)}
{onAltChange && (
<input
type="text"
+72
View File
@@ -0,0 +1,72 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface NameDialogProps {
open: boolean;
title: string;
initial: string;
confirmText: string;
onConfirm: (name: string) => void | Promise<void>;
onCancel: () => void;
}
// Malé okno na zadanie názvu (premenovanie, duplikovanie newslettera).
// Pri každom otvorení sa obsah pripojí nanovo, takže začína s predvyplneným názvom.
export const NameDialog: React.FC<NameDialogProps> = ({ open, ...props }) => (open ? <NameDialogBody {...props} /> : null);
const NameDialogBody: React.FC<Omit<NameDialogProps, 'open'>> = ({ title, initial, confirmText, onConfirm, onCancel }) => {
const [value, setValue] = useState(initial);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// predvyplnený názov označíme, aby sa dal hneď prepísať alebo len doplniť
useEffect(() => {
const t = setTimeout(() => inputRef.current?.select(), 30);
return () => clearTimeout(t);
}, []);
const submit = async () => {
const name = value.trim();
if (!name || busy) return;
setBusy(true);
try {
await onConfirm(name);
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-[60] bg-black/50 flex items-center justify-center p-4" onMouseDown={onCancel}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-5 space-y-4" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-sm font-bold text-gray-900">{title}</h3>
<input
ref={inputRef}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') onCancel();
}}
placeholder="Názov newslettera"
className="w-full text-sm border border-gray-300 rounded-xl px-3 py-2 outline-none focus:border-red-500 focus:ring-2 focus:ring-red-500/15"
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} className="px-4 py-1.5 text-xs font-bold text-gray-600 hover:bg-gray-100 rounded-xl">
Zrušiť
</button>
<button
type="button"
onClick={submit}
disabled={!value.trim() || busy}
className="px-4 py-1.5 text-xs font-bold bg-red-600 hover:bg-red-700 disabled:opacity-40 text-white rounded-xl"
>
{busy ? 'Ukladám…' : confirmText}
</button>
</div>
</div>
</div>
);
};
+311 -25
View File
@@ -2,27 +2,21 @@
// Editor jedného produktu (bežné aj vernostné produkty) – zbalený po produktoch, len to, čo treba:
// texty, fotka (zoom/posun), cenovka (šablóna, veľkosť, ceny), dostupnosť a ďalšie možnosti.
import React, { useEffect, useRef } from 'react';
import { BadgeTemplate, CardProduct } from '@/types/newsletter';
import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { BADGE_IMPORT_TYPES, BadgeTemplate, CardProduct, ProductLabel, ProductPhoto } from '@/types/newsletter';
import { ChevronDown, ChevronUp, Trash2, Pencil, Globe, Layers } from 'lucide-react';
import { ImageUploadInput } from './ImageUploadInput';
import { pickAlt } from '@/lib/altText';
import { DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, badgeWidthFor, productCardAlt } from '@/lib/productCard';
import { DEFAULT_IMAGE_SCALE, DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, MAIN_PHOTO, PRODUCT_BG_PRESETS, badgeWidthFor, photoStack, productCardAlt, usesAppPrice } from '@/lib/productCard';
export const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
];
export { PRODUCT_BG_PRESETS };
type Variant = 'standard' | 'loyalty';
// Textové polia cien produktu (bežné aj vernostné)
type TextKey =
| 'priceMain' | 'priceSub' | 'discountText'
| 'cardPriceMain' | 'cardPriceSub' | 'cardDiscount' | 'cardUnitPrice'
| 'appPriceMain' | 'appPriceSub'
| 'noCardPriceMain' | 'noCardPriceSub' | 'noCardDiscount' | 'noCardUnitPrice';
type Patch = Record<string, unknown>;
@@ -38,7 +32,7 @@ interface ProductEditorProps {
onOpenBadgeEditor?: () => void;
onApplyBadgeToSection?: () => void;
onApplyBadgeToAll?: () => void;
onApplyStoresOffsetToAll?: (x: number, y: number) => void; // poloha štítkov pre všetky produkty
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void; // poloha a veľkosť štítkov pre všetky produkty
}
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
@@ -105,6 +99,213 @@ const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
</button>
);
// Filter šablón cenoviek v produkte podľa tagov (typ, farba, pôvod, appka); prázdne = všetky
interface BadgeFilter {
type: string;
color: string;
origin: string; // '' | 'SK' | 'none'
app: string; // '' | 'app' | 'none'
}
const EMPTY_BADGE_FILTER: BadgeFilter = { type: '', color: '', origin: '', app: '' };
// posledný filter – pri otvorení ďalšieho produktu ostane nastavený
let lastBadgeFilter: BadgeFilter = EMPTY_BADGE_FILTER;
export function filterBadges(badges: BadgeTemplate[], f: BadgeFilter): BadgeTemplate[] {
return badges
.filter((b) => {
const t = b.importTags || {};
if (f.type && t.type !== f.type) return false;
if (f.color && t.color !== f.color) return false;
if (f.origin === 'SK' && t.origin !== 'SK') return false;
if (f.origin === 'none' && t.origin === 'SK') return false;
if (f.app === 'app' && !t.app) return false;
if (f.app === 'none' && t.app) return false;
return true;
})
.sort((a, b) => a.name.localeCompare(b.name, 'sk'));
}
const BadgeFilterBar: React.FC<{ filter: BadgeFilter; onChange: (f: BadgeFilter) => void; shown: number; total: number }> = ({
filter,
onChange,
shown,
total
}) => {
const cls = (active: boolean) =>
`min-w-0 text-[10px] rounded-md border px-1 py-1 bg-white ${active ? 'border-red-400 text-red-700 font-bold' : 'border-gray-200 text-gray-600'}`;
const set = (k: keyof BadgeFilter) => (e: React.ChangeEvent<HTMLSelectElement>) => onChange({ ...filter, [k]: e.target.value });
const active = Object.values(filter).some(Boolean);
return (
<div className="flex items-center gap-1">
<div className="grid grid-cols-4 gap-1 flex-1 min-w-0">
<select value={filter.type} onChange={set('type')} className={cls(!!filter.type)} title="Typ cenovky">
<option value="">Typ: všetky</option>
{BADGE_IMPORT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<select value={filter.color} onChange={set('color')} className={cls(!!filter.color)} title="Farba cenovky">
<option value="">Farba: všetky</option>
<option value="cervena">Červená</option>
<option value="oranzova">Oranžová</option>
<option value="zelena">Zelená</option>
</select>
<select value={filter.origin} onChange={set('origin')} className={cls(!!filter.origin)} title="Pôvod">
<option value="">Pôvod: všetky</option>
<option value="SK">SK</option>
<option value="none">Bez pôvodu</option>
</select>
<select value={filter.app} onChange={set('app')} className={cls(!!filter.app)} title="Cenovka pre appkáčov">
<option value="">Appka: všetky</option>
<option value="app">Pre appkáčov</option>
<option value="none">Bežná</option>
</select>
</div>
{active && (
<button
type="button"
onClick={() => onChange(EMPTY_BADGE_FILTER)}
className="shrink-0 text-[10px] font-bold text-gray-500 hover:text-red-700 px-1"
title="Zrušiť filter"
>
{shown}/{total} ✕
</button>
)}
</div>
);
};
// Poradie fotiek ako vrstvy: navrchu zoznamu je fotka najviac vpredu
const PhotoOrderList: React.FC<{ product: CardProduct; onChange: (order: string[]) => void }> = ({ product, onChange }) => {
const stack = photoStack(product); // odzadu dopredu
const extras = product.extraPhotos || [];
const move = (id: string, dir: 1 | -1) => {
const i = stack.indexOf(id);
const j = i + dir;
if (j < 0 || j >= stack.length) return;
const next = [...stack];
[next[i], next[j]] = [next[j], next[i]];
onChange(next);
};
const front = [...stack].reverse();
return (
<div className="rounded-lg border border-gray-200 bg-white p-2 space-y-1">
<span className="text-[10px] font-bold text-gray-600">Poradie fotiek (hore = vpredu)</span>
{front.map((id, k) => {
const isMain = id === MAIN_PHOTO;
const extraIdx = extras.findIndex((f) => f.id === id);
const url = isMain ? product.imageUrl : extras[extraIdx]?.url;
return (
<div key={id} className={`flex items-center gap-2 rounded-md px-1.5 py-1 ${isMain ? 'bg-red-50' : 'bg-gray-50'}`}>
<span className="w-7 h-7 rounded bg-white border border-gray-200 overflow-hidden flex items-center justify-center shrink-0">
{url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt="" className="w-full h-full object-contain" />
)}
</span>
<span className="flex-1 text-[11px] font-semibold text-gray-800">{isMain ? 'Hlavná fotka' : `Fotka ${extraIdx + 2}`}</span>
<button type="button" disabled={k === 0} onClick={() => move(id, 1)} className="p-0.5 text-gray-500 hover:text-gray-900 disabled:opacity-25" title="Dopredu">
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button type="button" disabled={k === front.length - 1} onClick={() => move(id, -1)} className="p-0.5 text-gray-500 hover:text-gray-900 disabled:opacity-25" title="Dozadu">
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
);
};
// Ďalšia fotka v karte (bundle): obrázok, poloha, veľkosť
const ExtraPhotoEditor: React.FC<{
index: number;
photo: ProductPhoto;
onChange: (patch: Partial<ProductPhoto>) => void;
onRemove: () => void;
}> = ({ index, photo, onChange, onRemove }) => (
<div className="rounded-lg border border-gray-200 bg-white p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-gray-600">Fotka {index + 2}</span>
<button type="button" onClick={onRemove} className="text-gray-300 hover:text-red-600 p-0.5" title="Odstrániť fotku">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<ImageUploadInput label="" value={photo.url} onChange={(url) => onChange({ url })} />
<Slider label="Vodorovne" value={photo.x} display={`${photo.x} %`} min={0} max={100} step={1} onChange={(v) => onChange({ x: v })} />
<Slider label="Zvisle" value={photo.y} display={`${photo.y} %`} min={0} max={100} step={1} onChange={(v) => onChange({ y: v })} />
<Slider label="Veľkosť" value={photo.scale} display={`${Math.round(photo.scale * 100)} %`} min={0.2} max={2} step={0.05} onChange={(v) => onChange({ scale: v })} />
</div>
);
// Nastavenie jedného vlastného štítka na fotke
const LABEL_COLORS: Array<[string, string]> = [
['#e2001a', 'Červená'],
['#ffde00', 'Žltá'],
['#003399', 'Modrá'],
['#000000', 'Čierna'],
['#ffffff', 'Biela']
];
const LabelEditor: React.FC<{
label: ProductLabel;
onChange: (patch: Partial<ProductLabel>) => void;
onRemove: () => void;
}> = ({ label, onChange, onRemove }) => {
const swatches = (key: 'bg' | 'color') => (
<div className="flex items-center gap-1">
{LABEL_COLORS.map(([c, name]) => (
<button
key={c}
type="button"
title={name}
onClick={() => onChange({ [key]: c })}
className={`w-4 h-4 rounded-full border ${label[key]?.toLowerCase() === c ? 'ring-2 ring-red-500 border-white' : 'border-black/20'}`}
style={{ backgroundColor: c }}
/>
))}
<input
type="color"
value={label[key] || '#ffffff'}
onChange={(e) => onChange({ [key]: e.target.value })}
className="w-4 h-4 rounded cursor-pointer border border-gray-300 p-0"
title="Vlastná farba"
/>
</div>
);
return (
<div className="rounded-lg border border-gray-200 bg-white p-2 space-y-1.5">
<div className="flex items-center gap-1.5">
<input
value={label.text}
onChange={(e) => onChange({ text: e.target.value })}
placeholder="Text štítka (<br> = nový riadok)"
className="flex-1 min-w-0 text-xs font-bold uppercase border border-gray-300 rounded-lg px-2 py-1"
/>
<button type="button" onClick={onRemove} className="text-gray-300 hover:text-red-600 p-0.5" title="Zmazať štítok">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<Slider label="Vodorovne" value={label.x} display={`${label.x} %`} min={0} max={100} step={1} onChange={(v) => onChange({ x: v })} />
<Slider label="Zvisle" value={label.y} display={`${label.y} %`} min={0} max={100} step={1} onChange={(v) => onChange({ y: v })} />
<Slider label="Veľkosť" value={label.size} display={`${label.size} px`} min={6} max={40} step={1} onChange={(v) => onChange({ size: v })} />
<Slider label="Natočenie" value={label.rotate || 0} display={`${label.rotate || 0}°`} min={-45} max={45} step={1} onChange={(v) => onChange({ rotate: v })} />
<div className="grid grid-cols-2 gap-2 pt-0.5">
<div className="space-y-1">
<span className="text-[10px] font-semibold text-gray-600">Pozadie</span>
{swatches('bg')}
</div>
<div className="space-y-1">
<span className="text-[10px] font-semibold text-gray-600">Text</span>
{swatches('color')}
</div>
</div>
</div>
);
};
// Šablóna používa ceny s kartou → zobrazíme aj polia s kartou
const usesCardPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('_s_kartou');
@@ -124,17 +325,24 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
}) => {
// Po otvorení (aj klikom na produkt v náhľade) posunieme panel na tento produkt
const rootRef = useRef<HTMLDivElement>(null);
const [badgeFilter, setBadgeFilterState] = useState<BadgeFilter>(() => lastBadgeFilter);
const setBadgeFilter = (f: BadgeFilter) => {
lastBadgeFilter = f;
setBadgeFilterState(f);
};
const filteredBadges = filterBadges(availableBadges, badgeFilter);
useEffect(() => {
if (open) rootRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}, [open]);
const isLoyalty = variant === 'loyalty';
const set = (key: string) => (v: unknown) => onChange({ [key]: v });
const scale = p.imageScale ?? 1;
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
const offsetX = p.imageOffsetX || 0;
const offsetY = p.imageOffsetY || 0;
const storesX = p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X;
const storesY = p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y;
const storesSize = p.storesSize ?? DEFAULT_STORES_SIZE;
const hasStores = !!(p.stores?.potraviny || p.stores?.supermarket || p.stores?.tempo);
const badgeWidth = badgeWidthFor(p, variant);
@@ -151,7 +359,7 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
const text = (key: TextKey) => p[key] as string | undefined;
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey: TextKey, unitKey?: TextKey) => (
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey?: TextKey, unitKey?: TextKey) => (
<div className="space-y-1">
<span className="text-[10px] font-semibold text-gray-600">{label}</span>
<div className={`grid ${unitKey ? 'grid-cols-[1fr_1fr_1fr]' : 'grid-cols-3'} gap-1.5`}>
@@ -161,9 +369,11 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
<Field label="Centy">
<TextInput value={text(subKey)} onChange={set(subKey)} bold />
</Field>
<Field label="Zľava">
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
</Field>
{discountKey && (
<Field label="Zľava">
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
</Field>
)}
</div>
{unitKey && (
<Field label="Jednotková cena">
@@ -255,20 +465,61 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
<Slider label="Veľkosť" value={scale} display={`${Math.round(scale * 100)} %`} min={0.4} max={2.5} step={0.05} onChange={set('imageScale')} />
<Slider label="Vodorovne" value={offsetX} display={`${offsetX}`} min={-150} max={150} step={1} onChange={set('imageOffsetX')} />
<Slider label="Zvisle" value={offsetY} display={`${offsetY}`} min={-120} max={120} step={1} onChange={set('imageOffsetY')} />
{/* Ďalšie fotky (bundle) */}
{(p.extraPhotos || []).map((f, i) => (
<ExtraPhotoEditor
key={f.id}
index={i}
photo={f}
onChange={(patch) => onChange({ extraPhotos: (p.extraPhotos || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
onRemove={() =>
onChange({
extraPhotos: (p.extraPhotos || []).filter((_, j) => j !== i),
photoOrder: photoStack(p).filter((id) => id !== f.id)
})
}
/>
))}
<button
type="button"
onClick={() => {
const id = `ph-${Date.now()}`;
onChange({
extraPhotos: [...(p.extraPhotos || []), { id, url: '', x: 62, y: 52, scale: 0.75 }],
photoOrder: [...photoStack(p), id] // nová fotka ide dopredu
});
}}
className="w-full text-[10px] font-bold text-gray-600 border border-dashed border-gray-300 hover:border-gray-400 hover:bg-white rounded-lg py-1.5"
>
+ Pridať ďalšiu fotku (bundle)
</button>
{(p.extraPhotos || []).length > 0 && <PhotoOrderList product={p} onChange={(photoOrder) => onChange({ photoOrder })} />}
</Block>
{/* CENOVKA */}
<Block title="Cenovka">
<BadgeFilterBar filter={badgeFilter} onChange={setBadgeFilter} shown={filteredBadges.length} total={availableBadges.length} />
<div className="flex items-center gap-1.5">
<select
value={p.customBadgePngUrl ? 'png' : p.badgeCustomTemplate?.id || 'default'}
value={p.customBadgePngUrl ? 'png' : p.badgeCustomTemplate?.id || (isLoyalty ? 'default' : availableBadges.find((b) => b.isDefault)?.id || '')}
onChange={(e) => selectBadge(e.target.value)}
className={`${inputCls} font-medium`}
>
<option value="default">{isLoyalty ? 'Predvolená (bez karty / s kartou)' : 'Predvolená COOP cenovka'}</option>
{availableBadges.map((b) => (
{/* bežné produkty: predvolená je šablóna označená ako predvolená (nastaví sa automaticky) */}
{isLoyalty && <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}>
{p.badgeCustomTemplate.name}
{availableBadges.some((b) => b.id === p.badgeCustomTemplate!.id) ? '' : ' (zmazaná šablóna)'}
</option>
)}
{filteredBadges.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
{b.isDefault ? ' (predvolená)' : ''}
</option>
))}
{p.customBadgePngUrl && <option value="png">Vlastná PNG cenovka</option>}
@@ -296,6 +547,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')}
</>
)}
@@ -326,8 +578,8 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
{/* DOSTUPNOSŤ */}
<Block
title="Dostupnosť"
action={hasStores && (storesX !== DEFAULT_STORES_OFFSET_X || storesY !== DEFAULT_STORES_OFFSET_Y) && (
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined })} />
action={hasStores && (storesX !== DEFAULT_STORES_OFFSET_X || storesY !== DEFAULT_STORES_OFFSET_Y || storesSize !== DEFAULT_STORES_SIZE) && (
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined, storesSize: undefined })} />
)}
>
<div className="grid grid-cols-3 gap-1.5">
@@ -357,19 +609,53 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
<>
<Slider label="Štítky ↔" value={storesX} display={`${storesX}`} min={-80} max={80} step={1} onChange={set('storesOffsetX')} />
<Slider label="Štítky ↕" value={storesY} display={`${storesY}`} min={-40} max={60} step={1} onChange={set('storesOffsetY')} />
<Slider label="Veľkosť" value={storesSize} display={`${storesSize} px`} min={8} max={30} step={1} onChange={set('storesSize')} />
{onApplyStoresOffsetToAll && (
<button
type="button"
onClick={() => onApplyStoresOffsetToAll(storesX, storesY)}
onClick={() => onApplyStoresOffsetToAll(storesX, storesY, storesSize)}
className="flex items-center gap-1 text-[10px] font-bold text-red-700 bg-red-50 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
>
<Globe className="w-3 h-3" /> Polohu štítkov použiť na všetky produkty
<Globe className="w-3 h-3" /> Polohu a veľkosť štítkov použiť na všetky produkty
</button>
)}
</>
)}
</Block>
{/* VLASTNÉ ŠTÍTKY NA FOTKE */}
<Block
title="Štítky na fotke"
action={
<button
type="button"
onClick={() =>
onChange({
labels: [
...(p.labels || []),
{ id: `lbl-${Date.now()}`, text: 'NOVINKA', x: 30, y: 20, size: 12, bg: '#e2001a', color: '#ffffff', rotate: 0 }
]
})
}
className="text-[10px] font-bold text-red-700 hover:text-red-900"
>
+ Pridať štítok
</button>
}
>
{(p.labels || []).length === 0 && (
<p className="text-[10px] text-gray-400">Napr. „PODIEL MÄSA 92 %“ alebo „NOVINKA“ priamo na fotke produktu.</p>
)}
{(p.labels || []).map((l, i) => (
<LabelEditor
key={l.id}
label={l}
onChange={(patch) => onChange({ labels: (p.labels || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
onRemove={() => onChange({ labels: (p.labels || []).filter((_, j) => j !== i) })}
/>
))}
</Block>
{/* ĎALŠIE MOŽNOSTI */}
<details className="group">
<summary className="text-[10px] font-bold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-800">
+205 -10
View File
@@ -3,21 +3,30 @@
import React, { useState } from 'react';
import Papa from 'papaparse';
import { XMLParser } from 'fast-xml-parser';
import { ProductItem } from '@/types/newsletter';
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 { CARD_BG_BY_BADGE_COLOR } from '@/lib/productCard';
import { shrinkImage, uploadFile } from '@/lib/upload';
interface ImportModalProps {
isOpen: boolean;
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ť
variant?: 'standard' | 'loyalty'; // vernostné zľavy: len ZIP s podkladmi, ceny s kartou / bez karty
}
export const ProductImportModal: React.FC<ImportModalProps> = ({
isOpen,
onClose,
onImportProducts
onImportProducts,
availableBadges = [],
variant = 'standard'
}) => {
const isLoyalty = variant === 'loyalty';
const [file, setFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
const [parsedRows, setParsedRows] = useState<any[]>([]);
@@ -32,6 +41,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
discountText: string;
imageUrl: string;
validity: string;
badgeCode: string;
}>({
title: '',
priceMain: '',
@@ -39,12 +49,18 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
unitPrice: '',
discountText: '',
imageUrl: '',
validity: ''
validity: '',
badgeCode: ''
});
const [presetName, setPresetName] = useState('');
const [savedPresets, setSavedPresets] = useState<any[]>([]);
// 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 [zipProgress, setZipProgress] = useState<string | null>(null);
const [replaceExisting, setReplaceExisting] = useState(false); // vymazať doterajšie produkty sekcie
if (!isOpen) return null;
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -54,6 +70,19 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
const ext = uploaded.name.split('.').pop()?.toLowerCase();
const reader = new FileReader();
setZip(null);
if (ext === 'zip') {
setColumns([]);
setParsedRows([]);
readPodkladyZip(uploaded, availableBadges)
.then(({ products, info }) => {
if (!products.length) throw new Error('V tabuľke sa nenašiel žiadny produkt.');
setZip({ products: products.map((p) => ({ ...p, thumb: p.photo ? URL.createObjectURL(p.photo) : '' })), info });
})
.catch((err: Error) => alert(`ZIP sa nepodarilo načítať: ${err.message}`));
return;
}
if (ext === 'csv') {
setFileType('csv');
@@ -125,11 +154,46 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
if (lower.includes('zlava') || lower.includes('discount')) newMapping.discountText = h;
if (lower.includes('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = h;
if (lower.includes('platnost') || lower.includes('valid')) newMapping.validity = h;
if (lower.includes('cenovk') || lower.includes('badge') || lower.includes('sablon') || lower.includes('template')) newMapping.badgeCode = h;
});
setMapping(newMapping);
};
const eur = (n: number | null) => (n != null ? `${n.toFixed(2).replace('.', ',')} €` : '—');
// Import zo ZIP-u: fotky sa zmenšia (max 1000 px) a nahrajú, potom sa produkty pridajú do sekcie
const applyZipImport = async () => {
if (!zip) return;
const items: ProductItem[] = [];
try {
for (let i = 0; i < zip.products.length; i++) {
const p = zip.products[i];
setZipProgress(`Nahrávam fotky ${i + 1}/${zip.products.length}…`);
let imageUrl = '';
if (p.photo) {
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));
}
} catch (err) {
alert(`Import sa nepodaril: ${(err as Error).message}`);
setZipProgress(null);
return;
}
setZipProgress(null);
zip.products.forEach((p) => p.thumb && URL.revokeObjectURL(p.thumb));
setZip(null);
setFile(null);
onImportProducts(items, { replace: replaceExisting });
onClose();
};
const applyImport = () => {
if (zip) {
applyZipImport();
return;
}
if (!mapping.title) {
alert('Prosím vyberte stĺpec pre Názov produktu.');
return;
@@ -148,7 +212,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
if (pSub.length === 1) pSub += '0';
}
// Šablóna cenovky podľa kódu (alebo názvu) zo súboru
const badge = mapping.badgeCode ? findBadgeByCode(availableBadges, row[mapping.badgeCode]) : undefined;
return {
...(badge ? { badgeCustomTemplate: badge, badgeId: badge.id, badgeWidth: badge.width ?? 110 } : {}),
id: `imported-${Date.now()}-${idx}`,
title: String(row[mapping.title] || 'Nový produkt'),
imageUrl: row[mapping.imageUrl] || 'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/produkt_1.png',
@@ -163,6 +231,20 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
};
});
// Kódy cenoviek, ku ktorým sa nenašla šablóna
if (mapping.badgeCode) {
const unknown = [
...new Set(
parsedRows
.map((row) => String(row[mapping.badgeCode] || '').trim())
.filter((v) => v && !findBadgeByCode(availableBadges, v))
)
];
if (unknown.length) {
alert(`Pre tieto kódy cenoviek sa nenašla šablóna (produkty dostanú predvolenú cenovku):\n${unknown.join(', ')}`);
}
}
onImportProducts(importedProducts);
onClose();
};
@@ -187,7 +269,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<div className="border-2 border-dashed border-gray-300 hover:border-red-500 rounded-xl p-6 text-center cursor-pointer transition-colors bg-gray-50/50">
<input
type="file"
accept=".csv,.json,.xml"
accept={isLoyalty ? '.zip' : '.csv,.json,.xml,.zip'}
onChange={handleFileUpload}
className="hidden"
id="file-upload-input"
@@ -195,12 +277,99 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<label htmlFor="file-upload-input" className="cursor-pointer">
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
<p className="font-semibold text-gray-700">
{file ? file.name : 'Kliknite sem alebo pretiahnite CSV, JSON alebo XML'}
{file ? file.name : isLoyalty ? 'Kliknite sem a vyberte ZIP s podkladmi' : 'Kliknite sem a vyberte ZIP s podkladmi, CSV, JSON alebo XML'}
</p>
<p className="text-xs text-gray-400 mt-1">
{isLoyalty
? 'Vernostné zľavy: .zip (podklady z Databázy produktov – tabuľka aj fotky)'
: 'Podporované formáty: .zip (podklady z Databázy produktov – tabuľka aj fotky), .csv, .json, .xml'}
</p>
<p className="text-xs text-gray-400 mt-1">Podporované formáty: .csv, .json, .xml</p>
</label>
</div>
{/* ZIP s podkladmi: náhľad produktov */}
{zip && (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-bold text-gray-800 uppercase tracking-wider">Produkty v ZIP-e ({zip.products.length})</h3>
{zip.info && <span className="text-[11px] text-gray-500 truncate">{zip.info}</span>}
</div>
<div className="border border-gray-200 rounded-xl divide-y divide-gray-100 overflow-hidden">
{zip.products.map((p) => {
const price = p.price != null ? splitPrice(p.price) : null;
const stores = [p.stores.potraviny && 'P', p.stores.supermarket && 'S', p.stores.tempo && 'T'].filter(Boolean).join(' ');
return (
<div key={p.ean} className="flex items-center gap-3 px-3 py-2 bg-white">
<span
className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shrink-0"
style={p.color && !isLoyalty ? { backgroundColor: CARD_BG_BY_BADGE_COLOR[p.color] } : undefined}
title={p.color ? 'Pozadie karty podľa farby cenovky' : undefined}
>
{p.thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={p.thumb} alt="" className="w-full h-full object-contain" />
) : (
<span className="text-[9px] text-red-600 font-bold text-center leading-tight">bez fotky</span>
)}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-900 truncate">
<span className="font-bold">{p.title}</span>
{p.quantity && <span className="ml-1 rounded bg-gray-100 px-1 text-gray-600">{p.quantity}</span>}
</p>
<p className="text-[11px] text-gray-500 truncate">
EAN {p.ean}
{p.unitPrice && ` · ${p.unitPrice}`}
{p.deposit && ` · záloha ${p.deposit} €`}
{p.appPrice != null && ` · v appke ${p.appPrice.toFixed(2).replace('.', ',')} €`}
{stores && ` · ${stores}`}
</p>
{p.extras.length > 0 && (
<p className="text-[11px] text-gray-500 truncate">
štítok na fotke: <span className="font-bold text-red-700">{p.extras.join(' · ')}</span>
</p>
)}
<p className="text-[11px] truncate">
{p.badge && p.badgeMissing.length ? (
<span className="text-amber-700">
cenovka: {p.badge.name} – chýba {p.badgeMissing.join(' a ')} pre „{p.badgeLabel}“
</span>
) : p.badge ? (
<span className="text-emerald-700">cenovka: {p.badge.name}</span>
) : (
<span className="text-amber-700">
{p.badgeLabel ? `cenovka „${p.badgeLabel}“ nie je medzi šablónami – použije sa predvolená` : 'bez cenovky – použije sa predvolená'}
</span>
)}
</p>
</div>
<div className="text-right shrink-0">
{isLoyalty ? (
<>
<p className="text-sm font-black text-red-600">{eur(p.cardPrice ?? p.price)} <span className="text-[10px] font-semibold">s kartou</span></p>
<p className="text-[11px] font-bold text-gray-500">{eur(p.cardPrice != null ? p.price : p.regularPrice)} bez karty</p>
</>
) : (
<p className="text-sm font-black text-red-600">{price ? `${price.main},${price.sub} €` : '—'}</p>
)}
{p.discount && <p className="text-[11px] font-bold text-gray-600">-{p.discount}</p>}
</div>
</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">
{isLoyalty
? 'Fotky sa pri importe zmenšia na najviac 1000 px. Cena s kartou = Cena s vernostnou kartou (inak akciová cena), bez karty = akciová (inak bežná) cena. Farba pozadia sa nemení, rozloženie karty sa prevezme z prvého produktu v sekcii.'
: '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.'}
</p>
</div>
)}
{/* Mapping Table */}
{columns.length > 0 && (
<div className="space-y-4">
@@ -268,6 +437,25 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
</select>
</div>
<div className="col-span-2">
<label className="block text-xs font-semibold text-gray-600 mb-1">Cenovka (kód šablóny)</label>
<select
value={mapping.badgeCode}
onChange={(e) => setMapping({ ...mapping, badgeCode: e.target.value })}
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
>
<option value="">-- Bez cenovky zo súboru (predvolená) --</option>
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<p className="text-[11px] text-gray-500 mt-1">
Kódy šablón:{' '}
{availableBadges.length
? availableBadges.map(badgeCode).join(', ')
: 'zatiaľ žiadne šablóny'}
{' '}(kód vzniká z názvu šablóny; v súbore môže byť kód aj presný názov)
</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
<select
@@ -290,7 +478,14 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
<div className="p-3 text-xs space-y-2 bg-white">
{parsedRows.slice(0, 2).map((row, idx) => (
<div key={idx} className="flex items-center justify-between border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-800">{row[mapping.title] || 'Bez názvu'}</span>
<span className="font-semibold text-gray-800">
{row[mapping.title] || 'Bez názvu'}
{mapping.badgeCode && (
<span className="ml-2 text-[10px] font-medium text-gray-500">
cenovka: {findBadgeByCode(availableBadges, row[mapping.badgeCode])?.name || `? (${row[mapping.badgeCode] || 'prázdne'})`}
</span>
)}
</span>
<span className="text-red-600 font-bold">
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
</span>
@@ -313,11 +508,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
</button>
<button
onClick={applyImport}
disabled={parsedRows.length === 0}
disabled={(parsedRows.length === 0 && !zip) || !!zipProgress}
className="px-5 py-2 text-sm font-bold bg-red-600 text-white rounded-xl hover:bg-red-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
<Check className="w-4 h-4" />
Importovať do kampane
{zipProgress || (zip ? `Importovať ${zip.products.length} produktov` : 'Importovať do kampane')}
</button>
</div>
</div>
+39 -5
View File
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { NewsletterCampaign } from '@/types/newsletter';
import { DEFAULT_CAMPAIGN } from '@/lib/defaultCampaign';
import { NameDialog } from './NameDialog';
import {
FolderOpen,
Plus,
@@ -15,7 +16,8 @@ import {
CheckCircle2,
Layers,
Search,
Sparkles
Sparkles,
Pencil
} from 'lucide-react';
interface SavedCampaignsModalProps {
@@ -24,6 +26,7 @@ interface SavedCampaignsModalProps {
currentCampaign: NewsletterCampaign;
onLoadCampaign: (campaign: NewsletterCampaign) => void;
onSaveCurrentAsCopy: (newTitle: string) => Promise<void>;
onRenameCurrent: (title: string) => void;
}
export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
@@ -31,13 +34,16 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
onClose,
currentCampaign,
onLoadCampaign,
onSaveCurrentAsCopy
onSaveCurrentAsCopy,
onRenameCurrent
}) => {
const [campaigns, setCampaigns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [isCreatingNew, setIsCreatingNew] = useState(false);
const [newTitle, setNewTitle] = useState('');
// okno na názov: premenovanie alebo duplikovanie vybraného newslettera
const [nameAction, setNameAction] = useState<{ kind: 'rename' | 'duplicate'; camp: { id: string; title: string; data?: unknown } } | null>(null);
const fetchCampaigns = async () => {
setLoading(true);
@@ -66,9 +72,20 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
if (!isOpen) return null;
const handleDuplicate = async (camp: any) => {
const handleRename = async (camp: { id: string }, title: string) => {
try {
const { error } = await supabase.from('templates').update({ title }).eq('id', camp.id);
if (error) throw error;
setCampaigns((prev) => prev.map((c) => (c.id === camp.id ? { ...c, title } : c)));
if (camp.id === currentCampaign.id) onRenameCurrent(title);
setNameAction(null);
} catch (err) {
alert(`Chyba pri premenovaní: ${(err as Error).message}`);
}
};
const handleDuplicate = async (camp: any, clonedTitle: string) => {
try {
const clonedTitle = `Kópia - ${camp.title}`;
const { data, error } = await supabase
.from('templates')
.insert([
@@ -90,6 +107,7 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
status: 'draft',
modules: data[0].data?.modules || []
});
setNameAction(null);
onClose();
}
} catch (err: any) {
@@ -326,7 +344,7 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
</button>
<button
onClick={() => handleDuplicate(camp)}
onClick={() => setNameAction({ kind: 'duplicate', camp })}
className="px-3 py-1.5 text-xs font-bold bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl flex items-center gap-1 transition-colors"
title="Vytvoriť kópiu"
>
@@ -334,6 +352,14 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
<span>Duplikovať</span>
</button>
<button
onClick={() => setNameAction({ kind: 'rename', camp })}
className="p-2 text-gray-400 hover:text-gray-900 hover:bg-gray-100 rounded-xl transition-colors"
title="Premenovať"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(camp.id, camp.title)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
@@ -359,6 +385,14 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
</button>
</div>
</div>
<NameDialog
open={!!nameAction}
title={nameAction?.kind === 'rename' ? 'Premenovať newsletter' : 'Duplikovať newsletter'}
initial={nameAction?.camp.title || ''}
confirmText={nameAction?.kind === 'rename' ? 'Uložiť názov' : 'Vytvoriť kópiu'}
onConfirm={(title) => (nameAction?.kind === 'rename' ? handleRename(nameAction.camp, title) : handleDuplicate(nameAction!.camp, title))}
onCancel={() => setNameAction(null)}
/>
</div>
);
};
+1 -5
View File
@@ -103,10 +103,6 @@ export const TopBar: React.FC<TopBarProps> = ({
<header className="h-16 bg-white border-b border-gray-200 px-4 md:px-6 flex items-center justify-between sticky top-0 z-30 shadow-xs">
{/* Ľavá časť: Logo, Moje newslettre, Titulok a stav */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#e2001a] flex items-center justify-center text-white font-black text-sm shadow-sm tracking-tighter shrink-0">
COOP
</div>
{/* Tlačidlo Moje newslettre */}
<button
onClick={onOpenSavedCampaigns}
@@ -142,7 +138,7 @@ export const TopBar: React.FC<TopBarProps> = ({
<h1 className="text-xs md:text-sm font-bold text-gray-900 leading-tight max-w-[180px] md:max-w-xs truncate group-hover:text-blue-600">
{campaign.title || 'COOP Jednota Newsletter'}
</h1>
<Edit2 className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" />
<Edit2 className="w-3 h-3 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
)}
+98 -16
View File
@@ -1,8 +1,68 @@
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
import { BadgeTemplate, BadgeLayer, BadgeImportTags } from '@/types/newsletter';
// Vymazané všetky preddefinované šablóny — v systéme sú výhradne vlastné cenovky vytvorené používateľom
export const DEFAULT_BADGE_TEMPLATES: BadgeTemplate[] = [];
// ---------------------------------------------------------------------------
// Šablóny v databáze (tabuľka badges) a kód šablóny pre hromadný import
// ---------------------------------------------------------------------------
export interface BadgeRow {
id: string;
name: string;
svg_raw: string | null;
layers_config:
| BadgeLayer[]
| {
layers?: BadgeLayer[];
width?: number;
align?: BadgeTemplate['align'];
paddingTop?: number;
paddingBottom?: number;
importTags?: BadgeImportTags;
isDefault?: boolean;
}
| null;
}
// Riadok tabuľky badges → šablóna (staršie riadky majú v layers_config len pole vrstiev)
export function badgeFromRow(d: BadgeRow): BadgeTemplate {
const cfg = d.layers_config && !Array.isArray(d.layers_config) ? d.layers_config : null;
return {
id: d.id,
name: d.name,
svgRaw: d.svg_raw || '',
layersConfig: cfg ? cfg.layers || [] : (d.layers_config as BadgeLayer[]) || [],
width: cfg?.width || 110,
align: cfg?.align || 'center',
paddingTop: cfg?.paddingTop ?? 4,
paddingBottom: cfg?.paddingBottom ?? 4,
importTags: cfg?.importTags,
isDefault: cfg?.isDefault || undefined
};
}
// Kód šablóny pre hromadný import = názov šablóny upravený na kód:
// bez diakritiky, veľké písmená, medzery a interpunkcia → _ (napr. „Super cena – 1+1“ → SUPER_CENA_1_1)
export function normalizeBadgeCode(value: string | null | undefined): string {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.trim()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
export const badgeCode = (b: Pick<BadgeTemplate, 'name'>) => normalizeBadgeCode(b.name);
// Šablóna podľa hodnoty zo súboru (kód alebo názov – oboje sa porovná po úprave na kód)
export function findBadgeByCode(badges: BadgeTemplate[], value: string | null | undefined): BadgeTemplate | undefined {
const wanted = normalizeBadgeCode(value);
if (!wanted) return undefined;
return badges.find((b) => badgeCode(b) === wanted);
}
export interface BadgeProductData {
priceMain: string;
priceSub: string;
@@ -11,10 +71,12 @@ export interface BadgeProductData {
cardPriceMain?: string; // cena s kartou → {cena_s_kartou}
cardPriceSub?: string;
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
appPriceMain?: string; // cena pre appkáčov → {cena_app}
appPriceSub?: string;
}
// Premenné ceny s centami v hornom indexe: {cena} a {cena_s_kartou}
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou)\}/;
// Premenné ceny s centami v hornom indexe: {cena}, {cena_s_kartou} a {cena_app}
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou|cena_app)\}/;
// Nahradenie jednoduchých textových premenných (bez horného indexu)
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
@@ -31,9 +93,14 @@ export function badgePriceParts(token: string, product: BadgeProductData): [stri
if (token === 'cena_s_kartou') {
return [product.cardPriceMain || '', product.cardPriceSub || ''];
}
if (token === 'cena_app') {
return [product.appPriceMain || '', product.appPriceSub || ''];
}
return [product.priceMain || '0', product.priceSub || '00'];
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/**
* Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek
* editovacích rámčekov, tooltippov či ovládacích prvkov.
@@ -90,13 +157,23 @@ export function renderCleanBadgeSvg(
const displayHeight = Math.round((displayWidth / vbWidth) * vbHeight);
// Zostavenie textových vrstiev
const textLayersSvg = (template.layersConfig || []).map((layer) => {
const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => {
const rawText = layer.text || '';
const fontSize = layer.fontSize || 16;
const subFontSize = layer.indexFontSize || Math.max(10, Math.round(fontSize * 0.55));
const subDy = layer.indexOffsetY !== undefined ? `${layer.indexOffsetY}px` : '-0.45em';
const subDx = layer.indexOffsetX !== undefined ? `${layer.indexOffsetX}px` : '1px';
const revertDy = layer.indexOffsetY !== undefined ? `${-layer.indexOffsetY}px` : '0.45em';
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
// (0.43 ≈ vzdialenosť stredu písma od účiarie v em – vrstvy sa kreslia so strednou účiariou)
const baseSize = layer.fontSize || 16;
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 fill = layer.fill || '#ffffff';
const fontFamily = layer.fontFamily
@@ -109,23 +186,23 @@ export function renderCleanBadgeSvg(
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
let contentSvg = '';
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
if (priceMatch) {
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
const idx = priceMatch.index || 0;
const before = escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product));
const after = escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product));
const before = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product)));
const after = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product)));
contentSvg = `${before}${escapeXml(tokMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(tokSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
} else if (rawText.includes('{centy}')) {
const parts = rawText.split('{centy}');
const before = escapeXml(parts[0] || '');
const after = escapeXml(parts[1] || '');
const before = tightenCarons(escapeXml(parts[0] || ''));
const after = tightenCarons(escapeXml(parts[1] || ''));
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
} else {
contentSvg = escapeXml(replaceBadgeTextVars(rawText, product));
contentSvg = tightenCarons(escapeXml(replaceBadgeTextVars(rawText, product)));
}
return `<text 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>`;
// 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"${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;">
@@ -186,6 +263,11 @@ export async function svgToCleanPng(
});
}
// Mäkčeň pri ľ, ť, ď je v písme široký – písmeno za ním trochu pritiahneme (ako v návrhoch z Illustratora)
function tightenCarons(text: string): string {
return text.replace(/([ľťď])([^\s<&])/g, '$1<tspan dx="-0.05em">$2</tspan>');
}
function escapeXml(unsafe: string): string {
if (!unsafe) return '';
return unsafe
+4 -10
View File
@@ -3,18 +3,12 @@
// a jeho adresa sa zapamätá pri produkte (cardImage). Pri ďalšom exporte sa nezmenené karty nenahrávajú znova.
import { toJpeg } from 'html-to-image';
import { CardProduct, NewsletterCampaign } from '@/types/newsletter';
import { cardContextForModule, cardVisualKey, cardVisualHeight } from './productCard';
import { cardContextFor, cardVisualKey, cardVisualHeight } from './productCard';
import { uploadFile } from './upload';
async function uploadJpeg(dataUrl: string, fileName: string): Promise<string> {
const blob = await (await fetch(dataUrl)).blob();
const formData = new FormData();
formData.append('file', new File([blob], fileName, { type: 'image/jpeg' }));
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (!data.success || !data.url) {
throw new Error(data.error || 'Nahratie obrázka karty zlyhalo');
}
return data.url;
return uploadFile(blob, fileName);
}
export async function prepareCardImages(
@@ -26,8 +20,8 @@ export async function prepareCardImages(
campaign.modules.forEach((m, mIdx) => {
const isCards = m.type === 'products_grid' || (m.type === 'loyalty_club' && m.data?.enabled !== false);
if (!isCards) return;
const ctx = cardContextForModule(m, 'preview');
(m.data.products || []).forEach((p: CardProduct, pIdx: number) => {
const ctx = cardContextFor(m, pIdx, 'preview');
const key = cardVisualKey(p, ctx);
if (p.cardImage?.key !== key) {
jobs.push({ mIdx, pIdx, key, bg: p.cardBgColor || ctx.cardBg, width: ctx.cardWidth });
+16 -7
View File
@@ -2,7 +2,7 @@ import { NewsletterCampaign } from '@/types/newsletter';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { pickAlt } from './altText';
import { escapeHtml, escapeWithBreaks } from './html';
import { RenderMode, cardContextForModule, renderProductGrid } from './productCard';
import { RenderMode, renderProductGrid } from './productCard';
// E-mail na export (ZIP/Brevo): produktové karty použijú obrázky pripravené cez prepareCardImages()
export function renderEmailHtml(campaign: NewsletterCampaign): string {
@@ -233,6 +233,15 @@ export function renderSingleModuleHtml(m: any, campaign: NewsletterCampaign): st
return renderModule(m, campaign, 'preview');
}
// Žiara (tieň) tlačidla vo farbe tlačidla: #rrggbb / #rgb → rgba(r, g, b, alpha)
function glowColor(hex: string, alpha: number): string {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((hex || '').trim());
if (!m) return `rgba(0, 0, 0, ${alpha})`;
const h = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// Globálna platnosť akcie kampane (zdieľaná všetkými produktovými sekciami)
export function getCampaignValidity(campaign: NewsletterCampaign): string {
const mod = (campaign.modules || []).find(
@@ -358,13 +367,13 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
${m.data.title || m.data.description || m.data.buttonText ? `
<tr>
<td style="padding: 30px 20px; text-align: center;" class="mobile-padding">
${m.data.title ? `<h1 class="force-text-dark" style="margin: 0 0 15px 0; font-size: 24px; color: #1a1a1a; font-weight: 700;">${escapeWithBreaks(m.data.title)}</h1>` : ''}
${m.data.title ? `<h1 ${m.data.titleColor ? '' : 'class="force-text-dark" '}style="margin: 0 0 15px 0; font-size: 24px; color: ${m.data.titleColor || '#1a1a1a'}; font-weight: 700;">${escapeWithBreaks(m.data.title)}</h1>` : ''}
${m.data.description ? `<p class="force-text-dark" style="margin: 0 0 25px 0; font-size: 15px; line-height: 1.5; color: #555555;">${escapeWithBreaks(m.data.description)}</p>` : ''}
${m.data.buttonText ? `
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
<tr>
<td style="text-align: center;">
<a href="${escapeHtml(m.data.buttonUrl || '#')}" class="btn force-text-white" style="background-color: ${m.data.buttonBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 13px; font-weight: 800; text-decoration: none; padding: 12px 30px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(226, 0, 26, 0.35);">${escapeHtml(m.data.buttonText)}</a>
<a href="${escapeHtml(m.data.buttonUrl || '#')}" class="btn force-text-white" style="background-color: ${m.data.buttonBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 13px; font-weight: 800; text-decoration: none; padding: 12px 30px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px ${glowColor(m.data.buttonBgColor || '#e2001a', 0.35)};">${escapeHtml(m.data.buttonText)}</a>
</td>
</tr>
</table>` : ''}
@@ -383,7 +392,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
<h2 class="force-text-white" style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeWithBreaks(m.data.sectionTitle)}</h2>
</td>
</tr>` : ''}
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
${renderProductGrid(m, mode)}
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="12" style="font-size: 12px; line-height: 12px;">&nbsp;</td></tr></table>`;
@@ -414,7 +423,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
</td>
</tr>
<!-- Produktové karty -->
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
${renderProductGrid(m, mode)}
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;">&nbsp;</td></tr></table>`;
@@ -451,7 +460,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
<tr>
<td style="text-align: center;">
<a href="${escapeHtml(it1.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it1.btnBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(226, 0, 26, 0.35);">${escapeHtml(it1.btnText)}</a>
<a href="${escapeHtml(it1.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it1.btnBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px ${glowColor(it1.btnBgColor || '#e2001a', 0.35)};">${escapeHtml(it1.btnText)}</a>
</td>
</tr>
</table>
@@ -485,7 +494,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
<tr>
<td style="text-align: center;">
<a href="${escapeHtml(it2.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it2.btnBgColor || '#29446f'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(41, 68, 111, 0.3);">${escapeHtml(it2.btnText)}</a>
<a href="${escapeHtml(it2.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it2.btnBgColor || '#29446f'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px ${glowColor(it2.btnBgColor || '#29446f', 0.3)};">${escapeHtml(it2.btnText)}</a>
</td>
</tr>
</table>
+348
View File
@@ -0,0 +1,348 @@
// Import ZIP-u s podkladmi z Databázy produktov: tabuľka podklady_*.xlsx (jeden riadok = jeden produkt)
// a fotky v priečinkoch <EAN>/web/*.png.
import JSZip from 'jszip';
import { BADGE_IMPORT_TYPES, BadgeColor, BadgeTemplate, CardProduct, ProductItem, ProductLabel } from '@/types/newsletter';
import { findBadgeByCode } from './badgeUtils';
import { CARD_BG_BY_BADGE_COLOR } from './productCard';
export interface PodkladyProduct {
ean: string;
title: string;
price: number | null; // akciová cena
regularPrice: number | null; // bežná cena
cardPrice: number | null; // cena s vernostnou kartou
appPrice: number | null; // cena pre appkáčov
unitPrice: string; // napr. "jednotková cena 5,39 EUR/kg"
quantity: string; // gramáž / množstvo, napr. "1 kg"
discount: string; // napr. "30%"
badgeLabel: string; // napr. "SUPER CENA | červená | SK" – z čoho sa hľadá šablóna cenovky
badge?: BadgeTemplate;
badgeMissing: string[]; // čo šablóne chýba voči riadku (napr. „SK variant“, „variant pre appkáčov“)
color?: BadgeColor; // farba cenovky → pozadie karty
stores: ProductItem['stores'];
deposit: string; // napr. "0,15"
extras: string[]; // doplnkové grafiky → štítky na fotke (napr. „PODIEL MÄSA 90 %“)
photo?: Blob;
photoName: string;
}
const norm = (s: string) =>
s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
// „červená“ → cervena
function toBadgeColor(value: string): BadgeColor | undefined {
const n = norm(value);
if (n.startsWith('cerven')) return 'cervena';
if (n.startsWith('oranz')) return 'oranzova';
if (n.startsWith('zelen')) return 'zelena';
return undefined;
}
// Šablóna cenovky podľa tagov (Typ + Farba + Pôvod + Appka). Náhradný variant sa berie len smerom,
// ktorý nič nepridá: SK produkt môže dostať cenovku bez vlajky a produkt s cenou pre appkáčov bežnú cenovku,
// ale zahraničný produkt nikdy cenovku so slovenskou vlajkou a bežný produkt nikdy cenovku pre appkáčov.
function findBadgeByTags(
badges: BadgeTemplate[],
type: string,
color: BadgeColor | undefined,
origin: string,
app: boolean
): { badge: BadgeTemplate; missing: string[] } | undefined {
const sk = norm(origin) === 'sk';
const candidates = badges.filter((b) => {
const t = b.importTags;
if (!t?.type || norm(t.type) !== norm(type)) return false;
if (t.color && t.color !== color) return false;
if (t.origin === 'SK' && !sk) return false;
if (t.app && !app) return false;
return true;
});
// najlepšia zhoda: rovnaký pôvod, rovnaká appka, presná farba
const score = (b: BadgeTemplate) =>
((b.importTags?.origin === 'SK') === sk ? 0 : 4) + (!!b.importTags?.app === app ? 0 : 2) + (b.importTags?.color ? 0 : 1);
const best = candidates.sort((a, b) => score(a) - score(b))[0];
if (!best) return undefined;
const missing = [
sk && best.importTags?.origin !== 'SK' ? 'SK variant' : '',
app && !best.importTags?.app ? 'variant pre appkáčov' : ''
].filter(Boolean);
return { badge: best, missing };
}
// Doplnková grafika → štítok na fotke; dlhší text sa zalomí na dva riadky
function extraLabel(text: string, idx: number): ProductLabel {
const words = text.split(' ');
let label = text;
if (text.length > 16 && words.length > 1) {
let best = 1;
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(' ').length;
const bestA = words.slice(0, best).join(' ').length;
if (Math.abs(a - text.length / 2) < Math.abs(bestA - text.length / 2)) best = i;
}
label = `${words.slice(0, best).join(' ')}<br>${words.slice(best).join(' ')}`;
}
return { id: `lbl-${Date.now()}-${idx}`, text: label, x: 28, y: 14 + idx * 14, size: 11, bg: '#e2001a', color: '#ffffff', rotate: 0 };
}
// ---------- jednoduché čítanie .xlsx (prvý hárok) ----------
function colIndex(ref: string): number {
const letters = ref.replace(/\d+/g, '');
let n = 0;
for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64);
return n - 1;
}
async function readXlsx(data: ArrayBuffer): Promise<string[][]> {
const xlsx = await JSZip.loadAsync(data);
const parse = async (path: string) => {
const text = await xlsx.file(path)?.async('string');
return text ? new DOMParser().parseFromString(text, 'application/xml') : null;
};
const shared: string[] = [];
const sst = await parse('xl/sharedStrings.xml');
sst?.querySelectorAll('si').forEach((si) => {
shared.push([...si.getElementsByTagName('t')].map((t) => t.textContent || '').join(''));
});
const sheetPath =
xlsx.file('xl/worksheets/sheet1.xml') ? 'xl/worksheets/sheet1.xml' : Object.keys(xlsx.files).find((p) => /^xl\/worksheets\/[^/]+\.xml$/.test(p));
const sheet = sheetPath ? await parse(sheetPath) : null;
if (!sheet) throw new Error('V tabuľke sa nenašiel hárok.');
const rows: string[][] = [];
sheet.querySelectorAll('sheetData > row').forEach((row) => {
const cells: string[] = [];
row.querySelectorAll('c').forEach((c) => {
const type = c.getAttribute('t');
const v = c.getElementsByTagName('v')[0]?.textContent ?? '';
let value = v;
if (type === 's') value = shared[Number(v)] ?? '';
else if (type === 'inlineStr') value = [...c.getElementsByTagName('t')].map((t) => t.textContent || '').join('');
cells[colIndex(c.getAttribute('r') || 'A')] = value;
});
rows.push(Array.from(cells, (x) => x ?? ''));
});
return rows;
}
// ---------- prevody hodnôt ----------
function toNumber(value: string): number | null {
const n = parseFloat(String(value || '').replace(/\s/g, '').replace(',', '.'));
return Number.isFinite(n) ? n : null;
}
const formatEur = (n: number) => n.toFixed(2).replace('.', ',');
// Jednotková cena: "5,39 EUR/kg" → "jednotková cena 5,39 EUR/kg"
// (bez jednotky v podkladoch sa doplní z Množstvo MJ: g → kg, ml → l)
export function formatUnitPrice(value: string, quantityUnit = ''): string {
const v = value.trim();
if (!v) return '';
const m = v.match(/^(?:jednotková cena\s*)?([\d\s.,]+?)\s*(?:EUR|€)?\s*(?:\/\s*(.+))?$/i);
const n = m ? toNumber(m[1]) : null;
if (!m || n == null) return v;
const unit = (m[2] || { g: 'kg', kg: 'kg', ml: 'l', l: 'l', ks: 'ks' }[quantityUnit.trim().toLowerCase()] || '').trim();
return `jednotková cena ${formatEur(n)} EUR${unit ? `/${unit}` : ''}`;
}
// Gramáž / množstvo na konci názvu z letáku: "Ryža guľatozrnná 1 kg", "Cereálie 2 druhy od 225 g do 250 g",
// "Vrecia na smeti 60 l 15 ks 1 bal.". Z konca názvu sa odoberú údaje o množstve; ak je medzi nimi množstvo
// zo stĺpca Množstvo, oddelí sa od neho (to pred ním patrí k názvu, napr. „60 l“ pri vreciach).
const UNIT = String.raw`(?:kg|g|mg|l|ml|cl|dl|ks|bal\.?|balenie|pracích dávok|pracie dávky|pracia dávka|PD|m|cm|mm|tbl\.?|kapsúl|plátkov|rolí|rolky|vreciek)`;
const NUM = String.raw`\d+(?:[.,]\d+)?`;
const AMOUNT = String.raw`(?:(?:${NUM}\s?[x×]\s?)?${NUM}\s?${UNIT}(?![\p{L}]))`;
const TAIL = new RegExp(String.raw`(?:^|\s)((?:od\s+${AMOUNT}\s+do\s+${AMOUNT})|${AMOUNT}|${NUM}\s?[x×]\s?${AMOUNT})\s*$`, 'iu');
export function splitQuantity(title: string, qty = '', qtyUnit = ''): { title: string; quantity: string } {
let rest = title.trim();
const parts: string[] = [];
for (let m = rest.match(TAIL); m; m = rest.match(TAIL)) {
parts.unshift(m[1]);
rest = rest.slice(0, m.index).trim();
}
if (!parts.length) {
// v názve nie je množstvo – doplníme ho zo stĺpcov Množstvo + Množstvo MJ
const n = toNumber(qty);
return { title: rest, quantity: n != null && qtyUnit ? `${String(n).replace('.', ',')} ${qtyUnit.trim()}` : '' };
}
// množstvo zo stĺpca (napr. 15 ks) – časti pred ním sú súčasť názvu
const n = toNumber(qty);
const key = n != null && qtyUnit ? `${n}|${qtyUnit.trim().toLowerCase()}` : '';
const idx = key ? parts.findIndex((p) => amountKeys(p).includes(key)) : -1;
const keep = idx > 0 ? parts.slice(0, idx) : [];
return { title: [rest, ...keep].filter(Boolean).join(' '), quantity: parts.slice(Math.max(idx, 0)).join(' ') };
}
// "0,555 l" → ["0.555|l", "555|ml"] – aby sa dalo porovnať s množstvom zo stĺpcov
function amountKeys(text: string): string[] {
const m = text.match(new RegExp(String.raw`(${NUM})\s?(kg|g|l|ml|ks)\b`, 'i'));
if (!m) return [];
const n = toNumber(m[1])!;
const u = m[2].toLowerCase();
const keys = [`${n}|${u}`];
if (u === 'kg') keys.push(`${+(n * 1000).toFixed(3)}|g`);
if (u === 'g') keys.push(`${+(n / 1000).toFixed(6)}|kg`);
if (u === 'l') keys.push(`${+(n * 1000).toFixed(3)}|ml`);
if (u === 'ml') keys.push(`${+(n / 1000).toFixed(6)}|l`);
return keys;
}
export function splitPrice(n: number): { main: string; sub: string } {
const [main, sub] = n.toFixed(2).split('.');
return { main, sub };
}
// ---------- načítanie ZIP-u ----------
export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Promise<{ products: PodkladyProduct[]; info: string }> {
const zip = await JSZip.loadAsync(file);
const files = Object.values(zip.files).filter((f) => !f.dir && !f.name.startsWith('__MACOSX/'));
const table = files.find((f) => /\.xlsx$/i.test(f.name));
if (!table) throw new Error('V ZIP-e chýba tabuľka .xlsx s podkladmi.');
const info = (await files.find((f) => /_info\.txt$/i.test(f.name))?.async('string'))?.trim() || '';
const [header = [], ...rows] = await readXlsx(await table.async('arraybuffer'));
const col = (...names: string[]) => header.findIndex((h) => names.some((n) => norm(h) === norm(n)));
const c = {
ean: col('EAN'),
title: col('Názov v letáku'),
titleAlt: col('Názov produktu'),
qty: col('Množstvo'),
qtyUnit: col('Množstvo MJ'),
price: col('Akciová cena (leták)', 'Cena (xlsx podklady)'),
regularPrice: col('Bežná cena'),
cardPrice: col('Cena s vernostnou kartou'),
appPrice: col('Cena pre appkáčov'),
extras: col('Doplnkové grafiky'),
unitPrice: col('Jednotková cena'),
discount: col('Zľava %'),
badge: col('Zľava grafická'),
color: col('Farba cenovky'),
origin: col('Pôvod'),
formats: col('Formát'),
deposit: col('Zálohovanie'),
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?');
const get = (row: string[], i: number) => (i >= 0 ? String(row[i] ?? '').trim() : '');
const images = files.filter((f) => /\.(png|jpe?g|webp)$/i.test(f.name));
const products: PodkladyProduct[] = [];
for (const row of rows) {
const ean = get(row, c.ean);
if (!ean) continue;
// fotka: <EAN>/web/… (prípadne hocijaký obrázok v priečinku <EAN>/)
const own = images.filter((f) => f.name.split('/')[0] === ean);
const photoFile = own.find((f) => f.name.includes('/web/')) || own[0];
// šablóna cenovky: podľa tagov šablóny, inak podľa názvu „Super cena | Červená | SK“
const graphic = get(row, c.badge);
const colorText = get(row, c.color);
const color = toBadgeColor(colorText);
const origin = get(row, c.origin);
// prázdna „Zľava grafická“ = základná cenovka (s výkričníkom podľa stĺpca Výkričník)
const type = graphic || (norm(get(row, c.exclaim)) === 'ano' ? 'ZÁKLADNÁ !' : 'ZÁKLADNÁ');
const tries = [[graphic, colorText, origin], [graphic, colorText], [graphic]].map((parts) => parts.filter(Boolean).join(' | '));
const appPrice = toNumber(get(row, c.appPrice));
const byTags = findBadgeByTags(badges, type, color, origin, appPrice != null);
const byName = !byTags && graphic ? tries.map((t) => findBadgeByCode(badges, t)).find(Boolean) : undefined;
const formats = norm(get(row, c.formats));
const deposit = toNumber(get(row, c.deposit));
const discount = toNumber(get(row, c.discount));
products.push({
ean,
...splitQuantity(get(row, c.title) || get(row, c.titleAlt) || ean, get(row, c.qty), get(row, c.qtyUnit)),
price: toNumber(get(row, c.price)),
regularPrice: toNumber(get(row, c.regularPrice)),
cardPrice: toNumber(get(row, c.cardPrice)),
appPrice,
unitPrice: formatUnitPrice(get(row, c.unitPrice), get(row, c.qtyUnit)),
discount: discount ? `${Math.round(discount)}%` : '',
badgeLabel: [BADGE_IMPORT_TYPES.find((t) => t.value === type)?.label || type, colorText, origin, appPrice != null ? 'appka' : '']
.filter(Boolean)
.join(' | '),
badge: byTags?.badge || byName,
badgeMissing: byTags?.missing || [],
color,
stores: formats
? { potraviny: formats.includes('potraviny'), supermarket: formats.includes('supermarket'), tempo: formats.includes('tempo') }
: { potraviny: true, supermarket: true, tempo: true },
deposit: deposit ? formatEur(deposit) : '',
extras: get(row, c.extras)
.split(/[;\n]/)
.map((s) => s.trim())
.filter(Boolean),
photo: photoFile ? await photoFile.async('blob') : undefined,
photoName: photoFile ? photoFile.name.split('/').pop()! : ''
});
}
return { products, info };
}
// Jednotková cena prepočítaná na inú cenu (napr. bez karty): "jednotková cena 5,39 EUR/kg" × 6,49 / 5,39
function scaleUnitPrice(unitPrice: string, from: number, to: number): string {
const m = unitPrice.match(/^(jednotková cena )([\d.,]+)( EUR.*)$/);
const n = m ? toNumber(m[2]) : null;
return m && n != null && from > 0 ? `${m[1]}${formatEur((n * to) / from)}${m[3]}` : '';
}
// 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 {
const base = podkladyToProductItem(p, imageUrl, idx);
delete base.cardBgColor;
const card = p.cardPrice ?? p.price;
const noCard = p.cardPrice != null ? p.price : p.regularPrice;
const c = card != null ? splitPrice(card) : null;
const n = noCard != null ? splitPrice(noCard) : null;
return {
...base,
id: `zip-lp-${Date.now()}-${idx}`,
priceMain: '',
priceSub: '',
unitPrice: '',
discountText: '',
cardPriceMain: c?.main || '',
cardPriceSub: c?.sub || '',
cardDiscount: p.discount,
cardUnitPrice: p.unitPrice,
noCardPriceMain: n?.main || '',
noCardPriceSub: n?.sub || '',
noCardDiscount: '',
noCardUnitPrice: card != null && noCard != null ? scaleUnitPrice(p.unitPrice, card, noCard) : ''
};
}
// Produkt pre kampaň (fotka už nahratá – imageUrl)
export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx: number): ProductItem {
const price = splitPrice(p.price ?? 0);
const card = p.cardPrice != null ? splitPrice(p.cardPrice) : null;
const app = p.appPrice != null ? splitPrice(p.appPrice) : null;
return {
...(p.badge ? { badgeCustomTemplate: p.badge, badgeId: p.badge.id } : {}),
id: `zip-${Date.now()}-${idx}`,
title: p.title,
imageUrl,
priceMain: price.main,
priceSub: price.sub,
quantity: p.quantity,
unitPrice: p.unitPrice,
discountText: p.discount,
...(card ? { cardPriceMain: card.main, cardPriceSub: card.sub } : {}),
...(app ? { appPriceMain: app.main, appPriceSub: app.sub } : {}),
...(p.extras.length ? { labels: p.extras.map(extraLabel) } : {}),
note: p.deposit ? `+ záloha ${p.deposit} €` : '',
badgeText: 'zľava',
stores: p.stores,
...(p.color ? { cardBgColor: CARD_BG_BY_BADGE_COLOR[p.color] } : {})
};
}
+131 -31
View File
@@ -5,7 +5,7 @@
// V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu
// uloží ako JPG (src/lib/cardImages.ts) a do e-mailu ide ako jeden obrázok – rovnako ako karta_produkt_*.jpg
// v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
import { BadgeTemplate, CardProduct, NewsletterModule } from '@/types/newsletter';
import { BadgeTemplate, CardProduct, NewsletterModule, ProductLabel, ProductPhoto } from '@/types/newsletter';
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { pickAlt } from './altText';
@@ -29,9 +29,13 @@ export interface StoresOffset {
x?: number; // + doprava, − doľava
y?: number; // + vyššie nad cenovku, − nižšie (cez horný okraj cenovky)
}
// Predvolená veľkosť fotky produktu (zoom), keď ju produkt nemá nastavenú
export const DEFAULT_IMAGE_SCALE = 0.95;
// Predvolená poloha štítkov voči pravému hornému rohu pozadia cenovky
export const DEFAULT_STORES_OFFSET_X = -15;
export const DEFAULT_STORES_OFFSET_Y = 5;
export const DEFAULT_STORES_OFFSET_X = -23;
// Predvolená výška jedného štítka predajne (px), nezávislá od veľkosti cenovky
export const DEFAULT_STORES_SIZE = 14;
export const DEFAULT_STORES_OFFSET_Y = 8;
// Výška vizuálu: pomer ako v šablóne (294 × 245), pri 1 produkte na riadok max 300 px
export function cardVisualHeight(cardWidth: number): number {
@@ -184,14 +188,27 @@ function renderDualSticker(p: CardProduct): string {
}
// Štítky predajní (Potraviny / Supermarket / Tempo) pod sebou; výška podľa veľkosti cenovky
function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number): string {
const tag = (color: string, file: string, alt: string) =>
`<div style="text-align: right; line-height: 0;"><img src="${escapeHtml(assetUrl(SUPABASE_ASSETS_BASE + file, mode))}" height="${tagHeight}" alt="${alt}" style="display: inline-block; height: ${tagHeight}px; width: auto; border: 0; background-color: ${color};"></div>`;
return [
p.stores?.potraviny ? tag('#e2001a', 'stit_potraviny.png', 'Potraviny') : '',
p.stores?.supermarket ? tag('#f9890e', 'stit_supermarket.png', 'Supermarket') : '',
p.stores?.tempo ? tag('#005239', 'stit_tempo.png', 'Tempo') : ''
].join('');
// 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).
function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number, badgeWidth: number): string {
const t = Math.round(((2.25 * badgeWidth) / 130) * 10) / 10;
const tags = [
p.stores?.potraviny ? ['#e2001a', 'stit_potraviny.png', 'Potraviny'] : null,
p.stores?.supermarket ? ['#f9890e', 'stit_supermarket.png', 'Supermarket'] : null,
p.stores?.tempo ? ['#005239', 'stit_tempo.png', 'Tempo'] : null
].filter((x): x is string[] => !!x);
if (!tags.length) return '';
const row = ([color, file, alt]: string[], halo: boolean) =>
`<div style="text-align: right; line-height: 0;"><img src="${escapeHtml(assetUrl(SUPABASE_ASSETS_BASE + file, mode))}" height="${tagHeight}" alt="${halo ? '' : alt}" style="display: inline-block; height: ${tagHeight}px; width: auto; border: 0; ${
halo
? `background-color: #ffffff; filter: brightness(0) invert(1); padding: ${t}px; margin: -${t}px; border-radius: ${t}px; box-sizing: content-box;`
: `background-color: ${color};`
}"></div>`;
return `<div style="position: relative;">
<div style="position: absolute; inset: 0;" aria-hidden="true">${tags.map((x) => row(x, true)).join('')}</div>
<div style="position: relative;">${tags.map((x) => row(x, false)).join('')}</div>
</div>`;
}
// Jednoznačný kľúč vizuálu – pri zmene čohokoľvek, čo ovplyvní obrázok, sa pri exporte vytvorí nový
@@ -199,13 +216,14 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
const t = p.badgeCustomTemplate;
const relevant = [
ctx.variant, ctx.cardWidth, p.cardBgColor || ctx.cardBg, ctx.variant === 'standard' ? ctx.defaultBadge : null,
p.imageUrl, p.imageScale ?? 1, p.imageOffsetX || 0, p.imageOffsetY || 0,
p.imageUrl, p.imageScale ?? DEFAULT_IMAGE_SCALE, p.imageOffsetX || 0, p.imageOffsetY || 0,
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
t ? [t.svgRaw, t.layersConfig, t.width] : null,
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice,
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice, p.appPriceMain, p.appPriceSub,
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE,
p.labels || [], p.extraPhotos || [], photoStack(p)
];
const str = JSON.stringify(relevant);
let h1 = 5381;
@@ -218,6 +236,51 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
return `cv-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
}
// Poradie fotiek v karte odzadu dopredu ('main' = hlavná fotka). Uložené poradie sa doplní o nové fotky
// (dopredu) a očistí o zmazané; bez uloženého poradia platí staršie nastavenie „za hlavnou fotkou“.
export const MAIN_PHOTO = 'main';
// Predvoľby farby pozadia karty produktu (oranžová, červená a zelená zodpovedajú farbám cenoviek v podkladoch)
export const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
{ label: 'Oranžová', sub: 'Super výhodné', color: '#fdebd8', border: '#f3d2ae' },
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
];
// Farba cenovky z podkladov → pozadie karty
export const CARD_BG_BY_BADGE_COLOR: Record<'cervena' | 'oranzova' | 'zelena', string> = {
cervena: '#feeaea',
oranzova: '#fdebd8',
zelena: '#e3f0e4'
};
export function photoStack(p: Pick<CardProduct, 'extraPhotos' | 'photoOrder'>): string[] {
const extras = p.extraPhotos || [];
const ids = new Set([MAIN_PHOTO, ...extras.map((f) => f.id)]);
if (p.photoOrder?.length) {
const order = p.photoOrder.filter((id) => ids.has(id));
for (const id of ids) if (!order.includes(id)) order.push(id);
return order;
}
return [...extras.filter((f) => f.behind).map((f) => f.id), MAIN_PHOTO, ...extras.filter((f) => !f.behind).map((f) => f.id)];
}
// Ďalšia fotka v karte (bundle): poloha v % vizuálu, veľkosť voči hlavnej fotke
function renderExtraPhoto(f: ProductPhoto, mode: RenderMode): string {
if (!f.url) return '';
return `<img src="${escapeHtml(assetUrl(f.url, mode))}" alt="" style="position: absolute; left: ${f.x}%; top: ${f.y}%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) scale(${f.scale || 1}); border: 0;">`;
}
// Vlastný štítok na fotke (nad fotkou, pod cenovkou) – štýl ako „PODIEL MÄSA 92 %“ z letáku, písmo Aptly Bold (Adobe Fonts)
function renderLabel(l: ProductLabel): string {
if (!l.text) return '';
const size = l.size || 12;
return `<div data-label-id="${escapeHtml(l.id)}" style="position: absolute; left: ${l.x}%; top: ${l.y}%; transform: translate(-50%, -50%) rotate(${l.rotate || 0}deg); background-color: ${l.bg || '#e2001a'}; color: ${l.color || '#ffffff'}; font-family: 'Aptly', 'aptly', 'Arial Narrow', 'Roboto Condensed', Arial, sans-serif; font-weight: 700; font-size: ${size}px; line-height: 1.1; padding: ${Math.round(size * 0.3)}px ${Math.round(size * 0.5)}px; border-radius: ${Math.round(size * 0.25)}px; text-transform: uppercase; text-align: center; white-space: nowrap; letter-spacing: 0.2px;">${escapeWithBreaks(l.text)}</div>`;
}
// Vizuál karty v náhľade: fotka (zoom/posun), cenovka vpravo dole, štítky nad cenovkou
// Viditeľné pozadie cenovky v SVG môže mať okolo seba priehľadný okraj. Štítky predajní ukotvujeme
// k pravému hornému rohu samotného pozadia, preto jeho hranice odmeriame v prehliadači (getBBox).
@@ -262,19 +325,28 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
const height = cardVisualHeight(ctx.cardWidth);
const bg = p.cardBgColor || ctx.cardBg;
const bw = badgeWidthFor(p, ctx.variant);
const scale = p.imageScale ?? 1;
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
const ox = p.imageOffsetX || 0;
const oy = p.imageOffsetY || 0;
const sx = Math.round(p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X);
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
const stores = renderStores(p, ctx.mode, Math.max(10, Math.round(bw * 0.12)));
const stores = renderStores(p, ctx.mode, p.storesSize ?? DEFAULT_STORES_SIZE, bw);
// Pravý horný roh viditeľného pozadia cenovky (pri PNG a predvolenej cenovke = roh cenovky)
const bgBox = p.badgeCustomTemplate && !p.customBadgePngUrl ? badgeBackgroundBox(p.badgeCustomTemplate) : null;
const insetTop = bgBox ? Math.round(bgBox.top * bw * bgBox.aspect) : 0;
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
return `
<div data-card-visual="${key}" style="position: relative; width: 100%; height: ${height}px; overflow: hidden; background-color: ${bg}; border-radius: 16px 16px 0 0; line-height: normal; font-size: 12px;">
${p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : ''}
${photoStack(p)
.map((id) => {
if (id === MAIN_PHOTO) {
return p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : '';
}
const f = (p.extraPhotos || []).find((x) => x.id === id);
return f ? renderExtraPhoto(f, ctx.mode) : '';
})
.join('')}
${(p.labels || []).map(renderLabel).join('')}
<div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;">
${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''}
${renderBadge(p, ctx, bw)}
@@ -282,6 +354,9 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
</div>`;
}
// Šablóna cenovky zobrazuje cenu pre appkáčov
export const usesAppPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('{cena_app}');
// Alt text obrázka karty: názov, gramáž a cena (ako v šablóne); ručne zadaný alt má prednosť
export function productCardAlt(p: CardProduct, variant: CardVariant): string {
const name = [pickAlt(p.title), p.quantity].filter(Boolean).join(' ');
@@ -298,9 +373,11 @@ export function productCardAlt(p: CardProduct, variant: CardVariant): string {
} else {
const main = price(p.priceMain, p.priceSub);
const card = price(p.cardPriceMain, p.cardPriceSub);
const app = usesAppPrice(p.badgeCustomTemplate) ? price(p.appPriceMain, p.appPriceSub) : '';
priceText = [
main ? `${main}${p.discountText ? `, zľava ${pct(p.discountText)}` : ''}` : '',
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : ''
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : '',
app ? `v aplikácii ${app}` : ''
].filter(Boolean).join(', ');
}
return pickAlt(p.imageAlt, [name, priceText].filter(Boolean).join(' – '), 'Produkt');
@@ -333,10 +410,10 @@ function renderTexts(p: CardProduct, ctx: CardContext): string {
: p.unitPrice;
const validity = p.validityOverride || ctx.validity;
const small = (text: string) =>
`<div class="prod-unit force-text-dark" style="margin-top: 2px; ${TEXT_FONT} font-size: 10px; line-height: 1.3; color: #888888;">${escapeHtml(text)}</div>`;
return `<h3 class="prod-name force-text-dark" style="margin: 0; ${TEXT_FONT} font-size: 15px; line-height: 1.25; font-weight: 700; color: #1a1a1a;">${escapeWithBreaks(p.title)}${p.quantity ? ` <span style="font-weight: 400;">${escapeHtml(p.quantity)}</span>` : ''}</h3>${
`<div class="prod-unit force-text-dark" style="margin-top: 2px; ${TEXT_FONT} font-size: 10px; line-height: 1.3; color: #888888;">${escapeWithBreaks(text)}</div>`;
return `<h3 class="prod-name force-text-dark" style="margin: 0; ${TEXT_FONT} font-size: 15px; line-height: 1.25; font-weight: 700; color: #1a1a1a;">${escapeWithBreaks(p.title)}${p.quantity ? ` <span style="font-weight: 400;">${escapeWithBreaks(p.quantity)}</span>` : ''}</h3>${
unitPrice ? `
<div class="prod-unit force-text-dark" style="margin-top: 3px; ${TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeHtml(unitPrice)}</div>` : ''}${
<div class="prod-unit force-text-dark" style="margin-top: 3px; ${TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeWithBreaks(unitPrice)}</div>` : ''}${
p.note ? small(p.note) : ''}${validity ? small(validity) : ''}`;
}
@@ -361,23 +438,46 @@ export function renderProductCard(p: CardProduct, ctx: CardContext): string {
</table>`;
}
// Mriežka kariet (1 alebo 2 na riadok) s rovnakou výškou kariet v riadku
export function renderProductGrid(products: CardProduct[], ctx: CardContext): string {
const cols = ctx.cardWidth > 300 ? 1 : 2;
const rows: CardProduct[][] = [];
for (let i = 0; i < products.length; i += cols) rows.push(products.slice(i, i + cols));
return rows
// Kontext konkrétnej karty: vo vernostných zľavách pri nepárnom počte je prvý produkt cez celý riadok
export function cardContextFor(m: NewsletterModule, index: number, mode: RenderMode): CardContext {
const base = cardContextForModule(m, mode);
const count = (m.data.products || []).length;
if (base.variant === 'loyalty' && count % 2 === 1 && index === 0) return { ...base, cardWidth: 600 };
return base;
}
// Riadky mriežky (indexy produktov): 1 na riadok, alebo po 2;
// vernostné zľavy s nepárnym počtom: prvý produkt sám cez celý riadok, ostatné po 2
function gridRows(m: NewsletterModule, count: number, oneColumn: boolean): number[][] {
const idx = Array.from({ length: count }, (_, i) => i);
if (oneColumn) return idx.map((i) => [i]);
const rows: number[][] = [];
let start = 0;
if (m.type === 'loyalty_club' && count % 2 === 1) {
rows.push([0]);
start = 1;
}
for (let i = start; i < count; i += 2) rows.push(idx.slice(i, i + 2));
return rows;
}
// Mriežka kariet s rovnakou výškou kariet v riadku
export function renderProductGrid(m: NewsletterModule, mode: RenderMode): string {
const products: CardProduct[] = m.data.products || [];
const oneColumn = cardContextForModule(m, mode).cardWidth > 300;
return gridRows(m, products.length, oneColumn)
.map((row, rIdx) => {
const spacer = rIdx > 0 ? '<tr><td height="12" style="font-size: 12px; line-height: 12px;">&nbsp;</td></tr>' : '';
const cells = row
.map(
(p, idx) => `
<td width="${cols === 1 ? 100 : 50}%" valign="top" height="1" style="${cols === 1 ? '' : idx === 0 ? 'padding-right: 6px;' : 'padding-left: 6px;'} height: 100%;">
${renderProductCard(p, ctx)}
(i, idx) => `
<td width="${row.length === 1 ? 100 : 50}%" valign="top" height="1" style="${row.length === 1 ? '' : idx === 0 ? 'padding-right: 6px;' : 'padding-left: 6px;'} height: 100%;">
${renderProductCard(products[i], cardContextFor(m, i, mode))}
</td>`
)
.join('');
const filler = cols === 2 && row.length === 1 ? '<td width="50%" style="padding-left: 6px;"></td>' : '';
// posledný riadok s 1 produktom v 2-stĺpcovej mriežke (bežné sekcie) – produkt ostane v ľavej polovici
const filler = !oneColumn && row.length === 1 && cardContextFor(m, row[0], mode).cardWidth <= 300 ? '<td width="50%" style="padding-left: 6px;"></td>' : '';
return `${spacer}
<tr>
<td>
+95
View File
@@ -0,0 +1,95 @@
// Nahratie súboru do úložiska: server vydá podpísanú adresu, súbor ide z prehliadača priamo do Supabase
// (cez Vercel by väčšie súbory neprešli – limit 4,5 MB na požiadavku).
import { supabase } from './supabase';
export async function uploadFile(file: Blob, name: string): Promise<string> {
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const data = await res.json().catch(() => ({ error: `Server vrátil chybu ${res.status}` }));
if (!data.success || !data.path || !data.token) {
throw new Error(data.error || 'Nahratie súboru zlyhalo');
}
const { error } = await supabase.storage
.from('newsletter-assets')
.uploadToSignedUrl(data.path, data.token, file, { contentType: file.type || 'image/png' });
if (error) throw new Error(error.message);
return data.url;
}
// Zmenšenie veľkej fotky pred nahratím: v e-maile má obrázok najviac 600 px, dlhšia strana 1000 px stačí
// aj na ostré displeje – tlačové podklady s tisíckami px a desiatkami MB sú zbytočné (bucket berie najviac 10 MB).
// PNG ostáva PNG kvôli priehľadnosti; SVG a GIF (animácie) sa nechávajú tak.
const MAX_SIDE = 1000;
const MAX_BYTES = 9 * 1024 * 1024;
export async function shrinkImage(file: File): Promise<File> {
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) return file;
const bitmap = await createImageBitmap(file).catch(() => null);
if (!bitmap) return file;
if (Math.max(bitmap.width, bitmap.height) <= MAX_SIDE && file.size <= MAX_BYTES) {
bitmap.close();
return file;
}
const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
let side = Math.min(MAX_SIDE, Math.max(bitmap.width, bitmap.height));
let blob: Blob | null = null;
for (let i = 0; i < 4; i++) {
const k = side / Math.max(bitmap.width, bitmap.height);
const canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * k);
canvas.height = Math.round(bitmap.height * k);
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, type, 0.9));
if (!blob || blob.size <= MAX_BYTES) break;
side = Math.round(side * 0.75);
}
bitmap.close();
if (!blob) return file;
const name = type === 'image/jpeg' ? file.name.replace(/\.\w+$/, '.jpg') : file.name;
return new File([blob], name, { type });
}
// Je adresa už v našom úložisku?
export function isOwnStorageUrl(url: string): boolean {
return url.includes('/storage/v1/object/public/newsletter-assets/');
}
export type RemoteImageResult =
| { status: 'skip' } // nie je to cudzia URL (naše úložisko, nedokončený text)
| { status: 'small' } // obrázok je dosť malý, adresa ostáva
| { status: 'shrunk'; url: string } // zmenšený a uložený k nám
| { status: 'login' } // server obrázka chce prihlásenie – v e-maile sa nezobrazí
| { status: 'failed'; error: string };
// Obrázok vložený cez URL: stiahne sa (priamo, ak to cudzí server dovolí, inak cez /api/img),
// a ak je väčší ako 1000 px, zmenší sa a nahrá do úložiska.
export async function shrinkRemoteImage(url: string): Promise<RemoteImageResult> {
if (!/^https?:\/\/[^/]+\.[^/]+\//i.test(url) || isOwnStorageUrl(url)) return { status: 'skip' };
let blob: Blob | null = null;
try {
const res = await fetch(url);
if (res.ok) blob = await res.blob();
} catch {
// CORS – skúsime cez náš proxy
}
if (!blob || !blob.type.startsWith('image/')) {
const res = await fetch(`/api/img?url=${encodeURIComponent(url)}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (data.upstreamStatus === 401 || data.upstreamStatus === 403) return { status: 'login' };
return { status: 'failed', error: data.error || `Chyba ${res.status}` };
}
blob = await res.blob();
}
const name = decodeURIComponent(new URL(url).pathname.split('/').pop() || '') || 'obrazok.png';
const file = new File([blob], name, { type: blob.type });
const small = await shrinkImage(file);
if (small === file) return { status: 'small' };
return { status: 'shrunk', url: await uploadFile(small, small.name) };
}
+50
View File
@@ -30,6 +30,8 @@ export interface ProductItem {
cardPriceMain?: string; // cena s kartou (vernostná), celé eurá
cardPriceSub?: string; // cena s kartou, centy
cardDiscount?: string; // zľava s kartou, napr. "30%"
appPriceMain?: string; // cena pre appkáčov (v aplikácii), celé eurá → {cena_app}
appPriceSub?: string; // cena pre appkáčov, centy
badgeText: string; // napr. "AKCIA" alebo "SUPER CENA"
badgeId?: string; // priradená cenovka z databázy
badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny)
@@ -49,6 +51,10 @@ export interface ProductItem {
imageAlt?: string; // Ručný alt text fotky (inak názov produktu)
storesOffsetX?: number; // posun štítkov predajní voči cenovke (+ doprava)
storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie)
storesSize?: number; // výška jedného štítka predajne v px (nezávislá od veľkosti cenovky)
labels?: ProductLabel[]; // vlastné štítky na fotke (napr. „PODIEL MÄSA 92 %“)
extraPhotos?: ProductPhoto[]; // ďalšie fotky v karte (bundle) popri hlavnej fotke
photoOrder?: string[]; // poradie fotiek odzadu dopredu: id ďalších fotiek a 'main' (hlavná fotka)
imageScale?: number; // Zoom fotky (1 = 100 %)
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol)
@@ -66,6 +72,28 @@ export interface CardProduct extends ProductItem {
noCardUnitPrice?: string;
}
// Ďalšia fotka v karte produktu (napr. bundle 2 produktov)
export interface ProductPhoto {
id: string;
url: string;
x: number; // stred fotky, % šírky vizuálu (0–100)
y: number; // stred fotky, % výšky vizuálu (0–100)
scale: number; // 1 = rovnaká veľkosť ako hlavná fotka bez zoomu
behind?: boolean; // (staršie) za hlavnou fotkou – nahradené poradím photoOrder
}
// Vlastný štítok na fotke produktu
export interface ProductLabel {
id: string;
text: string; // <br> = nový riadok
x: number; // stred štítka, % šírky fotky (0–100)
y: number; // stred štítka, % výšky fotky (0–100)
size: number; // veľkosť písma v px
bg: string; // farba pozadia
color: string; // farba textu
rotate?: number; // natočenie v stupňoch
}
export interface AnnouncementModuleData {
tipBadge: string;
text: string;
@@ -93,6 +121,7 @@ export interface BannerModuleData {
linkUrl: string;
altText: string;
title?: string;
titleColor?: string; // farba nadpisu pod bannerom (predvolene #1a1a1a)
description?: string;
buttonText?: string;
buttonUrl?: string;
@@ -200,6 +229,8 @@ export interface BadgeLayer {
indexFontSize?: number; // Veľkosť horného indexu v px
indexOffsetY?: number; // Vertikálny posun horného indexu v px (posun hore / dole)
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)
}
export interface BadgeTemplate {
@@ -213,4 +244,23 @@ export interface BadgeTemplate {
align?: 'left' | 'center' | 'right'; // Zarovnanie (vľavo, stred, vpravo)
paddingTop?: number;
paddingBottom?: number;
importTags?: BadgeImportTags; // podľa čoho sa šablóna vyberie pri importe podkladov (ZIP)
isDefault?: boolean; // predvolená cenovka produktov – nedá sa zmazať
}
// Druhy cenoviek v podkladoch: „Zľava grafická“, a keď je prázdna, základná cenovka s výkričníkom alebo bez
export const BADGE_IMPORT_TYPES = [
{ value: 'SUPER CENA', label: 'SUPER CENA' },
{ value: 'SUPER VÝHODNÉ', label: 'SUPER VÝHODNÉ' },
{ value: 'ZÁKLADNÁ !', label: 'Základná s výkričníkom' },
{ value: 'ZÁKLADNÁ', label: 'Základná (bez nápisu)' }
];
// Tagy šablóny cenovky pre import podkladov – zodpovedajú stĺpcom tabuľky z Databázy produktov
export type BadgeColor = 'cervena' | 'oranzova' | 'zelena';
export interface BadgeImportTags {
type?: string; // druh cenovky – jeden z BADGE_IMPORT_TYPES (SUPER CENA, SUPER VÝHODNÉ, ZÁKLADNÁ, ZÁKLADNÁ !)
color?: BadgeColor; // „Farba cenovky“
origin?: 'SK' | ''; // „Pôvod“: SK alebo bez pôvodu
app?: boolean; // cenovka pre appkáčov – pre produkty s „Cenou pre appkáčov“
}