1274 lines
44 KiB
TypeScript
1274 lines
44 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type React from "react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { motion } from "framer-motion";
|
|
import { Wallet, BarChart3, AlertCircle, RefreshCcw, PlugZap } from "lucide-react";
|
|
import BrokerConnectDialog from "./BrokerConnectDialog";
|
|
import LoginRequiredDialog from "./LoginRequiredDialog";
|
|
import { getQueryFn, apiRequest } from "@/lib/queryClient";
|
|
import { getStrategyStatus, startStrategy, stopStrategy } from "@/api/strategy";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import StrategyTimeline from "@/components/StrategyTimeline";
|
|
import { toast } from "@/hooks/use-toast";
|
|
import {
|
|
ChartContainer,
|
|
ChartTooltip,
|
|
ChartTooltipContent,
|
|
} from "@/components/ui/chart";
|
|
import {
|
|
Area,
|
|
AreaChart,
|
|
CartesianGrid,
|
|
XAxis,
|
|
YAxis,
|
|
} from "recharts";
|
|
import type { User } from "@shared/schema";
|
|
|
|
type BrokerStatusResponse = {
|
|
connected: boolean;
|
|
broker?: string;
|
|
connected_at?: string;
|
|
userName?: string;
|
|
brokerUserId?: string;
|
|
};
|
|
|
|
type SystemArmResponse = {
|
|
armed_runs: Array<{ run_id: string; status: string; next_run?: string; already_running?: boolean }>;
|
|
failed_runs: Array<{ run_id: string; status: string; reason: string }>;
|
|
next_execution?: string | null;
|
|
broker_state?: {
|
|
connected?: boolean;
|
|
auth_state?: string | null;
|
|
broker?: string | null;
|
|
user_name?: string | null;
|
|
};
|
|
};
|
|
|
|
type SystemStatusRun = {
|
|
run_id: string;
|
|
status: string;
|
|
strategy?: string | null;
|
|
mode?: string | null;
|
|
broker?: string | null;
|
|
next_run?: string | null;
|
|
lifecycle?: string | null;
|
|
};
|
|
|
|
type SystemStatusResponse = {
|
|
runs: SystemStatusRun[];
|
|
broker_state?: {
|
|
connected?: boolean;
|
|
auth_state?: string | null;
|
|
broker?: string | null;
|
|
user_name?: string | null;
|
|
};
|
|
};
|
|
|
|
type HoldingsResponse = {
|
|
holdings: any[];
|
|
};
|
|
|
|
type MarketStatusResponse = {
|
|
status?: "OPEN" | "CLOSED";
|
|
checked_at?: string;
|
|
};
|
|
|
|
type FundsResponse = {
|
|
funds?: {
|
|
net?: number;
|
|
cash?: number;
|
|
withdrawable?: number;
|
|
utilized?: number;
|
|
balance?: number;
|
|
};
|
|
};
|
|
|
|
type EquityCurveResponse = {
|
|
startDate: string;
|
|
endDate: string;
|
|
accountOpenDate?: string;
|
|
points: { date: string; value: number }[];
|
|
};
|
|
|
|
type EngineStatus = {
|
|
state?: string;
|
|
run_id?: string | null;
|
|
last_heartbeat_ts?: string | null;
|
|
last_execution_ts?: string | null;
|
|
next_eligible_ts?: string | null;
|
|
};
|
|
|
|
type SessionUser = Pick<User, "id" | "username">;
|
|
|
|
const MotionButton = motion(Button);
|
|
|
|
function formatCurrency(amount: number, options?: { decimals?: number }) {
|
|
const decimals = options?.decimals ?? 2;
|
|
return new Intl.NumberFormat("en-IN", {
|
|
style: "currency",
|
|
currency: "INR",
|
|
minimumFractionDigits: decimals,
|
|
maximumFractionDigits: decimals,
|
|
}).format(amount || 0);
|
|
}
|
|
|
|
function formatDateInput(date: Date) {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function formatMinuteTimestamp(date: Date) {
|
|
return date.toLocaleString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function formatRelativeSeconds(seconds: number) {
|
|
if (!Number.isFinite(seconds)) return "";
|
|
if (seconds <= 0) return "now";
|
|
const total = Math.round(seconds);
|
|
const days = Math.floor(total / 86400);
|
|
const hours = Math.floor((total % 86400) / 3600);
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|
const parts = [];
|
|
if (days) parts.push(`${days}d`);
|
|
if (hours) parts.push(`${hours}h`);
|
|
if (!days && minutes) parts.push(`${minutes}m`);
|
|
if (parts.length === 0) parts.push("less than 1m");
|
|
return `in ${parts.join(" ")}`;
|
|
}
|
|
|
|
function usePrefersReducedMotion() {
|
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const update = () => setPrefersReducedMotion(mediaQuery.matches);
|
|
update();
|
|
|
|
if (mediaQuery.addEventListener) {
|
|
mediaQuery.addEventListener("change", update);
|
|
return () => mediaQuery.removeEventListener("change", update);
|
|
}
|
|
|
|
mediaQuery.addListener(update);
|
|
return () => mediaQuery.removeListener(update);
|
|
}, []);
|
|
|
|
return prefersReducedMotion;
|
|
}
|
|
|
|
export default function PortfolioSection() {
|
|
const sectionRef = useRef<HTMLElement | null>(null);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [scrollY, setScrollY] = useState(0);
|
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
const [loginPromptOpen, setLoginPromptOpen] = useState(false);
|
|
const [connectPromptOpen, setConnectPromptOpen] = useState(false);
|
|
const [brokerDialogOpen, setBrokerDialogOpen] = useState(false);
|
|
const [sessionExpired, setSessionExpired] = useState(false);
|
|
const [reconnectAttempted, setReconnectAttempted] = useState(false);
|
|
const [cachedHoldings, setCachedHoldings] = useState<any[]>([]);
|
|
const [cachedFunds, setCachedFunds] = useState<FundsResponse["funds"] | null>(null);
|
|
const [cachedEquityCurve, setCachedEquityCurve] = useState<EquityCurveResponse | null>(null);
|
|
const [armSummary, setArmSummary] = useState<SystemArmResponse | null>(null);
|
|
const {
|
|
data: brokerStatus,
|
|
isFetching: brokerStatusLoading,
|
|
refetch: refetchBrokerStatus,
|
|
} = useQuery<BrokerStatusResponse | null>({
|
|
queryKey: ["/broker/status"],
|
|
queryFn: getQueryFn<BrokerStatusResponse>({ on401: "returnNull" }),
|
|
staleTime: 0,
|
|
refetchOnMount: "always",
|
|
});
|
|
const { data: sessionUser } = useQuery<SessionUser | null>({
|
|
queryKey: ["/me"],
|
|
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
|
});
|
|
const systemStatusQuery = useQuery<SystemStatusResponse>({
|
|
queryKey: ["/system/status"],
|
|
queryFn: getQueryFn<SystemStatusResponse>({ on401: "throw" }),
|
|
refetchInterval: 15000,
|
|
});
|
|
const armMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const res = await fetch("/system/arm", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
if (res.status === 401) {
|
|
let payload: any = {};
|
|
try {
|
|
payload = await res.json();
|
|
} catch {}
|
|
const redirect =
|
|
payload?.detail?.redirect_url ||
|
|
payload?.redirect_url ||
|
|
"/broker/login";
|
|
window.location.assign(redirect);
|
|
return null;
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(text || res.statusText);
|
|
}
|
|
return (await res.json()) as SystemArmResponse;
|
|
},
|
|
onSuccess: (data) => {
|
|
if (!data) return;
|
|
setArmSummary(data);
|
|
toast({
|
|
title: "System armed",
|
|
description: data.next_execution
|
|
? `Next execution at ${new Date(data.next_execution).toLocaleString()}`
|
|
: "All strategies are armed.",
|
|
});
|
|
systemStatusQuery.refetch();
|
|
refetchBrokerStatus();
|
|
},
|
|
onError: (err: any) =>
|
|
toast({
|
|
title: "Arm failed",
|
|
description: err?.message || "Unable to arm system.",
|
|
}),
|
|
});
|
|
|
|
const holdingsQuery = useQuery<HoldingsResponse>({
|
|
queryKey: ["/zerodha/holdings"],
|
|
queryFn: async () => {
|
|
const res = await apiRequest("GET", "/zerodha/holdings");
|
|
return res.json();
|
|
},
|
|
enabled: !!brokerStatus?.connected,
|
|
retry: 1,
|
|
retryDelay: 600,
|
|
onSuccess: (data) => {
|
|
setCachedHoldings(data?.holdings || []);
|
|
setSessionExpired(false);
|
|
setReconnectAttempted(false);
|
|
},
|
|
onError: () => {
|
|
if (brokerStatus?.connected) {
|
|
setSessionExpired(true);
|
|
}
|
|
},
|
|
});
|
|
|
|
const fundsQuery = useQuery<FundsResponse>({
|
|
queryKey: ["/zerodha/funds"],
|
|
queryFn: async () => {
|
|
const res = await apiRequest("GET", "/zerodha/funds");
|
|
return res.json();
|
|
},
|
|
enabled: !!brokerStatus?.connected,
|
|
retry: 1,
|
|
retryDelay: 600,
|
|
onSuccess: (data) => {
|
|
setCachedFunds(data?.funds ?? null);
|
|
setSessionExpired(false);
|
|
setReconnectAttempted(false);
|
|
},
|
|
onError: () => {
|
|
if (brokerStatus?.connected) {
|
|
setSessionExpired(true);
|
|
}
|
|
},
|
|
});
|
|
|
|
const [startDate, setStartDate] = useState(() =>
|
|
formatDateInput(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)),
|
|
);
|
|
const [sipAmount, setSipAmount] = useState(5000);
|
|
const [frequencyDays, setFrequencyDays] = useState(30);
|
|
const [strategyStatus, setStrategyStatus] = useState("STOPPED");
|
|
const [isStarting, setIsStarting] = useState(false);
|
|
const [isStopping, setIsStopping] = useState(false);
|
|
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
|
|
const [marketStatus, setMarketStatus] = useState<MarketStatusResponse | null>(null);
|
|
|
|
const linkedAtDate = brokerStatus?.connected_at
|
|
? new Date(brokerStatus.connected_at)
|
|
: null;
|
|
const linkedAtInput =
|
|
linkedAtDate && !isNaN(linkedAtDate.getTime())
|
|
? formatDateInput(linkedAtDate)
|
|
: undefined;
|
|
|
|
useEffect(() => {
|
|
if (linkedAtInput) {
|
|
setStartDate((prev) => (prev === linkedAtInput ? prev : linkedAtInput));
|
|
}
|
|
}, [linkedAtInput]);
|
|
|
|
const refreshStatus = useCallback(async () => {
|
|
try {
|
|
const status = await getStrategyStatus();
|
|
setStrategyStatus(status?.status ?? "STOPPED");
|
|
} catch (error) {
|
|
setStrategyStatus("STOPPED");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refreshStatus();
|
|
const interval = window.setInterval(refreshStatus, 15000);
|
|
return () => window.clearInterval(interval);
|
|
}, [refreshStatus]);
|
|
|
|
useEffect(() => {
|
|
const fetchStatus = async () => {
|
|
try {
|
|
const res = await fetch("/engine/status");
|
|
const data = await res.json();
|
|
setEngineStatus(data);
|
|
} catch {
|
|
setEngineStatus(null);
|
|
}
|
|
};
|
|
|
|
fetchStatus();
|
|
const id = window.setInterval(fetchStatus, 5000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const fetchMarketStatus = async () => {
|
|
try {
|
|
const res = await fetch("/market/status");
|
|
const data = await res.json();
|
|
setMarketStatus(data);
|
|
} catch {
|
|
setMarketStatus(null);
|
|
}
|
|
};
|
|
|
|
fetchMarketStatus();
|
|
const id = window.setInterval(fetchMarketStatus, 5000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (prefersReducedMotion) {
|
|
setIsVisible(true);
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
setIsVisible(true);
|
|
}
|
|
},
|
|
{ threshold: 0.2 },
|
|
);
|
|
|
|
if (sectionRef.current) {
|
|
observer.observe(sectionRef.current);
|
|
}
|
|
|
|
return () => observer.disconnect();
|
|
}, [prefersReducedMotion]);
|
|
|
|
useEffect(() => {
|
|
if (prefersReducedMotion) {
|
|
setScrollY(0);
|
|
return;
|
|
}
|
|
|
|
let rafId: number | null = null;
|
|
const handleScroll = () => {
|
|
if (rafId !== null) return;
|
|
rafId = window.requestAnimationFrame(() => {
|
|
setScrollY(window.scrollY);
|
|
rafId = null;
|
|
});
|
|
};
|
|
|
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
handleScroll();
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", handleScroll);
|
|
if (rafId !== null) {
|
|
window.cancelAnimationFrame(rafId);
|
|
}
|
|
};
|
|
}, [prefersReducedMotion]);
|
|
|
|
const equityCurveQuery = useQuery<EquityCurveResponse>({
|
|
queryKey: ["/zerodha/equity-curve", startDate],
|
|
queryFn: async () => {
|
|
const res = await apiRequest(
|
|
"GET",
|
|
`/zerodha/equity-curve${startDate ? `?from=${startDate}` : ""}`,
|
|
);
|
|
return res.json();
|
|
},
|
|
enabled: !!brokerStatus?.connected,
|
|
retry: 1,
|
|
retryDelay: 600,
|
|
onSuccess: (data) => {
|
|
setCachedEquityCurve(data ?? null);
|
|
setSessionExpired(false);
|
|
setReconnectAttempted(false);
|
|
},
|
|
onError: () => {
|
|
if (brokerStatus?.connected) {
|
|
setSessionExpired(true);
|
|
}
|
|
},
|
|
});
|
|
|
|
const isConnected = !!brokerStatus?.connected;
|
|
const isAuthed = brokerStatus !== null;
|
|
const holdings = holdingsQuery.data ? holdingsQuery.data.holdings : cachedHoldings;
|
|
const fundsSnapshot = fundsQuery.data?.funds ?? cachedFunds;
|
|
const noHoldings = holdings.length === 0;
|
|
const systemRuns = systemStatusQuery.data?.runs ?? [];
|
|
const armedCount = systemRuns.filter(
|
|
(run) => (run.status || "").toUpperCase() === "RUNNING",
|
|
).length;
|
|
const nextExecution = useMemo(() => {
|
|
const nextDates = systemRuns
|
|
.map((run) => (run.next_run ? new Date(run.next_run) : null))
|
|
.filter((value): value is Date => !!value && !Number.isNaN(value.getTime()));
|
|
if (!nextDates.length) return null;
|
|
nextDates.sort((a, b) => a.getTime() - b.getTime());
|
|
return nextDates[0];
|
|
}, [systemRuns]);
|
|
useEffect(() => {
|
|
if (!isConnected) {
|
|
setSessionExpired(false);
|
|
setReconnectAttempted(false);
|
|
}
|
|
}, [isConnected]);
|
|
|
|
useEffect(() => {
|
|
if (!sessionExpired || reconnectAttempted || !isConnected) {
|
|
return;
|
|
}
|
|
setReconnectAttempted(true);
|
|
(async () => {
|
|
try {
|
|
await refetchBrokerStatus();
|
|
await Promise.all([
|
|
holdingsQuery.refetch(),
|
|
fundsQuery.refetch(),
|
|
equityCurveQuery.refetch(),
|
|
]);
|
|
} catch {
|
|
return;
|
|
}
|
|
})();
|
|
}, [
|
|
sessionExpired,
|
|
reconnectAttempted,
|
|
isConnected,
|
|
refetchBrokerStatus,
|
|
holdingsQuery,
|
|
fundsQuery,
|
|
equityCurveQuery,
|
|
]);
|
|
const availableFunds =
|
|
fundsSnapshot?.balance ??
|
|
fundsSnapshot?.net ??
|
|
fundsSnapshot?.withdrawable ??
|
|
fundsSnapshot?.cash ??
|
|
fundsSnapshot?.raw?.net ??
|
|
fundsSnapshot?.raw?.available?.live_balance ??
|
|
fundsSnapshot?.raw?.available?.opening_balance ??
|
|
0;
|
|
|
|
const { totalValue, totalPnl } = useMemo(() => {
|
|
return holdings.reduce(
|
|
(acc, item) => {
|
|
const qty = Number(item.quantity ?? item.qty ?? 0);
|
|
const last = Number(item.last_price ?? item.average_price ?? 0);
|
|
const pnl = Number(item.pnl ?? 0);
|
|
return {
|
|
totalValue: acc.totalValue + qty * last,
|
|
totalPnl: acc.totalPnl + pnl,
|
|
};
|
|
},
|
|
{ totalValue: 0, totalPnl: 0 },
|
|
);
|
|
}, [holdings]);
|
|
|
|
const equityCurve = equityCurveQuery.data ?? cachedEquityCurve;
|
|
const equityCurvePoints = equityCurve?.points ?? [];
|
|
const showSessionExpired = sessionExpired && isConnected;
|
|
|
|
const normalizedStrategyStatus =
|
|
strategyStatus === "RUNNING" ? "RUNNING" : "STOPPED";
|
|
const isStrategyRunning = normalizedStrategyStatus === "RUNNING";
|
|
|
|
const heartbeatAgeSec = engineStatus?.last_heartbeat_ts
|
|
? (Date.now() - new Date(engineStatus.last_heartbeat_ts).getTime()) / 1000
|
|
: Infinity;
|
|
|
|
let liveness: "ACTIVE" | "STALLED" | "DEAD" | "STOPPED";
|
|
if (!engineStatus) {
|
|
liveness = "DEAD";
|
|
} else if (engineStatus.state !== "RUNNING") {
|
|
liveness = "STOPPED";
|
|
} else if (heartbeatAgeSec < 10) {
|
|
liveness = "ACTIVE";
|
|
} else if (heartbeatAgeSec < 30) {
|
|
liveness = "STALLED";
|
|
} else {
|
|
liveness = "DEAD";
|
|
}
|
|
|
|
const livenessBadgeClass =
|
|
liveness === "ACTIVE"
|
|
? "border-emerald-500/50 bg-emerald-500/15 text-emerald-300"
|
|
: liveness === "STALLED"
|
|
? "border-amber-400/50 bg-amber-400/15 text-amber-200"
|
|
: liveness === "DEAD"
|
|
? "border-red-500/50 bg-red-500/15 text-red-300"
|
|
: "border-slate-400/40 bg-slate-400/15 text-slate-200";
|
|
|
|
const marketState = marketStatus?.status ?? "UNKNOWN";
|
|
const executionAllowed =
|
|
marketState === "OPEN" && (liveness === "ACTIVE" || liveness === "STOPPED");
|
|
|
|
const nextEligibleTs = engineStatus?.next_eligible_ts
|
|
? new Date(engineStatus.next_eligible_ts)
|
|
: null;
|
|
const nextEligibleValid = nextEligibleTs && !Number.isNaN(nextEligibleTs.getTime());
|
|
const eligibleSeconds = nextEligibleValid
|
|
? (nextEligibleTs.getTime() - Date.now()) / 1000
|
|
: Infinity;
|
|
const isEligible = eligibleSeconds <= 0;
|
|
const relativeEligible = formatRelativeSeconds(eligibleSeconds);
|
|
|
|
let nextEligibleLine = "—";
|
|
let eligibilityStatus: string | null = "First execution pending";
|
|
let eligibilityClass = "text-muted-foreground";
|
|
|
|
if (nextEligibleValid) {
|
|
eligibilityStatus = null;
|
|
if (isEligible && marketState === "OPEN") {
|
|
nextEligibleLine = "Now";
|
|
eligibilityStatus = "Eligible — execution imminent";
|
|
eligibilityClass = "text-emerald-400";
|
|
} else if (isEligible && marketState === "CLOSED") {
|
|
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (eligible)`;
|
|
eligibilityStatus = "Eligible — waiting for market open";
|
|
eligibilityClass = "text-amber-300";
|
|
} else {
|
|
nextEligibleLine = `${formatMinuteTimestamp(nextEligibleTs)} (${relativeEligible})`;
|
|
eligibilityStatus = "Not eligible yet";
|
|
eligibilityClass = "text-muted-foreground";
|
|
}
|
|
}
|
|
|
|
const requireLogin = useCallback(() => {
|
|
if (!sessionUser) {
|
|
setLoginPromptOpen(true);
|
|
return false;
|
|
}
|
|
return true;
|
|
}, [sessionUser]);
|
|
|
|
const handleReconnectClick = useCallback(() => {
|
|
if (!requireLogin()) {
|
|
return;
|
|
}
|
|
setBrokerDialogOpen(true);
|
|
}, [requireLogin]);
|
|
|
|
const handleStart = async () => {
|
|
if (!requireLogin()) {
|
|
return;
|
|
}
|
|
if (!isConnected) {
|
|
setConnectPromptOpen(true);
|
|
return;
|
|
}
|
|
setIsStarting(true);
|
|
try {
|
|
const result = await startStrategy({
|
|
strategy_name: "Golden Nifty",
|
|
initial_cash: availableFunds,
|
|
sip_amount: sipAmount,
|
|
sip_frequency: {
|
|
value: frequencyDays,
|
|
unit: "days",
|
|
},
|
|
mode: "PAPER",
|
|
});
|
|
if (result?.status === "already_running") {
|
|
toast({
|
|
title: "Strategy already running",
|
|
description: "The engine is already active.",
|
|
});
|
|
}
|
|
} finally {
|
|
setIsStarting(false);
|
|
await refreshStatus();
|
|
}
|
|
};
|
|
|
|
const handleStop = async () => {
|
|
setIsStopping(true);
|
|
try {
|
|
await stopStrategy();
|
|
} finally {
|
|
setIsStopping(false);
|
|
await refreshStatus();
|
|
}
|
|
};
|
|
|
|
const summaryCards = [
|
|
{
|
|
icon: <Wallet className="h-5 w-5" />,
|
|
label: "Available funds",
|
|
value: formatCurrency(isConnected ? availableFunds : 0, { decimals: 2 }),
|
|
muted: !isConnected,
|
|
subText:
|
|
isConnected && fundsQuery.data?.funds?.utilized !== undefined
|
|
? `Utilized: ${formatCurrency(fundsQuery.data?.funds?.utilized || 0, { decimals: 2 })}`
|
|
: undefined,
|
|
},
|
|
{
|
|
icon: <Wallet className="h-5 w-5" />,
|
|
label: "Portfolio value",
|
|
value: formatCurrency(totalValue, { decimals: 2 }),
|
|
muted: !isConnected,
|
|
},
|
|
{
|
|
icon: <BarChart3 className="h-5 w-5" />,
|
|
label: "Positions",
|
|
value: holdings.length.toString(),
|
|
muted: !isConnected,
|
|
},
|
|
{
|
|
icon: <AlertCircle className="h-5 w-5" />,
|
|
label: "Unrealized P&L",
|
|
value: formatCurrency(totalPnl, { decimals: 2 }),
|
|
muted: !isConnected,
|
|
},
|
|
];
|
|
|
|
const revealTransition = prefersReducedMotion ? "" : "transition-all duration-700";
|
|
const sectionRevealClass = prefersReducedMotion
|
|
? "opacity-100"
|
|
: isVisible
|
|
? "opacity-100 translate-y-0"
|
|
: "opacity-0 translate-y-6";
|
|
const cardRevealClass = prefersReducedMotion
|
|
? "opacity-100"
|
|
: isVisible
|
|
? "opacity-100 translate-y-0"
|
|
: "opacity-0 translate-y-4";
|
|
const hoverLift = prefersReducedMotion
|
|
? ""
|
|
: "transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-primary/10";
|
|
const parallaxOffset = prefersReducedMotion ? 0 : Math.min(scrollY * 0.3, 220);
|
|
|
|
const cardContainer = {
|
|
hidden: {},
|
|
show: {
|
|
transition: {
|
|
staggerChildren: 0.12,
|
|
},
|
|
},
|
|
};
|
|
|
|
const cardItem = {
|
|
hidden: { opacity: 0, y: 14 },
|
|
show: { opacity: 1, y: 0 },
|
|
};
|
|
|
|
const summaryAnimate = prefersReducedMotion || isVisible ? "show" : "hidden";
|
|
const ctaMotionProps = prefersReducedMotion
|
|
? {}
|
|
: {
|
|
whileHover: { scale: 1.02 },
|
|
whileTap: { scale: 0.97 },
|
|
transition: { type: "spring", stiffness: 400, damping: 25 },
|
|
};
|
|
|
|
return (
|
|
<section
|
|
ref={sectionRef}
|
|
id="portfolio"
|
|
className={`relative overflow-hidden py-32 px-6 pb-32 bg-gradient-to-b from-background to-background/80 ${revealTransition} ${sectionRevealClass}`}
|
|
>
|
|
<LoginRequiredDialog
|
|
open={loginPromptOpen}
|
|
onOpenChange={setLoginPromptOpen}
|
|
context="start the strategy"
|
|
/>
|
|
<LoginRequiredDialog
|
|
open={connectPromptOpen}
|
|
onOpenChange={setConnectPromptOpen}
|
|
title="Connect broker"
|
|
message="Connect your broker to start the strategy."
|
|
/>
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<div
|
|
className="absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-primary/10 blur-3xl"
|
|
style={{ transform: `translate3d(0, ${parallaxOffset}px, 0)` }}
|
|
/>
|
|
<div
|
|
className="absolute top-6 right-[8%] h-80 w-80 rounded-full bg-chart-2/10 blur-3xl"
|
|
style={{ transform: `translate3d(0, ${parallaxOffset}px, 0)` }}
|
|
/>
|
|
<div
|
|
className="absolute top-32 left-8 h-48 w-48 rounded-full bg-chart-3/10 blur-3xl"
|
|
style={{ transform: `translate3d(0, ${parallaxOffset}px, 0)` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="relative z-10 max-w-6xl mx-auto space-y-10">
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<p className="text-sm uppercase tracking-[0.25em] text-muted-foreground">Portfolio</p>
|
|
<h3 className="text-3xl md:text-4xl font-bold">Your holdings & live positions</h3>
|
|
<p className="text-muted-foreground max-w-2xl">
|
|
Connect your broker to sync holdings. When disconnected, values stay at zero and you will see a prompt to connect.
|
|
</p>
|
|
{isConnected && (brokerStatus?.userName || brokerStatus?.broker) ? (
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/50 bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
|
|
<Wallet className="h-4 w-4" />
|
|
{brokerStatus?.userName ? (
|
|
<>
|
|
Connected as <span className="font-semibold">{brokerStatus.userName}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
Connected to <span className="font-semibold">{brokerStatus?.broker}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<BrokerConnectDialog open={brokerDialogOpen} onOpenChange={setBrokerDialogOpen} />
|
|
<Button variant="secondary" asChild>
|
|
<a href="/portfolio/paper" target="_blank" rel="noreferrer">
|
|
Paper Trading Portfolio
|
|
</a>
|
|
</Button>
|
|
{isConnected ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
holdingsQuery.refetch();
|
|
fundsQuery.refetch();
|
|
equityCurveQuery.refetch();
|
|
refetchBrokerStatus();
|
|
}}
|
|
disabled={holdingsQuery.isFetching || equityCurveQuery.isFetching}
|
|
>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
{holdingsQuery.isFetching || equityCurveQuery.isFetching
|
|
? "Refreshing..."
|
|
: "Refresh data"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="grid gap-4 md:grid-cols-3"
|
|
variants={cardContainer}
|
|
initial="hidden"
|
|
animate={summaryAnimate}
|
|
>
|
|
{summaryCards.map((card) => (
|
|
<motion.div
|
|
key={card.label}
|
|
variants={cardItem}
|
|
whileHover={{
|
|
y: -6,
|
|
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
|
|
}}
|
|
transition={{ type: "spring", stiffness: 300, damping: 24 }}
|
|
>
|
|
<SummaryCard {...card} prefersReducedMotion={prefersReducedMotion} />
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
|
|
<div
|
|
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
|
style={prefersReducedMotion ? undefined : { transitionDelay: "500ms" }}
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold">Holdings</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Current positions pulled from your connected broker.
|
|
</p>
|
|
</div>
|
|
<Badge variant={isConnected ? "secondary" : "outline"}>
|
|
{isConnected ? "Broker connected" : "Not connected"}
|
|
</Badge>
|
|
</div>
|
|
|
|
{!isAuthed ? (
|
|
<ZeroState message="Log in and connect your broker to see your portfolio." />
|
|
) : brokerStatusLoading ? (
|
|
<ZeroState message="Checking broker status..." />
|
|
) : !isConnected ? (
|
|
<ZeroState message="Connect to broker to see your portfolio." />
|
|
) : holdingsQuery.isLoading ? (
|
|
<ZeroState message="Fetching holdings..." />
|
|
) : holdingsQuery.isError && holdings.length === 0 ? (
|
|
<ZeroState
|
|
message={
|
|
showSessionExpired
|
|
? "Session expired. Reconnect to refresh holdings and funds."
|
|
: "Could not fetch holdings. Try refreshing."
|
|
}
|
|
/>
|
|
) : noHoldings ? (
|
|
<ZeroState message="No holdings yet. Refresh after connecting." />
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
{showSessionExpired ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 px-6 py-3 text-xs text-amber-200 bg-amber-500/10 border-b border-amber-400/20">
|
|
<span>Session expired. Showing the last known holdings. Reconnect to refresh.</span>
|
|
<Button size="sm" variant="secondary" onClick={handleReconnectClick}>
|
|
Reconnect broker
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<table className="min-w-full text-sm">
|
|
<thead className="bg-muted/40 text-muted-foreground">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left font-medium">Symbol</th>
|
|
<th className="px-6 py-3 text-left font-medium">Qty</th>
|
|
<th className="px-6 py-3 text-left font-medium">Avg price</th>
|
|
<th className="px-6 py-3 text-left font-medium">LTP</th>
|
|
<th className="px-6 py-3 text-left font-medium">P&L</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/60">
|
|
{holdings.map((item, idx) => {
|
|
const qty = Number(item.quantity ?? item.qty ?? 0);
|
|
const avg = Number(item.average_price ?? item.avg_price ?? 0);
|
|
const ltp = Number(item.last_price ?? 0);
|
|
const pnl = Number(item.pnl ?? 0);
|
|
return (
|
|
<tr key={`${item.tradingsymbol || item.instrument_token || idx}`}>
|
|
<td className="px-6 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold">
|
|
{item.tradingsymbol || item.symbol || "Instrument"}
|
|
</span>
|
|
<Badge variant="outline">{item.exchange || item.exchange_type || "N/A"}</Badge>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-3">{qty}</td>
|
|
<td className="px-6 py-3">{formatCurrency(avg, { decimals: 2 })}</td>
|
|
<td className="px-6 py-3">{ltp ? formatCurrency(ltp, { decimals: 2 }) : "-"}</td>
|
|
<td className="px-6 py-3">
|
|
<span className={pnl >= 0 ? "text-emerald-500" : "text-red-500"}>
|
|
{formatCurrency(pnl, { decimals: 2 })}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
|
style={prefersReducedMotion ? undefined : { transitionDelay: "560ms" }}
|
|
>
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 px-6 py-4 border-b border-border/50">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold">System arm</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Re-arm all active strategies after broker login.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
className="shimmer"
|
|
onClick={() => armMutation.mutate()}
|
|
disabled={armMutation.isPending || !isConnected}
|
|
>
|
|
<PlugZap className="h-4 w-4" />
|
|
{armMutation.isPending ? "Arming..." : "Arm All Strategies"}
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-5 px-6 py-4 text-sm">
|
|
<div className="space-y-1">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Broker</p>
|
|
<p className="font-semibold">
|
|
{isConnected ? "Connected" : "Not connected"}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Armed</p>
|
|
<p className="font-semibold">{armedCount}</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Market</p>
|
|
<p className="font-semibold">{marketState}</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Next execution</p>
|
|
<p className="font-semibold">
|
|
{nextExecution ? formatMinuteTimestamp(nextExecution) : "Unknown"}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Engine</p>
|
|
<p className="font-semibold">{liveness}</p>
|
|
</div>
|
|
</div>
|
|
{armSummary ? (
|
|
<div className="px-6 pb-4 text-xs text-muted-foreground">
|
|
System armed. {armSummary.failed_runs?.length ? "Some runs failed to arm." : "All runs armed."}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div
|
|
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
|
style={prefersReducedMotion ? undefined : { transitionDelay: "580ms" }}
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold">Strategy status</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Live status for every configured strategy run.
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline">{systemRuns.length} total</Badge>
|
|
</div>
|
|
{systemStatusQuery.isLoading ? (
|
|
<ZeroState message="Loading strategy status..." />
|
|
) : systemRuns.length === 0 ? (
|
|
<ZeroState message="No strategies configured yet." />
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-sm">
|
|
<thead className="bg-muted/40 text-muted-foreground">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left font-medium">Strategy</th>
|
|
<th className="px-6 py-3 text-left font-medium">Mode</th>
|
|
<th className="px-6 py-3 text-left font-medium">Status</th>
|
|
<th className="px-6 py-3 text-left font-medium">Next run</th>
|
|
<th className="px-6 py-3 text-left font-medium">Broker</th>
|
|
<th className="px-6 py-3 text-left font-medium">Lifecycle</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/60">
|
|
{systemRuns.map((run) => (
|
|
<tr key={run.run_id}>
|
|
<td className="px-6 py-3">
|
|
{run.strategy || "Strategy"}
|
|
</td>
|
|
<td className="px-6 py-3">{run.mode || "-"}</td>
|
|
<td className="px-6 py-3">{run.status}</td>
|
|
<td className="px-6 py-3">
|
|
{run.next_run ? new Date(run.next_run).toLocaleString() : "-"}
|
|
</td>
|
|
<td className="px-6 py-3">{run.broker || "-"}</td>
|
|
<td className="px-6 py-3">{run.lifecycle || run.status}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl overflow-hidden ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
|
style={prefersReducedMotion ? undefined : { transitionDelay: "600ms" }}
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold">Strategy control</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Start or stop the Golden Nifty SIP engine from the dashboard.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className={livenessBadgeClass}>
|
|
{liveness}
|
|
</Badge>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
normalizedStrategyStatus === "RUNNING"
|
|
? "border-emerald-500/50 bg-emerald-500/10 text-emerald-400"
|
|
: "border-red-500/40 bg-red-500/10 text-red-400"
|
|
}
|
|
>
|
|
{normalizedStrategyStatus}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div className="rounded-lg border border-border/60 bg-background/40 px-4 py-3">
|
|
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
|
Next eligible SIP
|
|
</div>
|
|
<div className="text-sm font-semibold text-foreground">{nextEligibleLine}</div>
|
|
{eligibilityStatus ? (
|
|
<div className={`text-xs ${eligibilityClass}`}>{eligibilityStatus}</div>
|
|
) : null}
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="strategy-sip">SIP Amount</Label>
|
|
<Input
|
|
id="strategy-sip"
|
|
type="number"
|
|
min={0}
|
|
step={100}
|
|
value={sipAmount}
|
|
onChange={(event) => {
|
|
const value = Number(event.target.value);
|
|
setSipAmount(Number.isNaN(value) ? 0 : value);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="strategy-frequency">Frequency (days)</Label>
|
|
<Input
|
|
id="strategy-frequency"
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
value={frequencyDays}
|
|
onChange={(event) => {
|
|
const value = Number(event.target.value);
|
|
setFrequencyDays(Number.isNaN(value) ? 1 : value);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<MotionButton
|
|
{...ctaMotionProps}
|
|
onClick={handleStart}
|
|
disabled={isStarting || !executionAllowed || isStrategyRunning}
|
|
className="shimmer"
|
|
>
|
|
{isStarting ? "Starting..." : "Start Strategy"}
|
|
</MotionButton>
|
|
<Button variant="outline" onClick={handleStop} disabled={isStopping}>
|
|
{isStopping ? "Stopping..." : "Stop Strategy"}
|
|
</Button>
|
|
</div>
|
|
{marketState === "CLOSED" ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Market closed — execution will resume at next session
|
|
</p>
|
|
) : null}
|
|
{isStrategyRunning ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Strategy running — next SIP will execute when eligible
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<StrategyTimeline />
|
|
|
|
<div
|
|
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl p-6 space-y-4 ${hoverLift} ${revealTransition} ${cardRevealClass}`}
|
|
style={prefersReducedMotion ? undefined : { transitionDelay: "700ms" }}
|
|
>
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold">Equity curve</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Track your account value over time from your demat open date.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label htmlFor="equity-start" className="text-xs text-muted-foreground">
|
|
From
|
|
</Label>
|
|
<Input
|
|
id="equity-start"
|
|
type="date"
|
|
value={startDate}
|
|
max={formatDateInput(new Date())}
|
|
min={linkedAtInput}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
className="w-[180px]"
|
|
disabled={!isConnected}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{!isConnected ? (
|
|
<ZeroState message="Connect to broker to see your equity curve." />
|
|
) : equityCurveQuery.isLoading && equityCurvePoints.length === 0 ? (
|
|
<ZeroState message="Loading equity curve..." />
|
|
) : equityCurveQuery.isError && equityCurvePoints.length === 0 ? (
|
|
<ZeroState message="Could not load equity curve." />
|
|
) : equityCurvePoints.length === 0 ? (
|
|
<ZeroState message="Could not load equity curve." />
|
|
) : (
|
|
<div className="h-80">
|
|
<ChartContainer
|
|
config={{
|
|
equity: { label: "Equity", color: "hsl(var(--chart-1))" },
|
|
}}
|
|
className="rounded-xl bg-background/60"
|
|
>
|
|
<AreaChart data={equityCurvePoints}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={(value) => new Date(value).toLocaleDateString("en-IN", { month: "short", day: "numeric" })}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
minTickGap={24}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={(v) =>
|
|
new Intl.NumberFormat("en-IN", { maximumFractionDigits: 0, notation: "compact" }).format(v as number)
|
|
}
|
|
width={80}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
labelFormatter={(value) =>
|
|
new Date(value as string).toLocaleDateString("en-IN", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
})
|
|
}
|
|
formatter={(val) => formatCurrency(Number(val))}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="value"
|
|
stroke="var(--color-equity)"
|
|
fill="var(--color-equity)"
|
|
fillOpacity={0.15}
|
|
strokeWidth={2}
|
|
/>
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Account open date: {equityCurve?.accountOpenDate
|
|
? new Date(equityCurve.accountOpenDate).toLocaleDateString("en-IN")
|
|
: "unknown"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
icon,
|
|
label,
|
|
value,
|
|
muted,
|
|
subText,
|
|
prefersReducedMotion,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: string;
|
|
muted?: boolean;
|
|
subText?: string;
|
|
prefersReducedMotion: boolean;
|
|
}) {
|
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
const [transform, setTransform] = useState({ rotateX: 0, rotateY: 0, scale: 1 });
|
|
const hoverClass = prefersReducedMotion
|
|
? ""
|
|
: "transition-all duration-300";
|
|
|
|
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (prefersReducedMotion || !cardRef.current) return;
|
|
const rect = cardRef.current.getBoundingClientRect();
|
|
const x = (event.clientX - rect.left) / rect.width - 0.5;
|
|
const y = (event.clientY - rect.top) / rect.height - 0.5;
|
|
setTransform({
|
|
rotateX: y * -8,
|
|
rotateY: x * 8,
|
|
scale: 1.02,
|
|
});
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (prefersReducedMotion) return;
|
|
setTransform({ rotateX: 0, rotateY: 0, scale: 1 });
|
|
};
|
|
|
|
const tiltStyle = prefersReducedMotion
|
|
? undefined
|
|
: {
|
|
transform: `perspective(900px) rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg) scale(${transform.scale})`,
|
|
transition: "transform 0.12s ease-out",
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className={`rounded-xl border border-border/70 bg-card/80 p-4 shadow-sm ${hoverClass}`}
|
|
style={prefersReducedMotion ? undefined : { perspective: "900px" }}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<div className="will-change-transform" style={tiltStyle}>
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-full bg-primary/10 p-2 text-primary">{icon}</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
|
|
<p className={`text-xl font-semibold ${muted ? "text-muted-foreground" : "text-foreground"}`}>
|
|
<motion.span
|
|
key={value}
|
|
initial={{ opacity: 0.6, y: 3 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
|
>
|
|
{value}
|
|
</motion.span>
|
|
</p>
|
|
{subText ? <p className="text-xs text-muted-foreground">{subText}</p> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ZeroState({ message }: { message: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
|
<div className="rounded-full bg-muted/70 p-3 text-muted-foreground">
|
|
<Wallet className="h-6 w-6" />
|
|
</div>
|
|
<p className="text-sm font-medium">{message}</p>
|
|
<p className="text-xs text-muted-foreground max-w-md">
|
|
Once connected, we will pull your latest holdings and live positions securely from your broker.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|