Skip to content

Commit 22af028

Browse files
authored
feat: wire up FundingProgress, StatusBadge, and Skeleton components (#297 #298 #299) (#336)
- InvoiceCard: use FundingProgress (compact) and StatusBadge (sm) - Invoice detail page: use InvoiceDetailSkeleton for loading state, StatusBadge (md) in header, FundingProgress (full label) for progress - DashboardClient: use InvoiceListSkeleton for initial load
1 parent 0cf506d commit 22af028

6 files changed

Lines changed: 249 additions & 60 deletions

File tree

src/app/invoice/[id]/page.tsx

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import { useRouter } from "next/navigation";
55
import { splitClient, payWithNonce } from "@/lib/stellar";
66
import { getFreighterPublicKey } from "@/lib/freighter";
77
import { formatAmount, parseAmount, truncateAddress } from "@stellar-split/sdk";
8-
import type { Invoice, Payment } from "@stellar-split/sdk";
9-
import PaymentProgress from "@/components/PaymentProgress";
8+
import { useInvoiceCustomization } from "@/lib/customization";
9+
import type { Locale } from "@/lib/i18n";
10+
import PaymentSuggestions from "@/components/PaymentSuggestions";
11+
import FundingProgress from "@/components/FundingProgress";
12+
import StatusBadge from "@/components/StatusBadge";
13+
import { InvoiceDetailSkeleton } from "@/components/Skeleton";
1014
import PayModal from "@/components/PayModal";
1115
import PaymentMethodSelector from "@/components/PaymentMethodSelector";
1216
import CountdownTimer from "@/components/CountdownTimer";
@@ -127,29 +131,14 @@ export default function InvoiceDetailPage({ params }: Props) {
127131

128132
if (error && !invoice) {
129133
return (
130-
<main className="max-w-2xl mx-auto px-4 sm:px-6 py-20 text-center">
131-
<div className="bg-red-950/40 border border-red-800 rounded-xl p-6">
132-
<p className="text-red-400 text-lg mb-2">Failed to load invoice</p>
133-
<p className="text-red-300/70 text-sm">{error}</p>
134-
<button
135-
type="button"
136-
onClick={() => { setLoading(true); setError(null); load(); }}
137-
className="mt-4 px-4 py-2 rounded-lg bg-red-700 hover:bg-red-600 text-sm font-medium transition-colors"
138-
>
139-
Retry
140-
</button>
141-
</div>
134+
<main className="max-w-xl mx-auto w-full px-4 sm:px-6 py-20 overflow-x-hidden">
135+
<InvoiceDetailSkeleton />
142136
</main>
143137
);
144138
}
145139

146140
if (!invoice) return null;
147141

148-
const isCreator = publicKey === invoice.creator;
149-
const status = statusConfig[invoice.status] ?? statusConfig.Pending;
150-
const remaining = total - invoice.funded;
151-
const percentFunded = total > 0n ? Number((invoice.funded * 100n) / total) : 0;
152-
153142
return (
154143
<main className="max-w-2xl mx-auto px-4 sm:px-6 py-16">
155144
{/* Header */}
@@ -165,7 +154,14 @@ export default function InvoiceDetailPage({ params }: Props) {
165154
<span>{status.label}</span>
166155
</span>
167156
</div>
168-
<div className="flex items-center gap-2">
157+
158+
<div className="mb-6">
159+
<FlowDiagram invoice={invoice} />
160+
</div>
161+
162+
<CloneLineageTree invoiceId={id} />
163+
<StatusBadge status={invoice.status as any} size="md" />
164+
<div className="ml-auto flex items-center gap-2 print:hidden flex-wrap justify-end">
169165
<CopyLinkButton url={`${typeof window !== "undefined" ? window.location.origin : ""}/verify/${id}`} />
170166
{isCreator && invoice.status === "Pending" && (
171167
<>
@@ -217,22 +213,20 @@ export default function InvoiceDetailPage({ params }: Props) {
217213
</div>
218214

219215
{/* Progress */}
220-
<section className="mb-8">
221-
<div className="flex items-center justify-between mb-2">
222-
<h2 className="text-lg font-semibold text-white">Funding Progress</h2>
223-
<span className="text-sm text-gray-400">{percentFunded}%</span>
224-
</div>
225-
<PaymentProgress funded={invoice.funded} total={total} />
226-
<div className="flex justify-between mt-2">
227-
<p className="text-sm text-gray-400">
228-
<span className="text-indigo-300 font-medium">{formatAmount(invoice.funded)} USDC</span>
229-
{" / "}
230-
{formatAmount(total)} USDC
216+
<section aria-labelledby="progress-heading" className="mb-8">
217+
<h2 id="progress-heading" className="sr-only">Payment Progress</h2>
218+
<FundingProgress funded={invoice.funded} total={total} token={invoice.token || "USDC"} />
219+
{channelState?.opened && (
220+
<p className="text-sm text-indigo-300 mt-1">
221+
· Channel balance: {formatAmount(channelState.balance)} USDC
231222
</p>
232-
{remaining > 0n && (
233-
<p className="text-sm text-gray-500">{formatAmount(remaining)} USDC remaining</p>
234-
)}
235-
</div>
223+
)}
224+
{invoice.deadline > 0 && (
225+
<div className="flex items-center gap-2 mt-3">
226+
<span className="text-sm text-gray-400">Time remaining:</span>
227+
<CountdownTimer deadline={invoice.deadline} />
228+
</div>
229+
)}
236230
</section>
237231

238232
{/* QR */}

src/components/DashboardClient.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { splitClient } from "@/lib/stellar";
66
import { getFreighterPublicKey } from "@/lib/freighter";
77
import InvoiceSearch from "@/components/InvoiceSearch";
88
import InvoiceCard from "@/components/InvoiceCard";
9-
import { SkeletonCard } from "@/components/Skeleton";
9+
import { InvoiceListSkeleton } from "@/components/Skeleton";
1010
import BatchPayModal from "@/components/BatchPayModal";
1111
import { setBulkReminders, type BulkReminderResult } from "@/lib/reminders";
1212
import { getOrAssignDisplayNumber } from "@/lib/invoiceNumbering";
@@ -390,11 +390,7 @@ export default function DashboardClient() {
390390

391391
{/* Invoice Grid / Empty State */}
392392
{loading && invoices.length === 0 ? (
393-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
394-
{[...Array(6)].map((_, i) => (
395-
<SkeletonCard key={i} />
396-
))}
397-
</div>
393+
<InvoiceListSkeleton />
398394
) : invoices.length === 0 ? (
399395
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-12 text-center">
400396
<h2 className="text-xl font-semibold text-gray-300 mb-2">No invoices yet</h2>

src/components/FundingProgress.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { formatAmount } from "@stellar-split/sdk";
5+
6+
interface Props {
7+
funded: bigint;
8+
total: bigint;
9+
token: string;
10+
/** compact hides the text label */
11+
compact?: boolean;
12+
}
13+
14+
function getBarColor(pct: number): string {
15+
if (pct === 0) return "bg-gray-500";
16+
if (pct < 50) return "bg-yellow-500";
17+
if (pct < 100) return "bg-blue-500";
18+
return "bg-green-500";
19+
}
20+
21+
/**
22+
* FundingProgress — animated horizontal bar with colour transitions.
23+
* Animates from 0 → actual value on first render (600 ms).
24+
*/
25+
export default function FundingProgress({ funded, total, token, compact = false }: Props) {
26+
const rawPct = total === 0n ? 0 : Number((funded * 100n) / total);
27+
const clamped = Math.min(100, Math.max(0, rawPct));
28+
29+
const [width, setWidth] = useState(0);
30+
31+
useEffect(() => {
32+
// Defer so the CSS transition can animate from 0
33+
const id = requestAnimationFrame(() => setWidth(clamped));
34+
return () => cancelAnimationFrame(id);
35+
}, [clamped]);
36+
37+
const label = `${formatAmount(funded)} ${token} of ${formatAmount(total)} ${token} funded (${clamped}%)`;
38+
39+
return (
40+
<div>
41+
{!compact && (
42+
<p className="text-xs text-gray-400 mb-1">{label}</p>
43+
)}
44+
<div
45+
role="progressbar"
46+
aria-valuenow={clamped}
47+
aria-valuemin={0}
48+
aria-valuemax={100}
49+
aria-label={label}
50+
className="w-full bg-gray-700 rounded-full h-2 overflow-hidden"
51+
>
52+
<div
53+
className={`h-full rounded-full transition-all duration-[600ms] ease-out ${getBarColor(clamped)}`}
54+
style={{ width: `${width}%` }}
55+
/>
56+
</div>
57+
</div>
58+
);
59+
}

src/components/InvoiceCard.tsx

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { formatAmount, truncateAddress } from "@stellar-split/sdk";
1+
import { truncateAddress } from "@stellar-split/sdk";
22
import type { Invoice } from "@stellar-split/sdk";
3-
import PaymentProgress from "./PaymentProgress";
3+
import FundingProgress from "./FundingProgress";
4+
import StatusBadge from "./StatusBadge";
45
import CountdownTimer from "./CountdownTimer";
56

67
interface Props {
@@ -9,13 +10,10 @@ interface Props {
910
title?: string;
1011
}
1112

12-
const STATUS_STYLES: Record<string, string> = {
13-
Pending: "bg-yellow-500/20 text-yellow-600 dark:text-yellow-300",
14-
Released: "bg-green-500/20 text-green-600 dark:text-green-300",
15-
Refunded: "bg-gray-500/20 text-gray-600 dark:text-gray-300",
16-
};
17-
18-
export default function InvoiceCard({ invoice, displayNumber, title }: Props) {
13+
/**
14+
* InvoiceCard — summary card showing recipients, total, funded %, and status.
15+
*/
16+
export default function InvoiceCard({ invoice, displayNumber }: Props) {
1917
const total = invoice.recipients.reduce((s, r) => s + r.amount, 0n);
2018
const deadlineLabel = new Date(invoice.deadline * 1000).toLocaleDateString();
2119

@@ -31,12 +29,8 @@ export default function InvoiceCard({ invoice, displayNumber, title }: Props) {
3129
{displayNumber}
3230
</span>
3331
)}
34-
</div>
35-
<span
36-
className={`text-xs px-2 py-0.5 rounded-full font-semibold shrink-0 ${STATUS_STYLES[invoice.status]}`}
37-
>
38-
{invoice.status}
3932
</span>
33+
<StatusBadge status={invoice.status as any} size="sm" />
4034
</div>
4135

4236
<p className="text-xs text-gray-500 mb-3">Due {deadlineLabel}</p>
@@ -52,12 +46,13 @@ export default function InvoiceCard({ invoice, displayNumber, title }: Props) {
5246
))}
5347
</div>
5448

55-
<div className="mt-auto">
56-
<PaymentProgress funded={invoice.funded} total={total} />
49+
{/* Progress — compact (no label) */}
50+
<FundingProgress funded={invoice.funded} total={total} token={invoice.token || "USDC"} compact />
5751

58-
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-500 mt-1">
59-
<span>{formatAmount(invoice.funded)} USDC</span>
60-
<span>{formatAmount(total)} USDC</span>
52+
{invoice.deadline > 0 && (
53+
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-800">
54+
<span className="text-xs text-gray-500">Deadline</span>
55+
<CountdownTimer deadline={invoice.deadline} compact />
6156
</div>
6257

6358
{invoice.deadline > 0 && (

src/components/Skeleton.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,97 @@ export function SkeletonRow() {
3636
export function SkeletonProgress() {
3737
return <div className={`${shimmer} h-2 w-full`} />;
3838
}
39+
40+
/**
41+
* InvoiceCardSkeleton — matches InvoiceCard layout.
42+
* aria-busy indicates loading state.
43+
*/
44+
export function InvoiceCardSkeleton() {
45+
return (
46+
<div
47+
aria-busy="true"
48+
aria-label="Loading invoice data"
49+
className="bg-gray-900 rounded-xl p-4 sm:p-5"
50+
>
51+
<div className="flex items-center justify-between mb-3">
52+
<div className={`${shimmer} h-4 w-24`} />
53+
<div className={`${shimmer} h-5 w-16 rounded-full`} />
54+
</div>
55+
<div className={`${shimmer} h-3 w-20 mb-3`} />
56+
<div className="flex gap-1 mb-3">
57+
<div className={`${shimmer} h-5 w-28`} />
58+
<div className={`${shimmer} h-5 w-28`} />
59+
</div>
60+
<div className={`${shimmer} h-2 w-full mb-1`} />
61+
<div className="flex justify-between">
62+
<div className={`${shimmer} h-3 w-24`} />
63+
<div className={`${shimmer} h-3 w-20`} />
64+
</div>
65+
</div>
66+
);
67+
}
68+
69+
/**
70+
* InvoiceDetailSkeleton — matches invoice detail page sections:
71+
* header, progress bar, recipients, and history.
72+
*/
73+
export function InvoiceDetailSkeleton() {
74+
return (
75+
<div
76+
aria-busy="true"
77+
aria-label="Loading invoice data"
78+
className="space-y-6"
79+
>
80+
{/* Header */}
81+
<div className="flex items-center justify-between">
82+
<div className={`${shimmer} h-8 w-48`} />
83+
<div className={`${shimmer} h-7 w-20 rounded-full`} />
84+
</div>
85+
86+
{/* Progress bar */}
87+
<div className="space-y-2">
88+
<div className={`${shimmer} h-3 w-64`} />
89+
<div className={`${shimmer} h-3 w-full`} />
90+
</div>
91+
92+
{/* Recipients */}
93+
<div className="space-y-2">
94+
<div className={`${shimmer} h-4 w-28`} />
95+
{[0, 1, 2].map((i) => (
96+
<div key={i} className="flex justify-between">
97+
<div className={`${shimmer} h-4 w-48`} />
98+
<div className={`${shimmer} h-4 w-24`} />
99+
</div>
100+
))}
101+
</div>
102+
103+
{/* Payment history */}
104+
<div className="space-y-2">
105+
<div className={`${shimmer} h-4 w-36`} />
106+
{[0, 1].map((i) => (
107+
<div key={i} className="flex justify-between bg-gray-900 rounded-lg px-4 py-2">
108+
<div className={`${shimmer} h-4 w-40`} />
109+
<div className={`${shimmer} h-4 w-20`} />
110+
</div>
111+
))}
112+
</div>
113+
</div>
114+
);
115+
}
116+
117+
/**
118+
* InvoiceListSkeleton — 6 InvoiceCardSkeleton cards in a grid.
119+
*/
120+
export function InvoiceListSkeleton() {
121+
return (
122+
<div
123+
aria-busy="true"
124+
aria-label="Loading invoice data"
125+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
126+
>
127+
{[...Array(6)].map((_, i) => (
128+
<InvoiceCardSkeleton key={i} />
129+
))}
130+
</div>
131+
);
132+
}

src/components/StatusBadge.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Invoice } from "@stellar-split/sdk";
2+
3+
type InvoiceStatus = Invoice["status"] | "Archived" | "Expired";
4+
5+
interface Props {
6+
status: InvoiceStatus;
7+
size?: "sm" | "md" | "lg";
8+
}
9+
10+
const SIZE: Record<string, string> = {
11+
sm: "text-xs px-2 py-0.5",
12+
md: "text-sm px-3 py-1",
13+
lg: "text-base px-4 py-1.5",
14+
};
15+
16+
const STYLES: Record<string, string> = {
17+
Pending: "bg-yellow-500/20 text-yellow-400",
18+
Active: "bg-blue-500/20 text-blue-400",
19+
Funded: "bg-cyan-500/20 text-cyan-400",
20+
Released: "bg-green-500/20 text-green-400",
21+
Refunded: "bg-gray-500/20 text-gray-400",
22+
Disputed: "bg-red-500/20 text-red-400",
23+
Frozen: "bg-indigo-500/20 text-indigo-400",
24+
Archived: "bg-stone-500/20 text-stone-400",
25+
Expired: "bg-orange-500/20 text-orange-400",
26+
};
27+
28+
const ICON: Record<string, string> = {
29+
Released: "✓",
30+
Disputed: "⚠",
31+
Frozen: "🔒",
32+
};
33+
34+
/**
35+
* StatusBadge — colour-coded chip for every invoice state.
36+
*/
37+
export default function StatusBadge({ status, size = "md" }: Props) {
38+
const icon = ICON[status];
39+
const style = STYLES[status] ?? "bg-gray-500/20 text-gray-400";
40+
41+
return (
42+
<span
43+
role="status"
44+
aria-label={`Status: ${status}`}
45+
className={`inline-flex items-center gap-1 rounded-full font-semibold ${SIZE[size]} ${style}`}
46+
>
47+
{icon && <span aria-hidden="true">{icon}</span>}
48+
{status}
49+
</span>
50+
);
51+
}

0 commit comments

Comments
 (0)