Skip to content

Commit 7e2cc5c

Browse files
Happybello365Stellar Wave DeveloperKingsman-99
authored
feat: Feat/payment velocity chart (#174)
* feat: add contract upgrade notifier banner * feat: add recipient onboarding modal * fix: remove unresolvable @stellar/walletconnect-kit and next-intl deps, add local stub * feat: add payment velocity chart to invoice detail page --------- Co-authored-by: Stellar Wave Developer <dev@stellar-wave.dev> Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 6959011 commit 7e2cc5c

7 files changed

Lines changed: 335 additions & 3 deletions

File tree

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@
1212
"dependencies": {
1313
"@stellar-split/sdk": "^0.1.0",
1414
"@stellar/freighter-api": "^3.0.0",
15-
"@stellar/walletconnect-kit": "^0.1.0",
1615
"next": "14.2.3",
17-
"next-intl": "^3.0.0",
1816
"qrcode.react": "4.2.0",
1917
"react": "^18.3.0",
2018
"react-dom": "^18.3.0",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import CommentSection from "@/components/CommentSection";
3030
import StatusTimeline from "@/components/StatusTimeline";
3131
import EscrowPanel from "@/components/EscrowPanel";
3232
import ActivityFeed from "@/components/ActivityFeed";
33+
import VelocityChart from "@/components/VelocityChart";
3334
import VestingTimeline from "@/components/VestingTimeline";
3435
import PresenceIndicators from "@/components/PresenceIndicators";
3536
import CollaborationCursors from "@/components/CollaborationCursors";

src/app/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export default function RootLayout({
6868
<UpgradeBanner />
6969
<ErrorBoundary>{children}</ErrorBoundary>
7070
<OnboardingFlow />
71+
<RecipientOnboarding />
7172
<Script id="register-sw" strategy="afterInteractive">
7273
{`if ("serviceWorker" in navigator) {
7374
window.addEventListener("load", function () {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { getFreighterPublicKey } from "@/lib/freighter";
5+
import { splitClient } from "@/lib/stellar";
6+
7+
const STORAGE_KEY = "recipientOnboarded";
8+
const TOTAL_STEPS = 3;
9+
10+
export default function RecipientOnboarding() {
11+
const [show, setShow] = useState(false);
12+
const [step, setStep] = useState(1);
13+
const [invoiceCount, setInvoiceCount] = useState(0);
14+
15+
useEffect(() => {
16+
if (localStorage.getItem(STORAGE_KEY)) return;
17+
18+
async function check() {
19+
try {
20+
const address = await getFreighterPublicKey();
21+
if (!address) return;
22+
23+
const invoices = await splitClient.getInvoicesByRecipient(address);
24+
if (!invoices || invoices.length === 0) return;
25+
26+
setInvoiceCount(invoices.length);
27+
setShow(true);
28+
} catch {
29+
// wallet not connected or SDK method unavailable — skip silently
30+
}
31+
}
32+
33+
check();
34+
35+
// Re-check when wallet connects later in the session
36+
const onWalletEvent = () => check();
37+
window.addEventListener("wallet-connected", onWalletEvent);
38+
return () => window.removeEventListener("wallet-connected", onWalletEvent);
39+
}, []);
40+
41+
const complete = () => {
42+
localStorage.setItem(STORAGE_KEY, "true");
43+
setShow(false);
44+
};
45+
46+
const next = () => (step < TOTAL_STEPS ? setStep((s: number) => s + 1) : complete());
47+
48+
if (!show) return null;
49+
50+
return (
51+
<div
52+
role="dialog"
53+
aria-modal="true"
54+
aria-label="Recipient onboarding"
55+
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
56+
>
57+
<div className="bg-gray-900 border border-gray-800 rounded-xl w-full max-w-md p-6 flex flex-col gap-5">
58+
{/* Progress bar */}
59+
<div className="flex gap-1.5" aria-label={`Step ${step} of ${TOTAL_STEPS}`}>
60+
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
61+
<div
62+
key={i}
63+
className={`h-1 flex-1 rounded-full transition-colors ${
64+
i < step ? "bg-indigo-500" : "bg-gray-700"
65+
}`}
66+
/>
67+
))}
68+
</div>
69+
70+
{/* Step 1 — You have incoming payments */}
71+
{step === 1 && (
72+
<>
73+
<div className="flex flex-col gap-2">
74+
<h2 className="text-lg font-bold">You have incoming payments 🎉</h2>
75+
<p className="text-sm text-gray-400">
76+
Your wallet is listed as a recipient on{" "}
77+
<span className="text-white font-semibold">
78+
{invoiceCount} invoice{invoiceCount !== 1 ? "s" : ""}
79+
</span>
80+
. StellarSplit is an on-chain invoicing tool on Stellar — when an invoice is fully
81+
funded, USDC is automatically routed to you.
82+
</p>
83+
</div>
84+
<StepActions step={step} total={TOTAL_STEPS} onNext={next} onSkip={complete} />
85+
</>
86+
)}
87+
88+
{/* Step 2 — How release works */}
89+
{step === 2 && (
90+
<>
91+
<div className="flex flex-col gap-2">
92+
<h2 className="text-lg font-bold">How payment release works</h2>
93+
<ul className="text-sm text-gray-400 space-y-2 list-none">
94+
<li className="flex gap-2">
95+
<span className="text-indigo-400 shrink-0">1.</span>
96+
Payers fund their share of the invoice in USDC.
97+
</li>
98+
<li className="flex gap-2">
99+
<span className="text-indigo-400 shrink-0">2.</span>
100+
Once the invoice is 100% funded, the smart contract releases funds automatically.
101+
</li>
102+
<li className="flex gap-2">
103+
<span className="text-indigo-400 shrink-0">3.</span>
104+
Your share lands directly in your Stellar wallet — no action needed from you.
105+
</li>
106+
</ul>
107+
</div>
108+
<StepActions step={step} total={TOTAL_STEPS} onNext={next} onSkip={complete} />
109+
</>
110+
)}
111+
112+
{/* Step 3 — Go to recipient portal */}
113+
{step === 3 && (
114+
<>
115+
<div className="flex flex-col gap-2">
116+
<h2 className="text-lg font-bold">Track your payments</h2>
117+
<p className="text-sm text-gray-400">
118+
Head to your recipient portal to see all invoices you&apos;re part of, track funding
119+
progress, and view your payment history.
120+
</p>
121+
</div>
122+
<a
123+
href="/recipient"
124+
onClick={complete}
125+
className="w-full text-center px-4 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
126+
>
127+
Go to Recipient Portal →
128+
</a>
129+
<button
130+
onClick={complete}
131+
className="w-full px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500"
132+
>
133+
Maybe later
134+
</button>
135+
</>
136+
)}
137+
</div>
138+
</div>
139+
);
140+
}
141+
142+
function StepActions({
143+
step,
144+
total,
145+
onNext,
146+
onSkip,
147+
}: {
148+
step: number;
149+
total: number;
150+
onNext: () => void;
151+
onSkip: () => void;
152+
}) {
153+
return (
154+
<div className="flex gap-2 mt-1">
155+
<button
156+
onClick={onSkip}
157+
className="flex-1 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500"
158+
>
159+
Skip
160+
</button>
161+
<button
162+
onClick={onNext}
163+
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
164+
>
165+
{step === total - 1 ? "Next" : "Next"}
166+
</button>
167+
</div>
168+
);
169+
}

src/components/VelocityChart.tsx

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"use client";
2+
3+
import {
4+
AreaChart,
5+
Area,
6+
XAxis,
7+
YAxis,
8+
CartesianGrid,
9+
Tooltip,
10+
ReferenceLine,
11+
ResponsiveContainer,
12+
} from "recharts";
13+
import { formatAmount } from "@stellar-split/sdk";
14+
import type { Payment } from "@stellar-split/sdk";
15+
16+
interface Props {
17+
payments: Payment[];
18+
total: bigint;
19+
createdAt: number; // unix seconds — invoice creation timestamp
20+
}
21+
22+
interface DataPoint {
23+
/** Seconds elapsed since invoice creation */
24+
elapsed: number;
25+
/** Human-readable x-axis label */
26+
label: string;
27+
/** Cumulative funded amount as a plain number (USDC units, 7 decimals) */
28+
cumulative: number;
29+
/** Raw bigint for tooltip formatting */
30+
cumulativeRaw: bigint;
31+
}
32+
33+
/** Format elapsed seconds into a readable relative label */
34+
function formatElapsed(seconds: number): string {
35+
if (seconds < 60) return `${seconds}s`;
36+
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
37+
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
38+
return `${Math.round(seconds / 86400)}d`;
39+
}
40+
41+
/** Convert a bigint USDC amount (7 decimal places) to a plain JS number */
42+
function toNumber(amount: bigint): number {
43+
return Number(amount) / 1e7;
44+
}
45+
46+
function buildSeries(payments: Payment[], createdAt: number): DataPoint[] {
47+
const sorted = [...payments]
48+
.filter((p) => !("pending" in p && (p as { pending?: boolean }).pending))
49+
.sort((a, b) => a.timestamp - b.timestamp);
50+
51+
if (sorted.length === 0) return [];
52+
53+
// Always start from the origin (invoice creation)
54+
const points: DataPoint[] = [
55+
{ elapsed: 0, label: "0s", cumulative: 0, cumulativeRaw: 0n },
56+
];
57+
58+
let running = 0n;
59+
for (const p of sorted) {
60+
running += p.amount;
61+
const elapsed = Math.max(0, p.timestamp - createdAt);
62+
points.push({
63+
elapsed,
64+
label: formatElapsed(elapsed),
65+
cumulative: toNumber(running),
66+
cumulativeRaw: running,
67+
});
68+
}
69+
70+
return points;
71+
}
72+
73+
interface CustomTooltipProps {
74+
active?: boolean;
75+
payload?: Array<{ payload: DataPoint }>;
76+
}
77+
78+
function CustomTooltip({ active, payload }: CustomTooltipProps) {
79+
if (!active || !payload?.length) return null;
80+
const point = payload[0].payload;
81+
return (
82+
<div className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-xs">
83+
<p className="text-gray-400 mb-1">{point.label} after creation</p>
84+
<p className="text-indigo-300 font-semibold">
85+
{formatAmount(point.cumulativeRaw)} USDC
86+
</p>
87+
</div>
88+
);
89+
}
90+
91+
export default function VelocityChart({ payments, total, createdAt }: Props) {
92+
const series = buildSeries(payments, createdAt);
93+
const totalNum = toNumber(total);
94+
95+
if (series.length === 0) {
96+
return (
97+
<div className="flex items-center justify-center h-40 rounded-xl bg-gray-900 border border-gray-800 text-sm text-gray-500">
98+
No payments yet — chart will appear once funding begins.
99+
</div>
100+
);
101+
}
102+
103+
return (
104+
<ResponsiveContainer width="100%" height={220}>
105+
<AreaChart data={series} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
106+
<defs>
107+
<linearGradient id="velocityGradient" x1="0" y1="0" x2="0" y2="1">
108+
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.35} />
109+
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
110+
</linearGradient>
111+
</defs>
112+
<CartesianGrid strokeDasharray="3 3" stroke="#374151" vertical={false} />
113+
<XAxis
114+
dataKey="label"
115+
tick={{ fill: "#9ca3af", fontSize: 11 }}
116+
axisLine={false}
117+
tickLine={false}
118+
interval="preserveStartEnd"
119+
/>
120+
<YAxis
121+
tickFormatter={(v: number) => formatAmount(BigInt(Math.round(v * 1e7)))}
122+
tick={{ fill: "#9ca3af", fontSize: 11 }}
123+
axisLine={false}
124+
tickLine={false}
125+
width={72}
126+
/>
127+
<Tooltip content={<CustomTooltip />} />
128+
{totalNum > 0 && (
129+
<ReferenceLine
130+
y={totalNum}
131+
stroke="#4ade80"
132+
strokeDasharray="4 3"
133+
label={{
134+
value: `Total: ${formatAmount(total)} USDC`,
135+
position: "insideTopRight",
136+
fill: "#4ade80",
137+
fontSize: 11,
138+
}}
139+
/>
140+
)}
141+
<Area
142+
type="monotone"
143+
dataKey="cumulative"
144+
stroke="#6366f1"
145+
strokeWidth={2}
146+
fill="url(#velocityGradient)"
147+
dot={{ r: 3, fill: "#6366f1", strokeWidth: 0 }}
148+
activeDot={{ r: 5, fill: "#818cf8" }}
149+
isAnimationActive={false}
150+
/>
151+
</AreaChart>
152+
</ResponsiveContainer>
153+
);
154+
}

src/lib/freighter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ async function initWalletConnectKit() {
2020
if (typeof window === "undefined") throw new Error("Browser only");
2121

2222
try {
23-
const { getStellarWalletKit } = await import("@stellar/walletconnect-kit");
23+
const { getStellarWalletKit } = await import("@/lib/walletconnect-kit-stub");
2424
walletConnectKit = getStellarWalletKit();
2525
return walletConnectKit;
2626
} catch {

src/lib/walletconnect-kit-stub.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Stub for @stellar/walletconnect-kit.
3+
* The real package is not published; this stub satisfies the dynamic import
4+
* in freighter.ts so the build succeeds. WalletConnect features will be
5+
* unavailable until a real implementation is provided.
6+
*/
7+
export function getStellarWalletKit() {
8+
throw new Error("WalletConnect kit is not available in this build.");
9+
}

0 commit comments

Comments
 (0)