diff --git a/src/components/landing/BrokerConnectDialog.tsx b/src/components/landing/BrokerConnectDialog.tsx index 1ca8f5d6..5409e8b0 100644 --- a/src/components/landing/BrokerConnectDialog.tsx +++ b/src/components/landing/BrokerConnectDialog.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; 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 { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -10,7 +11,6 @@ import { 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"; @@ -36,6 +36,11 @@ type BrokerStatusResponse = { authState?: string; }; +type HoldingsResponse = { + broker?: string; + holdings?: any[]; +}; + const CALLBACK_STORAGE_KEY = "zerodha:callback"; const CALLBACK_MAX_AGE_MS = 5 * 60 * 1000; @@ -45,6 +50,13 @@ function buildReconnectRedirectUrl() { 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({ layout = "desktop", open, @@ -61,8 +73,10 @@ export default function BrokerConnectDialog({ onOpenChange?.(nextOpen); }; const [loginPromptOpen, setLoginPromptOpen] = useState(false); - const [apiKey, setApiKey] = useState(""); - const [apiSecret, setApiSecret] = useState(""); + const [zerodhaApiKey, setZerodhaApiKey] = useState(""); + const [zerodhaApiSecret, setZerodhaApiSecret] = useState(""); + const [growwApiKey, setGrowwApiKey] = useState(""); + const [growwApiSecret, setGrowwApiSecret] = useState(""); const [holdings, setHoldings] = useState([]); const { data: sessionUser, refetch: refetchSessionUser } = useQuery({ @@ -79,21 +93,36 @@ export default function BrokerConnectDialog({ 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 () => { - if (!apiKey.trim()) { - throw new Error("API key is required"); + if (!zerodhaApiKey.trim()) { + throw new Error("Zerodha API key is required"); } - if (!apiSecret.trim()) { - throw new Error("API secret is required"); + if (!zerodhaApiSecret.trim()) { + throw new Error("Zerodha API secret is required"); } const redirectUrl = `${window.location.origin}/login`; const res = await apiRequest("POST", "/broker/zerodha/login", { - apiKey, - apiSecret, + apiKey: zerodhaApiKey, + apiSecret: zerodhaApiSecret, redirectUrl, }); - return res.json() as Promise<{ loginUrl: string }>; + return (await res.json()) as { loginUrl: string }; }, onSuccess: ({ loginUrl }) => { window.open(loginUrl, "_blank", "noopener,noreferrer"); @@ -103,15 +132,18 @@ export default function BrokerConnectDialog({ }); }, 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 () => { const redirectUrl = buildReconnectRedirectUrl(); const params = new URLSearchParams({ redirectUrl }); 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 }) => { 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({ mutationFn: async () => { - const res = await apiRequest("GET", "/zerodha/holdings"); - return res.json() as Promise<{ holdings: any[] }>; + const res = await apiRequest("GET", "/broker/holdings"); + return (await res.json()) as HoldingsResponse; }, onSuccess: (data) => { 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) => toast({ 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({ mutationFn: async () => { const res = await apiRequest("POST", "/broker/disconnect"); - return res.json() as Promise<{ connected: boolean }>; + return (await res.json()) as { connected: boolean }; }, - onSuccess: () => { - refetchStatus(); - toast({ title: "Broker disconnected", description: "Your broker has been unlinked." }); + onSuccess: async () => { + await refetchStatus(); + toast({ + title: "Broker disconnected", + description: "Your broker has been unlinked.", + }); }, onError: (err: any) => 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 () => { if (connected) { return; @@ -244,7 +339,7 @@ export default function BrokerConnectDialog({ <> - +
-
-
-
- -
-
-

Secure brokerage linking

-

- Start in Zerodha and we will complete the connection automatically. -

-
- - {connected ? "Connected" : "Not connected"} - -
- {connected && ( -
- - - {brokerStatus?.userName - ? `Linked as ${brokerStatus.userName}` - : `Linked to ${brokerStatus?.broker || "broker"}`}{" "} - -{" "} - {connectedAt - ? connectedAt.toLocaleString() - : "just now"} - -
- )} -
- - -
-
-

Zerodha

-

- Provide your Kite API key & secret to launch the login and connect your account. -

-
- Live -
- -
-
- - setApiKey(e.target.value)} - /> -
-
- - setApiSecret(e.target.value)} - /> -
-
- -
- - {canReconnectWithSavedZerodha ? ( - - ) : null} - {connected ? ( - - ) : null} - {connected ? ( - - ) : null} -
- -

