Redesign broker connection flow

This commit is contained in:
Thigazhezhilan J 2026-04-05 20:00:43 +05:30
parent 56f6e8551e
commit af5e0a50a8

View File

@ -1,7 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowUpRight, PlugZap, RefreshCcw, ShieldCheck } from "lucide-react";
import { motion } from "framer-motion";
import {
ArrowRight,
ArrowUpRight,
ChevronLeft,
PlugZap,
RefreshCcw,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -41,9 +49,45 @@ type HoldingsResponse = {
holdings?: any[];
};
type BrokerKey = "ZERODHA" | "GROWW";
type BrokerChoice = {
key: BrokerKey;
title: string;
subtitle: string;
accentClass: string;
glowClass: string;
bulletOne: string;
bulletTwo: string;
connectLabel: string;
};
const CALLBACK_STORAGE_KEY = "zerodha:callback";
const CALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
const brokerChoices: BrokerChoice[] = [
{
key: "ZERODHA",
title: "Zerodha",
subtitle: "Use the Kite login flow and finish the broker approval in a new tab.",
accentClass: "border-cyan-400/40 bg-cyan-500/10 text-cyan-200",
glowClass: "from-cyan-500/20 via-cyan-500/5 to-transparent",
bulletOne: "Browser-based login approval",
bulletTwo: "Saved credential reconnect after first setup",
connectLabel: "Continue with Zerodha",
},
{
key: "GROWW",
title: "Groww",
subtitle: "Use direct API approval and create a live broker session without a callback step.",
accentClass: "border-emerald-400/40 bg-emerald-500/10 text-emerald-200",
glowClass: "from-emerald-500/20 via-emerald-500/5 to-transparent",
bulletOne: "Direct API key and secret approval",
bulletTwo: "Server-side reconnect using saved approval",
connectLabel: "Continue with Groww",
},
];
function buildReconnectRedirectUrl() {
const url = new URL(`${window.location.origin}/login`);
url.searchParams.set("flow", "reconnect");
@ -57,6 +101,58 @@ function formatBrokerName(broker?: string | null) {
return normalized || "broker";
}
function BrokerChoiceCard({
choice,
onSelect,
}: {
choice: BrokerChoice;
onSelect: (broker: BrokerKey) => void;
}) {
return (
<motion.button
type="button"
onClick={() => onSelect(choice.key)}
className="group relative overflow-hidden rounded-2xl border border-border/60 bg-background/70 p-5 text-left shadow-lg transition-colors hover:border-primary/50"
whileHover={{
y: -8,
boxShadow: "0 24px 60px rgba(0,0,0,0.22)",
}}
whileTap={{ scale: 0.99 }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
>
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${choice.glowClass} opacity-90`} />
<div className="relative space-y-5">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="rounded-full bg-primary/15 p-2 text-primary">
<PlugZap className="h-4 w-4" />
</div>
<p className="text-lg font-semibold">{choice.title}</p>
</div>
<p className="max-w-sm text-sm leading-6 text-muted-foreground">{choice.subtitle}</p>
</div>
<Badge className={choice.accentClass}>Live</Badge>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="rounded-xl border border-white/5 bg-background/65 px-3 py-2">
{choice.bulletOne}
</div>
<div className="rounded-xl border border-white/5 bg-background/65 px-3 py-2">
{choice.bulletTwo}
</div>
</div>
<div className="flex items-center justify-between rounded-xl border border-primary/20 bg-primary/10 px-3 py-3 text-sm font-medium text-primary">
<span>{choice.connectLabel}</span>
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</div>
</div>
</motion.button>
);
}
export default function BrokerConnectDialog({
layout = "desktop",
open,
@ -73,13 +169,14 @@ export default function BrokerConnectDialog({
onOpenChange?.(nextOpen);
};
const [loginPromptOpen, setLoginPromptOpen] = useState(false);
const [selectedBroker, setSelectedBroker] = useState<BrokerKey | null>(null);
const [zerodhaApiKey, setZerodhaApiKey] = useState("");
const [zerodhaApiSecret, setZerodhaApiSecret] = useState("");
const [growwApiKey, setGrowwApiKey] = useState("");
const [growwApiSecret, setGrowwApiSecret] = useState("");
const [holdings, setHoldings] = useState<any[]>([]);
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
const { refetch: refetchSessionUser } = useQuery<SessionUser | null>({
queryKey: ["/me"],
queryFn: getQueryFn<SessionUser | null>({ on401: "returnNull" }),
staleTime: 0,
@ -94,7 +191,7 @@ export default function BrokerConnectDialog({
});
const connected = !!brokerStatus?.connected;
const connectedBroker = (brokerStatus?.broker || "").trim().toUpperCase();
const connectedBroker = (brokerStatus?.broker || "").trim().toUpperCase() as BrokerKey | "";
const brokerLabel = formatBrokerName(connectedBroker);
const connectedAt = brokerStatus?.connected_at ? new Date(brokerStatus.connected_at) : null;
const canReconnectWithSavedZerodha = connected && connectedBroker === "ZERODHA";
@ -108,6 +205,8 @@ export default function BrokerConnectDialog({
: layoutClasses;
}, [connected, layout]);
const selectedChoice = brokerChoices.find((choice) => choice.key === selectedBroker) || null;
const zerodhaLoginMutation = useMutation({
mutationFn: async () => {
if (!zerodhaApiKey.trim()) {
@ -248,7 +347,9 @@ export default function BrokerConnectDialog({
setHoldings(data?.holdings || []);
toast({
title: "Holdings fetched",
description: `Latest positions pulled from ${formatBrokerName(data?.broker || brokerStatus?.broker)}.`,
description: `Latest positions pulled from ${formatBrokerName(
data?.broker || brokerStatus?.broker,
)}.`,
});
},
onError: (err: any) =>
@ -265,6 +366,7 @@ export default function BrokerConnectDialog({
},
onSuccess: async () => {
await refetchStatus();
setSelectedBroker(null);
toast({
title: "Broker disconnected",
description: "Your broker has been unlinked.",
@ -289,6 +391,18 @@ export default function BrokerConnectDialog({
setConnectOpen(true);
};
useEffect(() => {
if (!connectOpen) {
setSelectedBroker(null);
return;
}
if (connectedBroker === "ZERODHA" || connectedBroker === "GROWW") {
setSelectedBroker(connectedBroker);
return;
}
setSelectedBroker(null);
}, [connectOpen, connectedBroker]);
useEffect(() => {
const consumeCallback = (rawValue?: string) => {
const value = rawValue || localStorage.getItem(CALLBACK_STORAGE_KEY);
@ -335,83 +449,41 @@ export default function BrokerConnectDialog({
return () => window.removeEventListener("storage", handleStorage);
}, [refetchStatus]);
const renderSelectedBrokerForm = () => {
if (!selectedChoice) {
return null;
}
if (selectedChoice.key === "ZERODHA") {
return (
<>
<Dialog open={connectOpen} onOpenChange={setConnectOpen}>
<Button
variant="secondary"
className={[triggerClassName, triggerClassNameProp].filter(Boolean).join(" ")}
disabled={connected}
onClick={handleConnectClick}
>
<PlugZap className="h-4 w-4" />
{connected ? "Broker connected" : "Connect broker"}
</Button>
<DialogContent className="sm:max-w-3xl border-border/70 bg-gradient-to-br from-background via-background to-muted/30">
<motion.div
key="zerodha-step"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.28, ease: "easeOut" }}
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">
<div className="relative overflow-hidden rounded-2xl border border-cyan-400/30 bg-background/80 p-5">
<div className="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-transparent to-transparent" />
<div className="relative flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="rounded-full bg-cyan-500/15 p-2 text-cyan-200">
<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">
Connect with Zerodha through the broker login flow or use Groww with direct API approval.
<p className="text-lg font-semibold">Zerodha approval</p>
</div>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Enter your Kite API key and secret, then continue in Zerodha to approve the session. After the
first setup, reconnect can reuse the saved credentials.
</p>
</div>
<Badge variant={connected ? "secondary" : "outline"} className="ml-auto">
{connected ? "Connected" : "Not connected"}
</Badge>
<Badge className="border-cyan-400/40 bg-cyan-500/10 text-cyan-200">Live flow</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 ${brokerLabel}`}{" "}
via {brokerLabel}
{" - "}
{connectedAt ? connectedAt.toLocaleString() : "just now"}
</span>
</div>
) : null}
</div>
<div className="grid gap-4 xl:grid-cols-2">
<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 and 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="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="zerodha-api-key">API key</Label>
<Input
@ -433,9 +505,9 @@ export default function BrokerConnectDialog({
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={() => zerodhaLoginMutation.mutate()}
disabled={zerodhaLoginMutation.isPending}
>
@ -445,6 +517,7 @@ export default function BrokerConnectDialog({
{canReconnectWithSavedZerodha ? (
<Button
variant="secondary"
className="w-full sm:w-auto"
onClick={() => zerodhaReconnectMutation.mutate()}
disabled={zerodhaReconnectMutation.isPending}
>
@ -454,35 +527,43 @@ export default function BrokerConnectDialog({
) : 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>
<div className="rounded-2xl border border-border/60 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">
Keep this tab open until the broker login completes. Zerodha access tokens expire daily, so reconnect
may be required after market sessions.
</div>
</motion.div>
);
}
return (
<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 }}
key="groww-step"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="space-y-4"
>
<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.
<div className="relative overflow-hidden rounded-2xl border border-emerald-400/30 bg-background/80 p-5">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/20 via-transparent to-transparent" />
<div className="relative flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="rounded-full bg-emerald-500/15 p-2 text-emerald-200">
<Sparkles className="h-4 w-4" />
</div>
<p className="text-lg font-semibold">Groww approval</p>
</div>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Enter your Groww API key and secret to create the broker session directly. Reconnect runs
server-side, so you can refresh the session without another browser callback step.
</p>
</div>
<Badge variant="secondary">Live</Badge>
<Badge className="border-emerald-400/40 bg-emerald-500/10 text-emerald-200">Direct API</Badge>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="groww-api-key">API key</Label>
<Input
@ -504,9 +585,9 @@ export default function BrokerConnectDialog({
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={() => growwConnectMutation.mutate()}
disabled={growwConnectMutation.isPending}
>
@ -516,6 +597,7 @@ export default function BrokerConnectDialog({
{canReconnectWithSavedGroww ? (
<Button
variant="secondary"
className="w-full sm:w-auto"
onClick={() => growwReconnectMutation.mutate()}
disabled={growwReconnectMutation.isPending}
>
@ -525,23 +607,167 @@ export default function BrokerConnectDialog({
) : 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>
<div className="rounded-2xl border border-border/60 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">
Groww reconnect uses your saved approval data on the server, so the portfolio can refresh access without
opening another tab after the first setup.
</div>
</motion.div>
);
};
return (
<>
<Dialog open={connectOpen} onOpenChange={setConnectOpen}>
<Button
variant="secondary"
className={[triggerClassName, triggerClassNameProp].filter(Boolean).join(" ")}
disabled={connected}
onClick={handleConnectClick}
>
<PlugZap className="h-4 w-4" />
{connected ? "Broker connected" : "Connect broker"}
</Button>
<DialogContent className="overflow-hidden border-border/70 bg-gradient-to-br from-background via-background to-muted/30 sm:max-w-3xl">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -right-24 top-0 h-64 w-64 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute left-0 top-40 h-56 w-56 rounded-full bg-chart-2/10 blur-3xl" />
</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">
<motion.div
className="relative space-y-5"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: "easeOut" }}
>
<DialogHeader className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge className="border-primary/30 bg-primary/10 text-primary">Broker setup</Badge>
<Badge variant={connected ? "secondary" : "outline"}>
{connected ? `${brokerLabel} connected` : "No broker connected"}
</Badge>
</div>
<DialogTitle>Connect your broker</DialogTitle>
<DialogDescription>
Choose a broker first, then complete a guided setup flow instead of juggling every provider at once.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 rounded-2xl border border-border/60 bg-card/60 p-3 sm:grid-cols-[1fr_1fr]">
<div
className={`rounded-xl px-4 py-3 text-sm ${
selectedBroker
? "border border-border/60 bg-background/40 text-muted-foreground"
: "border border-primary/30 bg-primary/10 text-primary"
}`}
>
<p className="text-xs uppercase tracking-[0.18em]">Step 1</p>
<p className="mt-1 font-medium">Choose your broker</p>
</div>
<div
className={`rounded-xl px-4 py-3 text-sm ${
selectedBroker
? "border border-primary/30 bg-primary/10 text-primary"
: "border border-border/60 bg-background/40 text-muted-foreground"
}`}
>
<p className="text-xs uppercase tracking-[0.18em]">Step 2</p>
<p className="mt-1 font-medium">Enter broker details</p>
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/70 p-4">
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-border/60 bg-gradient-to-r from-primary/10 via-background to-background px-4 py-4">
<div className="flex items-start gap-3">
<div className="rounded-full bg-primary/15 p-2 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">Secure brokerage linking</p>
<p className="leading-6 text-muted-foreground">
The setup keeps your execution on the broker side while QuantFortune only syncs holdings,
funds, and live strategy status.
</p>
</div>
</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 ${brokerLabel}`}{" "}
via {brokerLabel}
{" - "}
{connectedAt ? connectedAt.toLocaleString() : "just now"}
</span>
</div>
) : null}
</div>
<AnimatePresence mode="wait">
{!selectedBroker ? (
<motion.div
key="broker-picker"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="space-y-5"
>
<div className="space-y-1">
<p className="text-lg font-semibold">Choose a broker to connect</p>
<p className="text-sm text-muted-foreground">
Pick the broker you want to authorize now. You can change or reconnect later from the same dashboard.
</p>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{brokerChoices.map((choice) => (
<BrokerChoiceCard
key={choice.key}
choice={choice}
onSelect={setSelectedBroker}
/>
))}
</div>
<div className="rounded-2xl border border-dashed border-border/50 bg-background/50 p-4">
<p className="text-sm font-medium text-foreground">More brokers on the way</p>
<div className="mt-3 flex flex-wrap gap-2 text-sm text-muted-foreground">
<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>
</motion.div>
) : (
<motion.div
key={`broker-details-${selectedBroker}`}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="space-y-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<Button
variant="ghost"
className="h-auto px-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={() => setSelectedBroker(null)}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Back to broker selection
</Button>
<Badge className={selectedChoice?.accentClass}>{selectedChoice?.title}</Badge>
</div>
{renderSelectedBrokerForm()}
</motion.div>
)}
</AnimatePresence>
{connected ? (
<div className="space-y-2 rounded-lg border border-border/60 bg-background/80 p-4">
<div className="mt-5 space-y-3 rounded-2xl 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">
<ShieldCheck className="h-4 w-4 text-primary" />
@ -565,6 +791,7 @@ export default function BrokerConnectDialog({
</Button>
</div>
</div>
{holdings.length === 0 ? (
<p className="text-xs text-muted-foreground">
No holdings pulled yet. Click &ldquo;Fetch holdings&rdquo; after connecting.