Compare commits

...
3 Commits
Author SHA1 Message Date
Jakub_HostacnyandClaude Opus 5.5 cfacf2474b feat: pripomienky cez zdielany odkaz; predvolena poloha stitkov a pouzit na vsetky
Pripomienky:
- odkaz /review/<token> so snimkou newslettra: nic sa neda upravit, da sa
  komentovat (spendlik), pisat text do newslettra a kreslit; meno autora sa pamata
- pripomienky su ukotvene k modulu a ulozene k odkazu - vidi ich kazdy s odkazom
- v editore: tlacidlo Pripomienky (pocet otvorenych), vytvorenie a kopirovanie
  odkazu, aktualizacia nahladu v odkaze, zoznam pripomienok, Hotovo a mazanie;
  pripomienky sa zobrazuju aj priamo na platne a daju sa tam oznacit ako hotove
- supabase/migrations/002_reviews.sql (tabulky reviews, review_comments)
- pismo Poppins v layoute (nahlad vernejsi e-mailu)

Stitky predajni:
- predvolena poloha -15 vodorovne / +5 zvisle od praveho horneho rohu pozadia cenovky
- tlacidlo Polohu stitkov pouzit na vsetky produkty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:03:34 +02:00
Jakub_HostacnyandClaude Opus 5.5 b0a050bd12 fix: stitky predajni ukotvene k pravemu hornemu rohu pozadia cenovky
Pozadie cenovky moze mat v SVG priehladny okraj; jeho skutocne hranice sa
odmeraju v prehliadaci (getBBox) a stitky sa ukotvia k jeho pravemu
hornemu rohu namiesto rohu celeho SVG. Posun stitkov sa pocita od tohto rohu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:53:40 +02:00
Jakub_HostacnyandClaude Opus 5.5 c426145c48 feat: klik na produkt v nahlade otvori jeho editor (harmonika)
- v paneli je otvoreny vzdy len jeden produkt, ostatne su zbalene
- klik na kartu produktu v nahlade vyberie sekciu aj produkt, panel sa
  posunie na jeho editor a karta sa v nahlade zvyrazni
- odkazy v nahlade sa klikom neotvaraju

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:52:09 +02:00
13 changed files with 1256 additions and 24 deletions

No files matched your search

