Add Groww broker support to portfolio UI
This commit is contained in:
parent
71a99b7b0a
commit
56f6e8551e
@ -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 “Fetch holdings” after connecting.
|
No holdings pulled yet. Click “Fetch holdings” 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>
|
||||||
|
|||||||
@ -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?",
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user