Compare commits
30
Commits
109d65a690
...
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 });
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
|
<head>
|
||||||
|
{/* Písmo e-mailu (Poppins) – náhľad v editore aj na stránke s pripomienkami */}
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800;900&display=swap" />
|
||||||
|
{/* Adobe Fonts (napr. Aptly pre cenovky a štítky) – odkaz na web projekt z fonts.adobe.com */}
|
||||||
|
{process.env.NEXT_PUBLIC_ADOBE_FONTS_URL && <link rel="stylesheet" href={process.env.NEXT_PUBLIC_ADOBE_FONTS_URL} />}
|
||||||
|
</head>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+154
-25
@@ -5,21 +5,36 @@ 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 } 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 { ReviewModal } from '@/components/review/ReviewModal';
|
||||||
|
import { AnnotationLayer, useModuleLayout } from '@/components/review/AnnotationLayer';
|
||||||
|
import { ReviewComment } from '@/lib/reviews';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react';
|
import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN);
|
const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN);
|
||||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky');
|
const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky');
|
||||||
|
// Produkt otvorený v paneli (harmonika); nastaví sa aj klikom na produkt v náhľade
|
||||||
|
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pripomienky zo zdieľaného odkazu (zobrazujú sa aj na plátne, dajú sa označiť ako hotové)
|
||||||
|
const reviews = useCampaignReview(campaign);
|
||||||
|
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||||
|
const [showReviewOverlay, setShowReviewOverlay] = useState(true);
|
||||||
|
const [selectedCommentId, setSelectedCommentId] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasLayout = useModuleLayout(canvasRef, [campaign.modules, reviews.comments.length, showReviewOverlay]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isExportingBrevo, setIsExportingBrevo] = useState(false);
|
const [isExportingBrevo, setIsExportingBrevo] = useState(false);
|
||||||
const [brevoUrl, setBrevoUrl] = useState<string | null>(null);
|
const [brevoUrl, setBrevoUrl] = useState<string | null>(null);
|
||||||
@@ -28,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);
|
||||||
|
|
||||||
@@ -42,19 +58,7 @@ 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);
|
||||||
@@ -335,7 +339,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);
|
||||||
@@ -345,7 +355,7 @@ 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') {
|
||||||
@@ -353,11 +363,24 @@ export default function Home() {
|
|||||||
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 (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);
|
||||||
@@ -410,10 +433,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([
|
||||||
@@ -437,7 +459,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}`);
|
||||||
@@ -592,6 +614,19 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Poloha štítkov predajní pre všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
||||||
|
const handleApplyStoresOffsetToAll = (x: number, y: number, size: number) => {
|
||||||
|
if (!confirm(`Použiť polohu (${x} / ${y}) a veľkosť (${size} px) štítkov pre všetky produkty v newsletteri?`)) return;
|
||||||
|
setCampaign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modules: prev.modules.map((m) =>
|
||||||
|
m.type === 'products_grid' || m.type === 'loyalty_club'
|
||||||
|
? { ...m, data: { ...m.data, products: (m.data.products || []).map((p: ProductItem) => ({ ...p, storesOffsetX: x, storesOffsetY: y, storesSize: size })) } }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Hromadná aplikácia cenovky na všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
// Hromadná aplikácia cenovky na všetky produkty v celej kampani (všetky sekcie + vernostné zľavy)
|
||||||
const handleApplyBadgeToAll = (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => {
|
const handleApplyBadgeToAll = (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => {
|
||||||
setCampaign((prev) => ({
|
setCampaign((prev) => ({
|
||||||
@@ -647,7 +682,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}
|
||||||
@@ -655,6 +690,8 @@ export default function Home() {
|
|||||||
brevoListingUrl="https://app.brevo.com/campaigns/listing"
|
brevoListingUrl="https://app.brevo.com/campaigns/listing"
|
||||||
onClearBrevoId={handleClearBrevoId}
|
onClearBrevoId={handleClearBrevoId}
|
||||||
onAddModule={handleAddModule}
|
onAddModule={handleAddModule}
|
||||||
|
onOpenReviews={() => setIsReviewOpen(true)}
|
||||||
|
reviewOpenCount={reviews.openCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 2. Hlavný pracovný priestor */}
|
{/* 2. Hlavný pracovný priestor */}
|
||||||
@@ -667,8 +704,12 @@ export default function Home() {
|
|||||||
backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)'
|
backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Zvýraznenie produktu otvoreného v paneli */}
|
||||||
|
{selectedProductId && (
|
||||||
|
<style>{`main [data-product-id="${selectedProductId.replace(/["\\]/g, '')}"] { outline: 3px solid #ef4444; outline-offset: 3px; }`}</style>
|
||||||
|
)}
|
||||||
{/* E-mail Container 1:1 k originálu */}
|
{/* E-mail Container 1:1 k originálu */}
|
||||||
<div className="w-full max-w-[600px] my-4">
|
<div ref={canvasRef} className="relative w-full max-w-[600px] h-fit my-4">
|
||||||
{campaign.modules.map((mod, idx) => {
|
{campaign.modules.map((mod, idx) => {
|
||||||
const isSelected = mod.id === selectedModuleId;
|
const isSelected = mod.id === selectedModuleId;
|
||||||
const isAnchored = isAnchoredModule(mod);
|
const isAnchored = isAnchoredModule(mod);
|
||||||
@@ -683,7 +724,17 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mod.id}
|
key={mod.id}
|
||||||
onClick={() => setSelectedModuleId(mod.id)}
|
data-module-id={mod.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Klik na kartu produktu otvorí v paneli práve tento produkt
|
||||||
|
const card = (e.target as HTMLElement).closest('[data-product-id]');
|
||||||
|
if (card) {
|
||||||
|
setSelectedProductId(card.getAttribute('data-product-id'));
|
||||||
|
} else if (mod.id !== selectedModuleId) {
|
||||||
|
setSelectedProductId(null);
|
||||||
|
}
|
||||||
|
setSelectedModuleId(mod.id);
|
||||||
|
}}
|
||||||
className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${
|
className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'ring-2 ring-offset-4 ring-offset-[#001a33] ring-red-500 shadow-2xl'
|
? 'ring-2 ring-offset-4 ring-offset-[#001a33] ring-red-500 shadow-2xl'
|
||||||
@@ -866,21 +917,63 @@ export default function Home() {
|
|||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: renderSingleModuleHtml(mod, campaign)
|
__html: renderSingleModuleHtml(mod, campaign)
|
||||||
}}
|
}}
|
||||||
className="pointer-events-none"
|
onClick={(e) => {
|
||||||
|
// odkazy v náhľade neotvárame, klik slúži na výber modulu / produktu
|
||||||
|
if ((e.target as HTMLElement).closest('a')) e.preventDefault();
|
||||||
|
}}
|
||||||
|
className="[&_[data-product-id]]:cursor-pointer"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Pripomienky zo zdieľaného odkazu */}
|
||||||
|
{showReviewOverlay && reviews.comments.length > 0 && (
|
||||||
|
<AnnotationLayer
|
||||||
|
layout={canvasLayout}
|
||||||
|
comments={reviews.comments}
|
||||||
|
selectedId={selectedCommentId}
|
||||||
|
onSelect={setSelectedCommentId}
|
||||||
|
renderActions={(c: ReviewComment) => (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => reviews.toggleDone(c)}
|
||||||
|
className={`text-[11px] font-bold px-2.5 py-1 rounded-lg ${
|
||||||
|
c.done ? 'bg-gray-100 text-gray-700' : 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.done ? 'Vrátiť ako neurobené' : '✓ Označiť ako hotové'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Zmazať túto pripomienku? Zmizne aj pre ostatných s odkazom.')) {
|
||||||
|
reviews.remove(c);
|
||||||
|
setSelectedCommentId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-[11px] font-bold px-2.5 py-1 rounded-lg text-red-700 bg-red-50 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
Zmazať
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
||||||
|
onSelectProduct={setSelectedProductId}
|
||||||
onUpdateModule={handleUpdateModule}
|
onUpdateModule={handleUpdateModule}
|
||||||
onApplyBadgeToAll={handleApplyBadgeToAll}
|
onApplyBadgeToAll={handleApplyBadgeToAll}
|
||||||
|
onApplyStoresOffsetToAll={handleApplyStoresOffsetToAll}
|
||||||
onUpdateAllProductSections={handleUpdateAllProductSections}
|
onUpdateAllProductSections={handleUpdateAllProductSections}
|
||||||
campaignValidity={getCampaignValidity(campaign)}
|
campaignValidity={getCampaignValidity(campaign)}
|
||||||
availableBadges={availableBadges}
|
availableBadges={availableBadges}
|
||||||
@@ -893,11 +986,35 @@ export default function Home() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pripomienky: odkaz a zoznam */}
|
||||||
|
<ReviewModal
|
||||||
|
isOpen={isReviewOpen}
|
||||||
|
onClose={() => setIsReviewOpen(false)}
|
||||||
|
hasCampaignId={!!campaign.id}
|
||||||
|
review={reviews.review}
|
||||||
|
comments={reviews.comments}
|
||||||
|
error={reviews.error}
|
||||||
|
busy={reviews.busy}
|
||||||
|
showOverlay={showReviewOverlay}
|
||||||
|
onToggleOverlay={() => setShowReviewOverlay(!showReviewOverlay)}
|
||||||
|
onCreate={reviews.create}
|
||||||
|
onUpdateSnapshot={reviews.updateSnapshot}
|
||||||
|
onToggleDone={reviews.toggleDone}
|
||||||
|
onDelete={reviews.remove}
|
||||||
|
onShowComment={(c) => {
|
||||||
|
setShowReviewOverlay(true);
|
||||||
|
setSelectedCommentId(c.id);
|
||||||
|
setIsReviewOpen(false);
|
||||||
|
document.querySelector(`[data-module-id="${c.module_id}"]`)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Modál Hromadného importu */}
|
{/* Modál Hromadného importu */}
|
||||||
<ProductImportModal
|
<ProductImportModal
|
||||||
isOpen={isImportOpen}
|
isOpen={isImportOpen}
|
||||||
onClose={() => setIsImportOpen(false)}
|
onClose={() => setIsImportOpen(false)}
|
||||||
onImportProducts={handleImportProducts}
|
onImportProducts={handleImportProducts}
|
||||||
|
availableBadges={availableBadges}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modál Editora cenoviek (SVG -> PNG) */}
|
{/* Modál Editora cenoviek (SVG -> PNG) */}
|
||||||
@@ -912,11 +1029,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) => {
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Náhľad newslettra na pripomienky (zdieľaný odkaz). Nič sa tu nedá upravovať – len komentovať,
|
||||||
|
// písať text priamo do newslettra a kresliť. Pripomienky sa ukladajú k odkazu, vidí ich každý s odkazom.
|
||||||
|
import React, { use, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { MessageCircle, Type, Pencil, MousePointer2, CheckCircle2, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
import { renderSingleModuleHtml } from '@/lib/emailRenderer';
|
||||||
|
import {
|
||||||
|
Review,
|
||||||
|
ReviewComment,
|
||||||
|
ReviewKind,
|
||||||
|
REVIEW_COLORS,
|
||||||
|
getReviewByToken,
|
||||||
|
listReviewComments,
|
||||||
|
addReviewComment,
|
||||||
|
deleteReviewComment
|
||||||
|
} from '@/lib/reviews';
|
||||||
|
import { AnnotationLayer, pointToModuleCoords, useModuleLayout } from '@/components/review/AnnotationLayer';
|
||||||
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
|
|
||||||
|
type Tool = 'view' | ReviewKind;
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
kind: 'comment' | 'text';
|
||||||
|
moduleId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTHOR_KEY = 'coop_review_author';
|
||||||
|
const POLL_MS = 10000;
|
||||||
|
|
||||||
|
const TOOLS: Array<{ id: Tool; label: string; icon: React.ElementType; hint: string }> = [
|
||||||
|
{ id: 'view', label: 'Prezerať', icon: MousePointer2, hint: 'Prezeranie a výber pripomienok' },
|
||||||
|
{ id: 'comment', label: 'Komentár', icon: MessageCircle, hint: 'Kliknite do newslettra a napíšte komentár' },
|
||||||
|
{ id: 'text', label: 'Text', icon: Type, hint: 'Kliknite do newslettra a napíšte text priamo do neho' },
|
||||||
|
{ id: 'drawing', label: 'Kresliť', icon: Pencil, hint: 'Ťahaním myšou kreslite do newslettra' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ReviewPage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = use(params);
|
||||||
|
const [review, setReview] = useState<Review | null>(null);
|
||||||
|
const [comments, setComments] = useState<ReviewComment[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tool, setTool] = useState<Tool>('comment');
|
||||||
|
const [color, setColor] = useState(REVIEW_COLORS[0]);
|
||||||
|
// meno sa pamätá v prehliadači (stránka sa vykreslí až po načítaní odkazu, takže bez rozdielu so serverom)
|
||||||
|
const [author, setAuthor] = useState(() => {
|
||||||
|
try {
|
||||||
|
return typeof window === 'undefined' ? '' : localStorage.getItem(AUTHOR_KEY) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [draftText, setDraftText] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [livePath, setLivePath] = useState<{ moduleId: string; points: Array<[number, number]> } | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const layout = useModuleLayout(containerRef, [review, comments.length]);
|
||||||
|
|
||||||
|
const refreshComments = useCallback(async (reviewId: string) => {
|
||||||
|
try {
|
||||||
|
setComments(await listReviewComments(reviewId));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Pripomienky sa nepodarilo načítať:', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await getReviewByToken(token);
|
||||||
|
if (!r) {
|
||||||
|
setError('Odkaz neexistuje alebo bol zrušený.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReview(r);
|
||||||
|
await refreshComments(r.id);
|
||||||
|
timer = setInterval(() => refreshComments(r.id), POLL_MS);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Náhľad sa nepodarilo načítať.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [token, refreshComments]);
|
||||||
|
|
||||||
|
const saveAuthor = (value: string) => {
|
||||||
|
setAuthor(value);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTHOR_KEY, value);
|
||||||
|
} catch {
|
||||||
|
// localStorage nemusí byť dostupný
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (data: { kind: ReviewKind; moduleId: string; x: number; y: number; text?: string; path?: Array<[number, number]> }) => {
|
||||||
|
if (!review) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await addReviewComment({
|
||||||
|
review_id: review.id,
|
||||||
|
kind: data.kind,
|
||||||
|
module_id: data.moduleId,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
path: data.path || null,
|
||||||
|
text: data.text || '',
|
||||||
|
color,
|
||||||
|
author: author.trim() || 'Anonym'
|
||||||
|
});
|
||||||
|
setComments((prev) => [...prev, created]);
|
||||||
|
if (data.kind !== 'drawing') setSelectedId(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Pripomienku sa nepodarilo uložiť.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zmazanie pripomienky (zmizne pre všetkých s odkazom aj v editore)
|
||||||
|
const removeComment = async (c: ReviewComment) => {
|
||||||
|
if (!confirm('Zmazať túto pripomienku? Zmizne aj pre ostatných s odkazom.')) return;
|
||||||
|
try {
|
||||||
|
await deleteReviewComment(c.id);
|
||||||
|
setComments((prev) => prev.filter((x) => x.id !== c.id));
|
||||||
|
if (selectedId === c.id) setSelectedId(null);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Pripomienku sa nepodarilo zmazať.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Klik do newslettra: komentár / text → okienko na napísanie
|
||||||
|
const onCanvasClick = (e: React.MouseEvent) => {
|
||||||
|
if (tool !== 'comment' && tool !== 'text') return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const coords = pointToModuleCoords(container, e.clientX, e.clientY);
|
||||||
|
if (!coords) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
setDraft({ kind: tool, ...coords, left: e.clientX - rect.left, top: e.clientY - rect.top });
|
||||||
|
setDraftText('');
|
||||||
|
setSelectedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitDraft = async () => {
|
||||||
|
if (!draft || !draftText.trim()) return;
|
||||||
|
await save({ kind: draft.kind, moduleId: draft.moduleId, x: draft.x, y: draft.y, text: draftText.trim() });
|
||||||
|
setDraft(null);
|
||||||
|
setDraftText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kreslenie: body sa ukotvia k modulu, v ktorom ťah začal
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (tool !== 'drawing' || !containerRef.current) return;
|
||||||
|
const start = pointToModuleCoords(containerRef.current, e.clientX, e.clientY);
|
||||||
|
if (!start) return;
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
setLivePath({ moduleId: start.moduleId, points: [[start.x, start.y]] });
|
||||||
|
};
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (!livePath || !containerRef.current) return;
|
||||||
|
const p = pointToModuleCoords(containerRef.current, e.clientX, e.clientY, livePath.moduleId);
|
||||||
|
if (p) setLivePath({ ...livePath, points: [...livePath.points, [p.x, p.y]] });
|
||||||
|
};
|
||||||
|
const onPointerUp = async () => {
|
||||||
|
if (!livePath) return;
|
||||||
|
const path = livePath;
|
||||||
|
setLivePath(null);
|
||||||
|
if (path.points.length < 2) return;
|
||||||
|
await save({ kind: 'drawing', moduleId: path.moduleId, x: path.points[0][0], y: path.points[0][1], path: path.points });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#001a33] text-white gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" /> Načítavam náhľad…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error || !review) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#001a33] p-6">
|
||||||
|
<p className="max-w-md text-center text-sm text-white/90 bg-white/10 rounded-xl p-5">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign: NewsletterCampaign = { id: '', status: 'draft', ...review.snapshot };
|
||||||
|
const openCount = comments.filter((c) => !c.done).length;
|
||||||
|
const livePreview = livePath && layout
|
||||||
|
? livePath.points.map(([x, y]) => `${x * layout.width},${(layout.tops[livePath.moduleId] ?? 0) + y}`).join(' ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col overflow-hidden bg-slate-950 font-sans">
|
||||||
|
{/* Horná lišta: nástroje */}
|
||||||
|
<header className="h-14 shrink-0 bg-white border-b border-gray-200 px-4 flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-[#e2001a] flex items-center justify-center text-white font-black text-[11px]">COOP</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-bold text-gray-900 truncate">{review.title || 'Newsletter'}</p>
|
||||||
|
<p className="text-[10px] text-gray-500">Náhľad na pripomienky · aktualizovaný {new Date(review.updated_at).toLocaleString('sk-SK')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-xl">
|
||||||
|
{TOOLS.map(({ id, label, icon: Icon, hint }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
title={hint}
|
||||||
|
onClick={() => {
|
||||||
|
setTool(id);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-bold transition-colors ${
|
||||||
|
tool === id ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{REVIEW_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-5 h-5 rounded-full border-2 ${color === c ? 'border-gray-900 scale-110' : 'border-white shadow'}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
title="Farba"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Newsletter */}
|
||||||
|
<main
|
||||||
|
className="flex-1 overflow-y-auto p-4 md:p-8 flex justify-center"
|
||||||
|
style={{ backgroundColor: '#001a33', backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)' }}
|
||||||
|
onClick={() => setSelectedId(null)}
|
||||||
|
>
|
||||||
|
<div ref={containerRef} className="relative w-full max-w-[600px] h-fit my-4 select-none">
|
||||||
|
{review.snapshot.modules.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
data-module-id={m.id}
|
||||||
|
className="pointer-events-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderSingleModuleHtml(m, campaign) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AnnotationLayer
|
||||||
|
layout={layout}
|
||||||
|
comments={comments}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
renderActions={(c) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeComment(c)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-bold px-2.5 py-1 rounded-lg text-red-700 bg-red-50 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" /> Zmazať
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Plocha na pridávanie pripomienok */}
|
||||||
|
{tool !== 'view' && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 z-20 ${tool === 'drawing' ? 'cursor-crosshair touch-none' : 'cursor-copy'}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCanvasClick(e);
|
||||||
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rozkreslená čiara */}
|
||||||
|
{livePath && (
|
||||||
|
<svg className="absolute inset-0 w-full h-full pointer-events-none z-40 overflow-visible">
|
||||||
|
<polyline points={livePreview} fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Okienko na napísanie komentára / textu */}
|
||||||
|
{draft && (
|
||||||
|
<div
|
||||||
|
className="absolute z-50 w-64 bg-white rounded-xl shadow-2xl border border-gray-200 p-2.5"
|
||||||
|
style={{ left: Math.min(draft.left, (layout?.width || 600) - 256), top: draft.top + 6 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
rows={3}
|
||||||
|
value={draftText}
|
||||||
|
onChange={(e) => setDraftText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitDraft();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setDraft(null);
|
||||||
|
}}
|
||||||
|
placeholder={draft.kind === 'comment' ? 'Napíšte komentár…' : 'Text, ktorý sa zobrazí v newslettri…'}
|
||||||
|
className="w-full text-xs border border-gray-300 rounded-lg p-2 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
|
<span className="text-[10px] text-gray-400">Enter = uložiť, Shift+Enter = nový riadok</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button type="button" onClick={() => setDraft(null)} className="text-[11px] font-bold text-gray-500 px-2 py-1">
|
||||||
|
Zrušiť
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !draftText.trim()}
|
||||||
|
onClick={submitDraft}
|
||||||
|
className="text-[11px] font-bold text-white bg-[#e2001a] disabled:opacity-40 px-2.5 py-1 rounded-lg"
|
||||||
|
>
|
||||||
|
Uložiť
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Zoznam pripomienok */}
|
||||||
|
<aside className="w-80 shrink-0 bg-white border-l border-gray-200 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-100 space-y-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wider">Vaše meno</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={author}
|
||||||
|
onChange={(e) => saveAuthor(e.target.value)}
|
||||||
|
placeholder="Meno, aby bolo jasné, kto pripomienku napísal"
|
||||||
|
className={`mt-1 w-full text-xs border rounded-lg p-2 ${author.trim() ? 'border-gray-300' : 'border-amber-400 bg-amber-50'}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{TOOLS.find((t) => t.id === tool)?.hint}. Pripomienky uvidí každý, kto má tento odkaz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 text-[11px] font-bold text-gray-700 flex items-center justify-between">
|
||||||
|
<span>Pripomienky ({comments.length})</span>
|
||||||
|
<span className="text-gray-400 font-medium">{openCount} otvorených</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-1.5">
|
||||||
|
{comments.length === 0 && <p className="text-xs text-gray-400 px-1 py-6 text-center">Zatiaľ žiadne pripomienky.</p>}
|
||||||
|
{comments.map((c) => {
|
||||||
|
const number = comments.filter((x) => x.kind === 'comment').indexOf(c) + 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setSelectedId(c.id === selectedId ? null : c.id)}
|
||||||
|
className={`group w-full text-left rounded-lg border p-2 transition-colors cursor-pointer ${
|
||||||
|
c.id === selectedId ? 'border-gray-900 bg-gray-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
|
} ${c.done ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-4 h-4 rounded-full text-[9px] font-black text-white flex items-center justify-center shrink-0" style={{ backgroundColor: c.color }}>
|
||||||
|
{c.kind === 'comment' ? number : c.kind === 'text' ? 'T' : '✎'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
|
||||||
|
{c.done && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-600 shrink-0" />}
|
||||||
|
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{new Date(c.created_at).toLocaleDateString('sk-SK')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeComment(c);
|
||||||
|
}}
|
||||||
|
className="text-gray-300 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Zmazať pripomienku"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-700 whitespace-pre-wrap line-clamp-3">
|
||||||
|
{c.text || (c.kind === 'drawing' ? 'Kresba' : '')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -15,12 +15,16 @@ 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, 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)
|
||||||
onOpenBadgeManager?: (productId: string) => void;
|
onOpenBadgeManager?: (productId: string) => void;
|
||||||
onOpenImport?: () => void;
|
onOpenImport?: () => void;
|
||||||
availableBadges?: BadgeTemplate[];
|
availableBadges?: BadgeTemplate[];
|
||||||
|
// Otvorený produkt v harmonike (klik na produkt v náhľade ho otvorí)
|
||||||
|
selectedProductId?: string | null;
|
||||||
|
onSelectProduct?: (productId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jednoduchý HTML editor: vlastné HTML nahradí modul vygenerovaný z formulára
|
// Jednoduchý HTML editor: vlastné HTML nahradí modul vygenerovaný z formulára
|
||||||
@@ -90,11 +94,14 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
module,
|
module,
|
||||||
onUpdateModule,
|
onUpdateModule,
|
||||||
onApplyBadgeToAll,
|
onApplyBadgeToAll,
|
||||||
|
onApplyStoresOffsetToAll,
|
||||||
onUpdateAllProductSections,
|
onUpdateAllProductSections,
|
||||||
campaignValidity = '',
|
campaignValidity = '',
|
||||||
onOpenBadgeManager,
|
onOpenBadgeManager,
|
||||||
onOpenImport,
|
onOpenImport,
|
||||||
availableBadges = []
|
availableBadges = [],
|
||||||
|
selectedProductId = null,
|
||||||
|
onSelectProduct
|
||||||
}) => {
|
}) => {
|
||||||
if (!module) {
|
if (!module) {
|
||||||
return (
|
return (
|
||||||
@@ -422,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>
|
||||||
@@ -546,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();
|
||||||
@@ -660,13 +694,15 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
|||||||
product={p}
|
product={p}
|
||||||
index={pIdx}
|
index={pIdx}
|
||||||
variant="standard"
|
variant="standard"
|
||||||
defaultOpen={pIdx === 0}
|
open={selectedProductId === 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((_: ProductItem, i: number) => i !== pIdx) })}
|
onDelete={() => updateData({ products: (module.data.products || []).filter((_: ProductItem, 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, 'standard') : undefined}
|
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'standard') : undefined}
|
||||||
|
onApplyStoresOffsetToAll={onApplyStoresOffsetToAll}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -756,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();
|
||||||
@@ -834,23 +870,56 @@ 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>
|
||||||
|
<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}
|
||||||
product={p}
|
product={p}
|
||||||
index={pIdx}
|
index={pIdx}
|
||||||
variant="loyalty"
|
variant="loyalty"
|
||||||
defaultOpen={pIdx === 0}
|
open={selectedProductId === 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}
|
||||||
|
onApplyStoresOffsetToAll={onApplyStoresOffsetToAll}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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, { useState } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { BadgeTemplate, CardProduct } from '@/types/newsletter';
|
import { 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_Y, badgeWidthFor, productCardAlt } from '@/lib/productCard';
|
import { 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>;
|
||||||
|
|
||||||
@@ -31,12 +25,14 @@ interface ProductEditorProps {
|
|||||||
index: number;
|
index: number;
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
availableBadges: BadgeTemplate[];
|
availableBadges: BadgeTemplate[];
|
||||||
defaultOpen?: boolean;
|
open: boolean; // harmonika: otvorený je vždy len jeden produkt
|
||||||
|
onToggle: () => void;
|
||||||
onChange: (patch: Patch) => void;
|
onChange: (patch: Patch) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onOpenBadgeEditor?: () => void;
|
onOpenBadgeEditor?: () => void;
|
||||||
onApplyBadgeToSection?: () => void;
|
onApplyBadgeToSection?: () => void;
|
||||||
onApplyBadgeToAll?: () => void;
|
onApplyBadgeToAll?: () => void;
|
||||||
|
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void; // poloha a veľkosť štítkov pre všetky produkty
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
|
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
|
||||||
@@ -103,6 +99,135 @@ const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
@@ -111,22 +236,29 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
index,
|
index,
|
||||||
variant,
|
variant,
|
||||||
availableBadges,
|
availableBadges,
|
||||||
defaultOpen = false,
|
open,
|
||||||
|
onToggle,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpenBadgeEditor,
|
onOpenBadgeEditor,
|
||||||
onApplyBadgeToSection,
|
onApplyBadgeToSection,
|
||||||
onApplyBadgeToAll
|
onApplyBadgeToAll,
|
||||||
|
onApplyStoresOffsetToAll
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
// Po otvorení (aj klikom na produkt v náhľade) posunieme panel na tento produkt
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) rootRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
}, [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 ?? 1;
|
||||||
const offsetX = p.imageOffsetX || 0;
|
const offsetX = p.imageOffsetX || 0;
|
||||||
const offsetY = p.imageOffsetY || 0;
|
const offsetY = p.imageOffsetY || 0;
|
||||||
const storesX = p.storesOffsetX || 0;
|
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);
|
||||||
|
|
||||||
@@ -143,7 +275,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`}>
|
||||||
@@ -153,9 +285,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">
|
||||||
@@ -166,10 +300,13 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 overflow-hidden">
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={`rounded-xl border bg-gray-50 overflow-hidden scroll-mt-2 ${open ? 'border-red-400 ring-1 ring-red-200' : 'border-gray-200'}`}
|
||||||
|
>
|
||||||
{/* Hlavička produktu – klik rozbalí / zbalí */}
|
{/* Hlavička produktu – klik rozbalí / zbalí */}
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2 bg-white">
|
<div className="flex items-center gap-2 px-2.5 py-2 bg-white">
|
||||||
<button type="button" onClick={() => setOpen(!open)} className="flex-1 flex items-center gap-2 min-w-0 text-left">
|
<button type="button" onClick={onToggle} className="flex-1 flex items-center gap-2 min-w-0 text-left">
|
||||||
<span className="w-8 h-8 rounded-md bg-gray-100 border border-gray-200 shrink-0 overflow-hidden flex items-center justify-center">
|
<span className="w-8 h-8 rounded-md bg-gray-100 border border-gray-200 shrink-0 overflow-hidden flex items-center justify-center">
|
||||||
{p.imageUrl && (
|
{p.imageUrl && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -244,6 +381,37 @@ 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 */}
|
||||||
@@ -285,6 +453,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')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -315,8 +484,8 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
|||||||
{/* DOSTUPNOSŤ */}
|
{/* DOSTUPNOSŤ */}
|
||||||
<Block
|
<Block
|
||||||
title="Dostupnosť"
|
title="Dostupnosť"
|
||||||
action={hasStores && (storesX !== 0 || 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">
|
||||||
@@ -346,10 +515,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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onApplyStoresOffsetToAll(storesX, storesY, storesSize)}
|
||||||
|
className="flex items-center gap-1 text-[10px] font-bold text-red-700 bg-red-50 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" /> Polohu a veľkosť štítkov použiť na všetky produkty
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Block>
|
</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,20 +3,26 @@
|
|||||||
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, 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ť
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductImportModal: React.FC<ImportModalProps> = ({
|
export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onImportProducts
|
onImportProducts,
|
||||||
|
availableBadges = []
|
||||||
}) => {
|
}) => {
|
||||||
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');
|
||||||
@@ -32,6 +38,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 +46,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 +67,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 +151,44 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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(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 +207,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 +226,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 +264,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=".csv,.json,.xml,.zip"
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="file-upload-input"
|
id="file-upload-input"
|
||||||
@@ -195,12 +272,89 @@ 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 : 'Kliknite sem a vyberte ZIP s podkladmi, CSV, JSON alebo XML'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
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 ? { 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">
|
||||||
|
<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">
|
||||||
|
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 +422,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 +463,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 +493,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
Check
|
Check,
|
||||||
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NewsletterCampaign } from '@/types/newsletter';
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||||
@@ -32,6 +33,8 @@ interface TopBarProps {
|
|||||||
brevoListingUrl?: string | null;
|
brevoListingUrl?: string | null;
|
||||||
onClearBrevoId?: () => void;
|
onClearBrevoId?: () => void;
|
||||||
onAddModule: (type: any) => void;
|
onAddModule: (type: any) => void;
|
||||||
|
onOpenReviews?: () => void;
|
||||||
|
reviewOpenCount?: number; // počet otvorených pripomienok
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopBar: React.FC<TopBarProps> = ({
|
export const TopBar: React.FC<TopBarProps> = ({
|
||||||
@@ -45,7 +48,9 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
onExportBrevo,
|
onExportBrevo,
|
||||||
isExportingBrevo,
|
isExportingBrevo,
|
||||||
onClearBrevoId,
|
onClearBrevoId,
|
||||||
onAddModule
|
onAddModule,
|
||||||
|
onOpenReviews,
|
||||||
|
reviewOpenCount = 0
|
||||||
}) => {
|
}) => {
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [isVerifyingBrevo, setIsVerifyingBrevo] = useState(false);
|
const [isVerifyingBrevo, setIsVerifyingBrevo] = useState(false);
|
||||||
@@ -137,7 +142,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -173,6 +178,23 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
|
|
||||||
{/* Pravá časť: Uložiť, Duplikovať, Export do ZIP a Inteligentné Brevo akcie */}
|
{/* Pravá časť: Uložiť, Duplikovať, Export do ZIP a Inteligentné Brevo akcie */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Pripomienky cez zdieľaný odkaz */}
|
||||||
|
{onOpenReviews && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenReviews}
|
||||||
|
className="relative flex items-center gap-1.5 text-xs font-bold text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 px-3 py-1.5 rounded-xl transition-all shadow-xs"
|
||||||
|
title="Odkaz na pripomienky a zoznam pripomienok"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5 text-gray-600" />
|
||||||
|
<span className="hidden md:inline">Pripomienky</span>
|
||||||
|
{reviewOpenCount > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-[#e2001a] text-white text-[10px] font-black flex items-center justify-center">
|
||||||
|
{reviewOpenCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tlačidlo manuálneho uloženia */}
|
{/* Tlačidlo manuálneho uloženia */}
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Vrstva pripomienok nad newsletterom (stránka s odkazom aj editor).
|
||||||
|
// Kontajner musí mať position: relative a moduly v ňom atribút data-module-id.
|
||||||
|
import React, { useLayoutEffect, useState } from 'react';
|
||||||
|
import { ReviewComment } from '@/lib/reviews';
|
||||||
|
|
||||||
|
interface ModuleLayout {
|
||||||
|
width: number;
|
||||||
|
tops: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepočet súradníc kliknutia na súradnice ukotvené k modulu
|
||||||
|
export function pointToModuleCoords(
|
||||||
|
container: HTMLElement,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
moduleId?: string
|
||||||
|
): { moduleId: string; x: number; y: number } | null {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const modules = Array.from(container.querySelectorAll<HTMLElement>('[data-module-id]'));
|
||||||
|
const target = moduleId
|
||||||
|
? modules.find((m) => m.dataset.moduleId === moduleId)
|
||||||
|
: modules.find((m) => {
|
||||||
|
const r = m.getBoundingClientRect();
|
||||||
|
return clientY >= r.top && clientY <= r.bottom;
|
||||||
|
}) || modules[modules.length - 1];
|
||||||
|
if (!target || !rect.width) return null;
|
||||||
|
const top = target.getBoundingClientRect().top;
|
||||||
|
return {
|
||||||
|
moduleId: target.dataset.moduleId!,
|
||||||
|
x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
|
||||||
|
y: Math.round(clientY - top)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poloha modulov v kontajneri; prepočíta sa pri zmene veľkosti a obsahu
|
||||||
|
export function useModuleLayout(containerRef: React.RefObject<HTMLElement | null>, deps: unknown[]): ModuleLayout | null {
|
||||||
|
const [layout, setLayout] = useState<ModuleLayout | null>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const cr = el.getBoundingClientRect();
|
||||||
|
const tops: Record<string, number> = {};
|
||||||
|
el.querySelectorAll<HTMLElement>('[data-module-id]').forEach((m) => {
|
||||||
|
tops[m.dataset.moduleId!] = m.getBoundingClientRect().top - cr.top;
|
||||||
|
});
|
||||||
|
setLayout({ width: cr.width, tops });
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
// obrázky sa načítavajú postupne a menia výšku modulov
|
||||||
|
el.addEventListener('load', measure, true);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
el.removeEventListener('load', measure, true);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [containerRef, ...deps]);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnotationLayerProps {
|
||||||
|
layout: ModuleLayout | null;
|
||||||
|
comments: ReviewComment[];
|
||||||
|
showDone?: boolean;
|
||||||
|
selectedId?: string | null;
|
||||||
|
onSelect?: (id: string | null) => void;
|
||||||
|
// Akcie v bubline vybranej pripomienky (napr. Hotovo v editore)
|
||||||
|
renderActions?: (c: ReviewComment) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||||
|
layout,
|
||||||
|
comments,
|
||||||
|
showDone = true,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
renderActions
|
||||||
|
}) => {
|
||||||
|
if (!layout) return null;
|
||||||
|
const visible = comments.filter((c) => (showDone || !c.done) && layout.tops[c.module_id] !== undefined);
|
||||||
|
const pos = (moduleId: string, x: number, y: number) => ({ left: x * layout.width, top: layout.tops[moduleId] + y });
|
||||||
|
const commentNumbers = new Map(comments.filter((c) => c.kind === 'comment').map((c, i) => [c.id, i + 1]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none z-30">
|
||||||
|
{/* Kresby */}
|
||||||
|
<svg className="absolute inset-0 w-full h-full overflow-visible">
|
||||||
|
{visible
|
||||||
|
.filter((c) => c.kind === 'drawing' && c.path && c.path.length > 1)
|
||||||
|
.map((c) => (
|
||||||
|
<polyline
|
||||||
|
key={c.id}
|
||||||
|
points={c.path!.map(([x, y]) => {
|
||||||
|
const p = pos(c.module_id, x, y);
|
||||||
|
return `${p.left},${p.top}`;
|
||||||
|
}).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke={c.color}
|
||||||
|
strokeWidth={selectedId === c.id ? 5 : 3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity={c.done ? 0.3 : 0.9}
|
||||||
|
className="pointer-events-auto cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect?.(selectedId === c.id ? null : c.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Komentáre (špendlíky) a texty */}
|
||||||
|
{visible
|
||||||
|
.filter((c) => c.kind !== 'drawing')
|
||||||
|
.map((c) => {
|
||||||
|
const p = pos(c.module_id, c.x, c.y);
|
||||||
|
const isSelected = selectedId === c.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="absolute pointer-events-auto"
|
||||||
|
style={{ left: p.left, top: p.top, zIndex: isSelected ? 40 : 30 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect?.(isSelected ? null : c.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.kind === 'comment' ? (
|
||||||
|
<div
|
||||||
|
className={`-translate-x-1/2 -translate-y-full w-7 h-7 rounded-full rounded-bl-none rotate-[-45deg] flex items-center justify-center shadow-lg border-2 border-white cursor-pointer ${c.done ? 'opacity-40' : ''} ${isSelected ? 'scale-125' : ''}`}
|
||||||
|
style={{ backgroundColor: c.color }}
|
||||||
|
>
|
||||||
|
<span className="rotate-45 text-[11px] font-black text-white">{c.done ? '✓' : commentNumbers.get(c.id)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`-translate-y-1/2 max-w-[260px] px-1.5 py-0.5 rounded bg-white/85 text-[13px] font-bold leading-tight whitespace-pre-wrap shadow cursor-pointer ${c.done ? 'opacity-40 line-through' : ''} ${isSelected ? 'ring-2 ring-offset-1' : ''}`}
|
||||||
|
style={{ color: c.color }}
|
||||||
|
>
|
||||||
|
{c.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Bublina vybranej pripomienky */}
|
||||||
|
{(() => {
|
||||||
|
const c = visible.find((v) => v.id === selectedId);
|
||||||
|
if (!c || c.kind === 'text') {
|
||||||
|
return c && renderActions ? (
|
||||||
|
<div className="absolute pointer-events-auto" style={{ left: pos(c.module_id, c.x, c.y).left, top: pos(c.module_id, c.x, c.y).top + 14 }}>
|
||||||
|
<div className="flex gap-1 bg-white rounded-lg shadow-xl border border-gray-200 p-1">{renderActions(c)}</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
const anchor = c.kind === 'drawing' && c.path?.length ? c.path[c.path.length - 1] : [c.x, c.y];
|
||||||
|
const p = pos(c.module_id, anchor[0], anchor[1]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-auto w-64 bg-white rounded-xl shadow-2xl border border-gray-200 p-3 text-left"
|
||||||
|
style={{ left: Math.min(p.left + 12, layout.width - 256), top: p.top + 8, zIndex: 50 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
|
||||||
|
<span className="text-[10px] text-gray-400 shrink-0">{new Date(c.created_at).toLocaleString('sk-SK')}</span>
|
||||||
|
</div>
|
||||||
|
{c.text ? (
|
||||||
|
<p className="text-xs text-gray-700 whitespace-pre-wrap">{c.text}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-400 italic">{c.kind === 'drawing' ? 'Kresba bez poznámky' : ''}</p>
|
||||||
|
)}
|
||||||
|
{c.done && <p className="mt-1 text-[10px] font-bold text-emerald-600">✓ Hotovo</p>}
|
||||||
|
{renderActions && <div className="mt-2 flex gap-1.5">{renderActions(c)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Okno v editore: odkaz na pripomienky (vytvoriť / kopírovať / aktualizovať náhľad) a zoznam pripomienok
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Link2, Copy, Check, RefreshCw, ExternalLink, Trash2, CheckCircle2, Circle, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { Review, ReviewComment, reviewUrl } from '@/lib/reviews';
|
||||||
|
|
||||||
|
interface ReviewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
hasCampaignId: boolean;
|
||||||
|
review: Review | null;
|
||||||
|
comments: ReviewComment[];
|
||||||
|
error: string | null;
|
||||||
|
busy: boolean;
|
||||||
|
showOverlay: boolean;
|
||||||
|
onToggleOverlay: () => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onUpdateSnapshot: () => void;
|
||||||
|
onToggleDone: (c: ReviewComment) => void;
|
||||||
|
onDelete: (c: ReviewComment) => void;
|
||||||
|
onShowComment: (c: ReviewComment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewModal: React.FC<ReviewModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
hasCampaignId,
|
||||||
|
review,
|
||||||
|
comments,
|
||||||
|
error,
|
||||||
|
busy,
|
||||||
|
showOverlay,
|
||||||
|
onToggleOverlay,
|
||||||
|
onCreate,
|
||||||
|
onUpdateSnapshot,
|
||||||
|
onToggleDone,
|
||||||
|
onDelete,
|
||||||
|
onShowComment
|
||||||
|
}) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [hideDone, setHideDone] = useState(false);
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const url = review ? reviewUrl(review.token) : '';
|
||||||
|
const visible = comments.filter((c) => !hideDone || !c.done);
|
||||||
|
const commentNumber = (c: ReviewComment) => comments.filter((x) => x.kind === 'comment').indexOf(c) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-gray-900">Pripomienky k newslettru</h2>
|
||||||
|
<p className="text-[11px] text-gray-500">Pošlite odkaz – v náhľade sa dá komentovať, písať a kresliť, nič sa nedá upraviť.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-700 p-1">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4 overflow-y-auto">
|
||||||
|
{error && <p className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg p-2.5">{error}</p>}
|
||||||
|
|
||||||
|
{!hasCampaignId ? (
|
||||||
|
<p className="text-xs text-gray-500">Kampaň sa ešte ukladá, skúste to o chvíľu.</p>
|
||||||
|
) : !review ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onCreate}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-[#e2001a] hover:bg-red-700 disabled:opacity-50 text-white text-sm font-bold"
|
||||||
|
>
|
||||||
|
<Link2 className="w-4 h-4" /> Vytvoriť odkaz na pripomienky
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input readOnly value={url} onFocus={(e) => e.target.select()} className="flex-1 text-xs font-mono border border-gray-300 rounded-lg px-2 py-2 bg-gray-50" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-2.5 rounded-lg bg-gray-900 text-white text-xs font-bold"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
{copied ? 'Skopírované' : 'Kopírovať'}
|
||||||
|
</button>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" className="flex items-center px-2.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50" title="Otvoriť náhľad">
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
Náhľad v odkaze je z {new Date(review.updated_at).toLocaleString('sk-SK')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onUpdateSnapshot}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-bold text-gray-700 border border-gray-300 hover:bg-gray-50 disabled:opacity-50 px-2 py-1 rounded-lg"
|
||||||
|
title="Recenzenti uvidia aktuálnu verziu newslettra, pripomienky ostanú"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${busy ? 'animate-spin' : ''}`} /> Aktualizovať náhľad v odkaze
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{review && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-gray-800">
|
||||||
|
Pripomienky ({comments.length}) · {comments.filter((c) => !c.done).length} otvorených
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHideDone(!hideDone)}
|
||||||
|
className="text-[10px] font-bold text-gray-500 hover:text-gray-900 px-1.5 py-1"
|
||||||
|
>
|
||||||
|
{hideDone ? 'Zobraziť hotové' : 'Skryť hotové'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleOverlay}
|
||||||
|
className="flex items-center gap-1 text-[10px] font-bold text-gray-700 border border-gray-300 rounded-lg px-1.5 py-1 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{showOverlay ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||||
|
{showOverlay ? 'Skryť v náhľade' : 'Zobraziť v náhľade'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visible.length === 0 && <p className="text-xs text-gray-400 text-center py-6">Zatiaľ žiadne pripomienky.</p>}
|
||||||
|
{visible.map((c) => (
|
||||||
|
<div key={c.id} className={`flex gap-2 rounded-lg border p-2 ${c.done ? 'border-gray-100 bg-gray-50 opacity-70' : 'border-gray-200'}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleDone(c)}
|
||||||
|
className={c.done ? 'text-emerald-600' : 'text-gray-300 hover:text-emerald-600'}
|
||||||
|
title={c.done ? 'Označiť ako neurobené' : 'Označiť ako hotové'}
|
||||||
|
>
|
||||||
|
{c.done ? <CheckCircle2 className="w-5 h-5" /> : <Circle className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onShowComment(c)} className="flex-1 min-w-0 text-left" title="Ukázať v náhľade">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-4 h-4 rounded-full text-[9px] font-black text-white flex items-center justify-center shrink-0" style={{ backgroundColor: c.color }}>
|
||||||
|
{c.kind === 'comment' ? commentNumber(c) : c.kind === 'text' ? 'T' : '✎'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{new Date(c.created_at).toLocaleString('sk-SK')}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`mt-0.5 text-xs text-gray-700 whitespace-pre-wrap ${c.done ? 'line-through' : ''}`}>
|
||||||
|
{c.text || (c.kind === 'drawing' ? 'Kresba' : '')}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => confirm('Zmazať túto pripomienku?') && onDelete(c)}
|
||||||
|
className="text-gray-300 hover:text-red-600 self-start p-0.5"
|
||||||
|
title="Zmazať"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Pripomienky aktuálnej kampane v editore: odkaz, zoznam pripomienok (pravidelne obnovovaný), akcie
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
|
import {
|
||||||
|
Review,
|
||||||
|
ReviewComment,
|
||||||
|
createReview,
|
||||||
|
deleteReviewComment,
|
||||||
|
getReviewForTemplate,
|
||||||
|
listReviewComments,
|
||||||
|
setReviewCommentDone,
|
||||||
|
updateReviewSnapshot
|
||||||
|
} from '@/lib/reviews';
|
||||||
|
|
||||||
|
const POLL_MS = 20000;
|
||||||
|
|
||||||
|
export function useCampaignReview(campaign: NewsletterCampaign) {
|
||||||
|
const [review, setReview] = useState<Review | null>(null);
|
||||||
|
const [comments, setComments] = useState<ReviewComment[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const templateId = campaign.id;
|
||||||
|
|
||||||
|
const refresh = useCallback(async (reviewId: string) => {
|
||||||
|
try {
|
||||||
|
setComments(await listReviewComments(reviewId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Odkaz kampane sa načíta pri otvorení kampane; pripomienky sa obnovujú pravidelne
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
if (!templateId) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await getReviewForTemplate(templateId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setReview(r);
|
||||||
|
setComments([]);
|
||||||
|
setError(null);
|
||||||
|
if (!r) return;
|
||||||
|
await refresh(r.id);
|
||||||
|
timer = setInterval(() => refresh(r.id), POLL_MS);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [templateId, refresh]);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<void>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
review,
|
||||||
|
comments,
|
||||||
|
error,
|
||||||
|
busy,
|
||||||
|
openCount: comments.filter((c) => !c.done).length,
|
||||||
|
create: () => run(async () => setReview(await createReview(campaign))),
|
||||||
|
updateSnapshot: () =>
|
||||||
|
run(async () => {
|
||||||
|
if (review) setReview(await updateReviewSnapshot(review.id, campaign));
|
||||||
|
}),
|
||||||
|
toggleDone: (c: ReviewComment) =>
|
||||||
|
run(async () => {
|
||||||
|
await setReviewCommentDone(c.id, !c.done);
|
||||||
|
setComments((prev) => prev.map((x) => (x.id === c.id ? { ...x, done: !c.done } : x)));
|
||||||
|
}),
|
||||||
|
remove: (c: ReviewComment) =>
|
||||||
|
run(async () => {
|
||||||
|
await deleteReviewComment(c.id);
|
||||||
|
setComments((prev) => prev.filter((x) => x.id !== c.id));
|
||||||
|
}),
|
||||||
|
refresh: () => review && refresh(review.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
+96
-16
@@ -1,8 +1,66 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
| 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +69,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 +91,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 +155,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 +184,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 +261,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,311 @@
|
|||||||
|
// 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, 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
|
||||||
|
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)'),
|
||||||
|
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)),
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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] } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
+153
-23
@@ -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 { 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,7 +29,11 @@ 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)
|
||||||
}
|
}
|
||||||
export const DEFAULT_STORES_OFFSET_Y = 2;
|
// Predvolená poloha štítkov voči pravému hornému rohu pozadia cenovky
|
||||||
|
export const DEFAULT_STORES_OFFSET_X = -15;
|
||||||
|
// 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 = 5;
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -201,9 +205,10 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
|||||||
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 || 0, 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;
|
||||||
@@ -216,7 +221,91 @@ 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
|
||||||
|
// k pravému hornému rohu samotného pozadia, preto jeho hranice odmeriame v prehliadači (getBBox).
|
||||||
|
// Výsledok je podiel rozmerov viewBoxu; na serveri (bez DOM) sú okraje 0.
|
||||||
|
interface BadgeBgBox {
|
||||||
|
top: number; // okraj zhora / výška
|
||||||
|
right: number; // okraj sprava / šírka
|
||||||
|
aspect: number; // výška / šírka SVG
|
||||||
|
}
|
||||||
|
const badgeBgCache = new Map<string, BadgeBgBox>();
|
||||||
|
|
||||||
|
function badgeBackgroundBox(t: BadgeTemplate): BadgeBgBox | null {
|
||||||
|
if (!t.svgRaw || typeof document === 'undefined') return null;
|
||||||
|
const cached = badgeBgCache.get(t.svgRaw);
|
||||||
|
if (cached) return cached;
|
||||||
|
let box: BadgeBgBox | null = null;
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.style.cssText = 'position: absolute; left: -10000px; top: 0; visibility: hidden;';
|
||||||
|
try {
|
||||||
|
holder.innerHTML = renderCleanBadgeSvg({ ...t, layersConfig: [] }, { priceMain: '', priceSub: '' }, 200);
|
||||||
|
document.body.appendChild(holder);
|
||||||
|
const svg = holder.querySelector('svg');
|
||||||
|
const vb = svg?.viewBox.baseVal;
|
||||||
|
const bb = svg?.getBBox();
|
||||||
|
if (vb && bb && vb.width && vb.height && bb.width && bb.height) {
|
||||||
|
box = {
|
||||||
|
top: Math.max(0, (bb.y - vb.y) / vb.height),
|
||||||
|
right: Math.max(0, (vb.x + vb.width - (bb.x + bb.width)) / vb.width),
|
||||||
|
aspect: vb.height / vb.width
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
box = null;
|
||||||
|
} finally {
|
||||||
|
holder.remove();
|
||||||
|
}
|
||||||
|
if (box) badgeBgCache.set(t.svgRaw, box);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): string {
|
function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): string {
|
||||||
const height = cardVisualHeight(ctx.cardWidth);
|
const height = cardVisualHeight(ctx.cardWidth);
|
||||||
const bg = p.cardBgColor || ctx.cardBg;
|
const bg = p.cardBgColor || ctx.cardBg;
|
||||||
@@ -224,19 +313,35 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
|
|||||||
const scale = p.imageScale ?? 1;
|
const scale = p.imageScale ?? 1;
|
||||||
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 || 0);
|
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);
|
||||||
|
// Pravý horný roh viditeľného pozadia cenovky (pri PNG a predvolenej cenovke = roh cenovky)
|
||||||
|
const bgBox = p.badgeCustomTemplate && !p.customBadgePngUrl ? badgeBackgroundBox(p.badgeCustomTemplate) : null;
|
||||||
|
const insetTop = bgBox ? Math.round(bgBox.top * bw * bgBox.aspect) : 0;
|
||||||
|
const insetRight = bgBox ? Math.round(bgBox.right * bw) : 0;
|
||||||
return `
|
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: ${-sx}px; bottom: calc(100% + ${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)}
|
||||||
</div>
|
</div>
|
||||||
</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(' ');
|
||||||
@@ -253,9 +358,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');
|
||||||
@@ -288,10 +395,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) : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +409,7 @@ export function renderProductCard(p: CardProduct, ctx: CardContext): string {
|
|||||||
const key = cardVisualKey(p, ctx);
|
const key = cardVisualKey(p, ctx);
|
||||||
const visual = ctx.mode === 'preview' ? renderVisualPreview(p, ctx, key) : renderVisualExport(p, ctx, key);
|
const visual = ctx.mode === 'preview' ? renderVisualPreview(p, ctx, key) : renderVisualExport(p, ctx, key);
|
||||||
return `
|
return `
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product ${colorClass}" bgcolor="${bg}" style="background-color: ${bg}; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); overflow: hidden; height: 100%;">
|
<table ${ctx.mode === 'preview' ? `data-product-id="${escapeHtml(p.id)}" ` : ''}role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" height="100%" class="glass-product ${colorClass}" bgcolor="${bg}" style="background-color: ${bg}; border-radius: 16px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); overflow: hidden; height: 100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 0; line-height: 0; font-size: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">
|
<td style="padding: 0; line-height: 0; font-size: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">
|
||||||
${visual}
|
${visual}
|
||||||
@@ -316,23 +423,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,131 @@
|
|||||||
|
// Pripomienky k newslettru cez zdieľaný odkaz (/review/<token>)
|
||||||
|
// Odkaz nesie snímku kampane; pripomienky (komentár, text, kresba) sú ukotvené k modulu:
|
||||||
|
// x = podiel šírky e-mailu (0–1), y = px od vrchu modulu, kresba = body [x, y] v rovnakých súradniciach.
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
import { NewsletterCampaign } from '@/types/newsletter';
|
||||||
|
|
||||||
|
export type ReviewKind = 'comment' | 'text' | 'drawing';
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
template_id: string | null;
|
||||||
|
title: string;
|
||||||
|
snapshot: Pick<NewsletterCampaign, 'title' | 'preheader' | 'modules'>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewComment {
|
||||||
|
id: string;
|
||||||
|
review_id: string;
|
||||||
|
kind: ReviewKind;
|
||||||
|
module_id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
path: Array<[number, number]> | null;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
author: string;
|
||||||
|
done: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewReviewComment = Omit<ReviewComment, 'id' | 'done' | 'created_at'>;
|
||||||
|
|
||||||
|
export const REVIEW_COLORS = ['#e2001a', '#003399', '#16a34a', '#111827'];
|
||||||
|
|
||||||
|
// Chýbajúce tabuľky (migrácia nebola spustená) – zrozumiteľná chyba
|
||||||
|
function explain(error: { message?: string; code?: string } | null): Error | null {
|
||||||
|
if (!error) return null;
|
||||||
|
if (error.code === '42P01' || error.code === 'PGRST205' || /reviews|review_comments/.test(error.message || '')) {
|
||||||
|
return new Error(
|
||||||
|
'Tabuľky pre pripomienky v databáze chýbajú. Spustite v Supabase SQL editore súbor supabase/migrations/002_reviews.sql.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Error(error.message || 'Chyba databázy');
|
||||||
|
}
|
||||||
|
|
||||||
|
function newToken(): string {
|
||||||
|
const bytes = new Uint8Array(12);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (b) => b.toString(36).padStart(2, '0')).join('').slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewUrl(token: string): string {
|
||||||
|
return `${window.location.origin}/review/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotOf(campaign: NewsletterCampaign): Review['snapshot'] {
|
||||||
|
return { title: campaign.title, preheader: campaign.preheader, modules: campaign.modules };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odkaz na pripomienky pre kampaň (posledný vytvorený), ak existuje
|
||||||
|
export async function getReviewForTemplate(templateId: string): Promise<Review | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select('*')
|
||||||
|
.eq('template_id', templateId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
const err = explain(error);
|
||||||
|
if (err) throw err;
|
||||||
|
return (data?.[0] as Review) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReviewByToken(token: string): Promise<Review | null> {
|
||||||
|
const { data, error } = await supabase.from('reviews').select('*').eq('token', token).limit(1);
|
||||||
|
const err = explain(error);
|
||||||
|
if (err) throw err;
|
||||||
|
return (data?.[0] as Review) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReview(campaign: NewsletterCampaign): Promise<Review> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.insert([{ token: newToken(), template_id: campaign.id || null, title: campaign.title, snapshot: snapshotOf(campaign) }])
|
||||||
|
.select();
|
||||||
|
const err = explain(error);
|
||||||
|
if (err) throw err;
|
||||||
|
return data![0] as Review;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualizácia snímky v odkaze (pripomienky ostávajú)
|
||||||
|
export async function updateReviewSnapshot(reviewId: string, campaign: NewsletterCampaign): Promise<Review> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.update({ title: campaign.title, snapshot: snapshotOf(campaign), updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', reviewId)
|
||||||
|
.select();
|
||||||
|
const err = explain(error);
|
||||||
|
if (err) throw err;
|
||||||
|
return data![0] as Review;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReviewComments(reviewId: string): Promise<ReviewComment[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('review_comments')
|
||||||
|
.select('*')
|
||||||
|
.eq('review_id', reviewId)
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
const err = explain(error);
|
||||||
|
if (err) throw err;
|
||||||
|
return (data as ReviewComment[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addReviewComment(comment: NewReviewComment): Promise<ReviewComment> {
|
||||||
|
const { data, error } = await supabase.from('review_comments').insert([comment]).select();
|
||||||
|
const err = explain(error);
|
||||||
|
if (err) throw err;
|
||||||
|
return data![0] as ReviewComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setReviewCommentDone(id: string, done: boolean): Promise<void> {
|
||||||
|
const err = explain((await supabase.from('review_comments').update({ done }).eq('id', id)).error);
|
||||||
|
if (err) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReviewComment(id: string): Promise<void> {
|
||||||
|
const err = explain((await supabase.from('review_comments').delete().eq('id', id)).error);
|
||||||
|
if (err) throw err;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Nahratie súboru do úložiska: server vydá podpísanú adresu, súbor ide z prehliadača priamo do Supabase
|
||||||
|
// (cez Vercel by väčšie súbory neprešli – limit 4,5 MB na požiadavku).
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
export async function uploadFile(file: Blob, name: string): Promise<string> {
|
||||||
|
const res = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({ error: `Server vrátil chybu ${res.status}` }));
|
||||||
|
if (!data.success || !data.path || !data.token) {
|
||||||
|
throw new Error(data.error || 'Nahratie súboru zlyhalo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.storage
|
||||||
|
.from('newsletter-assets')
|
||||||
|
.uploadToSignedUrl(data.path, data.token, file, { contentType: file.type || 'image/png' });
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zmenšenie veľkej fotky pred nahratím: v e-maile má obrázok najviac 600 px, dlhšia strana 1000 px stačí
|
||||||
|
// aj na ostré displeje – tlačové podklady s tisíckami px a desiatkami MB sú zbytočné (bucket berie najviac 10 MB).
|
||||||
|
// PNG ostáva PNG kvôli priehľadnosti; SVG a GIF (animácie) sa nechávajú tak.
|
||||||
|
const MAX_SIDE = 1000;
|
||||||
|
const MAX_BYTES = 9 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function shrinkImage(file: File): Promise<File> {
|
||||||
|
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) return file;
|
||||||
|
const bitmap = await createImageBitmap(file).catch(() => null);
|
||||||
|
if (!bitmap) return file;
|
||||||
|
if (Math.max(bitmap.width, bitmap.height) <= MAX_SIDE && file.size <= MAX_BYTES) {
|
||||||
|
bitmap.close();
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
let side = Math.min(MAX_SIDE, Math.max(bitmap.width, bitmap.height));
|
||||||
|
let blob: Blob | null = null;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const k = side / Math.max(bitmap.width, bitmap.height);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.round(bitmap.width * k);
|
||||||
|
canvas.height = Math.round(bitmap.height * k);
|
||||||
|
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||||
|
blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, type, 0.9));
|
||||||
|
if (!blob || blob.size <= MAX_BYTES) break;
|
||||||
|
side = Math.round(side * 0.75);
|
||||||
|
}
|
||||||
|
bitmap.close();
|
||||||
|
if (!blob) return file;
|
||||||
|
const name = type === 'image/jpeg' ? file.name.replace(/\.\w+$/, '.jpg') : file.name;
|
||||||
|
return new File([blob], name, { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Je adresa už v našom úložisku?
|
||||||
|
export function isOwnStorageUrl(url: string): boolean {
|
||||||
|
return url.includes('/storage/v1/object/public/newsletter-assets/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteImageResult =
|
||||||
|
| { status: 'skip' } // nie je to cudzia URL (naše úložisko, nedokončený text)
|
||||||
|
| { status: 'small' } // obrázok je dosť malý, adresa ostáva
|
||||||
|
| { status: 'shrunk'; url: string } // zmenšený a uložený k nám
|
||||||
|
| { status: 'login' } // server obrázka chce prihlásenie – v e-maile sa nezobrazí
|
||||||
|
| { status: 'failed'; error: string };
|
||||||
|
|
||||||
|
// Obrázok vložený cez URL: stiahne sa (priamo, ak to cudzí server dovolí, inak cez /api/img),
|
||||||
|
// a ak je väčší ako 1000 px, zmenší sa a nahrá do úložiska.
|
||||||
|
export async function shrinkRemoteImage(url: string): Promise<RemoteImageResult> {
|
||||||
|
if (!/^https?:\/\/[^/]+\.[^/]+\//i.test(url) || isOwnStorageUrl(url)) return { status: 'skip' };
|
||||||
|
let blob: Blob | null = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) blob = await res.blob();
|
||||||
|
} catch {
|
||||||
|
// CORS – skúsime cez náš proxy
|
||||||
|
}
|
||||||
|
if (!blob || !blob.type.startsWith('image/')) {
|
||||||
|
const res = await fetch(`/api/img?url=${encodeURIComponent(url)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data.upstreamStatus === 401 || data.upstreamStatus === 403) return { status: 'login' };
|
||||||
|
return { status: 'failed', error: data.error || `Chyba ${res.status}` };
|
||||||
|
}
|
||||||
|
blob = await res.blob();
|
||||||
|
}
|
||||||
|
const name = decodeURIComponent(new URL(url).pathname.split('/').pop() || '') || 'obrazok.png';
|
||||||
|
const file = new File([blob], name, { type: blob.type });
|
||||||
|
const small = await shrinkImage(file);
|
||||||
|
if (small === file) return { status: 'small' };
|
||||||
|
return { status: 'shrunk', url: await uploadFile(small, small.name) };
|
||||||
|
}
|
||||||
@@ -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,22 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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“
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- Pripomienky k newslettru cez zdieľaný odkaz
|
||||||
|
--
|
||||||
|
-- Spustenie na existujúcom projekte: Supabase Dashboard → SQL Editor → vložiť tento súbor → Run.
|
||||||
|
-- (Pre novú inštaláciu je to už súčasťou supabase/schema.sql.) Idempotentné – dá sa spustiť opakovane.
|
||||||
|
|
||||||
|
-- Odkaz na pripomienky: snímka newslettra v čase vytvorenia / poslednej aktualizácie
|
||||||
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token TEXT NOT NULL UNIQUE, -- časť odkazu /review/<token>
|
||||||
|
template_id UUID REFERENCES templates(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, -- kampaň (moduly) v čase zdieľania
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reviews_template ON reviews(template_id);
|
||||||
|
|
||||||
|
-- Pripomienky: komentár (špendlík), text priamo v newslettri, kresba
|
||||||
|
-- Poloha je ukotvená k modulu: x = podiel šírky (0–1), y = px od vrchu modulu
|
||||||
|
CREATE TABLE IF NOT EXISTS review_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('comment', 'text', 'drawing')),
|
||||||
|
module_id TEXT NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
path JSONB, -- kresba: [[x, y], ...] v rovnakých súradniciach
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT '#e2001a',
|
||||||
|
author TEXT NOT NULL DEFAULT '',
|
||||||
|
done BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_review_comments_review ON review_comments(review_id, created_at);
|
||||||
|
|
||||||
|
-- Interný nástroj: prístup cez anon kľúč (rovnako ako ostatné tabuľky)
|
||||||
|
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE review_comments ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS "Allow public all on reviews" ON reviews;
|
||||||
|
DROP POLICY IF EXISTS "Allow public all on review_comments" ON review_comments;
|
||||||
|
CREATE POLICY "Allow public all on reviews" ON reviews FOR ALL USING (true) WITH CHECK (true);
|
||||||
|
CREATE POLICY "Allow public all on review_comments" ON review_comments FOR ALL USING (true) WITH CHECK (true);
|
||||||
@@ -59,3 +59,42 @@ CREATE POLICY "Allow public all on mapping_presets" ON mapping_presets FOR ALL U
|
|||||||
INSERT INTO storage.buckets (id, name, public)
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
VALUES ('newsletter-assets', 'newsletter-assets', true)
|
VALUES ('newsletter-assets', 'newsletter-assets', true)
|
||||||
ON CONFLICT (id) DO UPDATE SET public = true;
|
ON CONFLICT (id) DO UPDATE SET public = true;
|
||||||
|
|
||||||
|
-- 6. Pripomienky k newslettru cez zdieľaný odkaz (rovnaké ako supabase/migrations/002_reviews.sql)
|
||||||
|
-- Odkaz na pripomienky: snímka newslettra v čase vytvorenia / poslednej aktualizácie
|
||||||
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token TEXT NOT NULL UNIQUE, -- časť odkazu /review/<token>
|
||||||
|
template_id UUID REFERENCES templates(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, -- kampaň (moduly) v čase zdieľania
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reviews_template ON reviews(template_id);
|
||||||
|
|
||||||
|
-- Pripomienky: komentár (špendlík), text priamo v newslettri, kresba
|
||||||
|
-- Poloha je ukotvená k modulu: x = podiel šírky (0–1), y = px od vrchu modulu
|
||||||
|
CREATE TABLE IF NOT EXISTS review_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('comment', 'text', 'drawing')),
|
||||||
|
module_id TEXT NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
path JSONB, -- kresba: [[x, y], ...] v rovnakých súradniciach
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT '#e2001a',
|
||||||
|
author TEXT NOT NULL DEFAULT '',
|
||||||
|
done BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_review_comments_review ON review_comments(review_id, created_at);
|
||||||
|
|
||||||
|
-- Interný nástroj: prístup cez anon kľúč (rovnako ako ostatné tabuľky)
|
||||||
|
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE review_comments ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS "Allow public all on reviews" ON reviews;
|
||||||
|
DROP POLICY IF EXISTS "Allow public all on review_comments" ON review_comments;
|
||||||
|
CREATE POLICY "Allow public all on reviews" ON reviews FOR ALL USING (true) WITH CHECK (true);
|
||||||
|
CREATE POLICY "Allow public all on review_comments" ON review_comments FOR ALL USING (true) WITH CHECK (true);
|
||||||
Reference in new issue
Block a user