diff --git a/src/components/CommentSection.tsx b/src/components/CommentSection.tsx index 33a5758..07cca9f 100644 --- a/src/components/CommentSection.tsx +++ b/src/components/CommentSection.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { parseMentions, notifyMention } from "@/lib/notifications"; import RelativeTime from "@/components/ui/RelativeTime"; +import { ALLOWED_EMOJIS, type AllowedEmoji } from "@/lib/commentStore"; interface Comment { id: string; @@ -12,12 +13,21 @@ interface Comment { timestamp: number; } +/** Per-comment reaction state stored in localStorage. */ +interface ReactionState { + /** Aggregate counts: emoji → count */ + counts: Record; + /** Emojis this user has actively reacted with */ + myReactions: AllowedEmoji[]; +} + interface Props { invoiceId: string; walletAddress: string; } const STORAGE_KEY = "stellarsplit_comments"; +const REACTIONS_STORAGE_KEY = "stellarsplit_reactions"; function loadComments(invoiceId: string, walletAddress: string): Comment[] { if (typeof window === "undefined") return []; @@ -48,6 +58,19 @@ function deleteComment(id: string) { ); } +function loadReactionsMap(): Record { + if (typeof window === "undefined") return {}; + try { + return JSON.parse(localStorage.getItem(REACTIONS_STORAGE_KEY) ?? "{}"); + } catch { + return {}; + } +} + +function saveReactionsMap(map: Record) { + localStorage.setItem(REACTIONS_STORAGE_KEY, JSON.stringify(map)); +} + /** Stellar address pattern — must match parseMentions regex. */ const MENTION_SPLIT_RE = /(\bG[A-Z0-9]{55}\b)/g; const MENTION_TEST_RE = /^G[A-Z0-9]{55}$/; @@ -73,18 +96,82 @@ export function renderCommentText(text: string): React.ReactNode[] { ); } +/** Compact emoji picker that appears when the "+" button is clicked. */ +function EmojiPicker({ + commentId, + myReactions, + onToggle, + onClose, +}: { + commentId: string; + myReactions: AllowedEmoji[]; + onToggle: (emoji: AllowedEmoji) => void; + onClose: () => void; +}) { + const ref = useRef(null); + + // Close when clicking outside + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + return ( +
+ {ALLOWED_EMOJIS.map((emoji) => { + const isActive = myReactions.includes(emoji); + return ( + + ); + })} +
+ ); +} + /** * CommentSection — off-chain per-invoice notes stored in localStorage. * Only shows comments belonging to the connected wallet address. - * Supports @G... mention chips and fires browser notifications to mentioned addresses. + * Supports @G... mention chips, browser notifications, and emoji reactions. + * + * Reactions are persisted locally and also synced to the server via + * POST /api/invoices/[id]/comments/[commentId]/reactions when available. */ export default function CommentSection({ invoiceId, walletAddress }: Props) { const [comments, setComments] = useState([]); const [text, setText] = useState(""); + const [reactionsMap, setReactionsMap] = useState>({}); + const [openPickerFor, setOpenPickerFor] = useState(null); const inputRef = useRef(null); useEffect(() => { setComments(loadComments(invoiceId, walletAddress)); + setReactionsMap(loadReactionsMap()); }, [invoiceId, walletAddress]); const handleSubmit = (e: React.FormEvent) => { @@ -115,6 +202,58 @@ export default function CommentSection({ invoiceId, walletAddress }: Props) { setComments((prev) => prev.filter((c) => c.id !== id)); }; + /** + * Toggle an emoji reaction for a comment. + * Updates local state + localStorage, and attempts to sync to the server. + */ + const handleToggleReaction = async (commentId: string, emoji: AllowedEmoji) => { + const current = reactionsMap[commentId] ?? { counts: {}, myReactions: [] }; + const isActive = current.myReactions.includes(emoji); + + // Optimistic local update + const newMyReactions: AllowedEmoji[] = isActive + ? current.myReactions.filter((e) => e !== emoji) + : [...current.myReactions, emoji]; + + const newCounts = { ...current.counts }; + const prevCount = newCounts[emoji] ?? 0; + newCounts[emoji] = isActive ? Math.max(0, prevCount - 1) : prevCount + 1; + + const updated: ReactionState = { counts: newCounts, myReactions: newMyReactions }; + const newMap = { ...reactionsMap, [commentId]: updated }; + + setReactionsMap(newMap); + saveReactionsMap(newMap); + + // Best-effort server sync (fire-and-forget; server is source of truth for + // multi-user scenarios but localStorage keeps single-user UX snappy). + try { + const res = await fetch( + `/api/invoices/${invoiceId}/comments/${commentId}/reactions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emoji, reactorId: walletAddress }), + } + ); + if (res.ok) { + const data: { counts: Record; active: boolean } = await res.json(); + // Reconcile server counts + const reconciled: ReactionState = { + counts: data.counts as Record, + myReactions: data.active + ? [...new Set([...newMyReactions, emoji])] + : newMyReactions.filter((e) => e !== emoji), + }; + const reconciledMap = { ...newMap, [commentId]: reconciled }; + setReactionsMap(reconciledMap); + saveReactionsMap(reconciledMap); + } + } catch { + // Network unavailable — local state is already updated, which is fine. + } + }; + return (

Notes

@@ -123,26 +262,88 @@ export default function CommentSection({ invoiceId, walletAddress }: Props) {

No notes yet.

) : (
    - {comments.map((c) => ( -
  • -
    -

    {renderCommentText(c.text)}

    -

    - -

    -
    - -
  • - ))} +
    +

    {renderCommentText(c.text)}

    +

    + +

    + + {/* Reaction chips */} + {hasReactions && ( +
    + {ALLOWED_EMOJIS.map((emoji) => { + const count = reactionState.counts[emoji] ?? 0; + if (count === 0) return null; + const isActive = reactionState.myReactions.includes(emoji); + return ( + + ); + })} +
    + )} + + {/* Add reaction row */} +
    + + {openPickerFor === c.id && ( + handleToggleReaction(c.id, emoji)} + onClose={() => setOpenPickerFor(null)} + /> + )} +
    +
    + + + + ); + })}
)} diff --git a/src/components/CustomizationPanel.tsx b/src/components/CustomizationPanel.tsx index 10e6345..04c4384 100644 --- a/src/components/CustomizationPanel.tsx +++ b/src/components/CustomizationPanel.tsx @@ -14,9 +14,127 @@ interface Props { onCustomizationChange?: (customization: Customization) => void; } +/** + * Live preview card that mimics the public invoice view layout. + * Updates immediately as the user edits settings — no save required. + */ +function InvoicePreviewCard({ + invoiceId, + title, + message, + accentColor, +}: { + invoiceId: string; + title: string; + message: string; + accentColor: string; +}) { + // Derive a readable text color (white vs. black) based on accent luminance + const hexToRgb = (hex: string) => { + const cleaned = hex.replace("#", ""); + const full = + cleaned.length === 3 + ? cleaned + .split("") + .map((c) => c + c) + .join("") + : cleaned; + const num = parseInt(full, 16); + return [(num >> 16) & 255, (num >> 8) & 255, num & 255]; + }; + + const relativeLuminance = (r: number, g: number, b: number) => { + const toLinear = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); + }; + + let accentTextColor = "#ffffff"; + try { + const [r, g, b] = hexToRgb(accentColor); + const lum = relativeLuminance(r, g, b); + accentTextColor = lum > 0.179 ? "#111827" : "#ffffff"; + } catch { + // Invalid hex — use default white + } + + const displayTitle = title.trim() || "Invoice Preview"; + + return ( +
+ {/* Header band styled with accent color */} +
+

+ Invoice +

+

{displayTitle}

+

#{invoiceId}

+
+ + {/* Body */} +
+ {message.trim() ? ( +

+ {message} +

+ ) : ( +

No custom message set.

+ )} + + {/* Placeholder content blocks that mimic the invoice layout */} +
+
+ Recipients + — — +
+
+
+
+
+ 35% funded + + 350 / 1,000 USDC + +
+
+ +
+ +
+
+
+ ); +} + /** * CustomizationPanel — allows customizing invoice title, message, and accent color. - * Stores customization in localStorage. + * Stores customization in localStorage and shows a live preview pane that + * reflects all branding changes in real time without requiring a save. + * + * Layout: side-by-side on desktop (lg+), stacked on mobile. */ export default function CustomizationPanel({ invoiceId, @@ -54,63 +172,90 @@ export default function CustomizationPanel({

Customize Invoice

-
-
- - setTitle(e.target.value)} - className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> -
- -
- -