From 7f897173c59299e5303574b33c5abb024fefccee Mon Sep 17 00:00:00 2001 From: priscaenoch Date: Mon, 30 Mar 2026 13:35:06 +0100 Subject: [PATCH 1/2] Frontend cancel subscription action --- frontend/src/app/subscriptions/page.tsx | 29 +++++++++-- frontend/src/lib/stellar.ts | 65 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/subscriptions/page.tsx b/frontend/src/app/subscriptions/page.tsx index d3bb471a..3fb065f3 100644 --- a/frontend/src/app/subscriptions/page.tsx +++ b/frontend/src/app/subscriptions/page.tsx @@ -14,6 +14,7 @@ import { BaseCard } from '@/components/cards/BaseCard'; import HistoryCardSkeleton from '@/components/ui/HistoryCardSkeleton'; import { useToast } from '@/contexts/ToastContext'; import { subscriptionActionToast, subscriptionsLoadFailed } from '@/lib/error-copy'; +import { cancelSubscriptionOnSoroban } from '@/lib/stellar'; export default function SubscriptionsPage() { const { showInfo, showSuccess, showError, showLoading, dismiss } = useToast(); @@ -130,10 +131,27 @@ export default function SubscriptionsPage() { setIsCancelling(true); const loadingToastId = showLoading(`Cancelling ${cancelTarget.creatorName}...`); try { - // Replace with API: await cancelSubscription(cancelTarget.id); - setActiveList((prev: ActiveSubscription[]) => prev.filter((s: ActiveSubscription) => s.id !== cancelTarget.id)); + // Derive fan address from connected wallet; fall back to demo address + const fanAddress = + typeof window !== 'undefined' && + (window as any).freighter + ? await (window as any).freighter.getPublicKey().catch(() => 'fan_demo_address') + : 'fan_demo_address'; + + await cancelSubscriptionOnSoroban({ + fanAddress, + creatorAddress: cancelTarget.creatorId, + reason: 0, + }); + + setActiveList((prev: ActiveSubscription[]) => + prev.filter((s: ActiveSubscription) => s.id !== cancelTarget.id), + ); setCancelTarget(null); - showInfo('Subscription cancelled', `Access remains active until ${formatDate(cancelTarget.currentPeriodEnd)}.`); + showInfo( + 'Subscription cancelled', + `Access remains active until ${formatDate(cancelTarget.currentPeriodEnd)}. No refund is issued for the current period.`, + ); } catch { showError('TX_FAILED', subscriptionActionToast.cancelFailed()); } finally { @@ -348,9 +366,12 @@ export default function SubscriptionsPage() {

Cancel subscription?

-

+

You will lose access to {cancelTarget.creatorName}'s {cancelTarget.planName} content at the end of your current billing period ({formatDate(cancelTarget.currentPeriodEnd)}). You can resubscribe anytime.

+

+ ⚠ No refund will be issued for the remaining days in the current period. Cancellation takes effect on-chain immediately. +

- {/* Table / Cards */} -
- {/* Desktop Table */} -
- - - - - - - - - - - - {paginatedData.length > 0 ? paginatedData.map((sub, idx) => ( - - - - - - - - )) : ( - - - - )} - -
handleSort('name')}> -
Fan {getSortIcon('name')}
-
handleSort('plan')}> -
Plan {getSortIcon('plan')}
-
handleSort('joinDate')}> -
Dates {getSortIcon('joinDate')}
-
handleSort('status')}> -
Status {getSortIcon('status')}
-
handleSort('totalPaid')}> -
Total Paid {getSortIcon('totalPaid')}
-
-
- {sub.name} -
-
{sub.name}
-
{sub.email}
-
-
-
-
{sub.plan}
-
{sub.tier}
-
-
Joined: {sub.joinDate}
-
Renews: {sub.renewDate}
-
- {getStatusBadge(sub.status)} - - ${sub.totalPaid.toFixed(2)} -
- No subscribers found matching your criteria. -
-
- - {/* Mobile Cards */} -
- {paginatedData.length > 0 ? paginatedData.map((sub) => ( -
-
-
- {sub.name} -
-
{sub.name}
-
{sub.email}
-
-
-
- {getStatusBadge(sub.status)} -
-
- -
-
-
Plan
-
{sub.plan}
-
{sub.tier}
-
-
-
Total Paid
-
${sub.totalPaid.toFixed(2)}
-
-
-
Status
- {getStatusBadge(sub.status)} -
-
-
Joined
-
{sub.joinDate}
-
-
-
Renews
-
{sub.renewDate}
-
-
-
- )) : ( -
- No subscribers found matching your criteria. -
- )} -
- - {/* Pagination Controls */} - {totalPages > 0 && ( -
- - Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, processedData.length)} of {processedData.length} results - -
- - -
-
- )} -
+ + columns={COLUMNS} + data={filtered} + keyExtractor={(s) => s.id} + sort={sort} + onSortChange={setSort} + page={page} + onPageChange={setPage} + pageSize={5} + emptyMessage="No subscribers found matching your criteria." + caption="Subscribers" + /> ); } diff --git a/frontend/src/components/transactions/TransactionTable.tsx b/frontend/src/components/transactions/TransactionTable.tsx index 785a3106..c48155c9 100644 --- a/frontend/src/components/transactions/TransactionTable.tsx +++ b/frontend/src/components/transactions/TransactionTable.tsx @@ -1,69 +1,67 @@ -import Link from "next/link"; +import Link from 'next/link'; +import DataTable, { ColumnDef } from '../ui/DataTable'; interface Transaction { - id: string; - type: "subscription" | "payment" | "refund"; - status: "pending" | "success" | "failed"; - amount: number; - currency: string; - txHash?: string; - createdAt: string; + id: string; + type: 'subscription' | 'payment' | 'refund'; + status: 'pending' | 'success' | 'failed'; + amount: number; + currency: string; + txHash?: string; + createdAt: string; } -export function TransactionTable({ data }: { data: Transaction[] }) { - return ( -
- - - - - - - - - - +type TxKey = 'type' | 'amount' | 'status' | 'createdAt' | 'txHash'; - - {data.map((tx) => ( - - - - - - - - ))} - -
TypeAmountStatusDateTx
{tx.type} - {tx.amount} {tx.currency} - - - {tx.status} - - - {new Date(tx.createdAt).toLocaleDateString()} - - {tx.txHash ? ( - - View - - ) : ( - "-" - )} -
-
- ); +const STATUS_CLASSES: Record = { + success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', + failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +}; + +const COLUMNS: ColumnDef[] = [ + { key: 'type', header: 'Type', sortable: true, render: (tx) => {tx.type} }, + { key: 'amount', header: 'Amount', sortable: true, render: (tx) => `${tx.amount} ${tx.currency}` }, + { + key: 'status', + header: 'Status', + sortable: true, + render: (tx) => ( + + {tx.status} + + ), + }, + { key: 'createdAt', header: 'Date', sortable: true, render: (tx) => new Date(tx.createdAt).toLocaleDateString() }, + { + key: 'txHash', + header: 'Tx', + render: (tx) => + tx.txHash ? ( + + View + + ) : ( + '–' + ), + }, +]; + +export function TransactionTable({ data, isLoading, error }: { data: Transaction[]; isLoading?: boolean; error?: string | null }) { + return ( + + columns={COLUMNS} + data={data} + keyExtractor={(tx) => tx.id} + isLoading={isLoading} + error={error} + emptyMessage="No transactions found." + caption="Transactions" + /> + ); } diff --git a/frontend/src/components/ui/DataTable.test.tsx b/frontend/src/components/ui/DataTable.test.tsx new file mode 100644 index 00000000..8801c74f --- /dev/null +++ b/frontend/src/components/ui/DataTable.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { DataTable, ColumnDef } from './DataTable'; + +interface Row { id: string; name: string; score: number } + +const COLUMNS: ColumnDef[] = [ + { key: 'name', header: 'Name', sortable: true }, + { key: 'score', header: 'Score', sortable: true }, +]; + +const DATA: Row[] = [ + { id: '1', name: 'Alice', score: 80 }, + { id: '2', name: 'Charlie', score: 50 }, + { id: '3', name: 'Bob', score: 95 }, +]; + +const keyExtractor = (r: Row) => r.id; + +describe('DataTable', () => { + // ── Rendering ────────────────────────────────────────────────────────────── + + it('renders column headers', () => { + render(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Score')).toBeInTheDocument(); + }); + + it('renders all rows', () => { + render(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + }); + + it('uses custom render function', () => { + const cols: ColumnDef[] = [ + { key: 'name', header: 'Name', render: (r) => {r.name}! }, + { key: 'score', header: 'Score' }, + ]; + render(); + expect(screen.getAllByTestId('custom')).toHaveLength(3); + }); + + // ── Empty / Loading / Error states ───────────────────────────────────────── + + it('shows empty message when data is empty', () => { + render(); + expect(screen.getByText('Nothing here.')).toBeInTheDocument(); + }); + + it('shows default empty message', () => { + render(); + expect(screen.getByText('No data found.')).toBeInTheDocument(); + }); + + it('shows loading skeleton when isLoading=true', () => { + const { container } = render(); + // TableSkeleton renders divs, not a table + expect(container.querySelector('table')).toBeNull(); + }); + + it('shows error alert when error is provided', () => { + render(); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Something went wrong.'); + }); + + it('does not render table when error is set', () => { + const { container } = render(); + expect(container.querySelector('table')).toBeNull(); + }); + + // ── Sorting ──────────────────────────────────────────────────────────────── + + it('sorts ascending on first click', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /name/i })); + const cells = screen.getAllByRole('cell').filter((_, i) => i % 2 === 0); // name column cells + expect(cells[0]).toHaveTextContent('Alice'); + expect(cells[1]).toHaveTextContent('Bob'); + expect(cells[2]).toHaveTextContent('Charlie'); + }); + + it('sorts descending on second click', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + fireEvent.click(nameBtn); + fireEvent.click(nameBtn); + const cells = screen.getAllByRole('cell').filter((_, i) => i % 2 === 0); + expect(cells[0]).toHaveTextContent('Charlie'); + }); + + it('sets aria-sort on active column', () => { + render(); + const nameHeader = screen.getByRole('button', { name: /name/i }); + expect(nameHeader).toHaveAttribute('aria-sort', 'none'); + fireEvent.click(nameHeader); + expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'); + fireEvent.click(nameHeader); + expect(nameHeader).toHaveAttribute('aria-sort', 'descending'); + }); + + it('calls onSortChange when controlled', () => { + const onSortChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /score/i })); + expect(onSortChange).toHaveBeenCalledWith({ key: 'score', direction: 'asc' }); + }); + + // ── Keyboard accessibility ───────────────────────────────────────────────── + + it('triggers sort on Enter key', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + fireEvent.keyDown(nameBtn, { key: 'Enter' }); + expect(nameBtn).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('triggers sort on Space key', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + fireEvent.keyDown(nameBtn, { key: ' ' }); + expect(nameBtn).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('sortable headers have tabIndex=0', () => { + render(); + const nameBtn = screen.getByRole('button', { name: /name/i }); + expect(nameBtn).toHaveAttribute('tabindex', '0'); + }); + + it('non-sortable headers have no tabIndex', () => { + const cols: ColumnDef[] = [ + { key: 'name', header: 'Name', sortable: false }, + { key: 'score', header: 'Score' }, + ]; + render(); + const nameHeader = screen.getByText('Name').closest('th'); + expect(nameHeader).not.toHaveAttribute('tabindex'); + }); + + it('pagination buttons are keyboard accessible', () => { + const manyRows = Array.from({ length: 12 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + const nav = screen.getByRole('navigation', { name: /pagination/i }); + const buttons = within(nav).getAllByRole('button'); + buttons.forEach((btn) => expect(btn).not.toHaveAttribute('tabindex', '-1')); + }); + + // ── Pagination ───────────────────────────────────────────────────────────── + + it('paginates data correctly', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + expect(screen.getByText('User 0')).toBeInTheDocument(); + expect(screen.queryByText('User 3')).not.toBeInTheDocument(); + }); + + it('navigates to next page', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + expect(screen.getByText('User 3')).toBeInTheDocument(); + expect(screen.queryByText('User 0')).not.toBeInTheDocument(); + }); + + it('disables previous button on first page', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + expect(screen.getByRole('button', { name: /previous page/i })).toBeDisabled(); + }); + + it('disables next button on last page', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + fireEvent.click(screen.getByRole('button', { name: /page 3/i })); + expect(screen.getByRole('button', { name: /next page/i })).toBeDisabled(); + }); + + it('calls onPageChange when controlled', () => { + const onPageChange = vi.fn(); + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it('shows pagination range text', () => { + const manyRows = Array.from({ length: 7 }, (_, i) => ({ id: String(i), name: `User ${i}`, score: i })); + render(); + expect(screen.getByText(/1–3/)).toBeInTheDocument(); + expect(screen.getByText(/7/)).toBeInTheDocument(); + }); + + it('does not render pagination when all data fits one page', () => { + render(); + expect(screen.queryByRole('navigation', { name: /pagination/i })).not.toBeInTheDocument(); + }); + + // ── Caption / aria-label ─────────────────────────────────────────────────── + + it('applies caption as aria-label on table', () => { + render(); + expect(screen.getByRole('table', { name: 'My Table' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx new file mode 100644 index 00000000..530457a8 --- /dev/null +++ b/frontend/src/components/ui/DataTable.tsx @@ -0,0 +1,242 @@ +'use client'; + +import React, { useState, useMemo, useCallback } from 'react'; +import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { TableSkeleton } from './states'; + +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + key: K | null; + direction: SortDirection; +} + +export interface ColumnDef { + key: K; + header: string; + sortable?: boolean; + className?: string; + headerClassName?: string; + render?: (row: T) => React.ReactNode; +} + +export interface DataTableProps { + columns: ColumnDef[]; + data: T[]; + keyExtractor: (row: T) => string; + isLoading?: boolean; + error?: string | null; + emptyMessage?: string; + pageSize?: number; + /** Controlled sort — pass to manage externally */ + sort?: SortState; + onSortChange?: (sort: SortState) => void; + /** Controlled page — pass to manage externally */ + page?: number; + onPageChange?: (page: number) => void; + /** Total items count for controlled/server-side pagination */ + totalItems?: number; + caption?: string; + className?: string; +} + +function SortIcon({ active, direction }: { active: boolean; direction: SortDirection }) { + if (!active) return ; + return direction === 'asc' + ? + : ; +} + +export function DataTable({ + columns, + data, + keyExtractor, + isLoading = false, + error = null, + emptyMessage = 'No data found.', + pageSize = 10, + sort: controlledSort, + onSortChange, + page: controlledPage, + onPageChange, + totalItems, + caption, + className = '', +}: DataTableProps) { + const [internalSort, setInternalSort] = useState>({ key: null, direction: 'asc' }); + const [internalPage, setInternalPage] = useState(1); + + const isControlledSort = controlledSort !== undefined; + const isControlledPage = controlledPage !== undefined; + + const sort = isControlledSort ? controlledSort : internalSort; + const currentPage = isControlledPage ? controlledPage : internalPage; + + const handleSort = useCallback((key: K) => { + const next: SortState = { + key, + direction: sort.key === key && sort.direction === 'asc' ? 'desc' : 'asc', + }; + if (isControlledSort) onSortChange?.(next); + else setInternalSort(next); + // Reset to page 1 on sort + if (!isControlledPage) setInternalPage(1); + else onPageChange?.(1); + }, [sort, isControlledSort, isControlledPage, onSortChange, onPageChange]); + + const handlePageChange = useCallback((page: number) => { + if (isControlledPage) onPageChange?.(page); + else setInternalPage(page); + }, [isControlledPage, onPageChange]); + + // Client-side sort (skipped when server-side — caller provides pre-sorted data) + const sortedData = useMemo(() => { + if (!sort.key || isControlledSort) return data; + const key = sort.key; + return [...data].sort((a, b) => { + const av = (a as Record)[key]; + const bv = (b as Record)[key]; + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return sort.direction === 'asc' ? cmp : -cmp; + }); + }, [data, sort, isControlledSort]); + + // Client-side pagination (skipped when server-side — caller provides pre-paged data) + const total = totalItems ?? sortedData.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const pagedData = isControlledPage ? sortedData : sortedData.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ); + + const from = total === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const to = Math.min(currentPage * pageSize, total); + + if (isLoading) { + return 8 ? 5 : pageSize} />; + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+
+ + {caption && } + + + {columns.map((col) => ( + + ))} + + + + {pagedData.length === 0 ? ( + + + + ) : ( + pagedData.map((row, idx) => ( + + {columns.map((col) => ( + + ))} + + )) + )} + +
{caption}
handleSort(col.key) : undefined} + onKeyDown={col.sortable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSort(col.key); } } : undefined} + tabIndex={col.sortable ? 0 : undefined} + role={col.sortable ? 'button' : undefined} + aria-sort={ + col.sortable && sort.key === col.key + ? sort.direction === 'asc' ? 'ascending' : 'descending' + : col.sortable ? 'none' : undefined + } + > + + {col.header} + {col.sortable && ( + + )} + +
+ {emptyMessage} +
+ {col.render + ? col.render(row) + : String((row as Record)[col.key] ?? '')} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {total === 0 ? 'No results' : ( + <>Showing {from}–{to} of {total} + )} + + +
+ )} +
+ ); +} + +export default DataTable;