Compare commits
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# Skopírujte tento súbor ako .env.local a doplňte hodnoty.
|
||||
|
||||
# Supabase (Project Settings → API)
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://VAS-PROJEKT.supabase.co
|
||||
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
|
||||
# E-mail odosielateľa musí byť v Brevo overený
|
||||
BREVO_SENDER_EMAIL=
|
||||
@@ -49,3 +49,6 @@ docs/
|
||||
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
# vzor premenných prostredia sa commituje
|
||||
!.env.example
|
||||
@@ -1,36 +1,82 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# COOP Jednota – generátor newsletterov
|
||||
|
||||
## Getting Started
|
||||
Interný editor e-mailových newsletterov pre COOP Jednota: skladanie kampane z modulov
|
||||
(hlavička, bannery, produktové sekcie, vernostné zľavy, letáky, recepty, pätička),
|
||||
editor grafických cenoviek, export do ZIP a odoslanie kampane do Brevo.
|
||||
|
||||
First, run the development server:
|
||||
Technológie: Next.js 16, React 19, Tailwind CSS 4, Supabase (databáza + úložisko obrázkov), Brevo API.
|
||||
|
||||
## Inštalácia na nový server
|
||||
|
||||
### 1. Supabase projekt
|
||||
|
||||
1. Vytvorte nový projekt na [supabase.com](https://supabase.com) (alebo použite vlastný Supabase server).
|
||||
2. **Databáza:** otvorte *SQL Editor*, vložte celý obsah [`supabase/schema.sql`](supabase/schema.sql) a kliknite *Run*.
|
||||
Vytvoria sa tabuľky `templates` (kampane) a `badges` (šablóny cenoviek), prístupové pravidlá
|
||||
a verejné úložisko obrázkov `newsletter-assets`. Skript sa dá bezpečne spustiť aj opakovane.
|
||||
3. V *Project Settings → API* si skopírujte **Project URL**, **anon** kľúč a **service_role** kľúč.
|
||||
|
||||
### 2. Premenné prostredia
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Doplňte do `.env.local` údaje zo Supabase a Brevo (popis je priamo v súbore).
|
||||
Na hostingu (Vercel, Docker…) nastavte rovnaké premenné v jeho nastaveniach.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### 3. Inštalácia a statické obrázky
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
npm install
|
||||
npm run db:upload-assets
|
||||
```
|
||||
|
||||
## Learn More
|
||||
Druhý príkaz nahrá do úložiska `newsletter-assets` logá, ikony sociálnych sietí, štítky predajní
|
||||
a ukážkové obrázky zo [`supabase/assets/`](supabase/assets/), ktoré šablóna newslettera používa.
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
### 4. Prenos existujúcich kampaní a cenoviek (voliteľné)
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
V [`supabase/data/`](supabase/data/) je export kampaní a šablón cenoviek z pôvodného projektu.
|
||||
Na novom serveri (po kroku 1–3) ich naimportujete:
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
```bash
|
||||
npm run db:import -- --dry-run # skúška: vypíše, čo sa naimportuje, nič nezapíše
|
||||
npm run db:import
|
||||
```
|
||||
|
||||
## Deploy on Vercel
|
||||
Import zachová pôvodné ID, odkazy na obrázky v pôvodnom úložisku prepíše na nové
|
||||
a dá sa spustiť opakovane. Čerstvý export z pôvodného projektu (pred odovzdaním) urobí
|
||||
majiteľ pôvodného Supabase príkazom `npm run db:export` a commitne priečinok `supabase/data/`.
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
### 5. Spustenie
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
```bash
|
||||
npm run dev # vývoj na http://localhost:3000
|
||||
npm run build # produkčný build
|
||||
npm run start # produkčný server
|
||||
```
|
||||
|
||||
Pri prvom spustení s prázdnou databázou sa vytvorí nová kampaň z predvolenej šablóny
|
||||
([`src/lib/defaultCampaign.ts`](src/lib/defaultCampaign.ts)) a automaticky sa uloží.
|
||||
|
||||
## Štruktúra projektu
|
||||
|
||||
| Cesta | Obsah |
|
||||
|---|---|
|
||||
| `src/app/page.tsx` | Hlavná obrazovka editora (stav kampane, auto-save, náhľad) |
|
||||
| `src/components/editor/` | Panel nastavení, editor cenoviek, modály (import, uložené kampane) |
|
||||
| `src/lib/emailRenderer.ts` | Generovanie HTML e-mailu z kampane (náhľad aj export) |
|
||||
| `src/lib/badgeUtils.ts` | Generovanie SVG cenoviek a premenné (`{cena}`, `{zlava}`, `{cena_s_kartou}`…) |
|
||||
| `src/app/api/` | Serverové API: nahrávanie obrázkov, inline CSS, export a overenie kampane v Brevo |
|
||||
| `supabase/schema.sql` | Schéma databázy a úložiska |
|
||||
| `supabase/assets/` | Statické obrázky šablóny (nahrávajú sa cez `npm run db:upload-assets`) |
|
||||
| `supabase/data/` | Export kampaní a cenoviek (`npm run db:export` / `npm run db:import`) |
|
||||
| `scripts/` | Skripty na nahratie obrázkov, export a import dát |
|
||||
|
||||
## Poznámky
|
||||
|
||||
- Aplikácia nemá prihlasovanie a databáza je prístupná cez anon kľúč – je určená na interné použitie.
|
||||
Ak bude verejne dostupná, treba doplniť autentifikáciu.
|
||||
- Nové kampane a novo nahraté obrázky používajú úložisko z `.env.local`; importované kampane
|
||||
majú odkazy prepísané na nové úložisko.
|
||||
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"db:upload-assets": "node --env-file=.env.local scripts/upload-assets.mjs",
|
||||
"db:export": "node --env-file=.env.local scripts/export-data.mjs",
|
||||
"db:import": "node --env-file=.env.local scripts/import-data.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.117.1",
|
||||
|
||||
@@ -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']))
|
||||
@@ -0,0 +1,69 @@
|
||||
// Export kampaní (templates) a šablón cenoviek (badges) zo Supabase do supabase/data/
|
||||
// pre prenos na nový server. Stiahne aj obrázky z úložiska newsletter-assets, na ktoré
|
||||
// dáta odkazujú a nie sú medzi statickými obrázkami v supabase/assets/.
|
||||
// Spustenie: npm run db:export (číta .env.local pôvodného projektu)
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { mkdir, writeFile, access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const BUCKET = 'newsletter-assets';
|
||||
const TABLES = ['templates', 'badges'];
|
||||
const DATA_DIR = path.join(process.cwd(), 'supabase', 'data');
|
||||
const FILES_DIR = path.join(DATA_DIR, 'files');
|
||||
const ASSETS_DIR = path.join(process.cwd(), 'supabase', 'assets');
|
||||
|
||||
const url = (process.env.NEXT_PUBLIC_SUPABASE_URL || '').replace(/\/$/, '');
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!url || !key) {
|
||||
console.error('Chýba NEXT_PUBLIC_SUPABASE_URL alebo kľúč (SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY) v .env.local');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(url, key);
|
||||
const storageBase = `${url}/storage/v1/object/public/${BUCKET}/`;
|
||||
await mkdir(FILES_DIR, { recursive: true });
|
||||
|
||||
const referenced = new Set();
|
||||
for (const table of TABLES) {
|
||||
const { data, error } = await supabase.from(table).select('*').order('created_at', { ascending: true });
|
||||
if (error) {
|
||||
console.error(`✗ ${table}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
await writeFile(path.join(DATA_DIR, `${table}.json`), JSON.stringify(data, null, 2) + '\n');
|
||||
console.log(`✓ ${table}: ${data.length} záznamov`);
|
||||
|
||||
// Odkazy na súbory v úložisku (cesta v rámci bucketu)
|
||||
const json = JSON.stringify(data);
|
||||
let i = 0;
|
||||
while ((i = json.indexOf(storageBase, i)) !== -1) {
|
||||
const start = i + storageBase.length;
|
||||
let end = start;
|
||||
while (end < json.length && !`"'()\\ `.includes(json[end])) end++;
|
||||
referenced.add(decodeURIComponent(json.slice(start, end)));
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
|
||||
const exists = (p) => access(p).then(() => true, () => false);
|
||||
let downloaded = 0;
|
||||
for (const filePath of referenced) {
|
||||
// Statické obrázky šablóny sú už v supabase/assets a nahrajú sa cez db:upload-assets
|
||||
if (!filePath.includes('/') && (await exists(path.join(ASSETS_DIR, filePath)))) continue;
|
||||
const res = await fetch(storageBase + filePath.split('/').map(encodeURIComponent).join('/'));
|
||||
if (!res.ok) {
|
||||
console.warn(`! ${filePath}: HTTP ${res.status} (preskočené)`);
|
||||
continue;
|
||||
}
|
||||
const target = path.join(FILES_DIR, filePath);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, Buffer.from(await res.arrayBuffer()));
|
||||
downloaded++;
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(DATA_DIR, 'meta.json'),
|
||||
JSON.stringify({ exportedAt: new Date().toISOString(), sourceStorageBase: storageBase }, null, 2) + '\n'
|
||||
);
|
||||
console.log(`✓ obrázky: ${referenced.size} odkazov, ${downloaded} stiahnutých do supabase/data/files/`);
|
||||
console.log('\nHotovo. Commitnite priečinok supabase/data/ a na novom serveri spustite npm run db:import.');
|
||||
@@ -0,0 +1,79 @@
|
||||
// Import kampaní a šablón cenoviek zo supabase/data/ (vytvorené cez npm run db:export) do nového Supabase.
|
||||
// - nahrá obrázky zo supabase/data/files/ do úložiska newsletter-assets
|
||||
// - odkazy na pôvodné úložisko v dátach prepíše na úložisko nového projektu
|
||||
// - záznamy vloží s pôvodnými ID; existujúce záznamy s rovnakým ID prepíše (dá sa spustiť opakovane)
|
||||
// Predpoklad: spustený supabase/schema.sql. Spustenie: npm run db:import
|
||||
// Skúška bez zápisu: npm run db:import -- --dry-run
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const BUCKET = 'newsletter-assets';
|
||||
const TABLES = ['templates', 'badges'];
|
||||
const DATA_DIR = path.join(process.cwd(), 'supabase', 'data');
|
||||
const FILES_DIR = path.join(DATA_DIR, 'files');
|
||||
const CONTENT_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp' };
|
||||
|
||||
const url = (process.env.NEXT_PUBLIC_SUPABASE_URL || '').replace(/\/$/, '');
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!url || !serviceKey) {
|
||||
console.error('Chýba NEXT_PUBLIC_SUPABASE_URL alebo SUPABASE_SERVICE_ROLE_KEY v .env.local');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const supabase = createClient(url, serviceKey);
|
||||
if (dryRun) console.log('Skúšobný režim (--dry-run): nič sa nezapíše.');
|
||||
const meta = JSON.parse(await readFile(path.join(DATA_DIR, 'meta.json'), 'utf8'));
|
||||
const newStorageBase = `${url}/storage/v1/object/public/${BUCKET}/`;
|
||||
|
||||
// 1. Obrázky
|
||||
async function listFiles(dir, prefix = '') {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) out.push(...(await listFiles(path.join(dir, e.name), rel)));
|
||||
else out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const files = await listFiles(FILES_DIR);
|
||||
let failed = 0;
|
||||
for (const rel of files) {
|
||||
if (dryRun) continue;
|
||||
const { error } = await supabase.storage.from(BUCKET).upload(rel, await readFile(path.join(FILES_DIR, rel)), {
|
||||
contentType: CONTENT_TYPES[path.extname(rel).toLowerCase()] || 'application/octet-stream',
|
||||
upsert: true
|
||||
});
|
||||
if (error) {
|
||||
failed++;
|
||||
console.error(`✗ súbor ${rel}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`${dryRun ? '•' : '✓'} obrázky: ${files.length - failed}/${files.length} ${dryRun ? 'na nahratie' : 'nahratých'}`);
|
||||
|
||||
// 2. Dáta (s prepisom odkazov na nové úložisko)
|
||||
for (const table of TABLES) {
|
||||
const raw = await readFile(path.join(DATA_DIR, `${table}.json`), 'utf8');
|
||||
const rewritten = raw.split(meta.sourceStorageBase).length - 1;
|
||||
const rows = JSON.parse(raw.split(meta.sourceStorageBase).join(newStorageBase));
|
||||
if (dryRun) {
|
||||
console.log(`• ${table}: ${rows.length} záznamov, ${meta.sourceStorageBase === newStorageBase ? 0 : rewritten} prepísaných odkazov na úložisko`);
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < rows.length; i += 50) {
|
||||
const { error } = await supabase.from(table).upsert(rows.slice(i, i + 50), { onConflict: 'id' });
|
||||
if (error) {
|
||||
console.error(`✗ ${table}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(`✓ ${table}: ${rows.length} záznamov`);
|
||||
}
|
||||
|
||||
if (meta.sourceStorageBase !== newStorageBase) {
|
||||
console.log(`\nOdkazy ${meta.sourceStorageBase} → ${newStorageBase}`);
|
||||
}
|
||||
console.log('Hotovo.');
|
||||
if (failed) process.exit(1);
|
||||
@@ -0,0 +1,45 @@
|
||||
// Nahrá statické obrázky aplikácie (supabase/assets/) do Supabase Storage bucketu newsletter-assets.
|
||||
// Spustenie: npm run db:upload-assets (číta NEXT_PUBLIC_SUPABASE_URL a SUPABASE_SERVICE_ROLE_KEY z .env.local)
|
||||
// Existujúce súbory s rovnakým názvom sa prepíšu.
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const BUCKET = 'newsletter-assets';
|
||||
const ASSETS_DIR = path.join(process.cwd(), 'supabase', 'assets');
|
||||
const CONTENT_TYPES = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!url || !serviceKey) {
|
||||
console.error('Chýba NEXT_PUBLIC_SUPABASE_URL alebo SUPABASE_SERVICE_ROLE_KEY v .env.local');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(url, serviceKey);
|
||||
const files = (await readdir(ASSETS_DIR)).filter((f) => CONTENT_TYPES[path.extname(f).toLowerCase()]);
|
||||
|
||||
let failed = 0;
|
||||
for (const file of files) {
|
||||
const body = await readFile(path.join(ASSETS_DIR, file));
|
||||
const { error } = await supabase.storage.from(BUCKET).upload(file, body, {
|
||||
contentType: CONTENT_TYPES[path.extname(file).toLowerCase()],
|
||||
upsert: true
|
||||
});
|
||||
if (error) {
|
||||
failed++;
|
||||
console.error(`✗ ${file}: ${error.message}`);
|
||||
} else {
|
||||
console.log(`✓ ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nNahraté ${files.length - failed}/${files.length} súborov do bucketu ${BUCKET}.`);
|
||||
if (failed) process.exit(1);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -63,7 +64,7 @@ export async function POST(req: NextRequest) {
|
||||
success: true,
|
||||
updated: true,
|
||||
campaignId: Number(existingCampaignId),
|
||||
brevoUrl: 'https://app.brevo.com/campaigns/listing',
|
||||
brevoUrl: getBrevoCampaignUrl(existingCampaignId),
|
||||
brevoListingUrl: 'https://app.brevo.com/campaigns/listing',
|
||||
message: 'Kampaň bola úspešne aktualizovaná v Brevo!'
|
||||
});
|
||||
@@ -113,7 +114,7 @@ export async function POST(req: NextRequest) {
|
||||
success: true,
|
||||
updated: false,
|
||||
campaignId: data.id,
|
||||
brevoUrl: 'https://app.brevo.com/campaigns/listing',
|
||||
brevoUrl: getBrevoCampaignUrl(data.id),
|
||||
brevoListingUrl: 'https://app.brevo.com/campaigns/listing',
|
||||
message: 'Kampaň bola úspešne vytvorená v Brevo!'
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
@@ -51,7 +52,7 @@ export async function GET(req: NextRequest) {
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
subject: data.subject,
|
||||
brevoUrl: 'https://app.brevo.com/campaigns/listing',
|
||||
brevoUrl: getBrevoCampaignUrl(data.id),
|
||||
brevoListingUrl: 'https://app.brevo.com/campaigns/listing'
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Proxy obrázkov pre náhľad produktových kariet: obrázky z iných domén idú cez rovnaký pôvod,
|
||||
// aby sa vizuál karty dal pri exporte uložiť ako obrázok (canvas nesmie byť "tainted" cez CORS).
|
||||
const MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const target = new URL(request.url).searchParams.get('url');
|
||||
if (!target || !/^https?:\/\//i.test(target)) {
|
||||
return Response.json({ error: 'Neplatná adresa obrázka.' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
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}).`, upstreamStatus: res.status }, { status: 502 });
|
||||
}
|
||||
const body = await res.arrayBuffer();
|
||||
if (body.byteLength > MAX_BYTES) {
|
||||
return Response.json({ error: 'Obrázok je príliš veľký.' }, { status: 413 });
|
||||
}
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
'cache-control': 'public, max-age=86400'
|
||||
}
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Chyba pri načítaní obrázka';
|
||||
return Response.json({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -23,6 +23,12 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from '@/types/newsletter';
|
||||
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE } from '@/lib/defaultCampaign';
|
||||
import { renderEmailHtml, renderSingleModuleHtml } from '@/lib/emailRenderer';
|
||||
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';
|
||||
import { ReviewModal } from '@/components/review/ReviewModal';
|
||||
import { AnnotationLayer, useModuleLayout } from '@/components/review/AnnotationLayer';
|
||||
import { ReviewComment } from '@/lib/reviews';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN);
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky');
|
||||
// Produkt otvorený v paneli (harmonika); nastaví sa aj klikom na produkt v náhľade
|
||||
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
||||
|
||||
// Pripomienky zo zdieľaného odkazu (zobrazujú sa aj na plátne, dajú sa označiť ako hotové)
|
||||
const reviews = useCampaignReview(campaign);
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const [showReviewOverlay, setShowReviewOverlay] = useState(true);
|
||||
const [selectedCommentId, setSelectedCommentId] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const canvasLayout = useModuleLayout(canvasRef, [campaign.modules, reviews.comments.length, showReviewOverlay]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isExportingBrevo, setIsExportingBrevo] = useState(false);
|
||||
const [brevoUrl, setBrevoUrl] = useState<string | null>(null);
|
||||
@@ -26,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);
|
||||
|
||||
@@ -40,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));
|
||||
@@ -86,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';
|
||||
|
||||
@@ -122,6 +143,12 @@ export default function Home() {
|
||||
return [...topMods, ...middleMods, ...flyerMods, ...recipeMods, ...footerMods];
|
||||
};
|
||||
|
||||
// Auto-save sa spustí až po načítaní kampane z databázy. Inak by sa pri pomalšom načítaní
|
||||
// predvolená kampaň uložila ako nová kópia a ďalšie zmeny by sa ukladali do nej.
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
// Prebiehajúce vloženie novej kampane – kým nemáme jej id, ďalšie vloženie by vytvorilo duplikát
|
||||
const insertInFlight = useRef(false);
|
||||
|
||||
// Načítanie existujúceho draftu z databázy pri štarte
|
||||
useEffect(() => {
|
||||
async function loadInitial() {
|
||||
@@ -134,22 +161,24 @@ export default function Home() {
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const dbItem = data[0];
|
||||
if (dbItem.data && dbItem.data.modules && dbItem.data.modules.length >= 7) {
|
||||
if (Array.isArray(dbItem.data?.modules) && dbItem.data.modules.length > 0) {
|
||||
setCampaign({
|
||||
id: dbItem.id,
|
||||
title: dbItem.title,
|
||||
preheader: dbItem.data.preheader || DEFAULT_CAMPAIGN.preheader,
|
||||
status: dbItem.status,
|
||||
brevoCampaignId: dbItem.brevo_campaign_id,
|
||||
modules: enforceAnchors(dbItem.data.modules)
|
||||
modules: enforceAnchors(ensureValidityBar(dbItem.data.modules))
|
||||
});
|
||||
if (dbItem.brevo_campaign_id) {
|
||||
setBrevoUrl('https://app.brevo.com/campaigns/listing');
|
||||
setBrevoUrl(getBrevoCampaignUrl(dbItem.brevo_campaign_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading template from Supabase:', err);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}
|
||||
loadInitial();
|
||||
@@ -157,10 +186,13 @@ export default function Home() {
|
||||
|
||||
// Auto-save s debounce (1 sekunda)
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!campaign.id && insertInFlight.current) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (!campaign.id) {
|
||||
insertInFlight.current = true;
|
||||
const { data } = await supabase
|
||||
.from('templates')
|
||||
.insert([
|
||||
@@ -176,7 +208,8 @@ export default function Home() {
|
||||
.select();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
setCampaign((prev) => ({ ...prev, id: data[0].id }));
|
||||
// id nastavíme len ak medzitým nebola otvorená iná kampaň
|
||||
setCampaign((prev) => (prev.id ? prev : { ...prev, id: data[0].id }));
|
||||
}
|
||||
} else {
|
||||
await supabase
|
||||
@@ -194,12 +227,13 @@ export default function Home() {
|
||||
} catch (err) {
|
||||
console.error('Auto-save error:', err);
|
||||
} finally {
|
||||
insertInFlight.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [campaign.title, campaign.preheader, campaign.modules, campaign.id]);
|
||||
}, [isLoaded, campaign.title, campaign.preheader, campaign.modules, campaign.id]);
|
||||
|
||||
const selectedModule = campaign.modules.find((m) => m.id === selectedModuleId) || null;
|
||||
|
||||
@@ -233,7 +267,7 @@ export default function Home() {
|
||||
// Duplikácia: Ukotvené moduly sa nedajú duplikovať
|
||||
const duplicateModule = (index: number) => {
|
||||
const original = campaign.modules[index];
|
||||
if (isAnchoredModule(original)) return;
|
||||
if (isAnchoredModule(original) || original.type === 'validity_bar') return;
|
||||
|
||||
const clone: NewsletterModule = {
|
||||
...original,
|
||||
@@ -255,6 +289,10 @@ export default function Home() {
|
||||
alert('Hlavičku, letáky, články/recepty ani pätičku nie je možné vymazať.');
|
||||
return;
|
||||
}
|
||||
if (mod.type === 'validity_bar') {
|
||||
alert('Lištu platnosti akcie nie je možné vymazať, dá sa len skryť.');
|
||||
return;
|
||||
}
|
||||
if (campaign.modules.length <= 1) return;
|
||||
|
||||
const newModules = campaign.modules.filter((_, i) => i !== index);
|
||||
@@ -316,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);
|
||||
@@ -326,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);
|
||||
@@ -391,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([
|
||||
@@ -418,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}`);
|
||||
@@ -427,8 +486,19 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
// Pred exportom sa vizuály produktových kariet uložia ako obrázky (nezmenené sa nenahrávajú znova)
|
||||
const prepareForExport = async (): Promise<NewsletterCampaign> => {
|
||||
const { campaign: prepared, failed } = await prepareCardImages(campaign);
|
||||
setCampaign((prev) => ({ ...prev, modules: prepared.modules }));
|
||||
if (failed > 0) {
|
||||
alert(`${failed} kariet produktov sa nepodarilo uložiť ako obrázok – v e-maile budú v jednoduchšej podobe.`);
|
||||
}
|
||||
return prepared;
|
||||
};
|
||||
|
||||
const handleExportZip = async () => {
|
||||
const rawHtml = renderEmailHtml(campaign);
|
||||
const prepared = await prepareForExport();
|
||||
const rawHtml = renderEmailHtml(prepared);
|
||||
let finalHtml = rawHtml;
|
||||
try {
|
||||
const res = await fetch('/api/inline-css', {
|
||||
@@ -451,7 +521,8 @@ export default function Home() {
|
||||
const handleExportBrevo = async (updateExisting = false) => {
|
||||
setIsExportingBrevo(true);
|
||||
try {
|
||||
const rawHtml = renderEmailHtml(campaign);
|
||||
const prepared = await prepareForExport();
|
||||
const rawHtml = renderEmailHtml(prepared);
|
||||
let finalHtml = rawHtml;
|
||||
try {
|
||||
const res = await fetch('/api/inline-css', {
|
||||
@@ -561,6 +632,19 @@ 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, 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, storesSize: size })) } }
|
||||
: m
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
// Hromadná aplikácia cenovky na všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
||||
const handleApplyBadgeToAll = (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => {
|
||||
setCampaign((prev) => ({
|
||||
@@ -571,7 +655,8 @@ export default function Home() {
|
||||
...m,
|
||||
data: {
|
||||
...m.data,
|
||||
products: (m.data.products || []).map((prod: any) => ({
|
||||
// Produkty s vlastnou PNG cenovkou (výnimky) sa neprepisujú
|
||||
products: (m.data.products || []).map((prod: any) => prod.customBadgePngUrl ? prod : ({
|
||||
...prod,
|
||||
badgeId: badge.id,
|
||||
badgeCustomTemplate: badge,
|
||||
@@ -579,8 +664,7 @@ export default function Home() {
|
||||
badgeAlign: badge.align ?? prod.badgeAlign ?? 'center',
|
||||
badgePaddingTop: badge.paddingTop ?? prod.badgePaddingTop ?? 4,
|
||||
badgePaddingBottom: badge.paddingBottom ?? prod.badgePaddingBottom ?? 4,
|
||||
badgeLayout: badgeLayout ?? prod.badgeLayout,
|
||||
customBadgePngUrl: undefined
|
||||
badgeLayout: badgeLayout ?? prod.badgeLayout
|
||||
}))
|
||||
}
|
||||
};
|
||||
@@ -590,6 +674,20 @@ export default function Home() {
|
||||
}));
|
||||
};
|
||||
|
||||
// Spoločné nastavenia (globálna platnosť akcie, zobrazenie platnosti na kartách) sa prepíšu
|
||||
// do všetkých produktových sekcií aj vernostných zliav
|
||||
// (produkty s vlastnou platnosťou validityOverride si ju ponechajú)
|
||||
const handleUpdateAllProductSections = (patch: Record<string, unknown>) => {
|
||||
setCampaign((prev) => ({
|
||||
...prev,
|
||||
modules: prev.modules.map((m) =>
|
||||
m.type === 'products_grid' || m.type === 'loyalty_club'
|
||||
? { ...m, data: { ...m.data, ...patch } }
|
||||
: m
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const topCount = campaign.modules.filter((m) => isTopModule(m)).length;
|
||||
const firstBottomIdx = campaign.modules.findIndex((m) => isBottomModule(m));
|
||||
|
||||
@@ -602,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}
|
||||
@@ -610,6 +708,8 @@ export default function Home() {
|
||||
brevoListingUrl="https://app.brevo.com/campaigns/listing"
|
||||
onClearBrevoId={handleClearBrevoId}
|
||||
onAddModule={handleAddModule}
|
||||
onOpenReviews={() => setIsReviewOpen(true)}
|
||||
reviewOpenCount={reviews.openCount}
|
||||
/>
|
||||
|
||||
{/* 2. Hlavný pracovný priestor */}
|
||||
@@ -622,8 +722,12 @@ export default function Home() {
|
||||
backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)'
|
||||
}}
|
||||
>
|
||||
{/* Zvýraznenie produktu otvoreného v paneli */}
|
||||
{selectedProductId && (
|
||||
<style>{`main [data-product-id="${selectedProductId.replace(/["\\]/g, '')}"] { outline: 3px solid #ef4444; outline-offset: 3px; }`}</style>
|
||||
)}
|
||||
{/* E-mail Container 1:1 k originálu */}
|
||||
<div className="w-full max-w-[600px] my-4">
|
||||
<div ref={canvasRef} className="relative w-full max-w-[600px] h-fit my-4">
|
||||
{campaign.modules.map((mod, idx) => {
|
||||
const isSelected = mod.id === selectedModuleId;
|
||||
const isAnchored = isAnchoredModule(mod);
|
||||
@@ -638,7 +742,17 @@ export default function Home() {
|
||||
return (
|
||||
<div
|
||||
key={mod.id}
|
||||
onClick={() => setSelectedModuleId(mod.id)}
|
||||
data-module-id={mod.id}
|
||||
onClick={(e) => {
|
||||
// Klik na kartu produktu otvorí v paneli práve tento produkt
|
||||
const card = (e.target as HTMLElement).closest('[data-product-id]');
|
||||
if (card) {
|
||||
setSelectedProductId(card.getAttribute('data-product-id'));
|
||||
} else if (mod.id !== selectedModuleId) {
|
||||
setSelectedProductId(null);
|
||||
}
|
||||
setSelectedModuleId(mod.id);
|
||||
}}
|
||||
className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${
|
||||
isSelected
|
||||
? 'ring-2 ring-offset-4 ring-offset-[#001a33] ring-red-500 shadow-2xl'
|
||||
@@ -690,6 +804,26 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{mod.type === 'validity_bar' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateModule({
|
||||
...mod,
|
||||
data: { ...mod.data, enabled: mod.data.enabled === false }
|
||||
});
|
||||
}}
|
||||
className={`text-[9px] font-bold px-2 py-0.5 rounded-full border transition-all mr-1 ${
|
||||
mod.data.enabled === false
|
||||
? 'bg-red-500/20 text-red-300 border-red-400/30 hover:bg-red-500/30'
|
||||
: 'bg-emerald-500/20 text-emerald-300 border-emerald-400/30 hover:bg-emerald-500/30'
|
||||
}`}
|
||||
title={mod.data.enabled === false ? 'Zobraziť lištu v e-maile' : 'Skryť lištu v e-maile'}
|
||||
>
|
||||
{mod.data.enabled === false ? 'Skrytá (zapnúť)' : 'Zobrazená'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -712,26 +846,30 @@ export default function Home() {
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateModule(idx);
|
||||
}}
|
||||
className="hover:text-blue-400 p-1 ml-1"
|
||||
title="Duplikovať sekciu"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteModule(idx);
|
||||
}}
|
||||
className="hover:text-red-400 p-1 ml-1"
|
||||
title="Zmazať sekciu"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{mod.type !== 'validity_bar' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateModule(idx);
|
||||
}}
|
||||
className="hover:text-blue-400 p-1 ml-1"
|
||||
title="Duplikovať sekciu"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteModule(idx);
|
||||
}}
|
||||
className="hover:text-red-400 p-1 ml-1"
|
||||
title="Zmazať sekciu"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -756,6 +894,24 @@ export default function Home() {
|
||||
Aktivovať vernostné zľavy
|
||||
</button>
|
||||
</div>
|
||||
) : mod.type === 'validity_bar' && mod.data.enabled === false ? (
|
||||
<div className="p-4 my-2 border-2 border-dashed border-red-500/40 bg-red-950/30 rounded-xl text-center">
|
||||
<p className="text-xs font-bold text-red-300 uppercase tracking-wider">
|
||||
Lišta platnosti akcie (Skrytá)
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-300 mt-0.5">
|
||||
Táto lišta je v e-maile skrytá. Kliknutím sem ju môžete aktivovať.
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateModule({ ...mod, data: { ...mod.data, enabled: true } });
|
||||
}}
|
||||
className="mt-2 px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs font-bold rounded-lg transition-colors shadow-sm"
|
||||
>
|
||||
Aktivovať lištu platnosti
|
||||
</button>
|
||||
</div>
|
||||
) : mod.type === 'announcement' && mod.data.enabled === false ? (
|
||||
<div className="p-4 my-2 border-2 border-dashed border-red-500/40 bg-red-950/30 rounded-xl text-center">
|
||||
<p className="text-xs font-bold text-red-300 uppercase tracking-wider">
|
||||
@@ -779,21 +935,65 @@ export default function Home() {
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSingleModuleHtml(mod, campaign)
|
||||
}}
|
||||
className="pointer-events-none"
|
||||
onClick={(e) => {
|
||||
// odkazy v náhľade neotvárame, klik slúži na výber modulu / produktu
|
||||
if ((e.target as HTMLElement).closest('a')) e.preventDefault();
|
||||
}}
|
||||
className="[&_[data-product-id]]:cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pripomienky zo zdieľaného odkazu */}
|
||||
{showReviewOverlay && reviews.comments.length > 0 && (
|
||||
<AnnotationLayer
|
||||
layout={canvasLayout}
|
||||
comments={reviews.comments}
|
||||
selectedId={selectedCommentId}
|
||||
onSelect={setSelectedCommentId}
|
||||
renderActions={(c: ReviewComment) => (
|
||||
<>
|
||||
<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={() => {
|
||||
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"
|
||||
>
|
||||
Zmazať
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</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}
|
||||
onSelectProduct={setSelectedProductId}
|
||||
onUpdateModule={handleUpdateModule}
|
||||
onApplyBadgeToAll={handleApplyBadgeToAll}
|
||||
onApplyStoresOffsetToAll={handleApplyStoresOffsetToAll}
|
||||
onUpdateAllProductSections={handleUpdateAllProductSections}
|
||||
campaignValidity={getCampaignValidity(campaign)}
|
||||
availableBadges={availableBadges}
|
||||
onOpenBadgeManager={(pId) => {
|
||||
setTargetProductIdForBadge(pId);
|
||||
@@ -804,11 +1004,36 @@ export default function Home() {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Pripomienky: odkaz a zoznam */}
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
hasCampaignId={!!campaign.id}
|
||||
review={reviews.review}
|
||||
comments={reviews.comments}
|
||||
error={reviews.error}
|
||||
busy={reviews.busy}
|
||||
showOverlay={showReviewOverlay}
|
||||
onToggleOverlay={() => setShowReviewOverlay(!showReviewOverlay)}
|
||||
onCreate={reviews.create}
|
||||
onUpdateSnapshot={reviews.updateSnapshot}
|
||||
onToggleDone={reviews.toggleDone}
|
||||
onDelete={reviews.remove}
|
||||
onShowComment={(c) => {
|
||||
setShowReviewOverlay(true);
|
||||
setSelectedCommentId(c.id);
|
||||
setIsReviewOpen(false);
|
||||
document.querySelector(`[data-module-id="${c.module_id}"]`)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modál Hromadného importu */}
|
||||
<ProductImportModal
|
||||
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) */}
|
||||
@@ -820,18 +1045,33 @@ export default function Home() {
|
||||
currentPriceMain={targetProduct?.priceMain || targetProduct?.cardPriceMain || '1'}
|
||||
currentPriceSub={targetProduct?.priceSub || targetProduct?.cardPriceSub || '99'}
|
||||
currentUnitPrice={targetProduct?.unitPrice || targetProduct?.cardUnitPrice || '1kg = 4,91 €'}
|
||||
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) => {
|
||||
setCampaign({ ...selected, modules: enforceAnchors(selected.modules) });
|
||||
setCampaign({ ...selected, modules: enforceAnchors(ensureValidityBar(selected.modules)) });
|
||||
setSelectedModuleId(selected.modules[1]?.id || selected.modules[0]?.id || null);
|
||||
if (selected.brevoCampaignId) {
|
||||
setBrevoUrl('https://app.brevo.com/campaigns');
|
||||
setBrevoUrl(getBrevoCampaignUrl(selected.brevoCampaignId));
|
||||
} else {
|
||||
setBrevoUrl(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
'use client';
|
||||
|
||||
// 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, Trash2 } from 'lucide-react';
|
||||
import { renderSingleModuleHtml } from '@/lib/emailRenderer';
|
||||
import {
|
||||
Review,
|
||||
ReviewComment,
|
||||
ReviewKind,
|
||||
REVIEW_COLORS,
|
||||
getReviewByToken,
|
||||
listReviewComments,
|
||||
addReviewComment,
|
||||
deleteReviewComment
|
||||
} from '@/lib/reviews';
|
||||
import { AnnotationLayer, pointToModuleCoords, useModuleLayout } from '@/components/review/AnnotationLayer';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
|
||||
type Tool = 'view' | ReviewKind;
|
||||
|
||||
interface Draft {
|
||||
kind: 'comment' | 'text';
|
||||
moduleId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const AUTHOR_KEY = 'coop_review_author';
|
||||
const POLL_MS = 10000;
|
||||
|
||||
const TOOLS: Array<{ id: Tool; label: string; icon: React.ElementType; hint: string }> = [
|
||||
{ id: 'view', label: 'Prezerať', icon: MousePointer2, hint: 'Prezeranie a výber pripomienok' },
|
||||
{ id: 'comment', label: 'Komentár', icon: MessageCircle, hint: 'Kliknite do newslettra a napíšte komentár' },
|
||||
{ id: 'text', label: 'Text', icon: Type, hint: 'Kliknite do newslettra a napíšte text priamo do neho' },
|
||||
{ id: 'drawing', label: 'Kresliť', icon: Pencil, hint: 'Ťahaním myšou kreslite do newslettra' }
|
||||
];
|
||||
|
||||
export default function ReviewPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params);
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [comments, setComments] = useState<ReviewComment[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tool, setTool] = useState<Tool>('comment');
|
||||
const [color, setColor] = useState(REVIEW_COLORS[0]);
|
||||
// meno sa pamätá v prehliadači (stránka sa vykreslí až po načítaní odkazu, takže bez rozdielu so serverom)
|
||||
const [author, setAuthor] = useState(() => {
|
||||
try {
|
||||
return typeof window === 'undefined' ? '' : localStorage.getItem(AUTHOR_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [livePath, setLivePath] = useState<{ moduleId: string; points: Array<[number, number]> } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const layout = useModuleLayout(containerRef, [review, comments.length]);
|
||||
|
||||
const refreshComments = useCallback(async (reviewId: string) => {
|
||||
try {
|
||||
setComments(await listReviewComments(reviewId));
|
||||
} catch (e) {
|
||||
console.warn('Pripomienky sa nepodarilo načítať:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await getReviewByToken(token);
|
||||
if (!r) {
|
||||
setError('Odkaz neexistuje alebo bol zrušený.');
|
||||
return;
|
||||
}
|
||||
setReview(r);
|
||||
await refreshComments(r.id);
|
||||
timer = setInterval(() => refreshComments(r.id), POLL_MS);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Náhľad sa nepodarilo načítať.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => clearInterval(timer);
|
||||
}, [token, refreshComments]);
|
||||
|
||||
const saveAuthor = (value: string) => {
|
||||
setAuthor(value);
|
||||
try {
|
||||
localStorage.setItem(AUTHOR_KEY, value);
|
||||
} catch {
|
||||
// localStorage nemusí byť dostupný
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (data: { kind: ReviewKind; moduleId: string; x: number; y: number; text?: string; path?: Array<[number, number]> }) => {
|
||||
if (!review) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await addReviewComment({
|
||||
review_id: review.id,
|
||||
kind: data.kind,
|
||||
module_id: data.moduleId,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
path: data.path || null,
|
||||
text: data.text || '',
|
||||
color,
|
||||
author: author.trim() || 'Anonym'
|
||||
});
|
||||
setComments((prev) => [...prev, created]);
|
||||
if (data.kind !== 'drawing') setSelectedId(created.id);
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Pripomienku sa nepodarilo uložiť.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const coords = pointToModuleCoords(container, e.clientX, e.clientY);
|
||||
if (!coords) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
setDraft({ kind: tool, ...coords, left: e.clientX - rect.left, top: e.clientY - rect.top });
|
||||
setDraftText('');
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const submitDraft = async () => {
|
||||
if (!draft || !draftText.trim()) return;
|
||||
await save({ kind: draft.kind, moduleId: draft.moduleId, x: draft.x, y: draft.y, text: draftText.trim() });
|
||||
setDraft(null);
|
||||
setDraftText('');
|
||||
};
|
||||
|
||||
// Kreslenie: body sa ukotvia k modulu, v ktorom ťah začal
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (tool !== 'drawing' || !containerRef.current) return;
|
||||
const start = pointToModuleCoords(containerRef.current, e.clientX, e.clientY);
|
||||
if (!start) return;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setLivePath({ moduleId: start.moduleId, points: [[start.x, start.y]] });
|
||||
};
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!livePath || !containerRef.current) return;
|
||||
const p = pointToModuleCoords(containerRef.current, e.clientX, e.clientY, livePath.moduleId);
|
||||
if (p) setLivePath({ ...livePath, points: [...livePath.points, [p.x, p.y]] });
|
||||
};
|
||||
const onPointerUp = async () => {
|
||||
if (!livePath) return;
|
||||
const path = livePath;
|
||||
setLivePath(null);
|
||||
if (path.points.length < 2) return;
|
||||
await save({ kind: 'drawing', moduleId: path.moduleId, x: path.points[0][0], y: path.points[0][1], path: path.points });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#001a33] text-white gap-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> Načítavam náhľad…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error || !review) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#001a33] p-6">
|
||||
<p className="max-w-md text-center text-sm text-white/90 bg-white/10 rounded-xl p-5">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const campaign: NewsletterCampaign = { id: '', status: 'draft', ...review.snapshot };
|
||||
const openCount = comments.filter((c) => !c.done).length;
|
||||
const livePreview = livePath && layout
|
||||
? livePath.points.map(([x, y]) => `${x * layout.width},${(layout.tops[livePath.moduleId] ?? 0) + y}`).join(' ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-slate-950 font-sans">
|
||||
{/* Horná lišta: nástroje */}
|
||||
<header className="h-14 shrink-0 bg-white border-b border-gray-200 px-4 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#e2001a] flex items-center justify-center text-white font-black text-[11px]">COOP</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-gray-900 truncate">{review.title || 'Newsletter'}</p>
|
||||
<p className="text-[10px] text-gray-500">Náhľad na pripomienky · aktualizovaný {new Date(review.updated_at).toLocaleString('sk-SK')}</p>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-xl">
|
||||
{TOOLS.map(({ id, label, icon: Icon, hint }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
title={hint}
|
||||
onClick={() => {
|
||||
setTool(id);
|
||||
setDraft(null);
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-bold transition-colors ${
|
||||
tool === id ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{REVIEW_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-5 h-5 rounded-full border-2 ${color === c ? 'border-gray-900 scale-110' : 'border-white shadow'}`}
|
||||
style={{ backgroundColor: c }}
|
||||
title="Farba"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Newsletter */}
|
||||
<main
|
||||
className="flex-1 overflow-y-auto p-4 md:p-8 flex justify-center"
|
||||
style={{ backgroundColor: '#001a33', backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)' }}
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
<div ref={containerRef} className="relative w-full max-w-[600px] h-fit my-4 select-none">
|
||||
{review.snapshot.modules.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
data-module-id={m.id}
|
||||
className="pointer-events-none"
|
||||
dangerouslySetInnerHTML={{ __html: renderSingleModuleHtml(m, campaign) }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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' && (
|
||||
<div
|
||||
className={`absolute inset-0 z-20 ${tool === 'drawing' ? 'cursor-crosshair touch-none' : 'cursor-copy'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCanvasClick(e);
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rozkreslená čiara */}
|
||||
{livePath && (
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-40 overflow-visible">
|
||||
<polyline points={livePreview} fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Okienko na napísanie komentára / textu */}
|
||||
{draft && (
|
||||
<div
|
||||
className="absolute z-50 w-64 bg-white rounded-xl shadow-2xl border border-gray-200 p-2.5"
|
||||
style={{ left: Math.min(draft.left, (layout?.width || 600) - 256), top: draft.top + 6 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={3}
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitDraft();
|
||||
}
|
||||
if (e.key === 'Escape') setDraft(null);
|
||||
}}
|
||||
placeholder={draft.kind === 'comment' ? 'Napíšte komentár…' : 'Text, ktorý sa zobrazí v newslettri…'}
|
||||
className="w-full text-xs border border-gray-300 rounded-lg p-2 resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-[10px] text-gray-400">Enter = uložiť, Shift+Enter = nový riadok</span>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setDraft(null)} className="text-[11px] font-bold text-gray-500 px-2 py-1">
|
||||
Zrušiť
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !draftText.trim()}
|
||||
onClick={submitDraft}
|
||||
className="text-[11px] font-bold text-white bg-[#e2001a] disabled:opacity-40 px-2.5 py-1 rounded-lg"
|
||||
>
|
||||
Uložiť
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Zoznam pripomienok */}
|
||||
<aside className="w-80 shrink-0 bg-white border-l border-gray-200 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-100 space-y-2">
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wider">Vaše meno</span>
|
||||
<input
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(e) => saveAuthor(e.target.value)}
|
||||
placeholder="Meno, aby bolo jasné, kto pripomienku napísal"
|
||||
className={`mt-1 w-full text-xs border rounded-lg p-2 ${author.trim() ? 'border-gray-300' : 'border-amber-400 bg-amber-50'}`}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{TOOLS.find((t) => t.id === tool)?.hint}. Pripomienky uvidí každý, kto má tento odkaz.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-[11px] font-bold text-gray-700 flex items-center justify-between">
|
||||
<span>Pripomienky ({comments.length})</span>
|
||||
<span className="text-gray-400 font-medium">{openCount} otvorených</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-1.5">
|
||||
{comments.length === 0 && <p className="text-xs text-gray-400 px-1 py-6 text-center">Zatiaľ žiadne pripomienky.</p>}
|
||||
{comments.map((c) => {
|
||||
const number = comments.filter((x) => x.kind === 'comment').indexOf(c) + 1;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedId(c.id === selectedId ? null : c.id)}
|
||||
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' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-4 h-4 rounded-full text-[9px] font-black text-white flex items-center justify-center shrink-0" style={{ backgroundColor: c.color }}>
|
||||
{c.kind === 'comment' ? number : c.kind === 'text' ? 'T' : '✎'}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,45 +2,38 @@
|
||||
|
||||
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;
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
placeholder?: string;
|
||||
// Voliteľný ručný alt text; prázdny = použije sa automatický (altPlaceholder)
|
||||
altValue?: string;
|
||||
onAltChange?: (alt: string) => void;
|
||||
altPlaceholder?: string;
|
||||
}
|
||||
|
||||
export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
||||
label = 'Obrázok',
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'https://... alebo nahrajte súbor'
|
||||
placeholder = 'https://... alebo nahrajte súbor',
|
||||
altValue,
|
||||
onAltChange,
|
||||
altPlaceholder
|
||||
}) => {
|
||||
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) {
|
||||
@@ -49,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>}
|
||||
@@ -74,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 */}
|
||||
@@ -107,6 +157,23 @@ 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"
|
||||
value={altValue || ''}
|
||||
onChange={(e) => onAltChange(e.target.value)}
|
||||
placeholder={altPlaceholder ? `Alt text (automaticky: ${altPlaceholder})` : 'Alt text'}
|
||||
title="Alt text obrázka. Ak ho nevyplníte, doplní sa automaticky."
|
||||
className="w-full text-[11px] border border-gray-200 rounded-lg px-2 py-1.5 bg-white placeholder:text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,716 @@
|
||||
'use client';
|
||||
|
||||
// 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, 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_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 { 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>;
|
||||
|
||||
interface ProductEditorProps {
|
||||
product: CardProduct;
|
||||
index: number;
|
||||
variant: Variant;
|
||||
availableBadges: BadgeTemplate[];
|
||||
open: boolean; // harmonika: otvorený je vždy len jeden produkt
|
||||
onToggle: () => void;
|
||||
onChange: (patch: Patch) => void;
|
||||
onDelete?: () => void;
|
||||
onOpenBadgeEditor?: () => void;
|
||||
onApplyBadgeToSection?: () => void;
|
||||
onApplyBadgeToAll?: () => void;
|
||||
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';
|
||||
|
||||
const Field: React.FC<{ label: string; children: React.ReactNode; className?: string }> = ({ label, children, className }) => (
|
||||
<label className={`block ${className || ''}`}>
|
||||
<span className="text-[10px] text-gray-500 font-semibold block mb-0.5">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
const TextInput: React.FC<{ value?: string; onChange: (v: string) => void; placeholder?: string; bold?: boolean }> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
bold
|
||||
}) => (
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`${inputCls} ${bold ? 'font-bold' : ''}`}
|
||||
/>
|
||||
);
|
||||
|
||||
const Slider: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
display: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
onChange: (v: number) => void;
|
||||
}> = ({ label, value, display, min, max, step, onChange }) => (
|
||||
<div className="grid grid-cols-[64px_1fr_44px] items-center gap-2">
|
||||
<span className="text-[10px] text-gray-600 font-semibold">{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1 accent-red-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-gray-700 text-right">{display}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Block: React.FC<{ title: string; action?: React.ReactNode; children: React.ReactNode }> = ({ title, action, children }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-gray-700 uppercase tracking-wider">{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
<button type="button" onClick={onClick} className="text-[10px] font-bold text-gray-400 hover:text-red-600">
|
||||
Obnoviť
|
||||
</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');
|
||||
|
||||
export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
product: p,
|
||||
index,
|
||||
variant,
|
||||
availableBadges,
|
||||
open,
|
||||
onToggle,
|
||||
onChange,
|
||||
onDelete,
|
||||
onOpenBadgeEditor,
|
||||
onApplyBadgeToSection,
|
||||
onApplyBadgeToAll,
|
||||
onApplyStoresOffsetToAll
|
||||
}) => {
|
||||
// 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 ?? 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);
|
||||
|
||||
const selectBadge = (id: string) => {
|
||||
if (id === 'default') {
|
||||
onChange({ badgeCustomTemplate: undefined, badgeId: undefined, customBadgePngUrl: undefined, badgeWidth: undefined });
|
||||
return;
|
||||
}
|
||||
const found = availableBadges.find((b) => b.id === id);
|
||||
if (found) {
|
||||
onChange({ badgeCustomTemplate: found, badgeId: found.id, badgeWidth: found.width ?? 110, customBadgePngUrl: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const text = (key: TextKey) => p[key] as string | undefined;
|
||||
|
||||
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`}>
|
||||
<Field label="€">
|
||||
<TextInput value={text(mainKey)} onChange={set(mainKey)} bold />
|
||||
</Field>
|
||||
<Field label="Centy">
|
||||
<TextInput value={text(subKey)} onChange={set(subKey)} bold />
|
||||
</Field>
|
||||
{discountKey && (
|
||||
<Field label="Zľava">
|
||||
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{unitKey && (
|
||||
<Field label="Jednotková cena">
|
||||
<TextInput value={text(unitKey)} onChange={set(unitKey)} placeholder="jednotková cena 9,43 EUR/kg" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`rounded-xl border bg-gray-50 overflow-hidden scroll-mt-2 ${open ? 'border-red-400 ring-1 ring-red-200' : 'border-gray-200'}`}
|
||||
>
|
||||
{/* Hlavička produktu – klik rozbalí / zbalí */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 bg-white">
|
||||
<button type="button" onClick={onToggle} className="flex-1 flex items-center gap-2 min-w-0 text-left">
|
||||
<span className="w-8 h-8 rounded-md bg-gray-100 border border-gray-200 shrink-0 overflow-hidden flex items-center justify-center">
|
||||
{p.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={p.imageUrl} alt="" className="w-full h-full object-contain" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-900 truncate">{pickAlt(p.title) || `Produkt ${index + 1}`}</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 shrink-0 ml-auto transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Zmazať produkt "${pickAlt(p.title) || index + 1}"?`)) onDelete();
|
||||
}}
|
||||
className="text-gray-300 hover:text-red-600 p-1"
|
||||
title="Zmazať produkt"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="p-3 space-y-4 border-t border-gray-200">
|
||||
{/* TEXTY */}
|
||||
<Block title="Texty">
|
||||
<Field label="Názov (Enter = nový riadok)">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={p.title || ''}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
className={`${inputCls} font-bold resize-y`}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Field label="Gramáž / množstvo">
|
||||
<TextInput value={p.quantity} onChange={set('quantity')} placeholder="35 g" />
|
||||
</Field>
|
||||
{isLoyalty ? (
|
||||
<Field label="Poznámka">
|
||||
<TextInput value={p.note} onChange={set('note')} placeholder="Ilustračné foto" />
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Jednotková cena">
|
||||
<TextInput value={p.unitPrice} onChange={set('unitPrice')} placeholder="jednotková cena 9,43 EUR/kg" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{!isLoyalty && (
|
||||
<Field label="Poznámka">
|
||||
<TextInput value={p.note} onChange={set('note')} placeholder="Ilustračné foto" />
|
||||
</Field>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{/* FOTKA */}
|
||||
<Block
|
||||
title="Fotka"
|
||||
action={(scale !== 1 || offsetX !== 0 || offsetY !== 0) && (
|
||||
<ResetLink onClick={() => onChange({ imageScale: undefined, imageOffsetX: undefined, imageOffsetY: undefined })} />
|
||||
)}
|
||||
>
|
||||
<ImageUploadInput
|
||||
label=""
|
||||
value={p.imageUrl || ''}
|
||||
onChange={set('imageUrl')}
|
||||
altValue={p.imageAlt}
|
||||
onAltChange={set('imageAlt')}
|
||||
altPlaceholder={productCardAlt({ ...p, imageAlt: undefined }, variant)}
|
||||
/>
|
||||
<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 || (isLoyalty ? 'default' : availableBadges.find((b) => b.isDefault)?.id || '')}
|
||||
onChange={(e) => selectBadge(e.target.value)}
|
||||
className={`${inputCls} font-medium`}
|
||||
>
|
||||
{/* bežné produkty: predvolená je šablóna označená ako predvolená (nastaví sa automaticky) */}
|
||||
{isLoyalty && <option value="default">Predvolená (bez karty / s kartou)</option>}
|
||||
{/* 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>}
|
||||
</select>
|
||||
{onOpenBadgeEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBadgeEditor}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-gray-300 bg-white text-gray-600 hover:text-red-600 hover:border-red-300"
|
||||
title="Editor cenoviek (vytvoriť / upraviť šablóny)"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Slider label="Veľkosť" value={badgeWidth} display={`${badgeWidth} px`} min={60} max={200} step={1} onChange={set('badgeWidth')} />
|
||||
|
||||
{isLoyalty ? (
|
||||
<>
|
||||
{priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount', 'cardUnitPrice')}
|
||||
{priceRow('Bez karty', 'noCardPriceMain', 'noCardPriceSub', 'noCardDiscount', 'noCardUnitPrice')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{priceRow('Cena', 'priceMain', 'priceSub', 'discountText')}
|
||||
{usesCardPrice(p.badgeCustomTemplate) && priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount')}
|
||||
{usesAppPrice(p.badgeCustomTemplate) && priceRow('Pre appkáčov', 'appPriceMain', 'appPriceSub')}
|
||||
</>
|
||||
)}
|
||||
|
||||
{p.badgeCustomTemplate && (onApplyBadgeToSection || onApplyBadgeToAll) && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{onApplyBadgeToSection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApplyBadgeToSection}
|
||||
className="flex items-center gap-1 text-[10px] font-bold text-gray-700 bg-white border border-gray-300 hover:bg-gray-100 px-2 py-1 rounded-lg"
|
||||
>
|
||||
<Layers className="w-3 h-3" /> Použiť v celej sekcii
|
||||
</button>
|
||||
)}
|
||||
{onApplyBadgeToAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApplyBadgeToAll}
|
||||
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" /> Použiť v celom newsletteri
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{/* DOSTUPNOSŤ */}
|
||||
<Block
|
||||
title="Dostupnosť"
|
||||
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">
|
||||
{(
|
||||
[
|
||||
['potraviny', 'Potraviny', 'bg-[#e2001a]'],
|
||||
['supermarket', 'Supermarket', 'bg-[#f9890e]'],
|
||||
['tempo', 'Tempo', 'bg-[#005239]']
|
||||
] as const
|
||||
).map(([key, label, color]) => {
|
||||
const on = !!p.stores?.[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onChange({ stores: { ...(p.stores || {}), [key]: !on } })}
|
||||
className={`text-[10px] font-bold py-1.5 rounded-lg border transition-colors ${
|
||||
on ? `${color} text-white border-transparent` : 'bg-white text-gray-400 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasStores && (
|
||||
<>
|
||||
<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, 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 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">
|
||||
Ďalšie možnosti
|
||||
</summary>
|
||||
<div className="pt-3 space-y-3">
|
||||
<Field label="Farba karty">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{PRODUCT_BG_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.color}
|
||||
type="button"
|
||||
title={preset.label}
|
||||
onClick={() => onChange({ cardBgColor: preset.color })}
|
||||
className={`w-6 h-6 rounded-full border ${
|
||||
(p.cardBgColor || '').toLowerCase() === preset.color ? 'ring-2 ring-red-500 border-white' : 'border-black/15'
|
||||
}`}
|
||||
style={{ backgroundColor: preset.color }}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={p.cardBgColor || '#feeaea'}
|
||||
onChange={(e) => onChange({ cardBgColor: e.target.value })}
|
||||
className="w-6 h-6 rounded cursor-pointer border border-gray-300 p-0 bg-white"
|
||||
title="Vlastná farba"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="Vlastná platnosť (len pre tento produkt)">
|
||||
<TextInput value={p.validityOverride} onChange={set('validityOverride')} placeholder="napr. 2. 5. – 9. 5. 2026" />
|
||||
</Field>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<ImageUploadInput
|
||||
label="Vlastná PNG cenovka (výnimka)"
|
||||
value={p.customBadgePngUrl || ''}
|
||||
onChange={(url) => onChange({ customBadgePngUrl: url || undefined })}
|
||||
placeholder="Celá cenovka ako PNG – prekryje šablónu"
|
||||
/>
|
||||
{p.customBadgePngUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ customBadgePngUrl: undefined })}
|
||||
className="w-full py-1 text-[10px] font-bold text-amber-800 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-lg"
|
||||
>
|
||||
Odstrániť PNG a vrátiť šablónu
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
Edit2,
|
||||
RefreshCw,
|
||||
UploadCloud,
|
||||
Check
|
||||
Check,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||
|
||||
interface TopBarProps {
|
||||
campaign: NewsletterCampaign;
|
||||
@@ -31,6 +33,8 @@ interface TopBarProps {
|
||||
brevoListingUrl?: string | null;
|
||||
onClearBrevoId?: () => void;
|
||||
onAddModule: (type: any) => void;
|
||||
onOpenReviews?: () => void;
|
||||
reviewOpenCount?: number; // počet otvorených pripomienok
|
||||
}
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = ({
|
||||
@@ -43,10 +47,10 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
onExportZip,
|
||||
onExportBrevo,
|
||||
isExportingBrevo,
|
||||
brevoCampaignUrl,
|
||||
brevoListingUrl = 'https://app.brevo.com/campaigns/listing',
|
||||
onClearBrevoId,
|
||||
onAddModule
|
||||
onAddModule,
|
||||
onOpenReviews,
|
||||
reviewOpenCount = 0
|
||||
}) => {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [isVerifyingBrevo, setIsVerifyingBrevo] = useState(false);
|
||||
@@ -99,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}
|
||||
@@ -138,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>
|
||||
)}
|
||||
|
||||
@@ -174,6 +174,23 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
|
||||
{/* Pravá časť: Uložiť, Duplikovať, Export do ZIP a Inteligentné Brevo akcie */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Pripomienky cez zdieľaný odkaz */}
|
||||
{onOpenReviews && (
|
||||
<button
|
||||
onClick={onOpenReviews}
|
||||
className="relative flex items-center gap-1.5 text-xs font-bold text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 px-3 py-1.5 rounded-xl transition-all shadow-xs"
|
||||
title="Odkaz na pripomienky a zoznam pripomienok"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-gray-600" />
|
||||
<span className="hidden md:inline">Pripomienky</span>
|
||||
{reviewOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-[#e2001a] text-white text-[10px] font-black flex items-center justify-center">
|
||||
{reviewOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tlačidlo manuálneho uloženia */}
|
||||
<button
|
||||
onClick={onSave}
|
||||
@@ -214,11 +231,11 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
<div className="flex items-center gap-1.5 bg-emerald-50 border border-emerald-200 p-1 rounded-xl">
|
||||
{/* Odkaz na zoznam / úpravu v Brevo */}
|
||||
<a
|
||||
href={brevoCampaignUrl || brevoListingUrl || 'https://app.brevo.com/campaigns/listing'}
|
||||
href={getBrevoCampaignUrl(campaign.brevoCampaignId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1 text-xs font-bold text-white bg-emerald-600 hover:bg-emerald-700 px-3 py-1.2 rounded-lg transition-all shadow-xs"
|
||||
title={`Kampaň #${campaign.brevoCampaignId} je v Brevo (${brevoCampaignStatus || 'draft'}). Kliknutím otvoríte zoznam kampaní v Brevo.`}
|
||||
title={`Kampaň #${campaign.brevoCampaignId} je v Brevo (${brevoCampaignStatus || 'draft'}). Kliknutím ju otvoríte v Brevo.`}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>Otvoriť v Brevo</span>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
// Vrstva pripomienok nad newsletterom (stránka s odkazom aj editor).
|
||||
// Kontajner musí mať position: relative a moduly v ňom atribút data-module-id.
|
||||
import React, { useLayoutEffect, useState } from 'react';
|
||||
import { ReviewComment } from '@/lib/reviews';
|
||||
|
||||
interface ModuleLayout {
|
||||
width: number;
|
||||
tops: Record<string, number>;
|
||||
}
|
||||
|
||||
// Prepočet súradníc kliknutia na súradnice ukotvené k modulu
|
||||
export function pointToModuleCoords(
|
||||
container: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
moduleId?: string
|
||||
): { moduleId: string; x: number; y: number } | null {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const modules = Array.from(container.querySelectorAll<HTMLElement>('[data-module-id]'));
|
||||
const target = moduleId
|
||||
? modules.find((m) => m.dataset.moduleId === moduleId)
|
||||
: modules.find((m) => {
|
||||
const r = m.getBoundingClientRect();
|
||||
return clientY >= r.top && clientY <= r.bottom;
|
||||
}) || modules[modules.length - 1];
|
||||
if (!target || !rect.width) return null;
|
||||
const top = target.getBoundingClientRect().top;
|
||||
return {
|
||||
moduleId: target.dataset.moduleId!,
|
||||
x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
|
||||
y: Math.round(clientY - top)
|
||||
};
|
||||
}
|
||||
|
||||
// Poloha modulov v kontajneri; prepočíta sa pri zmene veľkosti a obsahu
|
||||
export function useModuleLayout(containerRef: React.RefObject<HTMLElement | null>, deps: unknown[]): ModuleLayout | null {
|
||||
const [layout, setLayout] = useState<ModuleLayout | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const cr = el.getBoundingClientRect();
|
||||
const tops: Record<string, number> = {};
|
||||
el.querySelectorAll<HTMLElement>('[data-module-id]').forEach((m) => {
|
||||
tops[m.dataset.moduleId!] = m.getBoundingClientRect().top - cr.top;
|
||||
});
|
||||
setLayout({ width: cr.width, tops });
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
// obrázky sa načítavajú postupne a menia výšku modulov
|
||||
el.addEventListener('load', measure, true);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
el.removeEventListener('load', measure, true);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [containerRef, ...deps]);
|
||||
return layout;
|
||||
}
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
layout: ModuleLayout | null;
|
||||
comments: ReviewComment[];
|
||||
showDone?: boolean;
|
||||
selectedId?: string | null;
|
||||
onSelect?: (id: string | null) => void;
|
||||
// Akcie v bubline vybranej pripomienky (napr. Hotovo v editore)
|
||||
renderActions?: (c: ReviewComment) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
layout,
|
||||
comments,
|
||||
showDone = true,
|
||||
selectedId,
|
||||
onSelect,
|
||||
renderActions
|
||||
}) => {
|
||||
if (!layout) return null;
|
||||
const visible = comments.filter((c) => (showDone || !c.done) && layout.tops[c.module_id] !== undefined);
|
||||
const pos = (moduleId: string, x: number, y: number) => ({ left: x * layout.width, top: layout.tops[moduleId] + y });
|
||||
const commentNumbers = new Map(comments.filter((c) => c.kind === 'comment').map((c, i) => [c.id, i + 1]));
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-30">
|
||||
{/* Kresby */}
|
||||
<svg className="absolute inset-0 w-full h-full overflow-visible">
|
||||
{visible
|
||||
.filter((c) => c.kind === 'drawing' && c.path && c.path.length > 1)
|
||||
.map((c) => (
|
||||
<polyline
|
||||
key={c.id}
|
||||
points={c.path!.map(([x, y]) => {
|
||||
const p = pos(c.module_id, x, y);
|
||||
return `${p.left},${p.top}`;
|
||||
}).join(' ')}
|
||||
fill="none"
|
||||
stroke={c.color}
|
||||
strokeWidth={selectedId === c.id ? 5 : 3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={c.done ? 0.3 : 0.9}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect?.(selectedId === c.id ? null : c.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Komentáre (špendlíky) a texty */}
|
||||
{visible
|
||||
.filter((c) => c.kind !== 'drawing')
|
||||
.map((c) => {
|
||||
const p = pos(c.module_id, c.x, c.y);
|
||||
const isSelected = selectedId === c.id;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="absolute pointer-events-auto"
|
||||
style={{ left: p.left, top: p.top, zIndex: isSelected ? 40 : 30 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect?.(isSelected ? null : c.id);
|
||||
}}
|
||||
>
|
||||
{c.kind === 'comment' ? (
|
||||
<div
|
||||
className={`-translate-x-1/2 -translate-y-full w-7 h-7 rounded-full rounded-bl-none rotate-[-45deg] flex items-center justify-center shadow-lg border-2 border-white cursor-pointer ${c.done ? 'opacity-40' : ''} ${isSelected ? 'scale-125' : ''}`}
|
||||
style={{ backgroundColor: c.color }}
|
||||
>
|
||||
<span className="rotate-45 text-[11px] font-black text-white">{c.done ? '✓' : commentNumbers.get(c.id)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`-translate-y-1/2 max-w-[260px] px-1.5 py-0.5 rounded bg-white/85 text-[13px] font-bold leading-tight whitespace-pre-wrap shadow cursor-pointer ${c.done ? 'opacity-40 line-through' : ''} ${isSelected ? 'ring-2 ring-offset-1' : ''}`}
|
||||
style={{ color: c.color }}
|
||||
>
|
||||
{c.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bublina vybranej pripomienky */}
|
||||
{(() => {
|
||||
const c = visible.find((v) => v.id === selectedId);
|
||||
if (!c || c.kind === 'text') {
|
||||
return c && renderActions ? (
|
||||
<div className="absolute pointer-events-auto" style={{ left: pos(c.module_id, c.x, c.y).left, top: pos(c.module_id, c.x, c.y).top + 14 }}>
|
||||
<div className="flex gap-1 bg-white rounded-lg shadow-xl border border-gray-200 p-1">{renderActions(c)}</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
const anchor = c.kind === 'drawing' && c.path?.length ? c.path[c.path.length - 1] : [c.x, c.y];
|
||||
const p = pos(c.module_id, anchor[0], anchor[1]);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-auto w-64 bg-white rounded-xl shadow-2xl border border-gray-200 p-3 text-left"
|
||||
style={{ left: Math.min(p.left + 12, layout.width - 256), top: p.top + 8, zIndex: 50 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{new Date(c.created_at).toLocaleString('sk-SK')}</span>
|
||||
</div>
|
||||
{c.text ? (
|
||||
<p className="text-xs text-gray-700 whitespace-pre-wrap">{c.text}</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 italic">{c.kind === 'drawing' ? 'Kresba bez poznámky' : ''}</p>
|
||||
)}
|
||||
{c.done && <p className="mt-1 text-[10px] font-bold text-emerald-600">✓ Hotovo</p>}
|
||||
{renderActions && <div className="mt-2 flex gap-1.5">{renderActions(c)}</div>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
// Okno v editore: odkaz na pripomienky (vytvoriť / kopírovať / aktualizovať náhľad) a zoznam pripomienok
|
||||
import React, { useState } from 'react';
|
||||
import { X, Link2, Copy, Check, RefreshCw, ExternalLink, Trash2, CheckCircle2, Circle, Eye, EyeOff } from 'lucide-react';
|
||||
import { Review, ReviewComment, reviewUrl } from '@/lib/reviews';
|
||||
|
||||
interface ReviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
hasCampaignId: boolean;
|
||||
review: Review | null;
|
||||
comments: ReviewComment[];
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
showOverlay: boolean;
|
||||
onToggleOverlay: () => void;
|
||||
onCreate: () => void;
|
||||
onUpdateSnapshot: () => void;
|
||||
onToggleDone: (c: ReviewComment) => void;
|
||||
onDelete: (c: ReviewComment) => void;
|
||||
onShowComment: (c: ReviewComment) => void;
|
||||
}
|
||||
|
||||
export const ReviewModal: React.FC<ReviewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
hasCampaignId,
|
||||
review,
|
||||
comments,
|
||||
error,
|
||||
busy,
|
||||
showOverlay,
|
||||
onToggleOverlay,
|
||||
onCreate,
|
||||
onUpdateSnapshot,
|
||||
onToggleDone,
|
||||
onDelete,
|
||||
onShowComment
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [hideDone, setHideDone] = useState(false);
|
||||
if (!isOpen) return null;
|
||||
|
||||
const url = review ? reviewUrl(review.token) : '';
|
||||
const visible = comments.filter((c) => !hideDone || !c.done);
|
||||
const commentNumber = (c: ReviewComment) => comments.filter((x) => x.kind === 'comment').indexOf(c) + 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-900">Pripomienky k newslettru</h2>
|
||||
<p className="text-[11px] text-gray-500">Pošlite odkaz – v náhľade sa dá komentovať, písať a kresliť, nič sa nedá upraviť.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-700 p-1">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4 overflow-y-auto">
|
||||
{error && <p className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg p-2.5">{error}</p>}
|
||||
|
||||
{!hasCampaignId ? (
|
||||
<p className="text-xs text-gray-500">Kampaň sa ešte ukladá, skúste to o chvíľu.</p>
|
||||
) : !review ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onCreate}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-[#e2001a] hover:bg-red-700 disabled:opacity-50 text-white text-sm font-bold"
|
||||
>
|
||||
<Link2 className="w-4 h-4" /> Vytvoriť odkaz na pripomienky
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-1.5">
|
||||
<input readOnly value={url} onFocus={(e) => e.target.select()} className="flex-1 text-xs font-mono border border-gray-300 rounded-lg px-2 py-2 bg-gray-50" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2.5 rounded-lg bg-gray-900 text-white text-xs font-bold"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? 'Skopírované' : 'Kopírovať'}
|
||||
</button>
|
||||
<a href={url} target="_blank" rel="noreferrer" className="flex items-center px-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50" title="Otvoriť náhľad">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-gray-500">
|
||||
Náhľad v odkaze je z {new Date(review.updated_at).toLocaleString('sk-SK')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onUpdateSnapshot}
|
||||
className="flex items-center gap-1 text-[11px] font-bold text-gray-700 border border-gray-300 hover:bg-gray-50 disabled:opacity-50 px-2 py-1 rounded-lg"
|
||||
title="Recenzenti uvidia aktuálnu verziu newslettra, pripomienky ostanú"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${busy ? 'animate-spin' : ''}`} /> Aktualizovať náhľad v odkaze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-800">
|
||||
Pripomienky ({comments.length}) · {comments.filter((c) => !c.done).length} otvorených
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHideDone(!hideDone)}
|
||||
className="text-[10px] font-bold text-gray-500 hover:text-gray-900 px-1.5 py-1"
|
||||
>
|
||||
{hideDone ? 'Zobraziť hotové' : 'Skryť hotové'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleOverlay}
|
||||
className="flex items-center gap-1 text-[10px] font-bold text-gray-700 border border-gray-300 rounded-lg px-1.5 py-1 hover:bg-gray-50"
|
||||
>
|
||||
{showOverlay ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||
{showOverlay ? 'Skryť v náhľade' : 'Zobraziť v náhľade'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 && <p className="text-xs text-gray-400 text-center py-6">Zatiaľ žiadne pripomienky.</p>}
|
||||
{visible.map((c) => (
|
||||
<div key={c.id} className={`flex gap-2 rounded-lg border p-2 ${c.done ? 'border-gray-100 bg-gray-50 opacity-70' : 'border-gray-200'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDone(c)}
|
||||
className={c.done ? 'text-emerald-600' : 'text-gray-300 hover:text-emerald-600'}
|
||||
title={c.done ? 'Označiť ako neurobené' : 'Označiť ako hotové'}
|
||||
>
|
||||
{c.done ? <CheckCircle2 className="w-5 h-5" /> : <Circle className="w-5 h-5" />}
|
||||
</button>
|
||||
<button type="button" onClick={() => onShowComment(c)} className="flex-1 min-w-0 text-left" title="Ukázať v náhľade">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-4 h-4 rounded-full text-[9px] font-black text-white flex items-center justify-center shrink-0" style={{ backgroundColor: c.color }}>
|
||||
{c.kind === 'comment' ? commentNumber(c) : c.kind === 'text' ? 'T' : '✎'}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
|
||||
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{new Date(c.created_at).toLocaleString('sk-SK')}</span>
|
||||
</div>
|
||||
<p className={`mt-0.5 text-xs text-gray-700 whitespace-pre-wrap ${c.done ? 'line-through' : ''}`}>
|
||||
{c.text || (c.kind === 'drawing' ? 'Kresba' : '')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => confirm('Zmazať túto pripomienku?') && onDelete(c)}
|
||||
className="text-gray-300 hover:text-red-600 self-start p-0.5"
|
||||
title="Zmazať"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
// Pripomienky aktuálnej kampane v editore: odkaz, zoznam pripomienok (pravidelne obnovovaný), akcie
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
import {
|
||||
Review,
|
||||
ReviewComment,
|
||||
createReview,
|
||||
deleteReviewComment,
|
||||
getReviewForTemplate,
|
||||
listReviewComments,
|
||||
setReviewCommentDone,
|
||||
updateReviewSnapshot
|
||||
} from '@/lib/reviews';
|
||||
|
||||
const POLL_MS = 20000;
|
||||
|
||||
export function useCampaignReview(campaign: NewsletterCampaign) {
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [comments, setComments] = useState<ReviewComment[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const templateId = campaign.id;
|
||||
|
||||
const refresh = useCallback(async (reviewId: string) => {
|
||||
try {
|
||||
setComments(await listReviewComments(reviewId));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Odkaz kampane sa načíta pri otvorení kampane; pripomienky sa obnovujú pravidelne
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let cancelled = false;
|
||||
if (!templateId) return;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await getReviewForTemplate(templateId);
|
||||
if (cancelled) return;
|
||||
setReview(r);
|
||||
setComments([]);
|
||||
setError(null);
|
||||
if (!r) return;
|
||||
await refresh(r.id);
|
||||
timer = setInterval(() => refresh(r.id), POLL_MS);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [templateId, refresh]);
|
||||
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
review,
|
||||
comments,
|
||||
error,
|
||||
busy,
|
||||
openCount: comments.filter((c) => !c.done).length,
|
||||
create: () => run(async () => setReview(await createReview(campaign))),
|
||||
updateSnapshot: () =>
|
||||
run(async () => {
|
||||
if (review) setReview(await updateReviewSnapshot(review.id, campaign));
|
||||
}),
|
||||
toggleDone: (c: ReviewComment) =>
|
||||
run(async () => {
|
||||
await setReviewCommentDone(c.id, !c.done);
|
||||
setComments((prev) => prev.map((x) => (x.id === c.id ? { ...x, done: !c.done } : x)));
|
||||
}),
|
||||
remove: (c: ReviewComment) =>
|
||||
run(async () => {
|
||||
await deleteReviewComment(c.id);
|
||||
setComments((prev) => prev.filter((x) => x.id !== c.id));
|
||||
}),
|
||||
refresh: () => review && refresh(review.id)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Alt text obrázka: prvý neprázdny kandidát (ručne zadaný alt, názov produktu, nadpis okna...),
|
||||
// bez HTML značiek (napr. <br> v nadpisoch) a nadbytočných medzier
|
||||
export function pickAlt(...candidates: Array<string | null | undefined>): string {
|
||||
for (const c of candidates) {
|
||||
const clean = stripTags(c);
|
||||
if (clean) return clean;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function stripTags(str: string | null | undefined): string {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
@@ -1,15 +1,106 @@
|
||||
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;
|
||||
discountText?: string;
|
||||
unitPrice?: string;
|
||||
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}, {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 {
|
||||
return text
|
||||
.replace(/{cela_suma}/g, product.priceMain || '0')
|
||||
.replace(/{zlava_s_kartou}/g, (product.cardDiscount || '').replace('-', ''))
|
||||
.replace(/{zlava}/g, (product.discountText || '').replace('-', ''))
|
||||
.replace(/{jednotkova_cena}/g, product.unitPrice || '')
|
||||
.replace(/{mena}/g, '€');
|
||||
}
|
||||
|
||||
// Celé eurá a centy pre danú cenovú premennú
|
||||
export function badgePriceParts(token: string, product: BadgeProductData): [string, string] {
|
||||
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.
|
||||
@@ -21,10 +112,7 @@ export function renderCleanBadgeSvg(
|
||||
product: BadgeProductData,
|
||||
overrideWidth?: number
|
||||
): string {
|
||||
const pMain = product.priceMain || '0';
|
||||
const pSub = product.priceSub || '00';
|
||||
const discount = (product.discountText || '').replace('-', '');
|
||||
const unitPrice = product.unitPrice || '';
|
||||
|
||||
// Získanie viewBox alebo predvolených rozmerov
|
||||
let vb = '0 0 140 95';
|
||||
@@ -69,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
|
||||
@@ -88,26 +186,23 @@ export function renderCleanBadgeSvg(
|
||||
|
||||
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
||||
let contentSvg = '';
|
||||
if (rawText.includes('{cena}')) {
|
||||
const parts = rawText.split('{cena}');
|
||||
const before = escapeXml(parts[0] || '');
|
||||
const after = escapeXml(parts[1] || '');
|
||||
contentSvg = `${before}${escapeXml(pMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||
if (priceMatch) {
|
||||
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
||||
const idx = priceMatch.index || 0;
|
||||
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 {
|
||||
let replaced = rawText
|
||||
.replace(/{cela_suma}/g, pMain)
|
||||
.replace(/{zlava}/g, discount)
|
||||
.replace(/{jednotkova_cena}/g, unitPrice)
|
||||
.replace(/{mena}/g, '€');
|
||||
contentSvg = escapeXml(replaced);
|
||||
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;">
|
||||
@@ -168,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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Priamy odkaz na úpravu konkrétnej kampane v Brevo
|
||||
export function getBrevoCampaignUrl(campaignId: number | string): string {
|
||||
return `https://app.brevo.com/marketing-campaign/edit/${encodeURIComponent(String(campaignId))}`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Príprava obrázkov produktových kariet pre export (ZIP / Brevo):
|
||||
// vizuál karty z náhľadu v editore (fotka + cenovka + štítky) sa uloží ako JPG, nahrá do úložiska
|
||||
// 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 { 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();
|
||||
return uploadFile(blob, fileName);
|
||||
}
|
||||
|
||||
export async function prepareCardImages(
|
||||
campaign: NewsletterCampaign,
|
||||
onProgress?: (done: number, total: number) => void
|
||||
): Promise<{ campaign: NewsletterCampaign; failed: number }> {
|
||||
// Karty, ktorých obrázok chýba alebo je zastaraný
|
||||
const jobs: Array<{ mIdx: number; pIdx: number; key: string; bg: string; width: number }> = [];
|
||||
campaign.modules.forEach((m, mIdx) => {
|
||||
const isCards = m.type === 'products_grid' || (m.type === 'loyalty_club' && m.data?.enabled !== false);
|
||||
if (!isCards) return;
|
||||
(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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const modules = campaign.modules.map((m) => ({ ...m, data: { ...m.data, products: m.data.products ? [...m.data.products] : m.data.products } }));
|
||||
let failed = 0;
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
const job = jobs[i];
|
||||
onProgress?.(i, jobs.length);
|
||||
const node = document.querySelector<HTMLElement>(`[data-card-visual="${job.key}"]`);
|
||||
if (!node) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Presná veľkosť karty v e-maile (náhľad môže byť v užšom okne)
|
||||
const height = cardVisualHeight(job.width);
|
||||
const dataUrl = await toJpeg(node, {
|
||||
pixelRatio: 2,
|
||||
quality: 0.9,
|
||||
backgroundColor: job.bg,
|
||||
width: job.width,
|
||||
height,
|
||||
style: { width: `${job.width}px`, height: `${height}px` },
|
||||
// obrázky idú cez /api/img?url=… – bez parametrov by si html-to-image pomýlil rôzne obrázky
|
||||
includeQueryParams: true
|
||||
});
|
||||
const product = modules[job.mIdx].data.products[job.pIdx];
|
||||
const url = await uploadJpeg(dataUrl, `karta-${product.id}.jpg`);
|
||||
modules[job.mIdx].data.products[job.pIdx] = { ...product, cardImage: { key: job.key, url } };
|
||||
} catch (err) {
|
||||
console.warn('Obrázok karty sa nepodarilo pripraviť:', err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
onProgress?.(jobs.length, jobs.length);
|
||||
return { campaign: { ...campaign, modules }, failed };
|
||||
}
|
||||
@@ -1,7 +1,28 @@
|
||||
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
|
||||
import { NewsletterCampaign, NewsletterModule, ProductItem } from '@/types/newsletter';
|
||||
|
||||
export const SUPABASE_ASSETS_BASE =
|
||||
'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/';
|
||||
// Verejné úložisko statických obrázkov (logá, ikony, štítky predajní...) v Supabase projekte z .env.
|
||||
// Súbory sú v supabase/assets/ a nahrajú sa príkazom: npm run db:upload-assets
|
||||
export const SUPABASE_ASSETS_BASE = `${(process.env.NEXT_PUBLIC_SUPABASE_URL || '').replace(/\/$/, '')}/storage/v1/object/public/newsletter-assets/`;
|
||||
|
||||
// Lišta platnosti akcie nad produktmi – dá sa skryť a posúvať, nie zmazať
|
||||
export const VALIDITY_BAR_MODULE: NewsletterModule = {
|
||||
id: 'mod-validity-bar',
|
||||
type: 'validity_bar',
|
||||
title: 'Lišta platnosti akcie',
|
||||
data: {
|
||||
text: 'Akcia platí',
|
||||
enabled: true
|
||||
}
|
||||
};
|
||||
|
||||
// Staršie kampane bez lišty platnosti: vloží ju nad prvú produktovú sekciu
|
||||
export function ensureValidityBar(modules: NewsletterModule[]): NewsletterModule[] {
|
||||
if (modules.some((m) => m.type === 'validity_bar')) return modules;
|
||||
const idx = modules.findIndex((m) => m.type === 'products_grid' || m.type === 'loyalty_club');
|
||||
const bar = { ...VALIDITY_BAR_MODULE, data: { ...VALIDITY_BAR_MODULE.data } };
|
||||
if (idx === -1) return modules;
|
||||
return [...modules.slice(0, idx), bar, ...modules.slice(idx)];
|
||||
}
|
||||
|
||||
export const DEFAULT_CAMPAIGN: NewsletterCampaign = {
|
||||
id: '',
|
||||
@@ -66,6 +87,7 @@ export const DEFAULT_CAMPAIGN: NewsletterCampaign = {
|
||||
buttonBgColor: '#003399'
|
||||
}
|
||||
},
|
||||
VALIDITY_BAR_MODULE,
|
||||
{
|
||||
id: 'mod-products-fantasticky',
|
||||
type: 'products_grid',
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { NewsletterCampaign, ProductItem } from '@/types/newsletter';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||
import { renderCleanBadgeSvg } from './badgeUtils';
|
||||
import { pickAlt } from './altText';
|
||||
import { escapeHtml, escapeWithBreaks } from './html';
|
||||
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 {
|
||||
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign)).join('\n');
|
||||
const renderedModules = campaign.modules.map((m) => renderModule(m, campaign, 'export')).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="sk" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
@@ -118,6 +121,11 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
.top-msg { font-size: 11px !important; line-height: 1.4 !important; }
|
||||
.top-tip { font-size: 10px !important; padding: 1px 6px !important; }
|
||||
.top-link { font-size: 11px !important; }
|
||||
|
||||
/* Produktové karty */
|
||||
.prod-pad { padding-left: 10px !important; padding-right: 10px !important; }
|
||||
.prod-name { font-size: 13px !important; }
|
||||
.prod-unit { font-size: 10px !important; }
|
||||
}
|
||||
|
||||
/* ====== DARK MODE OVERRIDE — Outlook.com / O365 web / dark-mode klienty ====== */
|
||||
@@ -126,6 +134,7 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
[data-ogsc] .card-fantasticky, [data-ogsb] .card-fantasticky { background-color: #feeaea !important; }
|
||||
[data-ogsc] .card-domace, [data-ogsb] .card-domace { background-color: #fef0c7 !important; }
|
||||
[data-ogsc] .card-grill, [data-ogsb] .card-grill { background-color: #dcb085 !important; }
|
||||
[data-ogsc] .card-zelena, [data-ogsb] .card-zelena { background-color: #e3f0e4 !important; }
|
||||
[data-ogsc] .card-vernostna, [data-ogsb] .card-vernostna { background-color: #e6e1d9 !important; }
|
||||
[data-ogsc] .card-white-bg, [data-ogsb] .card-white-bg { background-color: #fefefe !important; }
|
||||
[data-ogsc] .force-text-dark, [data-ogsb] .force-text-dark { color: #1a1a1a !important; }
|
||||
@@ -146,6 +155,7 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
.card-fantasticky { background-color: #feeaea !important; }
|
||||
.card-domace { background-color: #fef0c7 !important; }
|
||||
.card-grill { background-color: #dcb085 !important; }
|
||||
.card-zelena { background-color: #e3f0e4 !important; }
|
||||
.card-vernostna { background-color: #e6e1d9 !important; }
|
||||
.card-white-bg { background-color: #fefefe !important; }
|
||||
.force-text-dark { color: #1a1a1a !important; }
|
||||
@@ -159,6 +169,26 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
.force-bg-green { background-color: #005239 !important; }
|
||||
.force-bg-articleblue { background-color: #5c85a8 !important; }
|
||||
}
|
||||
|
||||
/* ====== Apple Mail / iOS Mail dark mode extra protection ====== */
|
||||
u + .body .force-text-white,
|
||||
u + .body .force-bg-red,
|
||||
u + .body .force-bg-blue,
|
||||
u + .body .force-bg-darkblue,
|
||||
u + .body .force-bg-yellow,
|
||||
u + .body .force-bg-yellow2,
|
||||
u + .body .force-bg-orange,
|
||||
u + .body .force-bg-green,
|
||||
u + .body .force-bg-articleblue,
|
||||
u + .body .card-white-bg,
|
||||
u + .body .card-fantasticky,
|
||||
u + .body .card-domace,
|
||||
u + .body .card-grill,
|
||||
u + .body .card-zelena,
|
||||
u + .body .card-vernostna {
|
||||
color: revert !important;
|
||||
background-color: revert !important;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
@@ -198,11 +228,43 @@ export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Náhľad modulu v editore (produktové karty sa skladajú živo z HTML/CSS)
|
||||
export function renderSingleModuleHtml(m: any, campaign: NewsletterCampaign): string {
|
||||
return renderModule(m, campaign);
|
||||
return renderModule(m, campaign, 'preview');
|
||||
}
|
||||
|
||||
function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
// Ž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(
|
||||
(m) => (m.type === 'products_grid' || m.type === 'loyalty_club') && m.data?.globalValidity
|
||||
);
|
||||
return mod?.data.globalValidity || '';
|
||||
}
|
||||
|
||||
// Moduly, pri ktorých sa dá vygenerované HTML nahradiť vlastným (HTML editor)
|
||||
export const CUSTOM_HTML_MODULE_TYPES = ['header', 'footer'];
|
||||
|
||||
// HTML vygenerované z formulára (ignoruje vlastné HTML) – predvyplnenie HTML editora
|
||||
export function renderGeneratedModuleHtml(m: any): string {
|
||||
// Hlavička ani pätička nepoužívajú údaje kampane, stačí prázdny objekt
|
||||
return renderModule({ ...m, data: { ...m.data, useCustomHtml: false } }, {} as NewsletterCampaign).trim();
|
||||
}
|
||||
|
||||
function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = 'preview'): string {
|
||||
// Vlastné HTML hlavičky / pätičky má prednosť pred vygenerovaným
|
||||
if (CUSTOM_HTML_MODULE_TYPES.includes(m.type) && m.data?.useCustomHtml && m.data?.customHtml) {
|
||||
return m.data.customHtml;
|
||||
}
|
||||
|
||||
switch (m.type) {
|
||||
case 'announcement':
|
||||
if (m.data?.enabled === false) {
|
||||
@@ -235,6 +297,28 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
case 'validity_bar': {
|
||||
if (m.data?.enabled === false) {
|
||||
return '';
|
||||
}
|
||||
const validityText = [m.data.text, getCampaignValidity(campaign)].filter(Boolean).join(' ');
|
||||
return `
|
||||
<!-- LIŠTA PLATNOSTI AKCIE -->
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 15px 0 20px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" class="force-bg-red" bgcolor="#e2001a" style="background-color: #e2001a; background: linear-gradient(to right, #ff3d4f, #e2001a); border: 0; border-radius: 12px; box-shadow: 0 10px 25px rgba(226, 0, 26, 0.3);">
|
||||
<tr>
|
||||
<td style="padding: 10px 20px; text-align: center;">
|
||||
<span class="top-msg force-text-white" style="color: #fefefe; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; vertical-align: middle; display: inline-block; padding: 5px 10px;">${escapeHtml(validityText)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
case 'header':
|
||||
const rawNav = m.data.navLinks && m.data.navLinks.length > 0 ? m.data.navLinks : [
|
||||
{ label: 'Letáky', url: 'https://www.coop.sk/sk/letaky', variant: 'outline' },
|
||||
@@ -256,7 +340,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr>
|
||||
<td width="35%" valign="middle" style="text-align: left;" class="mobile-header-cell">
|
||||
<a href="${escapeHtml(m.data.logoLink || 'https://www.coop.sk')}" target="_blank">
|
||||
<img src="${escapeHtml(m.data.logoUrl)}" width="130" alt="COOP Jednota" style="display: inline-block; border: 0; border-radius: 8px;" class="logo-img">
|
||||
<img src="${escapeHtml(m.data.logoUrl)}" width="130" alt="${escapeHtml(pickAlt(m.data.logoAlt, 'COOP Jednota'))}" style="display: inline-block; border: 0; border-radius: 8px;" class="logo-img">
|
||||
</a>
|
||||
</td>
|
||||
<td width="65%" valign="middle" style="text-align: right;" class="mobile-header-cell">
|
||||
@@ -276,20 +360,20 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${escapeHtml(m.data.linkUrl || '#')}">
|
||||
<img src="${escapeHtml(m.data.imageUrl)}" width="600" alt="${escapeHtml(m.data.altText || m.data.title || m.title || 'COOP Jednota')}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: ${m.data.title || m.data.description || m.data.buttonText ? '16px 16px 0 0' : '16px'};">
|
||||
<img src="${escapeHtml(m.data.imageUrl)}" width="600" alt="${escapeHtml(pickAlt(m.data.altText, m.data.title, m.title, 'COOP Jednota'))}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: ${m.data.title || m.data.description || m.data.buttonText ? '16px 16px 0 0' : '16px'};">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
${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;">${escapeHtml(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;">${escapeHtml(m.data.description)}</p>` : ''}
|
||||
${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>` : ''}
|
||||
@@ -299,104 +383,47 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;"> </td></tr></table>`;
|
||||
|
||||
case 'products_grid':
|
||||
const globalValidity = m.data.globalValidity || '';
|
||||
const products: ProductItem[] = m.data.products || [];
|
||||
const cardBg = m.data.cardBgColor || '#feeaea';
|
||||
const cardBorder = m.data.cardBorderColor || '#f5d4d4';
|
||||
const badgeHdrBg = m.data.badgeHeaderBg || '#fefefe';
|
||||
const badgeHdrColor = m.data.badgeHeaderColor || '#000000';
|
||||
const badgePrcBg = m.data.badgePriceBg || '#e2001a';
|
||||
const badgePrcColor = m.data.badgePriceColor || '#fefefe';
|
||||
const badgeCustomTxt = m.data.badgeHeaderCustomText || null;
|
||||
|
||||
const cols = m.data.columns === 1 ? 1 : 2;
|
||||
const rows: ProductItem[][] = [];
|
||||
for (let i = 0; i < products.length; i += cols) {
|
||||
rows.push(products.slice(i, i + cols));
|
||||
}
|
||||
|
||||
const rowsHtml = rows.map((row) => {
|
||||
if (cols === 1) {
|
||||
const p = row[0];
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 12px;">
|
||||
<tr>
|
||||
<td width="100%" valign="top">
|
||||
${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="1" style="margin-bottom: 12px;">
|
||||
<tr>
|
||||
${row.map((p, idx) => `
|
||||
<td width="50%" valign="top" height="100%" style="padding-${idx === 0 ? 'right: 6px' : 'left: 6px'}; padding-bottom: 0; height: 100%;">
|
||||
${renderExactProductCard(p, globalValidity, cardBg, cardBorder, badgeHdrBg, badgeHdrColor, badgePrcBg, badgePrcColor, badgeCustomTxt, m.data.sectionTitle)}
|
||||
</td>`).join('\n')}
|
||||
${row.length === 1 ? '<td width="50%" valign="top" height="100%" style="height: 100%;"></td>' : ''}
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
}).join('\n');
|
||||
|
||||
return `
|
||||
<!-- PRODUKTOVÁ SEKCIA: ${escapeHtml(m.data.sectionTitle || '')} -->
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
${m.data.sectionTitle ? `
|
||||
<tr>
|
||||
<td style="text-align: center; padding-top: 25px; padding-bottom: 25px;">
|
||||
<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);">${escapeHtml(m.data.sectionTitle)}</h2>
|
||||
<td style="text-align: center; padding-top: 25px; padding-bottom: 15px;">
|
||||
<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>` : ''}
|
||||
<tr>
|
||||
<td>
|
||||
${rowsHtml}
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
${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;"> </td></tr></table>`;
|
||||
|
||||
case 'loyalty_club':
|
||||
if (m.data.enabled === false) {
|
||||
return '';
|
||||
}
|
||||
const lProducts = m.data.products || [];
|
||||
return `
|
||||
<!-- 7. VERNOSTNÉ ZĽAVY (COOP KLUB) -->
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding-top: 25px; padding-bottom: 25px;">
|
||||
<td style="text-align: center; padding-top: 25px; padding-bottom: 15px;">
|
||||
<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);">${escapeHtml(m.data.sectionTitle || 'Vernostné zľavy')}</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Banner karta -->
|
||||
<tr>
|
||||
<td style="padding-bottom: 20px;">
|
||||
<td style="padding-bottom: 15px;">
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" class="card-white card-vernostna" bgcolor="#e6e1d9" style="background-color: #e6e1d9; border: 0; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); overflow: hidden;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${escapeHtml(m.data.headerLink || '#')}">
|
||||
<img src="${escapeHtml(m.data.headerImageUrl)}" width="600" alt="${escapeHtml(m.data.title || m.data.sectionTitle || 'Vernostné zľavy')}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: 16px;">
|
||||
<img src="${escapeHtml(m.data.headerImageUrl)}" width="600" alt="${escapeHtml(pickAlt(m.data.headerImageAlt, m.data.title, m.data.sectionTitle, 'Vernostné zľavy'))}" style="display: block; border: 0; width: 100%; max-width: 100%; border-radius: 16px;">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Produktové karty - 2 vedľa seba -->
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
${lProducts.map((p: any, idx: number) => `
|
||||
<td width="50%" valign="top" height="100%" style="padding-${idx === 0 ? 'right: 6px' : 'left: 6px'}; padding-bottom: 0; height: 100%;">
|
||||
${renderExactLoyaltyCard(p, m.data.globalValidity || '2. 5. – 15. 5. 2026')}
|
||||
</td>`).join('\n')}
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Produktové karty -->
|
||||
${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;"> </td></tr></table>`;
|
||||
|
||||
@@ -416,7 +443,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr height="1">
|
||||
<td valign="top" style="line-height: 0; height: 1px;">
|
||||
<a href="${escapeHtml(it1.linkUrl || '#')}">
|
||||
<img src="${escapeHtml(it1.imageUrl)}" width="300" alt="${escapeHtml(it1.title || m.data.title || m.title || 'Aktuálny leták')}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
||||
<img src="${escapeHtml(it1.imageUrl)}" width="300" alt="${escapeHtml(pickAlt(it1.altText, it1.title, m.data.title, m.title, 'Aktuálny leták'))}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -425,7 +452,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" style="height: 100%;">
|
||||
<tr>
|
||||
<td valign="top" style="text-align: center; min-height: 44px; padding-bottom: 15px;">
|
||||
<h3 class="force-text-white" style="margin: 0; font-size: 17px; color: ${it1.textColor || '#fefefe'}; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; line-height: 1.25;">${escapeHtml(it1.title)}</h3>
|
||||
<h3 class="force-text-white" style="margin: 0; font-size: 17px; color: ${it1.textColor || '#fefefe'}; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; line-height: 1.25;">${escapeWithBreaks(it1.title)}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
<tr height="1">
|
||||
@@ -433,7 +460,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<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>
|
||||
@@ -450,7 +477,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<tr height="1">
|
||||
<td valign="top" style="line-height: 0; height: 1px;">
|
||||
<a href="${escapeHtml(it2.linkUrl || '#')}">
|
||||
<img src="${escapeHtml(it2.imageUrl)}" width="300" alt="${escapeHtml(it2.title || m.data.title || m.title || 'Aktuálny leták')}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
||||
<img src="${escapeHtml(it2.imageUrl)}" width="300" alt="${escapeHtml(pickAlt(it2.altText, it2.title, m.data.title, m.title, 'Aktuálny leták'))}" style="display: block; border: 0; width: 100%; max-width: 100%;">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -459,7 +486,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" style="height: 100%;">
|
||||
<tr>
|
||||
<td valign="top" style="text-align: center; min-height: 44px; padding-bottom: 15px;">
|
||||
<h3 style="margin: 0; font-size: 17px; color: ${it2.textColor || '#29446f'}; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; line-height: 1.25;">${escapeHtml(it2.title)}</h3>
|
||||
<h3 style="margin: 0; font-size: 17px; color: ${it2.textColor || '#29446f'}; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; line-height: 1.25;">${escapeWithBreaks(it2.title)}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
<tr height="1">
|
||||
@@ -467,7 +494,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<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>
|
||||
@@ -502,7 +529,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
||||
<tr>
|
||||
<td align="center" valign="bottom" style="padding-bottom: 10px;">
|
||||
<img src="${escapeHtml(it1.badgeImgUrl)}" width="70" alt="${escapeHtml(it1.title || 'Recepty')}" style="display: block; border: 0; margin: 0 auto;">
|
||||
<img src="${escapeHtml(it1.badgeImgUrl)}" width="70" alt="${escapeHtml(pickAlt(it1.altText, it1.title, 'Recepty'))}" style="display: block; border: 0; margin: 0 auto;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -512,7 +539,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 15px 15px 25px 15px; text-align: center; height: 100%;" valign="middle">
|
||||
<h3 class="force-text-white" style="margin: 0; font-size: 17px; color: #fefefe; font-weight: 800; text-transform: uppercase; letter-spacing: 0.8px; line-height: 1.25;">${escapeHtml(it1.title)}</h3>
|
||||
<h3 class="force-text-white" style="margin: 0; font-size: 17px; color: #fefefe; font-weight: 800; text-transform: uppercase; letter-spacing: 0.8px; line-height: 1.25;">${escapeWithBreaks(it1.title)}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -531,7 +558,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%">
|
||||
<tr>
|
||||
<td align="center" valign="bottom" style="padding-bottom: 10px;">
|
||||
<img src="${escapeHtml(it2.badgeImgUrl)}" width="70" alt="${escapeHtml(it2.title || 'Časopis Jednota')}" style="display: block; border: 0; margin: 0 auto;">
|
||||
<img src="${escapeHtml(it2.badgeImgUrl)}" width="70" alt="${escapeHtml(pickAlt(it2.altText, it2.title, 'Časopis Jednota'))}" style="display: block; border: 0; margin: 0 auto;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -541,7 +568,7 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 15px 15px 25px 15px; text-align: center; height: 100%;" valign="middle">
|
||||
<h3 class="force-text-white" style="margin: 0; font-size: 17px; color: #fefefe; font-weight: 800; text-transform: uppercase; letter-spacing: 0.8px; line-height: 1.25;">${escapeHtml(it2.title)}</h3>
|
||||
<h3 class="force-text-white" style="margin: 0; font-size: 17px; color: #fefefe; font-weight: 800; text-transform: uppercase; letter-spacing: 0.8px; line-height: 1.25;">${escapeWithBreaks(it2.title)}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -614,306 +641,3 @@ function renderModule(m: any, campaign: NewsletterCampaign): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderExactProductCard(
|
||||
p: ProductItem,
|
||||
globalValidity: string,
|
||||
cardBg: string,
|
||||
cardBorder: string,
|
||||
badgeHdrBg: string,
|
||||
badgeHdrColor: string,
|
||||
badgePrcBg: string,
|
||||
badgePrcColor: string,
|
||||
badgeCustomTxt: string | null,
|
||||
sectionTitle?: string
|
||||
): string {
|
||||
const validity = p.validityOverride || globalValidity;
|
||||
const effectiveCardBg = p.cardBgColor || cardBg || '#feeaea';
|
||||
|
||||
const storesHtml = `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right">
|
||||
${p.stores?.potraviny ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 0; background-color: #e2001a; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_potraviny.png" height="10" alt="Potraviny" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
|
||||
${p.stores?.supermarket ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 0; background-color: #f9890e; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_supermarket.png" height="10" alt="Supermarket" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
|
||||
${p.stores?.tempo ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 0; background-color: #005239; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_tempo.png" height="10" alt="Tempo" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
|
||||
</table>`;
|
||||
|
||||
const badgeLayout = p.badgeLayout || 'below';
|
||||
|
||||
// Pomocná funkcia na render samotnej cenovky (bez obalového <td>)
|
||||
const renderBadgeContent = (bWidth: number, bAlign: string, padTop: number, padBottom: number, marginStyle: string) => {
|
||||
if (p.badgeCustomTemplate) {
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
||||
<tr>
|
||||
<td align="${bAlign}">
|
||||
${renderCleanBadgeSvg(p.badgeCustomTemplate, p, bWidth)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
if (p.customBadgePngUrl) {
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
||||
<tr>
|
||||
<td align="${bAlign}">
|
||||
<img src="${escapeHtml(p.customBadgePngUrl)}" width="${bWidth}" alt="${escapeHtml(p.title || 'Cenovka')}" style="display: block; width: 100%; max-width: ${bWidth}px; margin: 0 auto;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
const mainFontSize = Math.max(28, Math.round(bWidth * 0.54));
|
||||
const subFontSize = Math.max(14, Math.round(bWidth * 0.27));
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
||||
<tr>
|
||||
<td align="${bAlign}">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: ${badgeHdrBg}; border: 0; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.18); width: ${bWidth}px; overflow: hidden;">
|
||||
${badgeCustomTxt ? `
|
||||
<tr>
|
||||
<td bgcolor="${badgeHdrBg}" style="padding: 4px 4px; text-align: center; background-color: ${badgeHdrBg};">
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 10px; color: ${badgeHdrColor}; font-weight: 800; text-transform: uppercase;">${escapeHtml(badgeCustomTxt)}</span>
|
||||
</td>
|
||||
</tr>` : (p.discountText ? `
|
||||
<tr>
|
||||
<td style="padding: 2px 4px; text-align: right; background-color: ${badgeHdrBg};">
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 9px; color: ${badgeHdrColor}; font-weight: 700; text-transform: lowercase;">${escapeHtml(p.badgeText || 'zľava')}</span>
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 20px; color: ${badgeHdrColor}; font-weight: 900; line-height: 1; margin-left: 2px;">${escapeHtml(p.discountText)}</span>
|
||||
</td>
|
||||
</tr>` : '')}
|
||||
<tr>
|
||||
<td class="force-text-white" bgcolor="${badgePrcBg}" style="background-color: ${badgePrcBg}; padding: 4px 2px 2px 2px; font-family: 'Arial Narrow', Arial, sans-serif; color: ${badgePrcColor}; font-weight: 900; line-height: 1; text-align: center; border-radius: 0 0 12px 12px;">
|
||||
<span style="font-size: ${mainFontSize}px;">${escapeHtml(p.priceMain)}</span><sup style="font-size: ${subFontSize}px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.priceSub)}</sup>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
};
|
||||
|
||||
if (badgeLayout === 'side') {
|
||||
// Rozloženie: obrázok vľavo | cenovka vpravo
|
||||
const bWidth = p.badgeWidth || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.width || 110) : 95);
|
||||
const bAlign = 'center';
|
||||
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate?.paddingTop ?? 0);
|
||||
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate?.paddingBottom ?? 0);
|
||||
const marginStyle = 'margin: 0 auto;';
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product" bgcolor="${effectiveCardBg}" style="background-color: ${effectiveCardBg}; border: 0; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); height: 100%; min-height: 100%;">
|
||||
<tr height="1">
|
||||
<td colspan="2" style="padding: 15px 15px 0 15px; height: 1px;" valign="top">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
|
||||
<td align="right">${storesHtml}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="58%" height="160" style="padding: 8px 4px 8px 8px; text-align: center; height: 160px;" valign="middle">
|
||||
<img src="${escapeHtml(p.imageUrl)}" width="180" alt="${escapeHtml(p.title || sectionTitle || 'Produkt')}" style="display: block; width: 100%; max-width: 180px; margin: 0 auto; border-radius: 8px;">
|
||||
</td>
|
||||
<td width="42%" style="padding: 8px 8px 8px 4px; text-align: center;" valign="middle">
|
||||
${renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle)}
|
||||
${p.unitPrice ? `<div style="font-size: 8px; color: #666666; font-weight: 500; font-family: Arial, sans-serif; text-align: center; margin-top: 4px;">${escapeHtml(p.unitPrice)}</div>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="54" style="padding: 0 8px 10px 8px; text-align: center; height: 54px; min-height: 54px;" valign="top">
|
||||
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Predvolené rozloženie: cenovka pod obrázkom
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product" bgcolor="${effectiveCardBg}" style="background-color: ${effectiveCardBg}; border: 0; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); height: 100%; min-height: 100%;">
|
||||
<tr height="1">
|
||||
<td style="padding: 15px 15px 0 15px; height: 1px;" valign="top">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(validity)}</td>
|
||||
<td align="right">${storesHtml}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr height="160">
|
||||
<td height="160" style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
|
||||
<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(p.title || sectionTitle || 'Produkt')}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="54" style="padding: 0 8px 10px 8px; text-align: center; height: 54px; min-height: 54px;" valign="top">
|
||||
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
<tr height="1">
|
||||
<td style="padding: 0 8px 4px 8px; text-align: center;" valign="bottom">
|
||||
${(() => {
|
||||
const bWidth = p.badgeWidth || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.width || 110) : 95);
|
||||
const bAlign = p.badgeAlign || (p.badgeCustomTemplate ? (p.badgeCustomTemplate.align || 'center') : 'center');
|
||||
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate?.paddingTop ?? 0);
|
||||
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate?.paddingBottom ?? 0);
|
||||
let marginStyle = 'margin: 0 auto;';
|
||||
if (bAlign === 'left') {
|
||||
marginStyle = 'margin: 0 auto 0 0; margin-right: auto; margin-left: 0;';
|
||||
} else if (bAlign === 'right') {
|
||||
marginStyle = 'margin: 0 0 0 auto; margin-left: auto; margin-right: 0;';
|
||||
}
|
||||
return renderBadgeContent(bWidth, bAlign, padTop, padBottom, marginStyle);
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr height="1">
|
||||
<td align="center" valign="bottom" style="padding: 2px 8px 12px 8px; text-align: center; height: 1px;">
|
||||
${p.unitPrice ? `<div style="font-size: 9px; color: #666666; font-weight: 500; font-family: Arial, sans-serif; text-align: center;">${escapeHtml(p.unitPrice)}</div>` : '<div style="font-size: 9px; line-height: 9px; height: 9px;"> </div>'}
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function renderExactLoyaltyCard(p: any, validity: string): string {
|
||||
const cardValidity = p.validityOverride || validity;
|
||||
const storesHtml = `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right">
|
||||
${p.stores?.potraviny ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 0; background-color: #e2001a; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_potraviny.png" height="10" alt="Potraviny" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
|
||||
${p.stores?.supermarket ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 0; background-color: #f9890e; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_supermarket.png" height="10" alt="Supermarket" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
|
||||
${p.stores?.tempo ? `<tr><td align="right" style="line-height: 0;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right"><tr><td style="border: 0; background-color: #005239; padding: 0; line-height: 0;"><img src="${SUPABASE_ASSETS_BASE}stit_tempo.png" height="10" alt="Tempo" style="display: block; height: 10px; width: auto; border: 0;"></td></tr></table></td></tr>` : ''}
|
||||
</table>`;
|
||||
|
||||
const loyaltyBg = p.cardBgColor || '#e6e1d9';
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product card-vernostna" bgcolor="${loyaltyBg}" style="background-color: ${loyaltyBg}; border: 0; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); height: 100%; min-height: 100%;">
|
||||
<tr height="1">
|
||||
<td style="padding: 15px 15px 0 15px; height: 1px;" valign="top">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size: 9px; color: #666666; font-weight: 500;" valign="top">${escapeHtml(cardValidity)}</td>
|
||||
<td align="right">${storesHtml}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr height="1">
|
||||
<td style="padding: 0 8px 15px 8px; text-align: center; height: 160px;" valign="middle">
|
||||
<img src="${escapeHtml(p.imageUrl)}" width="220" alt="${escapeHtml(p.title || 'Produkt')}" style="display: block; width: 100%; max-width: 220px; margin: 0 auto; border-radius: 8px;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 8px 10px 8px; text-align: center; height: 100%; min-height: 44px;" valign="top">
|
||||
<h3 class="force-text-dark" style="margin: 0; font-size: 15px; color: #1a1a1a; font-weight: 600; line-height: 1.25;">${escapeHtml(p.title)}</h3>
|
||||
</td>
|
||||
</tr>
|
||||
<tr height="1">
|
||||
<td style="padding: 0 8px ${p.badgeCustomTemplate ? '4px' : '12px'} 8px; text-align: center; height: 1px;" valign="bottom">
|
||||
${(() => {
|
||||
// 1. Ak má vernostný produkt vlastnú dynamickú šablónu cenovky
|
||||
if (p.badgeCustomTemplate) {
|
||||
const bWidth = p.badgeWidth || p.badgeCustomTemplate.width || 120;
|
||||
const bAlign = p.badgeAlign || p.badgeCustomTemplate.align || 'center';
|
||||
const padTop = p.badgePaddingTop ?? (p.badgeCustomTemplate.paddingTop ?? 0);
|
||||
const padBottom = p.badgePaddingBottom ?? (p.badgeCustomTemplate.paddingBottom ?? 0);
|
||||
|
||||
let marginStyle = 'margin: 0 auto;';
|
||||
if (bAlign === 'left') {
|
||||
marginStyle = 'margin: 0 auto 0 0; margin-right: auto; margin-left: 0;';
|
||||
} else if (bAlign === 'right') {
|
||||
marginStyle = 'margin: 0 0 0 auto; margin-left: auto; margin-right: 0;';
|
||||
}
|
||||
|
||||
const loyaltyProductData = {
|
||||
priceMain: p.cardPriceMain || p.priceMain || '0',
|
||||
priceSub: p.cardPriceSub || p.priceSub || '00',
|
||||
discountText: p.cardDiscount || p.discountText || '',
|
||||
unitPrice: p.cardUnitPrice || p.unitPrice || ''
|
||||
};
|
||||
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="${bAlign}" style="${marginStyle} width: ${bWidth}px; padding-top: ${padTop}px; padding-bottom: ${padBottom}px;">
|
||||
<tr>
|
||||
<td align="${bAlign}">
|
||||
${renderCleanBadgeSvg(p.badgeCustomTemplate, loyaltyProductData, bWidth)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// 2. Predvolený Dual Sticker Block (Bez karty / S kartou) 1:1
|
||||
return `
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto; width: 140px;">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width: 140px; margin: 0 auto;">
|
||||
<tr>
|
||||
<!-- Bez karty -->
|
||||
<td width="42%" valign="bottom" style="padding-right: 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border: 0; border-radius: 10px 0 0 10px; box-shadow: -2px 4px 8px rgba(0,0,0,0.12); overflow: hidden;">
|
||||
<tr>
|
||||
<td class="force-bg-red" bgcolor="#e2001a" style="background-color: #e2001a; padding: 4px 2px; text-align: center;">
|
||||
<div style="font-size: 6px; color: #fefefe; font-weight: 700; text-transform: uppercase; line-height: 1; margin-bottom: 1px;">Bez karty</div>
|
||||
<div style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 8px; color: #fefefe; font-weight: 900; line-height: 1;">zľava ${escapeHtml(p.noCardDiscount)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px; text-align: center; color: #000000; font-family: 'Arial Narrow', Arial, sans-serif; font-weight: 900; line-height: 1;">
|
||||
<span style="font-size: 20px;">${escapeHtml(p.noCardPriceMain)}</span><sup style="font-size: 10px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.noCardPriceSub)}</sup>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<!-- S kartou -->
|
||||
<td width="58%" valign="top">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border: 0; border-radius: 0 10px 10px 0; box-shadow: 4px 4px 12px rgba(0,0,0,0.15); overflow: hidden;">
|
||||
<tr>
|
||||
<td style="background-color: #fefefe; padding: 5px 4px 2px 4px; text-align: right;">
|
||||
<div style="font-size: 7px; color: #e2001a; font-weight: 800; text-transform: uppercase; line-height: 1; text-align: center; margin-bottom: 1px;">S kartou</div>
|
||||
<div style="text-align: right; line-height: 1;">
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 8px; color: #000000; font-weight: 700; text-transform: lowercase;">zľava</span>
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 18px; color: #000000; font-weight: 900; margin-left: 1px;">${escapeHtml(p.cardDiscount)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="force-bg-red force-text-white" bgcolor="#e2001a" style="background-color: #e2001a; padding: 3px 2px; text-align: center; color: #fefefe; font-family: 'Arial Narrow', Arial, sans-serif; font-weight: 900; line-height: 1; border-radius: 0 0 10px 0;">
|
||||
<span style="font-size: 48px;">${escapeHtml(p.cardPriceMain)}</span><sup style="font-size: 24px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.cardPriceSub)}</sup>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="42%" align="center" style="padding-top: 6px;">
|
||||
<div style="font-size: 8px; color: #666666; opacity: 0.7; font-weight: 500; font-family: Arial, sans-serif;">${escapeHtml(p.noCardUnitPrice)}</div>
|
||||
</td>
|
||||
<td width="58%" align="center" style="padding-top: 6px;">
|
||||
<div style="font-size: 9px; color: #e2001a; font-weight: 800; font-family: Arial, sans-serif;">${escapeHtml(p.cardUnitPrice)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
${p.badgeCustomTemplate ? `
|
||||
<tr height="1">
|
||||
<td align="center" valign="bottom" style="padding: 2px 8px 12px 8px; text-align: center; height: 1px;">
|
||||
${p.cardUnitPrice || p.unitPrice ? `<div style="font-size: 9px; color: #666666; font-weight: 500; font-family: Arial, sans-serif; text-align: center;">${escapeHtml(p.cardUnitPrice || p.unitPrice)}</div>` : '<div style="font-size: 9px; line-height: 9px; height: 9px;"> </div>'}
|
||||
</td>
|
||||
</tr>` : ''}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Escapovanie textu pre HTML e-mailu
|
||||
|
||||
export function escapeHtml(str: string | null | undefined): string {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Text nadpisu so zalomením riadkov: Enter (nový riadok) alebo <br> v texte = <br>, inak escapované
|
||||
export function escapeWithBreaks(str: string | null | undefined): string {
|
||||
return escapeHtml(str)
|
||||
.replace(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/\r?\n/g, '<br>');
|
||||
}
|
||||
@@ -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] } : {})
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// Produktová karta v štýle letáku (vzor coop_nws_009):
|
||||
// hore "vizuál" – fotka produktu cez celú kartu, cenovka cez fotku vpravo dole, štítky predajní nad cenovkou,
|
||||
// dole texty – názov (tučne) + gramáž, jednotková cena, poznámka.
|
||||
//
|
||||
// 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, ProductLabel, ProductPhoto } from '@/types/newsletter';
|
||||
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
|
||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||
import { pickAlt } from './altText';
|
||||
import { escapeHtml, escapeWithBreaks } from './html';
|
||||
|
||||
export type CardVariant = 'standard' | 'loyalty';
|
||||
export type RenderMode = 'preview' | 'export';
|
||||
|
||||
export interface CardContext {
|
||||
variant: CardVariant;
|
||||
mode: RenderMode;
|
||||
cardWidth: number; // nominálna šírka karty v px (2 stĺpce = 294, 1 stĺpec = 600)
|
||||
cardBg: string; // predvolená farba karty v sekcii
|
||||
validity: string; // platnosť zobrazená na karte ('' = skrytá)
|
||||
sectionTitle?: string;
|
||||
// Farby predvolenej (negenerovanej) cenovky bežných produktov
|
||||
defaultBadge: { hdrBg: string; hdrColor: string; prcBg: string; prcColor: string; customText: string | null };
|
||||
}
|
||||
|
||||
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 = -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 {
|
||||
return cardWidth <= 300 ? Math.round((cardWidth * 245) / 294) : 300;
|
||||
}
|
||||
|
||||
// Kontext karty z nastavení sekcie
|
||||
export function cardContextForModule(m: NewsletterModule, mode: RenderMode): CardContext {
|
||||
const variant: CardVariant = m.type === 'loyalty_club' ? 'loyalty' : 'standard';
|
||||
const showValidity = m.data.showCardValidity === true;
|
||||
return {
|
||||
variant,
|
||||
mode,
|
||||
cardWidth: variant === 'standard' && m.data.columns === 1 ? 600 : 294,
|
||||
cardBg: m.data.cardBgColor || (variant === 'loyalty' ? '#e6e1d9' : '#feeaea'),
|
||||
validity: showValidity ? m.data.globalValidity || '' : '',
|
||||
sectionTitle: m.data.sectionTitle,
|
||||
defaultBadge: {
|
||||
hdrBg: m.data.badgeHeaderBg || '#fefefe',
|
||||
hdrColor: m.data.badgeHeaderColor || '#000000',
|
||||
prcBg: m.data.badgePriceBg || '#e2001a',
|
||||
prcColor: m.data.badgePriceColor || '#fefefe',
|
||||
customText: m.data.badgeHeaderCustomText || null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Trieda karty pre ochranu farby v tmavom režime (podľa šablóny)
|
||||
const CARD_COLOR_CLASSES: Record<string, string> = {
|
||||
'#feeaea': 'card-fantasticky',
|
||||
'#fef0c7': 'card-domace',
|
||||
'#dcb085': 'card-grill',
|
||||
'#e3f0e4': 'card-zelena',
|
||||
'#e6e1d9': 'card-vernostna',
|
||||
'#fefefe': 'card-white-bg'
|
||||
};
|
||||
|
||||
// V náhľade idú cudzie obrázky cez /api/img (rovnaký pôvod), aby sa vizuál dal pri exporte uložiť ako obrázok
|
||||
function assetUrl(url: string | undefined, mode: RenderMode): string {
|
||||
if (!url) return '';
|
||||
if (mode === 'preview' && /^https?:\/\//i.test(url)) {
|
||||
return `/api/img?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Údaje pre premenné v šablóne cenovky
|
||||
function badgeData(p: CardProduct, variant: CardVariant): BadgeProductData {
|
||||
if (variant === 'loyalty') {
|
||||
return {
|
||||
priceMain: p.cardPriceMain || p.priceMain || '0',
|
||||
priceSub: p.cardPriceSub || p.priceSub || '00',
|
||||
discountText: p.cardDiscount || p.discountText || '',
|
||||
unitPrice: p.cardUnitPrice || p.unitPrice || '',
|
||||
cardPriceMain: p.cardPriceMain,
|
||||
cardPriceSub: p.cardPriceSub,
|
||||
cardDiscount: p.cardDiscount
|
||||
};
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function badgeWidthFor(p: CardProduct, variant: CardVariant): number {
|
||||
if (p.badgeWidth) return p.badgeWidth;
|
||||
if (p.badgeCustomTemplate) return p.badgeCustomTemplate.width || 110;
|
||||
if (p.customBadgePngUrl) return 110;
|
||||
return variant === 'loyalty' ? 140 : 95;
|
||||
}
|
||||
|
||||
// Samotná cenovka: vlastná PNG > šablóna > predvolená cenovka
|
||||
function renderBadge(p: CardProduct, ctx: CardContext, width: number): string {
|
||||
if (p.customBadgePngUrl) {
|
||||
return `<img src="${escapeHtml(assetUrl(p.customBadgePngUrl, ctx.mode))}" width="${width}" alt="" style="display: block; width: ${width}px; height: auto; border: 0;">`;
|
||||
}
|
||||
if (p.badgeCustomTemplate) {
|
||||
return renderCleanBadgeSvg(p.badgeCustomTemplate, badgeData(p, ctx.variant), width);
|
||||
}
|
||||
if (ctx.variant === 'loyalty') {
|
||||
return renderDualSticker(p);
|
||||
}
|
||||
const b = ctx.defaultBadge;
|
||||
const mainFontSize = Math.max(28, Math.round(width * 0.54));
|
||||
const subFontSize = Math.max(14, Math.round(width * 0.27));
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: ${b.hdrBg}; border: 0; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.18); width: ${width}px; overflow: hidden;">
|
||||
${b.customText ? `
|
||||
<tr>
|
||||
<td bgcolor="${b.hdrBg}" style="padding: 4px 4px; text-align: center; background-color: ${b.hdrBg};">
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 10px; color: ${b.hdrColor}; font-weight: 800; text-transform: uppercase;">${escapeHtml(b.customText)}</span>
|
||||
</td>
|
||||
</tr>` : p.discountText ? `
|
||||
<tr>
|
||||
<td style="padding: 2px 4px; text-align: right; background-color: ${b.hdrBg};">
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 9px; color: ${b.hdrColor}; font-weight: 700; text-transform: lowercase;">${escapeHtml(p.badgeText || 'zľava')}</span>
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 20px; color: ${b.hdrColor}; font-weight: 900; line-height: 1; margin-left: 2px;">${escapeHtml(p.discountText)}</span>
|
||||
</td>
|
||||
</tr>` : ''}
|
||||
<tr>
|
||||
<td class="force-text-white" bgcolor="${b.prcBg}" style="background-color: ${b.prcBg}; padding: 4px 2px 2px 2px; font-family: 'Arial Narrow', Arial, sans-serif; color: ${b.prcColor}; font-weight: 900; line-height: 1; text-align: center; border-radius: 0 0 12px 12px;">
|
||||
<span style="font-size: ${mainFontSize}px;">${escapeHtml(p.priceMain)}</span><sup style="font-size: ${subFontSize}px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.priceSub)}</sup>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Predvolená vernostná cenovka (Bez karty / S kartou)
|
||||
function renderDualSticker(p: CardProduct): string {
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width: 140px;">
|
||||
<tr>
|
||||
<td width="42%" valign="bottom">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border-radius: 10px 0 0 10px; box-shadow: -2px 4px 8px rgba(0,0,0,0.12); overflow: hidden;">
|
||||
<tr>
|
||||
<td bgcolor="#e2001a" style="background-color: #e2001a; padding: 4px 2px; text-align: center;">
|
||||
<div style="font-size: 6px; color: #fefefe; font-weight: 700; text-transform: uppercase; line-height: 1; margin-bottom: 1px;">Bez karty</div>
|
||||
<div style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 8px; color: #fefefe; font-weight: 900; line-height: 1;">zľava ${escapeHtml(p.noCardDiscount)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px; text-align: center; color: #000000; font-family: 'Arial Narrow', Arial, sans-serif; font-weight: 900; line-height: 1;">
|
||||
<span style="font-size: 20px;">${escapeHtml(p.noCardPriceMain)}</span><sup style="font-size: 10px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.noCardPriceSub)}</sup>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td width="58%" valign="top">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fefefe; border-radius: 0 10px 10px 0; box-shadow: 4px 4px 12px rgba(0,0,0,0.15); overflow: hidden;">
|
||||
<tr>
|
||||
<td style="background-color: #fefefe; padding: 5px 4px 2px 4px; text-align: right;">
|
||||
<div style="font-size: 7px; color: #e2001a; font-weight: 800; text-transform: uppercase; line-height: 1; text-align: center; margin-bottom: 1px;">S kartou</div>
|
||||
<div style="text-align: right; line-height: 1;">
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 8px; color: #000000; font-weight: 700; text-transform: lowercase;">zľava</span>
|
||||
<span style="font-family: 'Arial Narrow', Arial, sans-serif; font-size: 18px; color: #000000; font-weight: 900; margin-left: 1px;">${escapeHtml(p.cardDiscount)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#e2001a" style="background-color: #e2001a; padding: 3px 2px; text-align: center; color: #fefefe; font-family: 'Arial Narrow', Arial, sans-serif; font-weight: 900; line-height: 1; border-radius: 0 0 10px 0;">
|
||||
<span style="font-size: 48px;">${escapeHtml(p.cardPriceMain)}</span><sup style="font-size: 24px; vertical-align: top; line-height: 1.2;">${escapeHtml(p.cardPriceSub)}</sup>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 4px; font-size: 8px; color: #666666; font-family: Arial, sans-serif;">${escapeHtml(p.noCardUnitPrice)}</td>
|
||||
<td align="center" style="padding-top: 4px; font-size: 9px; color: #e2001a; font-weight: 800; font-family: Arial, sans-serif;">${escapeHtml(p.cardUnitPrice)}</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Štítky predajní (Potraviny / Supermarket / Tempo) pod sebou; výška podľa veľkosti cenovky
|
||||
// Biely okraj okolo nich ako skupiny: pod štítkami je biela vrstva z rovnakých štítkov, každý o hrúbku okraja
|
||||
// väčší a so zaoblenými rohmi – kde sa štítky dotýkajú, biela splynie, takže linka ide okolo celej skupiny
|
||||
// a vonkajšie rohy sú zaoblené (polovica hrúbky linky okolo pozadia cenovky: 2,25 px pri cenovke širokej 130 px).
|
||||
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ý
|
||||
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 ?? 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.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.storesSize ?? DEFAULT_STORES_SIZE,
|
||||
p.labels || [], p.extraPhotos || [], photoStack(p)
|
||||
];
|
||||
const str = JSON.stringify(relevant);
|
||||
let h1 = 5381;
|
||||
let h2 = 52711;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str.charCodeAt(i);
|
||||
h1 = (h1 * 33) ^ c;
|
||||
h2 = (h2 * 33) ^ c;
|
||||
}
|
||||
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).
|
||||
// Výsledok je podiel rozmerov viewBoxu; na serveri (bez DOM) sú okraje 0.
|
||||
interface BadgeBgBox {
|
||||
top: number; // okraj zhora / výška
|
||||
right: number; // okraj sprava / šírka
|
||||
aspect: number; // výška / šírka SVG
|
||||
}
|
||||
const badgeBgCache = new Map<string, BadgeBgBox>();
|
||||
|
||||
function badgeBackgroundBox(t: BadgeTemplate): BadgeBgBox | null {
|
||||
if (!t.svgRaw || typeof document === 'undefined') return null;
|
||||
const cached = badgeBgCache.get(t.svgRaw);
|
||||
if (cached) return cached;
|
||||
let box: BadgeBgBox | null = null;
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = 'position: absolute; left: -10000px; top: 0; visibility: hidden;';
|
||||
try {
|
||||
holder.innerHTML = renderCleanBadgeSvg({ ...t, layersConfig: [] }, { priceMain: '', priceSub: '' }, 200);
|
||||
document.body.appendChild(holder);
|
||||
const svg = holder.querySelector('svg');
|
||||
const vb = svg?.viewBox.baseVal;
|
||||
const bb = svg?.getBBox();
|
||||
if (vb && bb && vb.width && vb.height && bb.width && bb.height) {
|
||||
box = {
|
||||
top: Math.max(0, (bb.y - vb.y) / vb.height),
|
||||
right: Math.max(0, (vb.x + vb.width - (bb.x + bb.width)) / vb.width),
|
||||
aspect: vb.height / vb.width
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
box = null;
|
||||
} finally {
|
||||
holder.remove();
|
||||
}
|
||||
if (box) badgeBgCache.set(t.svgRaw, box);
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): string {
|
||||
const height = cardVisualHeight(ctx.cardWidth);
|
||||
const bg = p.cardBgColor || ctx.cardBg;
|
||||
const bw = badgeWidthFor(p, ctx.variant);
|
||||
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
|
||||
const ox = p.imageOffsetX || 0;
|
||||
const oy = p.imageOffsetY || 0;
|
||||
const sx = Math.round(p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X);
|
||||
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
|
||||
const stores = renderStores(p, ctx.mode, p.storesSize ?? DEFAULT_STORES_SIZE, bw);
|
||||
// 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;">
|
||||
${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)}
|
||||
</div>
|
||||
</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(' ');
|
||||
const price = (main?: string, sub?: string) => (main ? `${main}${sub ? `,${sub}` : ''} €` : '');
|
||||
const pct = (d?: string) => (d ? d.replace('-', '').replace(/\s*%/, ' %') : '');
|
||||
let priceText = '';
|
||||
if (variant === 'loyalty') {
|
||||
const card = price(p.cardPriceMain, p.cardPriceSub);
|
||||
const noCard = price(p.noCardPriceMain, p.noCardPriceSub);
|
||||
priceText = [
|
||||
card ? `s vernostnou kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : '',
|
||||
noCard ? `bez vernostnej karty ${noCard}${p.noCardDiscount ? ` (zľava ${pct(p.noCardDiscount)})` : ''}` : ''
|
||||
].filter(Boolean).join(', ');
|
||||
} 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)})` : ''}` : '',
|
||||
app ? `v aplikácii ${app}` : ''
|
||||
].filter(Boolean).join(', ');
|
||||
}
|
||||
return pickAlt(p.imageAlt, [name, priceText].filter(Boolean).join(' – '), 'Produkt');
|
||||
}
|
||||
|
||||
// Vizuál karty v e-maile: uložený obrázok z exportu, inak jednoduchá záloha (fotka + cenovka pod ňou)
|
||||
function renderVisualExport(p: CardProduct, ctx: CardContext, key: string): string {
|
||||
const alt = productCardAlt(p, ctx.variant);
|
||||
const w = ctx.cardWidth;
|
||||
if (p.cardImage?.key === key && p.cardImage?.url) {
|
||||
return `<img src="${escapeHtml(p.cardImage.url)}" width="${w}" alt="${escapeHtml(alt)}" style="display: block; width: 100%; max-width: ${w}px; height: auto; margin: 0 auto; border: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">`;
|
||||
}
|
||||
const bw = badgeWidthFor(p, ctx.variant);
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr><td style="padding: 12px 12px 0 12px; text-align: center; line-height: 0;"><img src="${escapeHtml(p.imageUrl)}" width="${Math.min(220, w - 24)}" alt="${escapeHtml(alt)}" style="display: block; width: 100%; max-width: ${Math.min(220, w - 24)}px; height: auto; margin: 0 auto; border: 0;"></td></tr>
|
||||
<tr><td align="right" style="padding: 6px 12px 0 12px; line-height: normal; font-size: 12px;"><table role="presentation" cellspacing="0" cellpadding="0" border="0" align="right" style="width: ${bw}px;"><tr><td>${renderBadge(p, ctx, bw)}</td></tr></table></td></tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Texty pod vizuálom: názov 15px/700 + gramáž 15px/400, jednotková cena 11px #555, poznámka a platnosť 10px #888
|
||||
const TEXT_FONT = "font-family: 'Poppins', Helvetica, Arial, sans-serif;";
|
||||
function renderTexts(p: CardProduct, ctx: CardContext): string {
|
||||
// Pri predvolenej vernostnej cenovke sú jednotkové ceny priamo na cenovke
|
||||
const unitPrice =
|
||||
ctx.variant === 'loyalty'
|
||||
? p.badgeCustomTemplate || p.customBadgePngUrl
|
||||
? p.cardUnitPrice || p.unitPrice
|
||||
: ''
|
||||
: 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;">${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;">${escapeWithBreaks(unitPrice)}</div>` : ''}${
|
||||
p.note ? small(p.note) : ''}${validity ? small(validity) : ''}`;
|
||||
}
|
||||
|
||||
// Celá karta produktu
|
||||
export function renderProductCard(p: CardProduct, ctx: CardContext): string {
|
||||
const bg = p.cardBgColor || ctx.cardBg;
|
||||
const colorClass = CARD_COLOR_CLASSES[(bg || '').toLowerCase()] || '';
|
||||
const key = cardVisualKey(p, ctx);
|
||||
const visual = ctx.mode === 'preview' ? renderVisualPreview(p, ctx, key) : renderVisualExport(p, ctx, key);
|
||||
return `
|
||||
<table ${ctx.mode === 'preview' ? `data-product-id="${escapeHtml(p.id)}" ` : ''}role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product ${colorClass}" bgcolor="${bg}" style="background-color: ${bg}; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); overflow: hidden; height: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 0; line-height: 0; font-size: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">
|
||||
${visual}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="prod-pad" valign="top" style="padding: 2px 14px 12px 14px; text-align: left; height: 100%;">
|
||||
${renderTexts(p, ctx)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// 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;"> </td></tr>' : '';
|
||||
const cells = row
|
||||
.map(
|
||||
(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('');
|
||||
// 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>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="1">
|
||||
<tr>${cells}${filler}
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Pripomienky k newslettru cez zdieľaný odkaz (/review/<token>)
|
||||
// Odkaz nesie snímku kampane; pripomienky (komentár, text, kresba) sú ukotvené k modulu:
|
||||
// x = podiel šírky e-mailu (0–1), y = px od vrchu modulu, kresba = body [x, y] v rovnakých súradniciach.
|
||||
import { supabase } from './supabase';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
|
||||
export type ReviewKind = 'comment' | 'text' | 'drawing';
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
token: string;
|
||||
template_id: string | null;
|
||||
title: string;
|
||||
snapshot: Pick<NewsletterCampaign, 'title' | 'preheader' | 'modules'>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
review_id: string;
|
||||
kind: ReviewKind;
|
||||
module_id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
path: Array<[number, number]> | null;
|
||||
text: string;
|
||||
color: string;
|
||||
author: string;
|
||||
done: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type NewReviewComment = Omit<ReviewComment, 'id' | 'done' | 'created_at'>;
|
||||
|
||||
export const REVIEW_COLORS = ['#e2001a', '#003399', '#16a34a', '#111827'];
|
||||
|
||||
// Chýbajúce tabuľky (migrácia nebola spustená) – zrozumiteľná chyba
|
||||
function explain(error: { message?: string; code?: string } | null): Error | null {
|
||||
if (!error) return null;
|
||||
if (error.code === '42P01' || error.code === 'PGRST205' || /reviews|review_comments/.test(error.message || '')) {
|
||||
return new Error(
|
||||
'Tabuľky pre pripomienky v databáze chýbajú. Spustite v Supabase SQL editore súbor supabase/migrations/002_reviews.sql.'
|
||||
);
|
||||
}
|
||||
return new Error(error.message || 'Chyba databázy');
|
||||
}
|
||||
|
||||
function newToken(): string {
|
||||
const bytes = new Uint8Array(12);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(36).padStart(2, '0')).join('').slice(0, 20);
|
||||
}
|
||||
|
||||
export function reviewUrl(token: string): string {
|
||||
return `${window.location.origin}/review/${token}`;
|
||||
}
|
||||
|
||||
function snapshotOf(campaign: NewsletterCampaign): Review['snapshot'] {
|
||||
return { title: campaign.title, preheader: campaign.preheader, modules: campaign.modules };
|
||||
}
|
||||
|
||||
// Odkaz na pripomienky pre kampaň (posledný vytvorený), ak existuje
|
||||
export async function getReviewForTemplate(templateId: string): Promise<Review | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('reviews')
|
||||
.select('*')
|
||||
.eq('template_id', templateId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1);
|
||||
const err = explain(error);
|
||||
if (err) throw err;
|
||||
return (data?.[0] as Review) || null;
|
||||
}
|
||||
|
||||
export async function getReviewByToken(token: string): Promise<Review | null> {
|
||||
const { data, error } = await supabase.from('reviews').select('*').eq('token', token).limit(1);
|
||||
const err = explain(error);
|
||||
if (err) throw err;
|
||||
return (data?.[0] as Review) || null;
|
||||
}
|
||||
|
||||
export async function createReview(campaign: NewsletterCampaign): Promise<Review> {
|
||||
const { data, error } = await supabase
|
||||
.from('reviews')
|
||||
.insert([{ token: newToken(), template_id: campaign.id || null, title: campaign.title, snapshot: snapshotOf(campaign) }])
|
||||
.select();
|
||||
const err = explain(error);
|
||||
if (err) throw err;
|
||||
return data![0] as Review;
|
||||
}
|
||||
|
||||
// Aktualizácia snímky v odkaze (pripomienky ostávajú)
|
||||
export async function updateReviewSnapshot(reviewId: string, campaign: NewsletterCampaign): Promise<Review> {
|
||||
const { data, error } = await supabase
|
||||
.from('reviews')
|
||||
.update({ title: campaign.title, snapshot: snapshotOf(campaign), updated_at: new Date().toISOString() })
|
||||
.eq('id', reviewId)
|
||||
.select();
|
||||
const err = explain(error);
|
||||
if (err) throw err;
|
||||
return data![0] as Review;
|
||||
}
|
||||
|
||||
export async function listReviewComments(reviewId: string): Promise<ReviewComment[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('review_comments')
|
||||
.select('*')
|
||||
.eq('review_id', reviewId)
|
||||
.order('created_at', { ascending: true });
|
||||
const err = explain(error);
|
||||
if (err) throw err;
|
||||
return (data as ReviewComment[]) || [];
|
||||
}
|
||||
|
||||
export async function addReviewComment(comment: NewReviewComment): Promise<ReviewComment> {
|
||||
const { data, error } = await supabase.from('review_comments').insert([comment]).select();
|
||||
const err = explain(error);
|
||||
if (err) throw err;
|
||||
return data![0] as ReviewComment;
|
||||
}
|
||||
|
||||
export async function setReviewCommentDone(id: string, done: boolean): Promise<void> {
|
||||
const err = explain((await supabase.from('review_comments').update({ done }).eq('id', id)).error);
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
export async function deleteReviewComment(id: string): Promise<void> {
|
||||
const err = explain((await supabase.from('review_comments').delete().eq('id', id)).error);
|
||||
if (err) throw err;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type ModuleType =
|
||||
| 'products_grid'
|
||||
| 'loyalty_club'
|
||||
| 'articles_row'
|
||||
| 'validity_bar'
|
||||
| 'footer';
|
||||
|
||||
export interface StoreTag {
|
||||
@@ -18,12 +19,19 @@ export interface StoreTag {
|
||||
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
title: string;
|
||||
title: string; // názov (tučne), napr. "Deli tyčinka 3 druhy"
|
||||
quantity?: string; // gramáž / množstvo (normálne písmo za názvom), napr. "35 g"
|
||||
note?: string; // poznámka pod jednotkovou cenou, napr. "Ilustračné foto"
|
||||
imageUrl: string;
|
||||
priceMain: string; // napr. "1" alebo "0"
|
||||
priceSub: string; // napr. "99" alebo "89"
|
||||
unitPrice: string; // napr. "1kg = 4,91 €"
|
||||
unitPrice: string; // jednotková cena pod názvom, napr. "jednotková cena 9,43 EUR/kg"
|
||||
discountText: string; // napr. "-23%" alebo "23%"
|
||||
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)
|
||||
@@ -40,9 +48,52 @@ export interface ProductItem {
|
||||
cardBgColor?: string; // HEX napr. "#feeaea" (fantasticky), "#fef0c7" (domace), "#dcb085" (grill)
|
||||
showSlovakFlag?: boolean;
|
||||
customBadgePngUrl?: string; // Voliteľná statická PNG cenovka
|
||||
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)
|
||||
imageAspect?: number; // (staršie) pomer strán fotky
|
||||
cardImage?: { key: string; url: string }; // obrázok vizuálu karty pripravený pri exporte
|
||||
badgeLayout?: 'below' | 'side'; // Poloha cenovky: 'below' = pod obrázkom (default), 'side' = vedľa obrázka
|
||||
}
|
||||
|
||||
// Produkt na karte: bežný produkt + ceny vernostného produktu (bez karty / s kartou)
|
||||
export interface CardProduct extends ProductItem {
|
||||
cardUnitPrice?: string;
|
||||
noCardPriceMain?: string;
|
||||
noCardPriceSub?: string;
|
||||
noCardDiscount?: string;
|
||||
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;
|
||||
@@ -52,9 +103,16 @@ export interface AnnouncementModuleData {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Lišta platnosti akcie nad produktmi (vzhľad ako oznamovacia lišta, bez badge a CTA)
|
||||
export interface ValidityBarModuleData {
|
||||
text: string; // text pred dátumom, napr. "Akcia platí"
|
||||
enabled?: boolean; // false = skrytá v e-maile
|
||||
}
|
||||
|
||||
export interface HeaderModuleData {
|
||||
logoUrl: string;
|
||||
logoLink: string;
|
||||
logoAlt?: string;
|
||||
navLinks: Array<{ label: string; url: string; variant?: 'outline' | 'filled' }>;
|
||||
}
|
||||
|
||||
@@ -63,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;
|
||||
@@ -95,10 +154,15 @@ export interface ProductsGridModuleData {
|
||||
cardBgColor?: string;
|
||||
cardBorderColor?: string;
|
||||
columns?: 1 | 2;
|
||||
showCardValidity?: boolean; // platnosť na kartách produktov (predvolene skrytá)
|
||||
// staršie kampane: posun štítkov na úrovni sekcie (dnes sa nastavuje pri produkte)
|
||||
storesOffsetX?: number;
|
||||
storesOffsetY?: number;
|
||||
}
|
||||
|
||||
export interface LoyaltyClubModuleData {
|
||||
headerImageUrl: string;
|
||||
headerImageAlt?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
@@ -165,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 {
|
||||
@@ -178,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“
|
||||
}
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 543 KiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"id": "57384db9-7751-4e36-898d-b8b19f2a1f63",
|
||||
"name": "Super cena so zlovaou",
|
||||
"svg_background_url": null,
|
||||
"svg_raw": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" xmlns=\"http://www.w3.org/2000/svg\" width=\"81.35\" height=\"117.08\" version=\"1.1\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 81.35 117.08\">\n <!-- Generator: Adobe Illustrator 30.8.2, SVG Export Plug-In . SVG Version: 2.1.4 Build 7) -->\n <defs>\n <clipPath id=\"clippath\">\n <rect x=\"1\" y=\"11.28\" width=\"79.11\" height=\"98.14\" fill=\"none\"/>\n </clipPath>\n <clipPath id=\"clippath-1\">\n <rect x=\"1\" y=\"11.28\" width=\"79.11\" height=\"98.14\" fill=\"none\"/>\n </clipPath>\n <clipPath id=\"clippath-2\">\n <rect x=\"-10.9\" y=\"-33.7\" width=\"131.52\" height=\"171.36\" fill=\"none\"/>\n </clipPath>\n <mask id=\"mask\" x=\"-10.9\" y=\"-33.7\" width=\"131.52\" height=\"171.36\" maskUnits=\"userSpaceOnUse\">\n <g clip-path=\"url(#clippath-2)\">\n <image width=\"548\" height=\"714\" transform=\"translate(-10.9 -33.7) scale(.24)\" xlink:href=\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAiIAAALJCAYAAACJN0hrAAAACXBIWXMAAC4jAAAuIwF4pT92AAAU8UlEQVR4nO3dIXAj9/3G4W/+7VyIhUpsbhefTEKk4lO5jY5Yxr6QEtukJDa/w5VJ0BmVxCIllUhJZBRic4kFWCgFV9RM/zNpk7vYfrW7zzOzk8xkkrxzM3fz8e5Pq8+q6kMBAAT8X3oAANBdQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABDz2/QAmmNvb69evnz54197vV69fPkyPQs+ymw2q/l8XrPZrBaLRXoOdN5nVfUhPYLN1O/3azgc1mAwqOFwWNvb2+lJ8KjW63XN5/Maj8e1XC7Tc6CThAg/2tvbq9FoVKPRqAaDQW1tbaUnwbNYrVY1Ho/r5uYmPQU6R4h0XK/Xq6Ojozo6OvKYhc47Pz+vi4uL9AzoFCHSUb1er05PT+vo6MgjF/gPf/zjH90ZgWckRDqm1+vVyclJnZycCBD4CavVqvb3950ZgWciRDpkOBzWZDKp3d3d9BTYaNPptEajUXoGdIIQ6YiLi4s6PT1Nz4BGWK/X1ev10jOgE7zQrOX6/X4tFgsRAh9ha2ur+v1+egZ0ghBpsfF4XN9++61Pw8AnGA6H6QnQCUKkpd6+fVt/+ctf0jOgsQaDQXoCdIIzIi00mUzq6OgoPQMabbVa1c7OTnoGtJ47Ii3z/v17EQKPYHt7u/b29tIzoPWESIu8f/++Dg4O0jMA4BcTIi3x9u1bEQJA4wiRFjg7O6uTk5P0DAD4aEKk4UajUX311VfpGQDwSYRIg+3s7NRkMknPAIBPJkQabDKZ+OI6ABpNiDTU2dlZvXr1Kj0DAH4VIdJA/X7fuRAAWkGINJBzIQC0hRBpmDdv3vgSOwBaQ4g0SK/Xq9PT0/QMAHg0QqRBTk5OfEoGgFYRIg3i7akAtI0QaYiDgwN3QwBoHSHSEL7QDoA2EiIN0Ov1ajQapWcAwKMTIg3w6tWr2traSs8AgEcnRBrA3RAA2kqINMBwOExPAIAnIUQ23M7OTu3u7qZnAMCTECIbbjAYpCcAwJMRIhvOYxkA2kyIbDghAkCbCZEN1uv1fNMuAK0mRDaYQ6oAtJ0Q2WAeywDQdkJkg3mbKgBtJ0Q2mPMhALSdEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQmSDzefz9ATorNVqVXd3d+kZ0HpCZIPNZrP0BOgsv//geQiRDbZYLGq9XqdnQCe5IwnPQ4hsOH8YQoY7IvA8hMiG84chPL/1el2LxSI9AzpBiGy4i4uLur6+Ts+AThmPx+kJ0BlCpAGOj4/r/v4+PQM64fLyUvzDM/qsqj6kR/DzhsNhTSaT2t3dTU+B1ppOpzUajdIzoFPcEWmI2WxW+/v7flKDJ3J5eSlCIOA3VfXn9Ah+mR9++KGur6/rn//8Z33xxRf14sWL9CRovPV6Xa9fv653796lp0AneTTTYP1+v4bDYQ0GgxoOh7W9vZ2eBBtvtVrVbDar+Xxes9nMp2MgTIi0yN7eXnoCbDyvbYfNIkQAgBiHVQGAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMT8Nj2Ax7O3t5eeABvv7u4uPQH4D0Kkwfr9fg2HwxoMBjUcDmt7ezs9CTbearWq2WxW8/m8ZrNZLRaL9CTotM+q6kN6BB/n7OysTk9Pa2trKz0FGm+9Xtd4PK7r6+v0FOik31TVn9Mj+GV6vV59/fXXdXJyUi9evEjPgVZ48eJFHRwc1Oeff15/+9vf0nOgc9wRaYjhcFiTyaR2d3fTU6C1ptNpjUaj9AzoFHdEGqDX69U333wjQuCJ7e7uujMCz0yINMDXX39dw+EwPQM6YTgc1nfffVffffddegp0gkczG+7s7Ky++uqr9AzolPV6Xb1eLz0DOsELzTacOyHw/La2tqrf76dnQCcIkQ03GAzSE6CT/BAAz0OIbLB+v+9dIRDihwB4HkJkg/mJDHL8/oPnIUQ2mJ/IIGd7e9v3N8EzECIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKECAAQI0QAgBghAgDECBEAIEaIAAAxQgQAiBEiAECMEAEAYoQIABAjRACAGCECAMQIEQAgRogAADFCBACIESIAQIwQAQBihAgAECNEAIAYIQIAxAgRACBGiAAAMUIEAIgRIgBAjBABAGKLine truncated
|
||||
"layers_config": {
|
||||
"align": "center",
|
||||
"width": 130,
|
||||
"layers": [
|
||||
{
|
||||
"x": 39,
|
||||
"y": 82,
|
||||
"id": "l-1790245126860-2",
|
||||
"fill": "#ffffff",
|
||||
"text": "{cena}",
|
||||
"type": "text",
|
||||
"align": "center",
|
||||
"fontSize": 37,
|
||||
"fontFamily": "Aptly",
|
||||
"fontSource": "adobe",
|
||||
"fontWeight": "900",
|
||||
"indexOffsetY": -10,
|
||||
"indexFontSize": 16
|
||||
},
|
||||
{
|
||||
"x": 50,
|
||||
"y": 20,
|
||||
"id": "l-1790245215231",
|
||||
"fill": "#ffffff",
|
||||
"text": "-{zlava}",
|
||||
"type": "text",
|
||||
"align": "center",
|
||||
"fontSize": 12,
|
||||
"fontFamily": "Aptly",
|
||||
"fontSource": "adobe",
|
||||
"fontWeight": "800"
|
||||
}
|
||||
],
|
||||
"paddingTop": 9,
|
||||
"paddingBottom": 4
|
||||
},
|
||||
"preview_image_url": null,
|
||||
"created_at": "2026-09-24T10:20:25.008977+00:00"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"exportedAt": "2026-10-19T08:59:25.680Z",
|
||||
"sourceStorageBase": "https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Pripomienky k newslettru cez zdieľaný odkaz
|
||||
--
|
||||
-- Spustenie na existujúcom projekte: Supabase Dashboard → SQL Editor → vložiť tento súbor → Run.
|
||||
-- (Pre novú inštaláciu je to už súčasťou supabase/schema.sql.) Idempotentné – dá sa spustiť opakovane.
|
||||
|
||||
-- Odkaz na pripomienky: snímka newslettra v čase vytvorenia / poslednej aktualizácie
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE, -- časť odkazu /review/<token>
|
||||
template_id UUID REFERENCES templates(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, -- kampaň (moduly) v čase zdieľania
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_template ON reviews(template_id);
|
||||
|
||||
-- Pripomienky: komentár (špendlík), text priamo v newslettri, kresba
|
||||
-- Poloha je ukotvená k modulu: x = podiel šírky (0–1), y = px od vrchu modulu
|
||||
CREATE TABLE IF NOT EXISTS review_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('comment', 'text', 'drawing')),
|
||||
module_id TEXT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
path JSONB, -- kresba: [[x, y], ...] v rovnakých súradniciach
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#e2001a',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
done BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_comments_review ON review_comments(review_id, created_at);
|
||||
|
||||
-- Interný nástroj: prístup cez anon kľúč (rovnako ako ostatné tabuľky)
|
||||
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE review_comments ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS "Allow public all on reviews" ON reviews;
|
||||
DROP POLICY IF EXISTS "Allow public all on review_comments" ON review_comments;
|
||||
CREATE POLICY "Allow public all on reviews" ON reviews FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "Allow public all on review_comments" ON review_comments FOR ALL USING (true) WITH CHECK (true);
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Schéma pre Modulárny generátor newsletterov (COOP Jednota)
|
||||
--
|
||||
-- Založenie databázy na novom Supabase projekte:
|
||||
-- Supabase Dashboard → SQL Editor → vložiť celý tento súbor → Run.
|
||||
-- Skript je idempotentný – dá sa spustiť opakovane bez straty dát.
|
||||
|
||||
-- 1. Tabuľka šablón / konceptov newslettera
|
||||
-- data = { preheader, modules[] } – celý obsah kampane ako JSON
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL DEFAULT 'Nový newsletter',
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'ready', 'exported')),
|
||||
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
brevo_campaign_id INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Index pre rýchle zoradenie podľa dátumu úpravy
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates(updated_at DESC);
|
||||
|
||||
-- 2. Tabuľka šablón cenoviek (Badges)
|
||||
-- layers_config = { layers[], width, align, paddingTop, paddingBottom }
|
||||
-- (staršie záznamy môžu mať len pole vrstiev – aplikácia podporuje oba tvary)
|
||||
CREATE TABLE IF NOT EXISTS badges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
svg_background_url TEXT,
|
||||
svg_raw TEXT,
|
||||
layers_config JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
preview_image_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 3. Tabuľka uložených profilov mapovania importu (zatiaľ nevyužitá aplikáciou)
|
||||
CREATE TABLE IF NOT EXISTS mapping_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
file_type TEXT NOT NULL CHECK (file_type IN ('csv', 'json', 'xml')),
|
||||
mapping_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 4. RLS (Row Level Security) – interný nástroj: povolený prístup cez anon kľúč
|
||||
ALTER TABLE templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE badges ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE mapping_presets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Allow public all on templates" ON templates;
|
||||
DROP POLICY IF EXISTS "Allow public all on badges" ON badges;
|
||||
DROP POLICY IF EXISTS "Allow public all on mapping_presets" ON mapping_presets;
|
||||
|
||||
CREATE POLICY "Allow public all on templates" ON templates FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "Allow public all on badges" ON badges FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "Allow public all on mapping_presets" ON mapping_presets FOR ALL USING (true) WITH CHECK (true);
|
||||
|
||||
-- 5. Úložisko obrázkov (Storage bucket) – verejne čitateľné, aby sa obrázky zobrazili v e-mailoch.
|
||||
-- Nahrávanie ide cez /api/upload so service_role kľúčom (obchádza RLS), ďalšie politiky netreba.
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('newsletter-assets', 'newsletter-assets', true)
|
||||
ON CONFLICT (id) DO UPDATE SET public = true;
|
||||
|
||||
-- 6. Pripomienky k newslettru cez zdieľaný odkaz (rovnaké ako supabase/migrations/002_reviews.sql)
|
||||
-- Odkaz na pripomienky: snímka newslettra v čase vytvorenia / poslednej aktualizácie
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE, -- časť odkazu /review/<token>
|
||||
template_id UUID REFERENCES templates(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, -- kampaň (moduly) v čase zdieľania
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_template ON reviews(template_id);
|
||||
|
||||
-- Pripomienky: komentár (špendlík), text priamo v newslettri, kresba
|
||||
-- Poloha je ukotvená k modulu: x = podiel šírky (0–1), y = px od vrchu modulu
|
||||
CREATE TABLE IF NOT EXISTS review_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('comment', 'text', 'drawing')),
|
||||
module_id TEXT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
path JSONB, -- kresba: [[x, y], ...] v rovnakých súradniciach
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#e2001a',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
done BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_comments_review ON review_comments(review_id, created_at);
|
||||
|
||||
-- Interný nástroj: prístup cez anon kľúč (rovnako ako ostatné tabuľky)
|
||||
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE review_comments ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS "Allow public all on reviews" ON reviews;
|
||||
DROP POLICY IF EXISTS "Allow public all on review_comments" ON review_comments;
|
||||
CREATE POLICY "Allow public all on reviews" ON reviews FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "Allow public all on review_comments" ON review_comments FOR ALL USING (true) WITH CHECK (true);
|
||||
@@ -1,72 +0,0 @@
|
||||
-- Schéma pre Modulárny generátor newsletterov (COOP Jednota)
|
||||
|
||||
-- 1. Tabuľka šablón / konceptov newslettera
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title TEXT NOT NULL DEFAULT 'Nový newsletter',
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'ready', 'exported')),
|
||||
data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
brevo_campaign_id INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Index pre rýchle zoradenie podľa dátumu úpravy
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates(updated_at DESC);
|
||||
|
||||
-- 2. Tabuľka cenoviek (Badges)
|
||||
CREATE TABLE IF NOT EXISTS badges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
svg_background_url TEXT,
|
||||
svg_raw TEXT,
|
||||
layers_config JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
preview_image_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 3. Tabuľka uložených profilov mapovania (Data Mapper Presets)
|
||||
CREATE TABLE IF NOT EXISTS mapping_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
file_type TEXT NOT NULL CHECK (file_type IN ('csv', 'json', 'xml')),
|
||||
mapping_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 4. Východiskové referenčné cenovky (Seed dát pre COOP Jednota)
|
||||
INSERT INTO badges (name, svg_raw, layers_config) VALUES
|
||||
(
|
||||
'Žltá AKCIA',
|
||||
'<svg viewBox="0 0 160 80" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="80" rx="8" fill="#ffd100"/><rect x="4" y="4" width="152" height="72" rx="6" fill="none" stroke="#e0001b" stroke-width="3"/></svg>',
|
||||
'[
|
||||
{"id": "l1", "type": "text", "text": "AKCIA", "x": 80, "y": 32, "fontSize": 24, "fontWeight": "900", "fill": "#e0001b", "align": "center"},
|
||||
{"id": "l2", "type": "text", "text": "{zlava}", "x": 80, "y": 64, "fontSize": 22, "fontWeight": "800", "fill": "#001a33", "align": "center"}
|
||||
]'::jsonb
|
||||
),
|
||||
(
|
||||
'Červená SUPER CENA',
|
||||
'<svg viewBox="0 0 160 80" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="80" rx="8" fill="#e0001b"/></svg>',
|
||||
'[
|
||||
{"id": "l1", "type": "text", "text": "SUPER CENA", "x": 80, "y": 34, "fontSize": 18, "fontWeight": "900", "fill": "#ffffff", "align": "center"},
|
||||
{"id": "l2", "type": "text", "text": "{zlava}", "x": 80, "y": 64, "fontSize": 24, "fontWeight": "900", "fill": "#ffd100", "align": "center"}
|
||||
]'::jsonb
|
||||
),
|
||||
(
|
||||
'Percentuálna zľava (Kruh)',
|
||||
'<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="48" fill="#e0001b" stroke="#ffd100" stroke-width="4"/></svg>',
|
||||
'[
|
||||
{"id": "l1", "type": "text", "text": "ZĽAVA", "x": 50, "y": 36, "fontSize": 13, "fontWeight": "800", "fill": "#ffffff", "align": "center"},
|
||||
{"id": "l2", "type": "text", "text": "{zlava}%", "x": 50, "y": 68, "fontSize": 24, "fontWeight": "900", "fill": "#ffd100", "align": "center"}
|
||||
]'::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 5. Nastavenie RLS (Row Level Security) - pre interný nástroj povolíme verejný prístup cez anon kľúč
|
||||
ALTER TABLE templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE badges ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE mapping_presets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Allow public all on templates" ON templates FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "Allow public all on badges" ON badges FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "Allow public all on mapping_presets" ON mapping_presets FOR ALL USING (true) WITH CHECK (true);
|
||||