Skip to content
Merged
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
139 changes: 139 additions & 0 deletions src/components/CoSignerStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use client';

import React from 'react';

export interface CoSigner {
address: string;
approved: boolean;
approvedAt?: string | number | Date | null;
}

export interface CoSignerStatusProps {
cosigners: CoSigner[];
threshold: number;
connectedAddress?: string | null;
approving?: boolean;
onApprove?: (address: string) => void | Promise<void>;
error?: string | null;
}

function truncateAddress(address: string): string {
if (!address) return '';
if (address.length <= 12) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}

function formatApprovedAt(value: string | number | Date): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
} catch {
return String(value);
}
}

export default function CoSignerStatus({
cosigners,
threshold,
connectedAddress,
approving = false,
onApprove,
error,
}: CoSignerStatusProps) {
const approvedCount = cosigners.filter((c) => c.approved).length;
const thresholdMet = approvedCount >= threshold;
const normalizedConnected = connectedAddress?.trim() ?? '';

return (
<div className="w-full rounded-lg border border-gray-200 bg-white p-4 shadow-sm sm:p-6">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Co-signer approvals
</h2>
<span
className={`inline-flex w-fit items-center rounded-full px-3 py-1 text-xs font-medium ${
thresholdMet
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
data-testid="threshold-indicator"
>
{thresholdMet
? 'Threshold met'
: `${approvedCount} of ${threshold} approved`}
</span>
</div>

{error ? (
<div
role="alert"
className="mb-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700"
>
{error}
</div>
) : null}

{cosigners.length === 0 ? (
<p className="text-sm text-gray-500">No co-signers required.</p>
) : (
<ul className="divide-y divide-gray-100">
{cosigners.map((cosigner) => {
const isConnected =
normalizedConnected.length > 0 &&
normalizedConnected === cosigner.address;
const canApprove =
isConnected && !cosigner.approved && !approving && !!onApprove;

return (
<li
key={cosigner.address}
className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between"
data-testid={`cosigner-${cosigner.address}`}
>
<div className="flex min-w-0 flex-col gap-1">
<span
className="truncate font-mono text-sm text-gray-900"
title={cosigner.address}
>
{truncateAddress(cosigner.address)}
</span>
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
cosigner.approved
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-700'
}`}
data-testid={`status-${cosigner.address}`}
>
{cosigner.approved ? 'Approved' : 'Pending'}
</span>
{cosigner.approved && cosigner.approvedAt ? (
<span className="text-xs text-gray-500">
{formatApprovedAt(cosigner.approvedAt)}
</span>
) : null}
</div>
</div>

<div className="flex items-center sm:justify-end">
{canApprove ? (
<button
type="button"
onClick={() => onApprove?.(cosigner.address)}
disabled={approving}
className="inline-flex w-full items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
>
{approving ? 'Approving...' : 'Approve'}
</button>
) : null}
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
Loading