Skip to content
Merged

Task #321

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
25 changes: 22 additions & 3 deletions src/app/api/pnl/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
/**
* Profit and Loss (PnL) API Route.
* Generates synthetic historical performance data for the dashboard charts.
* This is used for demonstrating portfolio tracking features.
*/

import { NextResponse } from 'next/server';

/**
* Historical data point for the PnL chart.
*/
interface PnLData {
/** Localized date string (e.g., "Jan 12") */
date: string;
/** The portfolio value at that specific point in time */
value: number;
}

/**
* GET handler for the PnL endpoint.
* Returns a 30-day series of simulated portfolio values.
*/
export async function GET() {
// Generate dummy PnL data for the last 30 days
// Generate mock PnL data for the last 30 days
const data: PnLData[] = [];
const today = new Date();
let currentValue = 10000; // Starting value

// Starting seed value for the simulation
let currentValue = 10000;

for (let i = 29; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);

// Random walk with slight upward trend
// Simulate a random walk with a slight positive bias (0.45 instead of 0.50)
// and a volatility factor of 200
const change = (Math.random() - 0.45) * 200;
currentValue += change;

Expand All @@ -25,6 +43,7 @@ export async function GET() {
});
}

// Return the series as a JSON response
return NextResponse.json(data);
}

Expand Down
23 changes: 22 additions & 1 deletion src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
/**
* Document Upload API Route.
* Handles the secure uploading of invoice documents to IPFS/Pinata.
* Currently disabled for maintenance or pending further security implementation.
*/

import { NextRequest, NextResponse } from 'next/server';

/**
* POST handler for the upload endpoint.
* Currently returns a 503 Service Unavailable error as the feature is locked.
*
* @param {NextRequest} request - The incoming upload request.
*/
export async function POST(request: NextRequest) {
// 1. Log the attempt for security auditing
const clientIp = request.headers.get('x-forwarded-for') || 'unknown';
console.log(`[UploadAPI] Blocked upload attempt from ${clientIp}`);

// 2. Return a consistent error response
return NextResponse.json(
{ error: 'Upload temporarily disabled' },
{
error: 'Upload service temporarily disabled',
reason: 'Undergoing maintenance',
retryAfter: 3600
},
{ status: 503 }
);
}
Expand Down
23 changes: 22 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/**
* TradeFlow Main Dashboard Page.
* This is the primary entry point for the application, providing users with
* a high-level overview of their assets, protocol status, and the RWA pipeline.
*/

"use client";

import React, { useState, useEffect, useRef } from "react";
Expand Down Expand Up @@ -29,15 +35,24 @@ import { useWalletConnection } from "../stores/useWeb3Store";
import { showError, showSuccess } from "../lib/toast";
import Icon from "../components/ui/Icon";

/**
* The root component for the TradeFlow dashboard.
* Manages high-level state for wallet connection, active tabs, and invoice data.
*/
export default function Page() {
const router = useRouter();
const searchParams = useSearchParams();
const { isConnected, walletAddress, isConnecting } = useWalletConnection();
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(false);
/** Controls visibility of the Invoice Minting modal */
const [showMintForm, setShowMintForm] = useState(false);
/** Controls visibility of the Wallet Selection modal */
const [isModalOpen, setIsModalOpen] = useState(false);
/** Currently active navigation tab (dashboard or watchlist) */
const [activeTab, setActiveTab] = useState("dashboard");

/** Watchlist management hook */
const { toggleWatchlist, isInWatchlist } = useWatchlist();
const riskSocketRef = useRef<RiskSocketClient | null>(null);

Expand Down Expand Up @@ -77,6 +92,8 @@ export default function Page() {
}
};

// --- Lifecycle Hooks ---

