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
163 changes: 76 additions & 87 deletions src/app/invoice/[id]/cosigners/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"use client";

import React, { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { QRCodeCanvas } from "qrcode.react";
import { useInvoiceStream } from "@/hooks/useInvoiceStream";
import type { CoSigner, SplitMeta } from "@/hooks/useSplitCalculator";
import { fetchInvoice } from "@/lib/api/invoices";
import CoSignerStatus from "@/components/invoice/CoSignerStatus";
import CopyLinkButton from "@/components/invoice/CopyLinkButton";

function truncateAddress(addr: string, chars = 6): string {
if (!addr || addr.length <= chars * 2) return addr;
Expand All @@ -14,19 +17,18 @@ function truncateAddress(addr: string, chars = 6): string {

export default function CoSignersPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { invoice, isConnected: streamConnected } = useInvoiceStream(id);
const { invoice, latestEvent, isConnected: streamConnected } = useInvoiceStream(id);

const [connectedAddress, setConnectedAddress] = useState<string | null>(null);
const [splitMeta, setSplitMeta] = useState<SplitMeta | null>(null);
const [cosigners, setCosigners] = useState<CoSigner[]>([]);
const [threshold, setThreshold] = useState<number>(1);
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState(false);
const [approveSuccess, setApproveSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [showQRModal, setShowQRModal] = useState(false);
const [copied, setCopied] = useState(false);
const lastEventRef = useRef<unknown>(null);

// Fetch connected wallet
useEffect(() => {
Expand All @@ -42,22 +44,23 @@ export default function CoSignersPage() {
checkWallet();
}, []);

// Fetch splitMeta and cosigners
// Fetch splitMeta and cosigners via API helper
const loadData = useCallback(async () => {
try {
const res = await fetch(`/api/invoices/${id}`);
if (res.ok) {
const data = await res.json();
if (data.splitMeta) {
setSplitMeta(data.splitMeta);
if (data.splitMeta.cosigners && data.splitMeta.cosigners.length > 0) {
setCosigners(data.splitMeta.cosigners);
setThreshold(data.splitMeta.cosignerThreshold || 1);
}
}
const data = await fetchInvoice(id);
const meta: SplitMeta | null = data?.splitMeta ?? null;
setSplitMeta(meta);
if (meta?.cosigners && meta.cosigners.length > 0) {
setCosigners(meta.cosigners);
setThreshold(meta.cosignerThreshold || 1);
} else {
setCosigners([]);
setThreshold(meta?.cosignerThreshold || 1);
}
setLoadError(null);
} catch (e) {
console.error("Error loading cosigners:", e);
setLoadError(e instanceof Error ? e.message : "Failed to load co-signers");
} finally {
setLoading(false);
}
Expand All @@ -67,6 +70,17 @@ export default function CoSignersPage() {
loadData();
}, [loadData]);

// Real-time refresh on stream events / invoice changes
useEffect(() => {
if (!latestEvent || latestEvent === lastEventRef.current) return;
lastEventRef.current = latestEvent;
loadData();
}, [latestEvent, loadData]);

useEffect(() => {
if (invoice) loadData();
}, [invoice, loadData]);

// Connect wallet helper
const handleConnectWallet = async () => {
try {
Expand All @@ -90,9 +104,18 @@ export default function CoSignersPage() {
setError(null);

try {
// Sign a message or approval transaction with Freighter
const { signAuthEntry } = await import("@stellar/freighter-api").catch(() => ({} as any));
// In case signAuthEntry is available or signTransaction
// Sign the approval payload with Freighter
const freighter = await import("@stellar/freighter-api");
const message = `Approve invoice ${id} as co-signer ${connectedAddress}`;
const encoded = Buffer.from(message, "utf8").toString("base64");
const signed = await freighter.signBlob(encoded, {
accountToSign: connectedAddress,
});
if (!signed || signed.error || !signed.signedBlob) {
throw new Error(
typeof signed?.error === "string" ? signed.error : "Signature was rejected"
);
}
const timestamp = new Date().toISOString();

const updated = cosigners.map((c) =>
Expand Down Expand Up @@ -129,7 +152,7 @@ export default function CoSignersPage() {
}
}

setApproveSuccess(true);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to record approval");
} finally {
Expand All @@ -139,14 +162,6 @@ export default function CoSignersPage() {

const shareUrl = typeof window !== "undefined" ? window.location.href : "";

const handleCopyLink = () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2500);
}
};

const approvedCount = cosigners.filter((c) => c.approved).length;
const isThresholdMet = cosigners.length > 0 && approvedCount >= threshold;
const isCosigner = Boolean(connectedAddress && cosigners.some((c) => c.address === connectedAddress));
Expand Down Expand Up @@ -181,15 +196,8 @@ export default function CoSignersPage() {
</div>

{/* Share buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyLink}
className="px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-200 text-xs font-medium transition-colors border border-gray-700 flex items-center gap-1.5"
aria-label="Copy co-signer invitation link"
>
{copied ? "✓ Copied!" : "Copy Link"}
</button>
<div className="flex items-center gap-2 flex-wrap">
<CopyLinkButton url={shareUrl} label="Copy Invitation Link" />
<button
type="button"
onClick={() => setShowQRModal(true)}
Expand Down Expand Up @@ -241,57 +249,40 @@ export default function CoSignersPage() {
</div>

{/* Co-Signers List */}
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 mb-8 shadow-xl">
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4 sm:p-6 mb-8 shadow-xl">
<h2 className="text-lg font-bold text-white mb-4">
Designated Co-Signers ({cosigners.length})
</h2>

{cosigners.length === 0 ? (
{loading ? (
<div className="flex items-center justify-center py-8 text-gray-400 gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading co-signers…</span>
</div>
) : loadError ? (
<div className="text-center py-8">
<p className="text-sm text-red-400 mb-3">{loadError}</p>
<button
type="button"
onClick={loadData}
className="px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-200 text-xs font-medium transition-colors border border-gray-700"
>
Retry
</button>
</div>
) : cosigners.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="text-sm">No co-signers were specified for this invoice.</p>
</div>
) : (
<div className="divide-y divide-gray-800">
{cosigners.map((signer, index) => {
const isYou = connectedAddress === signer.address;
return (
<div
key={index}
className="py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-gray-200 truncate" title={signer.address}>
{signer.address}
</span>
{isYou && (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-900/60 text-indigo-300 border border-indigo-700">
YOU
</span>
)}
</div>
{signer.approvedAt && (
<p className="text-xs text-gray-400 mt-0.5">
Approved on {new Date(signer.approvedAt).toLocaleString()}
</p>
)}
</div>

<div className="shrink-0">
{signer.approved ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-green-950/70 border border-green-700 text-green-300">
✓ Approved
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-yellow-950/70 border border-yellow-700 text-yellow-300">
⏳ Pending
</span>
)}
</div>
</div>
);
})}
</div>
<CoSignerStatus
cosigners={cosigners}
threshold={threshold}
connectedAddress={connectedAddress}
/>
)}
</div>

Expand Down Expand Up @@ -382,13 +373,11 @@ export default function CoSignersPage() {
</p>

<div className="flex gap-2">
<button
type="button"
onClick={handleCopyLink}
<CopyLinkButton
url={shareUrl}
label="Copy Link"
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold transition-colors"
>
{copied ? "Copied!" : "Copy Link"}
</button>
/>
<button
type="button"
onClick={() => setShowQRModal(false)}
Expand Down
42 changes: 42 additions & 0 deletions src/app/invoice/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,29 @@ function NewInvoiceForm() {
setStepErrors((prev) => ({ ...prev, [s]: "Deadline must be a positive number of days" }));
return false;
}
if (cosigners.length > 0) {
const invalid = cosigners.filter(
(addr) => !addr.startsWith("G") || addr.length < 50
);
if (invalid.length > 0) {
setStepErrors((prev) => ({
...prev,
[s]: "Each co-signer needs a valid G... address",
}));
return false;
}
if (
!Number.isInteger(cosignerThreshold) ||
cosignerThreshold < 1 ||
cosignerThreshold > cosigners.length
) {
setStepErrors((prev) => ({
...prev,
[s]: `Approval threshold must be between 1 and ${cosigners.length}`,
}));
return false;
}
}
setStepErrors((prev) => ({ ...prev, [s]: null }));
return true;
}
Expand Down Expand Up @@ -1109,6 +1132,25 @@ function NewInvoiceForm() {
setCosignerThreshold(thresh);
}}
/>

{cosigners.length > 0 && (
<div className="flex flex-col gap-2 rounded-lg bg-gray-800 border border-gray-700 px-4 py-3">
<p className="text-sm text-gray-300">
{cosignerThreshold} of {cosigners.length} co-signer approval
{cosigners.length === 1 ? "" : "s"} required before release.
</p>
{cosigners.some((addr) => !addr.startsWith("G") || addr.length < 50) && (
<p role="alert" className="text-xs text-red-400">
Each co-signer needs a valid G... address.
</p>
)}
{(cosignerThreshold < 1 || cosignerThreshold > cosigners.length) && (
<p role="alert" className="text-xs text-red-400">
Approval threshold must be between 1 and {cosigners.length}.
</p>
)}
</div>
)}
</div>
);
};
Expand Down
Loading