Compare commits
37
Commits
cfacf2474b
...
main
No files matched your search
@@ -6,6 +6,11 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
|||||||
# service_role kľúč – len na serveri (nahrávanie obrázkov), nikdy ho nedávajte do kódu pre prehliadač
|
# service_role kľúč – len na serveri (nahrávanie obrázkov), nikdy ho nedávajte do kódu pre prehliadač
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
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 (SMTP & API → API Keys)
|
||||||
BREVO_API_KEY=
|
BREVO_API_KEY=
|
||||||
BREVO_SENDER_NAME=COOP Jednota
|
BREVO_SENDER_NAME=COOP Jednota
|
||||||
|
|||||||
@@ -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']))
|
||||||
@@ -12,7 +12,7 @@ export async function GET(request: Request) {
|
|||||||
const res = await fetch(target, { redirect: 'follow' });
|
const res = await fetch(target, { redirect: 'follow' });
|
||||||
const contentType = res.headers.get('content-type') || '';
|
const contentType = res.headers.get('content-type') || '';
|
||||||
if (!res.ok || !contentType.startsWith('image/')) {
|
if (!res.ok || !contentType.startsWith('image/')) {
|
||||||
return Response.json({ error: `Obrázok sa nepodarilo načítať (${res.status}).` }, { status: 502 });
|
return Response.json({ error: `Obrázok sa nepodarilo načítať (${res.status}).`, upstreamStatus: res.status }, { status: 502 });
|
||||||
}
|
}
|
||||||
const body = await res.arrayBuffer();
|
const body = await res.arrayBuffer();
|
||||||
if (body.byteLength > MAX_BYTES) {
|
if (body.byteLength > MAX_BYTES) {
|
||||||
|
|||||||
+14
-26
@@ -1,42 +1,30 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getSupabaseAdmin } from '@/lib/supabase';
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await req.formData();
|
const { name } = (await req.json()) as { name?: string };
|
||||||
const file = formData.get('file') as File | null;
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'Chýba názov súboru.' }, { status: 400 });
|
||||||
if (!file) {
|
|
||||||
return NextResponse.json({ error: 'Súbor nebol poskytnutý.' }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const admin = getSupabaseAdmin();
|
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 path = `uploads/${Date.now()}-${safeName}`;
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const { data, error } = await admin.storage.from(BUCKET).createSignedUploadUrl(path);
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
if (error || !data) {
|
||||||
|
console.error('Signed upload URL error:', error);
|
||||||
const { error: uploadError } = await admin.storage
|
return NextResponse.json({ error: error?.message || 'Nepodarilo sa pripraviť nahratie' }, { status: 500 });
|
||||||
.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: pubData } = admin.storage
|
const { data: pubData } = admin.storage.from(BUCKET).getPublicUrl(path);
|
||||||
.from('newsletter-assets')
|
|
||||||
.getPublicUrl(path);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({ success: true, path, token: data.token, url: pubData.publicUrl });
|
||||||
success: true,
|
|
||||||
url: pubData.publicUrl
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('API /upload error:', err);
|
console.error('API /upload error:', err);
|
||||||
return NextResponse.json({ error: err.message || 'Chyba servera pri nahrávaní súboru' }, { status: 500 });
|
return NextResponse.json({ error: err.message || 'Chyba servera pri nahrávaní súboru' }, { status: 500 });
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
<head>
|
<head>
|
||||||
{/* Písmo e-mailu (Poppins) – náhľad v editore aj na stránke s pripomienkami */}
|
{/* 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" />
|
<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>
|
</head>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+102
-48
@@ -5,13 +5,14 @@ import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from
|
|||||||
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign';
|
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign';
|
||||||
import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer';
|
import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer';
|
||||||
import { supabase } from '@/lib/supabase';
|
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 { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||||
import { TopBar } from '@/components/editor/TopBar';
|
import { TopBar } from '@/components/editor/TopBar';
|
||||||
import { ConfigPanel } from '@/components/editor/ConfigPanel';
|
import { ConfigPanel } from '@/components/editor/ConfigPanel';
|
||||||
import { ProductImportModal } from '@/components/editor/ProductImportModal';
|
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 { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal';
|
||||||
|
import { NameDialog } from '@/components/editor/NameDialog';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { prepareCardImages } from '@/lib/cardImages';
|
import { prepareCardImages } from '@/lib/cardImages';
|
||||||
import { useCampaignReview } from '@/components/review/useCampaignReview';
|
import { useCampaignReview } from '@/components/review/useCampaignReview';
|
||||||
@@ -42,6 +43,7 @@ export default function Home() {
|
|||||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||||
const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false);
|
const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false);
|
||||||
const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false);
|
const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false);
|
||||||
|
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
|
||||||
const [targetProductIdForBadge, setTargetProductIdForBadge] = useState<string | null>(null);
|
const [targetProductIdForBadge, setTargetProductIdForBadge] = useState<string | null>(null);
|
||||||
const [availableBadges, setAvailableBadges] = useState<BadgeTemplate[]>(DEFAULT_BADGE_TEMPLATES);
|
const [availableBadges, setAvailableBadges] = useState<BadgeTemplate[]>(DEFAULT_BADGE_TEMPLATES);
|
||||||
|
|
||||||
@@ -56,39 +58,14 @@ export default function Home() {
|
|||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (data && !error && data.length > 0) {
|
if (data && !error && data.length > 0) {
|
||||||
loaded = data.map((d: any) => {
|
loaded = (data as BadgeRow[]).map(badgeFromRow);
|
||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Error fetching badges in page:', e);
|
console.warn('Error fetching badges in page:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// šablóny sú len v databáze; starú kópiu z prehliadača zmažeme
|
||||||
const local = localStorage.getItem('coop_custom_badges');
|
clearLocalBadges();
|
||||||
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) {}
|
|
||||||
|
|
||||||
const all = [...loaded];
|
const all = [...loaded];
|
||||||
const existingIds = new Set(all.map((b) => b.id));
|
const existingIds = new Set(all.map((b) => b.id));
|
||||||
@@ -102,6 +79,34 @@ export default function Home() {
|
|||||||
fetchBadges();
|
fetchBadges();
|
||||||
}, [isBadgeEditorOpen]);
|
}, [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
|
// 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';
|
const isTopModule = (m: NewsletterModule) => m.type === 'header' || m.type === 'announcement';
|
||||||
|
|
||||||
@@ -349,7 +354,13 @@ export default function Home() {
|
|||||||
setCampaign((prev) => {
|
setCampaign((prev) => {
|
||||||
const firstBottomIdx = prev.modules.findIndex((m) => isBottomModule(m));
|
const firstBottomIdx = prev.modules.findIndex((m) => isBottomModule(m));
|
||||||
const newModules = [...prev.modules];
|
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);
|
newModules.splice(firstBottomIdx, 0, newMod);
|
||||||
} else {
|
} else {
|
||||||
newModules.push(newMod);
|
newModules.push(newMod);
|
||||||
@@ -359,19 +370,35 @@ export default function Home() {
|
|||||||
setSelectedModuleId(newMod.id);
|
setSelectedModuleId(newMod.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportProducts = (imported: ProductItem[]) => {
|
const handleImportProducts = (imported: ProductItem[], options?: { replace?: boolean }) => {
|
||||||
if (!selectedModuleId) return;
|
if (!selectedModuleId) return;
|
||||||
const target = campaign.modules.find((m) => m.id === selectedModuleId);
|
const target = campaign.modules.find((m) => m.id === selectedModuleId);
|
||||||
if (!target || target.type !== 'products_grid') {
|
if (!target || (target.type !== 'products_grid' && target.type !== 'loyalty_club')) {
|
||||||
alert('Pre import prosím najskôr označte produktovú sekciu.');
|
alert('Pre import prosím najskôr označte produktovú sekciu.');
|
||||||
return;
|
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 = {
|
const updated = {
|
||||||
...target,
|
...target,
|
||||||
data: {
|
data: {
|
||||||
...target.data,
|
...target.data,
|
||||||
products: [...(target.data.products || []), ...imported]
|
products: options?.replace ? withLayout : [...(target.data.products || []), ...withLayout]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
handleUpdateModule(updated);
|
handleUpdateModule(updated);
|
||||||
@@ -424,10 +451,9 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Duplikácia aktuálnej kampane v Supabase
|
// Duplikácia aktuálnej kampane v Supabase
|
||||||
const handleDuplicateCurrentCampaign = async () => {
|
const handleDuplicateCurrentCampaign = async (newTitle: string) => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const newTitle = `${campaign.title} (kópia)`;
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('templates')
|
.from('templates')
|
||||||
.insert([
|
.insert([
|
||||||
@@ -451,7 +477,7 @@ export default function Home() {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
modules: campaign.modules
|
modules: campaign.modules
|
||||||
});
|
});
|
||||||
alert(`Kampaň bola zduplikovaná ako "${newTitle}"!`);
|
setIsDuplicateOpen(false);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(`Chyba pri duplikovaní: ${err.message}`);
|
alert(`Chyba pri duplikovaní: ${err.message}`);
|
||||||
@@ -607,13 +633,13 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Poloha štítkov predajní pre všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
// Poloha štítkov predajní pre všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
||||||
const handleApplyStoresOffsetToAll = (x: number, y: number) => {
|
const handleApplyStoresOffsetToAll = (x: number, y: number, size: number) => {
|
||||||
if (!confirm(`Použiť polohu štítkov (${x} / ${y}) pre všetky produkty v newsletteri?`)) return;
|
if (!confirm(`Použiť polohu (${x} / ${y}) a veľkosť (${size} px) štítkov pre všetky produkty v newsletteri?`)) return;
|
||||||
setCampaign((prev) => ({
|
setCampaign((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
modules: prev.modules.map((m) =>
|
modules: prev.modules.map((m) =>
|
||||||
m.type === 'products_grid' || m.type === 'loyalty_club'
|
m.type === 'products_grid' || m.type === 'loyalty_club'
|
||||||
? { ...m, data: { ...m.data, products: (m.data.products || []).map((p: ProductItem) => ({ ...p, storesOffsetX: x, storesOffsetY: y })) } }
|
? { ...m, data: { ...m.data, products: (m.data.products || []).map((p: ProductItem) => ({ ...p, storesOffsetX: x, storesOffsetY: y, storesSize: size })) } }
|
||||||
: m
|
: m
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
@@ -674,7 +700,7 @@ export default function Home() {
|
|||||||
onSave={handleManualSave}
|
onSave={handleManualSave}
|
||||||
onUpdateTitle={(title) => setCampaign((prev) => ({ ...prev, title }))}
|
onUpdateTitle={(title) => setCampaign((prev) => ({ ...prev, title }))}
|
||||||
onOpenSavedCampaigns={() => setIsSavedCampaignsOpen(true)}
|
onOpenSavedCampaigns={() => setIsSavedCampaignsOpen(true)}
|
||||||
onDuplicateCampaign={handleDuplicateCurrentCampaign}
|
onDuplicateCampaign={() => setIsDuplicateOpen(true)}
|
||||||
onExportZip={handleExportZip}
|
onExportZip={handleExportZip}
|
||||||
onExportBrevo={handleExportBrevo}
|
onExportBrevo={handleExportBrevo}
|
||||||
isExportingBrevo={isExportingBrevo}
|
isExportingBrevo={isExportingBrevo}
|
||||||
@@ -928,15 +954,29 @@ export default function Home() {
|
|||||||
selectedId={selectedCommentId}
|
selectedId={selectedCommentId}
|
||||||
onSelect={setSelectedCommentId}
|
onSelect={setSelectedCommentId}
|
||||||
renderActions={(c: ReviewComment) => (
|
renderActions={(c: ReviewComment) => (
|
||||||
<button
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => reviews.toggleDone(c)}
|
||||||
|
className={`text-[11px] font-bold px-2.5 py-1 rounded-lg ${
|
||||||
|
c.done ? 'bg-gray-100 text-gray-700' : 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.done ? 'Vrátiť ako neurobené' : '✓ Označiť ako hotové'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => reviews.toggleDone(c)}
|
onClick={() => {
|
||||||
className={`text-[11px] font-bold px-2.5 py-1 rounded-lg ${
|
if (confirm('Zmazať túto pripomienku? Zmizne aj pre ostatných s odkazom.')) {
|
||||||
c.done ? 'bg-gray-100 text-gray-700' : 'bg-emerald-600 text-white hover:bg-emerald-700'
|
reviews.remove(c);
|
||||||
}`}
|
setSelectedCommentId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-[11px] font-bold px-2.5 py-1 rounded-lg text-red-700 bg-red-50 hover:bg-red-100"
|
||||||
>
|
>
|
||||||
{c.done ? 'Vrátiť ako neurobené' : '✓ Označiť ako hotové'}
|
Zmazať
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -944,7 +984,7 @@ export default function Home() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Pravý konfiguračný panel */}
|
{/* 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
|
<ConfigPanel
|
||||||
module={selectedModule}
|
module={selectedModule}
|
||||||
selectedProductId={selectedProductId}
|
selectedProductId={selectedProductId}
|
||||||
@@ -992,6 +1032,8 @@ export default function Home() {
|
|||||||
isOpen={isImportOpen}
|
isOpen={isImportOpen}
|
||||||
onClose={() => setIsImportOpen(false)}
|
onClose={() => setIsImportOpen(false)}
|
||||||
onImportProducts={handleImportProducts}
|
onImportProducts={handleImportProducts}
|
||||||
|
availableBadges={availableBadges}
|
||||||
|
variant={campaign.modules.find((m) => m.id === selectedModuleId)?.type === 'loyalty_club' ? 'loyalty' : 'standard'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modál Editora cenoviek (SVG -> PNG) */}
|
{/* Modál Editora cenoviek (SVG -> PNG) */}
|
||||||
@@ -1006,11 +1048,23 @@ export default function Home() {
|
|||||||
currentCardPriceMain={targetProduct?.cardPriceMain || undefined}
|
currentCardPriceMain={targetProduct?.cardPriceMain || undefined}
|
||||||
currentCardPriceSub={targetProduct?.cardPriceSub || undefined}
|
currentCardPriceSub={targetProduct?.cardPriceSub || undefined}
|
||||||
currentCardDiscount={targetProduct?.cardDiscount || 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) */}
|
{/* 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
|
<SavedCampaignsModal
|
||||||
isOpen={isSavedCampaignsOpen}
|
isOpen={isSavedCampaignsOpen}
|
||||||
|
onRenameCurrent={(title) => setCampaign((prev) => ({ ...prev, title }))}
|
||||||
onClose={() => setIsSavedCampaignsOpen(false)}
|
onClose={() => setIsSavedCampaignsOpen(false)}
|
||||||
currentCampaign={campaign}
|
currentCampaign={campaign}
|
||||||
onLoadCampaign={(selected: NewsletterCampaign) => {
|
onLoadCampaign={(selected: NewsletterCampaign) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Náhľad newslettra na pripomienky (zdieľaný odkaz). Nič sa tu nedá upravovať – len komentovať,
|
// 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.
|
// 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 React, { use, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { MessageCircle, Type, Pencil, MousePointer2, CheckCircle2, Loader2 } from 'lucide-react';
|
import { MessageCircle, Type, Pencil, MousePointer2, CheckCircle2, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { renderSingleModuleHtml } from '@/lib/emailRenderer';
|
import { renderSingleModuleHtml } from '@/lib/emailRenderer';
|
||||||
import {
|
import {
|
||||||
Review,
|
Review,
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
REVIEW_COLORS,
|
REVIEW_COLORS,
|
||||||
getReviewByToken,
|
getReviewByToken,
|
||||||
listReviewComments,
|
listReviewComments,
|
||||||
addReviewComment
|
addReviewComment,
|
||||||
|
deleteReviewComment
|
||||||
} from '@/lib/reviews';
|
} from '@/lib/reviews';
|
||||||
import { AnnotationLayer, pointToModuleCoords, useModuleLayout } from '@/components/review/AnnotationLayer';
|
import { AnnotationLayer, pointToModuleCoords, useModuleLayout } from '@/components/review/AnnotationLayer';
|
||||||
import { NewsletterCampaign } from '@/types/newsletter';
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
@@ -124,6 +125,18 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Zmazanie pripomienky (zmizne pre všetkých s odkazom aj v editore)
|
||||||
|
const removeComment = async (c: ReviewComment) => {
|
||||||
|
if (!confirm('Zmazať túto pripomienku? Zmizne aj pre ostatných s odkazom.')) return;
|
||||||
|
try {
|
||||||
|
await deleteReviewComment(c.id);
|
||||||
|
setComments((prev) => prev.filter((x) => x.id !== c.id));
|
||||||
|
if (selectedId === c.id) setSelectedId(null);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Pripomienku sa nepodarilo zmazať.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Klik do newslettra: komentár / text → okienko na napísanie
|
// Klik do newslettra: komentár / text → okienko na napísanie
|
||||||
const onCanvasClick = (e: React.MouseEvent) => {
|
const onCanvasClick = (e: React.MouseEvent) => {
|
||||||
if (tool !== 'comment' && tool !== 'text') return;
|
if (tool !== 'comment' && tool !== 'text') return;
|
||||||
@@ -246,7 +259,21 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<AnnotationLayer layout={layout} comments={comments} selectedId={selectedId} onSelect={setSelectedId} />
|
<AnnotationLayer
|
||||||
|
layout={layout}
|
||||||
|
comments={comments}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
renderActions={(c) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeComment(c)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-bold px-2.5 py-1 rounded-lg text-red-700 bg-red-50 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" /> Zmazať
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Plocha na pridávanie pripomienok */}
|
{/* Plocha na pridávanie pripomienok */}
|
||||||
{tool !== 'view' && (
|
{tool !== 'view' && (
|
||||||
@@ -338,11 +365,12 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
|
|||||||
{comments.map((c) => {
|
{comments.map((c) => {
|
||||||
const number = comments.filter((x) => x.kind === 'comment').indexOf(c) + 1;
|
const number = comments.filter((x) => x.kind === 'comment').indexOf(c) + 1;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => setSelectedId(c.id === selectedId ? null : c.id)}
|
onClick={() => setSelectedId(c.id === selectedId ? null : c.id)}
|
||||||
className={`w-full text-left rounded-lg border p-2 transition-colors ${
|
className={`group w-full text-left rounded-lg border p-2 transition-colors cursor-pointer ${
|
||||||
c.id === selectedId ? 'border-gray-900 bg-gray-50' : 'border-gray-200 hover:border-gray-300'
|
c.id === selectedId ? 'border-gray-900 bg-gray-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
} ${c.done ? 'opacity-60' : ''}`}
|
} ${c.done ? 'opacity-60' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -353,11 +381,22 @@ export default function ReviewPage({ params }: { params: Promise<{ token: string
|
|||||||
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
|
<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" />}
|
{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>
|
<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>
|
</div>
|
||||||
<p className="mt-1 text-xs text-gray-700 whitespace-pre-wrap line-clamp-3">
|
<p className="mt-1 text-xs text-gray-700 whitespace-pre-wrap line-clamp-3">
|
||||||
{c.text || (c.kind === 'drawing' ? 'Kresba' : '')}
|
{c.text || (c.kind === 'drawing' ? 'Kresba' : '')}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -15,7 +15,7 @@ interface ConfigPanelProps {
|
|||||||
module: NewsletterModule | null;
|
module: NewsletterModule | null;
|
||||||
onUpdateModule: (updated: NewsletterModule) => void;
|
onUpdateModule: (updated: NewsletterModule) => void;
|
||||||
onApplyBadgeToAll?: (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => void;
|
onApplyBadgeToAll?: (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => void;
|
||||||
onApplyStoresOffsetToAll?: (x: number, y: number) => void;
|
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void;
|
||||||
// Zmena nastavení spoločných pre všetky produktové sekcie a vernostné zľavy (platnosť akcie...)
|
// Zmena nastavení spoločných pre všetky produktové sekcie a vernostné zľavy (platnosť akcie...)
|
||||||
onUpdateAllProductSections?: (patch: Record<string, unknown>) => void;
|
onUpdateAllProductSections?: (patch: Record<string, unknown>) => void;
|
||||||
campaignValidity?: string; // globálna platnosť akcie (pre Lištu platnosti)
|
campaignValidity?: string; // globálna platnosť akcie (pre Lištu platnosti)
|
||||||
@@ -429,6 +429,33 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
title="Enter = nový riadok (alebo <br>)"
|
title="Enter = nový riadok (alebo <br>)"
|
||||||
className="w-full text-sm border border-gray-300 rounded-lg p-2 resize-y"
|
className="w-full text-sm border border-gray-300 rounded-lg p-2 resize-y"
|
||||||
/>
|
/>
|
||||||
|
{/* Farba nadpisu */}
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-semibold text-gray-500">Farba nadpisu</span>
|
||||||
|
{[
|
||||||
|
['#1a1a1a', 'Čierna (predvolená)'],
|
||||||
|
['#003399', 'COOP modrá'],
|
||||||
|
['#e2001a', 'COOP červená']
|
||||||
|
].map(([c, name]) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
title={name}
|
||||||
|
onClick={() => updateData({ titleColor: c === '#1a1a1a' ? undefined : c })}
|
||||||
|
className={`w-5 h-5 rounded-full border ${
|
||||||
|
(module.data.titleColor || '#1a1a1a').toLowerCase() === c ? 'ring-2 ring-red-500 border-white' : 'border-black/15'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={module.data.titleColor || '#1a1a1a'}
|
||||||
|
onChange={(e) => updateData({ titleColor: e.target.value })}
|
||||||
|
className="w-6 h-6 rounded cursor-pointer border border-gray-300 p-0"
|
||||||
|
title="Vlastná farba"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Popis</label>
|
<label className="block text-xs font-semibold text-gray-700 mb-1">Popis</label>
|
||||||
@@ -553,7 +580,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rýchle predvoľby */}
|
{/* Rýchle predvoľby */}
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{PRODUCT_BG_PRESETS.map((preset) => {
|
{PRODUCT_BG_PRESETS.map((preset) => {
|
||||||
const currentBg = (module.data.cardBgColor || '#feeaea').toLowerCase();
|
const currentBg = (module.data.cardBgColor || '#feeaea').toLowerCase();
|
||||||
const isSelected = currentBg === preset.color.toLowerCase();
|
const isSelected = currentBg === preset.color.toLowerCase();
|
||||||
@@ -765,7 +792,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rýchle predvoľby */}
|
{/* Rýchle predvoľby */}
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{PRODUCT_BG_PRESETS.map((preset) => {
|
{PRODUCT_BG_PRESETS.map((preset) => {
|
||||||
const currentBg = (module.data.cardBgColor || '#e6e1d9').toLowerCase();
|
const currentBg = (module.data.cardBgColor || '#e6e1d9').toLowerCase();
|
||||||
const isSelected = currentBg === preset.color.toLowerCase();
|
const isSelected = currentBg === preset.color.toLowerCase();
|
||||||
@@ -843,11 +870,49 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vernostné produkty */}
|
{/* Vernostné produkty (ľubovoľný počet, 2 na riadok) */}
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<span className="text-xs font-bold text-gray-700 block">
|
<div className="flex items-center justify-between">
|
||||||
Vernostné produkty ({module.data.products?.length || 0})
|
<span className="text-xs font-bold text-gray-700">
|
||||||
</span>
|
Vernostné produkty ({module.data.products?.length || 0})
|
||||||
|
</span>
|
||||||
|
{onOpenImport && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenImport}
|
||||||
|
className="ml-auto mr-1.5 text-xs font-bold text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-2.5 py-1 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Import ZIP
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newP: CardProduct = {
|
||||||
|
id: `lp-${Date.now()}`,
|
||||||
|
title: 'Nový vernostný produkt',
|
||||||
|
imageUrl: `${SUPABASE_ASSETS_BASE}vernostna_karta-produkt_1.png`,
|
||||||
|
priceMain: '',
|
||||||
|
priceSub: '',
|
||||||
|
unitPrice: '',
|
||||||
|
discountText: '',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
noCardDiscount: '20%',
|
||||||
|
noCardPriceMain: '0',
|
||||||
|
noCardPriceSub: '99',
|
||||||
|
noCardUnitPrice: '',
|
||||||
|
cardDiscount: '30%',
|
||||||
|
cardPriceMain: '0',
|
||||||
|
cardPriceSub: '79',
|
||||||
|
cardUnitPrice: '',
|
||||||
|
stores: { potraviny: true, supermarket: true, tempo: true }
|
||||||
|
};
|
||||||
|
updateData({ products: [...(module.data.products || []), newP] });
|
||||||
|
onSelectProduct?.(newP.id);
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold text-white bg-gray-900 hover:bg-black px-2.5 py-1 rounded-lg flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Pridať produkt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{(module.data.products || []).map((p: CardProduct, pIdx: number) => (
|
{(module.data.products || []).map((p: CardProduct, pIdx: number) => (
|
||||||
<ProductEditor
|
<ProductEditor
|
||||||
key={p.id || pIdx}
|
key={p.id || pIdx}
|
||||||
@@ -858,6 +923,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
onToggle={() => onSelectProduct?.(selectedProductId === p.id ? null : p.id)}
|
onToggle={() => onSelectProduct?.(selectedProductId === p.id ? null : p.id)}
|
||||||
availableBadges={availableBadges || []}
|
availableBadges={availableBadges || []}
|
||||||
onChange={(patch) => updateProduct(pIdx, patch)}
|
onChange={(patch) => updateProduct(pIdx, patch)}
|
||||||
|
onDelete={() => updateData({ products: (module.data.products || []).filter((_: CardProduct, i: number) => i !== pIdx) })}
|
||||||
onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined}
|
onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined}
|
||||||
onApplyBadgeToSection={() => applyBadgeToSection(p)}
|
onApplyBadgeToSection={() => applyBadgeToSection(p)}
|
||||||
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'loyalty') : undefined}
|
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'loyalty') : undefined}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Upload, Loader2, Image as ImageIcon } from 'lucide-react';
|
import { Upload, Loader2, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import { shrinkImage, shrinkRemoteImage, uploadFile } from '@/lib/upload';
|
||||||
|
|
||||||
interface ImageUploadInputProps {
|
interface ImageUploadInputProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -26,28 +27,13 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadImage = async (file: File) => {
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const small = await shrinkImage(file);
|
||||||
formData.append('file', file);
|
onChange(await uploadFile(small, small.name));
|
||||||
|
} catch (err) {
|
||||||
const res = await fetch('/api/upload', {
|
alert(`Chyba pri nahrávaní obrázka: ${(err as Error).message}`);
|
||||||
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}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
@@ -56,6 +42,55 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) uploadImage(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vložená URL: príliš veľký obrázok sa zmenší a uloží k nám (posledná spracovaná adresa, aby sa nerobilo dvakrát)
|
||||||
|
const lastCheckedUrl = useRef('');
|
||||||
|
const [urlNote, setUrlNote] = useState<{ tone: 'ok' | 'warn'; text: string } | null>(null);
|
||||||
|
const checkUrl = async (url: string) => {
|
||||||
|
url = url.trim();
|
||||||
|
if (!url || url === lastCheckedUrl.current) return;
|
||||||
|
lastCheckedUrl.current = url;
|
||||||
|
setIsUploading(true);
|
||||||
|
setUrlNote(null);
|
||||||
|
try {
|
||||||
|
const result = await shrinkRemoteImage(url);
|
||||||
|
if (result.status === 'shrunk') {
|
||||||
|
lastCheckedUrl.current = result.url;
|
||||||
|
onChange(result.url);
|
||||||
|
setUrlNote({ tone: 'ok', text: 'Obrázok bol väčší ako 1000 px – zmenšili sme ho a uložili k nám.' });
|
||||||
|
} else if (result.status === 'small') {
|
||||||
|
setUrlNote({ tone: 'ok', text: 'Obrázok je v poriadku (najviac 1000 px), adresa ostáva.' });
|
||||||
|
} else if (result.status === 'login') {
|
||||||
|
setUrlNote({
|
||||||
|
tone: 'warn',
|
||||||
|
text: 'Obrázok je za prihlásením – v e-maile sa nezobrazí. Otvorte ho v prehliadači, pravým klikom „Kopírovať obrázok“ a sem vložte Ctrl+V.'
|
||||||
|
});
|
||||||
|
} else if (result.status === 'failed') {
|
||||||
|
setUrlNote({ tone: 'warn', text: `Obrázok sa nepodarilo stiahnuť (${result.error}). Skontrolujte adresu, prípadne obrázok skopírujte a vložte Ctrl+V.` });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setUrlNote({ tone: 'warn', text: `Obrázok sa nepodarilo spracovať: ${(err as Error).message}` });
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
// obrázok zo schránky (Ctrl+V) → nahrá sa ako súbor
|
||||||
|
const file = [...e.clipboardData.files].find((f) => f.type.startsWith('image/'));
|
||||||
|
if (file) {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadImage(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = e.clipboardData.getData('text');
|
||||||
|
if (/^https?:\/\//i.test(text.trim())) setTimeout(() => checkUrl(text), 0);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{label && <label className="block text-[11px] font-semibold text-gray-700">{label}</label>}
|
{label && <label className="block text-[11px] font-semibold text-gray-700">{label}</label>}
|
||||||
@@ -81,9 +116,17 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ''}
|
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}
|
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 */}
|
{/* Tlačidlo nahrávania */}
|
||||||
@@ -115,6 +158,12 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{onAltChange && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,27 +2,21 @@
|
|||||||
|
|
||||||
// Editor jedného produktu (bežné aj vernostné produkty) – zbalený po produktoch, len to, čo treba:
|
// 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.
|
// texty, fotka (zoom/posun), cenovka (šablóna, veľkosť, ceny), dostupnosť a ďalšie možnosti.
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { BadgeTemplate, CardProduct } from '@/types/newsletter';
|
import { BADGE_IMPORT_TYPES, BadgeTemplate, CardProduct, ProductLabel, ProductPhoto } from '@/types/newsletter';
|
||||||
import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Trash2, Pencil, Globe, Layers } from 'lucide-react';
|
||||||
import { ImageUploadInput } from './ImageUploadInput';
|
import { ImageUploadInput } from './ImageUploadInput';
|
||||||
import { pickAlt } from '@/lib/altText';
|
import { pickAlt } from '@/lib/altText';
|
||||||
import { DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, badgeWidthFor, productCardAlt } from '@/lib/productCard';
|
import { DEFAULT_IMAGE_SCALE, DEFAULT_STORES_OFFSET_X, DEFAULT_STORES_OFFSET_Y, DEFAULT_STORES_SIZE, MAIN_PHOTO, PRODUCT_BG_PRESETS, badgeWidthFor, photoStack, productCardAlt, usesAppPrice } from '@/lib/productCard';
|
||||||
|
|
||||||
export const PRODUCT_BG_PRESETS = [
|
export { PRODUCT_BG_PRESETS };
|
||||||
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
|
||||||
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
|
|
||||||
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
|
|
||||||
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
|
|
||||||
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
|
|
||||||
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
|
|
||||||
];
|
|
||||||
|
|
||||||
type Variant = 'standard' | 'loyalty';
|
type Variant = 'standard' | 'loyalty';
|
||||||
// Textové polia cien produktu (bežné aj vernostné)
|
// Textové polia cien produktu (bežné aj vernostné)
|
||||||
type TextKey =
|
type TextKey =
|
||||||
| 'priceMain' | 'priceSub' | 'discountText'
|
| 'priceMain' | 'priceSub' | 'discountText'
|
||||||
| 'cardPriceMain' | 'cardPriceSub' | 'cardDiscount' | 'cardUnitPrice'
|
| 'cardPriceMain' | 'cardPriceSub' | 'cardDiscount' | 'cardUnitPrice'
|
||||||
|
| 'appPriceMain' | 'appPriceSub'
|
||||||
| 'noCardPriceMain' | 'noCardPriceSub' | 'noCardDiscount' | 'noCardUnitPrice';
|
| 'noCardPriceMain' | 'noCardPriceSub' | 'noCardDiscount' | 'noCardUnitPrice';
|
||||||
type Patch = Record<string, unknown>;
|
type Patch = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -38,7 +32,7 @@ interface ProductEditorProps {
|
|||||||
onOpenBadgeEditor?: () => void;
|
onOpenBadgeEditor?: () => void;
|
||||||
onApplyBadgeToSection?: () => void;
|
onApplyBadgeToSection?: () => void;
|
||||||
onApplyBadgeToAll?: () => void;
|
onApplyBadgeToAll?: () => void;
|
||||||
onApplyStoresOffsetToAll?: (x: number, y: number) => void; // poloha štítkov pre všetky produkty
|
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void; // poloha a veľkosť štítkov pre všetky produkty
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
|
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
|
||||||
@@ -105,6 +99,213 @@ const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
|||||||
</button>
|
</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
|
// Š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');
|
const usesCardPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('_s_kartou');
|
||||||
|
|
||||||
@@ -124,17 +325,24 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// Po otvorení (aj klikom na produkt v náhľade) posunieme panel na tento produkt
|
// Po otvorení (aj klikom na produkt v náhľade) posunieme panel na tento produkt
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) rootRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
if (open) rootRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
}, [open]);
|
}, [open]);
|
||||||
const isLoyalty = variant === 'loyalty';
|
const isLoyalty = variant === 'loyalty';
|
||||||
const set = (key: string) => (v: unknown) => onChange({ [key]: v });
|
const set = (key: string) => (v: unknown) => onChange({ [key]: v });
|
||||||
|
|
||||||
const scale = p.imageScale ?? 1;
|
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
|
||||||
const offsetX = p.imageOffsetX || 0;
|
const offsetX = p.imageOffsetX || 0;
|
||||||
const offsetY = p.imageOffsetY || 0;
|
const offsetY = p.imageOffsetY || 0;
|
||||||
const storesX = p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X;
|
const storesX = p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X;
|
||||||
const storesY = p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y;
|
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 hasStores = !!(p.stores?.potraviny || p.stores?.supermarket || p.stores?.tempo);
|
||||||
const badgeWidth = badgeWidthFor(p, variant);
|
const badgeWidth = badgeWidthFor(p, variant);
|
||||||
|
|
||||||
@@ -151,7 +359,7 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
|
|
||||||
const text = (key: TextKey) => p[key] as string | undefined;
|
const text = (key: TextKey) => p[key] as string | undefined;
|
||||||
|
|
||||||
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey: TextKey, unitKey?: TextKey) => (
|
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey?: TextKey, unitKey?: TextKey) => (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] font-semibold text-gray-600">{label}</span>
|
<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`}>
|
<div className={`grid ${unitKey ? 'grid-cols-[1fr_1fr_1fr]' : 'grid-cols-3'} gap-1.5`}>
|
||||||
@@ -161,9 +369,11 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
<Field label="Centy">
|
<Field label="Centy">
|
||||||
<TextInput value={text(subKey)} onChange={set(subKey)} bold />
|
<TextInput value={text(subKey)} onChange={set(subKey)} bold />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Zľava">
|
{discountKey && (
|
||||||
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
|
<Field label="Zľava">
|
||||||
</Field>
|
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{unitKey && (
|
{unitKey && (
|
||||||
<Field label="Jednotková cena">
|
<Field label="Jednotková cena">
|
||||||
@@ -255,20 +465,61 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
<Slider label="Veľkosť" value={scale} display={`${Math.round(scale * 100)} %`} min={0.4} max={2.5} step={0.05} onChange={set('imageScale')} />
|
<Slider label="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="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')} />
|
<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>
|
</Block>
|
||||||
|
|
||||||
{/* CENOVKA */}
|
{/* CENOVKA */}
|
||||||
<Block title="Cenovka">
|
<Block title="Cenovka">
|
||||||
|
<BadgeFilterBar filter={badgeFilter} onChange={setBadgeFilter} shown={filteredBadges.length} total={availableBadges.length} />
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<select
|
<select
|
||||||
value={p.customBadgePngUrl ? 'png' : p.badgeCustomTemplate?.id || 'default'}
|
value={p.customBadgePngUrl ? 'png' : p.badgeCustomTemplate?.id || (isLoyalty ? 'default' : availableBadges.find((b) => b.isDefault)?.id || '')}
|
||||||
onChange={(e) => selectBadge(e.target.value)}
|
onChange={(e) => selectBadge(e.target.value)}
|
||||||
className={`${inputCls} font-medium`}
|
className={`${inputCls} font-medium`}
|
||||||
>
|
>
|
||||||
<option value="default">{isLoyalty ? 'Predvolená (bez karty / s kartou)' : 'Predvolená COOP cenovka'}</option>
|
{/* bežné produkty: predvolená je šablóna označená ako predvolená (nastaví sa automaticky) */}
|
||||||
{availableBadges.map((b) => (
|
{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}>
|
<option key={b.id} value={b.id}>
|
||||||
{b.name}
|
{b.name}
|
||||||
|
{b.isDefault ? ' (predvolená)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{p.customBadgePngUrl && <option value="png">Vlastná PNG cenovka</option>}
|
{p.customBadgePngUrl && <option value="png">Vlastná PNG cenovka</option>}
|
||||||
@@ -296,6 +547,7 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
<>
|
<>
|
||||||
{priceRow('Cena', 'priceMain', 'priceSub', 'discountText')}
|
{priceRow('Cena', 'priceMain', 'priceSub', 'discountText')}
|
||||||
{usesCardPrice(p.badgeCustomTemplate) && priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount')}
|
{usesCardPrice(p.badgeCustomTemplate) && priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount')}
|
||||||
|
{usesAppPrice(p.badgeCustomTemplate) && priceRow('Pre appkáčov', 'appPriceMain', 'appPriceSub')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -326,8 +578,8 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
{/* DOSTUPNOSŤ */}
|
{/* DOSTUPNOSŤ */}
|
||||||
<Block
|
<Block
|
||||||
title="Dostupnosť"
|
title="Dostupnosť"
|
||||||
action={hasStores && (storesX !== DEFAULT_STORES_OFFSET_X || storesY !== DEFAULT_STORES_OFFSET_Y) && (
|
action={hasStores && (storesX !== DEFAULT_STORES_OFFSET_X || storesY !== DEFAULT_STORES_OFFSET_Y || storesSize !== DEFAULT_STORES_SIZE) && (
|
||||||
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined })} />
|
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined, storesSize: undefined })} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
@@ -357,19 +609,53 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
<>
|
<>
|
||||||
<Slider label="Štítky ↔" value={storesX} display={`${storesX}`} min={-80} max={80} step={1} onChange={set('storesOffsetX')} />
|
<Slider label="Štítky ↔" value={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="Š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 && (
|
{onApplyStoresOffsetToAll && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onApplyStoresOffsetToAll(storesX, storesY)}
|
onClick={() => onApplyStoresOffsetToAll(storesX, storesY, storesSize)}
|
||||||
className="flex items-center gap-1 text-[10px] font-bold text-red-700 bg-red-50 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
|
className="flex items-center gap-1 text-[10px] font-bold text-red-700 bg-red-50 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
|
||||||
>
|
>
|
||||||
<Globe className="w-3 h-3" /> Polohu štítkov použiť na všetky produkty
|
<Globe className="w-3 h-3" /> Polohu a veľkosť štítkov použiť na všetky produkty
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Block>
|
</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 */}
|
{/* ĎALŠIE MOŽNOSTI */}
|
||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="text-[10px] font-bold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-800">
|
<summary className="text-[10px] font-bold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-800">
|
||||||
|
|||||||
@@ -3,21 +3,30 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Papa from 'papaparse';
|
import Papa from 'papaparse';
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
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 { Upload, Check, AlertCircle, FileText, X } from 'lucide-react';
|
||||||
import { supabase } from '@/lib/supabase';
|
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 {
|
interface ImportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImportProducts: (products: ProductItem[]) => void;
|
onImportProducts: (products: ProductItem[], options?: { replace?: boolean }) => void;
|
||||||
|
availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť
|
||||||
|
variant?: 'standard' | 'loyalty'; // vernostné zľavy: len ZIP s podkladmi, ceny s kartou / bez karty
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductImportModal: React.FC<ImportModalProps> = ({
|
export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onImportProducts
|
onImportProducts,
|
||||||
|
availableBadges = [],
|
||||||
|
variant = 'standard'
|
||||||
}) => {
|
}) => {
|
||||||
|
const isLoyalty = variant === 'loyalty';
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
|
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
|
||||||
const [parsedRows, setParsedRows] = useState<any[]>([]);
|
const [parsedRows, setParsedRows] = useState<any[]>([]);
|
||||||
@@ -32,6 +41,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
discountText: string;
|
discountText: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
validity: string;
|
validity: string;
|
||||||
|
badgeCode: string;
|
||||||
}>({
|
}>({
|
||||||
title: '',
|
title: '',
|
||||||
priceMain: '',
|
priceMain: '',
|
||||||
@@ -39,12 +49,18 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
unitPrice: '',
|
unitPrice: '',
|
||||||
discountText: '',
|
discountText: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
validity: ''
|
validity: '',
|
||||||
|
badgeCode: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const [presetName, setPresetName] = useState('');
|
const [presetName, setPresetName] = useState('');
|
||||||
const [savedPresets, setSavedPresets] = useState<any[]>([]);
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -54,6 +70,19 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
|
|
||||||
const ext = uploaded.name.split('.').pop()?.toLowerCase();
|
const ext = uploaded.name.split('.').pop()?.toLowerCase();
|
||||||
const reader = new FileReader();
|
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') {
|
if (ext === 'csv') {
|
||||||
setFileType('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('zlava') || lower.includes('discount')) newMapping.discountText = h;
|
||||||
if (lower.includes('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = 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('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);
|
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 = () => {
|
const applyImport = () => {
|
||||||
|
if (zip) {
|
||||||
|
applyZipImport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!mapping.title) {
|
if (!mapping.title) {
|
||||||
alert('Prosím vyberte stĺpec pre Názov produktu.');
|
alert('Prosím vyberte stĺpec pre Názov produktu.');
|
||||||
return;
|
return;
|
||||||
@@ -148,7 +212,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
if (pSub.length === 1) pSub += '0';
|
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 {
|
return {
|
||||||
|
...(badge ? { badgeCustomTemplate: badge, badgeId: badge.id, badgeWidth: badge.width ?? 110 } : {}),
|
||||||
id: `imported-${Date.now()}-${idx}`,
|
id: `imported-${Date.now()}-${idx}`,
|
||||||
title: String(row[mapping.title] || 'Nový produkt'),
|
title: String(row[mapping.title] || 'Nový produkt'),
|
||||||
imageUrl: row[mapping.imageUrl] || 'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/produkt_1.png',
|
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);
|
onImportProducts(importedProducts);
|
||||||
onClose();
|
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">
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,.json,.xml"
|
accept={isLoyalty ? '.zip' : '.csv,.json,.xml,.zip'}
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="file-upload-input"
|
id="file-upload-input"
|
||||||
@@ -195,12 +277,99 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
<label htmlFor="file-upload-input" className="cursor-pointer">
|
<label htmlFor="file-upload-input" className="cursor-pointer">
|
||||||
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
|
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
|
||||||
<p className="font-semibold text-gray-700">
|
<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>
|
||||||
<p className="text-xs text-gray-400 mt-1">Podporované formáty: .csv, .json, .xml</p>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 */}
|
{/* Mapping Table */}
|
||||||
{columns.length > 0 && (
|
{columns.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -268,6 +437,25 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
|
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
|
||||||
<select
|
<select
|
||||||
@@ -290,7 +478,14 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
<div className="p-3 text-xs space-y-2 bg-white">
|
<div className="p-3 text-xs space-y-2 bg-white">
|
||||||
{parsedRows.slice(0, 2).map((row, idx) => (
|
{parsedRows.slice(0, 2).map((row, idx) => (
|
||||||
<div key={idx} className="flex items-center justify-between border-b border-gray-100 pb-2">
|
<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">
|
<span className="text-red-600 font-bold">
|
||||||
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
|
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
|
||||||
</span>
|
</span>
|
||||||
@@ -313,11 +508,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={applyImport}
|
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"
|
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" />
|
<Check className="w-4 h-4" />
|
||||||
Importovať do kampane
|
{zipProgress || (zip ? `Importovať ${zip.products.length} produktov` : 'Importovať do kampane')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
import { NewsletterCampaign } from '@/types/newsletter';
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
import { DEFAULT_CAMPAIGN } from '@/lib/defaultCampaign';
|
import { DEFAULT_CAMPAIGN } from '@/lib/defaultCampaign';
|
||||||
|
import { NameDialog } from './NameDialog';
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -15,7 +16,8 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Layers,
|
Layers,
|
||||||
Search,
|
Search,
|
||||||
Sparkles
|
Sparkles,
|
||||||
|
Pencil
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SavedCampaignsModalProps {
|
interface SavedCampaignsModalProps {
|
||||||
@@ -24,6 +26,7 @@ interface SavedCampaignsModalProps {
|
|||||||
currentCampaign: NewsletterCampaign;
|
currentCampaign: NewsletterCampaign;
|
||||||
onLoadCampaign: (campaign: NewsletterCampaign) => void;
|
onLoadCampaign: (campaign: NewsletterCampaign) => void;
|
||||||
onSaveCurrentAsCopy: (newTitle: string) => Promise<void>;
|
onSaveCurrentAsCopy: (newTitle: string) => Promise<void>;
|
||||||
|
onRenameCurrent: (title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||||
@@ -31,13 +34,16 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
currentCampaign,
|
currentCampaign,
|
||||||
onLoadCampaign,
|
onLoadCampaign,
|
||||||
onSaveCurrentAsCopy
|
onSaveCurrentAsCopy,
|
||||||
|
onRenameCurrent
|
||||||
}) => {
|
}) => {
|
||||||
const [campaigns, setCampaigns] = useState<any[]>([]);
|
const [campaigns, setCampaigns] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState('');
|
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 () => {
|
const fetchCampaigns = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -66,9 +72,20 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
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 {
|
try {
|
||||||
const clonedTitle = `Kópia - ${camp.title}`;
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('templates')
|
.from('templates')
|
||||||
.insert([
|
.insert([
|
||||||
@@ -90,6 +107,7 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
modules: data[0].data?.modules || []
|
modules: data[0].data?.modules || []
|
||||||
});
|
});
|
||||||
|
setNameAction(null);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -326,7 +344,7 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
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"
|
title="Vytvoriť kópiu"
|
||||||
>
|
>
|
||||||
@@ -334,6 +352,14 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
|||||||
<span>Duplikovať</span>
|
<span>Duplikovať</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => handleDelete(camp.id, camp.title)}
|
onClick={() => handleDelete(camp.id, camp.title)}
|
||||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -103,10 +103,6 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
<header className="h-16 bg-white border-b border-gray-200 px-4 md:px-6 flex items-center justify-between sticky top-0 z-30 shadow-xs">
|
<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 */}
|
{/* Ľavá časť: Logo, Moje newslettre, Titulok a stav */}
|
||||||
<div className="flex items-center gap-3">
|
<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 */}
|
{/* Tlačidlo Moje newslettre */}
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSavedCampaigns}
|
onClick={onOpenSavedCampaigns}
|
||||||
@@ -142,7 +138,7 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
<h1 className="text-xs md:text-sm font-bold text-gray-900 leading-tight max-w-[180px] md:max-w-xs truncate group-hover:text-blue-600">
|
<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'}
|
{campaign.title || 'COOP Jednota Newsletter'}
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+98
-16
@@ -1,8 +1,68 @@
|
|||||||
import { BadgeTemplate, BadgeLayer } from '@/types/newsletter';
|
import { BadgeTemplate, BadgeLayer, BadgeImportTags } from '@/types/newsletter';
|
||||||
|
|
||||||
// Vymazané všetky preddefinované šablóny — v systéme sú výhradne vlastné cenovky vytvorené používateľom
|
// Vymazané všetky preddefinované šablóny — v systéme sú výhradne vlastné cenovky vytvorené používateľom
|
||||||
export const DEFAULT_BADGE_TEMPLATES: BadgeTemplate[] = [];
|
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 {
|
export interface BadgeProductData {
|
||||||
priceMain: string;
|
priceMain: string;
|
||||||
priceSub: string;
|
priceSub: string;
|
||||||
@@ -11,10 +71,12 @@ export interface BadgeProductData {
|
|||||||
cardPriceMain?: string; // cena s kartou → {cena_s_kartou}
|
cardPriceMain?: string; // cena s kartou → {cena_s_kartou}
|
||||||
cardPriceSub?: string;
|
cardPriceSub?: string;
|
||||||
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
|
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
|
||||||
|
appPriceMain?: string; // cena pre appkáčov → {cena_app}
|
||||||
|
appPriceSub?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Premenné ceny s centami v hornom indexe: {cena} a {cena_s_kartou}
|
// Premenné ceny s centami v hornom indexe: {cena}, {cena_s_kartou} a {cena_app}
|
||||||
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou)\}/;
|
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou|cena_app)\}/;
|
||||||
|
|
||||||
// Nahradenie jednoduchých textových premenných (bez horného indexu)
|
// Nahradenie jednoduchých textových premenných (bez horného indexu)
|
||||||
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
|
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
|
||||||
@@ -31,9 +93,14 @@ export function badgePriceParts(token: string, product: BadgeProductData): [stri
|
|||||||
if (token === 'cena_s_kartou') {
|
if (token === 'cena_s_kartou') {
|
||||||
return [product.cardPriceMain || '', product.cardPriceSub || ''];
|
return [product.cardPriceMain || '', product.cardPriceSub || ''];
|
||||||
}
|
}
|
||||||
|
if (token === 'cena_app') {
|
||||||
|
return [product.appPriceMain || '', product.appPriceSub || ''];
|
||||||
|
}
|
||||||
return [product.priceMain || '0', product.priceSub || '00'];
|
return [product.priceMain || '0', product.priceSub || '00'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek
|
* Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek
|
||||||
* editovacích rámčekov, tooltippov či ovládacích prvkov.
|
* editovacích rámčekov, tooltippov či ovládacích prvkov.
|
||||||
@@ -90,13 +157,23 @@ export function renderCleanBadgeSvg(
|
|||||||
const displayHeight = Math.round((displayWidth / vbWidth) * vbHeight);
|
const displayHeight = Math.round((displayWidth / vbWidth) * vbHeight);
|
||||||
|
|
||||||
// Zostavenie textových vrstiev
|
// Zostavenie textových vrstiev
|
||||||
const textLayersSvg = (template.layersConfig || []).map((layer) => {
|
const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => {
|
||||||
const rawText = layer.text || '';
|
const rawText = layer.text || '';
|
||||||
const fontSize = layer.fontSize || 16;
|
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
||||||
const subFontSize = layer.indexFontSize || Math.max(10, Math.round(fontSize * 0.55));
|
// Cena od 10 € (dvojciferné eurá): menšie písmo, účiaria eur aj centov ostáva na mieste
|
||||||
const subDy = layer.indexOffsetY !== undefined ? `${layer.indexOffsetY}px` : '-0.45em';
|
// (0.43 ≈ vzdialenosť stredu písma od účiarie v em – vrstvy sa kreslia so strednou účiariou)
|
||||||
const subDx = layer.indexOffsetX !== undefined ? `${layer.indexOffsetX}px` : '1px';
|
const baseSize = layer.fontSize || 16;
|
||||||
const revertDy = layer.indexOffsetY !== undefined ? `${-layer.indexOffsetY}px` : '0.45em';
|
const baseSub = layer.indexFontSize || Math.max(10, Math.round(baseSize * 0.55));
|
||||||
|
const long = !!priceMatch && !!layer.longPriceScale && layer.longPriceScale < 1 && badgePriceParts(priceMatch[1], product)[0].length >= 2;
|
||||||
|
const k = long ? layer.longPriceScale! : 1;
|
||||||
|
const fontSize = round2(baseSize * k);
|
||||||
|
const subFontSize = round2(baseSub * k);
|
||||||
|
const layerY = long ? round2(layer.y + 0.43 * baseSize * (1 - k)) : layer.y;
|
||||||
|
const offY = layer.indexOffsetY !== undefined ? round2(layer.indexOffsetY - (long ? 0.43 * (1 - k) * (baseSize - baseSub) : 0)) : undefined;
|
||||||
|
const offX = layer.indexOffsetX !== undefined ? round2(layer.indexOffsetX * k) : undefined;
|
||||||
|
const subDy = offY !== undefined ? `${offY}px` : '-0.45em';
|
||||||
|
const subDx = offX !== undefined ? `${offX}px` : '1px';
|
||||||
|
const revertDy = offY !== undefined ? `${-offY}px` : '0.45em';
|
||||||
const fontWeight = layer.fontWeight || '700';
|
const fontWeight = layer.fontWeight || '700';
|
||||||
const fill = layer.fill || '#ffffff';
|
const fill = layer.fill || '#ffffff';
|
||||||
const fontFamily = layer.fontFamily
|
const fontFamily = layer.fontFamily
|
||||||
@@ -109,23 +186,23 @@ export function renderCleanBadgeSvg(
|
|||||||
|
|
||||||
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
||||||
let contentSvg = '';
|
let contentSvg = '';
|
||||||
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
|
||||||
if (priceMatch) {
|
if (priceMatch) {
|
||||||
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
||||||
const idx = priceMatch.index || 0;
|
const idx = priceMatch.index || 0;
|
||||||
const before = escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product));
|
const before = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product)));
|
||||||
const after = escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), 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>` : ''}`;
|
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}')) {
|
} else if (rawText.includes('{centy}')) {
|
||||||
const parts = rawText.split('{centy}');
|
const parts = rawText.split('{centy}');
|
||||||
const before = escapeXml(parts[0] || '');
|
const before = tightenCarons(escapeXml(parts[0] || ''));
|
||||||
const after = escapeXml(parts[1] || '');
|
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>` : ''}`;
|
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||||
} else {
|
} else {
|
||||||
contentSvg = escapeXml(replaceBadgeTextVars(rawText, product));
|
contentSvg = tightenCarons(escapeXml(replaceBadgeTextVars(rawText, product)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<text x="${layer.x}" y="${layer.y}" text-anchor="${anchor}" dominant-baseline="central" font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
// data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne
|
||||||
|
return `<text data-layer-index="${layerIndex}" x="${layer.x}" y="${layerY}" text-anchor="${anchor}" dominant-baseline="central"${layer.letterSpacing ? ` letter-spacing="${layer.letterSpacing}em"` : ''} font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
||||||
@@ -186,6 +263,11 @@ export async function svgToCleanPng(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mäkčeň pri ľ, ť, ď je v písme široký – písmeno za ním trochu pritiahneme (ako v návrhoch z Illustratora)
|
||||||
|
function tightenCarons(text: string): string {
|
||||||
|
return text.replace(/([ľťď])([^\s<&])/g, '$1<tspan dx="-0.05em">$2</tspan>');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeXml(unsafe: string): string {
|
function escapeXml(unsafe: string): string {
|
||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
return unsafe
|
return unsafe
|
||||||
|
|||||||
+4
-10
@@ -3,18 +3,12 @@
|
|||||||
// a jeho adresa sa zapamätá pri produkte (cardImage). Pri ďalšom exporte sa nezmenené karty nenahrávajú znova.
|
// 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 { toJpeg } from 'html-to-image';
|
||||||
import { CardProduct, NewsletterCampaign } from '@/types/newsletter';
|
import { CardProduct, NewsletterCampaign } from '@/types/newsletter';
|
||||||
import { cardContextForModule, cardVisualKey, cardVisualHeight } from './productCard';
|
import { cardContextFor, cardVisualKey, cardVisualHeight } from './productCard';
|
||||||
|
import { uploadFile } from './upload';
|
||||||
|
|
||||||
async function uploadJpeg(dataUrl: string, fileName: string): Promise<string> {
|
async function uploadJpeg(dataUrl: string, fileName: string): Promise<string> {
|
||||||
const blob = await (await fetch(dataUrl)).blob();
|
const blob = await (await fetch(dataUrl)).blob();
|
||||||
const formData = new FormData();
|
return uploadFile(blob, fileName);
|
||||||
formData.append('file', new File([blob], fileName, { type: 'image/jpeg' }));
|
|
||||||
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data.success || !data.url) {
|
|
||||||
throw new Error(data.error || 'Nahratie obrázka karty zlyhalo');
|
|
||||||
}
|
|
||||||
return data.url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareCardImages(
|
export async function prepareCardImages(
|
||||||
@@ -26,8 +20,8 @@ export async function prepareCardImages(
|
|||||||
campaign.modules.forEach((m, mIdx) => {
|
campaign.modules.forEach((m, mIdx) => {
|
||||||
const isCards = m.type === 'products_grid' || (m.type === 'loyalty_club' && m.data?.enabled !== false);
|
const isCards = m.type === 'products_grid' || (m.type === 'loyalty_club' && m.data?.enabled !== false);
|
||||||
if (!isCards) return;
|
if (!isCards) return;
|
||||||
const ctx = cardContextForModule(m, 'preview');
|
|
||||||
(m.data.products || []).forEach((p: CardProduct, pIdx: number) => {
|
(m.data.products || []).forEach((p: CardProduct, pIdx: number) => {
|
||||||
|
const ctx = cardContextFor(m, pIdx, 'preview');
|
||||||
const key = cardVisualKey(p, ctx);
|
const key = cardVisualKey(p, ctx);
|
||||||
if (p.cardImage?.key !== key) {
|
if (p.cardImage?.key !== key) {
|
||||||
jobs.push({ mIdx, pIdx, key, bg: p.cardBgColor || ctx.cardBg, width: ctx.cardWidth });
|
jobs.push({ mIdx, pIdx, key, bg: p.cardBgColor || ctx.cardBg, width: ctx.cardWidth });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NewsletterCampaign } from '@/types/newsletter';
|
|||||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||||
import { pickAlt } from './altText';
|
import { pickAlt } from './altText';
|
||||||
import { escapeHtml, escapeWithBreaks } from './html';
|
import { escapeHtml, escapeWithBreaks } from './html';
|
||||||
import { RenderMode, cardContextForModule, renderProductGrid } from './productCard';
|
import { RenderMode, renderProductGrid } from './productCard';
|
||||||
|
|
||||||
// E-mail na export (ZIP/Brevo): produktové karty použijú obrázky pripravené cez prepareCardImages()
|
// E-mail na export (ZIP/Brevo): produktové karty použijú obrázky pripravené cez prepareCardImages()
|
||||||
export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||||
@@ -233,6 +233,15 @@ export function renderSingleModuleHtml(m: any, campaign: NewsletterCampaign): st
|
|||||||
return renderModule(m, campaign, 'preview');
|
return renderModule(m, campaign, 'preview');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Žiara (tieň) tlačidla vo farbe tlačidla: #rrggbb / #rgb → rgba(r, g, b, alpha)
|
||||||
|
function glowColor(hex: string, alpha: number): string {
|
||||||
|
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((hex || '').trim());
|
||||||
|
if (!m) return `rgba(0, 0, 0, ${alpha})`;
|
||||||
|
const h = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
|
||||||
|
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
// Globálna platnosť akcie kampane (zdieľaná všetkými produktovými sekciami)
|
// Globálna platnosť akcie kampane (zdieľaná všetkými produktovými sekciami)
|
||||||
export function getCampaignValidity(campaign: NewsletterCampaign): string {
|
export function getCampaignValidity(campaign: NewsletterCampaign): string {
|
||||||
const mod = (campaign.modules || []).find(
|
const mod = (campaign.modules || []).find(
|
||||||
@@ -358,13 +367,13 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
|||||||
${m.data.title || m.data.description || m.data.buttonText ? `
|
${m.data.title || m.data.description || m.data.buttonText ? `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px 20px; text-align: center;" class="mobile-padding">
|
<td style="padding: 30px 20px; text-align: center;" class="mobile-padding">
|
||||||
${m.data.title ? `<h1 class="force-text-dark" style="margin: 0 0 15px 0; font-size: 24px; color: #1a1a1a; font-weight: 700;">${escapeWithBreaks(m.data.title)}</h1>` : ''}
|
${m.data.title ? `<h1 ${m.data.titleColor ? '' : 'class="force-text-dark" '}style="margin: 0 0 15px 0; font-size: 24px; color: ${m.data.titleColor || '#1a1a1a'}; font-weight: 700;">${escapeWithBreaks(m.data.title)}</h1>` : ''}
|
||||||
${m.data.description ? `<p class="force-text-dark" style="margin: 0 0 25px 0; font-size: 15px; line-height: 1.5; color: #555555;">${escapeWithBreaks(m.data.description)}</p>` : ''}
|
${m.data.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 ? `
|
${m.data.buttonText ? `
|
||||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="text-align: center;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>` : ''}
|
</table>` : ''}
|
||||||
@@ -383,7 +392,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
|||||||
<h2 class="force-text-white" style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeWithBreaks(m.data.sectionTitle)}</h2>
|
<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>
|
</td>
|
||||||
</tr>` : ''}
|
</tr>` : ''}
|
||||||
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
|
${renderProductGrid(m, mode)}
|
||||||
</table>
|
</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>`;
|
<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>`;
|
||||||
|
|
||||||
@@ -414,7 +423,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- Produktové karty -->
|
<!-- Produktové karty -->
|
||||||
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
|
${renderProductGrid(m, mode)}
|
||||||
</table>
|
</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>`;
|
<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>`;
|
||||||
|
|
||||||
@@ -451,7 +460,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
|||||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="text-align: center;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -485,7 +494,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
|||||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="text-align: center;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// Import ZIP-u s podkladmi z Databázy produktov: tabuľka podklady_*.xlsx (jeden riadok = jeden produkt)
|
||||||
|
// a fotky v priečinkoch <EAN>/web/*.png.
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { BADGE_IMPORT_TYPES, BadgeColor, BadgeTemplate, CardProduct, ProductItem, ProductLabel } from '@/types/newsletter';
|
||||||
|
import { findBadgeByCode } from './badgeUtils';
|
||||||
|
import { CARD_BG_BY_BADGE_COLOR } from './productCard';
|
||||||
|
|
||||||
|
export interface PodkladyProduct {
|
||||||
|
ean: string;
|
||||||
|
title: string;
|
||||||
|
price: number | null; // akciová cena
|
||||||
|
regularPrice: number | null; // bežná cena
|
||||||
|
cardPrice: number | null; // cena s vernostnou kartou
|
||||||
|
appPrice: number | null; // cena pre appkáčov
|
||||||
|
unitPrice: string; // napr. "jednotková cena 5,39 EUR/kg"
|
||||||
|
quantity: string; // gramáž / množstvo, napr. "1 kg"
|
||||||
|
discount: string; // napr. "30%"
|
||||||
|
badgeLabel: string; // napr. "SUPER CENA | červená | SK" – z čoho sa hľadá šablóna cenovky
|
||||||
|
badge?: BadgeTemplate;
|
||||||
|
badgeMissing: string[]; // čo šablóne chýba voči riadku (napr. „SK variant“, „variant pre appkáčov“)
|
||||||
|
color?: BadgeColor; // farba cenovky → pozadie karty
|
||||||
|
stores: ProductItem['stores'];
|
||||||
|
deposit: string; // napr. "0,15"
|
||||||
|
extras: string[]; // doplnkové grafiky → štítky na fotke (napr. „PODIEL MÄSA 90 %“)
|
||||||
|
photo?: Blob;
|
||||||
|
photoName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// „červená“ → cervena
|
||||||
|
function toBadgeColor(value: string): BadgeColor | undefined {
|
||||||
|
const n = norm(value);
|
||||||
|
if (n.startsWith('cerven')) return 'cervena';
|
||||||
|
if (n.startsWith('oranz')) return 'oranzova';
|
||||||
|
if (n.startsWith('zelen')) return 'zelena';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Šablóna cenovky podľa tagov (Typ + Farba + Pôvod + Appka). Náhradný variant sa berie len smerom,
|
||||||
|
// ktorý nič nepridá: SK produkt môže dostať cenovku bez vlajky a produkt s cenou pre appkáčov bežnú cenovku,
|
||||||
|
// ale zahraničný produkt nikdy cenovku so slovenskou vlajkou a bežný produkt nikdy cenovku pre appkáčov.
|
||||||
|
function findBadgeByTags(
|
||||||
|
badges: BadgeTemplate[],
|
||||||
|
type: string,
|
||||||
|
color: BadgeColor | undefined,
|
||||||
|
origin: string,
|
||||||
|
app: boolean
|
||||||
|
): { badge: BadgeTemplate; missing: string[] } | undefined {
|
||||||
|
const sk = norm(origin) === 'sk';
|
||||||
|
const candidates = badges.filter((b) => {
|
||||||
|
const t = b.importTags;
|
||||||
|
if (!t?.type || norm(t.type) !== norm(type)) return false;
|
||||||
|
if (t.color && t.color !== color) return false;
|
||||||
|
if (t.origin === 'SK' && !sk) return false;
|
||||||
|
if (t.app && !app) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
// najlepšia zhoda: rovnaký pôvod, rovnaká appka, presná farba
|
||||||
|
const score = (b: BadgeTemplate) =>
|
||||||
|
((b.importTags?.origin === 'SK') === sk ? 0 : 4) + (!!b.importTags?.app === app ? 0 : 2) + (b.importTags?.color ? 0 : 1);
|
||||||
|
const best = candidates.sort((a, b) => score(a) - score(b))[0];
|
||||||
|
if (!best) return undefined;
|
||||||
|
const missing = [
|
||||||
|
sk && best.importTags?.origin !== 'SK' ? 'SK variant' : '',
|
||||||
|
app && !best.importTags?.app ? 'variant pre appkáčov' : ''
|
||||||
|
].filter(Boolean);
|
||||||
|
return { badge: best, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doplnková grafika → štítok na fotke; dlhší text sa zalomí na dva riadky
|
||||||
|
function extraLabel(text: string, idx: number): ProductLabel {
|
||||||
|
const words = text.split(' ');
|
||||||
|
let label = text;
|
||||||
|
if (text.length > 16 && words.length > 1) {
|
||||||
|
let best = 1;
|
||||||
|
for (let i = 1; i < words.length; i++) {
|
||||||
|
const a = words.slice(0, i).join(' ').length;
|
||||||
|
const bestA = words.slice(0, best).join(' ').length;
|
||||||
|
if (Math.abs(a - text.length / 2) < Math.abs(bestA - text.length / 2)) best = i;
|
||||||
|
}
|
||||||
|
label = `${words.slice(0, best).join(' ')}<br>${words.slice(best).join(' ')}`;
|
||||||
|
}
|
||||||
|
return { id: `lbl-${Date.now()}-${idx}`, text: label, x: 28, y: 14 + idx * 14, size: 11, bg: '#e2001a', color: '#ffffff', rotate: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- jednoduché čítanie .xlsx (prvý hárok) ----------
|
||||||
|
function colIndex(ref: string): number {
|
||||||
|
const letters = ref.replace(/\d+/g, '');
|
||||||
|
let n = 0;
|
||||||
|
for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||||
|
return n - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readXlsx(data: ArrayBuffer): Promise<string[][]> {
|
||||||
|
const xlsx = await JSZip.loadAsync(data);
|
||||||
|
const parse = async (path: string) => {
|
||||||
|
const text = await xlsx.file(path)?.async('string');
|
||||||
|
return text ? new DOMParser().parseFromString(text, 'application/xml') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shared: string[] = [];
|
||||||
|
const sst = await parse('xl/sharedStrings.xml');
|
||||||
|
sst?.querySelectorAll('si').forEach((si) => {
|
||||||
|
shared.push([...si.getElementsByTagName('t')].map((t) => t.textContent || '').join(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheetPath =
|
||||||
|
xlsx.file('xl/worksheets/sheet1.xml') ? 'xl/worksheets/sheet1.xml' : Object.keys(xlsx.files).find((p) => /^xl\/worksheets\/[^/]+\.xml$/.test(p));
|
||||||
|
const sheet = sheetPath ? await parse(sheetPath) : null;
|
||||||
|
if (!sheet) throw new Error('V tabuľke sa nenašiel hárok.');
|
||||||
|
|
||||||
|
const rows: string[][] = [];
|
||||||
|
sheet.querySelectorAll('sheetData > row').forEach((row) => {
|
||||||
|
const cells: string[] = [];
|
||||||
|
row.querySelectorAll('c').forEach((c) => {
|
||||||
|
const type = c.getAttribute('t');
|
||||||
|
const v = c.getElementsByTagName('v')[0]?.textContent ?? '';
|
||||||
|
let value = v;
|
||||||
|
if (type === 's') value = shared[Number(v)] ?? '';
|
||||||
|
else if (type === 'inlineStr') value = [...c.getElementsByTagName('t')].map((t) => t.textContent || '').join('');
|
||||||
|
cells[colIndex(c.getAttribute('r') || 'A')] = value;
|
||||||
|
});
|
||||||
|
rows.push(Array.from(cells, (x) => x ?? ''));
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- prevody hodnôt ----------
|
||||||
|
function toNumber(value: string): number | null {
|
||||||
|
const n = parseFloat(String(value || '').replace(/\s/g, '').replace(',', '.'));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEur = (n: number) => n.toFixed(2).replace('.', ',');
|
||||||
|
|
||||||
|
// Jednotková cena: "5,39 EUR/kg" → "jednotková cena 5,39 EUR/kg"
|
||||||
|
// (bez jednotky v podkladoch sa doplní z Množstvo MJ: g → kg, ml → l)
|
||||||
|
export function formatUnitPrice(value: string, quantityUnit = ''): string {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return '';
|
||||||
|
const m = v.match(/^(?:jednotková cena\s*)?([\d\s.,]+?)\s*(?:EUR|€)?\s*(?:\/\s*(.+))?$/i);
|
||||||
|
const n = m ? toNumber(m[1]) : null;
|
||||||
|
if (!m || n == null) return v;
|
||||||
|
const unit = (m[2] || { g: 'kg', kg: 'kg', ml: 'l', l: 'l', ks: 'ks' }[quantityUnit.trim().toLowerCase()] || '').trim();
|
||||||
|
return `jednotková cena ${formatEur(n)} EUR${unit ? `/${unit}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gramáž / množstvo na konci názvu z letáku: "Ryža guľatozrnná 1 kg", "Cereálie 2 druhy od 225 g do 250 g",
|
||||||
|
// "Vrecia na smeti 60 l 15 ks 1 bal.". Z konca názvu sa odoberú údaje o množstve; ak je medzi nimi množstvo
|
||||||
|
// zo stĺpca Množstvo, oddelí sa od neho (to pred ním patrí k názvu, napr. „60 l“ pri vreciach).
|
||||||
|
const UNIT = String.raw`(?:kg|g|mg|l|ml|cl|dl|ks|bal\.?|balenie|pracích dávok|pracie dávky|pracia dávka|PD|m|cm|mm|tbl\.?|kapsúl|plátkov|rolí|rolky|vreciek)`;
|
||||||
|
const NUM = String.raw`\d+(?:[.,]\d+)?`;
|
||||||
|
const AMOUNT = String.raw`(?:(?:${NUM}\s?[x×]\s?)?${NUM}\s?${UNIT}(?![\p{L}]))`;
|
||||||
|
const TAIL = new RegExp(String.raw`(?:^|\s)((?:od\s+${AMOUNT}\s+do\s+${AMOUNT})|${AMOUNT}|${NUM}\s?[x×]\s?${AMOUNT})\s*$`, 'iu');
|
||||||
|
|
||||||
|
export function splitQuantity(title: string, qty = '', qtyUnit = ''): { title: string; quantity: string } {
|
||||||
|
let rest = title.trim();
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let m = rest.match(TAIL); m; m = rest.match(TAIL)) {
|
||||||
|
parts.unshift(m[1]);
|
||||||
|
rest = rest.slice(0, m.index).trim();
|
||||||
|
}
|
||||||
|
if (!parts.length) {
|
||||||
|
// v názve nie je množstvo – doplníme ho zo stĺpcov Množstvo + Množstvo MJ
|
||||||
|
const n = toNumber(qty);
|
||||||
|
return { title: rest, quantity: n != null && qtyUnit ? `${String(n).replace('.', ',')} ${qtyUnit.trim()}` : '' };
|
||||||
|
}
|
||||||
|
// množstvo zo stĺpca (napr. 15 ks) – časti pred ním sú súčasť názvu
|
||||||
|
const n = toNumber(qty);
|
||||||
|
const key = n != null && qtyUnit ? `${n}|${qtyUnit.trim().toLowerCase()}` : '';
|
||||||
|
const idx = key ? parts.findIndex((p) => amountKeys(p).includes(key)) : -1;
|
||||||
|
const keep = idx > 0 ? parts.slice(0, idx) : [];
|
||||||
|
return { title: [rest, ...keep].filter(Boolean).join(' '), quantity: parts.slice(Math.max(idx, 0)).join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "0,555 l" → ["0.555|l", "555|ml"] – aby sa dalo porovnať s množstvom zo stĺpcov
|
||||||
|
function amountKeys(text: string): string[] {
|
||||||
|
const m = text.match(new RegExp(String.raw`(${NUM})\s?(kg|g|l|ml|ks)\b`, 'i'));
|
||||||
|
if (!m) return [];
|
||||||
|
const n = toNumber(m[1])!;
|
||||||
|
const u = m[2].toLowerCase();
|
||||||
|
const keys = [`${n}|${u}`];
|
||||||
|
if (u === 'kg') keys.push(`${+(n * 1000).toFixed(3)}|g`);
|
||||||
|
if (u === 'g') keys.push(`${+(n / 1000).toFixed(6)}|kg`);
|
||||||
|
if (u === 'l') keys.push(`${+(n * 1000).toFixed(3)}|ml`);
|
||||||
|
if (u === 'ml') keys.push(`${+(n / 1000).toFixed(6)}|l`);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitPrice(n: number): { main: string; sub: string } {
|
||||||
|
const [main, sub] = n.toFixed(2).split('.');
|
||||||
|
return { main, sub };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- načítanie ZIP-u ----------
|
||||||
|
export async function readPodkladyZip(file: File, badges: BadgeTemplate[]): Promise<{ products: PodkladyProduct[]; info: string }> {
|
||||||
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
const files = Object.values(zip.files).filter((f) => !f.dir && !f.name.startsWith('__MACOSX/'));
|
||||||
|
const table = files.find((f) => /\.xlsx$/i.test(f.name));
|
||||||
|
if (!table) throw new Error('V ZIP-e chýba tabuľka .xlsx s podkladmi.');
|
||||||
|
const info = (await files.find((f) => /_info\.txt$/i.test(f.name))?.async('string'))?.trim() || '';
|
||||||
|
|
||||||
|
const [header = [], ...rows] = await readXlsx(await table.async('arraybuffer'));
|
||||||
|
const col = (...names: string[]) => header.findIndex((h) => names.some((n) => norm(h) === norm(n)));
|
||||||
|
const c = {
|
||||||
|
ean: col('EAN'),
|
||||||
|
title: col('Názov v letáku'),
|
||||||
|
titleAlt: col('Názov produktu'),
|
||||||
|
qty: col('Množstvo'),
|
||||||
|
qtyUnit: col('Množstvo MJ'),
|
||||||
|
price: col('Akciová cena (leták)', 'Cena (xlsx podklady)'),
|
||||||
|
regularPrice: col('Bežná cena'),
|
||||||
|
cardPrice: col('Cena s vernostnou kartou'),
|
||||||
|
appPrice: col('Cena pre appkáčov'),
|
||||||
|
extras: col('Doplnkové grafiky'),
|
||||||
|
unitPrice: col('Jednotková cena'),
|
||||||
|
discount: col('Zľava %'),
|
||||||
|
badge: col('Zľava grafická'),
|
||||||
|
color: col('Farba cenovky'),
|
||||||
|
origin: col('Pôvod'),
|
||||||
|
formats: col('Formát'),
|
||||||
|
deposit: col('Zálohovanie'),
|
||||||
|
exclaim: col('Výkričník')
|
||||||
|
};
|
||||||
|
if (c.ean < 0) throw new Error('Tabuľka nemá stĺpec EAN – nejde o podklady z Databázy produktov?');
|
||||||
|
const get = (row: string[], i: number) => (i >= 0 ? String(row[i] ?? '').trim() : '');
|
||||||
|
|
||||||
|
const images = files.filter((f) => /\.(png|jpe?g|webp)$/i.test(f.name));
|
||||||
|
const products: PodkladyProduct[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const ean = get(row, c.ean);
|
||||||
|
if (!ean) continue;
|
||||||
|
|
||||||
|
// fotka: <EAN>/web/… (prípadne hocijaký obrázok v priečinku <EAN>/)
|
||||||
|
const own = images.filter((f) => f.name.split('/')[0] === ean);
|
||||||
|
const photoFile = own.find((f) => f.name.includes('/web/')) || own[0];
|
||||||
|
|
||||||
|
// šablóna cenovky: podľa tagov šablóny, inak podľa názvu „Super cena | Červená | SK“
|
||||||
|
const graphic = get(row, c.badge);
|
||||||
|
const colorText = get(row, c.color);
|
||||||
|
const color = toBadgeColor(colorText);
|
||||||
|
const origin = get(row, c.origin);
|
||||||
|
// prázdna „Zľava grafická“ = základná cenovka (s výkričníkom podľa stĺpca Výkričník)
|
||||||
|
const type = graphic || (norm(get(row, c.exclaim)) === 'ano' ? 'ZÁKLADNÁ !' : 'ZÁKLADNÁ');
|
||||||
|
const tries = [[graphic, colorText, origin], [graphic, colorText], [graphic]].map((parts) => parts.filter(Boolean).join(' | '));
|
||||||
|
const appPrice = toNumber(get(row, c.appPrice));
|
||||||
|
const byTags = findBadgeByTags(badges, type, color, origin, appPrice != null);
|
||||||
|
const byName = !byTags && graphic ? tries.map((t) => findBadgeByCode(badges, t)).find(Boolean) : undefined;
|
||||||
|
|
||||||
|
const formats = norm(get(row, c.formats));
|
||||||
|
const deposit = toNumber(get(row, c.deposit));
|
||||||
|
const discount = toNumber(get(row, c.discount));
|
||||||
|
|
||||||
|
products.push({
|
||||||
|
ean,
|
||||||
|
...splitQuantity(get(row, c.title) || get(row, c.titleAlt) || ean, get(row, c.qty), get(row, c.qtyUnit)),
|
||||||
|
price: toNumber(get(row, c.price)),
|
||||||
|
regularPrice: toNumber(get(row, c.regularPrice)),
|
||||||
|
cardPrice: toNumber(get(row, c.cardPrice)),
|
||||||
|
appPrice,
|
||||||
|
unitPrice: formatUnitPrice(get(row, c.unitPrice), get(row, c.qtyUnit)),
|
||||||
|
discount: discount ? `${Math.round(discount)}%` : '',
|
||||||
|
badgeLabel: [BADGE_IMPORT_TYPES.find((t) => t.value === type)?.label || type, colorText, origin, appPrice != null ? 'appka' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | '),
|
||||||
|
badge: byTags?.badge || byName,
|
||||||
|
badgeMissing: byTags?.missing || [],
|
||||||
|
color,
|
||||||
|
stores: formats
|
||||||
|
? { potraviny: formats.includes('potraviny'), supermarket: formats.includes('supermarket'), tempo: formats.includes('tempo') }
|
||||||
|
: { potraviny: true, supermarket: true, tempo: true },
|
||||||
|
deposit: deposit ? formatEur(deposit) : '',
|
||||||
|
extras: get(row, c.extras)
|
||||||
|
.split(/[;\n]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
photo: photoFile ? await photoFile.async('blob') : undefined,
|
||||||
|
photoName: photoFile ? photoFile.name.split('/').pop()! : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { products, info };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jednotková cena prepočítaná na inú cenu (napr. bez karty): "jednotková cena 5,39 EUR/kg" × 6,49 / 5,39
|
||||||
|
function scaleUnitPrice(unitPrice: string, from: number, to: number): string {
|
||||||
|
const m = unitPrice.match(/^(jednotková cena )([\d.,]+)( EUR.*)$/);
|
||||||
|
const n = m ? toNumber(m[2]) : null;
|
||||||
|
return m && n != null && from > 0 ? `${m[1]}${formatEur((n * to) / from)}${m[3]}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vernostný produkt (sekcia Vernostné zľavy): s kartou = Cena s vernostnou kartou, inak akciová cena z letáku;
|
||||||
|
// bez karty = akciová cena (ak je zvlášť cena s kartou), inak bežná cena. Farba pozadia sa nemení.
|
||||||
|
export function podkladyToLoyaltyItem(p: PodkladyProduct, imageUrl: string, idx: number): CardProduct {
|
||||||
|
const base = podkladyToProductItem(p, imageUrl, idx);
|
||||||
|
delete base.cardBgColor;
|
||||||
|
const card = p.cardPrice ?? p.price;
|
||||||
|
const noCard = p.cardPrice != null ? p.price : p.regularPrice;
|
||||||
|
const c = card != null ? splitPrice(card) : null;
|
||||||
|
const n = noCard != null ? splitPrice(noCard) : null;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
id: `zip-lp-${Date.now()}-${idx}`,
|
||||||
|
priceMain: '',
|
||||||
|
priceSub: '',
|
||||||
|
unitPrice: '',
|
||||||
|
discountText: '',
|
||||||
|
cardPriceMain: c?.main || '',
|
||||||
|
cardPriceSub: c?.sub || '',
|
||||||
|
cardDiscount: p.discount,
|
||||||
|
cardUnitPrice: p.unitPrice,
|
||||||
|
noCardPriceMain: n?.main || '',
|
||||||
|
noCardPriceSub: n?.sub || '',
|
||||||
|
noCardDiscount: '',
|
||||||
|
noCardUnitPrice: card != null && noCard != null ? scaleUnitPrice(p.unitPrice, card, noCard) : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produkt pre kampaň (fotka už nahratá – imageUrl)
|
||||||
|
export function podkladyToProductItem(p: PodkladyProduct, imageUrl: string, idx: number): ProductItem {
|
||||||
|
const price = splitPrice(p.price ?? 0);
|
||||||
|
const card = p.cardPrice != null ? splitPrice(p.cardPrice) : null;
|
||||||
|
const app = p.appPrice != null ? splitPrice(p.appPrice) : null;
|
||||||
|
return {
|
||||||
|
...(p.badge ? { badgeCustomTemplate: p.badge, badgeId: p.badge.id } : {}),
|
||||||
|
id: `zip-${Date.now()}-${idx}`,
|
||||||
|
title: p.title,
|
||||||
|
imageUrl,
|
||||||
|
priceMain: price.main,
|
||||||
|
priceSub: price.sub,
|
||||||
|
quantity: p.quantity,
|
||||||
|
unitPrice: p.unitPrice,
|
||||||
|
discountText: p.discount,
|
||||||
|
...(card ? { cardPriceMain: card.main, cardPriceSub: card.sub } : {}),
|
||||||
|
...(app ? { appPriceMain: app.main, appPriceSub: app.sub } : {}),
|
||||||
|
...(p.extras.length ? { labels: p.extras.map(extraLabel) } : {}),
|
||||||
|
note: p.deposit ? `+ záloha ${p.deposit} €` : '',
|
||||||
|
badgeText: 'zľava',
|
||||||
|
stores: p.stores,
|
||||||
|
...(p.color ? { cardBgColor: CARD_BG_BY_BADGE_COLOR[p.color] } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
+131
-31
@@ -5,7 +5,7 @@
|
|||||||
// V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu
|
// 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
|
// 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).
|
// v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
|
||||||
import { BadgeTemplate, CardProduct, NewsletterModule } from '@/types/newsletter';
|
import { BadgeTemplate, CardProduct, NewsletterModule, ProductLabel, ProductPhoto } from '@/types/newsletter';
|
||||||
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
|
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
|
||||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||||
import { pickAlt } from './altText';
|
import { pickAlt } from './altText';
|
||||||
@@ -29,9 +29,13 @@ export interface StoresOffset {
|
|||||||
x?: number; // + doprava, − doľava
|
x?: number; // + doprava, − doľava
|
||||||
y?: number; // + vyššie nad cenovku, − nižšie (cez horný okraj cenovky)
|
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
|
// Predvolená poloha štítkov voči pravému hornému rohu pozadia cenovky
|
||||||
export const DEFAULT_STORES_OFFSET_X = -15;
|
export const DEFAULT_STORES_OFFSET_X = -23;
|
||||||
export const DEFAULT_STORES_OFFSET_Y = 5;
|
// 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
|
// 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 {
|
export function cardVisualHeight(cardWidth: number): number {
|
||||||
@@ -184,14 +188,27 @@ function renderDualSticker(p: CardProduct): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Štítky predajní (Potraviny / Supermarket / Tempo) pod sebou; výška podľa veľkosti cenovky
|
// Štítky predajní (Potraviny / Supermarket / Tempo) pod sebou; výška podľa veľkosti cenovky
|
||||||
function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number): string {
|
// Biely okraj okolo nich ako skupiny: pod štítkami je biela vrstva z rovnakých štítkov, každý o hrúbku okraja
|
||||||
const tag = (color: string, file: string, alt: string) =>
|
// väčší a so zaoblenými rohmi – kde sa štítky dotýkajú, biela splynie, takže linka ide okolo celej skupiny
|
||||||
`<div style="text-align: right; line-height: 0;"><img src="${escapeHtml(assetUrl(SUPABASE_ASSETS_BASE + file, mode))}" height="${tagHeight}" alt="${alt}" style="display: inline-block; height: ${tagHeight}px; width: auto; border: 0; background-color: ${color};"></div>`;
|
// a vonkajšie rohy sú zaoblené (polovica hrúbky linky okolo pozadia cenovky: 2,25 px pri cenovke širokej 130 px).
|
||||||
return [
|
function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number, badgeWidth: number): string {
|
||||||
p.stores?.potraviny ? tag('#e2001a', 'stit_potraviny.png', 'Potraviny') : '',
|
const t = Math.round(((2.25 * badgeWidth) / 130) * 10) / 10;
|
||||||
p.stores?.supermarket ? tag('#f9890e', 'stit_supermarket.png', 'Supermarket') : '',
|
const tags = [
|
||||||
p.stores?.tempo ? tag('#005239', 'stit_tempo.png', 'Tempo') : ''
|
p.stores?.potraviny ? ['#e2001a', 'stit_potraviny.png', 'Potraviny'] : null,
|
||||||
].join('');
|
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ý
|
// Jednoznačný kľúč vizuálu – pri zmene čohokoľvek, čo ovplyvní obrázok, sa pri exporte vytvorí nový
|
||||||
@@ -199,13 +216,14 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
|||||||
const t = p.badgeCustomTemplate;
|
const t = p.badgeCustomTemplate;
|
||||||
const relevant = [
|
const relevant = [
|
||||||
ctx.variant, ctx.cardWidth, p.cardBgColor || ctx.cardBg, ctx.variant === 'standard' ? ctx.defaultBadge : null,
|
ctx.variant, ctx.cardWidth, p.cardBgColor || ctx.cardBg, ctx.variant === 'standard' ? ctx.defaultBadge : null,
|
||||||
p.imageUrl, p.imageScale ?? 1, p.imageOffsetX || 0, p.imageOffsetY || 0,
|
p.imageUrl, p.imageScale ?? DEFAULT_IMAGE_SCALE, p.imageOffsetX || 0, p.imageOffsetY || 0,
|
||||||
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
|
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
|
||||||
t ? [t.svgRaw, t.layersConfig, t.width] : null,
|
t ? [t.svgRaw, t.layersConfig, t.width] : null,
|
||||||
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
|
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
|
||||||
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice,
|
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice, p.appPriceMain, p.appPriceSub,
|
||||||
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
|
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
|
||||||
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y
|
p.stores, p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X, p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y, p.storesSize ?? DEFAULT_STORES_SIZE,
|
||||||
|
p.labels || [], p.extraPhotos || [], photoStack(p)
|
||||||
];
|
];
|
||||||
const str = JSON.stringify(relevant);
|
const str = JSON.stringify(relevant);
|
||||||
let h1 = 5381;
|
let h1 = 5381;
|
||||||
@@ -218,6 +236,51 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
|||||||
return `cv-${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
|
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
|
// 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
|
// 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).
|
// k pravému hornému rohu samotného pozadia, preto jeho hranice odmeriame v prehliadači (getBBox).
|
||||||
@@ -262,19 +325,28 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
|
|||||||
const height = cardVisualHeight(ctx.cardWidth);
|
const height = cardVisualHeight(ctx.cardWidth);
|
||||||
const bg = p.cardBgColor || ctx.cardBg;
|
const bg = p.cardBgColor || ctx.cardBg;
|
||||||
const bw = badgeWidthFor(p, ctx.variant);
|
const bw = badgeWidthFor(p, ctx.variant);
|
||||||
const scale = p.imageScale ?? 1;
|
const scale = p.imageScale ?? DEFAULT_IMAGE_SCALE;
|
||||||
const ox = p.imageOffsetX || 0;
|
const ox = p.imageOffsetX || 0;
|
||||||
const oy = p.imageOffsetY || 0;
|
const oy = p.imageOffsetY || 0;
|
||||||
const sx = Math.round(p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X);
|
const sx = Math.round(p.storesOffsetX ?? DEFAULT_STORES_OFFSET_X);
|
||||||
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
|
const sy = Math.round(p.storesOffsetY ?? DEFAULT_STORES_OFFSET_Y);
|
||||||
const stores = renderStores(p, ctx.mode, Math.max(10, Math.round(bw * 0.12)));
|
const stores = renderStores(p, ctx.mode, p.storesSize ?? DEFAULT_STORES_SIZE, bw);
|
||||||
// Pravý horný roh viditeľného pozadia cenovky (pri PNG a predvolenej cenovke = roh cenovky)
|
// 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 bgBox = p.badgeCustomTemplate && !p.customBadgePngUrl ? badgeBackgroundBox(p.badgeCustomTemplate) : null;
|
||||||
const insetTop = bgBox ? Math.round(bgBox.top * bw * bgBox.aspect) : 0;
|
const insetTop = bgBox ? Math.round(bgBox.top * bw * bgBox.aspect) : 0;
|
||||||
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
|
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
|
||||||
return `
|
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;">
|
<div data-card-visual="${key}" style="position: relative; width: 100%; height: ${height}px; overflow: hidden; background-color: ${bg}; border-radius: 16px 16px 0 0; line-height: normal; font-size: 12px;">
|
||||||
${p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : ''}
|
${photoStack(p)
|
||||||
|
.map((id) => {
|
||||||
|
if (id === MAIN_PHOTO) {
|
||||||
|
return p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : '';
|
||||||
|
}
|
||||||
|
const f = (p.extraPhotos || []).find((x) => x.id === id);
|
||||||
|
return f ? renderExtraPhoto(f, ctx.mode) : '';
|
||||||
|
})
|
||||||
|
.join('')}
|
||||||
|
${(p.labels || []).map(renderLabel).join('')}
|
||||||
<div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;">
|
<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>` : ''}
|
${stores ? `<div style="position: absolute; right: ${insetRight - sx}px; bottom: calc(100% - ${insetTop}px + ${sy}px);">${stores}</div>` : ''}
|
||||||
${renderBadge(p, ctx, bw)}
|
${renderBadge(p, ctx, bw)}
|
||||||
@@ -282,6 +354,9 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
|
|||||||
</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ť
|
// 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 {
|
export function productCardAlt(p: CardProduct, variant: CardVariant): string {
|
||||||
const name = [pickAlt(p.title), p.quantity].filter(Boolean).join(' ');
|
const name = [pickAlt(p.title), p.quantity].filter(Boolean).join(' ');
|
||||||
@@ -298,9 +373,11 @@ export function productCardAlt(p: CardProduct, variant: CardVariant): string {
|
|||||||
} else {
|
} else {
|
||||||
const main = price(p.priceMain, p.priceSub);
|
const main = price(p.priceMain, p.priceSub);
|
||||||
const card = price(p.cardPriceMain, p.cardPriceSub);
|
const card = price(p.cardPriceMain, p.cardPriceSub);
|
||||||
|
const app = usesAppPrice(p.badgeCustomTemplate) ? price(p.appPriceMain, p.appPriceSub) : '';
|
||||||
priceText = [
|
priceText = [
|
||||||
main ? `${main}${p.discountText ? `, zľava ${pct(p.discountText)}` : ''}` : '',
|
main ? `${main}${p.discountText ? `, zľava ${pct(p.discountText)}` : ''}` : '',
|
||||||
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : ''
|
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : '',
|
||||||
|
app ? `v aplikácii ${app}` : ''
|
||||||
].filter(Boolean).join(', ');
|
].filter(Boolean).join(', ');
|
||||||
}
|
}
|
||||||
return pickAlt(p.imageAlt, [name, priceText].filter(Boolean).join(' – '), 'Produkt');
|
return pickAlt(p.imageAlt, [name, priceText].filter(Boolean).join(' – '), 'Produkt');
|
||||||
@@ -333,10 +410,10 @@ function renderTexts(p: CardProduct, ctx: CardContext): string {
|
|||||||
: p.unitPrice;
|
: p.unitPrice;
|
||||||
const validity = p.validityOverride || ctx.validity;
|
const validity = p.validityOverride || ctx.validity;
|
||||||
const small = (text: string) =>
|
const small = (text: string) =>
|
||||||
`<div class="prod-unit force-text-dark" style="margin-top: 2px; ${TEXT_FONT} font-size: 10px; line-height: 1.3; color: #888888;">${escapeHtml(text)}</div>`;
|
`<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;">${escapeHtml(p.quantity)}</span>` : ''}</h3>${
|
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 ? `
|
unitPrice ? `
|
||||||
<div class="prod-unit force-text-dark" style="margin-top: 3px; ${TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeHtml(unitPrice)}</div>` : ''}${
|
<div class="prod-unit force-text-dark" style="margin-top: 3px; ${TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeWithBreaks(unitPrice)}</div>` : ''}${
|
||||||
p.note ? small(p.note) : ''}${validity ? small(validity) : ''}`;
|
p.note ? small(p.note) : ''}${validity ? small(validity) : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,23 +438,46 @@ export function renderProductCard(p: CardProduct, ctx: CardContext): string {
|
|||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mriežka kariet (1 alebo 2 na riadok) s rovnakou výškou kariet v riadku
|
// Kontext konkrétnej karty: vo vernostných zľavách pri nepárnom počte je prvý produkt cez celý riadok
|
||||||
export function renderProductGrid(products: CardProduct[], ctx: CardContext): string {
|
export function cardContextFor(m: NewsletterModule, index: number, mode: RenderMode): CardContext {
|
||||||
const cols = ctx.cardWidth > 300 ? 1 : 2;
|
const base = cardContextForModule(m, mode);
|
||||||
const rows: CardProduct[][] = [];
|
const count = (m.data.products || []).length;
|
||||||
for (let i = 0; i < products.length; i += cols) rows.push(products.slice(i, i + cols));
|
if (base.variant === 'loyalty' && count % 2 === 1 && index === 0) return { ...base, cardWidth: 600 };
|
||||||
return rows
|
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) => {
|
.map((row, rIdx) => {
|
||||||
const spacer = rIdx > 0 ? '<tr><td height="12" style="font-size: 12px; line-height: 12px;"> </td></tr>' : '';
|
const spacer = rIdx > 0 ? '<tr><td height="12" style="font-size: 12px; line-height: 12px;"> </td></tr>' : '';
|
||||||
const cells = row
|
const cells = row
|
||||||
.map(
|
.map(
|
||||||
(p, idx) => `
|
(i, idx) => `
|
||||||
<td width="${cols === 1 ? 100 : 50}%" valign="top" height="1" style="${cols === 1 ? '' : idx === 0 ? 'padding-right: 6px;' : 'padding-left: 6px;'} height: 100%;">
|
<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(p, ctx)}
|
${renderProductCard(products[i], cardContextFor(m, i, mode))}
|
||||||
</td>`
|
</td>`
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
const filler = cols === 2 && row.length === 1 ? '<td width="50%" style="padding-left: 6px;"></td>' : '';
|
// posledný riadok s 1 produktom v 2-stĺpcovej mriežke (bežné sekcie) – produkt ostane v ľavej polovici
|
||||||
|
const filler = !oneColumn && row.length === 1 && cardContextFor(m, row[0], mode).cardWidth <= 300 ? '<td width="50%" style="padding-left: 6px;"></td>' : '';
|
||||||
return `${spacer}
|
return `${spacer}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ export interface ProductItem {
|
|||||||
cardPriceMain?: string; // cena s kartou (vernostná), celé eurá
|
cardPriceMain?: string; // cena s kartou (vernostná), celé eurá
|
||||||
cardPriceSub?: string; // cena s kartou, centy
|
cardPriceSub?: string; // cena s kartou, centy
|
||||||
cardDiscount?: string; // zľava s kartou, napr. "30%"
|
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"
|
badgeText: string; // napr. "AKCIA" alebo "SUPER CENA"
|
||||||
badgeId?: string; // priradená cenovka z databázy
|
badgeId?: string; // priradená cenovka z databázy
|
||||||
badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny)
|
badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny)
|
||||||
@@ -49,6 +51,10 @@ export interface ProductItem {
|
|||||||
imageAlt?: string; // Ručný alt text fotky (inak názov produktu)
|
imageAlt?: string; // Ručný alt text fotky (inak názov produktu)
|
||||||
storesOffsetX?: number; // posun štítkov predajní voči cenovke (+ doprava)
|
storesOffsetX?: number; // posun štítkov predajní voči cenovke (+ doprava)
|
||||||
storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie)
|
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 %)
|
imageScale?: number; // Zoom fotky (1 = 100 %)
|
||||||
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
|
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
|
||||||
imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol)
|
imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol)
|
||||||
@@ -66,6 +72,28 @@ export interface CardProduct extends ProductItem {
|
|||||||
noCardUnitPrice?: 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 {
|
export interface AnnouncementModuleData {
|
||||||
tipBadge: string;
|
tipBadge: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -93,6 +121,7 @@ export interface BannerModuleData {
|
|||||||
linkUrl: string;
|
linkUrl: string;
|
||||||
altText: string;
|
altText: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
titleColor?: string; // farba nadpisu pod bannerom (predvolene #1a1a1a)
|
||||||
description?: string;
|
description?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
buttonUrl?: string;
|
buttonUrl?: string;
|
||||||
@@ -200,6 +229,8 @@ export interface BadgeLayer {
|
|||||||
indexFontSize?: number; // Veľkosť horného indexu v px
|
indexFontSize?: number; // Veľkosť horného indexu v px
|
||||||
indexOffsetY?: number; // Vertikálny posun horného indexu v px (posun hore / dole)
|
indexOffsetY?: number; // Vertikálny posun horného indexu v px (posun hore / dole)
|
||||||
indexOffsetX?: number; // Horizontálny posun horného indexu v px (medzera)
|
indexOffsetX?: number; // Horizontálny posun horného indexu v px (medzera)
|
||||||
|
longPriceScale?: number; // zmenšenie ceny od 10 € (dvojciferné eurá), napr. 0.8; účiaria ostáva
|
||||||
|
letterSpacing?: number; // rozostup písmen v em (napr. -0.03 = tesnejšie)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BadgeTemplate {
|
export interface BadgeTemplate {
|
||||||
@@ -213,4 +244,23 @@ export interface BadgeTemplate {
|
|||||||
align?: 'left' | 'center' | 'right'; // Zarovnanie (vľavo, stred, vpravo)
|
align?: 'left' | 'center' | 'right'; // Zarovnanie (vľavo, stred, vpravo)
|
||||||
paddingTop?: number;
|
paddingTop?: number;
|
||||||
paddingBottom?: 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“
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user