diff --git a/src/app/api/strategy/route.ts b/src/app/api/strategy/route.ts new file mode 100644 index 0000000..0bd1119 --- /dev/null +++ b/src/app/api/strategy/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + parseStrategyKind, + StrategyKind, + StrategyPreference, + StrategyUpdatePayload, +} from "@/lib/strategies"; + +// In-memory store for the demo/fallback path. +// Resets on server restart; the client layer mirrors to localStorage for persistence. +let mockPreference: StrategyKind | null = null; + +function resolveEndpoint(baseUrl: string, path: string): string { + const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + const normalizedPath = path.startsWith("/") ? path.slice(1) : path; + return new URL(normalizedPath, normalizedBase).toString(); +} + +export async function GET(request: NextRequest) { + const apiBaseUrl = process.env.NEUROWEALTH_API_BASE_URL; + const strategyPath = + process.env.NEUROWEALTH_STRATEGY_PATH ?? "/strategy/preference"; + + if (apiBaseUrl) { + try { + const res = await fetch(resolveEndpoint(apiBaseUrl, strategyPath), { + cache: "no-store", + headers: { Accept: "application/json" }, + }); + + if (res.ok) { + const data = (await res.json()) as StrategyPreference; + return NextResponse.json(data, { + headers: { "Cache-Control": "no-store" }, + }); + } + } catch { + // fall through to mock + } + } + + // Provide the in-memory default. Clients may override via localStorage. + const body: StrategyPreference = { strategy: mockPreference }; + return NextResponse.json(body, { + headers: { "Cache-Control": "no-store" }, + }); +} + +export async function PUT(request: NextRequest) { + const apiBaseUrl = process.env.NEUROWEALTH_API_BASE_URL; + const strategyPath = + process.env.NEUROWEALTH_STRATEGY_PATH ?? "/strategy/preference"; + + const payload = (await request.json()) as Partial; + const strategy = parseStrategyKind(payload.strategy ?? null); + + if (!strategy) { + return NextResponse.json( + { message: "Invalid strategy value. Must be conservative, balanced, or growth." }, + { status: 422 }, + ); + } + + if (apiBaseUrl) { + try { + const res = await fetch(resolveEndpoint(apiBaseUrl, strategyPath), { + method: "PUT", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ strategy }), + cache: "no-store", + }); + + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { + "Content-Type": res.headers.get("Content-Type") ?? "application/json", + "Cache-Control": "no-store", + }, + }); + } catch { + // fall through to mock + } + } + + // Mock: persist in memory and respond with the updated preference. + mockPreference = strategy; + const body: StrategyPreference = { strategy }; + return NextResponse.json(body, { + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/src/app/api/transaction-history/route.ts b/src/app/api/transaction-history/route.ts new file mode 100644 index 0000000..b3dfbc8 --- /dev/null +++ b/src/app/api/transaction-history/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + filterAndPaginateHistory, + parseHistoryKind, + parseHistoryStatus, + TransactionHistoryFilter, +} from "@/lib/transaction-history"; + +export function GET(req: NextRequest) { + const params = req.nextUrl.searchParams; + + const kind = parseHistoryKind(params.get("kind")); + const status = parseHistoryStatus(params.get("status")); + const dateFrom = params.get("dateFrom") ?? ""; + const dateTo = params.get("dateTo") ?? ""; + const page = Math.max(1, parseInt(params.get("page") ?? "1", 10) || 1); + const pageSize = Math.min( + 50, + Math.max(1, parseInt(params.get("pageSize") ?? "10", 10) || 10), + ); + + const filter: TransactionHistoryFilter = { + kind, + status, + dateFrom, + dateTo, + page, + pageSize, + }; + + const result = filterAndPaginateHistory(filter); + + return NextResponse.json(result); +} diff --git a/src/app/dashboard/history/page.tsx b/src/app/dashboard/history/page.tsx index 7e36b6b..ea145d0 100644 --- a/src/app/dashboard/history/page.tsx +++ b/src/app/dashboard/history/page.tsx @@ -1,5 +1,8 @@ "use client"; +import { TransactionHistory } from "@/components/transactions/TransactionHistory"; +import { ProtectedRoute } from "@/components/auth/ProtectedRoute"; +import { Suspense, useState } from "react"; import { useState, useEffect } from "react"; import { useSearchParams } from "next/navigation"; import { Clock, AlertTriangle, Loader2 } from "lucide-react"; @@ -7,6 +10,7 @@ import { EmptyState } from "@/components/ui/EmptyState"; import { TableSkeleton } from "@/components/ui/Skeleton"; import { useSandbox } from "@/contexts/SandboxContext"; + export default function HistoryPage() { const searchParams = useSearchParams(); const { getCurrentScenario, isSandboxMode } = useSandbox(); @@ -98,7 +102,6 @@ export default function HistoryPage() { ); } - return (
@@ -137,6 +140,8 @@ export default function HistoryPage() {
+ + ); } diff --git a/src/app/dashboard/strategy/page.tsx b/src/app/dashboard/strategy/page.tsx index fbc69b9..42129b6 100644 --- a/src/app/dashboard/strategy/page.tsx +++ b/src/app/dashboard/strategy/page.tsx @@ -3,6 +3,8 @@ import { CheckCircle, Shield, TrendingUp, Zap } from "lucide-react"; import StrategyLoading from "./loading"; import { cn } from "@/lib/utils"; import type { Strategy } from "@/types"; +import { StrategySelector } from "@/components/strategies/StrategySelector"; + export const metadata = { title: "Strategy — NeuroWealth" }; @@ -146,6 +148,7 @@ export default function StrategyPage() { return ( }> + ); } diff --git a/src/components/strategies/StrategySelector.tsx b/src/components/strategies/StrategySelector.tsx new file mode 100644 index 0000000..dc50e06 --- /dev/null +++ b/src/components/strategies/StrategySelector.tsx @@ -0,0 +1,591 @@ +"use client"; + +import { useEffect, useReducer, useRef } from "react"; +import Link from "next/link"; +import { + TrendingUp, + ShieldCheck, + Zap, + CheckCircle2, + AlertTriangle, + Loader2, + X, +} from "lucide-react"; +import { + STRATEGIES, + COMPARISON_ROWS, + StrategyCard, + StrategyKind, + StrategyPreference, + getStrategy, + loadStoredPreference, + saveStoredPreference, +} from "@/lib/strategies"; +import { Button } from "@/components/ui/Button"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type SaveStatus = "idle" | "saving" | "success" | "error"; + +interface State { + current: StrategyKind | null; + pending: StrategyKind | null; // strategy awaiting confirmation + saveStatus: SaveStatus; + errorMessage: string | null; + loadingInitial: boolean; +} + +type Action = + | { type: "LOAD_SUCCESS"; strategy: StrategyKind | null } + | { type: "REQUEST_CHANGE"; strategy: StrategyKind } + | { type: "CANCEL_CHANGE" } + | { type: "SAVE_START" } + | { type: "SAVE_SUCCESS"; strategy: StrategyKind } + | { type: "SAVE_ERROR"; message: string }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "LOAD_SUCCESS": + return { ...state, current: action.strategy, loadingInitial: false }; + case "REQUEST_CHANGE": + return { ...state, pending: action.strategy, saveStatus: "idle", errorMessage: null }; + case "CANCEL_CHANGE": + return { ...state, pending: null, saveStatus: "idle", errorMessage: null }; + case "SAVE_START": + return { ...state, saveStatus: "saving", errorMessage: null }; + case "SAVE_SUCCESS": + return { + ...state, + current: action.strategy, + pending: null, + saveStatus: "success", + errorMessage: null, + }; + case "SAVE_ERROR": + return { ...state, saveStatus: "error", errorMessage: action.message }; + } +} + +const INITIAL_STATE: State = { + current: null, + pending: null, + saveStatus: "idle", + errorMessage: null, + loadingInitial: true, +}; + +// ─── Design-spec risk badge ─────────────────────────────────────────────────── +// Conservative → accent (sky-400) +// Balanced → warning (amber-500) +// Growth → danger (red-500) + +function riskBadgeClass(tier: StrategyCard["riskTier"]): string { + switch (tier) { + case "low": + return "bg-sky-400/15 text-sky-400 border border-sky-400/30"; + case "medium": + return "bg-amber-500/15 text-amber-500 border border-amber-500/30"; + case "high": + return "bg-red-500/15 text-red-500 border border-red-500/30"; + } +} + +function strategyIcon(kind: StrategyKind) { + switch (kind) { + case "conservative": + return ; + case "balanced": + return ; + case "growth": + return ; + } +} + +function iconContainerClass(kind: StrategyKind): string { + switch (kind) { + case "conservative": + return "text-sky-400 bg-sky-400/10"; + case "balanced": + return "text-amber-400 bg-amber-400/10"; + case "growth": + return "text-red-400 bg-red-400/10"; + } +} + +// ─── Strategy card ──────────────────────────────────────────────────────────── + +interface StrategyCardProps { + strategy: StrategyCard; + isSelected: boolean; + onSelect: (kind: StrategyKind) => void; + saving: boolean; +} + +function StrategyCardView({ + strategy, + isSelected, + onSelect, + saving, +}: StrategyCardProps) { + // Spec: selected state → border-2 primary + background tint + const cardClass = isSelected + ? "border-2 border-sky-500 bg-sky-500/8 shadow-lg shadow-sky-500/10" + : "border border-white/10 bg-white/3 hover:border-white/20 hover:bg-white/5"; + + return ( +
+ {isSelected && ( + + + Current + + )} + + {/* Icon */} +
+ {strategyIcon(strategy.kind)} +
+ + {/* Title + APY */} +
+

{strategy.title}

+

+ {strategy.apyRange} + APY +

+
+ + {/* Risk badge — Spec: Conservative=accent, Balanced=warning, Growth=danger */} + + {strategy.riskLabel} + + + {/* Description ≤ 140 chars */} +

+ {strategy.description} +

+ + {/* Primary action */} + +
+ ); +} + +// ─── Confirmation modal ─────────────────────────────────────────────────────── + +interface ConfirmModalProps { + from: StrategyKind | null; + to: StrategyKind; + saveStatus: SaveStatus; + errorMessage: string | null; + onConfirm: () => void; + onCancel: () => void; +} + +function ConfirmModal({ + from, + to, + saveStatus, + errorMessage, + onConfirm, + onCancel, +}: ConfirmModalProps) { + const confirmRef = useRef(null); + const toStrategy = getStrategy(to); + const fromStrategy = from ? getStrategy(from) : null; + const saving = saveStatus === "saving"; + + // Trap focus and handle Escape + useEffect(() => { + confirmRef.current?.focus(); + function onKey(e: KeyboardEvent) { + if (e.key === "Escape" && !saving) onCancel(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onCancel, saving]); + + return ( +
+ {/* Backdrop */} + + ); +} + +// ─── Comparison table ───────────────────────────────────────────────────────── + +function ComparisonTable({ current }: { current: StrategyKind | null }) { + const highlight = (kind: StrategyKind) => + kind === current ? "bg-sky-500/8 font-semibold text-slate-100" : "text-slate-400"; + + return ( +
+

