Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfacf2474b | ||
|
|
b0a050bd12 | ||
|
|
c426145c48 |
No files matched your search
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
+51
-6
@@ -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}
|
||||
|
||||
@@ -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,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