SUP_GoldBees_Frontend/src/components/landing/BrokerConnectDialog.tsx
2026-03-27 22:30:51 +05:30

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 &ldquo;Fetch holdings&rdquo; 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"
/>
</>
);
}