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>
This commit is contained in:
Jakub_HostacnyandClaude Opus 5.5 committed 2026-10-21 11:03:34 +02:00
1 parent b0a050bd12
commit cfacf2474b
13 files changed
+1162 -10

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>
);
+74 -1
View File
@@ -14,6 +14,10 @@ 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';
@@ -22,6 +26,14 @@ export default function Home() {
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);
@@ -594,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) => ({
@@ -657,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 */}
@@ -674,7 +701,7 @@ export default function Home() {
<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);
@@ -689,6 +716,7 @@ export default function Home() {
return (
<div
key={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]');
@@ -891,6 +919,27 @@ export default function Home() {
</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>
@@ -902,6 +951,7 @@ export default function Home() {
onSelectProduct={setSelectedProductId}
onUpdateModule={handleUpdateModule}
onApplyBadgeToAll={handleApplyBadgeToAll}
onApplyStoresOffsetToAll={handleApplyStoresOffsetToAll}
onUpdateAllProductSections={handleUpdateAllProductSections}
campaignValidity={getCampaignValidity(campaign)}
availableBadges={availableBadges}
@@ -914,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>
);
}