From cfacf2474b2bc3c67ffb4130b1a9362a4c8f811a Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Wed, 21 Oct 2026 11:03:34 +0200 Subject: [PATCH] feat: pripomienky cez zdielany odkaz; predvolena poloha stitkov a pouzit na vsetky Pripomienky: - odkaz /review/ 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 --- src/app/layout.tsx | 4 + src/app/page.tsx | 75 ++++- src/app/review/[token]/page.tsx | 368 +++++++++++++++++++++ src/components/editor/ConfigPanel.tsx | 4 + src/components/editor/ProductEditor.tsx | 19 +- src/components/editor/TopBar.tsx | 26 +- src/components/review/AnnotationLayer.tsx | 185 +++++++++++ src/components/review/ReviewModal.tsx | 177 ++++++++++ src/components/review/useCampaignReview.ts | 94 ++++++ src/lib/productCard.ts | 8 +- src/lib/reviews.ts | 131 ++++++++ supabase/migrations/002_reviews.sql | 42 +++ supabase/schema.sql | 39 +++ 13 files changed, 1162 insertions(+), 10 deletions(-) create mode 100644 src/app/review/[token]/page.tsx create mode 100644 src/components/review/AnnotationLayer.tsx create mode 100644 src/components/review/ReviewModal.tsx create mode 100644 src/components/review/useCampaignReview.ts create mode 100644 src/lib/reviews.ts create mode 100644 supabase/migrations/002_reviews.sql diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9852c15..3068336 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,6 +23,10 @@ export default function RootLayout({ children }: LayoutProps<"/">) { lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > + + {/* Písmo e-mailu (Poppins) – náhľad v editore aj na stránke s pripomienkami */} + + {children} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 73f8ccb..7c6494e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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('mod-products-fantasticky'); // Produkt otvorený v paneli (harmonika); nastaví sa aj klikom na produkt v náhľade const [selectedProductId, setSelectedProductId] = useState(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(null); + const canvasRef = useRef(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(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() { )} {/* E-mail Container 1:1 k originálu */} -
+
{campaign.modules.map((mod, idx) => { const isSelected = mod.id === selectedModuleId; const isAnchored = isAnchoredModule(mod); @@ -689,6 +716,7 @@ export default function Home() { return (
{ // 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() {
); })} + + {/* Pripomienky zo zdieľaného odkazu */} + {showReviewOverlay && reviews.comments.length > 0 && ( + ( + + )} + /> + )}
@@ -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() {
+ {/* Pripomienky: odkaz a zoznam */} + 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 */} = [ + { 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(null); + const [comments, setComments] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [tool, setTool] = useState('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(null); + const [draft, setDraft] = useState(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(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 | 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 ( +
+ Načítavam náhľad… +
+ ); + } + if (error || !review) { + return ( +
+

{error}

+
+ ); + } + + 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 ( +
+ {/* Horná lišta: nástroje */} +
+
COOP
+
+

{review.title || 'Newsletter'}

+

Náhľad na pripomienky · aktualizovaný {new Date(review.updated_at).toLocaleString('sk-SK')}

+
+
+
+ {TOOLS.map(({ id, label, icon: Icon, hint }) => ( + + ))} +
+
+ {REVIEW_COLORS.map((c) => ( +
+
+ +
+ {/* Newsletter */} +
setSelectedId(null)} + > +
+ {review.snapshot.modules.map((m) => ( +
+ ))} + + + + {/* Plocha na pridávanie pripomienok */} + {tool !== 'view' && ( +
{ + e.stopPropagation(); + onCanvasClick(e); + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + )} + + {/* Rozkreslená čiara */} + {livePath && ( + + + + )} + + {/* Okienko na napísanie komentára / textu */} + {draft && ( +
e.stopPropagation()} + > +