useEffect(() => {
const controller = new AbortController();

Expand Down Expand Up @@ -147,14 +164,18 @@ export default function Page() {
const handleInvoiceMint = (data: Record<string, unknown>) => {
console.log("Invoice data received:", data);
setShowMintForm(false);
// TODO: Chain integration will be handled separately
// TODO: Initiate Soroban contract call for minting the NFT
};

// --- Configuration ---

/** Tab definitions for the main navigation */
const tabs = [
{ id: "dashboard", label: "Dashboard" },
{ id: "watchlist", label: "Watchlist", icon: <Icon icon={Star} dense /> },
];


return (
<div className="min-h-screen bg-slate-900 text-white font-sans flex flex-col">
{/* Header */}
Expand Down
43 changes: 41 additions & 2 deletions src/components/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,52 @@
/**
* Shared Card Component.
* Provides a consistent container for UI sections with standard
* TradeFlow styling (borders, padding, and background).
*/

import React from "react";

/**
* Props for the Card component.
*/
interface CardProps {
/** The content to be wrapped by the card */
children: React.ReactNode;
/** Additional CSS classes for custom styling overrides */
className?: string;
/** Optional click handler for interactive cards */
onClick?: () => void;
/** Optional hover effect toggle */
hoverable?: boolean;
}

export default function Card({ children, className = "" }: CardProps) {
/**
* A versatile layout component for grouping related content.
*/
export default function Card({
children,
className = "",
onClick,
hoverable = false
}: CardProps) {
// --- Styling ---
const baseStyles = "bg-slate-800 border border-slate-700 rounded-3xl p-6 transition-all duration-300 shadow-xl shadow-black/5";
const hoverStyles = hoverable ? "hover:border-slate-600 hover:bg-slate-800/80 hover:translate-y-[-2px]" : "";
const interactiveStyles = onClick ? "cursor-pointer active:scale-[0.99]" : "";

return (
<div className={`bg-slate-800 border border-slate-700 rounded-2xl p-6 ${className}`}>
<div
className={`${baseStyles} ${hoverStyles} ${interactiveStyles} ${className}`}
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={onClick ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
} : undefined}
>
{children}
</div>
);
Expand Down
27 changes: 25 additions & 2 deletions src/components/InvoiceMintForm.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/**
* Invoice Minting Form Component.
* Provides a guided interface for users to upload invoice PDFs and
* define metadata for on-chain NFT minting.
*/

"use client";

import React, { useState, useEffect } from "react";
Expand All @@ -23,6 +29,7 @@ const invoiceSchema = z.object({
.number()
.min(0.01, "Amount must be greater than 0")
.max(1000000, "Amount cannot exceed $1,000,000"),
/** Expected payment date for the invoice */
dueDate: z
.string()
.min(1, "Due date is required")
Expand All @@ -44,9 +51,14 @@ const invoiceSchema = z.object({
.or(z.literal("")),
});

/** TypeScript type inferred from the validation schema */
type InvoiceFormData = z.infer<typeof invoiceSchema>;

/**
* Props for the InvoiceMintForm component.
*/
interface InvoiceMintFormProps {
/** Callback to trigger when the form is closed/cancelled */
onClose: () => void;
onSuccess?: (txStatus: string) => void;
}
Expand All @@ -67,6 +79,7 @@ export default function InvoiceMintForm({ onClose, onSuccess }: InvoiceMintFormP
const { data: session } = useSession();
const { mint, loading: minting, error: mintError, txStatus } = useMintInvoice();

// --- Form Initialization ---
const {
register,
handleSubmit,
Expand Down Expand Up @@ -104,6 +117,11 @@ export default function InvoiceMintForm({ onClose, onSuccess }: InvoiceMintFormP
}
}, [watchedAmount]);

/**
* Manually updates the file field in react-hook-form when a file is selected.
*
* @param {React.ChangeEvent<HTMLInputElement>} event - The file input change event.
*/
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
Expand Down Expand Up @@ -215,7 +233,7 @@ export default function InvoiceMintForm({ onClose, onSuccess }: InvoiceMintFormP
<input
type="date"
{...register("dueDate")}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-4 py-3.5 bg-slate-900/50 border border-slate-700 rounded-2xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
/>
{errors.dueDate && <p className="mt-2 text-sm text-red-400">{errors.dueDate.message}</p>}
</div>
Expand All @@ -233,10 +251,15 @@ export default function InvoiceMintForm({ onClose, onSuccess }: InvoiceMintFormP
onChange={handleFileChange}
className="hidden"
id="invoice-file"
aria-describedby="file-error"
/>
<label
htmlFor="invoice-file"
className="flex items-center justify-center w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg cursor-pointer hover:bg-slate-600 transition-colors"
className={`flex flex-col items-center justify-center w-full px-4 py-8 border-2 border-dashed rounded-3xl cursor-pointer transition-all ${
filePreview
? "bg-indigo-500/5 border-indigo-500/50"
: "bg-slate-900/50 border-slate-700 hover:border-slate-600 hover:bg-slate-900/80"
}`}
>
<Icon icon={Upload} dense className="mr-2 text-slate-400" />
<span className="text-slate-300">{filePreview || "Choose PDF file"}</span>
Expand Down
74 changes: 65 additions & 9 deletions src/components/LoanTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,32 @@
import React from 'react';
import { formatCurrency, formatDate } from '../lib/format';

// --- Types & Interfaces ---
/**
* Valid states for a loan within the protocol.
*/
type LoanStatus = 'Active' | 'Overdue' | 'Repaid';

/**
* Data structure representing an on-chain loan.
*/
interface Loan {
/** Unique identifier for the loan record */
id: string;
/** The ID of the invoice used as collateral */
invoiceId: string;
/** Principal amount borrowed */
amountBorrowed: number;
interestRate: number; // Annual rate in percentage (e.g., 5 for 5%)
startDate: string; // ISO string date
/** Annual interest rate (e.g., 5 for 5%) */
interestRate: number;
/** ISO 8601 timestamp of when the loan was initiated */
startDate: string;
/** Current lifecycle status of the loan */
status: LoanStatus;
}

// --- Mock Data ---
/**
* Simulated on-chain loan data for development.
*/
const MOCK_LOANS: Loan[] = [
{ id: 'L-001', invoiceId: 'INV-8821', amountBorrowed: 5000, interestRate: 10, startDate: '2026-01-10T00:00:00Z', status: 'Active' },
{ id: 'L-002', invoiceId: 'INV-9942', amountBorrowed: 12000, interestRate: 12, startDate: '2025-11-01T00:00:00Z', status: 'Overdue' },
Expand All @@ -35,7 +48,9 @@ const calculateInterest = (amount: number, rate: number, startDateStr: string):
return interest;
};

// Returns the correct Tailwind classes based on the status
/**
* Renders a stylized badge reflecting the loan status.
*/
const StatusBadge = ({ status }: { status: LoanStatus }) => {
switch (status) {
case 'Repaid':
Expand All @@ -48,8 +63,14 @@ const StatusBadge = ({ status }: { status: LoanStatus }) => {
}
};

// --- Main Component ---
/**
* A responsive table component for managing loans.
*/
export default function LoanTable() {
/**
* Handles the initiation of a loan repayment.
* @param {string} loanId - The ID of the loan to repay.
*/
const handleRepay = (loanId: string) => {
console.log(`Initiating repayment for loan: ${loanId}`);
};
Expand Down Expand Up @@ -94,9 +115,44 @@ export default function LoanTable() {
</button>
</td>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-slate-800 bg-transparent">
{MOCK_LOANS.map((loan) => (
<tr key={loan.id} className="hover:bg-slate-800/30 transition-all duration-200 group">
<td className="px-6 py-4 font-mono text-blue-400 whitespace-nowrap">
{loan.invoiceId}
</td>
<td className="px-6 py-4 font-medium text-slate-100">
{formatCurrency(loan.amountBorrowed)}
</td>
<td className="px-6 py-4 text-emerald-400">
{formatCurrency(calculateInterest(loan.amountBorrowed, loan.interestRate, loan.startDate))}
</td>
<td className="px-6 py-4">
<StatusBadge status={loan.status} />
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleRepay(loan.id)}
disabled={loan.status === 'Repaid'}
className={`px-5 py-2 text-xs font-bold uppercase tracking-widest rounded-lg transition-all transform active:scale-95 ${loan.status === 'Repaid'
? 'bg-slate-700 text-slate-500 cursor-not-allowed opacity-50'
: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-md hover:shadow-indigo-500/20 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-900'
}`}
>
Repay
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{MOCK_LOANS.length === 0 && (
<div className="p-12 text-center text-slate-500 italic">
No active loans found in your history.
</div>
)}
</div>
);
}
Expand Down
Loading
Loading