Compare commits
29
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č
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Adobe Fonts (voliteľné): odkaz na CSS web projektu z fonts.adobe.com (Web Projects → Embed),
|
||||
# napr. https://use.typekit.net/abc1234.css – načíta písma ako Aptly pre cenovky a štítky.
|
||||
# V Adobe projekte treba povoliť domény (localhost aj produkčnú).
|
||||
NEXT_PUBLIC_ADOBE_FONTS_URL=
|
||||
|
||||
# Brevo (SMTP & API → API Keys)
|
||||
BREVO_API_KEY=
|
||||
BREVO_SENDER_NAME=COOP Jednota
|
||||
|
||||
@@ -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 contentType = res.headers.get('content-type') || '';
|
||||
if (!res.ok || !contentType.startsWith('image/')) {
|
||||
return Response.json({ error: `Obrázok sa nepodarilo načítať (${res.status}).` }, { status: 502 });
|
||||
return Response.json({ error: `Obrázok sa nepodarilo načítať (${res.status}).`, upstreamStatus: res.status }, { status: 502 });
|
||||
}
|
||||
const body = await res.arrayBuffer();
|
||||
if (body.byteLength > MAX_BYTES) {
|
||||
|
||||
+14
-26
@@ -1,42 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSupabaseAdmin } from '@/lib/supabase';
|
||||
|
||||
const BUCKET = 'newsletter-assets';
|
||||
|
||||
// Vydá podpísanú adresu na nahratie súboru priamo z prehliadača do Supabase Storage.
|
||||
// Samotný súbor cez server nejde – Vercel by odmietol požiadavky nad 4,5 MB („Request Entity Too Large“).
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Súbor nebol poskytnutý.' }, { status: 400 });
|
||||
const { name } = (await req.json()) as { name?: string };
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Chýba názov súboru.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getSupabaseAdmin();
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const path = `uploads/${Date.now()}-${safeName}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const { error: uploadError } = await admin.storage
|
||||
.from('newsletter-assets')
|
||||
.upload(path, buffer, {
|
||||
contentType: file.type || 'image/png',
|
||||
upsert: true
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload to Supabase Storage error:', uploadError);
|
||||
return NextResponse.json({ error: uploadError.message }, { status: 500 });
|
||||
const { data, error } = await admin.storage.from(BUCKET).createSignedUploadUrl(path);
|
||||
if (error || !data) {
|
||||
console.error('Signed upload URL error:', error);
|
||||
return NextResponse.json({ error: error?.message || 'Nepodarilo sa pripraviť nahratie' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { data: pubData } = admin.storage
|
||||
.from('newsletter-assets')
|
||||
.getPublicUrl(path);
|
||||
const { data: pubData } = admin.storage.from(BUCKET).getPublicUrl(path);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: pubData.publicUrl
|
||||
});
|
||||
return NextResponse.json({ success: true, path, token: data.token, url: pubData.publicUrl });
|
||||
} catch (err: any) {
|
||||
console.error('API /upload error:', err);
|
||||
return NextResponse.json({ error: err.message || 'Chyba servera pri nahrávaní súboru' }, { status: 500 });
|
||||
|
||||
@@ -23,6 +23,12 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<head>
|
||||
{/* Písmo e-mailu (Poppins) – náhľad v editore aj na stránke s pripomienkami */}
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800;900&display=swap" />
|
||||
{/* Adobe Fonts (napr. Aptly pre cenovky a štítky) – odkaz na web projekt z fonts.adobe.com */}
|
||||
{process.env.NEXT_PUBLIC_ADOBE_FONTS_URL && <link rel="stylesheet" href={process.env.NEXT_PUBLIC_ADOBE_FONTS_URL} />}
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+154
-25
@@ -5,21 +5,36 @@ import { NewsletterCampaign, NewsletterModule, ProductItem, BadgeTemplate } from
|
||||
import { DEFAULT_CAMPAIGN, SUPABASE_ASSETS_BASE, ensureValidityBar } from '@/lib/defaultCampaign';
|
||||
import { renderEmailHtml, renderSingleModuleHtml, getCampaignValidity } from '@/lib/emailRenderer';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { DEFAULT_BADGE_TEMPLATES } from '@/lib/badgeUtils';
|
||||
import { DEFAULT_BADGE_TEMPLATES, BadgeRow, badgeFromRow } from '@/lib/badgeUtils';
|
||||
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||
import { TopBar } from '@/components/editor/TopBar';
|
||||
import { ConfigPanel } from '@/components/editor/ConfigPanel';
|
||||
import { ProductImportModal } from '@/components/editor/ProductImportModal';
|
||||
import { BadgeEditorModal } from '@/components/editor/BadgeEditorModal';
|
||||
import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal';
|
||||
import { NameDialog } from '@/components/editor/NameDialog';
|
||||
import JSZip from 'jszip';
|
||||
import { prepareCardImages } from '@/lib/cardImages';
|
||||
import { useCampaignReview } from '@/components/review/useCampaignReview';
|
||||
import { ReviewModal } from '@/components/review/ReviewModal';
|
||||
import { AnnotationLayer, useModuleLayout } from '@/components/review/AnnotationLayer';
|
||||
import { ReviewComment } from '@/lib/reviews';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { ChevronUp, ChevronDown, Copy, Trash2, Anchor, Lock } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
const [campaign, setCampaign] = useState<NewsletterCampaign>(DEFAULT_CAMPAIGN);
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>('mod-products-fantasticky');
|
||||
// Produkt otvorený v paneli (harmonika); nastaví sa aj klikom na produkt v náhľade
|
||||
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
||||
|
||||
// Pripomienky zo zdieľaného odkazu (zobrazujú sa aj na plátne, dajú sa označiť ako hotové)
|
||||
const reviews = useCampaignReview(campaign);
|
||||
const [isReviewOpen, setIsReviewOpen] = useState(false);
|
||||
const [showReviewOverlay, setShowReviewOverlay] = useState(true);
|
||||
const [selectedCommentId, setSelectedCommentId] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const canvasLayout = useModuleLayout(canvasRef, [campaign.modules, reviews.comments.length, showReviewOverlay]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isExportingBrevo, setIsExportingBrevo] = useState(false);
|
||||
const [brevoUrl, setBrevoUrl] = useState<string | null>(null);
|
||||
@@ -28,6 +43,7 @@ export default function Home() {
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isBadgeEditorOpen, setIsBadgeEditorOpen] = useState(false);
|
||||
const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false);
|
||||
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
|
||||
const [targetProductIdForBadge, setTargetProductIdForBadge] = useState<string | null>(null);
|
||||
const [availableBadges, setAvailableBadges] = useState<BadgeTemplate[]>(DEFAULT_BADGE_TEMPLATES);
|
||||
|
||||
@@ -42,19 +58,7 @@ export default function Home() {
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (data && !error && data.length > 0) {
|
||||
loaded = data.map((d: any) => {
|
||||
const isObj = d.layers_config && !Array.isArray(d.layers_config);
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
svgRaw: d.svg_raw || '',
|
||||
layersConfig: isObj ? (d.layers_config.layers || []) : (d.layers_config || []),
|
||||
width: isObj ? (d.layers_config.width || 110) : 110,
|
||||
align: isObj ? (d.layers_config.align || 'center') : 'center',
|
||||
paddingTop: isObj ? (d.layers_config.paddingTop ?? 4) : 4,
|
||||
paddingBottom: isObj ? (d.layers_config.paddingBottom ?? 4) : 4
|
||||
};
|
||||
});
|
||||
loaded = (data as BadgeRow[]).map(badgeFromRow);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error fetching badges in page:', e);
|
||||
@@ -335,7 +339,13 @@ export default function Home() {
|
||||
setCampaign((prev) => {
|
||||
const firstBottomIdx = prev.modules.findIndex((m) => isBottomModule(m));
|
||||
const newModules = [...prev.modules];
|
||||
if (firstBottomIdx !== -1) {
|
||||
// Produktová sekcia ide za poslednú existujúcu produktovú sekciu, a keď žiadna nie je, hneď pod lištu „Akcia platí“
|
||||
const lastProductsIdx = prev.modules.map((m) => m.type).lastIndexOf('products_grid');
|
||||
const validityIdx = prev.modules.findIndex((m) => m.type === 'validity_bar');
|
||||
const productsAt = lastProductsIdx !== -1 ? lastProductsIdx + 1 : validityIdx !== -1 ? validityIdx + 1 : -1;
|
||||
if (newMod.type === 'products_grid' && productsAt !== -1) {
|
||||
newModules.splice(productsAt, 0, newMod);
|
||||
} else if (firstBottomIdx !== -1) {
|
||||
newModules.splice(firstBottomIdx, 0, newMod);
|
||||
} else {
|
||||
newModules.push(newMod);
|
||||
@@ -345,7 +355,7 @@ export default function Home() {
|
||||
setSelectedModuleId(newMod.id);
|
||||
};
|
||||
|
||||
const handleImportProducts = (imported: ProductItem[]) => {
|
||||
const handleImportProducts = (imported: ProductItem[], options?: { replace?: boolean }) => {
|
||||
if (!selectedModuleId) return;
|
||||
const target = campaign.modules.find((m) => m.id === selectedModuleId);
|
||||
if (!target || target.type !== 'products_grid') {
|
||||
@@ -353,11 +363,24 @@ export default function Home() {
|
||||
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 = {
|
||||
...target,
|
||||
data: {
|
||||
...target.data,
|
||||
products: [...(target.data.products || []), ...imported]
|
||||
products: options?.replace ? withLayout : [...(target.data.products || []), ...withLayout]
|
||||
}
|
||||
};
|
||||
handleUpdateModule(updated);
|
||||
@@ -410,10 +433,9 @@ export default function Home() {
|
||||
};
|
||||
|
||||
// Duplikácia aktuálnej kampane v Supabase
|
||||
const handleDuplicateCurrentCampaign = async () => {
|
||||
const handleDuplicateCurrentCampaign = async (newTitle: string) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const newTitle = `${campaign.title} (kópia)`;
|
||||
const { data, error } = await supabase
|
||||
.from('templates')
|
||||
.insert([
|
||||
@@ -437,7 +459,7 @@ export default function Home() {
|
||||
status: 'draft',
|
||||
modules: campaign.modules
|
||||
});
|
||||
alert(`Kampaň bola zduplikovaná ako "${newTitle}"!`);
|
||||
setIsDuplicateOpen(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`Chyba pri duplikovaní: ${err.message}`);
|
||||
@@ -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)
|
||||
const handleApplyBadgeToAll = (badge: BadgeTemplate, badgeLayout?: 'below' | 'side') => {
|
||||
setCampaign((prev) => ({
|
||||
@@ -647,7 +682,7 @@ export default function Home() {
|
||||
onSave={handleManualSave}
|
||||
onUpdateTitle={(title) => setCampaign((prev) => ({ ...prev, title }))}
|
||||
onOpenSavedCampaigns={() => setIsSavedCampaignsOpen(true)}
|
||||
onDuplicateCampaign={handleDuplicateCurrentCampaign}
|
||||
onDuplicateCampaign={() => setIsDuplicateOpen(true)}
|
||||
onExportZip={handleExportZip}
|
||||
onExportBrevo={handleExportBrevo}
|
||||
isExportingBrevo={isExportingBrevo}
|
||||
@@ -655,6 +690,8 @@ export default function Home() {
|
||||
brevoListingUrl="https://app.brevo.com/campaigns/listing"
|
||||
onClearBrevoId={handleClearBrevoId}
|
||||
onAddModule={handleAddModule}
|
||||
onOpenReviews={() => setIsReviewOpen(true)}
|
||||
reviewOpenCount={reviews.openCount}
|
||||
/>
|
||||
|
||||
{/* 2. Hlavný pracovný priestor */}
|
||||
@@ -667,8 +704,12 @@ export default function Home() {
|
||||
backgroundImage: 'linear-gradient(135deg, #005bb5 0%, #000d1a 100%)'
|
||||
}}
|
||||
>
|
||||
{/* Zvýraznenie produktu otvoreného v paneli */}
|
||||
{selectedProductId && (
|
||||
<style>{`main [data-product-id="${selectedProductId.replace(/["\\]/g, '')}"] { outline: 3px solid #ef4444; outline-offset: 3px; }`}</style>
|
||||
)}
|
||||
{/* E-mail Container 1:1 k originálu */}
|
||||
<div className="w-full max-w-[600px] my-4">
|
||||
<div ref={canvasRef} className="relative w-full max-w-[600px] h-fit my-4">
|
||||
{campaign.modules.map((mod, idx) => {
|
||||
const isSelected = mod.id === selectedModuleId;
|
||||
const isAnchored = isAnchoredModule(mod);
|
||||
@@ -683,7 +724,17 @@ export default function Home() {
|
||||
return (
|
||||
<div
|
||||
key={mod.id}
|
||||
onClick={() => setSelectedModuleId(mod.id)}
|
||||
data-module-id={mod.id}
|
||||
onClick={(e) => {
|
||||
// Klik na kartu produktu otvorí v paneli práve tento produkt
|
||||
const card = (e.target as HTMLElement).closest('[data-product-id]');
|
||||
if (card) {
|
||||
setSelectedProductId(card.getAttribute('data-product-id'));
|
||||
} else if (mod.id !== selectedModuleId) {
|
||||
setSelectedProductId(null);
|
||||
}
|
||||
setSelectedModuleId(mod.id);
|
||||
}}
|
||||
className={`relative cursor-pointer transition-all duration-150 rounded-2xl ${
|
||||
isSelected
|
||||
? 'ring-2 ring-offset-4 ring-offset-[#001a33] ring-red-500 shadow-2xl'
|
||||
@@ -866,21 +917,63 @@ export default function Home() {
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSingleModuleHtml(mod, campaign)
|
||||
}}
|
||||
className="pointer-events-none"
|
||||
onClick={(e) => {
|
||||
// odkazy v náhľade neotvárame, klik slúži na výber modulu / produktu
|
||||
if ((e.target as HTMLElement).closest('a')) e.preventDefault();
|
||||
}}
|
||||
className="[&_[data-product-id]]:cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pripomienky zo zdieľaného odkazu */}
|
||||
{showReviewOverlay && reviews.comments.length > 0 && (
|
||||
<AnnotationLayer
|
||||
layout={canvasLayout}
|
||||
comments={reviews.comments}
|
||||
selectedId={selectedCommentId}
|
||||
onSelect={setSelectedCommentId}
|
||||
renderActions={(c: ReviewComment) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reviews.toggleDone(c)}
|
||||
className={`text-[11px] font-bold px-2.5 py-1 rounded-lg ${
|
||||
c.done ? 'bg-gray-100 text-gray-700' : 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||
}`}
|
||||
>
|
||||
{c.done ? 'Vrátiť ako neurobené' : '✓ Označiť ako hotové'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Zmazať túto pripomienku? Zmizne aj pre ostatných s odkazom.')) {
|
||||
reviews.remove(c);
|
||||
setSelectedCommentId(null);
|
||||
}
|
||||
}}
|
||||
className="text-[11px] font-bold px-2.5 py-1 rounded-lg text-red-700 bg-red-50 hover:bg-red-100"
|
||||
>
|
||||
Zmazať
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Pravý konfiguračný panel */}
|
||||
<aside className="w-96 flex-shrink-0 h-full bg-white shadow-2xl z-20">
|
||||
<aside className="w-[440px] flex-shrink-0 h-full bg-white shadow-2xl z-20">
|
||||
<ConfigPanel
|
||||
module={selectedModule}
|
||||
selectedProductId={selectedProductId}
|
||||
onSelectProduct={setSelectedProductId}
|
||||
onUpdateModule={handleUpdateModule}
|
||||
onApplyBadgeToAll={handleApplyBadgeToAll}
|
||||
onApplyStoresOffsetToAll={handleApplyStoresOffsetToAll}
|
||||
onUpdateAllProductSections={handleUpdateAllProductSections}
|
||||
campaignValidity={getCampaignValidity(campaign)}
|
||||
availableBadges={availableBadges}
|
||||
@@ -893,11 +986,35 @@ export default function Home() {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Pripomienky: odkaz a zoznam */}
|
||||
<ReviewModal
|
||||
isOpen={isReviewOpen}
|
||||
onClose={() => setIsReviewOpen(false)}
|
||||
hasCampaignId={!!campaign.id}
|
||||
review={reviews.review}
|
||||
comments={reviews.comments}
|
||||
error={reviews.error}
|
||||
busy={reviews.busy}
|
||||
showOverlay={showReviewOverlay}
|
||||
onToggleOverlay={() => setShowReviewOverlay(!showReviewOverlay)}
|
||||
onCreate={reviews.create}
|
||||
onUpdateSnapshot={reviews.updateSnapshot}
|
||||
onToggleDone={reviews.toggleDone}
|
||||
onDelete={reviews.remove}
|
||||
onShowComment={(c) => {
|
||||
setShowReviewOverlay(true);
|
||||
setSelectedCommentId(c.id);
|
||||
setIsReviewOpen(false);
|
||||
document.querySelector(`[data-module-id="${c.module_id}"]`)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modál Hromadného importu */}
|
||||
<ProductImportModal
|
||||
isOpen={isImportOpen}
|
||||
onClose={() => setIsImportOpen(false)}
|
||||
onImportProducts={handleImportProducts}
|
||||
availableBadges={availableBadges}
|
||||
/>
|
||||
|
||||
{/* Modál Editora cenoviek (SVG -> PNG) */}
|
||||
@@ -912,11 +1029,23 @@ export default function Home() {
|
||||
currentCardPriceMain={targetProduct?.cardPriceMain || undefined}
|
||||
currentCardPriceSub={targetProduct?.cardPriceSub || undefined}
|
||||
currentCardDiscount={targetProduct?.cardDiscount || undefined}
|
||||
currentAppPriceMain={targetProduct?.appPriceMain || undefined}
|
||||
currentAppPriceSub={targetProduct?.appPriceSub || undefined}
|
||||
/>
|
||||
|
||||
{/* Modál Moje newslettre (Ukladanie, duplikácia, vytváranie a prepínanie) */}
|
||||
<NameDialog
|
||||
open={isDuplicateOpen}
|
||||
title="Duplikovať newsletter"
|
||||
initial={campaign.title || ''}
|
||||
confirmText="Vytvoriť kópiu"
|
||||
onConfirm={handleDuplicateCurrentCampaign}
|
||||
onCancel={() => setIsDuplicateOpen(false)}
|
||||
/>
|
||||
|
||||
<SavedCampaignsModal
|
||||
isOpen={isSavedCampaignsOpen}
|
||||
onRenameCurrent={(title) => setCampaign((prev) => ({ ...prev, title }))}
|
||||
onClose={() => setIsSavedCampaignsOpen(false)}
|
||||
currentCampaign={campaign}
|
||||
onLoadCampaign={(selected: NewsletterCampaign) => {
|
||||
|
||||
@@ -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;
|
||||
onUpdateModule: (updated: NewsletterModule) => 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...)
|
||||
onUpdateAllProductSections?: (patch: Record<string, unknown>) => void;
|
||||
campaignValidity?: string; // globálna platnosť akcie (pre Lištu platnosti)
|
||||
onOpenBadgeManager?: (productId: string) => void;
|
||||
onOpenImport?: () => void;
|
||||
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
|
||||
@@ -90,11 +94,14 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
module,
|
||||
onUpdateModule,
|
||||
onApplyBadgeToAll,
|
||||
onApplyStoresOffsetToAll,
|
||||
onUpdateAllProductSections,
|
||||
campaignValidity = '',
|
||||
onOpenBadgeManager,
|
||||
onOpenImport,
|
||||
availableBadges = []
|
||||
availableBadges = [],
|
||||
selectedProductId = null,
|
||||
onSelectProduct
|
||||
}) => {
|
||||
if (!module) {
|
||||
return (
|
||||
@@ -422,6 +429,33 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
title="Enter = nový riadok (alebo <br>)"
|
||||
className="w-full text-sm border border-gray-300 rounded-lg p-2 resize-y"
|
||||
/>
|
||||
{/* Farba nadpisu */}
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold text-gray-500">Farba nadpisu</span>
|
||||
{[
|
||||
['#1a1a1a', 'Čierna (predvolená)'],
|
||||
['#003399', 'COOP modrá'],
|
||||
['#e2001a', 'COOP červená']
|
||||
].map(([c, name]) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
title={name}
|
||||
onClick={() => updateData({ titleColor: c === '#1a1a1a' ? undefined : c })}
|
||||
className={`w-5 h-5 rounded-full border ${
|
||||
(module.data.titleColor || '#1a1a1a').toLowerCase() === c ? 'ring-2 ring-red-500 border-white' : 'border-black/15'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={module.data.titleColor || '#1a1a1a'}
|
||||
onChange={(e) => updateData({ titleColor: e.target.value })}
|
||||
className="w-6 h-6 rounded cursor-pointer border border-gray-300 p-0"
|
||||
title="Vlastná farba"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Popis</label>
|
||||
@@ -546,7 +580,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Rýchle predvoľby */}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{PRODUCT_BG_PRESETS.map((preset) => {
|
||||
const currentBg = (module.data.cardBgColor || '#feeaea').toLowerCase();
|
||||
const isSelected = currentBg === preset.color.toLowerCase();
|
||||
@@ -660,13 +694,15 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
product={p}
|
||||
index={pIdx}
|
||||
variant="standard"
|
||||
defaultOpen={pIdx === 0}
|
||||
open={selectedProductId === p.id}
|
||||
onToggle={() => onSelectProduct?.(selectedProductId === p.id ? null : p.id)}
|
||||
availableBadges={availableBadges || []}
|
||||
onChange={(patch) => updateProduct(pIdx, patch)}
|
||||
onDelete={() => updateData({ products: (module.data.products || []).filter((_: ProductItem, i: number) => i !== pIdx) })}
|
||||
onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined}
|
||||
onApplyBadgeToSection={() => applyBadgeToSection(p)}
|
||||
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'standard') : undefined}
|
||||
onApplyStoresOffsetToAll={onApplyStoresOffsetToAll}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -756,7 +792,7 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Rýchle predvoľby */}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{PRODUCT_BG_PRESETS.map((preset) => {
|
||||
const currentBg = (module.data.cardBgColor || '#e6e1d9').toLowerCase();
|
||||
const isSelected = currentBg === preset.color.toLowerCase();
|
||||
@@ -834,23 +870,56 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vernostné produkty */}
|
||||
{/* Vernostné produkty (ľubovoľný počet, 2 na riadok) */}
|
||||
<div className="space-y-2 pt-2">
|
||||
<span className="text-xs font-bold text-gray-700 block">
|
||||
Vernostné produkty ({module.data.products?.length || 0})
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-700">
|
||||
Vernostné produkty ({module.data.products?.length || 0})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newP: CardProduct = {
|
||||
id: `lp-${Date.now()}`,
|
||||
title: 'Nový vernostný produkt',
|
||||
imageUrl: `${SUPABASE_ASSETS_BASE}vernostna_karta-produkt_1.png`,
|
||||
priceMain: '',
|
||||
priceSub: '',
|
||||
unitPrice: '',
|
||||
discountText: '',
|
||||
badgeText: 'zľava',
|
||||
noCardDiscount: '20%',
|
||||
noCardPriceMain: '0',
|
||||
noCardPriceSub: '99',
|
||||
noCardUnitPrice: '',
|
||||
cardDiscount: '30%',
|
||||
cardPriceMain: '0',
|
||||
cardPriceSub: '79',
|
||||
cardUnitPrice: '',
|
||||
stores: { potraviny: true, supermarket: true, tempo: true }
|
||||
};
|
||||
updateData({ products: [...(module.data.products || []), newP] });
|
||||
onSelectProduct?.(newP.id);
|
||||
}}
|
||||
className="text-xs font-bold text-white bg-gray-900 hover:bg-black px-2.5 py-1 rounded-lg flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Pridať produkt
|
||||
</button>
|
||||
</div>
|
||||
{(module.data.products || []).map((p: CardProduct, pIdx: number) => (
|
||||
<ProductEditor
|
||||
key={p.id || pIdx}
|
||||
product={p}
|
||||
index={pIdx}
|
||||
variant="loyalty"
|
||||
defaultOpen={pIdx === 0}
|
||||
open={selectedProductId === p.id}
|
||||
onToggle={() => onSelectProduct?.(selectedProductId === p.id ? null : p.id)}
|
||||
availableBadges={availableBadges || []}
|
||||
onChange={(patch) => updateProduct(pIdx, patch)}
|
||||
onDelete={() => updateData({ products: (module.data.products || []).filter((_: CardProduct, i: number) => i !== pIdx) })}
|
||||
onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined}
|
||||
onApplyBadgeToSection={() => applyBadgeToSection(p)}
|
||||
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'loyalty') : undefined}
|
||||
onApplyStoresOffsetToAll={onApplyStoresOffsetToAll}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, Loader2, Image as ImageIcon } from 'lucide-react';
|
||||
import { shrinkImage, shrinkRemoteImage, uploadFile } from '@/lib/upload';
|
||||
|
||||
interface ImageUploadInputProps {
|
||||
label?: string;
|
||||
@@ -26,28 +27,13 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const uploadImage = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success && data.url) {
|
||||
onChange(data.url);
|
||||
} else {
|
||||
alert(`Chyba pri nahrávaní obrázka: ${data.error || 'Neznáma chyba'}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`Chyba: ${err.message}`);
|
||||
const small = await shrinkImage(file);
|
||||
onChange(await uploadFile(small, small.name));
|
||||
} catch (err) {
|
||||
alert(`Chyba pri nahrávaní obrázka: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
@@ -56,6 +42,55 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadImage(file);
|
||||
};
|
||||
|
||||
// Vložená URL: príliš veľký obrázok sa zmenší a uloží k nám (posledná spracovaná adresa, aby sa nerobilo dvakrát)
|
||||
const lastCheckedUrl = useRef('');
|
||||
const [urlNote, setUrlNote] = useState<{ tone: 'ok' | 'warn'; text: string } | null>(null);
|
||||
const checkUrl = async (url: string) => {
|
||||
url = url.trim();
|
||||
if (!url || url === lastCheckedUrl.current) return;
|
||||
lastCheckedUrl.current = url;
|
||||
setIsUploading(true);
|
||||
setUrlNote(null);
|
||||
try {
|
||||
const result = await shrinkRemoteImage(url);
|
||||
if (result.status === 'shrunk') {
|
||||
lastCheckedUrl.current = result.url;
|
||||
onChange(result.url);
|
||||
setUrlNote({ tone: 'ok', text: 'Obrázok bol väčší ako 1000 px – zmenšili sme ho a uložili k nám.' });
|
||||
} else if (result.status === 'small') {
|
||||
setUrlNote({ tone: 'ok', text: 'Obrázok je v poriadku (najviac 1000 px), adresa ostáva.' });
|
||||
} else if (result.status === 'login') {
|
||||
setUrlNote({
|
||||
tone: 'warn',
|
||||
text: 'Obrázok je za prihlásením – v e-maile sa nezobrazí. Otvorte ho v prehliadači, pravým klikom „Kopírovať obrázok“ a sem vložte Ctrl+V.'
|
||||
});
|
||||
} else if (result.status === 'failed') {
|
||||
setUrlNote({ tone: 'warn', text: `Obrázok sa nepodarilo stiahnuť (${result.error}). Skontrolujte adresu, prípadne obrázok skopírujte a vložte Ctrl+V.` });
|
||||
}
|
||||
} catch (err) {
|
||||
setUrlNote({ tone: 'warn', text: `Obrázok sa nepodarilo spracovať: ${(err as Error).message}` });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
// obrázok zo schránky (Ctrl+V) → nahrá sa ako súbor
|
||||
const file = [...e.clipboardData.files].find((f) => f.type.startsWith('image/'));
|
||||
if (file) {
|
||||
e.preventDefault();
|
||||
uploadImage(file);
|
||||
return;
|
||||
}
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (/^https?:\/\//i.test(text.trim())) setTimeout(() => checkUrl(text), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="block text-[11px] font-semibold text-gray-700">{label}</label>}
|
||||
@@ -81,9 +116,17 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setUrlNote(null);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') checkUrl(e.currentTarget.value);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onBlur={(e) => checkUrl(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 text-xs font-mono border border-gray-300 rounded-lg p-2 bg-white"
|
||||
className="flex-1 min-w-0 text-xs font-mono border border-gray-300 rounded-lg p-2 bg-white"
|
||||
/>
|
||||
|
||||
{/* Tlačidlo nahrávania */}
|
||||
@@ -115,6 +158,12 @@ export const ImageUploadInput: React.FC<ImageUploadInputProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{urlNote && (
|
||||
<p className={`text-[10px] leading-snug ${urlNote.tone === 'warn' ? 'text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-2 py-1' : 'text-emerald-700'}`}>
|
||||
{urlNote.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onAltChange && (
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -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:
|
||||
// texty, fotka (zoom/posun), cenovka (šablóna, veľkosť, ceny), dostupnosť a ďalšie možnosti.
|
||||
import React, { useState } from 'react';
|
||||
import { BadgeTemplate, CardProduct } from '@/types/newsletter';
|
||||
import { ChevronDown, Trash2, Pencil, Globe, Layers } from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { BadgeTemplate, CardProduct, ProductLabel, ProductPhoto } from '@/types/newsletter';
|
||||
import { ChevronDown, ChevronUp, Trash2, Pencil, Globe, Layers } from 'lucide-react';
|
||||
import { ImageUploadInput } from './ImageUploadInput';
|
||||
import { pickAlt } from '@/lib/altText';
|
||||
import { DEFAULT_STORES_OFFSET_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 = [
|
||||
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
||||
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
|
||||
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
|
||||
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
|
||||
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
|
||||
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
|
||||
];
|
||||
export { PRODUCT_BG_PRESETS };
|
||||
|
||||
type Variant = 'standard' | 'loyalty';
|
||||
// Textové polia cien produktu (bežné aj vernostné)
|
||||
type TextKey =
|
||||
| 'priceMain' | 'priceSub' | 'discountText'
|
||||
| 'cardPriceMain' | 'cardPriceSub' | 'cardDiscount' | 'cardUnitPrice'
|
||||
| 'appPriceMain' | 'appPriceSub'
|
||||
| 'noCardPriceMain' | 'noCardPriceSub' | 'noCardDiscount' | 'noCardUnitPrice';
|
||||
type Patch = Record<string, unknown>;
|
||||
|
||||
@@ -31,12 +25,14 @@ interface ProductEditorProps {
|
||||
index: number;
|
||||
variant: Variant;
|
||||
availableBadges: BadgeTemplate[];
|
||||
defaultOpen?: boolean;
|
||||
open: boolean; // harmonika: otvorený je vždy len jeden produkt
|
||||
onToggle: () => void;
|
||||
onChange: (patch: Patch) => void;
|
||||
onDelete?: () => void;
|
||||
onOpenBadgeEditor?: () => void;
|
||||
onApplyBadgeToSection?: () => void;
|
||||
onApplyBadgeToAll?: () => void;
|
||||
onApplyStoresOffsetToAll?: (x: number, y: number, size: number) => void; // poloha a veľkosť štítkov pre všetky produkty
|
||||
}
|
||||
|
||||
const inputCls = 'w-full text-xs border border-gray-300 rounded-lg px-2 py-1.5 bg-white';
|
||||
@@ -103,6 +99,135 @@ const ResetLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
</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
|
||||
const usesCardPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('_s_kartou');
|
||||
|
||||
@@ -111,22 +236,29 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
index,
|
||||
variant,
|
||||
availableBadges,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onToggle,
|
||||
onChange,
|
||||
onDelete,
|
||||
onOpenBadgeEditor,
|
||||
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 set = (key: string) => (v: unknown) => onChange({ [key]: v });
|
||||
|
||||
const scale = p.imageScale ?? 1;
|
||||
const offsetX = p.imageOffsetX || 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 storesSize = p.storesSize ?? DEFAULT_STORES_SIZE;
|
||||
const hasStores = !!(p.stores?.potraviny || p.stores?.supermarket || p.stores?.tempo);
|
||||
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 priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey: TextKey, unitKey?: TextKey) => (
|
||||
const priceRow = (label: string, mainKey: TextKey, subKey: TextKey, discountKey?: TextKey, unitKey?: TextKey) => (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-gray-600">{label}</span>
|
||||
<div className={`grid ${unitKey ? 'grid-cols-[1fr_1fr_1fr]' : 'grid-cols-3'} gap-1.5`}>
|
||||
@@ -153,9 +285,11 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
<Field label="Centy">
|
||||
<TextInput value={text(subKey)} onChange={set(subKey)} bold />
|
||||
</Field>
|
||||
<Field label="Zľava">
|
||||
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
|
||||
</Field>
|
||||
{discountKey && (
|
||||
<Field label="Zľava">
|
||||
<TextInput value={text(discountKey)} onChange={set(discountKey)} placeholder="25%" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{unitKey && (
|
||||
<Field label="Jednotková cena">
|
||||
@@ -166,10 +300,13 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
);
|
||||
|
||||
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í */}
|
||||
<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">
|
||||
{p.imageUrl && (
|
||||
// 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="Vodorovne" value={offsetX} display={`${offsetX}`} min={-150} max={150} step={1} onChange={set('imageOffsetX')} />
|
||||
<Slider label="Zvisle" value={offsetY} display={`${offsetY}`} min={-120} max={120} step={1} onChange={set('imageOffsetY')} />
|
||||
|
||||
{/* Ďalšie fotky (bundle) */}
|
||||
{(p.extraPhotos || []).map((f, i) => (
|
||||
<ExtraPhotoEditor
|
||||
key={f.id}
|
||||
index={i}
|
||||
photo={f}
|
||||
onChange={(patch) => onChange({ extraPhotos: (p.extraPhotos || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
|
||||
onRemove={() =>
|
||||
onChange({
|
||||
extraPhotos: (p.extraPhotos || []).filter((_, j) => j !== i),
|
||||
photoOrder: photoStack(p).filter((id) => id !== f.id)
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const id = `ph-${Date.now()}`;
|
||||
onChange({
|
||||
extraPhotos: [...(p.extraPhotos || []), { id, url: '', x: 62, y: 52, scale: 0.75 }],
|
||||
photoOrder: [...photoStack(p), id] // nová fotka ide dopredu
|
||||
});
|
||||
}}
|
||||
className="w-full text-[10px] font-bold text-gray-600 border border-dashed border-gray-300 hover:border-gray-400 hover:bg-white rounded-lg py-1.5"
|
||||
>
|
||||
+ Pridať ďalšiu fotku (bundle)
|
||||
</button>
|
||||
|
||||
{(p.extraPhotos || []).length > 0 && <PhotoOrderList product={p} onChange={(photoOrder) => onChange({ photoOrder })} />}
|
||||
</Block>
|
||||
|
||||
{/* CENOVKA */}
|
||||
@@ -285,6 +453,7 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
<>
|
||||
{priceRow('Cena', 'priceMain', 'priceSub', 'discountText')}
|
||||
{usesCardPrice(p.badgeCustomTemplate) && priceRow('S kartou', 'cardPriceMain', 'cardPriceSub', 'cardDiscount')}
|
||||
{usesAppPrice(p.badgeCustomTemplate) && priceRow('Pre appkáčov', 'appPriceMain', 'appPriceSub')}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -315,8 +484,8 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
|
||||
{/* DOSTUPNOSŤ */}
|
||||
<Block
|
||||
title="Dostupnosť"
|
||||
action={hasStores && (storesX !== 0 || storesY !== DEFAULT_STORES_OFFSET_Y) && (
|
||||
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined })} />
|
||||
action={hasStores && (storesX !== DEFAULT_STORES_OFFSET_X || storesY !== DEFAULT_STORES_OFFSET_Y || storesSize !== DEFAULT_STORES_SIZE) && (
|
||||
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined, storesSize: undefined })} />
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
@@ -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={storesY} display={`${storesY}`} min={-40} max={60} step={1} onChange={set('storesOffsetY')} />
|
||||
<Slider label="Veľkosť" value={storesSize} display={`${storesSize} px`} min={8} max={30} step={1} onChange={set('storesSize')} />
|
||||
{onApplyStoresOffsetToAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApplyStoresOffsetToAll(storesX, storesY, storesSize)}
|
||||
className="flex items-center gap-1 text-[10px] font-bold text-red-700 bg-red-50 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
|
||||
>
|
||||
<Globe className="w-3 h-3" /> Polohu a veľkosť štítkov použiť na všetky produkty
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{/* VLASTNÉ ŠTÍTKY NA FOTKE */}
|
||||
<Block
|
||||
title="Štítky na fotke"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
labels: [
|
||||
...(p.labels || []),
|
||||
{ id: `lbl-${Date.now()}`, text: 'NOVINKA', x: 30, y: 20, size: 12, bg: '#e2001a', color: '#ffffff', rotate: 0 }
|
||||
]
|
||||
})
|
||||
}
|
||||
className="text-[10px] font-bold text-red-700 hover:text-red-900"
|
||||
>
|
||||
+ Pridať štítok
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{(p.labels || []).length === 0 && (
|
||||
<p className="text-[10px] text-gray-400">Napr. „PODIEL MÄSA 92 %“ alebo „NOVINKA“ priamo na fotke produktu.</p>
|
||||
)}
|
||||
{(p.labels || []).map((l, i) => (
|
||||
<LabelEditor
|
||||
key={l.id}
|
||||
label={l}
|
||||
onChange={(patch) => onChange({ labels: (p.labels || []).map((x, j) => (j === i ? { ...x, ...patch } : x)) })}
|
||||
onRemove={() => onChange({ labels: (p.labels || []).filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
</Block>
|
||||
|
||||
{/* ĎALŠIE MOŽNOSTI */}
|
||||
<details className="group">
|
||||
<summary className="text-[10px] font-bold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-800">
|
||||
|
||||
@@ -3,20 +3,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import Papa from 'papaparse';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { ProductItem } from '@/types/newsletter';
|
||||
import { BadgeTemplate, ProductItem } from '@/types/newsletter';
|
||||
import { findBadgeByCode, badgeCode } from '@/lib/badgeUtils';
|
||||
import { Upload, Check, AlertCircle, FileText, X } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { PodkladyProduct, podkladyToProductItem, readPodkladyZip, splitPrice } from '@/lib/podkladyZip';
|
||||
import { CARD_BG_BY_BADGE_COLOR } from '@/lib/productCard';
|
||||
import { shrinkImage, uploadFile } from '@/lib/upload';
|
||||
|
||||
interface ImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImportProducts: (products: ProductItem[]) => void;
|
||||
onImportProducts: (products: ProductItem[], options?: { replace?: boolean }) => void;
|
||||
availableBadges?: BadgeTemplate[]; // šablóny cenoviek – stĺpec s kódom určí, ktorú použiť
|
||||
}
|
||||
|
||||
export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onImportProducts
|
||||
onImportProducts,
|
||||
availableBadges = []
|
||||
}) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileType, setFileType] = useState<'csv' | 'json' | 'xml'>('csv');
|
||||
@@ -32,6 +38,7 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
discountText: string;
|
||||
imageUrl: string;
|
||||
validity: string;
|
||||
badgeCode: string;
|
||||
}>({
|
||||
title: '',
|
||||
priceMain: '',
|
||||
@@ -39,12 +46,18 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
unitPrice: '',
|
||||
discountText: '',
|
||||
imageUrl: '',
|
||||
validity: ''
|
||||
validity: '',
|
||||
badgeCode: ''
|
||||
});
|
||||
|
||||
const [presetName, setPresetName] = useState('');
|
||||
const [savedPresets, setSavedPresets] = useState<any[]>([]);
|
||||
|
||||
// ZIP s podkladmi z Databázy produktov (tabuľka + fotky) – pevný formát, bez mapovania stĺpcov
|
||||
const [zip, setZip] = useState<{ products: (PodkladyProduct & { thumb: string })[]; info: string } | null>(null);
|
||||
const [zipProgress, setZipProgress] = useState<string | null>(null);
|
||||
const [replaceExisting, setReplaceExisting] = useState(false); // vymazať doterajšie produkty sekcie
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -54,6 +67,19 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
|
||||
const ext = uploaded.name.split('.').pop()?.toLowerCase();
|
||||
const reader = new FileReader();
|
||||
setZip(null);
|
||||
|
||||
if (ext === 'zip') {
|
||||
setColumns([]);
|
||||
setParsedRows([]);
|
||||
readPodkladyZip(uploaded, availableBadges)
|
||||
.then(({ products, info }) => {
|
||||
if (!products.length) throw new Error('V tabuľke sa nenašiel žiadny produkt.');
|
||||
setZip({ products: products.map((p) => ({ ...p, thumb: p.photo ? URL.createObjectURL(p.photo) : '' })), info });
|
||||
})
|
||||
.catch((err: Error) => alert(`ZIP sa nepodarilo načítať: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ext === 'csv') {
|
||||
setFileType('csv');
|
||||
@@ -125,11 +151,44 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
if (lower.includes('zlava') || lower.includes('discount')) newMapping.discountText = h;
|
||||
if (lower.includes('obrazok') || lower.includes('image') || lower.includes('foto')) newMapping.imageUrl = h;
|
||||
if (lower.includes('platnost') || lower.includes('valid')) newMapping.validity = h;
|
||||
if (lower.includes('cenovk') || lower.includes('badge') || lower.includes('sablon') || lower.includes('template')) newMapping.badgeCode = h;
|
||||
});
|
||||
setMapping(newMapping);
|
||||
};
|
||||
|
||||
// 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 = () => {
|
||||
if (zip) {
|
||||
applyZipImport();
|
||||
return;
|
||||
}
|
||||
if (!mapping.title) {
|
||||
alert('Prosím vyberte stĺpec pre Názov produktu.');
|
||||
return;
|
||||
@@ -148,7 +207,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
if (pSub.length === 1) pSub += '0';
|
||||
}
|
||||
|
||||
// Šablóna cenovky podľa kódu (alebo názvu) zo súboru
|
||||
const badge = mapping.badgeCode ? findBadgeByCode(availableBadges, row[mapping.badgeCode]) : undefined;
|
||||
|
||||
return {
|
||||
...(badge ? { badgeCustomTemplate: badge, badgeId: badge.id, badgeWidth: badge.width ?? 110 } : {}),
|
||||
id: `imported-${Date.now()}-${idx}`,
|
||||
title: String(row[mapping.title] || 'Nový produkt'),
|
||||
imageUrl: row[mapping.imageUrl] || 'https://dqexxfspkylqttrtxsww.supabase.co/storage/v1/object/public/newsletter-assets/produkt_1.png',
|
||||
@@ -163,6 +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);
|
||||
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">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.json,.xml"
|
||||
accept=".csv,.json,.xml,.zip"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload-input"
|
||||
@@ -195,12 +272,86 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
<label htmlFor="file-upload-input" className="cursor-pointer">
|
||||
<FileText className="w-10 h-10 text-gray-400 mx-auto mb-2" />
|
||||
<p className="font-semibold text-gray-700">
|
||||
{file ? file.name : 'Kliknite sem alebo pretiahnite CSV, JSON alebo XML'}
|
||||
{file ? file.name : '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 className="text-xs text-gray-400 mt-1">Podporované formáty: .csv, .json, .xml</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ZIP s podkladmi: náhľad produktov */}
|
||||
{zip && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-sm font-bold text-gray-800 uppercase tracking-wider">Produkty v ZIP-e ({zip.products.length})</h3>
|
||||
{zip.info && <span className="text-[11px] text-gray-500 truncate">{zip.info}</span>}
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-xl divide-y divide-gray-100 overflow-hidden">
|
||||
{zip.products.map((p) => {
|
||||
const price = p.price != null ? splitPrice(p.price) : null;
|
||||
const stores = [p.stores.potraviny && 'P', p.stores.supermarket && 'S', p.stores.tempo && 'T'].filter(Boolean).join(' ');
|
||||
return (
|
||||
<div key={p.ean} className="flex items-center gap-3 px-3 py-2 bg-white">
|
||||
<span
|
||||
className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shrink-0"
|
||||
style={p.color ? { 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 font-bold text-gray-900 truncate">{p.title}</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 */}
|
||||
{columns.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -268,6 +419,25 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">Cenovka (kód šablóny)</label>
|
||||
<select
|
||||
value={mapping.badgeCode}
|
||||
onChange={(e) => setMapping({ ...mapping, badgeCode: e.target.value })}
|
||||
className="w-full text-sm border border-gray-300 rounded-lg p-2 bg-white"
|
||||
>
|
||||
<option value="">-- Bez cenovky zo súboru (predvolená) --</option>
|
||||
{columns.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<p className="text-[11px] text-gray-500 mt-1">
|
||||
Kódy šablón:{' '}
|
||||
{availableBadges.length
|
||||
? availableBadges.map(badgeCode).join(', ')
|
||||
: 'zatiaľ žiadne šablóny'}
|
||||
{' '}(kód vzniká z názvu šablóny; v súbore môže byť kód aj presný názov)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">URL obrázka</label>
|
||||
<select
|
||||
@@ -290,7 +460,14 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
<div className="p-3 text-xs space-y-2 bg-white">
|
||||
{parsedRows.slice(0, 2).map((row, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between border-b border-gray-100 pb-2">
|
||||
<span className="font-semibold text-gray-800">{row[mapping.title] || 'Bez názvu'}</span>
|
||||
<span className="font-semibold text-gray-800">
|
||||
{row[mapping.title] || 'Bez názvu'}
|
||||
{mapping.badgeCode && (
|
||||
<span className="ml-2 text-[10px] font-medium text-gray-500">
|
||||
cenovka: {findBadgeByCode(availableBadges, row[mapping.badgeCode])?.name || `? (${row[mapping.badgeCode] || 'prázdne'})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-red-600 font-bold">
|
||||
{row[mapping.priceMain] || '0'},{row[mapping.priceSub] || '00'} €
|
||||
</span>
|
||||
@@ -313,11 +490,11 @@ export const ProductImportModal: React.FC<ImportModalProps> = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={applyImport}
|
||||
disabled={parsedRows.length === 0}
|
||||
disabled={(parsedRows.length === 0 && !zip) || !!zipProgress}
|
||||
className="px-5 py-2 text-sm font-bold bg-red-600 text-white rounded-xl hover:bg-red-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Importovať do kampane
|
||||
{zipProgress || (zip ? `Importovať ${zip.products.length} produktov` : 'Importovať do kampane')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
import { DEFAULT_CAMPAIGN } from '@/lib/defaultCampaign';
|
||||
import { NameDialog } from './NameDialog';
|
||||
import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
@@ -15,7 +16,8 @@ import {
|
||||
CheckCircle2,
|
||||
Layers,
|
||||
Search,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
Pencil
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SavedCampaignsModalProps {
|
||||
@@ -24,6 +26,7 @@ interface SavedCampaignsModalProps {
|
||||
currentCampaign: NewsletterCampaign;
|
||||
onLoadCampaign: (campaign: NewsletterCampaign) => void;
|
||||
onSaveCurrentAsCopy: (newTitle: string) => Promise<void>;
|
||||
onRenameCurrent: (title: string) => void;
|
||||
}
|
||||
|
||||
export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||
@@ -31,13 +34,16 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||
onClose,
|
||||
currentCampaign,
|
||||
onLoadCampaign,
|
||||
onSaveCurrentAsCopy
|
||||
onSaveCurrentAsCopy,
|
||||
onRenameCurrent
|
||||
}) => {
|
||||
const [campaigns, setCampaigns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
// okno na názov: premenovanie alebo duplikovanie vybraného newslettera
|
||||
const [nameAction, setNameAction] = useState<{ kind: 'rename' | 'duplicate'; camp: { id: string; title: string; data?: unknown } } | null>(null);
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
setLoading(true);
|
||||
@@ -66,9 +72,20 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleDuplicate = async (camp: any) => {
|
||||
const handleRename = async (camp: { id: string }, title: string) => {
|
||||
try {
|
||||
const { error } = await supabase.from('templates').update({ title }).eq('id', camp.id);
|
||||
if (error) throw error;
|
||||
setCampaigns((prev) => prev.map((c) => (c.id === camp.id ? { ...c, title } : c)));
|
||||
if (camp.id === currentCampaign.id) onRenameCurrent(title);
|
||||
setNameAction(null);
|
||||
} catch (err) {
|
||||
alert(`Chyba pri premenovaní: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (camp: any, clonedTitle: string) => {
|
||||
try {
|
||||
const clonedTitle = `Kópia - ${camp.title}`;
|
||||
const { data, error } = await supabase
|
||||
.from('templates')
|
||||
.insert([
|
||||
@@ -90,6 +107,7 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||
status: 'draft',
|
||||
modules: data[0].data?.modules || []
|
||||
});
|
||||
setNameAction(null);
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -326,7 +344,7 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDuplicate(camp)}
|
||||
onClick={() => setNameAction({ kind: 'duplicate', camp })}
|
||||
className="px-3 py-1.5 text-xs font-bold bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl flex items-center gap-1 transition-colors"
|
||||
title="Vytvoriť kópiu"
|
||||
>
|
||||
@@ -334,6 +352,14 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||
<span>Duplikovať</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setNameAction({ kind: 'rename', camp })}
|
||||
className="p-2 text-gray-400 hover:text-gray-900 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
title="Premenovať"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(camp.id, camp.title)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
|
||||
@@ -359,6 +385,14 @@ export const SavedCampaignsModal: React.FC<SavedCampaignsModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NameDialog
|
||||
open={!!nameAction}
|
||||
title={nameAction?.kind === 'rename' ? 'Premenovať newsletter' : 'Duplikovať newsletter'}
|
||||
initial={nameAction?.camp.title || ''}
|
||||
confirmText={nameAction?.kind === 'rename' ? 'Uložiť názov' : 'Vytvoriť kópiu'}
|
||||
onConfirm={(title) => (nameAction?.kind === 'rename' ? handleRename(nameAction.camp, title) : handleDuplicate(nameAction!.camp, title))}
|
||||
onCancel={() => setNameAction(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Edit2,
|
||||
RefreshCw,
|
||||
UploadCloud,
|
||||
Check
|
||||
Check,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { NewsletterCampaign } from '@/types/newsletter';
|
||||
import { getBrevoCampaignUrl } from '@/lib/brevo';
|
||||
@@ -32,6 +33,8 @@ interface TopBarProps {
|
||||
brevoListingUrl?: string | null;
|
||||
onClearBrevoId?: () => void;
|
||||
onAddModule: (type: any) => void;
|
||||
onOpenReviews?: () => void;
|
||||
reviewOpenCount?: number; // počet otvorených pripomienok
|
||||
}
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = ({
|
||||
@@ -45,7 +48,9 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
onExportBrevo,
|
||||
isExportingBrevo,
|
||||
onClearBrevoId,
|
||||
onAddModule
|
||||
onAddModule,
|
||||
onOpenReviews,
|
||||
reviewOpenCount = 0
|
||||
}) => {
|
||||
const [isEditingTitle, setIsEditingTitle] = 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">
|
||||
{campaign.title || 'COOP Jednota Newsletter'}
|
||||
</h1>
|
||||
<Edit2 className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<Edit2 className="w-3 h-3 text-gray-400 group-hover:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -173,6 +178,23 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
|
||||
{/* Pravá časť: Uložiť, Duplikovať, Export do ZIP a Inteligentné Brevo akcie */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Pripomienky cez zdieľaný odkaz */}
|
||||
{onOpenReviews && (
|
||||
<button
|
||||
onClick={onOpenReviews}
|
||||
className="relative flex items-center gap-1.5 text-xs font-bold text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 px-3 py-1.5 rounded-xl transition-all shadow-xs"
|
||||
title="Odkaz na pripomienky a zoznam pripomienok"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-gray-600" />
|
||||
<span className="hidden md:inline">Pripomienky</span>
|
||||
{reviewOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-[#e2001a] text-white text-[10px] font-black flex items-center justify-center">
|
||||
{reviewOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tlačidlo manuálneho uloženia */}
|
||||
<button
|
||||
onClick={onSave}
|
||||
|
||||
@@ -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
|
||||
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 {
|
||||
priceMain: string;
|
||||
priceSub: string;
|
||||
@@ -11,10 +69,12 @@ export interface BadgeProductData {
|
||||
cardPriceMain?: string; // cena s kartou → {cena_s_kartou}
|
||||
cardPriceSub?: string;
|
||||
cardDiscount?: string; // zľava s kartou → {zlava_s_kartou}
|
||||
appPriceMain?: string; // cena pre appkáčov → {cena_app}
|
||||
appPriceSub?: string;
|
||||
}
|
||||
|
||||
// Premenné ceny s centami v hornom indexe: {cena} a {cena_s_kartou}
|
||||
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou)\}/;
|
||||
// Premenné ceny s centami v hornom indexe: {cena}, {cena_s_kartou} a {cena_app}
|
||||
export const BADGE_PRICE_TOKEN_RE = /\{(cena|cena_s_kartou|cena_app)\}/;
|
||||
|
||||
// Nahradenie jednoduchých textových premenných (bez horného indexu)
|
||||
export function replaceBadgeTextVars(text: string, product: BadgeProductData): string {
|
||||
@@ -31,9 +91,14 @@ export function badgePriceParts(token: string, product: BadgeProductData): [stri
|
||||
if (token === 'cena_s_kartou') {
|
||||
return [product.cardPriceMain || '', product.cardPriceSub || ''];
|
||||
}
|
||||
if (token === 'cena_app') {
|
||||
return [product.appPriceMain || '', product.appPriceSub || ''];
|
||||
}
|
||||
return [product.priceMain || '0', product.priceSub || '00'];
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Vygeneruje 100% čistý vektorový SVG reťazec cenovky bez akýchkoľvek
|
||||
* editovacích rámčekov, tooltippov či ovládacích prvkov.
|
||||
@@ -90,13 +155,23 @@ export function renderCleanBadgeSvg(
|
||||
const displayHeight = Math.round((displayWidth / vbWidth) * vbHeight);
|
||||
|
||||
// Zostavenie textových vrstiev
|
||||
const textLayersSvg = (template.layersConfig || []).map((layer) => {
|
||||
const textLayersSvg = (template.layersConfig || []).map((layer, layerIndex) => {
|
||||
const rawText = layer.text || '';
|
||||
const fontSize = layer.fontSize || 16;
|
||||
const subFontSize = layer.indexFontSize || Math.max(10, Math.round(fontSize * 0.55));
|
||||
const subDy = layer.indexOffsetY !== undefined ? `${layer.indexOffsetY}px` : '-0.45em';
|
||||
const subDx = layer.indexOffsetX !== undefined ? `${layer.indexOffsetX}px` : '1px';
|
||||
const revertDy = layer.indexOffsetY !== undefined ? `${-layer.indexOffsetY}px` : '0.45em';
|
||||
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
||||
// Cena od 10 € (dvojciferné eurá): menšie písmo, účiaria eur aj centov ostáva na mieste
|
||||
// (0.43 ≈ vzdialenosť stredu písma od účiarie v em – vrstvy sa kreslia so strednou účiariou)
|
||||
const baseSize = layer.fontSize || 16;
|
||||
const baseSub = layer.indexFontSize || Math.max(10, Math.round(baseSize * 0.55));
|
||||
const long = !!priceMatch && !!layer.longPriceScale && layer.longPriceScale < 1 && badgePriceParts(priceMatch[1], product)[0].length >= 2;
|
||||
const k = long ? layer.longPriceScale! : 1;
|
||||
const fontSize = round2(baseSize * k);
|
||||
const subFontSize = round2(baseSub * k);
|
||||
const layerY = long ? round2(layer.y + 0.43 * baseSize * (1 - k)) : layer.y;
|
||||
const offY = layer.indexOffsetY !== undefined ? round2(layer.indexOffsetY - (long ? 0.43 * (1 - k) * (baseSize - baseSub) : 0)) : undefined;
|
||||
const offX = layer.indexOffsetX !== undefined ? round2(layer.indexOffsetX * k) : undefined;
|
||||
const subDy = offY !== undefined ? `${offY}px` : '-0.45em';
|
||||
const subDx = offX !== undefined ? `${offX}px` : '1px';
|
||||
const revertDy = offY !== undefined ? `${-offY}px` : '0.45em';
|
||||
const fontWeight = layer.fontWeight || '700';
|
||||
const fill = layer.fill || '#ffffff';
|
||||
const fontFamily = layer.fontFamily
|
||||
@@ -109,23 +184,23 @@ export function renderCleanBadgeSvg(
|
||||
|
||||
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
||||
let contentSvg = '';
|
||||
const priceMatch = rawText.match(BADGE_PRICE_TOKEN_RE);
|
||||
if (priceMatch) {
|
||||
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
||||
const idx = priceMatch.index || 0;
|
||||
const before = escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product));
|
||||
const after = escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product));
|
||||
const before = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product)));
|
||||
const after = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product)));
|
||||
contentSvg = `${before}${escapeXml(tokMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(tokSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||
} else if (rawText.includes('{centy}')) {
|
||||
const parts = rawText.split('{centy}');
|
||||
const before = escapeXml(parts[0] || '');
|
||||
const after = escapeXml(parts[1] || '');
|
||||
const before = tightenCarons(escapeXml(parts[0] || ''));
|
||||
const after = tightenCarons(escapeXml(parts[1] || ''));
|
||||
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||
} else {
|
||||
contentSvg = escapeXml(replaceBadgeTextVars(rawText, product));
|
||||
contentSvg = tightenCarons(escapeXml(replaceBadgeTextVars(rawText, product)));
|
||||
}
|
||||
|
||||
return `<text x="${layer.x}" y="${layer.y}" text-anchor="${anchor}" dominant-baseline="central" font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||||
// data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne
|
||||
return `<text data-layer-index="${layerIndex}" x="${layer.x}" y="${layerY}" text-anchor="${anchor}" dominant-baseline="central"${layer.letterSpacing ? ` letter-spacing="${layer.letterSpacing}em"` : ''} font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||||
}).join('\n ');
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
||||
@@ -186,6 +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 {
|
||||
if (!unsafe) return '';
|
||||
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.
|
||||
import { toJpeg } from 'html-to-image';
|
||||
import { CardProduct, NewsletterCampaign } from '@/types/newsletter';
|
||||
import { cardContextForModule, cardVisualKey, cardVisualHeight } from './productCard';
|
||||
import { cardContextFor, cardVisualKey, cardVisualHeight } from './productCard';
|
||||
import { uploadFile } from './upload';
|
||||
|
||||
async function uploadJpeg(dataUrl: string, fileName: string): Promise<string> {
|
||||
const blob = await (await fetch(dataUrl)).blob();
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([blob], fileName, { type: 'image/jpeg' }));
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.url) {
|
||||
throw new Error(data.error || 'Nahratie obrázka karty zlyhalo');
|
||||
}
|
||||
return data.url;
|
||||
return uploadFile(blob, fileName);
|
||||
}
|
||||
|
||||
export async function prepareCardImages(
|
||||
@@ -26,8 +20,8 @@ export async function prepareCardImages(
|
||||
campaign.modules.forEach((m, mIdx) => {
|
||||
const isCards = m.type === 'products_grid' || (m.type === 'loyalty_club' && m.data?.enabled !== false);
|
||||
if (!isCards) return;
|
||||
const ctx = cardContextForModule(m, 'preview');
|
||||
(m.data.products || []).forEach((p: CardProduct, pIdx: number) => {
|
||||
const ctx = cardContextFor(m, pIdx, 'preview');
|
||||
const key = cardVisualKey(p, ctx);
|
||||
if (p.cardImage?.key !== key) {
|
||||
jobs.push({ mIdx, pIdx, key, bg: p.cardBgColor || ctx.cardBg, width: ctx.cardWidth });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NewsletterCampaign } from '@/types/newsletter';
|
||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||
import { pickAlt } from './altText';
|
||||
import { escapeHtml, escapeWithBreaks } from './html';
|
||||
import { RenderMode, cardContextForModule, renderProductGrid } from './productCard';
|
||||
import { RenderMode, renderProductGrid } from './productCard';
|
||||
|
||||
// E-mail na export (ZIP/Brevo): produktové karty použijú obrázky pripravené cez prepareCardImages()
|
||||
export function renderEmailHtml(campaign: NewsletterCampaign): string {
|
||||
@@ -233,6 +233,15 @@ export function renderSingleModuleHtml(m: any, campaign: NewsletterCampaign): st
|
||||
return renderModule(m, campaign, 'preview');
|
||||
}
|
||||
|
||||
// Žiara (tieň) tlačidla vo farbe tlačidla: #rrggbb / #rgb → rgba(r, g, b, alpha)
|
||||
function glowColor(hex: string, alpha: number): string {
|
||||
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((hex || '').trim());
|
||||
if (!m) return `rgba(0, 0, 0, ${alpha})`;
|
||||
const h = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
// Globálna platnosť akcie kampane (zdieľaná všetkými produktovými sekciami)
|
||||
export function getCampaignValidity(campaign: NewsletterCampaign): string {
|
||||
const mod = (campaign.modules || []).find(
|
||||
@@ -358,13 +367,13 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
||||
${m.data.title || m.data.description || m.data.buttonText ? `
|
||||
<tr>
|
||||
<td style="padding: 30px 20px; text-align: center;" class="mobile-padding">
|
||||
${m.data.title ? `<h1 class="force-text-dark" style="margin: 0 0 15px 0; font-size: 24px; color: #1a1a1a; font-weight: 700;">${escapeWithBreaks(m.data.title)}</h1>` : ''}
|
||||
${m.data.title ? `<h1 ${m.data.titleColor ? '' : 'class="force-text-dark" '}style="margin: 0 0 15px 0; font-size: 24px; color: ${m.data.titleColor || '#1a1a1a'}; font-weight: 700;">${escapeWithBreaks(m.data.title)}</h1>` : ''}
|
||||
${m.data.description ? `<p class="force-text-dark" style="margin: 0 0 25px 0; font-size: 15px; line-height: 1.5; color: #555555;">${escapeWithBreaks(m.data.description)}</p>` : ''}
|
||||
${m.data.buttonText ? `
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${escapeHtml(m.data.buttonUrl || '#')}" class="btn force-text-white" style="background-color: ${m.data.buttonBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 13px; font-weight: 800; text-decoration: none; padding: 12px 30px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(226, 0, 26, 0.35);">${escapeHtml(m.data.buttonText)}</a>
|
||||
<a href="${escapeHtml(m.data.buttonUrl || '#')}" class="btn force-text-white" style="background-color: ${m.data.buttonBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 13px; font-weight: 800; text-decoration: none; padding: 12px 30px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px ${glowColor(m.data.buttonBgColor || '#e2001a', 0.35)};">${escapeHtml(m.data.buttonText)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>` : ''}
|
||||
@@ -383,7 +392,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
||||
<h2 class="force-text-white" style="margin: 0; font-size: 24px; color: #fefefe; font-weight: 900; text-transform: uppercase; text-shadow: 0px 2px 5px rgba(0,0,0,0.5);">${escapeWithBreaks(m.data.sectionTitle)}</h2>
|
||||
</td>
|
||||
</tr>` : ''}
|
||||
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
|
||||
${renderProductGrid(m, mode)}
|
||||
</table>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="12" style="font-size: 12px; line-height: 12px;"> </td></tr></table>`;
|
||||
|
||||
@@ -414,7 +423,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Produktové karty -->
|
||||
${renderProductGrid(m.data.products || [], cardContextForModule(m, mode))}
|
||||
${renderProductGrid(m, mode)}
|
||||
</table>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"><tr><td height="25" style="font-size: 25px; line-height: 25px;"> </td></tr></table>`;
|
||||
|
||||
@@ -451,7 +460,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${escapeHtml(it1.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it1.btnBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(226, 0, 26, 0.35);">${escapeHtml(it1.btnText)}</a>
|
||||
<a href="${escapeHtml(it1.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it1.btnBgColor || '#e2001a'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px ${glowColor(it1.btnBgColor || '#e2001a', 0.35)};">${escapeHtml(it1.btnText)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -485,7 +494,7 @@ function renderModule(m: any, campaign: NewsletterCampaign, mode: RenderMode = '
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;" class="btn-table">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${escapeHtml(it2.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it2.btnBgColor || '#29446f'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(41, 68, 111, 0.3);">${escapeHtml(it2.btnText)}</a>
|
||||
<a href="${escapeHtml(it2.linkUrl || '#')}" class="btn force-text-white" style="background-color: ${it2.btnBgColor || '#29446f'}; color: #fefefe; font-family: 'Poppins', sans-serif; font-size: 12px; font-weight: 800; text-decoration: none; padding: 10px 24px; border-radius: 25px; display: inline-block; text-transform: uppercase; letter-spacing: 0.5px; box-shadow: 0 4px 12px ${glowColor(it2.btnBgColor || '#29446f', 0.3)};">${escapeHtml(it2.btnText)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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. "1kg = 5,39 €"
|
||||
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('.', ',');
|
||||
|
||||
// "5,39 EUR/kg" → "1kg = 5,39 €" (formát ako v ostatných kartách)
|
||||
function formatUnitPrice(value: string): string {
|
||||
const m = value.trim().match(/^([\d.,]+)\s*(?:EUR|€)\s*\/\s*(\S+)$/i);
|
||||
return m ? `1${m[2]} = ${m[1]} €` : value.trim();
|
||||
}
|
||||
|
||||
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'),
|
||||
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,
|
||||
title: get(row, c.title) || get(row, c.titleAlt) || ean,
|
||||
price: toNumber(get(row, c.price)),
|
||||
cardPrice: toNumber(get(row, c.cardPrice)),
|
||||
appPrice,
|
||||
unitPrice: formatUnitPrice(get(row, c.unitPrice)),
|
||||
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,
|
||||
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
|
||||
// uloží ako JPG (src/lib/cardImages.ts) a do e-mailu ide ako jeden obrázok – rovnako ako karta_produkt_*.jpg
|
||||
// v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
|
||||
import { CardProduct, NewsletterModule } from '@/types/newsletter';
|
||||
import { BadgeTemplate, CardProduct, NewsletterModule, ProductLabel, ProductPhoto } from '@/types/newsletter';
|
||||
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
|
||||
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
|
||||
import { pickAlt } from './altText';
|
||||
@@ -29,7 +29,11 @@ export interface StoresOffset {
|
||||
x?: number; // + doprava, − doľava
|
||||
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
|
||||
export function cardVisualHeight(cardWidth: number): number {
|
||||
@@ -201,9 +205,10 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
||||
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
|
||||
t ? [t.svgRaw, t.layersConfig, t.width] : null,
|
||||
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
|
||||
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice,
|
||||
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice, p.appPriceMain, p.appPriceSub,
|
||||
p.noCardPriceMain, p.noCardPriceSub, p.noCardDiscount, p.noCardUnitPrice,
|
||||
p.stores, p.storesOffsetX || 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);
|
||||
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)}`;
|
||||
}
|
||||
|
||||
// Poradie fotiek v karte odzadu dopredu ('main' = hlavná fotka). Uložené poradie sa doplní o nové fotky
|
||||
// (dopredu) a očistí o zmazané; bez uloženého poradia platí staršie nastavenie „za hlavnou fotkou“.
|
||||
export const MAIN_PHOTO = 'main';
|
||||
|
||||
// Predvoľby farby pozadia karty produktu (oranžová, červená a zelená zodpovedajú farbám cenoviek v podkladoch)
|
||||
export const PRODUCT_BG_PRESETS = [
|
||||
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
|
||||
{ label: 'Oranžová', sub: 'Super výhodné', color: '#fdebd8', border: '#f3d2ae' },
|
||||
{ label: 'Žltá', sub: 'Domáce', color: '#fef0c7', border: '#e6d49a' },
|
||||
{ label: 'Hnedá', sub: 'Gril', color: '#dcb085', border: '#c49a6c' },
|
||||
{ label: 'Sivá', sub: 'Neutrálna', color: '#e6e1d9', border: '#d5cfc5' },
|
||||
{ label: 'Zelená', sub: 'Bio', color: '#e3f0e4', border: '#c8e6c9' },
|
||||
{ label: 'Biela', sub: 'Čistá', color: '#ffffff', border: '#e5e7eb' }
|
||||
];
|
||||
|
||||
// Farba cenovky z podkladov → pozadie karty
|
||||
export const CARD_BG_BY_BADGE_COLOR: Record<'cervena' | 'oranzova' | 'zelena', string> = {
|
||||
cervena: '#feeaea',
|
||||
oranzova: '#fdebd8',
|
||||
zelena: '#e3f0e4'
|
||||
};
|
||||
export function photoStack(p: Pick<CardProduct, 'extraPhotos' | 'photoOrder'>): string[] {
|
||||
const extras = p.extraPhotos || [];
|
||||
const ids = new Set([MAIN_PHOTO, ...extras.map((f) => f.id)]);
|
||||
if (p.photoOrder?.length) {
|
||||
const order = p.photoOrder.filter((id) => ids.has(id));
|
||||
for (const id of ids) if (!order.includes(id)) order.push(id);
|
||||
return order;
|
||||
}
|
||||
return [...extras.filter((f) => f.behind).map((f) => f.id), MAIN_PHOTO, ...extras.filter((f) => !f.behind).map((f) => f.id)];
|
||||
}
|
||||
|
||||
// Ďalšia fotka v karte (bundle): poloha v % vizuálu, veľkosť voči hlavnej fotke
|
||||
function renderExtraPhoto(f: ProductPhoto, mode: RenderMode): string {
|
||||
if (!f.url) return '';
|
||||
return `<img src="${escapeHtml(assetUrl(f.url, mode))}" alt="" style="position: absolute; left: ${f.x}%; top: ${f.y}%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) scale(${f.scale || 1}); border: 0;">`;
|
||||
}
|
||||
|
||||
// Vlastný štítok na fotke (nad fotkou, pod cenovkou) – štýl ako „PODIEL MÄSA 92 %“ z letáku, písmo Aptly Bold (Adobe Fonts)
|
||||
function renderLabel(l: ProductLabel): string {
|
||||
if (!l.text) return '';
|
||||
const size = l.size || 12;
|
||||
return `<div data-label-id="${escapeHtml(l.id)}" style="position: absolute; left: ${l.x}%; top: ${l.y}%; transform: translate(-50%, -50%) rotate(${l.rotate || 0}deg); background-color: ${l.bg || '#e2001a'}; color: ${l.color || '#ffffff'}; font-family: 'Aptly', 'aptly', 'Arial Narrow', 'Roboto Condensed', Arial, sans-serif; font-weight: 700; font-size: ${size}px; line-height: 1.1; padding: ${Math.round(size * 0.3)}px ${Math.round(size * 0.5)}px; border-radius: ${Math.round(size * 0.25)}px; text-transform: uppercase; text-align: center; white-space: nowrap; letter-spacing: 0.2px;">${escapeWithBreaks(l.text)}</div>`;
|
||||
}
|
||||
|
||||
// Vizuál karty v náhľade: fotka (zoom/posun), cenovka vpravo dole, štítky nad cenovkou
|
||||
// Viditeľné pozadie cenovky v SVG môže mať okolo seba priehľadný okraj. Štítky predajní ukotvujeme
|
||||
// k pravému hornému rohu samotného pozadia, preto jeho hranice odmeriame v prehliadači (getBBox).
|
||||
// Výsledok je podiel rozmerov viewBoxu; na serveri (bez DOM) sú okraje 0.
|
||||
interface BadgeBgBox {
|
||||
top: number; // okraj zhora / výška
|
||||
right: number; // okraj sprava / šírka
|
||||
aspect: number; // výška / šírka SVG
|
||||
}
|
||||
const badgeBgCache = new Map<string, BadgeBgBox>();
|
||||
|
||||
function badgeBackgroundBox(t: BadgeTemplate): BadgeBgBox | null {
|
||||
if (!t.svgRaw || typeof document === 'undefined') return null;
|
||||
const cached = badgeBgCache.get(t.svgRaw);
|
||||
if (cached) return cached;
|
||||
let box: BadgeBgBox | null = null;
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = 'position: absolute; left: -10000px; top: 0; visibility: hidden;';
|
||||
try {
|
||||
holder.innerHTML = renderCleanBadgeSvg({ ...t, layersConfig: [] }, { priceMain: '', priceSub: '' }, 200);
|
||||
document.body.appendChild(holder);
|
||||
const svg = holder.querySelector('svg');
|
||||
const vb = svg?.viewBox.baseVal;
|
||||
const bb = svg?.getBBox();
|
||||
if (vb && bb && vb.width && vb.height && bb.width && bb.height) {
|
||||
box = {
|
||||
top: Math.max(0, (bb.y - vb.y) / vb.height),
|
||||
right: Math.max(0, (vb.x + vb.width - (bb.x + bb.width)) / vb.width),
|
||||
aspect: vb.height / vb.width
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
box = null;
|
||||
} finally {
|
||||
holder.remove();
|
||||
}
|
||||
if (box) badgeBgCache.set(t.svgRaw, box);
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): string {
|
||||
const height = cardVisualHeight(ctx.cardWidth);
|
||||
const bg = p.cardBgColor || ctx.cardBg;
|
||||
@@ -224,19 +313,35 @@ function renderVisualPreview(p: CardProduct, ctx: CardContext, key: string): str
|
||||
const scale = p.imageScale ?? 1;
|
||||
const ox = p.imageOffsetX || 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 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 `
|
||||
<div data-card-visual="${key}" style="position: relative; width: 100%; height: ${height}px; overflow: hidden; background-color: ${bg}; border-radius: 16px 16px 0 0; line-height: normal; font-size: 12px;">
|
||||
${p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : ''}
|
||||
${photoStack(p)
|
||||
.map((id) => {
|
||||
if (id === MAIN_PHOTO) {
|
||||
return p.imageUrl ? `<img src="${escapeHtml(assetUrl(p.imageUrl, ctx.mode))}" alt="" style="position: absolute; left: 44%; top: 47%; max-width: 86%; max-height: 86%; transform: translate(-50%, -50%) translate(${ox}px, ${oy}px) scale(${scale}); border: 0;">` : '';
|
||||
}
|
||||
const f = (p.extraPhotos || []).find((x) => x.id === id);
|
||||
return f ? renderExtraPhoto(f, ctx.mode) : '';
|
||||
})
|
||||
.join('')}
|
||||
${(p.labels || []).map(renderLabel).join('')}
|
||||
<div style="position: absolute; right: 8px; bottom: 8px; width: ${bw}px;">
|
||||
${stores ? `<div style="position: absolute; right: ${-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)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Šablóna cenovky zobrazuje cenu pre appkáčov
|
||||
export const usesAppPrice = (t?: BadgeTemplate) => !!t && JSON.stringify(t.layersConfig || []).includes('{cena_app}');
|
||||
|
||||
// Alt text obrázka karty: názov, gramáž a cena (ako v šablóne); ručne zadaný alt má prednosť
|
||||
export function productCardAlt(p: CardProduct, variant: CardVariant): string {
|
||||
const name = [pickAlt(p.title), p.quantity].filter(Boolean).join(' ');
|
||||
@@ -253,9 +358,11 @@ export function productCardAlt(p: CardProduct, variant: CardVariant): string {
|
||||
} else {
|
||||
const main = price(p.priceMain, p.priceSub);
|
||||
const card = price(p.cardPriceMain, p.cardPriceSub);
|
||||
const app = usesAppPrice(p.badgeCustomTemplate) ? price(p.appPriceMain, p.appPriceSub) : '';
|
||||
priceText = [
|
||||
main ? `${main}${p.discountText ? `, zľava ${pct(p.discountText)}` : ''}` : '',
|
||||
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : ''
|
||||
card ? `s kartou ${card}${p.cardDiscount ? ` (zľava ${pct(p.cardDiscount)})` : ''}` : '',
|
||||
app ? `v aplikácii ${app}` : ''
|
||||
].filter(Boolean).join(', ');
|
||||
}
|
||||
return pickAlt(p.imageAlt, [name, priceText].filter(Boolean).join(' – '), 'Produkt');
|
||||
@@ -288,10 +395,10 @@ function renderTexts(p: CardProduct, ctx: CardContext): string {
|
||||
: p.unitPrice;
|
||||
const validity = p.validityOverride || ctx.validity;
|
||||
const small = (text: string) =>
|
||||
`<div class="prod-unit force-text-dark" style="margin-top: 2px; ${TEXT_FONT} font-size: 10px; line-height: 1.3; color: #888888;">${escapeHtml(text)}</div>`;
|
||||
return `<h3 class="prod-name force-text-dark" style="margin: 0; ${TEXT_FONT} font-size: 15px; line-height: 1.25; font-weight: 700; color: #1a1a1a;">${escapeWithBreaks(p.title)}${p.quantity ? ` <span style="font-weight: 400;">${escapeHtml(p.quantity)}</span>` : ''}</h3>${
|
||||
`<div class="prod-unit force-text-dark" style="margin-top: 2px; ${TEXT_FONT} font-size: 10px; line-height: 1.3; color: #888888;">${escapeWithBreaks(text)}</div>`;
|
||||
return `<h3 class="prod-name force-text-dark" style="margin: 0; ${TEXT_FONT} font-size: 15px; line-height: 1.25; font-weight: 700; color: #1a1a1a;">${escapeWithBreaks(p.title)}${p.quantity ? ` <span style="font-weight: 400;">${escapeWithBreaks(p.quantity)}</span>` : ''}</h3>${
|
||||
unitPrice ? `
|
||||
<div class="prod-unit force-text-dark" style="margin-top: 3px; ${TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeHtml(unitPrice)}</div>` : ''}${
|
||||
<div class="prod-unit force-text-dark" style="margin-top: 3px; ${TEXT_FONT} font-size: 11px; line-height: 1.3; color: #555555;">${escapeWithBreaks(unitPrice)}</div>` : ''}${
|
||||
p.note ? small(p.note) : ''}${validity ? small(validity) : ''}`;
|
||||
}
|
||||
|
||||
@@ -302,7 +409,7 @@ export function renderProductCard(p: CardProduct, ctx: CardContext): string {
|
||||
const key = cardVisualKey(p, ctx);
|
||||
const visual = ctx.mode === 'preview' ? renderVisualPreview(p, ctx, key) : renderVisualExport(p, ctx, key);
|
||||
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>
|
||||
<td style="padding: 0; line-height: 0; font-size: 0; border-top-left-radius: 16px; border-top-right-radius: 16px;">
|
||||
${visual}
|
||||
@@ -316,23 +423,46 @@ export function renderProductCard(p: CardProduct, ctx: CardContext): string {
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Mriežka kariet (1 alebo 2 na riadok) s rovnakou výškou kariet v riadku
|
||||
export function renderProductGrid(products: CardProduct[], ctx: CardContext): string {
|
||||
const cols = ctx.cardWidth > 300 ? 1 : 2;
|
||||
const rows: CardProduct[][] = [];
|
||||
for (let i = 0; i < products.length; i += cols) rows.push(products.slice(i, i + cols));
|
||||
return rows
|
||||
// Kontext konkrétnej karty: vo vernostných zľavách pri nepárnom počte je prvý produkt cez celý riadok
|
||||
export function cardContextFor(m: NewsletterModule, index: number, mode: RenderMode): CardContext {
|
||||
const base = cardContextForModule(m, mode);
|
||||
const count = (m.data.products || []).length;
|
||||
if (base.variant === 'loyalty' && count % 2 === 1 && index === 0) return { ...base, cardWidth: 600 };
|
||||
return base;
|
||||
}
|
||||
|
||||
// Riadky mriežky (indexy produktov): 1 na riadok, alebo po 2;
|
||||
// vernostné zľavy s nepárnym počtom: prvý produkt sám cez celý riadok, ostatné po 2
|
||||
function gridRows(m: NewsletterModule, count: number, oneColumn: boolean): number[][] {
|
||||
const idx = Array.from({ length: count }, (_, i) => i);
|
||||
if (oneColumn) return idx.map((i) => [i]);
|
||||
const rows: number[][] = [];
|
||||
let start = 0;
|
||||
if (m.type === 'loyalty_club' && count % 2 === 1) {
|
||||
rows.push([0]);
|
||||
start = 1;
|
||||
}
|
||||
for (let i = start; i < count; i += 2) rows.push(idx.slice(i, i + 2));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Mriežka kariet s rovnakou výškou kariet v riadku
|
||||
export function renderProductGrid(m: NewsletterModule, mode: RenderMode): string {
|
||||
const products: CardProduct[] = m.data.products || [];
|
||||
const oneColumn = cardContextForModule(m, mode).cardWidth > 300;
|
||||
return gridRows(m, products.length, oneColumn)
|
||||
.map((row, rIdx) => {
|
||||
const spacer = rIdx > 0 ? '<tr><td height="12" style="font-size: 12px; line-height: 12px;"> </td></tr>' : '';
|
||||
const cells = row
|
||||
.map(
|
||||
(p, idx) => `
|
||||
<td width="${cols === 1 ? 100 : 50}%" valign="top" height="1" style="${cols === 1 ? '' : idx === 0 ? 'padding-right: 6px;' : 'padding-left: 6px;'} height: 100%;">
|
||||
${renderProductCard(p, ctx)}
|
||||
(i, idx) => `
|
||||
<td width="${row.length === 1 ? 100 : 50}%" valign="top" height="1" style="${row.length === 1 ? '' : idx === 0 ? 'padding-right: 6px;' : 'padding-left: 6px;'} height: 100%;">
|
||||
${renderProductCard(products[i], cardContextFor(m, i, mode))}
|
||||
</td>`
|
||||
)
|
||||
.join('');
|
||||
const filler = cols === 2 && row.length === 1 ? '<td width="50%" style="padding-left: 6px;"></td>' : '';
|
||||
// posledný riadok s 1 produktom v 2-stĺpcovej mriežke (bežné sekcie) – produkt ostane v ľavej polovici
|
||||
const filler = !oneColumn && row.length === 1 && cardContextFor(m, row[0], mode).cardWidth <= 300 ? '<td width="50%" style="padding-left: 6px;"></td>' : '';
|
||||
return `${spacer}
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@@ -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á
|
||||
cardPriceSub?: string; // cena s kartou, centy
|
||||
cardDiscount?: string; // zľava s kartou, napr. "30%"
|
||||
appPriceMain?: string; // cena pre appkáčov (v aplikácii), celé eurá → {cena_app}
|
||||
appPriceSub?: string; // cena pre appkáčov, centy
|
||||
badgeText: string; // napr. "AKCIA" alebo "SUPER CENA"
|
||||
badgeId?: string; // priradená cenovka z databázy
|
||||
badgeCustomTemplate?: BadgeTemplate; // Uložená dynamická šablóna cenovky (bez fixnej ceny)
|
||||
@@ -49,6 +51,10 @@ export interface ProductItem {
|
||||
imageAlt?: string; // Ručný alt text fotky (inak názov produktu)
|
||||
storesOffsetX?: number; // posun štítkov predajní voči cenovke (+ doprava)
|
||||
storesOffsetY?: number; // posun štítkov predajní voči hornej hrane cenovky (+ vyššie)
|
||||
storesSize?: number; // výška jedného štítka predajne v px (nezávislá od veľkosti cenovky)
|
||||
labels?: ProductLabel[]; // vlastné štítky na fotke (napr. „PODIEL MÄSA 92 %“)
|
||||
extraPhotos?: ProductPhoto[]; // ďalšie fotky v karte (bundle) popri hlavnej fotke
|
||||
photoOrder?: string[]; // poradie fotiek odzadu dopredu: id ďalších fotiek a 'main' (hlavná fotka)
|
||||
imageScale?: number; // Zoom fotky (1 = 100 %)
|
||||
imageOffsetX?: number; // Posun fotky vodorovne v px (+ doprava)
|
||||
imageOffsetY?: number; // Posun fotky zvisle v px (+ nadol)
|
||||
@@ -66,6 +72,28 @@ export interface CardProduct extends ProductItem {
|
||||
noCardUnitPrice?: string;
|
||||
}
|
||||
|
||||
// Ďalšia fotka v karte produktu (napr. bundle 2 produktov)
|
||||
export interface ProductPhoto {
|
||||
id: string;
|
||||
url: string;
|
||||
x: number; // stred fotky, % šírky vizuálu (0–100)
|
||||
y: number; // stred fotky, % výšky vizuálu (0–100)
|
||||
scale: number; // 1 = rovnaká veľkosť ako hlavná fotka bez zoomu
|
||||
behind?: boolean; // (staršie) za hlavnou fotkou – nahradené poradím photoOrder
|
||||
}
|
||||
|
||||
// Vlastný štítok na fotke produktu
|
||||
export interface ProductLabel {
|
||||
id: string;
|
||||
text: string; // <br> = nový riadok
|
||||
x: number; // stred štítka, % šírky fotky (0–100)
|
||||
y: number; // stred štítka, % výšky fotky (0–100)
|
||||
size: number; // veľkosť písma v px
|
||||
bg: string; // farba pozadia
|
||||
color: string; // farba textu
|
||||
rotate?: number; // natočenie v stupňoch
|
||||
}
|
||||
|
||||
export interface AnnouncementModuleData {
|
||||
tipBadge: string;
|
||||
text: string;
|
||||
@@ -93,6 +121,7 @@ export interface BannerModuleData {
|
||||
linkUrl: string;
|
||||
altText: string;
|
||||
title?: string;
|
||||
titleColor?: string; // farba nadpisu pod bannerom (predvolene #1a1a1a)
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonUrl?: string;
|
||||
@@ -200,6 +229,8 @@ export interface BadgeLayer {
|
||||
indexFontSize?: number; // Veľkosť horného indexu v px
|
||||
indexOffsetY?: number; // Vertikálny posun horného indexu v px (posun hore / dole)
|
||||
indexOffsetX?: number; // Horizontálny posun horného indexu v px (medzera)
|
||||
longPriceScale?: number; // zmenšenie ceny od 10 € (dvojciferné eurá), napr. 0.8; účiaria ostáva
|
||||
letterSpacing?: number; // rozostup písmen v em (napr. -0.03 = tesnejšie)
|
||||
}
|
||||
|
||||
export interface BadgeTemplate {
|
||||
@@ -213,4 +244,22 @@ export interface BadgeTemplate {
|
||||
align?: 'left' | 'center' | 'right'; // Zarovnanie (vľavo, stred, vpravo)
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
importTags?: BadgeImportTags; // podľa čoho sa šablóna vyberie pri importe podkladov (ZIP)
|
||||
}
|
||||
|
||||
// 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)
|
||||
VALUES ('newsletter-assets', 'newsletter-assets', true)
|
||||
ON CONFLICT (id) DO UPDATE SET public = true;
|
||||
|
||||
-- 6. Pripomienky k newslettru cez zdieľaný odkaz (rovnaké ako supabase/migrations/002_reviews.sql)
|
||||
-- Odkaz na pripomienky: snímka newslettra v čase vytvorenia / poslednej aktualizácie
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE, -- časť odkazu /review/<token>
|
||||
template_id UUID REFERENCES templates(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, -- kampaň (moduly) v čase zdieľania
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_template ON reviews(template_id);
|
||||
|
||||
-- Pripomienky: komentár (špendlík), text priamo v newslettri, kresba
|
||||
-- Poloha je ukotvená k modulu: x = podiel šírky (0–1), y = px od vrchu modulu
|
||||
CREATE TABLE IF NOT EXISTS review_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('comment', 'text', 'drawing')),
|
||||
module_id TEXT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
path JSONB, -- kresba: [[x, y], ...] v rovnakých súradniciach
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '#e2001a',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
done BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_comments_review ON review_comments(review_id, created_at);
|
||||
|
||||
-- Interný nástroj: prístup cez anon kľúč (rovnako ako ostatné tabuľky)
|
||||
ALTER TABLE reviews ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE review_comments ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS "Allow public all on reviews" ON reviews;
|
||||
DROP POLICY IF EXISTS "Allow public all on review_comments" ON review_comments;
|
||||
CREATE POLICY "Allow public all on reviews" ON reviews FOR ALL USING (true) WITH CHECK (true);
|
||||
CREATE POLICY "Allow public all on review_comments" ON review_comments FOR ALL USING (true) WITH CHECK (true);
|
||||
Reference in new issue
Block a user