diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index db2f765..90baf4d 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -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"; @@ -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"; @@ -448,7 +450,9 @@ export default function InvoiceDetailPage({ params }: Props) { process.env.NEXT_PUBLIC_CONTRACT_ID ?? invoice.token; return ( -
+
+
+
{/* Reconnecting indicator */} {showReconnecting && (
@@ -750,7 +754,11 @@ export default function InvoiceDetailPage({ params }: Props) { onFocusChange={updateFocusedSection} className="mb-8" > - + {loading ? ( + + ) : ( + + )} {/* Split Calculator */} @@ -873,6 +881,11 @@ export default function InvoiceDetailPage({ params }: Props) { publicKey={publicKey} /> )} +
+
+ +
+
{showPayModal && invoice && publicKey && ( = { lg: "text-base px-4 py-1.5", }; -const STYLES: Record = { - 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 = { - 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 ( - {icon && } - {status} + {config.icon && } + {config.label} ); } diff --git a/src/components/illustrations/EmptyInvoices.tsx b/src/components/illustrations/EmptyInvoices.tsx new file mode 100644 index 0000000..1e5a13b --- /dev/null +++ b/src/components/illustrations/EmptyInvoices.tsx @@ -0,0 +1,18 @@ +export default function EmptyInvoices() { + return ( + + + + + + + + + + ); +} diff --git a/src/components/illustrations/EmptyRecipients.tsx b/src/components/illustrations/EmptyRecipients.tsx new file mode 100644 index 0000000..f6e6dc2 --- /dev/null +++ b/src/components/illustrations/EmptyRecipients.tsx @@ -0,0 +1,16 @@ +export default function EmptyRecipients() { + return ( + + + + + + + + ); +} diff --git a/src/components/illustrations/EmptySearch.tsx b/src/components/illustrations/EmptySearch.tsx new file mode 100644 index 0000000..69a95b1 --- /dev/null +++ b/src/components/illustrations/EmptySearch.tsx @@ -0,0 +1,16 @@ +export default function EmptySearch() { + return ( + + + + + + + + ); +} diff --git a/src/components/invoice/InvoiceSummaryPanel.tsx b/src/components/invoice/InvoiceSummaryPanel.tsx new file mode 100644 index 0000000..d9c7544 --- /dev/null +++ b/src/components/invoice/InvoiceSummaryPanel.tsx @@ -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 ( +
+
+

+ Status +

+ +
+ +
+

+ Total Amount +

+
+ +
+
+ +
+

+ Funded +

+
+ +
+
+ {fundedPercent.toFixed(1)}% of total +
+
+ +
+

+ Remaining +

+
+ +
+
+ +
+ {canPay && ( + + Pay Toward Invoice + + )} + {canRelease && ( + + )} + {canRefund && ( + + )} + {!canPay && !canRelease && !canRefund && ( +

+ No actions available for this invoice +

+ )} +
+
+ ); +} diff --git a/src/components/invoice/RecipientListSkeleton.tsx b/src/components/invoice/RecipientListSkeleton.tsx new file mode 100644 index 0000000..d739b0c --- /dev/null +++ b/src/components/invoice/RecipientListSkeleton.tsx @@ -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 ( + + + {Array.from({ length: count }).map((_, i) => ( + + + + + + + ))} + +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..e28d3fc --- /dev/null +++ b/src/components/ui/EmptyState.tsx @@ -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 ( +
+
+ {illustration} +
+

+ {heading} +

+

+ {description} +

+ {action &&
{action}
} +
+ ); +} diff --git a/src/lib/invoiceStatus.ts b/src/lib/invoiceStatus.ts new file mode 100644 index 0000000..698e31e --- /dev/null +++ b/src/lib/invoiceStatus.ts @@ -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 = { + 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", + }, +};