- After you log in with your Zerodha account, we will connect automatically. Keep this tab open - until the login completes. -

-

- Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key - and secret without re-entering them. -

-
- -
-

Other brokers

-
- Groww (coming soon) - Angel One (coming soon) - ICICI Direct (coming soon) - HDFC Securities (coming soon) -
-
- - {connected && ( -
-
- -

Latest holdings

-
- {holdings.length === 0 ? ( -

- No holdings pulled yet. Click “Fetch holdings” after connecting. -

- ) : ( -
- {holdings.map((item, idx) => ( -
-
-

- {item.tradingsymbol || item.symbol || "Instrument"} -

-

- Qty: {item.quantity ?? item.qty ?? "-"} | Avg:{" "} - {item.average_price ?? item.avg_price ?? "-"} -

-
- - {item.exchange || item.exchange_type || "N/A"} - -
- ))} +
+
+
+
- )} +
+

Secure brokerage linking

+

+ Connect with Zerodha through the broker login flow or use Groww with direct API approval. +

+
+ + {connected ? "Connected" : "Not connected"} + +
+ {connected ? ( +
+ + + {brokerStatus?.userName + ? `Linked as ${brokerStatus.userName}` + : `Linked to ${brokerLabel}`}{" "} + via {brokerLabel} + {" - "} + {connectedAt ? connectedAt.toLocaleString() : "just now"} + +
+ ) : null}
- )} -
+ +
+ +
+
+

Zerodha

+

+ Provide your Kite API key and secret to launch the login and connect your account. +

+
+ Live +
+ +
+
+ + setZerodhaApiKey(e.target.value)} + /> +
+
+ + setZerodhaApiSecret(e.target.value)} + /> +
+
+ +
+ + {canReconnectWithSavedZerodha ? ( + + ) : null} +
+ +

+ After you log in with your Zerodha account, we will connect automatically. Keep this tab open + until the login completes. +

+

+ Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key + and secret without re-entering them. +

+
+ + +
+
+

Groww

+

+ Provide your Groww API key and secret to generate a direct broker session for holdings and live execution. +

+
+ Live +
+ +
+
+ + setGrowwApiKey(e.target.value)} + /> +
+
+ + setGrowwApiSecret(e.target.value)} + /> +
+
+ +
+ + {canReconnectWithSavedGroww ? ( + + ) : null} +
+ +

+ Groww reconnect runs server-side using your saved API key and secret. No browser callback is required after the first setup. +

+
+
+ +
+

Other brokers

