From 69e96d70fc6b7489d840f1a1e1e15c40a528632c Mon Sep 17 00:00:00 2001 From: Promise Pius Obi Date: Fri, 17 Jul 2026 12:21:33 +0100 Subject: [PATCH] feat: add subscription management dashboard - /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 --- package-lock.json | 100 +++++++ package.json | 3 +- src/__tests__/SubscriptionCard.test.tsx | 101 +++++++ src/__tests__/SubscriptionDetail.test.tsx | 135 +++++++++ src/app/subscriptions/[id]/page.tsx | 16 + src/app/subscriptions/page.tsx | 16 + src/components/Navbar.tsx | 11 +- src/components/Skeleton.tsx | 61 ++++ .../SubscriptionCalendarPreview.tsx | 118 ++++++++ src/components/SubscriptionCard.tsx | 68 +++++ src/components/SubscriptionDetailClient.tsx | 282 ++++++++++++++++++ src/components/SubscriptionHistoryTable.tsx | 101 +++++++ src/components/SubscriptionsClient.tsx | 209 +++++++++++++ src/lib/stores/subscriptionStore.ts | 67 +++++ src/lib/subscriptions.ts | 129 ++++++++ src/types/subscription.ts | 28 ++ 16 files changed, 1439 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/SubscriptionCard.test.tsx create mode 100644 src/__tests__/SubscriptionDetail.test.tsx create mode 100644 src/app/subscriptions/[id]/page.tsx create mode 100644 src/app/subscriptions/page.tsx create mode 100644 src/components/SubscriptionCalendarPreview.tsx create mode 100644 src/components/SubscriptionCard.tsx create mode 100644 src/components/SubscriptionDetailClient.tsx create mode 100644 src/components/SubscriptionHistoryTable.tsx create mode 100644 src/components/SubscriptionsClient.tsx create mode 100644 src/lib/stores/subscriptionStore.ts create mode 100644 src/lib/subscriptions.ts create mode 100644 src/types/subscription.ts diff --git a/package-lock.json b/package-lock.json index 74b95b8..5244f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "devDependencies": { "@next/bundle-analyzer": "^14.2.3", "@playwright/test": "^1.40.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.5.0", @@ -2857,6 +2858,43 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2930,6 +2968,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/canvas-confetti": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.6.4.tgz", @@ -5208,6 +5253,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -8027,6 +8082,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -9012,6 +9077,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", diff --git a/package.json b/package.json index 5b227a9..3c34346 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "@tanstack/react-query": "5.80.7", "@types/canvas-confetti": "1.6.4", "canvas-confetti": "1.9.3", - "immer": "^11.1.8", "html2canvas": "^1.4.1", + "immer": "^11.1.8", "next": "14.2.3", "qrcode.react": "4.2.0", "react": "^18.3.0", @@ -33,6 +33,7 @@ "devDependencies": { "@next/bundle-analyzer": "^14.2.3", "@playwright/test": "^1.40.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.5.0", diff --git a/src/__tests__/SubscriptionCard.test.tsx b/src/__tests__/SubscriptionCard.test.tsx new file mode 100644 index 0000000..39cf7cf --- /dev/null +++ b/src/__tests__/SubscriptionCard.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import SubscriptionCard from "@/components/SubscriptionCard"; +import type { Subscription } from "@/types/subscription"; + +vi.mock("@stellar-split/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formatAmount: (n: bigint) => `${n / 1000000n}`, + truncateAddress: (s: string) => `${s.slice(0, 4)}...${s.slice(-4)}`, + }; +}); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: React.ComponentProps<"a"> & { href: string }) => ( + + {children} + + ), +})); + +const mockSubscription: Subscription = { + id: "sub-1", + templateName: "Monthly Hosting", + creator: "GCREADER1234567890ABCDEF", + recipients: [ + { address: "GDEST1234567890ABCDEF", amount: 50000000n }, + ], + frequency: "monthly", + intervalDays: 30, + status: "active", + createdAt: Math.floor(Date.now() / 1000) - 86400 * 30, + nextRunDate: Math.floor(Date.now() / 1000) + 86400 * 5, + lastRunDate: Math.floor(Date.now() / 1000) - 86400 * 25, + token: "USDC", + totalInvoicesGenerated: 3, + totalUsdcCollected: 150000000n, + invoiceHistory: [ + { + invoiceId: "101", + generatedAt: Math.floor(Date.now() / 1000) - 86400 * 30, + deadline: Math.floor(Date.now() / 1000) - 86400 * 25, + amount: 50000000n, + status: "Released", + }, + ], +}; + +describe("SubscriptionCard", () => { + it("renders template name", () => { + render(); + expect(screen.getByText("Monthly Hosting")).toBeInTheDocument(); + }); + + it("renders status", () => { + render(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("renders frequency", () => { + render(); + expect(screen.getByText("Monthly")).toBeInTheDocument(); + }); + + it("renders total invoices", () => { + render(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("renders USDC collected", () => { + render(); + expect(screen.getByText("150")).toBeInTheDocument(); + }); + + it("renders paused status", () => { + const paused = { ...mockSubscription, status: "paused" as const }; + render(); + expect(screen.getByText("Paused")).toBeInTheDocument(); + }); + + it("renders cancelled status", () => { + const cancelled = { ...mockSubscription, status: "cancelled" as const }; + render(); + expect(screen.getByText("Cancelled")).toBeInTheDocument(); + }); + + it("links to detail page", () => { + render(); + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("href", "/subscriptions/sub-1"); + }); + + it("shows dash for next run when paused", () => { + const paused = { ...mockSubscription, status: "paused" as const }; + render(); + // The next run date should show a dash for paused subscriptions + const nextRunSection = screen.getByText("Next Run"); + expect(nextRunSection.parentElement?.textContent).toContain("—"); + }); +}); diff --git a/src/__tests__/SubscriptionDetail.test.tsx b/src/__tests__/SubscriptionDetail.test.tsx new file mode 100644 index 0000000..a97e1c0 --- /dev/null +++ b/src/__tests__/SubscriptionDetail.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import SubscriptionDetailClient from "@/components/SubscriptionDetailClient"; +import type { Subscription } from "@/types/subscription"; + +vi.mock("@stellar-split/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formatAmount: (n: bigint) => `${n / 1000000n}`, + truncateAddress: (s: string) => `${s.slice(0, 4)}...${s.slice(-4)}`, + }; +}); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: React.ComponentProps<"a"> & { href: string }) => ( + + {children} + + ), +})); + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "sub-1" }), + useRouter: () => ({ replace: vi.fn() }), +})); + +const mockSubscription: Subscription = { + id: "sub-1", + templateName: "Weekly Design Retainer", + creator: "GCREADER1234567890ABCDEF", + recipients: [ + { address: "GDEST1234567890ABCDEF", amount: 100000000n }, + ], + frequency: "weekly", + intervalDays: 7, + status: "active", + createdAt: Math.floor(Date.now() / 1000) - 86400 * 14, + nextRunDate: Math.floor(Date.now() / 1000) + 86400 * 3, + lastRunDate: Math.floor(Date.now() / 1000) - 86400 * 4, + token: "USDC", + totalInvoicesGenerated: 2, + totalUsdcCollected: 200000000n, + invoiceHistory: [ + { + invoiceId: "201", + generatedAt: Math.floor(Date.now() / 1000) - 86400 * 14, + deadline: Math.floor(Date.now() / 1000) - 86400 * 10, + amount: 100000000n, + status: "Released", + }, + { + invoiceId: "202", + generatedAt: Math.floor(Date.now() / 1000) - 86400 * 7, + deadline: Math.floor(Date.now() / 1000) - 86400 * 3, + amount: 100000000n, + status: "Pending", + }, + ], +}; + +describe("SubscriptionDetailClient", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem( + "stellar_split_subscriptions", + JSON.stringify([ + { + ...mockSubscription, + recipients: mockSubscription.recipients.map((r) => ({ + ...r, + amount: r.amount.toString(), + })), + totalUsdcCollected: mockSubscription.totalUsdcCollected.toString(), + invoiceHistory: mockSubscription.invoiceHistory.map((inv) => ({ + ...inv, + amount: inv.amount.toString(), + })), + }, + ]) + ); + }); + + it("renders subscription template name", async () => { + render(); + const els = await screen.findAllByText("Weekly Design Retainer"); + expect(els.length).toBeGreaterThanOrEqual(1); + }); + + it("renders subscription status", async () => { + render(); + expect(await screen.findByText("Active")).toBeInTheDocument(); + }); + + it("renders frequency", async () => { + render(); + expect(await screen.findByText("Weekly")).toBeInTheDocument(); + }); + + it("renders pause button for active subscription", async () => { + render(); + expect(await screen.findByText("Pause Subscription")).toBeInTheDocument(); + }); + + it("renders cancel button", async () => { + render(); + expect(await screen.findByText("Cancel Subscription")).toBeInTheDocument(); + }); + + it("renders invoice history table", async () => { + render(); + expect(await screen.findByText(/Invoice History/)).toBeInTheDocument(); + }); + + it("renders recipients section", async () => { + render(); + expect(await screen.findByText("Recipients")).toBeInTheDocument(); + }); + + it("renders calendar preview for active subscription", async () => { + render(); + expect(await screen.findByText("Upcoming Invoice Dates")).toBeInTheDocument(); + }); + + it("renders total invoices count", async () => { + render(); + expect(await screen.findByText("Total Invoices")).toBeInTheDocument(); + }); + + it("renders back link", async () => { + render(); + const backLink = await screen.findByText("← Subscriptions"); + expect(backLink).toHaveAttribute("href", "/subscriptions"); + }); +}); diff --git a/src/app/subscriptions/[id]/page.tsx b/src/app/subscriptions/[id]/page.tsx new file mode 100644 index 0000000..bf5924e --- /dev/null +++ b/src/app/subscriptions/[id]/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react"; +import SubscriptionDetailClient from "@/components/SubscriptionDetailClient"; +import { SubscriptionDetailSkeleton } from "@/components/Skeleton"; + +export const metadata = { + robots: { index: false, follow: false }, + title: "Subscription Details — StellarSplit", +}; + +export default async function SubscriptionDetailPage() { + return ( + }> + + + ); +} diff --git a/src/app/subscriptions/page.tsx b/src/app/subscriptions/page.tsx new file mode 100644 index 0000000..de413f8 --- /dev/null +++ b/src/app/subscriptions/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react"; +import SubscriptionsClient from "@/components/SubscriptionsClient"; +import { SubscriptionListSkeleton } from "@/components/Skeleton"; + +export const metadata = { + robots: { index: false, follow: false }, + title: "Subscriptions — StellarSplit", +}; + +export default async function SubscriptionsPage() { + return ( + }> + + + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 04545c4..9dbeaea 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -11,11 +11,12 @@ import NetworkStatus from "@/components/NetworkStatus"; import GlobalSearch from "@/components/GlobalSearch"; const NAV_LINKS = [ - { href: "/dashboard", label: "Dashboard" }, - { href: "/groups", label: "Groups" }, - { href: "/address-book", label: "Contacts" }, - { href: "/recipients", label: "Recipients" }, - { href: "/leaderboard", label: "Leaderboard" }, + { href: "/dashboard", label: "Dashboard" }, + { href: "/subscriptions", label: "Subscriptions" }, + { href: "/groups", label: "Groups" }, + { href: "/address-book", label: "Contacts" }, + { href: "/recipients", label: "Recipients" }, + { href: "/leaderboard", label: "Leaderboard" }, ]; export default function Navbar() { diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx index 649bb15..aefdade 100644 --- a/src/components/Skeleton.tsx +++ b/src/components/Skeleton.tsx @@ -245,6 +245,67 @@ export function SkeletonDashboardStats() { * DeferredSkeleton — wraps any skeleton and only renders it after `delayMs` * (default 200ms) to avoid a flash of loading UI on fast connections. */ +export function SubscriptionCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export function SubscriptionListSkeleton({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +export function SubscriptionDetailSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} +
+
+ ); +} + export function DeferredSkeleton({ children, delayMs = 200, diff --git a/src/components/SubscriptionCalendarPreview.tsx b/src/components/SubscriptionCalendarPreview.tsx new file mode 100644 index 0000000..92b3e13 --- /dev/null +++ b/src/components/SubscriptionCalendarPreview.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useMemo } from "react"; +import { computeUpcomingDates } from "@/lib/subscriptions"; + +interface Props { + intervalDays: number; + count?: number; + fromDate?: Date; +} + +const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +export default function SubscriptionCalendarPreview({ + intervalDays, + count = 6, + fromDate, +}: Props) { + const upcomingDates = useMemo( + () => computeUpcomingDates(intervalDays, count, fromDate), + [intervalDays, count, fromDate] + ); + + const monthGroups = useMemo(() => { + const groups: Record }> = {}; + for (const date of upcomingDates) { + const key = `${date.getFullYear()}-${date.getMonth()}`; + if (!groups[key]) { + groups[key] = { + year: date.getFullYear(), + month: date.getMonth(), + days: new Set(), + }; + } + groups[key].days.add(date.getDate()); + } + return Object.values(groups); + }, [upcomingDates]); + + return ( +
+

+ Upcoming Invoice Dates +

+ + {monthGroups.length === 0 ? ( +

No upcoming dates.

+ ) : ( +
+ {monthGroups.map((group) => { + const monthName = new Date(group.year, group.month).toLocaleString( + "default", + { month: "long", year: "numeric" } + ); + const daysInMonth = getDaysInMonth(group.year, group.month); + const firstDayOfWeek = new Date( + group.year, + group.month, + 1 + ).getDay(); + + const cells: (number | null)[] = []; + for (let i = 0; i < firstDayOfWeek; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + return ( +
+

+ {monthName} +

+
+ {DAY_LABELS.map((label) => ( +
+ {label.charAt(0)} +
+ ))} + {cells.map((day, i) => { + if (day === null) { + return
; + } + const isInvoiceDay = group.days.has(day); + return ( +
+ {day} +
+ ); + })} +
+
+ ); + })} +
+ )} + +
+
+ + Invoice date +
+
Next {count} dates shown
+
+
+ ); +} diff --git a/src/components/SubscriptionCard.tsx b/src/components/SubscriptionCard.tsx new file mode 100644 index 0000000..2e49bed --- /dev/null +++ b/src/components/SubscriptionCard.tsx @@ -0,0 +1,68 @@ +"use client"; + +import Link from "next/link"; +import type { Subscription } from "@/types/subscription"; +import { + formatFrequency, + formatSubscriptionForDisplay, +} from "@/lib/subscriptions"; + +interface Props { + subscription: Subscription; +} + +const STATUS_STYLES: Record = { + active: "bg-green-500/20 text-green-400 border-green-500/30", + paused: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", + cancelled: "bg-red-500/20 text-red-400 border-red-500/30", +}; + +export default function SubscriptionCard({ subscription }: Props) { + const display = formatSubscriptionForDisplay(subscription); + + return ( + +
+

+ {display.templateName} +

+ + {subscription.status.charAt(0).toUpperCase() + subscription.status.slice(1)} + +
+ +

+ {formatFrequency(subscription.frequency)} +

+ +
+
+

Next Run

+

+ {subscription.status === "active" + ? display.nextRunDateFormatted + : "—"} +

+
+
+

Invoices

+

{subscription.totalInvoicesGenerated}

+
+
+

USDC Collected

+

{display.totalUsdcFormatted}

+
+
+

Created

+

{display.createdAtFormatted}

+
+
+ + ); +} diff --git a/src/components/SubscriptionDetailClient.tsx b/src/components/SubscriptionDetailClient.tsx new file mode 100644 index 0000000..e525107 --- /dev/null +++ b/src/components/SubscriptionDetailClient.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { splitClient } from "@/lib/stellar"; +import { SubscriptionDetailSkeleton } from "@/components/Skeleton"; +import SubscriptionCalendarPreview from "@/components/SubscriptionCalendarPreview"; +import SubscriptionHistoryTable from "@/components/SubscriptionHistoryTable"; +import type { Subscription } from "@/types/subscription"; +import { + loadSubscriptions, + saveSubscriptions, + formatFrequency, + pauseSubscription, + resumeSubscription, + cancelSubscription, + formatSubscriptionForDisplay, +} from "@/lib/subscriptions"; + +const POLL_INTERVAL_MS = 30_000; + +export default function SubscriptionDetailClient() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + const [subscription, setSubscription] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + + const fetchSubscription = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const stored = loadSubscriptions(); + const sub = stored.find((s) => s.id === id); + + if (!sub) { + setError("Subscription not found."); + setLoading(false); + return; + } + + // Enrich invoice history with latest on-chain data + for (const inv of sub.invoiceHistory) { + try { + const invoice = await splitClient.getInvoice(inv.invoiceId); + inv.status = invoice.status; + } catch { + // Invoice may not exist on-chain yet + } + } + + setSubscription(sub); + saveSubscriptions(stored); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + fetchSubscription(); + }, [fetchSubscription]); + + // Poll for real-time status + useEffect(() => { + const interval = setInterval(fetchSubscription, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchSubscription]); + + const updateSub = (updater: (sub: Subscription) => Subscription) => { + setSubscription((prev) => { + if (!prev) return prev; + const updated = updater(prev); + const all = loadSubscriptions(); + const idx = all.findIndex((s) => s.id === id); + if (idx !== -1) all[idx] = updated; + saveSubscriptions(all); + return updated; + }); + }; + + const handlePause = async () => { + setActionLoading(true); + try { + updateSub(pauseSubscription); + } finally { + setActionLoading(false); + } + }; + + const handleResume = async () => { + setActionLoading(true); + try { + updateSub(resumeSubscription); + } finally { + setActionLoading(false); + } + }; + + const handleCancel = async () => { + if (!confirm("Are you sure you want to cancel this subscription?")) return; + setActionLoading(true); + try { + updateSub(cancelSubscription); + } finally { + setActionLoading(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !subscription) { + return ( +
+

{error ?? "Subscription not found."}

+ + Back to Subscriptions + +
+ ); + } + + const display = formatSubscriptionForDisplay(subscription); + + return ( +
+ {/* Header */} +
+
+ + ← Subscriptions + +
+
+ +
+

{display.templateName}

+ + {subscription.status.charAt(0).toUpperCase() + subscription.status.slice(1)} + +
+ + {/* Subscription Config */} +
+

+ Subscription Configuration +

+
+
+

Template Name

+

{display.templateName}

+
+
+

Frequency

+

{formatFrequency(subscription.frequency)}

+
+
+

Interval

+

{subscription.intervalDays} days

+
+
+

Created

+

{display.createdAtFormatted}

+
+
+

Next Run

+

+ {subscription.status === "active" + ? display.nextRunDateFormatted + : "—"} +

+
+
+

Total Invoices

+

{subscription.totalInvoicesGenerated}

+
+
+

Total USDC Collected

+

+ {display.totalUsdcFormatted} USDC +

+
+
+
+ + {/* Recipients */} +
+

Recipients

+
+ {subscription.recipients.map((r) => ( +
+ + {r.address} + + + {Number(r.amount) / 1_000_000} USDC + +
+ ))} +
+
+ + {/* Actions */} +
+

Actions

+
+ {subscription.status === "active" && ( + + )} + {subscription.status === "paused" && ( + + )} + {subscription.status !== "cancelled" && ( + + )} +
+
+ + {/* Calendar Preview */} + {subscription.status === "active" && ( +
+ +
+ )} + + {/* Invoice History */} +
+

+ Invoice History ({subscription.invoiceHistory.length}) +

+ +
+
+ ); +} diff --git a/src/components/SubscriptionHistoryTable.tsx b/src/components/SubscriptionHistoryTable.tsx new file mode 100644 index 0000000..277fb63 --- /dev/null +++ b/src/components/SubscriptionHistoryTable.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { SubscriptionInvoice } from "@/types/subscription"; +import { formatAmount } from "@stellar-split/sdk"; + +interface Props { + history: SubscriptionInvoice[]; +} + +const STATUS_STYLES: Record = { + Pending: "text-yellow-400", + Released: "text-green-400", + Refunded: "text-gray-400", +}; + +export default function SubscriptionHistoryTable({ history }: Props) { + if (history.length === 0) { + return ( +

+ No invoices generated yet. +

+ ); + } + + return ( +
+ {/* Desktop table */} +
+ + + + + + + + + + + + {history.map((inv) => ( + + + + + + + + ))} + +
+ Invoice ID + + Generated + + Deadline + + Amount + + Status +
#{inv.invoiceId} + {new Date(inv.generatedAt * 1000).toLocaleDateString()} + + {new Date(inv.deadline * 1000).toLocaleDateString()} + + {formatAmount(inv.amount)} USDC + + {inv.status} +
+
+ + {/* Mobile cards */} +
+ {history.map((inv) => ( +
+
+ #{inv.invoiceId} + + {inv.status} + +
+
+

Generated: {new Date(inv.generatedAt * 1000).toLocaleDateString()}

+

Deadline: {new Date(inv.deadline * 1000).toLocaleDateString()}

+

+ Amount: {formatAmount(inv.amount)} USDC +

+
+
+ ))} +
+
+ ); +} diff --git a/src/components/SubscriptionsClient.tsx b/src/components/SubscriptionsClient.tsx new file mode 100644 index 0000000..dbaafc1 --- /dev/null +++ b/src/components/SubscriptionsClient.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { splitClient } from "@/lib/stellar"; +import { getFreighterPublicKey } from "@/lib/freighter"; +import { SubscriptionListSkeleton } from "@/components/Skeleton"; +import SubscriptionCard from "@/components/SubscriptionCard"; +import type { Subscription } from "@/types/subscription"; +import { + loadSubscriptions, + saveSubscriptions, + pauseSubscription, + resumeSubscription, + cancelSubscription, +} from "@/lib/subscriptions"; + +const POLL_INTERVAL_MS = 30_000; + +export default function SubscriptionsClient() { + const router = useRouter(); + const [publicKey, setPublicKey] = useState(null); + const [subscriptions, setSubscriptions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getFreighterPublicKey() + .then(setPublicKey) + .catch(() => + setError("Connect your Freighter wallet to view subscriptions.") + ); + }, []); + + const fetchSubscriptions = useCallback(async () => { + if (!publicKey) return; + + setLoading(true); + setError(null); + + try { + // Load locally-stored subscriptions and enrich with on-chain data + const stored = loadSubscriptions(); + const creatorSubs = stored.filter((s) => s.creator === publicKey); + + // Enrich each subscription with latest invoice data from chain + const enriched: Subscription[] = []; + for (const sub of creatorSubs) { + try { + // Check if the associated invoices exist on-chain + for (const inv of sub.invoiceHistory) { + try { + const invoice = await splitClient.getInvoice(inv.invoiceId); + // Update invoice status from chain + inv.status = invoice.status; + } catch { + // Invoice may not exist on-chain yet + } + } + enriched.push(sub); + } catch { + enriched.push(sub); + } + } + + setSubscriptions(enriched); + saveSubscriptions(enriched); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, [publicKey]); + + useEffect(() => { + fetchSubscriptions(); + }, [fetchSubscriptions]); + + // Poll for real-time status updates + useEffect(() => { + if (!publicKey) return; + const interval = setInterval(fetchSubscriptions, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [publicKey, fetchSubscriptions]); + + const handlePause = async (id: string) => { + setSubscriptions((prev) => { + const updated = prev.map((s) => (s.id === id ? pauseSubscription(s) : s)); + saveSubscriptions(updated); + return updated; + }); + }; + + const handleResume = async (id: string) => { + setSubscriptions((prev) => { + const updated = prev.map((s) => (s.id === id ? resumeSubscription(s) : s)); + saveSubscriptions(updated); + return updated; + }); + }; + + const handleCancel = async (id: string) => { + if (!confirm("Are you sure you want to cancel this subscription?")) return; + setSubscriptions((prev) => { + const updated = prev.map((s) => (s.id === id ? cancelSubscription(s) : s)); + saveSubscriptions(updated); + return updated; + }); + }; + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+ {/* Header */} +
+

Subscriptions

+ + Back to Dashboard + +
+ + {/* Summary */} + {!loading && subscriptions.length > 0 && ( +
+
+

Active

+

+ {subscriptions.filter((s) => s.status === "active").length} +

+
+
+

Paused

+

+ {subscriptions.filter((s) => s.status === "paused").length} +

+
+
+

Cancelled

+

+ {subscriptions.filter((s) => s.status === "cancelled").length} +

+
+
+ )} + + {/* Subscription List */} + {loading ? ( + + ) : subscriptions.length === 0 ? ( +
+

+ No subscriptions yet. Create recurring invoices to get started. +

+ + Create Invoice + +
+ ) : ( +
+ {subscriptions.map((sub) => ( +
+ +
+ {sub.status === "active" && ( + + )} + {sub.status === "paused" && ( + + )} + {sub.status !== "cancelled" && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/lib/stores/subscriptionStore.ts b/src/lib/stores/subscriptionStore.ts new file mode 100644 index 0000000..084417a --- /dev/null +++ b/src/lib/stores/subscriptionStore.ts @@ -0,0 +1,67 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; +import type { Subscription } from "@/types/subscription"; + +interface SubscriptionState { + subscriptions: Record; + loading: boolean; + error: string | null; + lastFetched: number | null; +} + +interface SubscriptionActions { + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + upsertSubscription: (sub: Subscription) => void; + upsertSubscriptions: (subs: Subscription[]) => void; + removeSubscription: (id: string) => void; + reset: () => void; +} + +const initialState: SubscriptionState = { + subscriptions: {}, + loading: false, + error: null, + lastFetched: null, +}; + +export const useSubscriptionStore = create()( + devtools( + immer((set) => ({ + ...initialState, + setLoading: (loading) => + set((s) => { s.loading = loading; }, false, "subscription/setLoading"), + setError: (error) => + set((s) => { s.error = error; }, false, "subscription/setError"), + upsertSubscription: (sub) => + set( + (s) => { + s.subscriptions[sub.id] = sub; + s.lastFetched = Date.now(); + }, + false, + "subscription/upsert" + ), + upsertSubscriptions: (subs) => + set( + (s) => { + for (const sub of subs) { + s.subscriptions[sub.id] = sub; + } + s.lastFetched = Date.now(); + }, + false, + "subscription/upsertMany" + ), + removeSubscription: (id) => + set((s) => { delete s.subscriptions[id]; }, false, "subscription/remove"), + reset: () => + set(() => ({ ...initialState }), false, "subscription/reset"), + })), + { + name: "SubscriptionStore", + enabled: process.env.NODE_ENV === "development", + } + ) +); diff --git a/src/lib/subscriptions.ts b/src/lib/subscriptions.ts new file mode 100644 index 0000000..bad9998 --- /dev/null +++ b/src/lib/subscriptions.ts @@ -0,0 +1,129 @@ +import { formatAmount } from "@stellar-split/sdk"; +import type { + Subscription, + SubscriptionFrequency, + SubscriptionInvoice, +} from "@/types/subscription"; + +const STORAGE_KEY = "stellar_split_subscriptions"; + +export function loadSubscriptions(): Subscription[] { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as Array>; + return parsed.map((s) => ({ + ...s, + recipients: (s.recipients as Array<{ address: string; amount: string }>).map( + (r) => ({ ...r, amount: BigInt(r.amount) }) + ), + totalUsdcCollected: BigInt(s.totalUsdcCollected as string), + invoiceHistory: (s.invoiceHistory as Array>).map( + (inv) => ({ + ...inv, + amount: BigInt(inv.amount as string), + }) + ), + })) as Subscription[]; + } catch { + return []; + } +} + +export function saveSubscriptions(subs: Subscription[]): void { + if (typeof window === "undefined") return; + const serializable = subs.map((s) => ({ + ...s, + recipients: s.recipients.map((r) => ({ + ...r, + amount: r.amount.toString(), + })), + totalUsdcCollected: s.totalUsdcCollected.toString(), + invoiceHistory: s.invoiceHistory.map((inv) => ({ + ...inv, + amount: inv.amount.toString(), + })), + })); + localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable)); +} + +export function getSubscriptionById(id: string): Subscription | undefined { + return loadSubscriptions().find((s) => s.id === id); +} + +export function frequencyToIntervalDays(freq: SubscriptionFrequency): number { + switch (freq) { + case "weekly": + return 7; + case "biweekly": + return 14; + case "monthly": + return 30; + } +} + +export function formatFrequency(freq: SubscriptionFrequency): string { + switch (freq) { + case "weekly": + return "Weekly"; + case "biweekly": + return "Biweekly"; + case "monthly": + return "Monthly"; + } +} + +/** + * Compute upcoming invoice dates from a subscription's config. + * Returns the next N dates after `fromDate` (defaults to now). + */ +export function computeUpcomingDates( + intervalDays: number, + count: number, + fromDate: Date = new Date() +): Date[] { + const dates: Date[] = []; + let current = new Date(fromDate); + for (let i = 0; i < count; i++) { + current = new Date(current.getTime() + intervalDays * 24 * 60 * 60 * 1000); + dates.push(current); + } + return dates; +} + +/** + * Pause a subscription — sets status to "paused". + */ +export function pauseSubscription(sub: Subscription): Subscription { + return { ...sub, status: "paused" as const }; +} + +/** + * Resume a subscription — sets status to "active" and recomputes nextRunDate. + */ +export function resumeSubscription(sub: Subscription): Subscription { + const now = Date.now() / 1000; + const nextRunDate = now + sub.intervalDays * 86400; + return { ...sub, status: "active" as const, nextRunDate }; +} + +/** + * Cancel a subscription — sets status to "cancelled". + */ +export function cancelSubscription(sub: Subscription): Subscription { + return { ...sub, status: "cancelled" as const }; +} + +/** + * Format a subscription for display in the list. + */ +export function formatSubscriptionForDisplay(sub: Subscription) { + return { + ...sub, + totalUsdcFormatted: formatAmount(sub.totalUsdcCollected), + nextRunDateFormatted: new Date(sub.nextRunDate * 1000).toLocaleDateString(), + createdAtFormatted: new Date(sub.createdAt * 1000).toLocaleDateString(), + frequencyLabel: formatFrequency(sub.frequency), + }; +} diff --git a/src/types/subscription.ts b/src/types/subscription.ts new file mode 100644 index 0000000..40fde46 --- /dev/null +++ b/src/types/subscription.ts @@ -0,0 +1,28 @@ +export type SubscriptionFrequency = "weekly" | "biweekly" | "monthly"; + +export type SubscriptionStatus = "active" | "paused" | "cancelled"; + +export interface SubscriptionInvoice { + invoiceId: string; + generatedAt: number; + deadline: number; + amount: bigint; + status: "Pending" | "Released" | "Refunded"; +} + +export interface Subscription { + id: string; + templateName: string; + creator: string; + recipients: Array<{ address: string; amount: bigint }>; + frequency: SubscriptionFrequency; + intervalDays: number; + status: SubscriptionStatus; + createdAt: number; + nextRunDate: number; + lastRunDate: number | null; + token: string; + totalInvoicesGenerated: number; + totalUsdcCollected: bigint; + invoiceHistory: SubscriptionInvoice[]; +}