+4
View File
@@ -23,6 +23,10 @@ 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" />
</head>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
+97 -3
View File
@@ -14,12 +14,26 @@ import { BadgeEditorModal } from '@/components/editor/BadgeEditorModal';
import { SavedCampaignsModal } from '@/components/editor/SavedCampaignsModal';
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);
@@ -592,6 +606,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) => {
if (!confirm(`Použiť polohu štítkov (${x} / ${y}) pre všetky produkty v newsletteri?`)) return;
setCampaign((prev) => ({
...prev,
modules: prev.modules.map((m) =>
m.type === 'products_grid' || m.type === 'loyalty_club'
? { ...m, data: { ...m.data, products: (m.data.products || []).map((p: ProductItem) => ({ ...p, storesOffsetX: x, storesOffsetY: y })) } }
: m
)
}));
};
// 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) => ({
@@ -655,6 +682,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 +696,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 +716,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,12 +909,37 @@ 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>
)}
/>
)}
</div>
</main>
@@ -879,8 +947,11 @@ export default function Home() {
<aside className="w-96 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,6 +964,29 @@ 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}
+368
View File
@@ -0,0 +1,368 @@
'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 } from 'lucide-react';
import { renderSingleModuleHtml } from '@/lib/emailRenderer';
import {
Review,
ReviewComment,
ReviewKind,
REVIEW_COLORS,
getReviewByToken,
listReviewComments,
addReviewComment
} 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);
}
};
// 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} />
{/* 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 (
<button
key={c.id}
type="button"
onClick={() => setSelectedId(c.id === selectedId ? null : c.id)}
className={`w-full text-left rounded-lg border p-2 transition-colors ${
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>
</div>
<p className="mt-1 text-xs text-gray-700 whitespace-pre-wrap line-clamp-3">
{c.text || (c.kind === 'drawing' ? 'Kresba' : '')}
</p>
</button>
);
})}
</div>
</aside>
</div>
</div>
);
}
+14 -3
View File
@@ -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) => 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 (
@@ -660,13 +667,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>
@@ -845,12 +854,14 @@ export const ConfigPanel: React.FC<ConfigPanelProps> = ({
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)}
onOpenBadgeEditor={onOpenBadgeManager ? () => onOpenBadgeManager(p.id) : undefined}
onApplyBadgeToSection={() => applyBadgeToSection(p)}
onApplyBadgeToAll={onApplyBadgeToAll ? () => applyBadgeToAll(p, 'loyalty') : undefined}
onApplyStoresOffsetToAll={onApplyStoresOffsetToAll}
/>
))}
</div>
+30 -10
View File
@@ -2,12 +2,12 @@
// 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 React, { useEffect, useRef } from 'react';
import { BadgeTemplate, CardProduct } from '@/types/newsletter';
import { ChevronDown, 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, badgeWidthFor, productCardAlt } from '@/lib/productCard';
export const PRODUCT_BG_PRESETS = [
{ label: 'Červená', sub: 'Fantasticky', color: '#feeaea', border: '#f5d4d4' },
@@ -31,12 +31,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) => void; // poloha š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';
@@ -111,21 +113,27 @@ 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 hasStores = !!(p.stores?.potraviny || p.stores?.supermarket || p.stores?.tempo);
const badgeWidth = badgeWidthFor(p, variant);
@@ -166,10 +174,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
@@ -315,7 +326,7 @@ export const ProductEditor: React.FC<ProductEditorProps> = ({
{/* DOSTUPNOSŤ */}
<Block
title="Dostupnosť"
action={hasStores && (storesX !== 0 || storesY !== DEFAULT_STORES_OFFSET_Y) && (
action={hasStores && (storesX !== DEFAULT_STORES_OFFSET_X || storesY !== DEFAULT_STORES_OFFSET_Y) && (
<ResetLink onClick={() => onChange({ storesOffsetX: undefined, storesOffsetY: undefined })} />
)}
>
@@ -346,6 +357,15 @@ 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')} />
{onApplyStoresOffsetToAll && (
<button
type="button"
onClick={() => onApplyStoresOffsetToAll(storesX, storesY)}
className="flex items-center gap-1 text-[10px] font-bold text-red-700 bg-red-50 border border-red-200 hover:bg-red-100 px-2 py-1 rounded-lg"
>
<Globe className="w-3 h-3" /> Polohu štítkov použiť na všetky produkty
</button>
)}
</>
)}
</Block>
+24 -2
View File
@@ -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);
@@ -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}
+185
View File
@@ -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>
);
};
+177
View File
@@ -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)
};
}
+51 -6
View File
@@ -5,7 +5,7 @@
// V editore (mode 'preview') sa vizuál skladá živo z HTML/CSS. Pri exporte (ZIP/Brevo) sa vizuál z náhľadu
// uloží ako JPG (src/lib/cardImages.ts) a do e-mailu ide ako jeden obrázok – rovnako ako karta_produkt_*.jpg
// v šablóne. Vďaka tomu karta vyzerá rovnako vo všetkých klientoch (Gmail, Outlook, mobil).
import { CardProduct, NewsletterModule } from '@/types/newsletter';
import { BadgeTemplate, CardProduct, NewsletterModule } from '@/types/newsletter';
import { renderCleanBadgeSvg, BadgeProductData } from './badgeUtils';
import { SUPABASE_ASSETS_BASE } from './defaultCampaign';
import { pickAlt } from './altText';
@@ -29,7 +29,9 @@ 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;
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 {
@@ -203,7 +205,7 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
p.priceMain, p.priceSub, p.discountText, p.badgeText, p.unitPrice,
p.cardPriceMain, p.cardPriceSub, p.cardDiscount, p.cardUnitPrice,
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
];
const str = JSON.stringify(relevant);
let h1 = 5381;
@@ -217,6 +219,45 @@ export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
}
// 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,14 +265,18 @@ 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)));
// 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;">` : ''}
<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>`;
@@ -302,7 +347,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}
+131
View File
@@ -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;
}
+42
View File
@@ -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);
+39
View File
@@ -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);