Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions src/app/address-book/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use client";

import { useEffect, useState } from "react";
import {
getAddressBook,
addEntry,
updateEntry,
removeEntry,
type AddressEntry,
} from "@/lib/addressBook";

/**
* Address Book page — manage saved Stellar addresses with nicknames.
*/
export default function AddressBookPage() {
const [entries, setEntries] = useState<AddressEntry[]>([]);
const [nickname, setNickname] = useState("");
const [address, setAddress] = useState("");
const [editAddress, setEditAddress] = useState<string | null>(null);
const [editNickname, setEditNickname] = useState("");

useEffect(() => {
setEntries(getAddressBook());
}, []);

const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
if (!nickname.trim() || !address.trim()) return;
const updated = addEntry({ nickname: nickname.trim(), address: address.trim() });
setEntries(updated);
setNickname("");
setAddress("");
};

const handleEdit = (addr: string) => {
const entry = entries.find((e) => e.address === addr);
if (!entry) return;
setEditAddress(addr);
setEditNickname(entry.nickname);
};

const handleSaveEdit = (addr: string) => {
const updated = updateEntry(addr, { nickname: editNickname.trim() });
setEntries(updated);
setEditAddress(null);
};

const handleRemove = (addr: string) => {
setEntries(removeEntry(addr));
};

return (
<main className="max-w-xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-8">Address Book</h1>

{/* Add new entry */}
<form onSubmit={handleAdd} className="flex flex-col gap-3 mb-10">
<h2 className="text-lg font-semibold">Add Address</h2>
<input
type="text"
placeholder="Nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
className="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"
/>
<input
type="text"
placeholder="G... Stellar address"
value={address}
onChange={(e) => setAddress(e.target.value)}
required
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
type="submit"
disabled={entries.length >= 50}
className="self-start px-5 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors disabled:opacity-40"
>
{entries.length >= 50 ? "Limit reached (50)" : "Save Address"}
</button>
</form>

{/* Saved entries */}
{entries.length === 0 ? (
<p className="text-sm text-gray-400">No saved addresses yet.</p>
) : (
<ul className="flex flex-col gap-2">
{entries.map((entry) => (
<li
key={entry.address}
className="flex items-center gap-3 bg-gray-900 rounded-lg px-4 py-3"
>
{editAddress === entry.address ? (
<div className="flex flex-1 gap-2 items-center">
<input
type="text"
value={editNickname}
onChange={(e) => setEditNickname(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
autoFocus
/>
<button
onClick={() => handleSaveEdit(entry.address)}
className="px-3 py-1 rounded bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold transition-colors"
>
Save
</button>
<button
onClick={() => setEditAddress(null)}
className="px-3 py-1 rounded bg-gray-700 hover:bg-gray-600 text-xs transition-colors"
>
Cancel
</button>
</div>
) : (
<>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-200">{entry.nickname}</p>
<p className="text-xs text-gray-400 font-mono truncate">{entry.address}</p>
</div>
<button
onClick={() => handleEdit(entry.address)}
aria-label={`Edit ${entry.nickname}`}
className="text-xs text-gray-400 hover:text-indigo-300 transition-colors"
>
Edit
</button>
<button
onClick={() => handleRemove(entry.address)}
aria-label={`Remove ${entry.nickname}`}
className="text-xs text-gray-400 hover:text-red-400 transition-colors"
>
Remove
</button>
</>
)}
</li>
))}
</ul>
)}
</main>
);
}
12 changes: 12 additions & 0 deletions src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -116,6 +118,11 @@ export default function InvoiceDetailPage({ params }: Props) {
</ul>
</section>

{/* Installment schedule — only shown to payers with a registered plan */}
{publicKey && (
<InstallmentPanel invoiceId={id} publicKey={publicKey} />
)}

{/* Pay form */}
{invoice.status === "Pending" && publicKey && (
<form onSubmit={handlePay} className="flex flex-col gap-4">
Expand Down Expand Up @@ -151,6 +158,11 @@ export default function InvoiceDetailPage({ params }: Props) {
This invoice is {invoice.status.toLowerCase()} and no longer accepts payments.
</p>
)}

{/* Private notes — only visible to the connected wallet */}
{publicKey && (
<CommentSection invoiceId={id} walletAddress={publicKey} />
)}
</main>
);
}
62 changes: 59 additions & 3 deletions src/app/invoice/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export default function NewInvoicePage() {
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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();
Expand All @@ -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),
Expand All @@ -58,12 +65,61 @@ export default function NewInvoicePage() {
<h1 className="text-3xl font-bold mb-8">Create Invoice</h1>

<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Equal Split toggle */}
<div className="flex items-center justify-between rounded-lg bg-gray-800 border border-gray-700 px-4 py-3">
<span className="text-sm font-medium text-gray-300">Equal Split</span>
<button
type="button"
role="switch"
aria-checked={equalSplit}
onClick={() => setEqualSplit((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
equalSplit ? "bg-indigo-600" : "bg-gray-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
equalSplit ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>

{/* Total amount input (equal split mode) */}
{equalSplit && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Total Amount (USDC)
</label>
<input
type="number"
placeholder="0.00"
step="0.0000001"
min="0.0000001"
value={totalAmount}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-gray-400">
{perRecipientAmount} USDC per recipient
</p>
)}
</div>
)}

{/* Recipients */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Recipients &amp; Amounts (USDC)
Recipients {equalSplit ? "" : "& Amounts (USDC)"}
</label>
<RecipientForm recipients={recipients} onChange={setRecipients} />
<RecipientForm
recipients={recipients}
onChange={setRecipients}
equalSplit={equalSplit}
amountOverride={perRecipientAmount}
/>
</div>

{/* Token address */}
Expand Down
15 changes: 15 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -11,6 +12,20 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en">
<body className="min-h-screen bg-gray-950 text-gray-100 antialiased">
<header className="sticky top-0 z-40 flex items-center justify-between px-6 py-3 bg-gray-950/80 backdrop-blur border-b border-gray-800">
<a href="/" className="font-bold text-lg tracking-tight">
StellarSplit
</a>
<div className="flex items-center gap-2">
<a
href="/address-book"
className="text-sm text-gray-400 hover:text-gray-200 transition-colors px-2 py-1"
>
Address Book
</a>
<NotificationCenter />
</div>
</header>
{children}
</body>
</html>
Expand Down
Loading