+ Strategy comparison +

+ + {/* Spec: mobile horizontally scrollable; sticky header on desktop */} +
+ + + + + {STRATEGIES.map((s) => ( + + ))} + + + + {COMPARISON_ROWS.map((row, i) => ( + + + + + + + ))} + +
+ Feature + + {s.title} + {s.kind === current && ( + + active + + )} +
+ {row.feature} + + {row.conservative} + + {row.balanced} + + {row.growth} +
+
+
+ ); +} + +// ─── Success toast ──────────────────────────────────────────────────────────── + +function SuccessBanner({ strategy }: { strategy: StrategyKind }) { + return ( +
+ + + Strategy updated to{" "} + {getStrategy(strategy).title}. Rebalancing + will apply on the next scheduled cycle. + +
+ ); +} + +// ─── Skeleton cards ─────────────────────────────────────────────────────────── + +function SkeletonCards() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ +
+ + +
+ +
+ + + +
+ +
+ ))} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function StrategySelector() { + const [state, dispatch] = useReducer(reducer, INITIAL_STATE); + + // Load preference on mount — client localStorage first, then API + useEffect(() => { + const stored = loadStoredPreference(); + if (stored) { + dispatch({ type: "LOAD_SUCCESS", strategy: stored }); + return; + } + + fetch("/api/strategy", { cache: "no-store" }) + .then((res) => { + if (!res.ok) throw new Error("Failed to load"); + return res.json() as Promise; + }) + .then((data) => { + dispatch({ type: "LOAD_SUCCESS", strategy: data.strategy }); + }) + .catch(() => { + // Default to no preference selected + dispatch({ type: "LOAD_SUCCESS", strategy: null }); + }); + }, []); + + function handleSelect(kind: StrategyKind) { + if (kind === state.current) return; + dispatch({ type: "REQUEST_CHANGE", strategy: kind }); + } + + function handleCancel() { + dispatch({ type: "CANCEL_CHANGE" }); + } + + async function handleConfirm() { + if (!state.pending) return; + const strategy = state.pending; + + dispatch({ type: "SAVE_START" }); + + try { + const res = await fetch("/api/strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy }), + cache: "no-store", + }); + + if (!res.ok) { + const err = (await res.json()) as { message?: string }; + throw new Error(err.message ?? `Request failed (${res.status})`); + } + + saveStoredPreference(strategy); + dispatch({ type: "SAVE_SUCCESS", strategy }); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to save strategy. Please try again."; + dispatch({ type: "SAVE_ERROR", message }); + } + } + + const { current, pending, saveStatus, errorMessage, loadingInitial } = state; + + return ( +
+
+ {/* Page header */} +
+

+ Settings +

+

Choose your strategy

+

+ Select the risk/APY profile that matches your goals. Your active positions + will rebalance on the next scheduled cycle. +

+
+ + {/* Success banner */} + {saveStatus === "success" && current && ( +
+ +
+ )} + + {/* Strategy cards — Spec: 3 cards with equal visual weight */} +
+ {loadingInitial ? ( + + ) : ( +
+ {STRATEGIES.map((strategy) => ( + + ))} +
+ )} +
+ + {/* Comparison table */} +
+ +
+ + {/* Dashboard link */} +
+ + + +
+
+ + {/* Confirmation modal */} + {pending && ( + + )} +
+ ); +} diff --git a/src/components/transactions/TransactionHistory.tsx b/src/components/transactions/TransactionHistory.tsx new file mode 100644 index 0000000..f9dad2f --- /dev/null +++ b/src/components/transactions/TransactionHistory.tsx @@ -0,0 +1,766 @@ +"use client"; + +import { useEffect, useReducer, useRef } from "react"; +import Link from "next/link"; +import { + ArrowDownCircle, + ArrowUpCircle, + RefreshCw, + ExternalLink, + ChevronLeft, + ChevronRight, + ClipboardList, + Loader2, +} from "lucide-react"; +import { + HistoryKind, + HistoryStatus, + TransactionHistoryPage, +} from "@/lib/transaction-history"; +import { formatTimestamp } from "@/lib/formatters"; +import { Button } from "@/components/ui/Button"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface FilterState { + kind: HistoryKind | "all"; + status: HistoryStatus | "all"; + dateFrom: string; + dateTo: string; + page: number; +} + +type FilterAction = + | { type: "SET_KIND"; value: HistoryKind | "all" } + | { type: "SET_STATUS"; value: HistoryStatus | "all" } + | { type: "SET_DATE_FROM"; value: string } + | { type: "SET_DATE_TO"; value: string } + | { type: "SET_PAGE"; value: number } + | { type: "RESET" }; + +interface DataState { + data: TransactionHistoryPage | null; + loading: boolean; + error: string | null; +} + +type DataAction = + | { type: "FETCH_START" } + | { type: "FETCH_SUCCESS"; payload: TransactionHistoryPage } + | { type: "FETCH_ERROR"; message: string }; + +// ─── Reducers ──────────────────────────────────────────────────────────────── + +const INITIAL_FILTER: FilterState = { + kind: "all", + status: "all", + dateFrom: "", + dateTo: "", + page: 1, +}; + +function filterReducer(state: FilterState, action: FilterAction): FilterState { + switch (action.type) { + case "SET_KIND": + return { ...state, kind: action.value, page: 1 }; + case "SET_STATUS": + return { ...state, status: action.value, page: 1 }; + case "SET_DATE_FROM": + return { ...state, dateFrom: action.value, page: 1 }; + case "SET_DATE_TO": + return { ...state, dateTo: action.value, page: 1 }; + case "SET_PAGE": + return { ...state, page: action.value }; + case "RESET": + return INITIAL_FILTER; + } +} + +const INITIAL_DATA: DataState = { data: null, loading: true, error: null }; + +function dataReducer(state: DataState, action: DataAction): DataState { + switch (action.type) { + case "FETCH_START": + return { ...state, loading: true, error: null }; + case "FETCH_SUCCESS": + return { data: action.payload, loading: false, error: null }; + case "FETCH_ERROR": + return { ...state, loading: false, error: action.message }; + } +} + +// ─── Config ────────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 10; + +const KIND_CHIPS: { label: string; value: HistoryKind | "all" }[] = [ + { label: "All", value: "all" }, + { label: "Deposits", value: "deposit" }, + { label: "Withdrawals", value: "withdrawal" }, + { label: "Rebalances", value: "rebalance" }, +]; + +const STATUS_CHIPS: { label: string; value: HistoryStatus | "all" }[] = [ + { label: "All", value: "all" }, + { label: "Success", value: "success" }, + { label: "Pending", value: "pending" }, + { label: "Failed", value: "failed" }, +]; + +// Stellar Testnet explorer base URL +const EXPLORER_BASE = "https://stellar.expert/explorer/testnet/tx"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Spec: success #10B981 · pending #F59E0B · failed #EF4444 */ +function statusStyles(status: HistoryStatus): string { + switch (status) { + case "success": + return "bg-emerald-500/15 text-emerald-500"; + case "pending": + return "bg-amber-500/15 text-amber-500"; + case "failed": + return "bg-red-500/15 text-red-500"; + } +} + +function statusLabel(status: HistoryStatus): string { + switch (status) { + case "success": + return "Success"; + case "pending": + return "Pending"; + case "failed": + return "Failed"; + } +} + +function kindIcon(kind: HistoryKind) { + switch (kind) { + case "deposit": + return ; + case "withdrawal": + return ; + case "rebalance": + return ; + } +} + +function kindLabel(kind: HistoryKind): string { + switch (kind) { + case "deposit": + return "Deposit"; + case "withdrawal": + return "Withdrawal"; + case "rebalance": + return "Rebalance"; + } +} + +function formatAmount(amount: number | null, kind: HistoryKind): string { + if (amount === null) return "—"; + const abs = Math.abs(amount).toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + }); + if (kind === "withdrawal" || amount < 0) return `-${abs}`; + return `+${abs}`; +} + +function amountColor(amount: number | null, kind: HistoryKind): string { + if (amount === null) return "text-slate-500"; + if (kind === "deposit") return "text-emerald-400"; + if (kind === "withdrawal") return "text-red-400"; + return "text-slate-400"; +} + +function truncateHash(hash: string): string { + return `${hash.slice(0, 8)}…${hash.slice(-6)}`; +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function StatusTag({ status }: { status: HistoryStatus }) { + return ( + + {statusLabel(status)} + + ); +} + +interface TxHashLinkProps { + txHash: string | null; +} + +function TxHashLink({ txHash }: TxHashLinkProps) { + if (!txHash) { + return ; + } + return ( + + {truncateHash(txHash)} + + + ); +} + +function SkeletonRows({ count }: { count: number }) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( + + {Array.from({ length: 6 }).map((__, j) => ( + + + + ))} + + ))} + + ); +} + +function SkeletonCards({ count }: { count: number }) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( +
+
+ + +
+ + +
+ + +
+
+ ))} + + ); +} + +interface EmptyStateProps { + filtered: boolean; + onReset: () => void; +} + +function EmptyState({ filtered, onReset }: EmptyStateProps) { + return ( +
+
+ +
+
+

+ {filtered ? "No matching transactions" : "No transaction history yet"} +

+

+ {filtered + ? "Try adjusting your filters or clearing the date range." + : "Make your first deposit to start building your history."} +

+
+ {filtered ? ( + + ) : ( + + + + )} +
+ ); +} + +interface PaginationProps { + page: number; + totalPages: number; + total: number; + pageSize: number; + onPage: (n: number) => void; +} + +function Pagination({ page, totalPages, total, pageSize, onPage }: PaginationProps) { + if (totalPages <= 1) return null; + + const start = (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, total); + + return ( +
+

+ Showing {start}–{end} of{" "} + {total} +

+
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( + + ))} + + +
+
+ ); +} + +// ─── Filter bar ────────────────────────────────────────────────────────────── + +interface FilterBarProps { + filter: FilterState; + dispatch: React.Dispatch; +} + +function FilterBar({ filter, dispatch }: FilterBarProps) { + const isFiltered = + filter.kind !== "all" || + filter.status !== "all" || + filter.dateFrom !== "" || + filter.dateTo !== ""; + + return ( +
+ {/* Type chips */} +
+ + Type + +
+ {KIND_CHIPS.map((chip) => ( + + ))} +
+
+ + {/* Status chips */} +
+ + Status + +
+ {STATUS_CHIPS.map((chip) => ( + + ))} +
+
+ + {/* Date range */} +
+ Date range +
+
+ + dispatch({ type: "SET_DATE_FROM", value: e.target.value })} + className="h-8 rounded-lg border border-white/15 bg-white/5 px-2.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500 transition-colors" + aria-label="From date" + /> +
+ to +
+ + dispatch({ type: "SET_DATE_TO", value: e.target.value })} + className="h-8 rounded-lg border border-white/15 bg-white/5 px-2.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500 transition-colors" + aria-label="To date" + /> +
+
+ + {isFiltered && ( + + )} +
+
+ ); +} + +// ─── Desktop table ──────────────────────────────────────────────────────────── + +function DesktopTable({ + data, + loading, +}: { + data: TransactionHistoryPage | null; + loading: boolean; +}) { + return ( + /* Spec: table with sticky header */ +
+
+ + + + + + + + + + + + + {loading ? ( + + ) : data && data.items.length > 0 ? ( + data.items.map((item) => ( + + + + {/* Spec: timestamp in muted monospace */} + + + + {/* Spec: tx hash in muted monospace */} + + + )) + ) : ( + + + + )} + +
+ Type + + Description + + Date + + Amount + + Status + + Tx Hash +
+ + {kindIcon(item.kind)} + {kindLabel(item.kind)} + + +

