From 195fbe4f630aac73de3e563092a21f3e8d2b0154 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 1 Mar 2026 17:57:26 +0900 Subject: [PATCH] wip Signed-off-by: Yujong Lee --- apps/desktop/src/auth/useConnections.ts | 4 +- .../components/oauth/provider-content.tsx | 38 +- apps/web/src/hooks/use-api-client.ts | 21 ++ apps/web/src/hooks/use-billing.ts | 12 +- apps/web/src/hooks/use-connections.ts | 14 +- .../_view/app/-account-integrations.tsx | 31 +- .../routes/_view/app/-account-settings.tsx | 3 +- .../src/routes/_view/app/-integration-ui.tsx | 56 +++ .../_view/app/-integrations-connect-flow.tsx | 329 ++++++++++-------- .../app/-integrations-disconnect-flow.tsx | 140 +++----- .../app/-integrations-upgrade-prompt.tsx | 85 +++-- apps/web/src/routes/_view/app/integration.tsx | 33 +- 12 files changed, 439 insertions(+), 327 deletions(-) create mode 100644 apps/web/src/hooks/use-api-client.ts create mode 100644 apps/web/src/routes/_view/app/-integration-ui.tsx diff --git a/apps/desktop/src/auth/useConnections.ts b/apps/desktop/src/auth/useConnections.ts index eefeccd96f..aa6fab2d7e 100644 --- a/apps/desktop/src/auth/useConnections.ts +++ b/apps/desktop/src/auth/useConnections.ts @@ -7,7 +7,7 @@ import { useAuth } from "./context"; import { env } from "~/env"; -export function useConnections() { +export function useConnections({ enabled = true }: { enabled?: boolean } = {}) { const auth = useAuth(); const userId = auth?.session?.user.id; @@ -25,6 +25,6 @@ export function useConnections() { } return data?.connections ?? []; }, - enabled: !!userId, + enabled: !!userId && enabled, }); } diff --git a/apps/desktop/src/calendar/components/oauth/provider-content.tsx b/apps/desktop/src/calendar/components/oauth/provider-content.tsx index f8cd0936b0..cb6334e5c3 100644 --- a/apps/desktop/src/calendar/components/oauth/provider-content.tsx +++ b/apps/desktop/src/calendar/components/oauth/provider-content.tsx @@ -13,11 +13,15 @@ import { useBillingAccess } from "~/auth/billing"; import { useConnections } from "~/auth/useConnections"; import type { CalendarProvider } from "~/calendar/components/shared"; import { buildWebAppUrl } from "~/shared/utils"; +import { useTabs } from "~/store/zustand/tabs"; export function OAuthProviderContent({ config }: { config: CalendarProvider }) { const auth = useAuth(); const billing = useBillingAccess(); - const { data: connections, isError } = useConnections(); + const openNew = useTabs((state) => state.openNew); + const { data: connections, isError } = useConnections({ + enabled: billing.isPro, + }); const connection = connections?.find( (c) => c.integration_id === config.nangoIntegrationId, ); @@ -118,16 +122,6 @@ export function OAuthProviderContent({ config }: { config: CalendarProvider }) { ); } - if (isError) { - return ( -
- - Failed to load integration status - -
- ); - } - if (!auth.session) { const connectButton = ( ); } + if (isError) { + return ( +
+ + Failed to load integration status + +
+ ); + } + return (
{!billing.isPro && !isConnected ? (
- Current plan: {planDisplay} + Current plan: {planDisplay}{" "} +
{renderPlanButton()} diff --git a/apps/web/src/routes/_view/app/-integration-ui.tsx b/apps/web/src/routes/_view/app/-integration-ui.tsx new file mode 100644 index 0000000000..502c7fafd2 --- /dev/null +++ b/apps/web/src/routes/_view/app/-integration-ui.tsx @@ -0,0 +1,56 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@hypr/utils"; + +const BUTTON_BASE = + "flex h-12 w-full items-center justify-center gap-2 rounded-full text-base font-medium transition-all"; + +const BUTTON_VARIANTS = { + primary: "bg-linear-to-t from-stone-600 to-stone-500 text-white shadow-md", + danger: "bg-linear-to-t from-red-600 to-red-500 text-white shadow-md", + secondary: + "border border-neutral-300 bg-linear-to-b from-white to-stone-50 text-neutral-700 shadow-xs", +} as const; + +const BUTTON_INTERACTIVE = + "cursor-pointer hover:scale-[102%] hover:shadow-lg active:scale-[98%]"; + +const BUTTON_DISABLED = + "disabled:cursor-not-allowed disabled:opacity-70 disabled:pointer-events-none"; + +export function integrationButtonClassName( + variant: keyof typeof BUTTON_VARIANTS, +) { + return cn([ + BUTTON_BASE, + BUTTON_VARIANTS[variant], + BUTTON_INTERACTIVE, + BUTTON_DISABLED, + ]); +} + +export function IntegrationButton({ + variant = "primary", + className, + ...props +}: { + variant?: keyof typeof BUTTON_VARIANTS; + className?: string; +} & ButtonHTMLAttributes) { + return ( + - )} - - {status === "error" && ( -
-

- Something went wrong. Please try again. -

- -
- )} - - + + + + )} + {isLoading ? "Connecting..." : `Connect ${display.name}`} + + )} + + {isCreateSessionError && !isConnecting && ( +
+

+ Something went wrong. Please try again. +

+ + Try again + +
+ )} + ); } diff --git a/apps/web/src/routes/_view/app/-integrations-disconnect-flow.tsx b/apps/web/src/routes/_view/app/-integrations-disconnect-flow.tsx index 6b14be2beb..e569eb2e6f 100644 --- a/apps/web/src/routes/_view/app/-integrations-disconnect-flow.tsx +++ b/apps/web/src/routes/_view/app/-integrations-disconnect-flow.tsx @@ -1,38 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; import { deleteConnection } from "@hypr/api-client"; -import { createClient } from "@hypr/api-client/client"; -import { cn } from "@hypr/utils"; -import { env } from "@/env"; -import { getAccessToken } from "@/functions/access-token"; +import { useApiClient } from "@/hooks/use-api-client"; +import { IntegrationButton, IntegrationPageLayout } from "./-integration-ui"; import { getIntegrationDisplay, Route } from "./integration"; export function DisconnectFlow() { const search = Route.useSearch(); const navigate = useNavigate(); - const [status, setStatus] = useState< - "idle" | "loading" | "success" | "error" - >("idle"); + const queryClient = useQueryClient(); + const { getClient } = useApiClient(); const display = getIntegrationDisplay(search.integration_id); - const handleDisconnect = async () => { - if (!search.connection_id) { - setStatus("error"); - return; - } - - setStatus("loading"); + const disconnectMutation = useMutation({ + mutationFn: async () => { + if (!search.connection_id) { + throw new Error("Missing connection id"); + } - try { - const token = await getAccessToken(); - const client = createClient({ - baseUrl: env.VITE_API_URL, - headers: { Authorization: `Bearer ${token}` }, - }); + const client = await getClient(); const { data, error } = await deleteConnection({ client, body: { @@ -42,72 +32,58 @@ export function DisconnectFlow() { }); if (error || !data) { - setStatus("error"); - return; + throw new Error("Failed to disconnect integration"); } - } catch { - setStatus("error"); - return; - } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["integration-status"], + }); - setStatus("success"); - void navigate({ - to: "/callback/integration/", - search: { - integration_id: search.integration_id, - status: "success", - flow: search.flow, - scheme: search.scheme, - return_to: search.return_to, - }, - }); - }; + await navigate({ + to: "/callback/integration/", + search: { + integration_id: search.integration_id, + status: "success", + flow: search.flow, + scheme: search.scheme, + return_to: search.return_to, + }, + }); + }, + }); return ( -
-
-
-

- Disconnect {display.name} -

-

- This will stop syncing data from {display.name}. -

-
+ +
+

+ Disconnect {display.name} +

+

+ This will stop syncing data from {display.name}. +

+
- {status !== "error" && ( - - )} + {!disconnectMutation.isError && ( + disconnectMutation.mutate()} + disabled={disconnectMutation.isPending || !search.connection_id} + > + {disconnectMutation.isPending ? "Disconnecting..." : "Disconnect"} + + )} - {status === "error" && ( -
-

- Could not disconnect this integration. Please try again. -

- -
- )} -
-
+ {disconnectMutation.isError && ( +
+

+ Could not disconnect this integration. Please try again. +

+ disconnectMutation.mutate()}> + Try again + +
+ )} + ); } diff --git a/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx b/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx index f52c3fa2ef..570d5febbc 100644 --- a/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx +++ b/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx @@ -1,7 +1,9 @@ import { Link } from "@tanstack/react-router"; -import { cn } from "@hypr/utils"; - +import { + IntegrationPageLayout, + integrationButtonClassName, +} from "./-integration-ui"; import { getIntegrationDisplay } from "./integration"; export function UpgradePrompt({ @@ -16,53 +18,48 @@ export function UpgradePrompt({ const display = getIntegrationDisplay(integrationId); return ( -
-
-
-
-

- {display.name} -

- - Pro - -
-

- Upgrade to Pro to connect {display.name} and other integrations. -

+ +
+
+

+ {display.name} +

+ + Pro +
+

+ Upgrade to Pro to connect {display.name} and other integrations. +

+
+ +
+ + Upgrade to Pro + -
+ {flow === "desktop" ? ( + + ) : ( - Upgrade to Pro + Back to account - - {flow === "desktop" ? ( - - ) : ( - - Back to account - - )} -
+ )}
-
+ ); } diff --git a/apps/web/src/routes/_view/app/integration.tsx b/apps/web/src/routes/_view/app/integration.tsx index 4072833a70..a20c7825d5 100644 --- a/apps/web/src/routes/_view/app/integration.tsx +++ b/apps/web/src/routes/_view/app/integration.tsx @@ -1,8 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; import { z } from "zod"; import { useBilling } from "@/hooks/use-billing"; +import { IntegrationPageLayout } from "./-integration-ui"; import { ConnectFlow } from "./-integrations-connect-flow"; import { DisconnectFlow } from "./-integrations-disconnect-flow"; import { UpgradePrompt } from "./-integrations-upgrade-prompt"; @@ -48,21 +50,34 @@ export const Route = createFileRoute("/_view/app/integration")({ function Component() { const search = Route.useSearch(); const billing = useBilling(); + const desktopRefreshStartedRef = useRef(false); + const [isSyncingDesktopBilling, setIsSyncingDesktopBilling] = useState(false); - if (search.action === "disconnect") { - return ; - } + useEffect(() => { + if (search.flow !== "desktop" || desktopRefreshStartedRef.current) { + return; + } + + desktopRefreshStartedRef.current = true; + setIsSyncingDesktopBilling(true); + void billing + .refreshBilling() + .catch(() => {}) + .finally(() => setIsSyncingDesktopBilling(false)); + }, [billing.refreshBilling, search.flow]); - if (!billing.isReady) { + if (!billing.isReady || isSyncingDesktopBilling) { return ( -
-
-

Loading...

-
-
+ +

Loading...

+
); } + if (search.action === "disconnect") { + return ; + } + if (!billing.isPro) { return (