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.

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