{item.title}

+

{item.detail}

+
+ {formatTimestamp(item.occurredAt)} + + {formatAmount(item.amount, item.kind)} + + + + +
+ {/* empty state rendered outside the table for better layout */} +
+
+
+ ); +} + +// ─── Mobile card list ───────────────────────────────────────────────────────── + +function MobileCards({ + data, + loading, +}: { + data: TransactionHistoryPage | null; + loading: boolean; +}) { + return ( + /* Spec: card list layout on mobile */ +
+ {loading ? ( + + ) : data && data.items.length > 0 ? ( + data.items.map((item) => ( +
+
+ + {kindIcon(item.kind)} + {kindLabel(item.kind)} + + +
+ +
+

{item.title}

+

{item.detail}

+
+ +
+ {/* Spec: timestamp in muted monospace */} + + {formatTimestamp(item.occurredAt)} + + + {formatAmount(item.amount, item.kind)} + +
+ + {/* Spec: tx hash in muted monospace */} + {item.txHash && ( +
+ Tx: + +
+ )} +
+ )) + ) : null} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function TransactionHistory() { + const [filter, dispatchFilter] = useReducer(filterReducer, INITIAL_FILTER); + const [dataState, dispatchData] = useReducer(dataReducer, INITIAL_DATA); + const abortRef = useRef(null); + + useEffect(() => { + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + + dispatchData({ type: "FETCH_START" }); + + const params = new URLSearchParams({ + kind: filter.kind, + status: filter.status, + dateFrom: filter.dateFrom, + dateTo: filter.dateTo, + page: String(filter.page), + pageSize: String(PAGE_SIZE), + }); + + fetch(`/api/transaction-history?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }) + .then((res) => { + if (!res.ok) throw new Error(`Request failed (${res.status})`); + return res.json() as Promise; + }) + .then((payload) => { + if (!controller.signal.aborted) { + dispatchData({ type: "FETCH_SUCCESS", payload }); + } + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + const message = + err instanceof Error ? err.message : "Unable to load transaction history."; + dispatchData({ type: "FETCH_ERROR", message }); + }); + + return () => controller.abort(); + }, [filter.kind, filter.status, filter.dateFrom, filter.dateTo, filter.page]); + + const { data, loading, error } = dataState; + + const isFiltered = + filter.kind !== "all" || + filter.status !== "all" || + filter.dateFrom !== "" || + filter.dateTo !== ""; + + const isEmpty = !loading && !error && data?.total === 0; + + return ( +
+
+ {/* Header */} +
+

+ Activity +

+

Transaction history

+

+ Full record of deposits, withdrawals, and rebalancing events. Click a + transaction hash to view it on the Stellar explorer. +

+
+ + {/* Filter bar */} +
+ +
+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Loading indicator (accessible) */} + {loading && ( +
+ Loading transaction history… +
+ )} + + {/* Spinning indicator shown near results */} + {loading && ( +
+ +
+ )} + + {/* Desktop table */} + {!isEmpty && } + + {/* Mobile cards */} + {!isEmpty && } + + {/* Empty state (shared for both breakpoints) */} + {isEmpty && ( + dispatchFilter({ type: "RESET" })} + /> + )} + + {/* Pagination */} + {!loading && data && data.totalPages > 1 && ( +
+ dispatchFilter({ type: "SET_PAGE", value: n })} + /> +
+ )} +
+
+ ); +} diff --git a/src/lib/strategies.ts b/src/lib/strategies.ts new file mode 100644 index 0000000..a01f253 --- /dev/null +++ b/src/lib/strategies.ts @@ -0,0 +1,155 @@ +export type StrategyKind = "conservative" | "balanced" | "growth"; +export type RiskTier = "low" | "medium" | "high"; + +export interface StrategyCard { + kind: StrategyKind; + title: string; + apyRange: string; + apyMin: number; + apyMax: number; + riskLabel: string; + riskTier: RiskTier; + /** Must be ≤ 140 characters per design spec */ + description: string; + primaryAction: string; +} + +export interface ComparisonRow { + feature: string; + conservative: string; + balanced: string; + growth: string; +} + +export interface StrategyPreference { + strategy: StrategyKind | null; +} + +export interface StrategyUpdatePayload { + strategy: StrategyKind; +} + +// ─── Strategy definitions ───────────────────────────────────────────────────── +// Descriptions are verified ≤ 140 characters each. + +export const STRATEGIES: StrategyCard[] = [ + { + kind: "conservative", + title: "Conservative", + apyRange: "4–6%", + apyMin: 4, + apyMax: 6, + riskLabel: "Low risk", + riskTier: "low", + // 116 chars + description: + "Stablecoin lending and idle reserve coverage. Capital-preserving with predictable yield and minimal drawdown exposure.", + primaryAction: "Select Conservative", + }, + { + kind: "balanced", + title: "Balanced", + apyRange: "7–10%", + apyMin: 7, + apyMax: 10, + riskLabel: "Medium risk", + riskTier: "medium", + // 121 chars + description: + "Yield split across Blend lending, DEX liquidity, and a stable reserve. Best for steady growth with controlled volatility.", + primaryAction: "Select Balanced", + }, + { + kind: "growth", + title: "Growth", + apyRange: "11–18%", + apyMin: 11, + apyMax: 18, + riskLabel: "High risk", + riskTier: "high", + // 118 chars + description: + "Leans into incentive programs, active rebalancing, and higher-volatility positions. Maximum upside with elevated risk.", + primaryAction: "Select Growth", + }, +]; + +// ─── Comparison table ───────────────────────────────────────────────────────── + +export const COMPARISON_ROWS: ComparisonRow[] = [ + { + feature: "APY range", + conservative: "4–6%", + balanced: "7–10%", + growth: "11–18%", + }, + { + feature: "Risk level", + conservative: "Low", + balanced: "Medium", + growth: "High", + }, + { + feature: "Rebalance", + conservative: "Monthly", + balanced: "Weekly", + growth: "Daily", + }, + { + feature: "Liquidity", + conservative: "Same-day", + balanced: "Same-day", + growth: "1–2 days", + }, + { + feature: "Max drawdown", + conservative: "< 5%", + balanced: "< 15%", + growth: "< 35%", + }, + { + feature: "Ideal horizon", + conservative: "3+ months", + balanced: "6+ months", + growth: "12+ months", + }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function parseStrategyKind(value: string | null): StrategyKind | null { + if ( + value === "conservative" || + value === "balanced" || + value === "growth" + ) { + return value; + } + return null; +} + +export function getStrategy(kind: StrategyKind): StrategyCard { + const found = STRATEGIES.find((s) => s.kind === kind); + // STRATEGIES covers all StrategyKind values so this is always defined + return found!; +} + +const PREFERENCE_STORAGE_KEY = "nw_strategy_preference"; + +export function loadStoredPreference(): StrategyKind | null { + if (typeof window === "undefined") return null; + try { + return parseStrategyKind(localStorage.getItem(PREFERENCE_STORAGE_KEY)); + } catch { + return null; + } +} + +export function saveStoredPreference(kind: StrategyKind): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(PREFERENCE_STORAGE_KEY, kind); + } catch { + // storage may be unavailable + } +} diff --git a/src/lib/transaction-history.ts b/src/lib/transaction-history.ts new file mode 100644 index 0000000..3cc9722 --- /dev/null +++ b/src/lib/transaction-history.ts @@ -0,0 +1,327 @@ +export type HistoryKind = "deposit" | "withdrawal" | "rebalance"; +export type HistoryStatus = "success" | "pending" | "failed"; + +export interface TransactionHistoryItem { + id: string; + kind: HistoryKind; + title: string; + detail: string; + amount: number | null; + status: HistoryStatus; + occurredAt: string; + txHash: string | null; +} + +export interface TransactionHistoryFilter { + kind: HistoryKind | "all"; + status: HistoryStatus | "all"; + dateFrom: string; + dateTo: string; + page: number; + pageSize: number; +} + +export interface TransactionHistoryPage { + items: TransactionHistoryItem[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +// 64-char hex Stellar transaction hashes (mock) +const MOCK_TX_HASHES: Record = { + t01: "a1b2c3d4e5f67890123456789012345678901234567890abcdef1234567890ab", + t02: "b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678", + t03: "c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef1234", + t04: "d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890cd", + t05: "e5f6789012345678901234567890abcdef1234567890abcdef1234567890abcd", + t06: "f678901234567890abcdef1234567890abcdef1234567890abcdef1234567890", + t07: "0123456789abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + t08: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + t09: "234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12", + t10: "34567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef123", + t11: "4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", + t12: "567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345", + t13: "67890abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456", + t14: "7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567", + t15: "890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678", + t16: "90abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789", +}; + +export const MOCK_HISTORY_ITEMS: TransactionHistoryItem[] = [ + { + id: "hist-001", + kind: "deposit", + title: "Deposit confirmed", + detail: "USDC deposited from Freighter wallet and routed to Balanced strategy.", + amount: 8500, + status: "success", + occurredAt: "2026-03-24T07:42:00.000Z", + txHash: MOCK_TX_HASHES.t01, + }, + { + id: "hist-002", + kind: "rebalance", + title: "Auto-rebalance executed", + detail: "Capital shifted from reserve into Blend lending after rate improvement.", + amount: null, + status: "success", + occurredAt: "2026-03-24T05:16:00.000Z", + txHash: MOCK_TX_HASHES.t02, + }, + { + id: "hist-003", + kind: "withdrawal", + title: "Withdrawal confirmed", + detail: "Liquidity released and settled to destination wallet.", + amount: -1200, + status: "success", + occurredAt: "2026-03-23T18:22:00.000Z", + txHash: MOCK_TX_HASHES.t03, + }, + { + id: "hist-004", + kind: "deposit", + title: "Deposit pending", + detail: "Transaction submitted, awaiting Stellar network confirmation.", + amount: 3000, + status: "pending", + occurredAt: "2026-03-23T15:03:00.000Z", + txHash: null, + }, + { + id: "hist-005", + kind: "withdrawal", + title: "Withdrawal failed", + detail: "Treasury liquidity changed mid-flight. Please retry with updated amount.", + amount: -500, + status: "failed", + occurredAt: "2026-03-22T11:44:00.000Z", + txHash: null, + }, + { + id: "hist-006", + kind: "deposit", + title: "Deposit confirmed", + detail: "USDC deposited and allocated to Stellar DEX LP position.", + amount: 5000, + status: "success", + occurredAt: "2026-03-22T09:10:00.000Z", + txHash: MOCK_TX_HASHES.t04, + }, + { + id: "hist-007", + kind: "rebalance", + title: "Auto-rebalance executed", + detail: "Reserve topped up from DEX LP as APY spread narrowed.", + amount: null, + status: "success", + occurredAt: "2026-03-21T22:31:00.000Z", + txHash: MOCK_TX_HASHES.t05, + }, + { + id: "hist-008", + kind: "withdrawal", + title: "Withdrawal confirmed", + detail: "Scheduled withdrawal settled to Freighter destination wallet.", + amount: -2400, + status: "success", + occurredAt: "2026-03-21T14:05:00.000Z", + txHash: MOCK_TX_HASHES.t06, + }, + { + id: "hist-009", + kind: "deposit", + title: "Deposit confirmed", + detail: "Lump-sum deposit split across Blend lending and DEX liquidity.", + amount: 12000, + status: "success", + occurredAt: "2026-03-20T19:50:00.000Z", + txHash: MOCK_TX_HASHES.t07, + }, + { + id: "hist-010", + kind: "rebalance", + title: "Rebalance pending", + detail: "Triggered by strategy drift; awaiting on-chain settlement.", + amount: null, + status: "pending", + occurredAt: "2026-03-20T08:20:00.000Z", + txHash: null, + }, + { + id: "hist-011", + kind: "deposit", + title: "Deposit confirmed", + detail: "Small top-up deposited into protected reserve buffer.", + amount: 750, + status: "success", + occurredAt: "2026-03-19T16:11:00.000Z", + txHash: MOCK_TX_HASHES.t08, + }, + { + id: "hist-012", + kind: "withdrawal", + title: "Withdrawal confirmed", + detail: "Emergency liquidity withdrawn after manual request.", + amount: -4200, + status: "success", + occurredAt: "2026-03-19T09:33:00.000Z", + txHash: MOCK_TX_HASHES.t09, + }, + { + id: "hist-013", + kind: "deposit", + title: "Deposit failed", + detail: "Network fee estimate expired before submission. Refresh and retry.", + amount: 1500, + status: "failed", + occurredAt: "2026-03-18T21:07:00.000Z", + txHash: null, + }, + { + id: "hist-014", + kind: "rebalance", + title: "Auto-rebalance executed", + detail: "Monthly strategy review triggered reallocation to growth positions.", + amount: null, + status: "success", + occurredAt: "2026-03-18T06:00:00.000Z", + txHash: MOCK_TX_HASHES.t10, + }, + { + id: "hist-015", + kind: "withdrawal", + title: "Withdrawal confirmed", + detail: "Profit-taking withdrawal cleared same-day.", + amount: -8800, + status: "success", + occurredAt: "2026-03-17T13:55:00.000Z", + txHash: MOCK_TX_HASHES.t11, + }, + { + id: "hist-016", + kind: "deposit", + title: "Deposit confirmed", + detail: "DCA deposit routed into Blend USDC lending pool.", + amount: 2500, + status: "success", + occurredAt: "2026-03-17T07:30:00.000Z", + txHash: MOCK_TX_HASHES.t12, + }, + { + id: "hist-017", + kind: "rebalance", + title: "Auto-rebalance executed", + detail: "AQUA rewards reinvested into primary lending position.", + amount: null, + status: "success", + occurredAt: "2026-03-16T20:18:00.000Z", + txHash: MOCK_TX_HASHES.t13, + }, + { + id: "hist-018", + kind: "withdrawal", + title: "Withdrawal pending", + detail: "Queued for same-day settlement; liquidity being freed.", + amount: -650, + status: "pending", + occurredAt: "2026-03-16T12:40:00.000Z", + txHash: null, + }, + { + id: "hist-019", + kind: "deposit", + title: "Deposit confirmed", + detail: "Initial portfolio deposit routed to Balanced strategy.", + amount: 20000, + status: "success", + occurredAt: "2026-03-15T10:00:00.000Z", + txHash: MOCK_TX_HASHES.t14, + }, + { + id: "hist-020", + kind: "withdrawal", + title: "Withdrawal confirmed", + detail: "Standard withdrawal settled to Stellar destination wallet.", + amount: -3300, + status: "success", + occurredAt: "2026-03-14T15:22:00.000Z", + txHash: MOCK_TX_HASHES.t15, + }, + { + id: "hist-021", + kind: "rebalance", + title: "Auto-rebalance executed", + detail: "Quarterly rebalance to maintain Balanced strategy drift limits.", + amount: null, + status: "success", + occurredAt: "2026-03-13T04:00:00.000Z", + txHash: MOCK_TX_HASHES.t16, + }, + { + id: "hist-022", + kind: "deposit", + title: "Deposit failed", + detail: "Wallet signature timed out. Funds were not debited — safe to retry.", + amount: 200, + status: "failed", + occurredAt: "2026-03-12T18:55:00.000Z", + txHash: null, + }, +]; + +export function parseHistoryKind(value: string | null): HistoryKind | "all" { + if (value === "deposit" || value === "withdrawal" || value === "rebalance") { + return value; + } + return "all"; +} + +export function parseHistoryStatus(value: string | null): HistoryStatus | "all" { + if (value === "success" || value === "pending" || value === "failed") { + return value; + } + return "all"; +} + +export function filterAndPaginateHistory( + filter: TransactionHistoryFilter, +): TransactionHistoryPage { + let items = MOCK_HISTORY_ITEMS.slice(); + + if (filter.kind !== "all") { + items = items.filter((item) => item.kind === filter.kind); + } + + if (filter.status !== "all") { + items = items.filter((item) => item.status === filter.status); + } + + if (filter.dateFrom) { + const from = new Date(filter.dateFrom).getTime(); + items = items.filter((item) => new Date(item.occurredAt).getTime() >= from); + } + + if (filter.dateTo) { + // include the full dateTo day + const to = new Date(filter.dateTo); + to.setDate(to.getDate() + 1); + items = items.filter((item) => new Date(item.occurredAt).getTime() < to.getTime()); + } + + const total = items.length; + const totalPages = Math.max(1, Math.ceil(total / filter.pageSize)); + const clampedPage = Math.min(Math.max(1, filter.page), totalPages); + const start = (clampedPage - 1) * filter.pageSize; + const pageItems = items.slice(start, start + filter.pageSize); + + return { + items: pageItems, + total, + page: clampedPage, + pageSize: filter.pageSize, + totalPages, + }; +}