Skip to content

Commit b4b39fe

Browse files
jessie-hash-pixelStellarSplit DevKingsman-99
authored
feat: add ReminderSender, state sync, activity heatmap, revenue page (#181)
Issue 1 - ReminderSender component (invoice/[id]/page.tsx): - Send Reminder button visible to invoice creator on Pending invoices - Reminder text: Invoice ID, amount, deadline, verify URL - Copy Reminder button copies text to clipboard - WhatsApp and Telegram share links with pre-filled message - Mobile responsive (375px+) Issue 2 - State sync (lib/stateSync.ts + settings/sync/page.tsx): - Export serialises all stellarsplit_ prefixed localStorage keys to JSON - Signs export with Freighter wallet via signMessage - Import verifies signature before applying - Conflict resolution: imported values overwrite existing - Downloadable as stellarsplit-state.json file - Mobile responsive (375px+) Issue 3 - ActivityHeatmap + analytics/page.tsx: - 52-week x 7-day SVG calendar heatmap - Cell colour intensity reflects payment count per day - Tooltip shows date and payment count on hover - Month labels shown above columns - Empty days shown as lightest colour - Mobile responsive (375px+) Issue 4 - revenue/page.tsx: - Total USDC received shown as summary card - 30-day projection from pending invoices - Monthly BarChart (recharts) for last 6 months - Invoice breakdown table with status and share - Mobile responsive (375px+) Also: update layout.tsx with Analytics, Revenue, Sync nav links Co-authored-by: StellarSplit Dev <dev@stellarsplit.app> Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent d27600b commit b4b39fe

6 files changed

Lines changed: 815 additions & 1 deletion

File tree

src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export default function RootLayout({
5656
</a>
5757
<a
5858
href="/leaderboard"
59-
className="text-sm text-gray-400 hover:text-gray-200 transition-colors px-2 py-1"
59+
className="text-sm text-gray-400 hover:text-gray-200 transition-colors px-2 min-h-11 inline-flex items-center"
6060
>
6161
Leaderboard
6262
</a>

src/app/revenue/page.tsx

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
import { splitClient } from "@/lib/stellar";
5+
import { getFreighterPublicKey } from "@/lib/freighter";
6+
import { formatAmount } from "@stellar-split/sdk";
7+
import {
8+
BarChart,
9+
Bar,
10+
XAxis,
11+
YAxis,
12+
CartesianGrid,
13+
Tooltip,
14+
ResponsiveContainer,
15+
} from "recharts";
16+
import type { Invoice } from "@stellar-split/sdk";
17+
18+
/** Convert a bigint USDC amount (7 decimals) to a JS number for charting. */
19+
function toUsdc(amount: bigint): number {
20+
return Number(amount) / 1e7;
21+
}
22+
23+
/** Return "YYYY-MM" for a unix timestamp in seconds. */
24+
function monthKey(ts: number): string {
25+
const d = new Date(ts * 1000);
26+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
27+
}
28+
29+
/** Return a human-readable month label like "Jan 25". */
30+
function monthLabel(key: string): string {
31+
const [year, month] = key.split("-");
32+
const d = new Date(Number(year), Number(month) - 1, 1);
33+
return d.toLocaleDateString(undefined, { month: "short", year: "2-digit" });
34+
}
35+
36+
/** Last N months as YYYY-MM keys, oldest first. */
37+
function lastNMonths(n: number): string[] {
38+
const keys: string[] = [];
39+
const now = new Date();
40+
for (let i = n - 1; i >= 0; i--) {
41+
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
42+
keys.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
43+
}
44+
return keys;
45+
}
46+
47+
export default function RevenuePage() {
48+
const [invoices, setInvoices] = useState<Invoice[]>([]);
49+
const [publicKey, setPublicKey] = useState<string | null>(null);
50+
const [loading, setLoading] = useState(true);
51+
const [error, setError] = useState<string | null>(null);
52+
53+
useEffect(() => {
54+
async function load() {
55+
try {
56+
const pk = await getFreighterPublicKey().catch(() => null);
57+
if (!pk) {
58+
setError("Connect your wallet to view revenue.");
59+
return;
60+
}
61+
setPublicKey(pk);
62+
const result: Invoice[] = (await (splitClient as any).getInvoicesByRecipient(pk)) ?? [];
63+
setInvoices(result);
64+
} catch (err) {
65+
setError(String(err));
66+
} finally {
67+
setLoading(false);
68+
}
69+
}
70+
load();
71+
}, []);
72+
73+
// Total USDC received (Released invoices where this wallet is a recipient)
74+
const totalReceived = useMemo(() => {
75+
if (!publicKey) return 0n;
76+
return invoices
77+
.filter((inv) => inv.status === "Released")
78+
.reduce((sum, inv) => {
79+
const myShare = inv.recipients
80+
.filter((r) => r.address === publicKey)
81+
.reduce((s, r) => s + r.amount, 0n);
82+
return sum + myShare;
83+
}, 0n);
84+
}, [invoices, publicKey]);
85+
86+
// Monthly revenue for last 6 months
87+
const monthlyData = useMemo(() => {
88+
const months = lastNMonths(6);
89+
const byMonth: Record<string, number> = {};
90+
months.forEach((m) => (byMonth[m] = 0));
91+
92+
if (publicKey) {
93+
for (const inv of invoices) {
94+
if (inv.status !== "Released") continue;
95+
for (const payment of inv.payments ?? []) {
96+
const ts = (payment as { timestamp?: number }).timestamp;
97+
if (!ts) continue;
98+
const mk = monthKey(ts);
99+
if (mk in byMonth) {
100+
// Attribute payment proportionally to this recipient's share
101+
const total = inv.recipients.reduce((s, r) => s + r.amount, 0n);
102+
const myShare = inv.recipients
103+
.filter((r) => r.address === publicKey)
104+
.reduce((s, r) => s + r.amount, 0n);
105+
const ratio = total > 0n ? Number(myShare) / Number(total) : 0;
106+
byMonth[mk] += toUsdc(payment.amount) * ratio;
107+
}
108+
}
109+
}
110+
}
111+
112+
return months.map((m) => ({
113+
month: monthLabel(m),
114+
usdc: Math.round(byMonth[m] * 100) / 100,
115+
}));
116+
}, [invoices, publicKey]);
117+
118+
// 30-day projection from pending invoices
119+
const projection = useMemo(() => {
120+
if (!publicKey) return 0n;
121+
const now = Date.now() / 1000;
122+
const in30Days = now + 30 * 86400;
123+
return invoices
124+
.filter(
125+
(inv) =>
126+
inv.status === "Pending" &&
127+
(inv.deadline === 0 || inv.deadline <= in30Days)
128+
)
129+
.reduce((sum, inv) => {
130+
const myShare = inv.recipients
131+
.filter((r) => r.address === publicKey)
132+
.reduce((s, r) => s + r.amount, 0n);
133+
return sum + myShare;
134+
}, 0n);
135+
}, [invoices, publicKey]);
136+
137+
return (
138+
<main className="max-w-4xl mx-auto w-full px-4 sm:px-6 py-8">
139+
<h1 className="text-3xl font-bold mb-2">Revenue</h1>
140+
<p className="text-gray-400 mb-8">Your USDC earnings as a recipient across all invoices.</p>
141+
142+
{loading && (
143+
<div className="animate-pulse space-y-4">
144+
<div className="h-24 bg-gray-800 rounded-xl" />
145+
<div className="h-48 bg-gray-800 rounded-xl" />
146+
</div>
147+
)}
148+
149+
{error && (
150+
<p role="alert" className="text-red-400 text-sm">{error}</p>
151+
)}
152+
153+
{!loading && !error && (
154+
<>
155+
{/* Summary cards */}
156+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
157+
<div className="bg-gray-900 rounded-xl p-5">
158+
<p className="text-sm text-gray-400 mb-1">Total USDC Received</p>
159+
<p className="text-3xl font-bold text-indigo-300">
160+
{formatAmount(totalReceived)} <span className="text-lg font-normal text-gray-400">USDC</span>
161+
</p>
162+
</div>
163+
<div className="bg-gray-900 rounded-xl p-5">
164+
<p className="text-sm text-gray-400 mb-1">30-Day Projection</p>
165+
<p className="text-3xl font-bold text-amber-300">
166+
{formatAmount(projection)} <span className="text-lg font-normal text-gray-400">USDC</span>
167+
</p>
168+
<p className="text-xs text-gray-500 mt-1">From pending invoices due within 30 days</p>
169+
</div>
170+
</div>
171+
172+
{/* Monthly bar chart */}
173+
<section aria-labelledby="chart-heading" className="bg-gray-900 rounded-xl p-5 mb-8">
174+
<h2 id="chart-heading" className="text-lg font-semibold mb-4">Monthly Revenue (Last 6 Months)</h2>
175+
<div className="w-full h-48 sm:h-64">
176+
<ResponsiveContainer width="100%" height="100%">
177+
<BarChart data={monthlyData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
178+
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
179+
<XAxis dataKey="month" tick={{ fill: "#9ca3af", fontSize: 12 }} />
180+
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} unit=" USDC" width={80} />
181+
<Tooltip
182+
contentStyle={{ backgroundColor: "#1f2937", border: "1px solid #374151", borderRadius: 8 }}
183+
labelStyle={{ color: "#e5e7eb" }}
184+
itemStyle={{ color: "#818cf8" }}
185+
formatter={(value: number) => [`${value} USDC`, "Revenue"]}
186+
/>
187+
<Bar dataKey="usdc" fill="#6366f1" radius={[4, 4, 0, 0]} />
188+
</BarChart>
189+
</ResponsiveContainer>
190+
</div>
191+
</section>
192+
193+
{/* Invoice breakdown table */}
194+
<section aria-labelledby="breakdown-heading" className="bg-gray-900 rounded-xl p-5">
195+
<h2 id="breakdown-heading" className="text-lg font-semibold mb-4">Invoice Breakdown</h2>
196+
{invoices.length === 0 ? (
197+
<p className="text-gray-500 text-sm">No invoices found where you are a recipient.</p>
198+
) : (
199+
<div className="overflow-x-auto">
200+
<table className="w-full text-sm min-w-[480px]">
201+
<thead>
202+
<tr className="text-left text-gray-400 border-b border-gray-800">
203+
<th className="pb-2 pr-4 font-medium">Invoice ID</th>
204+
<th className="pb-2 pr-4 font-medium">Status</th>
205+
<th className="pb-2 pr-4 font-medium">Your Share</th>
206+
<th className="pb-2 font-medium">Deadline</th>
207+
</tr>
208+
</thead>
209+
<tbody>
210+
{invoices.map((inv) => {
211+
const myShare = publicKey
212+
? inv.recipients
213+
.filter((r) => r.address === publicKey)
214+
.reduce((s, r) => s + r.amount, 0n)
215+
: 0n;
216+
const deadlineStr =
217+
inv.deadline > 0
218+
? new Date(inv.deadline * 1000).toLocaleDateString()
219+
: "—";
220+
const statusColor: Record<string, string> = {
221+
Pending: "text-yellow-400",
222+
Released: "text-green-400",
223+
Refunded: "text-gray-400",
224+
};
225+
return (
226+
<tr key={inv.id} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
227+
<td className="py-2 pr-4 font-mono text-indigo-300">
228+
<a href={`/invoice/${inv.id}`} className="hover:underline">
229+
#{inv.id}
230+
</a>
231+
</td>
232+
<td className={`py-2 pr-4 font-medium ${statusColor[inv.status] ?? "text-gray-300"}`}>
233+
{inv.status}
234+
</td>
235+
<td className="py-2 pr-4">{formatAmount(myShare)} USDC</td>
236+
<td className="py-2 text-gray-400">{deadlineStr}</td>
237+
</tr>
238+
);
239+
})}
240+
</tbody>
241+
</table>
242+
</div>
243+
)}
244+
</section>
245+
</>
246+
)}
247+
</main>
248+
);
249+
}

src/app/settings/sync/page.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
import { getFreighterPublicKey } from "@/lib/freighter";
5+
import {
6+
exportState,
7+
importState,
8+
downloadBlob,
9+
type StateBlob,
10+
} from "@/lib/stateSync";
11+
12+
type Status = { type: "idle" } | { type: "loading" } | { type: "success"; message: string } | { type: "error"; message: string };
13+
14+
export default function StateSyncPage() {
15+
const [walletAddress, setWalletAddress] = useState<string | null>(null);
16+
const [exportStatus, setExportStatus] = useState<Status>({ type: "idle" });
17+
const [importStatus, setImportStatus] = useState<Status>({ type: "idle" });
18+
const fileInputRef = useRef<HTMLInputElement>(null);
19+
20+
useEffect(() => {
21+
getFreighterPublicKey().then(setWalletAddress).catch(() => null);
22+
}, []);
23+
24+
const handleExport = async () => {
25+
if (!walletAddress) {
26+
setExportStatus({ type: "error", message: "Connect your wallet first." });
27+
return;
28+
}
29+
setExportStatus({ type: "loading" });
30+
try {
31+
const blob = await exportState(walletAddress);
32+
downloadBlob(blob);
33+
setExportStatus({ type: "success", message: "State exported and downloaded successfully." });
34+
} catch (err) {
35+
setExportStatus({ type: "error", message: String(err) });
36+
}
37+
};
38+
39+
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
40+
const file = e.target.files?.[0];
41+
if (!file) return;
42+
setImportStatus({ type: "loading" });
43+
try {
44+
const text = await file.text();
45+
const blob: StateBlob = JSON.parse(text);
46+
const { imported } = await importState(blob);
47+
setImportStatus({ type: "success", message: `Imported ${imported} key${imported !== 1 ? "s" : ""} successfully.` });
48+
} catch (err) {
49+
setImportStatus({ type: "error", message: String(err) });
50+
} finally {
51+
// Reset file input so the same file can be re-imported
52+
if (fileInputRef.current) fileInputRef.current.value = "";
53+
}
54+
};
55+
56+
return (
57+
<main className="max-w-2xl mx-auto w-full px-4 sm:px-6 py-8">
58+
<h1 className="text-3xl font-bold mb-2">State Sync</h1>
59+
<p className="text-gray-400 mb-8">
60+
Export your app state (templates, address book, preferences) as a signed JSON file and import it on another device.
61+
Only keys prefixed with <code className="text-indigo-300 text-sm">stellarsplit_</code> are included.
62+
</p>
63+
64+
{!walletAddress && (
65+
<div className="mb-6 p-4 bg-amber-900/30 border border-amber-700/50 rounded-lg text-amber-300 text-sm">
66+
Connect your Freighter wallet to sign exports and verify imports.
67+
</div>
68+
)}
69+
70+
{/* Export */}
71+
<section aria-labelledby="export-heading" className="bg-gray-900 rounded-xl p-5 mb-6">
72+
<h2 id="export-heading" className="text-lg font-semibold mb-1">Export State</h2>
73+
<p className="text-sm text-gray-400 mb-4">
74+
Serialises all <code className="text-indigo-300">stellarsplit_</code> localStorage keys to a signed JSON file you can download.
75+
</p>
76+
<button
77+
type="button"
78+
onClick={handleExport}
79+
disabled={exportStatus.type === "loading"}
80+
className="min-h-11 px-5 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 font-semibold text-sm transition-colors disabled:opacity-50"
81+
>
82+
{exportStatus.type === "loading" ? "Exporting…" : "Export State"}
83+
</button>
84+
{exportStatus.type === "success" && (
85+
<p role="status" className="mt-3 text-green-400 text-sm">{exportStatus.message}</p>
86+
)}
87+
{exportStatus.type === "error" && (
88+
<p role="alert" className="mt-3 text-red-400 text-sm">{exportStatus.message}</p>
89+
)}
90+
</section>
91+
92+
{/* Import */}
93+
<section aria-labelledby="import-heading" className="bg-gray-900 rounded-xl p-5">
94+
<h2 id="import-heading" className="text-lg font-semibold mb-1">Import State</h2>
95+
<p className="text-sm text-gray-400 mb-4">
96+
Select a previously exported JSON file. The signature will be verified before any data is written.
97+
Imported values overwrite existing ones for matching keys.
98+
</p>
99+
<label className="block">
100+
<span className="sr-only">Choose state file to import</span>
101+
<input
102+
ref={fileInputRef}
103+
type="file"
104+
accept="application/json,.json"
105+
onChange={handleImportFile}
106+
disabled={importStatus.type === "loading"}
107+
className="block w-full text-sm text-gray-300
108+
file:mr-4 file:py-2 file:px-4
109+
file:rounded-lg file:border-0
110+
file:text-sm file:font-semibold
111+
file:bg-indigo-600 file:text-white
112+
hover:file:bg-indigo-500
113+
file:cursor-pointer file:min-h-11
114+
disabled:opacity-50"
115+
/>
116+
</label>
117+
{importStatus.type === "loading" && (
118+
<p role="status" className="mt-3 text-gray-400 text-sm">Verifying and importing…</p>
119+
)}
120+
{importStatus.type === "success" && (
121+
<p role="status" className="mt-3 text-green-400 text-sm">{importStatus.message}</p>
122+
)}
123+
{importStatus.type === "error" && (
124+
<p role="alert" className="mt-3 text-red-400 text-sm">{importStatus.message}</p>
125+
)}
126+
</section>
127+
</main>
128+
);
129+
}

0 commit comments

Comments
 (0)