From b68a941fbf757eb502618af052e7346b520e9396 Mon Sep 17 00:00:00 2001
From: Marvelous Felix
Date: Tue, 26 May 2026 17:18:51 +0000
Subject: [PATCH 1/2] feat: add Equal Split toggle to new invoice form
---
src/app/invoice/new/page.tsx | 62 ++++++++++++++++++++++++++++++--
src/components/RecipientForm.tsx | 24 ++++++++++---
2 files changed, 78 insertions(+), 8 deletions(-)
diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx
index 6c4ae0c..2966b4f 100644
--- a/src/app/invoice/new/page.tsx
+++ b/src/app/invoice/new/page.tsx
@@ -26,6 +26,13 @@ export default function NewInvoicePage() {
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
+ const [equalSplit, setEqualSplit] = useState(false);
+ const [totalAmount, setTotalAmount] = useState("");
+
+ const perRecipientAmount =
+ equalSplit && totalAmount && recipients.length > 0
+ ? (parseFloat(totalAmount) / recipients.length).toFixed(7)
+ : undefined;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -39,7 +46,7 @@ export default function NewInvoicePage() {
creator,
recipients: recipients.map((r) => ({
address: r.address,
- amount: parseAmount(r.amount),
+ amount: parseAmount(equalSplit ? (perRecipientAmount ?? "0") : r.amount),
})),
token,
deadline: deadlineFromDays(deadlineDays),
@@ -58,12 +65,61 @@ export default function NewInvoicePage() {
Create Invoice
)}
+
+ {/* Private notes — only visible to the connected wallet */}
+ {publicKey && (
+
+ )}
);
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 91c9b70..479206d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
+import NotificationCenter from "@/components/NotificationCenter";
export const metadata: Metadata = {
title: "StellarSplit — On-chain Invoice Splitting",
@@ -11,6 +12,20 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
+
{children}
diff --git a/src/components/CommentSection.tsx b/src/components/CommentSection.tsx
new file mode 100644
index 0000000..5a0d1be
--- /dev/null
+++ b/src/components/CommentSection.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+interface Comment {
+ id: string;
+ invoiceId: string;
+ walletAddress: string;
+ text: string;
+ timestamp: number;
+}
+
+interface Props {
+ invoiceId: string;
+ walletAddress: string;
+}
+
+const STORAGE_KEY = "stellarsplit_comments";
+
+function loadComments(invoiceId: string, walletAddress: string): Comment[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const all: Comment[] = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
+ return all.filter(
+ (c) => c.invoiceId === invoiceId && c.walletAddress === walletAddress
+ );
+ } catch {
+ return [];
+ }
+}
+
+function saveComment(comment: Comment) {
+ const all: Comment[] = JSON.parse(
+ localStorage.getItem(STORAGE_KEY) ?? "[]"
+ );
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...all, comment]));
+}
+
+function deleteComment(id: string) {
+ const all: Comment[] = JSON.parse(
+ localStorage.getItem(STORAGE_KEY) ?? "[]"
+ );
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify(all.filter((c) => c.id !== id))
+ );
+}
+
+function relativeTime(timestamp: number): string {
+ const diff = (Date.now() - timestamp) / 1000;
+ if (diff < 60) return "just now";
+ if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
+ return `${Math.floor(diff / 86400)} days ago`;
+}
+
+/**
+ * CommentSection — off-chain per-invoice notes stored in localStorage.
+ * Only shows comments belonging to the connected wallet address.
+ */
+export default function CommentSection({ invoiceId, walletAddress }: Props) {
+ const [comments, setComments] = useState([]);
+ const [text, setText] = useState("");
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ setComments(loadComments(invoiceId, walletAddress));
+ }, [invoiceId, walletAddress]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ const comment: Comment = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ invoiceId,
+ walletAddress,
+ text: trimmed,
+ timestamp: Date.now(),
+ };
+ saveComment(comment);
+ setComments((prev) => [...prev, comment]);
+ setText("");
+ inputRef.current?.focus();
+ };
+
+ const handleDelete = (id: string) => {
+ deleteComment(id);
+ setComments((prev) => prev.filter((c) => c.id !== id));
+ };
+
+ return (
+
+ Notes
+
+ {comments.length === 0 ? (
+ No notes yet.
+ ) : (
+
+ {comments.map((c) => (
+ -
+
+
{c.text}
+
{relativeTime(c.timestamp)}
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/InstallmentPanel.tsx b/src/components/InstallmentPanel.tsx
new file mode 100644
index 0000000..d6d3f08
--- /dev/null
+++ b/src/components/InstallmentPanel.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { splitClient } from "@/lib/stellar";
+import { formatAmount } from "@stellar-split/sdk";
+
+interface Installment {
+ dueDate: number; // unix timestamp (seconds)
+ amount: bigint;
+ paid: boolean;
+}
+
+interface Props {
+ invoiceId: string;
+ publicKey: string;
+}
+
+/**
+ * InstallmentPanel — shows the payer's installment schedule for an invoice.
+ * Highlights the next due installment; marks past ones as paid if payment exists.
+ */
+export default function InstallmentPanel({ invoiceId, publicKey }: Props) {
+ const [installments, setInstallments] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (splitClient as any)
+ .getInstallmentPlan(invoiceId, publicKey)
+ .then((plan: Installment[] | null) => setInstallments(plan ?? []))
+ .catch(() => setInstallments([]))
+ .finally(() => setLoading(false));
+ }, [invoiceId, publicKey]);
+
+ if (loading) return null;
+
+ if (!installments || installments.length === 0) {
+ return (
+
+ Installment Schedule
+ No plan registered.
+
+ );
+ }
+
+ const now = Date.now() / 1000;
+ const nextDueIndex = installments.findIndex((inst) => !inst.paid && inst.dueDate >= now);
+
+ return (
+
+ Installment Schedule
+
+ {installments.map((inst, i) => {
+ const isNext = i === nextDueIndex;
+ const isPast = inst.paid || (!isNext && inst.dueDate < now);
+ return (
+ -
+
+ {inst.paid ? (
+ ✓ Paid
+ ) : isNext ? (
+ Next due
+ ) : (
+ #{i + 1}
+ )}
+
+ {new Date(inst.dueDate * 1000).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+
+
+ {formatAmount(inst.amount)} USDC
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx
new file mode 100644
index 0000000..215057f
--- /dev/null
+++ b/src/components/NotificationCenter.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+
+export interface AppNotification {
+ id: string;
+ type: "payment" | "funded" | "released";
+ invoiceId: string;
+ message: string;
+ timestamp: number;
+ read: boolean;
+}
+
+const STORAGE_KEY = "stellarsplit_notifications";
+
+function loadNotifications(): AppNotification[] {
+ if (typeof window === "undefined") return [];
+ try {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
+ } catch {
+ return [];
+ }
+}
+
+function saveNotifications(notifications: AppNotification[]) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
+}
+
+/**
+ * NotificationCenter — bell icon with unread badge, dropdown of recent events.
+ * Notifications are written to localStorage by the polling mechanism.
+ */
+export default function NotificationCenter() {
+ const [notifications, setNotifications] = useState([]);
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const router = useRouter();
+
+ // Load on mount and listen for storage changes from other tabs/polling
+ useEffect(() => {
+ setNotifications(loadNotifications());
+
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === STORAGE_KEY) setNotifications(loadNotifications());
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, []);
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
+ const unread = notifications.filter((n) => !n.read).length;
+
+ const markAllRead = () => {
+ const updated = notifications.map((n) => ({ ...n, read: true }));
+ saveNotifications(updated);
+ setNotifications(updated);
+ };
+
+ const handleClick = (n: AppNotification) => {
+ const updated = notifications.map((x) =>
+ x.id === n.id ? { ...x, read: true } : x
+ );
+ saveNotifications(updated);
+ setNotifications(updated);
+ setOpen(false);
+ router.push(`/invoice/${n.invoiceId}`);
+ };
+
+ const sorted = [...notifications].sort((a, b) => b.timestamp - a.timestamp);
+
+ return (
+
+
+
+ {open && (
+
+
+ Notifications
+ {unread > 0 && (
+
+ )}
+
+
+ {sorted.length === 0 ? (
+
+ No notifications yet.
+
+ ) : (
+
+ {sorted.map((n) => (
+ -
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx
index cb552d4..2ff82f4 100644
--- a/src/components/RecipientForm.tsx
+++ b/src/components/RecipientForm.tsx
@@ -1,5 +1,8 @@
"use client";
+import { useEffect, useRef, useState } from "react";
+import { searchEntries, addEntry, type AddressEntry } from "@/lib/addressBook";
+
interface RecipientRow {
address: string;
amount: string;
@@ -12,8 +15,13 @@ interface Props {
/**
* RecipientForm — dynamic add/remove rows for recipients and split amounts.
+ * Address input auto-suggests saved addresses from the address book.
*/
export default function RecipientForm({ recipients, onChange }: Props) {
+ const [suggestions, setSuggestions] = useState([]);
+ const [activeIndex, setActiveIndex] = useState(null);
+ const dropdownRef = useRef(null);
+
const update = (index: number, field: keyof RecipientRow, value: string) => {
const next = recipients.map((r, i) =>
i === index ? { ...r, [field]: value } : r
@@ -26,19 +34,82 @@ export default function RecipientForm({ recipients, onChange }: Props) {
const removeRow = (index: number) =>
onChange(recipients.filter((_, i) => i !== index));
+ const handleAddressChange = (index: number, value: string) => {
+ update(index, "address", value);
+ setActiveIndex(index);
+ if (value.trim().length >= 2) {
+ setSuggestions(searchEntries(value.trim()));
+ } else {
+ setSuggestions([]);
+ }
+ };
+
+ const selectSuggestion = (index: number, entry: AddressEntry) => {
+ update(index, "address", entry.address);
+ setSuggestions([]);
+ setActiveIndex(null);
+ };
+
+ const handleAddressBlur = () => {
+ // Delay to allow click on suggestion
+ setTimeout(() => {
+ setSuggestions([]);
+ setActiveIndex(null);
+ }, 150);
+ };
+
+ // Save address to book when a valid G... address is entered
+ const handleAddressSave = (address: string) => {
+ if (address.startsWith("G") && address.length >= 56) {
+ addEntry({ nickname: address.slice(0, 8) + "…", address });
+ }
+ };
+
return (
{recipients.map((row, i) => (