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
17 changes: 15 additions & 2 deletions src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import VotingPanel from "@/components/VotingPanel";
import DeadlineExtensionPanel from "@/components/DeadlineExtensionPanel";
import SuccessAnimation from "@/components/SuccessAnimation";
import RecipientPayoutTracker from "@/components/RecipientPayoutTracker";
import RecipientListSkeleton from "@/components/invoice/RecipientListSkeleton";
import CloneLineageTree from "@/components/CloneLineageTree";
import TransferOwnershipModal from "@/components/TransferOwnershipModal";
import StellarErrorBoundary from "@/components/error/StellarErrorBoundary";
Expand All @@ -54,6 +55,7 @@ import ActivityFeed from "@/components/ActivityFeed";
import InstallmentTracker from "@/components/InstallmentTracker";
import InstallmentPanel from "@/components/InstallmentPanel";
import InvoiceView from "@/components/invoice/InvoiceView";
import InvoiceSummaryPanel from "@/components/invoice/InvoiceSummaryPanel";
import CoCreatorPanel from "@/components/CoCreatorPanel";
import PaymentChannelPanel from "@/components/PaymentChannelPanel";
import DisputeTimeline from "@/components/DisputeTimeline";
Expand Down Expand Up @@ -448,7 +450,9 @@ export default function InvoiceDetailPage({ params }: Props) {
process.env.NEXT_PUBLIC_CONTRACT_ID ?? invoice.token;

return (
<main className="w-full max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 overflow-x-hidden">
<main className="w-full max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 overflow-x-hidden">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6">
<div className="min-w-0">
{/* Reconnecting indicator */}
{showReconnecting && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-yellow-600 text-white px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 animate-pulse">
Expand Down Expand Up @@ -750,7 +754,11 @@ export default function InvoiceDetailPage({ params }: Props) {
onFocusChange={updateFocusedSection}
className="mb-8"
>
<RecipientPayoutTracker invoice={invoice} publicKey={publicKey} />
{loading ? (
<RecipientListSkeleton count={3} />
) : (
<RecipientPayoutTracker invoice={invoice} publicKey={publicKey} />
)}
</InvoiceSection>

{/* Split Calculator */}
Expand Down Expand Up @@ -873,6 +881,11 @@ export default function InvoiceDetailPage({ params }: Props) {
publicKey={publicKey}
/>
)}
</div>
<div className="lg:sticky lg:top-4 lg:max-h-screen lg:overflow-y-auto">
<InvoiceSummaryPanel invoice={invoice} total={total} publicKey={publicKey} />
</div>
</div>

{showPayModal && invoice && publicKey && (
<PayModal
Expand Down
33 changes: 7 additions & 26 deletions src/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { Invoice } from "@stellar-split/sdk";

type InvoiceStatus = Invoice["status"] | "Archived" | "Expired";
import { STATUS_CONFIG, type InvoiceStatus } from "@/lib/invoiceStatus";

interface Props {
status: InvoiceStatus;
Expand All @@ -13,39 +11,22 @@ const SIZE: Record<string, string> = {
lg: "text-base px-4 py-1.5",
};

const STYLES: Record<string, string> = {
Pending: "bg-yellow-500/20 text-yellow-400",
Active: "bg-blue-500/20 text-blue-400",
Funded: "bg-cyan-500/20 text-cyan-400",
Released: "bg-green-500/20 text-green-400",
Refunded: "bg-gray-500/20 text-gray-400",
Disputed: "bg-red-500/20 text-red-400",
Frozen: "bg-indigo-500/20 text-indigo-400",
Archived: "bg-stone-500/20 text-stone-400",
Expired: "bg-orange-500/20 text-orange-400",
};

const ICON: Record<string, string> = {
Released: "✓",
Disputed: "⚠",
Frozen: "🔒",
};

/**
* StatusBadge — colour-coded chip for every invoice state.
* Consumes centralized STATUS_CONFIG from src/lib/invoiceStatus.ts
*/
export default function StatusBadge({ status, size = "md" }: Props) {
const icon = ICON[status];
const style = STYLES[status] ?? "bg-gray-500/20 text-gray-400";
const config = STATUS_CONFIG[status];
if (!config) return null;

return (
<span
role="status"
aria-label={`Status: ${status}`}
className={`inline-flex items-center gap-1 rounded-full font-semibold ${SIZE[size]} ${style}`}
className={`inline-flex items-center gap-1 rounded-full font-semibold ${SIZE[size]} ${config.colorClass}`}
>
{icon && <span aria-hidden="true">{icon}</span>}
{status}
{config.icon && <span aria-hidden="true">{config.icon}</span>}
{config.label}
</span>
);
}
18 changes: 18 additions & 0 deletions src/components/illustrations/EmptyInvoices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function EmptyInvoices() {
return (
<svg
viewBox="0 0 96 96"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-full h-full"
>
<rect x="16" y="24" width="56" height="64" rx="4" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" />
<line x1="24" y1="32" x2="72" y2="32" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" />
<line x1="24" y1="40" x2="56" y2="40" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="1.5" />
<line x1="24" y1="48" x2="56" y2="48" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="1.5" />
<line x1="24" y1="56" x2="56" y2="56" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="1.5" />
<circle cx="64" cy="48" r="8" className="fill-gray-400 dark:fill-gray-600" />
<path d="M64 46v4m-2-2h4" className="stroke-gray-100 dark:stroke-gray-800" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
16 changes: 16 additions & 0 deletions src/components/illustrations/EmptyRecipients.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default function EmptyRecipients() {
return (
<svg
viewBox="0 0 96 96"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-full h-full"
>
<circle cx="48" cy="32" r="12" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" />
<path d="M32 48c0-8.837 3.582-16 16-16s16 7.163 16 16" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" strokeLinecap="round" />
<circle cx="60" cy="60" r="12" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" />
<line x1="60" y1="54" x2="60" y2="66" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" strokeLinecap="round" />
<line x1="54" y1="60" x2="66" y2="60" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
16 changes: 16 additions & 0 deletions src/components/illustrations/EmptySearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default function EmptySearch() {
return (
<svg
viewBox="0 0 96 96"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-full h-full"
>
<circle cx="40" cy="40" r="20" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" />
<line x1="56" y1="56" x2="68" y2="68" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" strokeLinecap="round" />
<line x1="28" y1="40" x2="52" y2="40" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" strokeLinecap="round" opacity="0.5" />
<circle cx="72" cy="72" r="12" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" />
<line x1="68" y1="68" x2="76" y2="76" className="stroke-gray-400 dark:stroke-gray-600" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
103 changes: 103 additions & 0 deletions src/components/invoice/InvoiceSummaryPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import Link from "next/link";
import { formatAmount, type Invoice } from "@stellar-split/sdk";
import StatusBadge from "@/components/StatusBadge";
import { STATUS_CONFIG } from "@/lib/invoiceStatus";
import AmountDisplay from "./AmountDisplay";

interface Props {
invoice: Invoice;
total: bigint;
publicKey: string | null;
}

/**
* InvoiceSummaryPanel — sticky sidebar showing totals, funded amount, and status.
* Remains visible while scrolling through the recipient list on lg+ screens.
* On mobile, displays below the recipient list without sticky behavior.
*/
export default function InvoiceSummaryPanel({ invoice, total, publicKey }: Props) {
const remaining = total - invoice.funded;
const fundedPercent = total > 0n ? Number((invoice.funded * 100n) / total) : 0;

const canPay =
invoice.status === "Pending" && publicKey && publicKey !== invoice.creator;
const canRelease =
invoice.status === "Funded" && publicKey === invoice.creator;
const canRefund =
invoice.status === "Released" && publicKey === invoice.creator;

return (
<div className="bg-gray-900/60 border border-gray-800 rounded-xl p-6 space-y-6">
<div>
<h2 className="text-sm uppercase tracking-wide text-gray-500 mb-2">
Status
</h2>
<StatusBadge status={invoice.status as any} size="md" />
</div>

<div>
<h3 className="text-sm uppercase tracking-wide text-gray-500 mb-2">
Total Amount
</h3>
<div className="text-3xl font-bold text-white">
<AmountDisplay amount={total} />
</div>
</div>

<div>
<h3 className="text-sm uppercase tracking-wide text-gray-500 mb-2">
Funded
</h3>
<div className="text-2xl font-bold text-green-400">
<AmountDisplay amount={invoice.funded} />
</div>
<div className="text-xs text-gray-400 mt-1">
{fundedPercent.toFixed(1)}% of total
</div>
</div>

<div>
<h3 className="text-sm uppercase tracking-wide text-gray-500 mb-2">
Remaining
</h3>
<div className="text-2xl font-bold text-yellow-400">
<AmountDisplay amount={remaining} />
</div>
</div>

<div className="pt-4 border-t border-gray-800">
{canPay && (
<Link
href={`/invoice/${invoice.id}#pay-heading`}
className="block min-h-11 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-center transition-colors"
>
Pay Toward Invoice
</Link>
)}
{canRelease && (
<button
disabled
className="block w-full min-h-11 px-4 py-2 rounded-xl bg-green-600 hover:bg-green-500 text-white font-semibold transition-colors"
>
Release Funds
</button>
)}
{canRefund && (
<button
disabled
className="block w-full min-h-11 px-4 py-2 rounded-xl bg-gray-600 hover:bg-gray-500 text-white font-semibold transition-colors"
>
Refund
</button>
)}
{!canPay && !canRelease && !canRefund && (
<p className="text-sm text-gray-400 text-center py-2">
No actions available for this invoice
</p>
)}
</div>
</div>
);
}
40 changes: 40 additions & 0 deletions src/components/invoice/RecipientListSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const shimmer = "animate-pulse bg-gray-200 dark:bg-gray-700 rounded";

interface Props {
count?: number;
}

/**
* RecipientListSkeleton — matches RecipientPayoutTracker table row structure.
* Each skeleton row matches real row height and internal proportions.
*/
export default function RecipientListSkeleton({ count = 3 }: Props) {
return (
<table className="w-full">
<tbody>
{Array.from({ length: count }).map((_, i) => (
<tr
key={i}
className="border-b border-gray-700/50"
role="status"
aria-busy="true"
aria-label="Loading recipient data"
>
<td className="px-4 py-3 text-sm">
<div className={`${shimmer} h-4 w-48`} />
</td>
<td className="px-4 py-3 text-sm text-right">
<div className={`${shimmer} h-4 w-20 ml-auto`} />
</td>
<td className="px-4 py-3 text-sm text-right">
<div className={`${shimmer} h-5 w-16 ml-auto rounded-full`} />
</td>
<td className="px-4 py-3 text-sm text-right">
<div className={`${shimmer} h-8 w-20 ml-auto`} />
</td>
</tr>
))}
</tbody>
</table>
);
}
40 changes: 40 additions & 0 deletions src/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";

interface Props {
illustration: ReactNode;
heading: string;
description: string;
action?: ReactNode;
className?: string;
}

/**
* EmptyState — displays an illustration, heading, description, and optional CTA
* when a list or section has no data. Supports both light and dark modes via inline SVG.
*/
export default function EmptyState({
illustration,
heading,
description,
action,
className = "",
}: Props) {
return (
<div
className={`flex flex-col items-center justify-center py-12 px-4 text-center ${className}`}
role="status"
aria-label={heading}
>
<div className="mb-4 w-24 h-24 text-gray-400 dark:text-gray-600">
{illustration}
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{heading}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6 max-w-sm">
{description}
</p>
{action && <div className="flex gap-2">{action}</div>}
</div>
);
}
52 changes: 52 additions & 0 deletions src/lib/invoiceStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Invoice } from "@stellar-split/sdk";

export type InvoiceStatus = Invoice["status"] | "Archived" | "Expired";

export interface StatusConfig {
label: string;
colorClass: string;
icon?: string;
}

export const STATUS_CONFIG: Record<InvoiceStatus, StatusConfig> = {
Pending: {
label: "Pending",
colorClass: "bg-yellow-500/20 text-yellow-400",
icon: "⏳",
},
Active: {
label: "Active",
colorClass: "bg-blue-500/20 text-blue-400",
},
Funded: {
label: "Funded",
colorClass: "bg-cyan-500/20 text-cyan-400",
},
Released: {
label: "Released",
colorClass: "bg-green-500/20 text-green-400",
icon: "✓",
},
Refunded: {
label: "Refunded",
colorClass: "bg-gray-500/20 text-gray-400",
},
Disputed: {
label: "Disputed",
colorClass: "bg-red-500/20 text-red-400",
icon: "⚠",
},
Frozen: {
label: "Frozen",
colorClass: "bg-indigo-500/20 text-indigo-400",
icon: "🔒",
},
Archived: {
label: "Archived",
colorClass: "bg-stone-500/20 text-stone-400",
},
Expired: {
label: "Expired",
colorClass: "bg-orange-500/20 text-orange-400",
},
};