+
+ Angel One (coming soon) + ICICI Direct (coming soon) + HDFC Securities (coming soon) +
+
+ + {connected ? ( +
+
+
+ +

Latest holdings

+
+
+ + +
+
+ {holdings.length === 0 ? ( +

+ No holdings pulled yet. Click “Fetch holdings” after connecting. +

+ ) : ( +
+ {holdings.map((item, idx) => ( +
+
+

+ {item.tradingsymbol || item.symbol || "Instrument"} +

+

+ Qty: {item.quantity ?? item.qty ?? "-"} | Avg:{" "} + {item.average_price ?? item.avg_price ?? "-"} +

+
+ + {item.exchange || item.exchange_type || "N/A"} + +
+ ))} +
+ )} +
+ ) : null} +
diff --git a/src/components/landing/FAQSection.tsx b/src/components/landing/FAQSection.tsx index 2110ac67..21620f7d 100644 --- a/src/components/landing/FAQSection.tsx +++ b/src/components/landing/FAQSection.tsx @@ -35,7 +35,7 @@ const faqItems = [ { question: "What brokers are supported?", 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?", diff --git a/src/components/landing/PortfolioSection.tsx b/src/components/landing/PortfolioSection.tsx index 42d0021e..e03830c8 100644 --- a/src/components/landing/PortfolioSection.tsx +++ b/src/components/landing/PortfolioSection.tsx @@ -154,6 +154,13 @@ function buildReconnectRedirectUrl() { 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[]) { for (const value of values) { const parsed = Number(value); @@ -263,7 +270,7 @@ export default function PortfolioSection() { refetchOnMount: "always", }); - async function startSavedBrokerReconnect() { + async function startSavedZerodhaReconnect() { try { const redirectUrl = buildReconnectRedirectUrl(); 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({ mutationFn: async () => { const res = await apiRequest("POST", "/broker/disconnect"); @@ -306,9 +343,9 @@ export default function PortfolioSection() { setCachedHoldings([]); setCachedFunds(null); setCachedEquityCurve(null); - queryClient.removeQueries({ queryKey: ["/zerodha/holdings"] }); - queryClient.removeQueries({ queryKey: ["/zerodha/funds"] }); - queryClient.removeQueries({ queryKey: ["/zerodha/equity-curve"] }); + queryClient.removeQueries({ queryKey: ["/broker/holdings"] }); + queryClient.removeQueries({ queryKey: ["/broker/funds"] }); + queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] }); await refetchBrokerStatus(); toast({ title: "Broker disconnected", @@ -323,9 +360,9 @@ export default function PortfolioSection() { }); const holdingsQuery = useQuery({ - queryKey: ["/zerodha/holdings"], + queryKey: ["/broker/holdings"], queryFn: async () => { - const res = await apiRequest("GET", "/zerodha/holdings"); + const res = await apiRequest("GET", "/broker/holdings"); return res.json(); }, enabled: !!brokerStatus?.connected, @@ -344,9 +381,9 @@ export default function PortfolioSection() { }); const fundsQuery = useQuery({ - queryKey: ["/zerodha/funds"], + queryKey: ["/broker/funds"], queryFn: async () => { - const res = await apiRequest("GET", "/zerodha/funds"); + const res = await apiRequest("GET", "/broker/funds"); return res.json(); }, enabled: !!brokerStatus?.connected, @@ -491,11 +528,11 @@ export default function PortfolioSection() { }, [prefersReducedMotion]); const equityCurveQuery = useQuery({ - queryKey: ["/zerodha/equity-curve", startDate], + queryKey: ["/broker/equity-curve", startDate], queryFn: async () => { const res = await apiRequest( "GET", - `/zerodha/equity-curve${startDate ? `?from=${startDate}` : ""}`, + `/broker/equity-curve${startDate ? `?from=${startDate}` : ""}`, ); return res.json(); }, @@ -619,6 +656,8 @@ export default function PortfolioSection() { const showSessionExpired = sessionExpired && isConnected; const brokerAuthExpired = (brokerStatus?.authState || "").toUpperCase() === "EXPIRED"; const showReconnectBroker = isConnected && (showSessionExpired || brokerAuthExpired); + const connectedBroker = (brokerStatus?.broker || "").trim().toUpperCase(); + const brokerLabel = formatBrokerName(connectedBroker); const normalizedStrategyStatus = strategyStatus === "RUNNING" @@ -727,17 +766,27 @@ export default function PortfolioSection() { return true; }, [refetchSessionUser]); + const reconnectCurrentBroker = async () => { + if (connectedBroker === "GROWW") { + return startSavedGrowwReconnect(); + } + if (connectedBroker === "ZERODHA") { + return startSavedZerodhaReconnect(); + } + setBrokerDialogOpen(true); + }; + const handleReconnectClick = useCallback(() => { requireLogin() .then((isLoggedIn) => { if (!isLoggedIn) { return; } - if (brokerStatus?.connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA") { - return startSavedBrokerReconnect().catch((error: any) => + if (brokerStatus?.connected) { + return reconnectCurrentBroker().catch((error: any) => toast({ 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(() => { setLoginPromptOpen(true); }); - }, [brokerStatus, requireLogin]); + }, [brokerLabel, brokerStatus, reconnectCurrentBroker, requireLogin]); const handleRefreshBrokerData = useCallback(() => { void refreshBrokerData({ includeEquityCurve: true }); @@ -767,7 +816,7 @@ export default function PortfolioSection() { if (showSessionExpired || brokerAuthExpired) { toast({ 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(); return; @@ -786,9 +835,9 @@ export default function PortfolioSection() { if (result?.status === "broker_auth_required") { toast({ 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; } if (result?.status === "already_running") { @@ -800,7 +849,7 @@ export default function PortfolioSection() { setFreshStartRequested(false); toast({ 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 { toast({ @@ -839,7 +888,7 @@ export default function PortfolioSection() { if (showSessionExpired || brokerAuthExpired) { toast({ 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(); return; @@ -850,9 +899,9 @@ export default function PortfolioSection() { if (result?.status === "broker_auth_required") { toast({ 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; } if (result?.status === "already_running") { diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index f576d0cb..048d5cbc 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -20,7 +20,7 @@ const privacyCards = [ }, { 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",