diff --git a/__tests__/admin/BookPayoutPanel.test.jsx b/__tests__/admin/BookPayoutPanel.test.jsx new file mode 100644 index 00000000..331cce2f --- /dev/null +++ b/__tests__/admin/BookPayoutPanel.test.jsx @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import BookPayoutPanel from "@/components/admin/BookPayoutPanel"; + +vi.mock("@/lib/config/font.config", () => ({ + poppins_400: { className: "" }, + poppins_500: { className: "" }, + poppins_600: { className: "" }, +})); + +const toastMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: toastMock })); + +vi.mock("@/lib/config/env", () => ({ + config: { + stellarNetwork: "testnet", + }, +})); + +const explorerMock = vi.hoisted(() => ({ + isValidStellarAddress: vi.fn(), + getExplorerTransactionUrl: vi.fn( + (hash) => `https://stellar.expert/explorer/testnet/tx/${hash}` + ), + getExplorerUrl: vi.fn( + (pubkey) => `https://stellar.expert/explorer/testnet/account/${pubkey}` + ), +})); +vi.mock("@/lib/utils/stellarExplorer", () => explorerMock); + +const serviceMocks = vi.hoisted(() => ({ + fetchBookPayouts: vi.fn(), +})); +vi.mock("@/lib/actions/admin-book-payouts", () => ({ + fetchBookPayouts: serviceMocks.fetchBookPayouts, +})); + +const BOOK = { + _id: "bk_002", + title: "Understanding Hadith Sciences", + author: { name: "Dr. Fatima" }, + price: 12.99, +}; + +const WALLET = "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567"; + +const MOCK_SUMMARY = { + bookId: "bk_002", + title: "Understanding Hadith Sciences", + creatorName: "Dr. Fatima", + creatorWallet: WALLET, + unitsSold: 3, + grossUsdc: 38.97, + settlements: [ + { + _id: "pt_201", + txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", + amount: 12.99, + currency: "USDC", + status: "confirmed", + buyerName: "Amina Yusuf", + createdAt: "2026-08-05T10:00:00.000Z", + }, + { + _id: "pt_202", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + amount: 12.99, + currency: "USDC", + status: "confirmed", + buyerName: "Umar Farouk", + createdAt: "2026-07-19T14:30:00.000Z", + }, + { + _id: "pt_203", + txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", + amount: 12.99, + currency: "USDC", + status: "pending", + buyerName: "Zaynab Idris", + createdAt: "2026-07-02T08:15:00.000Z", + }, + { + _id: "pt_204", + txHash: "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a", + amount: 12.99, + currency: "USDC", + status: "pending", + buyerName: "Hassan Ibrahim", + createdAt: "2026-06-21T09:45:00.000Z", + }, + ], +}; + +function renderPanel() { + return render( + + ); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + vi.clearAllMocks(); + explorerMock.isValidStellarAddress.mockReturnValue(true); + serviceMocks.fetchBookPayouts.mockResolvedValue({ + success: true, + summary: MOCK_SUMMARY, + }); + + if (!navigator.clipboard) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + writable: true, + }); + } else { + vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + } +}); + +describe("BookPayoutPanel", () => { + it("renders the payout summary for a book", async () => { + renderPanel(); + + expect(screen.getByRole("heading", { name: "Author Payouts" })).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + expect(screen.getByText("Units Sold")).toBeInTheDocument(); + expect(screen.getByText("$38.97")).toBeInTheDocument(); + expect(screen.getByText("Gross USDC")).toBeInTheDocument(); + + expect(screen.getByText(WALLET)).toBeInTheDocument(); + expect(screen.getByText("Validated")).toBeInTheDocument(); + + const accountLink = screen.getByRole("link", { name: "View on explorer" }); + expect(accountLink).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/account/${WALLET}` + ); + + const txLink = screen.getByRole("link", { + name: "View settlement transaction on explorer for Amina Yusuf", + }); + expect(txLink).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/tx/${MOCK_SUMMARY.settlements[0].txHash}` + ); + }); + + it("requests payouts with the book identity", async () => { + renderPanel(); + + await waitFor(() => { + expect(serviceMocks.fetchBookPayouts).toHaveBeenCalledWith( + expect.objectContaining({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + creatorName: "Dr. Fatima", + }) + ); + }); + }); + + it("ignores a stale response from a superseded request", async () => { + const stale = deferred(); + const fresh = deferred(); + serviceMocks.fetchBookPayouts + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise); + + const otherBook = { + _id: "bk_004", + title: "Tajweed Made Simple", + author: { name: "Ustadh Ibrahim" }, + price: 5.0, + }; + + const { rerender } = render( + + ); + rerender( + + ); + + fresh.resolve({ + success: true, + summary: { + ...MOCK_SUMMARY, + bookId: "bk_004", + title: "Tajweed Made Simple", + creatorName: "Ustadh Ibrahim", + creatorWallet: "GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0", + }, + }); + expect( + await screen.findByText("GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0") + ).toBeInTheDocument(); + + stale.resolve({ success: true, summary: MOCK_SUMMARY }); + await waitFor(() => { + expect(screen.queryByText(WALLET)).not.toBeInTheDocument(); + }); + }); + + it("shows an error and recovers on Retry", async () => { + serviceMocks.fetchBookPayouts + .mockResolvedValueOnce({ success: false, error: "Server exploded" }) + .mockResolvedValueOnce({ success: true, summary: MOCK_SUMMARY }); + + renderPanel(); + + expect(await screen.findByText("Server exploded")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByText("$38.97")).toBeInTheDocument(); + expect(screen.getByText("Validated")).toBeInTheDocument(); + }); + + it("flags an unverifiable creator wallet format", async () => { + explorerMock.isValidStellarAddress.mockReturnValue(false); + serviceMocks.fetchBookPayouts.mockResolvedValue({ + success: true, + summary: { ...MOCK_SUMMARY, creatorWallet: "not-an-address" }, + }); + + renderPanel(); + + expect(await screen.findByText("Unverified format")).toBeInTheDocument(); + expect(screen.getByText("not-an-address")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "View on explorer" })).not.toBeInTheDocument(); + }); + + it("copies the creator wallet address", async () => { + renderPanel(); + + const copyButton = await screen.findByRole("button", { + name: "Copy wallet address", + }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(WALLET); + }); + expect(toastMock.success).toHaveBeenCalledWith( + "Creator wallet copied to clipboard!" + ); + }); +}); \ No newline at end of file diff --git a/__tests__/admin/admin-book-payouts.service.test.js b/__tests__/admin/admin-book-payouts.service.test.js new file mode 100644 index 00000000..f91f463f --- /dev/null +++ b/__tests__/admin/admin-book-payouts.service.test.js @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchBookPayouts } from "@/lib/actions/admin-book-payouts"; +import axiosInstance from "@/lib/config/axios.config"; + +vi.mock("@/lib/config/axios.config", () => ({ + default: { + get: vi.fn(), + }, +})); + +describe("admin-book-payouts service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("queries book transactions with date params and aggregates matched book", async () => { + const confirmed = { + _id: "tx_a", + txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", + itemType: "book", + itemId: "bk_002", + itemTitle: "Understanding Hadith Sciences", + amount: 12.99, + status: "confirmed", + buyer: { name: "Amina Yusuf" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-05T10:00:00.000Z", + }; + const pending = { + _id: "tx_b", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + itemType: "book", + itemId: "bk_002", + itemTitle: "Understanding Hadith Sciences", + amount: 12.99, + status: "pending", + buyer: { name: "Umar Farouk" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-01T12:00:00.000Z", + }; + const otherItem = { + _id: "tx_c", + txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", + itemType: "course", + itemTitle: "Arabic Grammar Essentials", + amount: 35, + status: "confirmed", + creator: { name: "Dr. Bilal Karim" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-04T12:00:00.000Z", + }; + + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [confirmed, pending, otherItem] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + creatorName: "Dr. Fatima", + dateFrom: "2026-08-01", + dateTo: "2026-08-31", + }); + + expect(axiosInstance.get).toHaveBeenCalledWith( + "/api/admin/transactions", + expect.objectContaining({ + params: expect.objectContaining({ + itemType: "book", + limit: 200, + dateFrom: "2026-08-01", + dateTo: "2026-08-31", + }), + }) + ); + + expect(result.success).toBe(true); + expect(result.summary.unitsSold).toBe(1); + expect(result.summary.grossUsdc).toBe(12.99); + expect(result.summary.settlements.length).toBe(2); + expect(result.summary.creatorWallet).toBe( + "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567" + ); + expect(result.summary.creatorName).toBe("Dr. Fatima"); + }); + + it("prefers the immutable book identifier over title matching", async () => { + const matchingId = { + _id: "tx_a", + txHash: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c", + itemType: "book", + itemId: "bk_002", + itemTitle: "Some Other Title", + amount: 12.99, + status: "confirmed", + buyer: { name: "Amina Yusuf" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-05T10:00:00.000Z", + }; + const wrongId = { + _id: "tx_b", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + itemType: "book", + itemId: "bk_999", + itemTitle: "Understanding Hadith Sciences", + amount: 12.99, + status: "confirmed", + buyer: { name: "Umar Farouk" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GB7BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q9R0", + createdAt: "2026-08-04T12:00:00.000Z", + }; + + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [matchingId, wrongId] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + }); + + expect(result.summary.creatorWallet).toBe( + "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567" + ); + expect(result.summary.settlements.length).toBe(1); + expect(result.summary.unitsSold).toBe(1); + }); + + it("returns an empty verified summary when the API has no transactions for the book", async () => { + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_001", + bookTitle: "Introduction to Fiqh", + creatorName: "Sheikh Ahmad", + }); + + expect(result.success).toBe(true); + expect(result.summary.bookId).toBe("bk_001"); + expect(result.summary.title).toBe("Introduction to Fiqh"); + expect(result.summary.unitsSold).toBe(0); + expect(result.summary.grossUsdc).toBe(0); + expect(result.summary.settlements).toEqual([]); + expect(result.summary.creatorWallet).toBe(""); + }); + + it("returns an empty verified summary when no transaction matches the book id", async () => { + const orphan = { + _id: "tx_c", + txHash: "3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b", + itemType: "book", + amount: 9.99, + status: "confirmed", + buyer: { name: "Fatima Ali" }, + creator: { name: "Unknown" }, + creatorWallet: "GC3BDSVU7WAKCCGLTDTBQLP3Y4S7G45P6W6Y5Z2XJ3K4L5M6N7P8Q111", + createdAt: "2026-08-05T10:00:00.000Z", + }; + + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [orphan] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + }); + + expect(result.success).toBe(true); + expect(result.summary.bookId).toBe("bk_002"); + expect(result.summary.settlements).toEqual([]); + expect(result.summary.unitsSold).toBe(0); + expect(result.summary.creatorWallet).toBe(""); + }); + + it("filters settlements to the requested date range", async () => { + const inRange = { + _id: "tx_a", + txHash: "1a1b1c1d1e1f1a1b1c1d1e1f1a1b1c1d1e1f1a1b1c1d1e1f1a1b1c1d1e1f1a", + itemType: "book", + itemId: "bk_002", + amount: 12.99, + status: "confirmed", + buyer: { name: "Umar Farouk" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-07-19T14:30:00.000Z", + }; + const outOfRange = { + _id: "tx_b", + txHash: "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c", + itemType: "book", + itemId: "bk_002", + amount: 12.99, + status: "confirmed", + buyer: { name: "Amina Yusuf" }, + creator: { name: "Dr. Fatima" }, + creatorWallet: "GCFXHS4GXL6BVUCFZFDXA2P2VJ2XGCLLK7O6R72EC2Q656BUKZ2W4567", + createdAt: "2026-08-05T10:00:00.000Z", + }; + + axiosInstance.get.mockResolvedValueOnce({ + data: { success: true, transactions: [inRange, outOfRange] }, + }); + + const result = await fetchBookPayouts({ + bookId: "bk_002", + bookTitle: "Understanding Hadith Sciences", + dateFrom: "2026-07-15", + dateTo: "2026-07-31", + }); + + expect(result.success).toBe(true); + expect(result.summary.settlements.length).toBe(1); + expect(result.summary.settlements[0].createdAt).toBe("2026-07-19T14:30:00.000Z"); + expect(result.summary.unitsSold).toBe(1); + expect(result.summary.grossUsdc).toBe(12.99); + }); + + it("propagates the server error message when the API is unavailable", async () => { + axiosInstance.get.mockRejectedValueOnce({ + response: { status: 500, data: { message: "Server exploded" } }, + }); + + const result = await fetchBookPayouts({ bookId: "bk_002" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Server exploded"); + }); + + it("propagates a fallback error message when the API errors without a message", async () => { + axiosInstance.get.mockRejectedValueOnce({ + code: "ERR_NETWORK", + message: "Network Error", + }); + + const result = await fetchBookPayouts({ bookId: "bk_002" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Network Error"); + }); +}); diff --git a/app/[locale]/admin/books/page.jsx b/app/[locale]/admin/books/page.jsx index 6a1906ea..f10a5c3a 100644 --- a/app/[locale]/admin/books/page.jsx +++ b/app/[locale]/admin/books/page.jsx @@ -8,6 +8,7 @@ * - Take-down action opens a reason-category confirmation dialog * - Restore action returns the book to its prior active state * - Status changes reflected immediately in the list + * - Payouts action opens a read-only author payout summary panel (#258) */ import { useState, useCallback, useEffect, useMemo } from "react"; @@ -56,6 +57,7 @@ import { CheckCircle, Ban, Search, + Wallet, } from "lucide-react"; import { cn } from "@/lib/utils"; import { poppins_400, poppins_500 } from "@/lib/config/font.config"; @@ -63,6 +65,7 @@ import TakeDownBookDialog from "@/components/admin/TakeDownBookDialog"; import RestoreBookDialog from "@/components/admin/RestoreBookDialog"; import BlurImage from "@/components/ui/blur-image"; import MediaBlurToggle from "@/components/admin/MediaBlurToggle"; +import BookPayoutPanel from "@/components/admin/BookPayoutPanel"; // Mock books data — replace with real API call const generateMockBooks = () => [ @@ -156,6 +159,8 @@ export default function AdminBooksPage() { const [takedownTarget, setTakedownTarget] = useState(null); const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); const [restoreTarget, setRestoreTarget] = useState(null); + const [payoutDialogOpen, setPayoutDialogOpen] = useState(false); + const [payoutTarget, setPayoutTarget] = useState(null); const fetchBooks = useCallback(async () => { setLoading(true); @@ -395,6 +400,16 @@ export default function AdminBooksPage() { + { + setPayoutTarget(book); + setPayoutDialogOpen(true); + }} + > + + Payouts + + {book.status === "active" ? ( { @@ -453,6 +468,17 @@ export default function AdminBooksPage() { onRestored={handleRestored} /> )} + + {payoutTarget && ( + { + setPayoutDialogOpen(open); + if (!open) setPayoutTarget(null); + }} + book={payoutTarget} + /> + )} ); } diff --git a/components/admin/BookPayoutPanel.jsx b/components/admin/BookPayoutPanel.jsx new file mode 100644 index 00000000..8b372eda --- /dev/null +++ b/components/admin/BookPayoutPanel.jsx @@ -0,0 +1,359 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { RangeFilter } from "@/components/admin/range-filter"; +import { fetchBookPayouts } from "@/lib/actions/admin-book-payouts"; +import { + isValidStellarAddress, + getExplorerTransactionUrl, + getExplorerUrl, +} from "@/lib/utils/stellarExplorer"; +import { config } from "@/lib/config/env"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { poppins_400, poppins_500, poppins_600 } from "@/lib/config/font.config"; +import { + Loader2, + Wallet, + ExternalLink, + Copy, + Check, + ShieldCheck, + AlertTriangle, + CircleDollarSign, + BookOpen, + Receipt, + RefreshCw, + PackageX, +} from "lucide-react"; + +function StatChip({ icon: Icon, label, value }) { + return ( + + +
+
+

+ {value} +

+
+
+ ); +} + +function formatDate(iso) { + const date = new Date(iso); + if (isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export default function BookPayoutPanel({ book, open, onOpenChange }) { + const [range, setRange] = useState({ from: null, to: null }); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copiedWallet, setCopiedWallet] = useState(false); + const requestIdRef = useRef(0); + + const network = config.stellarNetwork; + + const loadPayouts = useCallback(async () => { + if (!book) return; + const requestId = ++requestIdRef.current; + setLoading(true); + setError(null); + try { + const res = await fetchBookPayouts({ + bookId: book._id, + bookTitle: book.title, + creatorName: book.author?.name, + dateFrom: range.from ? range.from.toISOString() : undefined, + dateTo: range.to ? range.to.toISOString() : undefined, + }); + if (requestId !== requestIdRef.current) return; + if (res.success && res.summary) { + setSummary(res.summary); + } else { + setSummary(null); + setError(res.error || "Unable to load payout summary."); + } + } catch (err) { + if (requestId !== requestIdRef.current) return; + setSummary(null); + setError(err?.message || "Unable to load payout summary."); + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, [book, range.from, range.to]); + + useEffect(() => { + if (open && book) loadPayouts(); + }, [open, book, loadPayouts]); + + useEffect(() => { + if (!open) { + requestIdRef.current += 1; + setRange({ from: null, to: null }); + setSummary(null); + setError(null); + setCopiedWallet(false); + } + }, [open]); + + if (!book) return null; + + const wallet = summary?.creatorWallet || ""; + const walletValid = wallet ? isValidStellarAddress(wallet) : false; + const accountUrl = walletValid ? getExplorerUrl(wallet, network) : null; + + const handleCopyWallet = async () => { + if (!wallet) return; + try { + await navigator.clipboard.writeText(wallet); + setCopiedWallet(true); + toast.success("Creator wallet copied to clipboard!"); + setTimeout(() => setCopiedWallet(false), 2000); + } catch { + toast.error("Could not copy wallet address."); + } + }; + + return ( + + + + + + + Payout summary for{" "} + {book.title} by{" "} + {book.author?.name || "Unknown author"}. Read-only quick glance — + reconciliation lives in the payments section. + + + +
+
+ + Read-only + + {book.price === 0 && ( + + Free + + )} +
+ +
+ + {loading ? ( +
+
+ ) : error ? ( +
+
+ ) : summary ? ( +
+
+ + + +
+ + + +
+ + Creator Wallet + + {wallet ? ( + walletValid ? ( + + + ) : ( + + + ) + ) : ( + Not set + )} +
+ + {wallet ? ( +
+ + {wallet} + + + {accountUrl && ( + + View on explorer + + )} +
+ ) : ( +

+ Creator has no registered payout address yet. +

+ )} +
+
+ + {summary.settlements.length === 0 ? ( +
+
+ ) : ( +
+ + + + Buyer + Amount + Status + Date + On-chain + + + + {summary.settlements.map((st) => { + const txUrl = st.txHash + ? getExplorerTransactionUrl(st.txHash, network) + : null; + return ( + + + {st.buyerName || "—"} + + + + ${Number(st.amount).toFixed(2)} + {" "} + + USDC + + + + + {st.status} + + + + {formatDate(st.createdAt)} + + + {txUrl ? ( + + View + + ) : ( + "—" + )} + + + ); + })} + +
+
+ )} +
+ ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/lib/actions/admin-book-payouts.ts b/lib/actions/admin-book-payouts.ts new file mode 100644 index 00000000..7369d8d2 --- /dev/null +++ b/lib/actions/admin-book-payouts.ts @@ -0,0 +1,160 @@ +import axiosInstance from "@/lib/config/axios.config"; + +export interface PayoutSettlement { + _id: string; + txHash: string; + amount: number; + currency: string; + status: string; + buyerName?: string; + createdAt: string; +} + +export interface BookPayoutSummary { + bookId: string; + title: string; + creatorName: string; + creatorWallet: string; + unitsSold: number; + grossUsdc: number; + settlements: PayoutSettlement[]; +} + +export interface FetchBookPayoutsParams { + bookId: string; + bookTitle?: string; + creatorName?: string; + dateFrom?: string; + dateTo?: string; +} + +export interface FetchBookPayoutsResult { + success: boolean; + summary?: BookPayoutSummary; + error?: string; +} + +interface AdminTransactionRecord { + itemType?: string; + itemId?: string; + itemTitle?: string; + amount?: number; + status?: string; + txHash?: string; + _id?: string; + buyer?: { name?: string }; + creator?: { name?: string }; + creatorWallet?: string; + createdAt?: string; +} + +function isWithinRange(iso: string, dateFrom?: string, dateTo?: string): boolean { + const date = new Date(iso); + if (isNaN(date.getTime())) return false; + if (dateFrom) { + const from = new Date(dateFrom); + if (!isNaN(from.getTime()) && date < from) return false; + } + if (dateTo) { + const to = new Date(dateTo); + if (!isNaN(to.getTime())) { + to.setHours(23, 59, 59, 999); + if (date > to) return false; + } + } + return true; +} + +function buildSummary( + params: FetchBookPayoutsParams, + settlements: PayoutSettlement[], + creatorWallet: string, + creatorName: string +): BookPayoutSummary { + const confirmed = settlements.filter( + (s) => s.status && s.status.toLowerCase() === "confirmed" + ); + return { + bookId: params.bookId, + title: params.bookTitle || "Book", + creatorName: creatorName || params.creatorName || "Unknown author", + creatorWallet, + unitsSold: confirmed.length, + grossUsdc: Math.round(confirmed.reduce((sum, s) => sum + (Number(s.amount) || 0), 0) * 100) / 100, + settlements, + }; +} + +function toSettlement(tx: AdminTransactionRecord): PayoutSettlement { + return { + _id: tx._id || "", + txHash: tx.txHash || "", + amount: Number(tx.amount) || 0, + currency: "USDC", + status: tx.status || "unknown", + buyerName: tx.buyer?.name, + createdAt: tx.createdAt || "", + }; +} + +export async function fetchBookPayouts( + params: FetchBookPayoutsParams +): Promise { + if (!params.bookId) { + return { success: false, error: "Missing bookId" }; + } + + const queryParams: Record = { + itemType: "book", + page: 1, + limit: 200, + }; + if (params.dateFrom) queryParams.dateFrom = params.dateFrom; + if (params.dateTo) queryParams.dateTo = params.dateTo; + + let res; + try { + res = await axiosInstance.get("/api/admin/transactions", { + params: queryParams, + }); + } catch (error: any) { + console.error("Failed to fetch book payouts:", error); + return { + success: false, + error: + error?.response?.data?.message || + error?.message || + "Failed to fetch payout summary", + }; + } + + const txList: AdminTransactionRecord[] = + (res.data && res.data.transactions) || []; + + const matched = txList.filter((tx) => { + if (tx.itemType?.toLowerCase() !== "book") return false; + return !!tx.itemId && tx.itemId === params.bookId; + }); + + if (matched.length === 0) { + return { + success: true, + summary: buildSummary(params, [], "", ""), + }; + } + + const settlements = matched + .map(toSettlement) + .filter((s) => isWithinRange(s.createdAt, params.dateFrom, params.dateTo)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return { + success: true, + summary: buildSummary( + params, + settlements, + matched[0].creatorWallet || "", + matched[0].creator?.name || "" + ), + }; +} diff --git a/package-lock.json b/package-lock.json index 8969cabc..536bd0e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@vidstack/react": "^1.12.13", "axios": "^1.9.0", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.11.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", @@ -10972,6 +10973,18 @@ "node": ">=12" } }, + "node_modules/cloudinary": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.11.0.tgz", + "integrity": "sha512-o2pAkDDqrzPFE/mn9GCfFjBIGQmPs++Ts6rtkFl4vY528DNHiMovKWjH16HBjeGa9iwI7eAad24aWN2emT0aww==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/clsx": { "version": "2.1.1", "license": "MIT", @@ -14451,7 +14464,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.camelcase": { diff --git a/package.json b/package.json index 2ebe9def..cbcfd270 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@vidstack/react": "^1.12.13", "axios": "^1.9.0", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.11.0", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0",