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

+ {/* Equal Split toggle */} +
+ Equal Split + +
+ + {/* Total amount input (equal split mode) */} + {equalSplit && ( +
+ + setTotalAmount(e.target.value)} + required + className="w-full 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" + /> + {perRecipientAmount && ( +

+ {perRecipientAmount} USDC per recipient +

+ )} +
+ )} + {/* Recipients */}
- +
{/* Token address */} diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx index cb552d4..be91673 100644 --- a/src/components/RecipientForm.tsx +++ b/src/components/RecipientForm.tsx @@ -8,12 +8,19 @@ interface RecipientRow { interface Props { recipients: RecipientRow[]; onChange: (rows: RecipientRow[]) => void; + equalSplit?: boolean; + amountOverride?: string; } /** * RecipientForm — dynamic add/remove rows for recipients and split amounts. */ -export default function RecipientForm({ recipients, onChange }: Props) { +export default function RecipientForm({ + recipients, + onChange, + equalSplit = false, + amountOverride, +}: Props) { const update = (index: number, field: keyof RecipientRow, value: string) => { const next = recipients.map((r, i) => i === index ? { ...r, [field]: value } : r @@ -37,18 +44,25 @@ export default function RecipientForm({ recipients, onChange }: Props) { onChange={(e) => update(i, "address", e.target.value)} required aria-label={`Recipient ${i + 1} address`} - className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono" + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono min-w-0" /> update(i, "amount", e.target.value)} + value={equalSplit ? (amountOverride ?? "") : row.amount} + onChange={ + equalSplit ? undefined : (e) => update(i, "amount", e.target.value) + } + readOnly={equalSplit} required aria-label={`Recipient ${i + 1} amount`} - className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + className={`w-28 bg-gray-800 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + equalSplit + ? "border-gray-600 text-gray-400 cursor-not-allowed" + : "border-gray-700" + }`} /> {recipients.length > 1 && ( +
+ + {/* Saved entries */} + {entries.length === 0 ? ( +

No saved addresses yet.

+ ) : ( + + )} + + ); +} diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 325594e..1cdbef6 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -5,6 +5,8 @@ import { splitClient } from "@/lib/stellar"; import { getFreighterPublicKey } from "@/lib/freighter"; import { formatAmount, parseAmount } from "@stellar-split/sdk"; import PaymentProgress from "@/components/PaymentProgress"; +import InstallmentPanel from "@/components/InstallmentPanel"; +import CommentSection from "@/components/CommentSection"; import type { Invoice } from "@stellar-split/sdk"; interface Props { @@ -116,6 +118,11 @@ export default function InvoiceDetailPage({ params }: Props) { + {/* Installment schedule — only shown to payers with a registered plan */} + {publicKey && ( + + )} + {/* Pay form */} {invoice.status === "Pending" && publicKey && (
@@ -151,6 +158,11 @@ export default function InvoiceDetailPage({ params }: Props) { This invoice is {invoice.status.toLowerCase()} and no longer accepts payments.

)} + + {/* 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 ( +
+ + StellarSplit + + +
{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)}

    +
    + +
  • + ))} +
+ )} + + +