Add Groww broker support to portfolio UI

This commit is contained in:
Thigazhezhilan J 2026-04-05 19:42:08 +05:30
parent 71a99b7b0a
commit 56f6e8551e
4 changed files with 437 additions and 227 deletions

View File

@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { PlugZap, ArrowUpRight, ShieldCheck, RefreshCcw } from "lucide-react"; import { ArrowUpRight, PlugZap, RefreshCcw, ShieldCheck } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -10,7 +11,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
@ -36,6 +36,11 @@ type BrokerStatusResponse = {
authState?: string; authState?: string;
}; };
type HoldingsResponse = {
broker?: string;
holdings?: any[];
};
const CALLBACK_STORAGE_KEY = "zerodha:callback"; const CALLBACK_STORAGE_KEY = "zerodha:callback";
const CALLBACK_MAX_AGE_MS = 5 * 60 * 1000; const CALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
@ -45,6 +50,13 @@ function buildReconnectRedirectUrl() {
return url.toString(); return url.toString();
} }
function formatBrokerName(broker?: string | null) {
const normalized = (broker || "").trim().toUpperCase();
if (normalized === "GROWW") return "Groww";
if (normalized === "ZERODHA") return "Zerodha";
return normalized || "broker";
}
export default function BrokerConnectDialog({ export default function BrokerConnectDialog({
layout = "desktop", layout = "desktop",
open, open,
@ -61,8 +73,10 @@ export default function BrokerConnectDialog({
onOpenChange?.(nextOpen); onOpenChange?.(nextOpen);
}; };
const [loginPromptOpen, setLoginPromptOpen] = useState(false); const [loginPromptOpen, setLoginPromptOpen] = useState(false);
const [apiKey, setApiKey] = useState(""); const [zerodhaApiKey, setZerodhaApiKey] = useState("");
const [apiSecret, setApiSecret] = useState(""); const [zerodhaApiSecret, setZerodhaApiSecret] = useState("");
const [growwApiKey, setGrowwApiKey] = useState("");
const [growwApiSecret, setGrowwApiSecret] = useState("");
const [holdings, setHoldings] = useState<any[]>([]); const [holdings, setHoldings] = useState<any[]>([]);
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({ const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
@ -79,21 +93,36 @@ export default function BrokerConnectDialog({
refetchOnMount: "always", refetchOnMount: "always",
}); });
const loginUrlMutation = useMutation({ const connected = !!brokerStatus?.connected;
const connectedBroker = (brokerStatus?.broker || "").trim().toUpperCase();
const brokerLabel = formatBrokerName(connectedBroker);
const connectedAt = brokerStatus?.connected_at ? new Date(brokerStatus.connected_at) : null;
const canReconnectWithSavedZerodha = connected && connectedBroker === "ZERODHA";
const canReconnectWithSavedGroww = connected && connectedBroker === "GROWW";
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 zerodhaLoginMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!apiKey.trim()) { if (!zerodhaApiKey.trim()) {
throw new Error("API key is required"); throw new Error("Zerodha API key is required");
} }
if (!apiSecret.trim()) { if (!zerodhaApiSecret.trim()) {
throw new Error("API secret is required"); throw new Error("Zerodha API secret is required");
} }
const redirectUrl = `${window.location.origin}/login`; const redirectUrl = `${window.location.origin}/login`;
const res = await apiRequest("POST", "/broker/zerodha/login", { const res = await apiRequest("POST", "/broker/zerodha/login", {
apiKey, apiKey: zerodhaApiKey,
apiSecret, apiSecret: zerodhaApiSecret,
redirectUrl, redirectUrl,
}); });
return res.json() as Promise<{ loginUrl: string }>; return (await res.json()) as { loginUrl: string };
}, },
onSuccess: ({ loginUrl }) => { onSuccess: ({ loginUrl }) => {
window.open(loginUrl, "_blank", "noopener,noreferrer"); window.open(loginUrl, "_blank", "noopener,noreferrer");
@ -103,15 +132,18 @@ export default function BrokerConnectDialog({
}); });
}, },
onError: (err: any) => onError: (err: any) =>
toast({ title: "Could not start Zerodha login", description: err?.message || "Try again." }), toast({
title: "Could not start Zerodha login",
description: err?.message || "Try again.",
}),
}); });
const reconnectSavedMutation = useMutation({ const zerodhaReconnectMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const redirectUrl = buildReconnectRedirectUrl(); const redirectUrl = buildReconnectRedirectUrl();
const params = new URLSearchParams({ redirectUrl }); const params = new URLSearchParams({ redirectUrl });
const res = await apiRequest("GET", `/broker/login-url?${params.toString()}`); const res = await apiRequest("GET", `/broker/login-url?${params.toString()}`);
return res.json() as Promise<{ loginUrl: string }>; return (await res.json()) as { loginUrl: string };
}, },
onSuccess: ({ loginUrl }) => { onSuccess: ({ loginUrl }) => {
window.open(loginUrl, "_blank", "noopener,noreferrer"); window.open(loginUrl, "_blank", "noopener,noreferrer");
@ -136,30 +168,107 @@ export default function BrokerConnectDialog({
}, },
}); });
const growwConnectMutation = useMutation({
mutationFn: async () => {
if (!growwApiKey.trim()) {
throw new Error("Groww API key is required");
}
if (!growwApiSecret.trim()) {
throw new Error("Groww API secret is required");
}
const res = await apiRequest("POST", "/broker/groww/connect", {
apiKey: growwApiKey,
apiSecret: growwApiSecret,
});
return (await res.json()) as {
connected?: boolean;
broker?: string;
userName?: string;
};
},
onSuccess: async (data) => {
await refetchStatus();
setConnectOpen(false);
toast({
title: "Groww connected",
description:
data?.userName
? `Connected as ${data.userName}.`
: "Your Groww broker session is now active.",
});
},
onError: (err: any) =>
toast({
title: "Could not connect Groww",
description: err?.message || "Try again.",
}),
});
const growwReconnectMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/broker/groww/reconnect");
return (await res.json()) as {
connected?: boolean;
broker?: string;
userName?: string;
};
},
onSuccess: async (data) => {
await refetchStatus();
toast({
title: "Groww reconnected",
description:
data?.userName
? `Session refreshed for ${data.userName}.`
: "Your Groww broker session has been refreshed.",
});
},
onError: (err: any) => {
const message = String(err?.message || "");
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
toast({
title: "Enter Groww API credentials",
description: "Saved credentials are missing. Enter the API key and secret once to reconnect.",
});
return;
}
toast({
title: "Could not reconnect Groww",
description: err?.message || "Try again.",
});
},
});
const holdingsMutation = useMutation({ const holdingsMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await apiRequest("GET", "/zerodha/holdings"); const res = await apiRequest("GET", "/broker/holdings");
return res.json() as Promise<{ holdings: any[] }>; return (await res.json()) as HoldingsResponse;
}, },
onSuccess: (data) => { onSuccess: (data) => {
setHoldings(data?.holdings || []); setHoldings(data?.holdings || []);
toast({ title: "Holdings fetched", description: "Latest positions pulled from Zerodha." }); toast({
title: "Holdings fetched",
description: `Latest positions pulled from ${formatBrokerName(data?.broker || brokerStatus?.broker)}.`,
});
}, },
onError: (err: any) => onError: (err: any) =>
toast({ toast({
title: "Could not fetch holdings", title: "Could not fetch holdings",
description: err?.message || "Check your Zerodha session and try again.", description: err?.message || "Check your broker session and try again.",
}), }),
}); });
const disconnectMutation = useMutation({ const disconnectMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await apiRequest("POST", "/broker/disconnect"); const res = await apiRequest("POST", "/broker/disconnect");
return res.json() as Promise<{ connected: boolean }>; return (await res.json()) as { connected: boolean };
}, },
onSuccess: () => { onSuccess: async () => {
refetchStatus(); await refetchStatus();
toast({ title: "Broker disconnected", description: "Your broker has been unlinked." }); toast({
title: "Broker disconnected",
description: "Your broker has been unlinked.",
});
}, },
onError: (err: any) => onError: (err: any) =>
toast({ toast({
@ -168,20 +277,6 @@ export default function BrokerConnectDialog({
}), }),
}); });
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 () => { const handleConnectClick = async () => {
if (connected) { if (connected) {
return; return;
@ -244,7 +339,7 @@ export default function BrokerConnectDialog({
<> <>
<Dialog open={connectOpen} onOpenChange={setConnectOpen}> <Dialog open={connectOpen} onOpenChange={setConnectOpen}>
<Button <Button
variant={connected ? "secondary" : "secondary"} variant="secondary"
className={[triggerClassName, triggerClassNameProp].filter(Boolean).join(" ")} className={[triggerClassName, triggerClassNameProp].filter(Boolean).join(" ")}
disabled={connected} disabled={connected}
onClick={handleConnectClick} onClick={handleConnectClick}
@ -252,7 +347,7 @@ export default function BrokerConnectDialog({
<PlugZap className="h-4 w-4" /> <PlugZap className="h-4 w-4" />
{connected ? "Broker connected" : "Connect broker"} {connected ? "Broker connected" : "Connect broker"}
</Button> </Button>
<DialogContent className="sm:max-w-2xl border-border/70 bg-gradient-to-br from-background via-background to-muted/30"> <DialogContent className="sm:max-w-3xl border-border/70 bg-gradient-to-br from-background via-background to-muted/30">
<motion.div <motion.div
className="space-y-4" className="space-y-4"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@ -275,29 +370,29 @@ export default function BrokerConnectDialog({
<div className="text-sm leading-tight"> <div className="text-sm leading-tight">
<p className="font-medium">Secure brokerage linking</p> <p className="font-medium">Secure brokerage linking</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Start in Zerodha and we will complete the connection automatically. Connect with Zerodha through the broker login flow or use Groww with direct API approval.
</p> </p>
</div> </div>
<Badge variant={connected ? "secondary" : "outline"} className="ml-auto"> <Badge variant={connected ? "secondary" : "outline"} className="ml-auto">
{connected ? "Connected" : "Not connected"} {connected ? "Connected" : "Not connected"}
</Badge> </Badge>
</div> </div>
{connected && ( {connected ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheck className="h-4 w-4 text-primary" /> <ShieldCheck className="h-4 w-4 text-primary" />
<span> <span>
{brokerStatus?.userName {brokerStatus?.userName
? `Linked as ${brokerStatus.userName}` ? `Linked as ${brokerStatus.userName}`
: `Linked to ${brokerStatus?.broker || "broker"}`}{" "} : `Linked to ${brokerLabel}`}{" "}
-{" "} via {brokerLabel}
{connectedAt {" - "}
? connectedAt.toLocaleString() {connectedAt ? connectedAt.toLocaleString() : "just now"}
: "just now"}
</span> </span>
</div> </div>
)} ) : null}
</div> </div>
<div className="grid gap-4 xl:grid-cols-2">
<motion.div <motion.div
className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4 shadow-sm" className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4 shadow-sm"
whileHover={{ whileHover={{
@ -310,7 +405,7 @@ export default function BrokerConnectDialog({
<div> <div>
<p className="text-sm font-semibold">Zerodha</p> <p className="text-sm font-semibold">Zerodha</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Provide your Kite API key & secret to launch the login and connect your account. Provide your Kite API key and secret to launch the login and connect your account.
</p> </p>
</div> </div>
<Badge variant="secondary">Live</Badge> <Badge variant="secondary">Live</Badge>
@ -322,8 +417,8 @@ export default function BrokerConnectDialog({
<Input <Input
id="zerodha-api-key" id="zerodha-api-key"
placeholder="Enter Zerodha API key" placeholder="Enter Zerodha API key"
value={apiKey} value={zerodhaApiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setZerodhaApiKey(e.target.value)}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -332,8 +427,8 @@ export default function BrokerConnectDialog({
id="zerodha-api-secret" id="zerodha-api-secret"
placeholder="Enter Zerodha API secret" placeholder="Enter Zerodha API secret"
type="password" type="password"
value={apiSecret} value={zerodhaApiSecret}
onChange={(e) => setApiSecret(e.target.value)} onChange={(e) => setZerodhaApiSecret(e.target.value)}
/> />
</div> </div>
</div> </div>
@ -341,39 +436,20 @@ export default function BrokerConnectDialog({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => loginUrlMutation.mutate()} onClick={() => zerodhaLoginMutation.mutate()}
disabled={loginUrlMutation.isPending} disabled={zerodhaLoginMutation.isPending}
> >
<ArrowUpRight className="h-4 w-4" /> <ArrowUpRight className="h-4 w-4" />
{loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"} {zerodhaLoginMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
</Button> </Button>
{canReconnectWithSavedZerodha ? ( {canReconnectWithSavedZerodha ? (
<Button <Button
variant="secondary" variant="secondary"
onClick={() => reconnectSavedMutation.mutate()} onClick={() => zerodhaReconnectMutation.mutate()}
disabled={reconnectSavedMutation.isPending} disabled={zerodhaReconnectMutation.isPending}
> >
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
{reconnectSavedMutation.isPending ? "Opening Zerodha..." : "Reconnect saved Zerodha"} {zerodhaReconnectMutation.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> </Button>
) : null} ) : null}
</div> </div>
@ -388,22 +464,107 @@ export default function BrokerConnectDialog({
</p> </p>
</motion.div> </motion.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">Groww</p>
<p className="text-xs text-muted-foreground">
Provide your Groww API key and secret to generate a direct broker session for holdings and live execution.
</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="groww-api-key">API key</Label>
<Input
id="groww-api-key"
placeholder="Enter Groww API key"
value={growwApiKey}
onChange={(e) => setGrowwApiKey(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="groww-api-secret">API secret</Label>
<Input
id="groww-api-secret"
placeholder="Enter Groww API secret"
type="password"
value={growwApiSecret}
onChange={(e) => setGrowwApiSecret(e.target.value)}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => growwConnectMutation.mutate()}
disabled={growwConnectMutation.isPending}
>
<PlugZap className="h-4 w-4" />
{growwConnectMutation.isPending ? "Connecting Groww..." : "Connect Groww"}
</Button>
{canReconnectWithSavedGroww ? (
<Button
variant="secondary"
onClick={() => growwReconnectMutation.mutate()}
disabled={growwReconnectMutation.isPending}
>
<RefreshCcw className="h-4 w-4" />
{growwReconnectMutation.isPending ? "Refreshing Groww..." : "Reconnect saved Groww"}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
Groww reconnect runs server-side using your saved API key and secret. No browser callback is required after the first setup.
</p>
</motion.div>
</div>
<div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground"> <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> <p className="font-medium text-foreground">Other brokers</p>
<div className="flex flex-wrap gap-2"> <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">Angel One (coming soon)</Badge>
<Badge variant="outline">ICICI Direct (coming soon)</Badge> <Badge variant="outline">ICICI Direct (coming soon)</Badge>
<Badge variant="outline">HDFC Securities (coming soon)</Badge> <Badge variant="outline">HDFC Securities (coming soon)</Badge>
</div> </div>
</div> </div>
{connected && ( {connected ? (
<div className="space-y-2 rounded-lg border border-border/60 bg-background/80 p-4"> <div className="space-y-2 rounded-lg border border-border/60 bg-background/80 p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" /> <ShieldCheck className="h-4 w-4 text-primary" />
<p className="text-sm font-semibold">Latest holdings</p> <p className="text-sm font-semibold">Latest holdings</p>
</div> </div>
<div className="ml-auto flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={() => holdingsMutation.mutate()}
disabled={holdingsMutation.isPending}
>
<RefreshCcw className="h-4 w-4" />
{holdingsMutation.isPending ? "Fetching..." : "Fetch holdings"}
</Button>
<Button
variant="outline"
onClick={() => disconnectMutation.mutate()}
disabled={disconnectMutation.isPending}
>
{disconnectMutation.isPending ? "Disconnecting..." : "Disconnect broker"}
</Button>
</div>
</div>
{holdings.length === 0 ? ( {holdings.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No holdings pulled yet. Click &ldquo;Fetch holdings&rdquo; after connecting. No holdings pulled yet. Click &ldquo;Fetch holdings&rdquo; after connecting.
@ -412,7 +573,7 @@ export default function BrokerConnectDialog({
<div className="grid gap-2"> <div className="grid gap-2">
{holdings.map((item, idx) => ( {holdings.map((item, idx) => (
<div <div
key={`${item.tradingsymbol || item.instrument_token || idx}`} key={`${item.tradingsymbol || item.instrument_token || item.symbol || idx}`}
className="flex items-center justify-between rounded-md border border-border/50 bg-card/40 px-3 py-2 text-sm" className="flex items-center justify-between rounded-md border border-border/50 bg-card/40 px-3 py-2 text-sm"
> >
<div> <div>
@ -432,7 +593,7 @@ export default function BrokerConnectDialog({
</div> </div>
)} )}
</div> </div>
)} ) : null}
</div> </div>
</motion.div> </motion.div>
</DialogContent> </DialogContent>

View File

@ -35,7 +35,7 @@ const faqItems = [
{ {
question: "What brokers are supported?", question: "What brokers are supported?",
answer: answer:
"Zerodha is supported now. Additional brokers will be available soon.", "Zerodha and Groww are supported now. Additional brokers will be available soon.",
}, },
{ {
question: "Is my data secure?", question: "Is my data secure?",

View File

@ -154,6 +154,13 @@ function buildReconnectRedirectUrl() {
return url.toString(); return url.toString();
} }
function formatBrokerName(broker?: string | null) {
const normalized = (broker || "").trim().toUpperCase();
if (normalized === "GROWW") return "Groww";
if (normalized === "ZERODHA") return "Zerodha";
return normalized || "broker";
}
function firstNumber(...values: unknown[]) { function firstNumber(...values: unknown[]) {
for (const value of values) { for (const value of values) {
const parsed = Number(value); const parsed = Number(value);
@ -263,7 +270,7 @@ export default function PortfolioSection() {
refetchOnMount: "always", refetchOnMount: "always",
}); });
async function startSavedBrokerReconnect() { async function startSavedZerodhaReconnect() {
try { try {
const redirectUrl = buildReconnectRedirectUrl(); const redirectUrl = buildReconnectRedirectUrl();
const params = new URLSearchParams({ redirectUrl }); const params = new URLSearchParams({ redirectUrl });
@ -295,6 +302,36 @@ export default function PortfolioSection() {
} }
} }
async function startSavedGrowwReconnect() {
try {
const res = await apiRequest("POST", "/broker/groww/reconnect");
await res.json();
await Promise.allSettled([
refetchBrokerStatus(),
refreshBrokerData({ includeEquityCurve: true }),
]);
toast({
title: "Groww reconnected",
description: "Your Groww broker session has been refreshed.",
});
} catch (error: any) {
const message = String(error?.message || "");
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
setBrokerDialogOpen(true);
toast({
title: "Enter Groww API credentials",
description: "Saved Groww credentials are missing. Enter the API key and secret once to reconnect.",
});
return;
}
if (message.includes("401:")) {
setLoginPromptOpen(true);
return;
}
throw error;
}
}
const disconnectBrokerMutation = useMutation({ const disconnectBrokerMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await apiRequest("POST", "/broker/disconnect"); const res = await apiRequest("POST", "/broker/disconnect");
@ -306,9 +343,9 @@ export default function PortfolioSection() {
setCachedHoldings([]); setCachedHoldings([]);
setCachedFunds(null); setCachedFunds(null);
setCachedEquityCurve(null); setCachedEquityCurve(null);
queryClient.removeQueries({ queryKey: ["/zerodha/holdings"] }); queryClient.removeQueries({ queryKey: ["/broker/holdings"] });
queryClient.removeQueries({ queryKey: ["/zerodha/funds"] }); queryClient.removeQueries({ queryKey: ["/broker/funds"] });
queryClient.removeQueries({ queryKey: ["/zerodha/equity-curve"] }); queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] });
await refetchBrokerStatus(); await refetchBrokerStatus();
toast({ toast({
title: "Broker disconnected", title: "Broker disconnected",
@ -323,9 +360,9 @@ export default function PortfolioSection() {
}); });
const holdingsQuery = useQuery<HoldingsResponse>({ const holdingsQuery = useQuery<HoldingsResponse>({
queryKey: ["/zerodha/holdings"], queryKey: ["/broker/holdings"],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", "/zerodha/holdings"); const res = await apiRequest("GET", "/broker/holdings");
return res.json(); return res.json();
}, },
enabled: !!brokerStatus?.connected, enabled: !!brokerStatus?.connected,
@ -344,9 +381,9 @@ export default function PortfolioSection() {
}); });
const fundsQuery = useQuery<FundsResponse>({ const fundsQuery = useQuery<FundsResponse>({
queryKey: ["/zerodha/funds"], queryKey: ["/broker/funds"],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest("GET", "/zerodha/funds"); const res = await apiRequest("GET", "/broker/funds");
return res.json(); return res.json();
}, },
enabled: !!brokerStatus?.connected, enabled: !!brokerStatus?.connected,
@ -491,11 +528,11 @@ export default function PortfolioSection() {
}, [prefersReducedMotion]); }, [prefersReducedMotion]);
const equityCurveQuery = useQuery<EquityCurveResponse>({ const equityCurveQuery = useQuery<EquityCurveResponse>({
queryKey: ["/zerodha/equity-curve", startDate], queryKey: ["/broker/equity-curve", startDate],
queryFn: async () => { queryFn: async () => {
const res = await apiRequest( const res = await apiRequest(
"GET", "GET",
`/zerodha/equity-curve${startDate ? `?from=${startDate}` : ""}`, `/broker/equity-curve${startDate ? `?from=${startDate}` : ""}`,
); );
return res.json(); return res.json();
}, },
@ -619,6 +656,8 @@ export default function PortfolioSection() {
const showSessionExpired = sessionExpired && isConnected; const showSessionExpired = sessionExpired && isConnected;
const brokerAuthExpired = (brokerStatus?.authState || "").toUpperCase() === "EXPIRED"; const brokerAuthExpired = (brokerStatus?.authState || "").toUpperCase() === "EXPIRED";
const showReconnectBroker = isConnected && (showSessionExpired || brokerAuthExpired); const showReconnectBroker = isConnected && (showSessionExpired || brokerAuthExpired);
const connectedBroker = (brokerStatus?.broker || "").trim().toUpperCase();
const brokerLabel = formatBrokerName(connectedBroker);
const normalizedStrategyStatus = const normalizedStrategyStatus =
strategyStatus === "RUNNING" strategyStatus === "RUNNING"
@ -727,17 +766,27 @@ export default function PortfolioSection() {
return true; return true;
}, [refetchSessionUser]); }, [refetchSessionUser]);
const reconnectCurrentBroker = async () => {
if (connectedBroker === "GROWW") {
return startSavedGrowwReconnect();
}
if (connectedBroker === "ZERODHA") {
return startSavedZerodhaReconnect();
}
setBrokerDialogOpen(true);
};
const handleReconnectClick = useCallback(() => { const handleReconnectClick = useCallback(() => {
requireLogin() requireLogin()
.then((isLoggedIn) => { .then((isLoggedIn) => {
if (!isLoggedIn) { if (!isLoggedIn) {
return; return;
} }
if (brokerStatus?.connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA") { if (brokerStatus?.connected) {
return startSavedBrokerReconnect().catch((error: any) => return reconnectCurrentBroker().catch((error: any) =>
toast({ toast({
title: "Reconnect failed", title: "Reconnect failed",
description: error?.message || "Unable to start Zerodha reconnect.", description: error?.message || `Unable to reconnect ${brokerLabel}.`,
}), }),
); );
} }
@ -746,7 +795,7 @@ export default function PortfolioSection() {
.catch(() => { .catch(() => {
setLoginPromptOpen(true); setLoginPromptOpen(true);
}); });
}, [brokerStatus, requireLogin]); }, [brokerLabel, brokerStatus, reconnectCurrentBroker, requireLogin]);
const handleRefreshBrokerData = useCallback(() => { const handleRefreshBrokerData = useCallback(() => {
void refreshBrokerData({ includeEquityCurve: true }); void refreshBrokerData({ includeEquityCurve: true });
@ -767,7 +816,7 @@ export default function PortfolioSection() {
if (showSessionExpired || brokerAuthExpired) { if (showSessionExpired || brokerAuthExpired) {
toast({ toast({
title: "Reconnect broker", title: "Reconnect broker",
description: "Your Zerodha session has expired. Reconnect before starting the live strategy.", description: `Your ${brokerLabel} session has expired. Reconnect before starting the live strategy.`,
}); });
handleReconnectClick(); handleReconnectClick();
return; return;
@ -786,9 +835,9 @@ export default function PortfolioSection() {
if (result?.status === "broker_auth_required") { if (result?.status === "broker_auth_required") {
toast({ toast({
title: "Reconnect broker", title: "Reconnect broker",
description: "Your Zerodha session has expired. Reconnect and try again.", description: `Your ${brokerLabel} session has expired. Reconnect and try again.`,
}); });
await startSavedBrokerReconnect(); await reconnectCurrentBroker();
return; return;
} }
if (result?.status === "already_running") { if (result?.status === "already_running") {
@ -800,7 +849,7 @@ export default function PortfolioSection() {
setFreshStartRequested(false); setFreshStartRequested(false);
toast({ toast({
title: "Live strategy started", title: "Live strategy started",
description: "Golden Nifty is now armed for live Zerodha execution.", description: `Golden Nifty is now armed for live ${brokerLabel} execution.`,
}); });
} else { } else {
toast({ toast({
@ -839,7 +888,7 @@ export default function PortfolioSection() {
if (showSessionExpired || brokerAuthExpired) { if (showSessionExpired || brokerAuthExpired) {
toast({ toast({
title: "Reconnect broker", title: "Reconnect broker",
description: "Your Zerodha session has expired. Reconnect before resuming the live strategy.", description: `Your ${brokerLabel} session has expired. Reconnect before resuming the live strategy.`,
}); });
handleReconnectClick(); handleReconnectClick();
return; return;
@ -850,9 +899,9 @@ export default function PortfolioSection() {
if (result?.status === "broker_auth_required") { if (result?.status === "broker_auth_required") {
toast({ toast({
title: "Reconnect broker", title: "Reconnect broker",
description: "Your Zerodha session has expired. Reconnect and resume again.", description: `Your ${brokerLabel} session has expired. Reconnect and resume again.`,
}); });
await startSavedBrokerReconnect(); await reconnectCurrentBroker();
return; return;
} }
if (result?.status === "already_running") { if (result?.status === "already_running") {

View File

@ -20,7 +20,7 @@ const privacyCards = [
}, },
{ {
title: "Third Parties", title: "Third Parties",
body: "Brokerage APIs (e.g., Zerodha) are accessed only with your consent. No unauthorized sharing with any other party.", body: "Brokerage APIs (e.g., Zerodha and Groww) are accessed only with your consent. No unauthorized sharing with any other party.",
}, },
{ {
title: "User Control", title: "User Control",