Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16,561 changes: 5,525 additions & 11,036 deletions frontend/package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
Expand All @@ -28,6 +31,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"jsdom": "^29.0.1",
"tailwindcss": "^4",
"typescript": "5.9.3",
"vitest": "^3.2.4"
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/app/subscriptions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -348,9 +366,12 @@ export default function SubscriptionsPage() {
<h3 id="cancel-dialog-title" className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Cancel subscription?
</h3>
<p id="cancel-dialog-description" className="text-sm text-gray-500 dark:text-gray-400 mb-4">
<p id="cancel-dialog-description" className="text-sm text-gray-500 dark:text-gray-400 mb-2">
You will lose access to {cancelTarget.creatorName}&apos;s {cancelTarget.planName} content at the end of your current billing period ({formatDate(cancelTarget.currentPeriodEnd)}). You can resubscribe anytime.
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-md px-3 py-2 mb-4">
⚠ No refund will be issued for the remaining days in the current period. Cancellation takes effect on-chain immediately.
</p>
<div className="flex gap-3 justify-end">
<button
type="button"
Expand Down
358 changes: 110 additions & 248 deletions frontend/src/components/dashboard/SubscribersTable.tsx

Large diffs are not rendered by default.

42 changes: 39 additions & 3 deletions frontend/src/components/earnings/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react';
import { BaseCard } from '@/components/cards';
import { fetchTransactionHistory, type Transaction } from '@/lib/earnings-api';
import { transactionsToCSV, downloadCSV } from '@/lib/earnings-export';

interface TransactionHistoryProps {
limit?: number;
Expand All @@ -29,6 +30,7 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps)
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [total, setTotal] = useState(0);
const [exporting, setExporting] = useState(false);

useEffect(() => {
const load = async () => {
Expand Down Expand Up @@ -77,14 +79,48 @@ export function TransactionHistoryCard({ limit = 20 }: TransactionHistoryProps)
);
}

const handleExport = async () => {
try {
setExporting(true);
// Fetch all pages for a complete export
const allItems: Transaction[] = [];
let p = 1;
let totalPages = 1;
do {
const res = await fetchTransactionHistory(p, 100);
allItems.push(...res.items);
totalPages = res.total_pages;
p++;
} while (p <= totalPages);

const csv = transactionsToCSV(allItems);
const date = new Date().toISOString().slice(0, 10);
downloadCSV(csv, `earnings-${date}.csv`);
} catch {
// silently fail; user can retry
} finally {
setExporting(false);
}
};

const startItem = (page - 1) * limit + 1;
const endItem = Math.min(page * limit, total);

return (
<BaseCard padding="lg" as="section" aria-labelledby="transactions-heading">
<h2 id="transactions-heading" className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Transaction History
</h2>
<div className="flex items-center justify-between mb-4">
<h2 id="transactions-heading" className="text-lg font-semibold text-gray-900 dark:text-white">
Transaction History
</h2>
<button
onClick={handleExport}
disabled={exporting || transactions.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Export earnings as CSV"
>
{exporting ? 'Exporting…' : '⬇ Export CSV'}
</button>
</div>

<div className="space-y-2 sm:space-y-3">
{transactions.map((tx) => (
Expand Down
124 changes: 61 additions & 63 deletions frontend/src/components/transactions/TransactionTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="overflow-x-auto">
<table className="w-full border rounded-lg">
<thead className="bg-gray-100">
<tr>
<th className="p-3 text-left">Type</th>
<th className="p-3 text-left">Amount</th>
<th className="p-3 text-left">Status</th>
<th className="p-3 text-left">Date</th>
<th className="p-3 text-left">Tx</th>
</tr>
</thead>
type TxKey = 'type' | 'amount' | 'status' | 'createdAt' | 'txHash';

<tbody>
{data.map((tx) => (
<tr key={tx.id} className="border-t">
<td className="p-3 capitalize">{tx.type}</td>
<td className="p-3">
{tx.amount} {tx.currency}
</td>
<td className="p-3">
<span
className={`px-2 py-1 rounded text-sm ${
tx.status === "success"
? "bg-green-100 text-green-700"
: tx.status === "pending"
? "bg-yellow-100 text-yellow-700"
: "bg-red-100 text-red-700"
}`}
>
{tx.status}
</span>
</td>
<td className="p-3">
{new Date(tx.createdAt).toLocaleDateString()}
</td>
<td className="p-3">
{tx.txHash ? (
<Link
href={`https://etherscan.io/tx/${tx.txHash}`}
target="_blank"
className="text-blue-600 underline"
>
View
</Link>
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
const STATUS_CLASSES: Record<Transaction['status'], string> = {
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<Transaction, TxKey>[] = [
{ key: 'type', header: 'Type', sortable: true, render: (tx) => <span className="capitalize">{tx.type}</span> },
{ key: 'amount', header: 'Amount', sortable: true, render: (tx) => `${tx.amount} ${tx.currency}` },
{
key: 'status',
header: 'Status',
sortable: true,
render: (tx) => (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_CLASSES[tx.status]}`}>
{tx.status}
</span>
),
},
{ key: 'createdAt', header: 'Date', sortable: true, render: (tx) => new Date(tx.createdAt).toLocaleDateString() },
{
key: 'txHash',
header: 'Tx',
render: (tx) =>
tx.txHash ? (
<Link
href={`https://stellar.expert/explorer/testnet/tx/${tx.txHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-emerald-600 dark:text-emerald-400 underline hover:no-underline"
>
View
</Link>
) : (
'–'
),
},
];

export function TransactionTable({ data, isLoading, error }: { data: Transaction[]; isLoading?: boolean; error?: string | null }) {
return (
<DataTable<Transaction, TxKey>
columns={COLUMNS}
data={data}
keyExtractor={(tx) => tx.id}
isLoading={isLoading}
error={error}
emptyMessage="No transactions found."
caption="Transactions"
/>
);
}
Loading