438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { PlugZap, ArrowUpRight, ShieldCheck, RefreshCcw } from "lucide-react";
|
|
import { motion } from "framer-motion";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { toast } from "@/hooks/use-toast";
|
|
import { apiRequest, getQueryFn } from "@/lib/queryClient";
|
|
import type { User } from "@shared/schema";
|
|
import LoginRequiredDialog from "./LoginRequiredDialog";
|
|
|
|
type BrokerConnectDialogProps = {
|
|
layout?: "desktop" | "mobile";
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
};
|
|
|
|
type SessionUser = Pick<User, "id" | "username">;
|
|
|
|
type BrokerStatusResponse = {
|
|
connected: boolean;
|
|
broker?: string;
|
|
connected_at?: string;
|
|
userName?: string;
|
|
brokerUserId?: string;
|
|
authState?: string;
|
|
};
|
|
|
|
const CALLBACK_STORAGE_KEY = "zerodha:callback";
|
|
const CALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
|
|
export default function BrokerConnectDialog({
|
|
layout = "desktop",
|
|
open,
|
|
onOpenChange,
|
|
}: BrokerConnectDialogProps) {
|
|
const [connectOpenInternal, setConnectOpenInternal] = useState(false);
|
|
const isControlled = open !== undefined;
|
|
const connectOpen = isControlled ? open : connectOpenInternal;
|
|
const setConnectOpen = (nextOpen: boolean) => {
|
|
if (!isControlled) {
|
|
setConnectOpenInternal(nextOpen);
|
|
}
|
|
onOpenChange?.(nextOpen);
|
|
};
|
|
const [loginPromptOpen, setLoginPromptOpen] = useState(false);
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [apiSecret, setApiSecret] = useState("");
|
|
const [holdings, setHoldings] = useState<any[]>([]);
|
|
|
|
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
|
|
queryKey: ["/me"],
|
|
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
|
|
staleTime: 0,
|
|
refetchOnMount: "always",
|
|
});
|
|
|
|
const { data: brokerStatus, refetch: refetchStatus } = useQuery<BrokerStatusResponse | null>({
|
|
queryKey: ["/broker/status"],
|
|
queryFn: getQueryFn<BrokerStatusResponse>({ on401: "returnNull" }),
|
|
staleTime: 0,
|
|
refetchOnMount: "always",
|
|
});
|
|
|
|
const loginUrlMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (!apiKey.trim()) {
|
|
throw new Error("API key is required");
|
|
}
|
|
if (!apiSecret.trim()) {
|
|
throw new Error("API secret is required");
|
|
}
|
|
const redirectUrl = `${window.location.origin}/login`;
|
|
const res = await apiRequest("POST", "/broker/zerodha/login", {
|
|
apiKey,
|
|
apiSecret,
|
|
redirectUrl,
|
|
});
|
|
return res.json() as Promise<{ loginUrl: string }>;
|
|
},
|
|
onSuccess: ({ loginUrl }) => {
|
|
window.open(loginUrl, "_blank", "noopener,noreferrer");
|
|
toast({
|
|
title: "Continue in Zerodha",
|
|
description: "Log in and return here. We will connect your broker automatically.",
|
|
});
|
|
},
|
|
onError: (err: any) =>
|
|
toast({ title: "Could not start Zerodha login", description: err?.message || "Try again." }),
|
|
});
|
|
|
|
const reconnectSavedMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const res = await apiRequest("GET", "/broker/login-url");
|
|
return res.json() as Promise<{ loginUrl: string }>;
|
|
},
|
|
onSuccess: ({ loginUrl }) => {
|
|
window.open(loginUrl, "_blank", "noopener,noreferrer");
|
|
toast({
|
|
title: "Continue in Zerodha",
|
|
description: "Log in and return here. We will reconnect your broker automatically.",
|
|
});
|
|
},
|
|
onError: (err: any) => {
|
|
const message = String(err?.message || "");
|
|
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
|
|
toast({
|
|
title: "Enter Zerodha API credentials",
|
|
description: "Saved credentials are missing. Enter the API key and secret once to reconnect.",
|
|
});
|
|
return;
|
|
}
|
|
toast({
|
|
title: "Could not reconnect Zerodha",
|
|
description: err?.message || "Try again.",
|
|
});
|
|
},
|
|
});
|
|
|
|
const holdingsMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const res = await apiRequest("GET", "/zerodha/holdings");
|
|
return res.json() as Promise<{ holdings: any[] }>;
|
|
},
|
|
onSuccess: (data) => {
|
|
setHoldings(data?.holdings || []);
|
|
toast({ title: "Holdings fetched", description: "Latest positions pulled from Zerodha." });
|
|
},
|
|
onError: (err: any) =>
|
|
toast({
|
|
title: "Could not fetch holdings",
|
|
description: err?.message || "Check your Zerodha session and try again.",
|
|
}),
|
|
});
|
|
|
|
const disconnectMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const res = await apiRequest("POST", "/broker/disconnect");
|
|
return res.json() as Promise<{ connected: boolean }>;
|
|
},
|
|
onSuccess: () => {
|
|
refetchStatus();
|
|
toast({ title: "Broker disconnected", description: "Your broker has been unlinked." });
|
|
},
|
|
onError: (err: any) =>
|
|
toast({
|
|
title: "Disconnect failed",
|
|
description: err?.message || "Try again.",
|
|
}),
|
|
});
|
|
|
|
const connected = !!brokerStatus?.connected;
|
|
const canReconnectWithSavedZerodha =
|
|
connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA";
|
|
const connectedAt = brokerStatus?.connected_at
|
|
? new Date(brokerStatus.connected_at)
|
|
: null;
|
|
const triggerClassName = useMemo(() => {
|
|
const layoutClasses =
|
|
layout === "mobile" ? "w-full justify-center shimmer" : "px-4 rounded-xl shimmer";
|
|
return connected
|
|
? `${layoutClasses} disabled:opacity-100 disabled:cursor-default`
|
|
: layoutClasses;
|
|
}, [connected, layout]);
|
|
|
|
const handleConnectClick = async () => {
|
|
if (connected) {
|
|
return;
|
|
}
|
|
const latest = await refetchSessionUser();
|
|
if (!latest.data) {
|
|
setLoginPromptOpen(true);
|
|
return;
|
|
}
|
|
setConnectOpen(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const consumeCallback = (rawValue?: string) => {
|
|
const value = rawValue || localStorage.getItem(CALLBACK_STORAGE_KEY);
|
|
if (!value) return;
|
|
localStorage.removeItem(CALLBACK_STORAGE_KEY);
|
|
|
|
try {
|
|
const payload = JSON.parse(value) as {
|
|
status?: "success" | "error";
|
|
message?: string;
|
|
ts?: number;
|
|
};
|
|
if (payload.ts && Date.now() - payload.ts > CALLBACK_MAX_AGE_MS) {
|
|
return;
|
|
}
|
|
if (payload.status === "success") {
|
|
refetchStatus();
|
|
toast({
|
|
title: "Zerodha connected",
|
|
description: "Your broker connection has been completed.",
|
|
});
|
|
setConnectOpen(false);
|
|
} else if (payload.status === "error") {
|
|
toast({
|
|
title: "Zerodha login failed",
|
|
description: payload.message || "Please retry the login.",
|
|
});
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
};
|
|
|
|
consumeCallback();
|
|
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.key !== CALLBACK_STORAGE_KEY || !event.newValue) {
|
|
return;
|
|
}
|
|
consumeCallback(event.newValue);
|
|
};
|
|
|
|
window.addEventListener("storage", handleStorage);
|
|
return () => window.removeEventListener("storage", handleStorage);
|
|
}, [refetchStatus]);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={connectOpen} onOpenChange={setConnectOpen}>
|
|
<Button
|
|
variant={connected ? "secondary" : "secondary"}
|
|
className={triggerClassName}
|
|
disabled={connected}
|
|
onClick={handleConnectClick}
|
|
>
|
|
<PlugZap className="h-4 w-4" />
|
|
{connected ? "Broker connected" : "Connect broker"}
|
|
</Button>
|
|
<DialogContent className="sm:max-w-2xl border-border/70 bg-gradient-to-br from-background via-background to-muted/30">
|
|
<motion.div
|
|
className="space-y-4"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
|
>
|
|
<DialogHeader className="space-y-2">
|
|
<DialogTitle>Connect your broker</DialogTitle>
|
|
<DialogDescription>
|
|
Link your brokerage to pull positions and keep your dashboard in sync.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 rounded-xl border border-border/70 bg-card/70 p-4">
|
|
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-gradient-to-r from-primary/10 via-background to-background px-3 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-full bg-primary/15 p-2 text-primary">
|
|
<PlugZap className="h-4 w-4" />
|
|
</div>
|
|
<div className="text-sm leading-tight">
|
|
<p className="font-medium">Secure brokerage linking</p>
|
|
<p className="text-muted-foreground">
|
|
Start in Zerodha and we will complete the connection automatically.
|
|
</p>
|
|
</div>
|
|
<Badge variant={connected ? "secondary" : "outline"} className="ml-auto">
|
|
{connected ? "Connected" : "Not connected"}
|
|
</Badge>
|
|
</div>
|
|
{connected && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<ShieldCheck className="h-4 w-4 text-primary" />
|
|
<span>
|
|
{brokerStatus?.userName
|
|
? `Linked as ${brokerStatus.userName}`
|
|
: `Linked to ${brokerStatus?.broker || "broker"}`}{" "}
|
|
-{" "}
|
|
{connectedAt
|
|
? connectedAt.toLocaleString()
|
|
: "just now"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<motion.div
|
|
className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4 shadow-sm"
|
|
whileHover={{
|
|
y: -6,
|
|
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
|
|
}}
|
|
transition={{ type: "spring", stiffness: 300, damping: 24 }}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-semibold">Zerodha</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Provide your Kite API key & secret to launch the login and connect your account.
|
|
</p>
|
|
</div>
|
|
<Badge variant="secondary">Live</Badge>
|
|
</div>
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="zerodha-api-key">API key</Label>
|
|
<Input
|
|
id="zerodha-api-key"
|
|
placeholder="Enter Zerodha API key"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="zerodha-api-secret">API secret</Label>
|
|
<Input
|
|
id="zerodha-api-secret"
|
|
placeholder="Enter Zerodha API secret"
|
|
type="password"
|
|
value={apiSecret}
|
|
onChange={(e) => setApiSecret(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => loginUrlMutation.mutate()}
|
|
disabled={loginUrlMutation.isPending}
|
|
>
|
|
<ArrowUpRight className="h-4 w-4" />
|
|
{loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
|
|
</Button>
|
|
{canReconnectWithSavedZerodha ? (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => reconnectSavedMutation.mutate()}
|
|
disabled={reconnectSavedMutation.isPending}
|
|
>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
{reconnectSavedMutation.isPending ? "Opening Zerodha..." : "Reconnect saved Zerodha"}
|
|
</Button>
|
|
) : null}
|
|
{connected ? (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => holdingsMutation.mutate()}
|
|
disabled={holdingsMutation.isPending}
|
|
>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
{holdingsMutation.isPending ? "Fetching..." : "Fetch holdings"}
|
|
</Button>
|
|
) : null}
|
|
{connected ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => disconnectMutation.mutate()}
|
|
disabled={disconnectMutation.isPending}
|
|
>
|
|
{disconnectMutation.isPending ? "Disconnecting..." : "Disconnect broker"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
After you log in with your Zerodha account, we will connect automatically. Keep this tab open
|
|
until the login completes.
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key
|
|
and secret without re-entering them.
|
|
</p>
|
|
</motion.div>
|
|
|
|
<div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground">
|
|
<p className="font-medium text-foreground">Other brokers</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="outline">Groww (coming soon)</Badge>
|
|
<Badge variant="outline">Angel One (coming soon)</Badge>
|
|
<Badge variant="outline">ICICI Direct (coming soon)</Badge>
|
|
<Badge variant="outline">HDFC Securities (coming soon)</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{connected && (
|
|
<div className="space-y-2 rounded-lg border border-border/60 bg-background/80 p-4">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="h-4 w-4 text-primary" />
|
|
<p className="text-sm font-semibold">Latest holdings</p>
|
|
</div>
|
|
{holdings.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
No holdings pulled yet. Click “Fetch holdings” after connecting.
|
|
</p>
|
|
) : (
|
|
<div className="grid gap-2">
|
|
{holdings.map((item, idx) => (
|
|
<div
|
|
key={`${item.tradingsymbol || item.instrument_token || idx}`}
|
|
className="flex items-center justify-between rounded-md border border-border/50 bg-card/40 px-3 py-2 text-sm"
|
|
>
|
|
<div>
|
|
<p className="font-medium">
|
|
{item.tradingsymbol || item.symbol || "Instrument"}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Qty: {item.quantity ?? item.qty ?? "-"} | Avg:{" "}
|
|
{item.average_price ?? item.avg_price ?? "-"}
|
|
</p>
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{item.exchange || item.exchange_type || "N/A"}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<LoginRequiredDialog
|
|
open={loginPromptOpen}
|
|
onOpenChange={setLoginPromptOpen}
|
|
context="connect your broker"
|
|
/>
|
|
</>
|
|
);
|
|
}
|