Skip to content

Commit 6dbcdb8

Browse files
authored
feat: add subscription management dashboard (#393)
- /subscriptions list page with template name, frequency, next run date, invoice count, USDC collected, and pause/resume/cancel actions - /subscriptions/[id] detail page with config, editable actions, calendar-style upcoming dates preview, and full invoice history table - Zustand store, subscription types, and SDK helper functions - Skeleton loading states, subscription components - 19 unit tests for SubscriptionCard and SubscriptionDetail - Navbar updated with Subscriptions link
1 parent 65ef90c commit 6dbcdb8

14 files changed

Lines changed: 1337 additions & 5 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { vi } from "vitest";
3+
import SubscriptionCard from "@/components/SubscriptionCard";
4+
import type { Subscription } from "@/types/subscription";
5+
6+
vi.mock("@stellar-split/sdk", async (importOriginal) => {
7+
const actual = await importOriginal<typeof import("@stellar-split/sdk")>();
8+
return {
9+
...actual,
10+
formatAmount: (n: bigint) => `${n / 1000000n}`,
11+
truncateAddress: (s: string) => `${s.slice(0, 4)}...${s.slice(-4)}`,
12+
};
13+
});
14+
15+
vi.mock("next/link", () => ({
16+
default: ({ children, href, ...props }: React.ComponentProps<"a"> & { href: string }) => (
17+
<a href={href} {...props}>
18+
{children}
19+
</a>
20+
),
21+
}));
22+
23+
const mockSubscription: Subscription = {
24+
id: "sub-1",
25+
templateName: "Monthly Hosting",
26+
creator: "GCREADER1234567890ABCDEF",
27+
recipients: [
28+
{ address: "GDEST1234567890ABCDEF", amount: 50000000n },
29+
],
30+
frequency: "monthly",
31+
intervalDays: 30,
32+
status: "active",
33+
createdAt: Math.floor(Date.now() / 1000) - 86400 * 30,
34+
nextRunDate: Math.floor(Date.now() / 1000) + 86400 * 5,
35+
lastRunDate: Math.floor(Date.now() / 1000) - 86400 * 25,
36+
token: "USDC",
37+
totalInvoicesGenerated: 3,
38+
totalUsdcCollected: 150000000n,
39+
invoiceHistory: [
40+
{
41+
invoiceId: "101",
42+
generatedAt: Math.floor(Date.now() / 1000) - 86400 * 30,
43+
deadline: Math.floor(Date.now() / 1000) - 86400 * 25,
44+
amount: 50000000n,
45+
status: "Released",
46+
},
47+
],
48+
};
49+
50+
describe("SubscriptionCard", () => {
51+
it("renders template name", () => {
52+
render(<SubscriptionCard subscription={mockSubscription} />);
53+
expect(screen.getByText("Monthly Hosting")).toBeInTheDocument();
54+
});
55+
56+
it("renders status", () => {
57+
render(<SubscriptionCard subscription={mockSubscription} />);
58+
expect(screen.getByText("Active")).toBeInTheDocument();
59+
});
60+
61+
it("renders frequency", () => {
62+
render(<SubscriptionCard subscription={mockSubscription} />);
63+
expect(screen.getByText("Monthly")).toBeInTheDocument();
64+
});
65+
66+
it("renders total invoices", () => {
67+
render(<SubscriptionCard subscription={mockSubscription} />);
68+
expect(screen.getByText("3")).toBeInTheDocument();
69+
});
70+
71+
it("renders USDC collected", () => {
72+
render(<SubscriptionCard subscription={mockSubscription} />);
73+
expect(screen.getByText("150")).toBeInTheDocument();
74+
});
75+
76+
it("renders paused status", () => {
77+
const paused = { ...mockSubscription, status: "paused" as const };
78+
render(<SubscriptionCard subscription={paused} />);
79+
expect(screen.getByText("Paused")).toBeInTheDocument();
80+
});
81+
82+
it("renders cancelled status", () => {
83+
const cancelled = { ...mockSubscription, status: "cancelled" as const };
84+
render(<SubscriptionCard subscription={cancelled} />);
85+
expect(screen.getByText("Cancelled")).toBeInTheDocument();
86+
});
87+
88+
it("links to detail page", () => {
89+
render(<SubscriptionCard subscription={mockSubscription} />);
90+
const link = screen.getByRole("link");
91+
expect(link).toHaveAttribute("href", "/subscriptions/sub-1");
92+
});
93+
94+
it("shows dash for next run when paused", () => {
95+
const paused = { ...mockSubscription, status: "paused" as const };
96+
render(<SubscriptionCard subscription={paused} />);
97+
// The next run date should show a dash for paused subscriptions
98+
const nextRunSection = screen.getByText("Next Run");
99+
expect(nextRunSection.parentElement?.textContent).toContain("—");
100+
});
101+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { vi } from "vitest";
3+
import SubscriptionDetailClient from "@/components/SubscriptionDetailClient";
4+
import type { Subscription } from "@/types/subscription";
5+
6+
vi.mock("@stellar-split/sdk", async (importOriginal) => {
7+
const actual = await importOriginal<typeof import("@stellar-split/sdk")>();
8+
return {
9+
...actual,
10+
formatAmount: (n: bigint) => `${n / 1000000n}`,
11+
truncateAddress: (s: string) => `${s.slice(0, 4)}...${s.slice(-4)}`,
12+
};
13+
});
14+
15+
vi.mock("next/link", () => ({
16+
default: ({ children, href, ...props }: React.ComponentProps<"a"> & { href: string }) => (
17+
<a href={href} {...props}>
18+
{children}
19+
</a>
20+
),
21+
}));
22+
23+
vi.mock("next/navigation", () => ({
24+
useParams: () => ({ id: "sub-1" }),
25+
useRouter: () => ({ replace: vi.fn() }),
26+
}));
27+
28+
const mockSubscription: Subscription = {
29+
id: "sub-1",
30+
templateName: "Weekly Design Retainer",
31+
creator: "GCREADER1234567890ABCDEF",
32+
recipients: [
33+
{ address: "GDEST1234567890ABCDEF", amount: 100000000n },
34+
],
35+
frequency: "weekly",
36+
intervalDays: 7,
37+
status: "active",
38+
createdAt: Math.floor(Date.now() / 1000) - 86400 * 14,
39+
nextRunDate: Math.floor(Date.now() / 1000) + 86400 * 3,
40+
lastRunDate: Math.floor(Date.now() / 1000) - 86400 * 4,
41+
token: "USDC",
42+
totalInvoicesGenerated: 2,
43+
totalUsdcCollected: 200000000n,
44+
invoiceHistory: [
45+
{
46+
invoiceId: "201",
47+
generatedAt: Math.floor(Date.now() / 1000) - 86400 * 14,
48+
deadline: Math.floor(Date.now() / 1000) - 86400 * 10,
49+
amount: 100000000n,
50+
status: "Released",
51+
},
52+
{
53+
invoiceId: "202",
54+
generatedAt: Math.floor(Date.now() / 1000) - 86400 * 7,
55+
deadline: Math.floor(Date.now() / 1000) - 86400 * 3,
56+
amount: 100000000n,
57+
status: "Pending",
58+
},
59+
],
60+
};
61+
62+
describe("SubscriptionDetailClient", () => {
63+
beforeEach(() => {
64+
localStorage.clear();
65+
localStorage.setItem(
66+
"stellar_split_subscriptions",
67+
JSON.stringify([
68+
{
69+
...mockSubscription,
70+
recipients: mockSubscription.recipients.map((r) => ({
71+
...r,
72+
amount: r.amount.toString(),
73+
})),
74+
totalUsdcCollected: mockSubscription.totalUsdcCollected.toString(),
75+
invoiceHistory: mockSubscription.invoiceHistory.map((inv) => ({
76+
...inv,
77+
amount: inv.amount.toString(),
78+
})),
79+
},
80+
])
81+
);
82+
});
83+
84+
it("renders subscription template name", async () => {
85+
render(<SubscriptionDetailClient />);
86+
const els = await screen.findAllByText("Weekly Design Retainer");
87+
expect(els.length).toBeGreaterThanOrEqual(1);
88+
});
89+
90+
it("renders subscription status", async () => {
91+
render(<SubscriptionDetailClient />);
92+
expect(await screen.findByText("Active")).toBeInTheDocument();
93+
});
94+
95+
it("renders frequency", async () => {
96+
render(<SubscriptionDetailClient />);
97+
expect(await screen.findByText("Weekly")).toBeInTheDocument();
98+
});
99+
100+
it("renders pause button for active subscription", async () => {
101+
render(<SubscriptionDetailClient />);
102+
expect(await screen.findByText("Pause Subscription")).toBeInTheDocument();
103+
});
104+
105+
it("renders cancel button", async () => {
106+
render(<SubscriptionDetailClient />);
107+
expect(await screen.findByText("Cancel Subscription")).toBeInTheDocument();
108+
});
109+
110+
it("renders invoice history table", async () => {
111+
render(<SubscriptionDetailClient />);
112+
expect(await screen.findByText(/Invoice History/)).toBeInTheDocument();
113+
});
114+
115+
it("renders recipients section", async () => {
116+
render(<SubscriptionDetailClient />);
117+
expect(await screen.findByText("Recipients")).toBeInTheDocument();
118+
});
119+
120+
it("renders calendar preview for active subscription", async () => {
121+
render(<SubscriptionDetailClient />);
122+
expect(await screen.findByText("Upcoming Invoice Dates")).toBeInTheDocument();
123+
});
124+
125+
it("renders total invoices count", async () => {
126+
render(<SubscriptionDetailClient />);
127+
expect(await screen.findByText("Total Invoices")).toBeInTheDocument();
128+
});
129+
130+
it("renders back link", async () => {
131+
render(<SubscriptionDetailClient />);
132+
const backLink = await screen.findByText("← Subscriptions");
133+
expect(backLink).toHaveAttribute("href", "/subscriptions");
134+
});
135+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Suspense } from "react";
2+
import SubscriptionDetailClient from "@/components/SubscriptionDetailClient";
3+
import { SubscriptionDetailSkeleton } from "@/components/Skeleton";
4+
5+
export const metadata = {
6+
robots: { index: false, follow: false },
7+
title: "Subscription Details — StellarSplit",
8+
};
9+
10+
export default async function SubscriptionDetailPage() {
11+
return (
12+
<Suspense fallback={<SubscriptionDetailSkeleton />}>
13+
<SubscriptionDetailClient />
14+
</Suspense>
15+
);
16+
}

src/app/subscriptions/page.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Suspense } from "react";
2+
import SubscriptionsClient from "@/components/SubscriptionsClient";
3+
import { SubscriptionListSkeleton } from "@/components/Skeleton";
4+
5+
export const metadata = {
6+
robots: { index: false, follow: false },
7+
title: "Subscriptions — StellarSplit",
8+
};
9+
10+
export default async function SubscriptionsPage() {
11+
return (
12+
<Suspense fallback={<SubscriptionListSkeleton />}>
13+
<SubscriptionsClient />
14+
</Suspense>
15+
);
16+
}

src/components/Navbar.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import NetworkStatus from "@/components/NetworkStatus";
1111
import GlobalSearch from "@/components/GlobalSearch";
1212

1313
const NAV_LINKS = [
14-
{ href: "/dashboard", label: "Dashboard" },
15-
{ href: "/groups", label: "Groups" },
16-
{ href: "/address-book", label: "Contacts" },
17-
{ href: "/recipients", label: "Recipients" },
18-
{ href: "/leaderboard", label: "Leaderboard" },
14+
{ href: "/dashboard", label: "Dashboard" },
15+
{ href: "/subscriptions", label: "Subscriptions" },
16+
{ href: "/groups", label: "Groups" },
17+
{ href: "/address-book", label: "Contacts" },
18+
{ href: "/recipients", label: "Recipients" },
19+
{ href: "/leaderboard", label: "Leaderboard" },
1920
];
2021

2122
export default function Navbar() {

src/components/Skeleton.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,67 @@ export function SkeletonDashboardStats() {
252252
* DeferredSkeleton — wraps any skeleton and only renders it after `delayMs`
253253
* (default 200ms) to avoid a flash of loading UI on fast connections.
254254
*/
255+
export function SubscriptionCardSkeleton() {
256+
return (
257+
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
258+
<div className="flex items-center justify-between mb-3">
259+
<div className={`${shimmer} h-4 w-32`} />
260+
<div className={`${shimmer} h-5 w-16 rounded-full`} />
261+
</div>
262+
<div className={`${shimmer} h-3 w-24 mb-3`} />
263+
<div className="grid grid-cols-2 gap-3 mt-4">
264+
<div className={`${shimmer} h-3 w-20`} />
265+
<div className={`${shimmer} h-3 w-20`} />
266+
<div className={`${shimmer} h-3 w-20`} />
267+
<div className={`${shimmer} h-3 w-20`} />
268+
</div>
269+
</div>
270+
);
271+
}
272+
273+
export function SubscriptionListSkeleton({ count = 3 }: { count?: number }) {
274+
return (
275+
<div
276+
aria-busy="true"
277+
aria-label="Loading subscriptions"
278+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
279+
>
280+
{Array.from({ length: count }).map((_, i) => (
281+
<SubscriptionCardSkeleton key={i} />
282+
))}
283+
</div>
284+
);
285+
}
286+
287+
export function SubscriptionDetailSkeleton() {
288+
return (
289+
<div
290+
aria-busy="true"
291+
aria-label="Loading subscription details"
292+
className="space-y-6"
293+
>
294+
<div className="flex items-center justify-between">
295+
<div className={`${shimmer} h-8 w-56`} />
296+
<div className={`${shimmer} h-7 w-20 rounded-full`} />
297+
</div>
298+
<div className="bg-gray-900 rounded-lg p-4 space-y-3">
299+
<div className={`${shimmer} h-4 w-48`} />
300+
<div className={`${shimmer} h-4 w-32`} />
301+
<div className={`${shimmer} h-4 w-40`} />
302+
</div>
303+
<div className="bg-gray-900 rounded-lg p-4 space-y-3">
304+
<div className={`${shimmer} h-4 w-36`} />
305+
{[0, 1, 2].map((i) => (
306+
<div key={i} className="flex justify-between">
307+
<div className={`${shimmer} h-4 w-40`} />
308+
<div className={`${shimmer} h-4 w-24`} />
309+
</div>
310+
))}
311+
</div>
312+
</div>
313+
);
314+
}
315+
255316
export function DeferredSkeleton({
256317
children,
257318
delayMs = 200,

0 commit comments

Comments
 (0)