diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index c0e2e88..3b42f62 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -38,6 +38,8 @@ import PresenceIndicators from "@/components/PresenceIndicators"; import CollaborationCursors from "@/components/CollaborationCursors"; import SplitCalculator from "@/components/SplitCalculator"; import InvoiceQR from "@/components/InvoiceQR"; +import InvoiceChat from "@/components/InvoiceChat"; +import PaymentExport from "@/components/PaymentExport"; import { getReminderForInvoice, cancelReminder, setReminder } from "@/lib/reminders"; import { sendWebhookIfConfigured } from "@/components/WebhookConfig"; import TxConfirmModal from "@/components/TxConfirmModal"; @@ -529,6 +531,14 @@ export default function InvoiceDetailPage({ params }: Props) { /> + {/* Invoice Chat */} + + {/* Recipients */} Recipients diff --git a/src/components/InvoiceChat.tsx b/src/components/InvoiceChat.tsx new file mode 100644 index 0000000..9e4e6c1 --- /dev/null +++ b/src/components/InvoiceChat.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { truncateAddress } from "@stellar-split/sdk"; + +type Recipient = { address: string }; + +interface InvoiceChatMessage { + invoiceId: string; + sender: string; + text: string; + timestamp: number; +} + +interface Props { + invoiceId: string; + creator: string; + recipients: Recipient[]; + currentAddress: string | null; +} + +const STORAGE_PREFIX = "invoice-chat-"; + +function getStorageKey(invoiceId: string) { + return `${STORAGE_PREFIX}${invoiceId}`; +} + +function parseMessages(value: string | null): InvoiceChatMessage[] { + try { + const parsed = value ? JSON.parse(value) : []; + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (item): item is InvoiceChatMessage => + typeof item === "object" && + item !== null && + typeof item.invoiceId === "string" && + typeof item.sender === "string" && + typeof item.text === "string" && + typeof item.timestamp === "number" + ) + .sort((a, b) => a.timestamp - b.timestamp); + } catch { + return []; + } +} + +function loadMessages(invoiceId: string): InvoiceChatMessage[] { + if (typeof window === "undefined") return []; + return parseMessages(localStorage.getItem(getStorageKey(invoiceId))); +} + +function saveMessage(invoiceId: string, message: InvoiceChatMessage) { + const messages = loadMessages(invoiceId); + localStorage.setItem( + getStorageKey(invoiceId), + JSON.stringify([...messages, message]) + ); +} + +function formatDate(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(timestamp)); +} + +export default function InvoiceChat({ + invoiceId, + creator, + recipients, + currentAddress, +}: Props) { + const [messages, setMessages] = useState([]); + const [text, setText] = useState(""); + const [isAllowed, setIsAllowed] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + setMessages(loadMessages(invoiceId)); + }, [invoiceId]); + + useEffect(() => { + setIsAllowed( + Boolean( + currentAddress && + (currentAddress === creator || + recipients.some((recipient) => recipient.address === currentAddress)) + ) + ); + }, [currentAddress, creator, recipients]); + + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key === getStorageKey(invoiceId)) { + setMessages(loadMessages(invoiceId)); + } + }; + + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, [invoiceId]); + + const sortedMessages = useMemo( + () => [...messages].sort((a, b) => a.timestamp - b.timestamp), + [messages] + ); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!currentAddress || !isAllowed) return; + + const trimmed = text.trim(); + if (!trimmed) return; + + const message: InvoiceChatMessage = { + invoiceId, + sender: currentAddress, + text: trimmed, + timestamp: Date.now(), + }; + + saveMessage(invoiceId, message); + setMessages((prev) => [...prev, message]); + setText(""); + inputRef.current?.focus(); + }; + + const placeholder = currentAddress + ? isAllowed + ? "Write a message to the invoice participants…" + : "Only invoice creator and recipients can post messages." + : "Connect your wallet to join the chat."; + + return ( + + Invoice Chat + + {sortedMessages.length === 0 ? ( + + No messages yet. Start the conversation with the invoice creator or recipients. + + ) : ( + + {sortedMessages.map((message, index) => ( + + + + {truncateAddress(message.sender)} + + + {formatDate(message.timestamp)} + + + {message.text} + + ))} + + )} + + + setText(event.target.value)} + placeholder={placeholder} + rows={3} + disabled={!isAllowed} + className="w-full min-h-[88px] resize-none rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-sm text-gray-100 outline-none transition-colors focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 disabled:cursor-not-allowed disabled:opacity-60" + /> + + + {currentAddress ? ( + isAllowed ? ( + <>Posting as {truncateAddress(currentAddress)}> + ) : ( + <>Your connected wallet is not listed on this invoice.> + ) + ) : ( + "Connect a wallet to post messages." + )} + + + Send Message + + + + + + ); +}
{message.text}
+ {currentAddress ? ( + isAllowed ? ( + <>Posting as {truncateAddress(currentAddress)}> + ) : ( + <>Your connected wallet is not listed on this invoice.> + ) + ) : ( + "Connect a wallet to post messages." + )} +