diff --git a/apps/desktop/src/calendar/components/oauth/provider-content.tsx b/apps/desktop/src/calendar/components/oauth/provider-content.tsx index d94c907953..34bc5d07ce 100644 --- a/apps/desktop/src/calendar/components/oauth/provider-content.tsx +++ b/apps/desktop/src/calendar/components/oauth/provider-content.tsx @@ -17,12 +17,14 @@ import { useAuth } from "~/auth"; import { useBillingAccess } from "~/auth/billing"; import { useConnections } from "~/auth/useConnections"; import type { CalendarProvider } from "~/calendar/components/shared"; -import { openIntegrationUrl } from "~/shared/integration"; +import { useOAuthFlow } from "~/shared/hooks/useOAuthFlow"; +import { buildIntegrationUrl } from "~/shared/integration"; export function OAuthProviderContent({ config }: { config: CalendarProvider }) { const auth = useAuth(); const { isPro, upgradeToPro } = useBillingAccess(); const { data: connections, isError } = useConnections(isPro); + const { start: startOAuthFlow } = useOAuthFlow(); const providerConnections = useMemo( () => connections?.filter( @@ -31,16 +33,20 @@ export function OAuthProviderContent({ config }: { config: CalendarProvider }) { [connections, config.nangoIntegrationId], ); - const handleAddAccount = useCallback( - () => - openIntegrationUrl( - config.nangoIntegrationId, - undefined, - "connect", - "calendar", - ), - [config.nangoIntegrationId], - ); + const handleAddAccount = useCallback(async () => { + const url = await buildIntegrationUrl( + config.nangoIntegrationId, + undefined, + "connect", + "calendar", + ); + if (!url) return; + await startOAuthFlow({ + url, + title: `Connect ${config.displayName} Calendar`, + description: `Complete the connection in your browser, then return to Char.`, + }); + }, [config.nangoIntegrationId, config.displayName, startOAuthFlow]); if (!auth.session) { return ( @@ -86,22 +92,34 @@ export function OAuthProviderContent({ config }: { config: CalendarProvider }) { - openIntegrationUrl( + onReconnect={async () => { + const url = await buildIntegrationUrl( config.nangoIntegrationId, connection.connection_id, "reconnect", "calendar", - ) - } - onDisconnect={() => - openIntegrationUrl( + ); + if (!url) return; + await startOAuthFlow({ + url, + title: `Reconnect ${config.displayName} Calendar`, + description: `Complete the reconnection in your browser, then return to Char.`, + }); + }} + onDisconnect={async () => { + const url = await buildIntegrationUrl( config.nangoIntegrationId, connection.connection_id, "disconnect", "calendar", - ) - } + ); + if (!url) return; + await startOAuthFlow({ + url, + title: `Disconnect ${config.displayName} Calendar`, + description: `Complete the disconnection in your browser, then return to Char.`, + }); + }} errorDescription={connection.last_error_description ?? null} /> ))} @@ -183,6 +201,7 @@ function ConnectedContent({ }) { const { groups, connectionSourceMap, handleToggle, isLoading } = useOAuthCalendarSelection(config); + const { start: startOAuthFlow } = useOAuthFlow(); const groupsWithMenus = useMemo( () => @@ -201,29 +220,50 @@ function ConnectedContent({ { id: `reconnect-${connection.connection_id}`, text: "Reconnect", - action: () => - void openIntegrationUrl( + action: async () => { + const url = await buildIntegrationUrl( config.nangoIntegrationId, connection.connection_id, "reconnect", "calendar", - ), + ); + if (!url) return; + await startOAuthFlow({ + url, + title: `Reconnect ${config.displayName} Calendar`, + description: `Complete the reconnection in your browser, then return to Char.`, + }); + }, }, { id: `disconnect-${connection.connection_id}`, text: "Disconnect", - action: () => - void openIntegrationUrl( + action: async () => { + const url = await buildIntegrationUrl( config.nangoIntegrationId, connection.connection_id, "disconnect", "calendar", - ), + ); + if (!url) return; + await startOAuthFlow({ + url, + title: `Disconnect ${config.displayName} Calendar`, + description: `Complete the disconnection in your browser, then return to Char.`, + }); + }, }, ], }; }), - [config.nangoIntegrationId, connectionSourceMap, connections, groups], + [ + config.nangoIntegrationId, + config.displayName, + connectionSourceMap, + connections, + groups, + startOAuthFlow, + ], ); return ( diff --git a/apps/desktop/src/routeTree.gen.ts b/apps/desktop/src/routeTree.gen.ts index 39396d3608..7b816dfd22 100644 --- a/apps/desktop/src/routeTree.gen.ts +++ b/apps/desktop/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AppRouteRouteImport } from './routes/app/route' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' import { Route as AppControlRouteImport } from './routes/app/control' +import { Route as AppBrowserPendingRouteImport } from './routes/app/browser-pending' import { Route as AppMainLayoutRouteImport } from './routes/app/main/_layout' import { Route as AppMainLayoutIndexRouteImport } from './routes/app/main/_layout.index' @@ -30,6 +31,11 @@ const AppControlRoute = AppControlRouteImport.update({ path: '/control', getParentRoute: () => AppRouteRoute, } as any) +const AppBrowserPendingRoute = AppBrowserPendingRouteImport.update({ + id: '/browser-pending', + path: '/browser-pending', + getParentRoute: () => AppRouteRoute, +} as any) const AppMainLayoutRoute = AppMainLayoutRouteImport.update({ id: '/main/_layout', path: '/main', @@ -43,6 +49,7 @@ const AppMainLayoutIndexRoute = AppMainLayoutIndexRouteImport.update({ export interface FileRoutesByFullPath { '/app': typeof AppRouteRouteWithChildren + '/app/browser-pending': typeof AppBrowserPendingRoute '/app/control': typeof AppControlRoute '/auth/callback': typeof AuthCallbackRoute '/app/main': typeof AppMainLayoutRouteWithChildren @@ -50,6 +57,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/app': typeof AppRouteRouteWithChildren + '/app/browser-pending': typeof AppBrowserPendingRoute '/app/control': typeof AppControlRoute '/auth/callback': typeof AuthCallbackRoute '/app/main': typeof AppMainLayoutIndexRoute @@ -57,6 +65,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/app': typeof AppRouteRouteWithChildren + '/app/browser-pending': typeof AppBrowserPendingRoute '/app/control': typeof AppControlRoute '/auth/callback': typeof AuthCallbackRoute '/app/main/_layout': typeof AppMainLayoutRouteWithChildren @@ -66,15 +75,22 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/app' + | '/app/browser-pending' | '/app/control' | '/auth/callback' | '/app/main' | '/app/main/' fileRoutesByTo: FileRoutesByTo - to: '/app' | '/app/control' | '/auth/callback' | '/app/main' + to: + | '/app' + | '/app/browser-pending' + | '/app/control' + | '/auth/callback' + | '/app/main' id: | '__root__' | '/app' + | '/app/browser-pending' | '/app/control' | '/auth/callback' | '/app/main/_layout' @@ -109,6 +125,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppControlRouteImport parentRoute: typeof AppRouteRoute } + '/app/browser-pending': { + id: '/app/browser-pending' + path: '/browser-pending' + fullPath: '/app/browser-pending' + preLoaderRoute: typeof AppBrowserPendingRouteImport + parentRoute: typeof AppRouteRoute + } '/app/main/_layout': { id: '/app/main/_layout' path: '/main' @@ -139,11 +162,13 @@ const AppMainLayoutRouteWithChildren = AppMainLayoutRoute._addFileChildren( ) interface AppRouteRouteChildren { + AppBrowserPendingRoute: typeof AppBrowserPendingRoute AppControlRoute: typeof AppControlRoute AppMainLayoutRoute: typeof AppMainLayoutRouteWithChildren } const AppRouteRouteChildren: AppRouteRouteChildren = { + AppBrowserPendingRoute: AppBrowserPendingRoute, AppControlRoute: AppControlRoute, AppMainLayoutRoute: AppMainLayoutRouteWithChildren, } diff --git a/apps/desktop/src/routes/app/browser-pending.tsx b/apps/desktop/src/routes/app/browser-pending.tsx new file mode 100644 index 0000000000..00d638e989 --- /dev/null +++ b/apps/desktop/src/routes/app/browser-pending.tsx @@ -0,0 +1,54 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; + +import { commands as windowsCommands } from "@hypr/plugin-windows"; + +export const Route = createFileRoute("/app/browser-pending")({ + validateSearch: (search): { title: string; description: string } => ({ + title: String((search as { title?: string }).title ?? ""), + description: String((search as { description?: string }).description ?? ""), + }), + component: BrowserPendingRoute, +}); + +function BrowserPendingRoute() { + const { title, description } = Route.useSearch(); + const navigate = useNavigate(); + + const handleCancel = useCallback(async () => { + await windowsCommands.windowRestoreFrameAnimated({ type: "main" }); + await navigate({ to: "/app/main" }); + }, [navigate]); + + return ( +
+ + +
+

{title}

+

{description}

+
+ +
+
+
+
+
+ + +
+ ); +} diff --git a/apps/desktop/src/settings/general/account.tsx b/apps/desktop/src/settings/general/account.tsx index 96eef56495..3971e7a5ec 100644 --- a/apps/desktop/src/settings/general/account.tsx +++ b/apps/desktop/src/settings/general/account.tsx @@ -18,6 +18,7 @@ import { import { canStartTrial as canStartTrialApi, + deleteAccount as deleteAccountApi, startTrial, } from "@hypr/api-client"; import { createClient } from "@hypr/api-client/client"; @@ -30,16 +31,15 @@ import { type TierAction, } from "@hypr/pricing"; import { Button } from "@hypr/ui/components/ui/button"; -import { Input } from "@hypr/ui/components/ui/input"; import { cn } from "@hypr/utils"; import { useAuth } from "~/auth"; import { useBillingAccess } from "~/auth/billing"; import { env } from "~/env"; import { configureProSettings } from "~/shared/config/configure-pro-settings"; +import { useOAuthFlow } from "~/shared/hooks/useOAuthFlow"; +import { buildWebAppUrl } from "~/shared/utils"; import * as settings from "~/store/tinybase/store/settings"; - -const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000"; const ACCOUNT_FEATURES = [ { label: "Cloud Services", @@ -66,25 +66,18 @@ export function SettingsAccount() { const auth = useAuth(); const { plan, isPaid, isPro, isTrialing, trialDaysRemaining } = useBillingAccess(); + const { start: startOAuthFlow } = useOAuthFlow(); const isAuthenticated = !!auth?.session; - const [isPending, setIsPending] = useState(false); - const [callbackUrl, setCallbackUrl] = useState(""); - - useEffect(() => { - if (isAuthenticated) { - setIsPending(false); - } - }, [isAuthenticated]); const handleSignIn = useCallback(async () => { - setIsPending(true); - try { - await auth?.signIn(); - } catch { - setIsPending(false); - } - }, [auth]); + const url = await buildWebAppUrl("/auth"); + await startOAuthFlow({ + url, + title: "Sign in to Char", + description: "Complete sign-in in your browser, then return to Char.", + }); + }, [startOAuthFlow]); const signOutMutation = useMutation({ mutationFn: async () => { @@ -102,46 +95,6 @@ export function SettingsAccount() { }); if (!isAuthenticated) { - if (isPending) { - return ( -
-
-

Account

- - Reopen sign-in page - - } - > -
-

- Having trouble? Paste the callback URL manually. -

-
- setCallbackUrl(e.target.value)} - /> - -
-
-
-
-
- ); - } - return (
@@ -203,6 +156,64 @@ export function SettingsAccount() { isPaid={isPaid} isPro={isPro} /> + + +
+ ); +} + +function DeleteAccountSection() { + const auth = useAuth(); + const [confirmed, setConfirmed] = useState(false); + + const deleteAccountMutation = useMutation({ + mutationFn: async () => { + const headers = auth?.getHeaders(); + if (!headers) { + throw new Error("Not authenticated"); + } + const client = createClient({ baseUrl: env.VITE_API_URL, headers }); + const { error } = await deleteAccountApi({ client }); + if (error) { + throw error; + } + await auth?.signOut(); + }, + }); + + return ( +
+

Danger Zone

+ deleteAccountMutation.mutate()} + disabled={deleteAccountMutation.isPending} + className={cn([ + "border-red-300 bg-red-50 text-red-700 hover:border-red-400 hover:bg-red-100 hover:text-red-800", + ])} + > + {deleteAccountMutation.isPending + ? "Deleting..." + : "Confirm delete"} + + ) : ( + + ) + } + />
); } @@ -299,7 +310,7 @@ function PlanBillingSection({ const isUpgrade = action.style === "upgrade"; - const handleClick = () => { + const handleClick = async () => { if (action.label === "Start free trial") { startTrialMutation.mutate(); return; @@ -312,13 +323,17 @@ function PlanBillingSection({ }); if (isPaid && action.targetPlan) { - openUrl( - `${WEB_APP_BASE_URL}/app/switch-plan?targetPlan=${action.targetPlan}&targetPeriod=monthly`, - ); + const url = await buildWebAppUrl("/app/switch-plan", { + targetPlan: action.targetPlan, + targetPeriod: "monthly", + }); + openUrl(url); } else { - openUrl( - `${WEB_APP_BASE_URL}/app/checkout?plan=${action.targetPlan}&period=monthly`, - ); + const url = await buildWebAppUrl("/app/checkout", { + plan: action.targetPlan, + period: "monthly", + }); + openUrl(url); } }; @@ -375,7 +390,10 @@ function PlanBillingSection({ {isPaid && ( -
- -
- { - if (!value) { - setShowDeleteConfirm(false); - deleteAccountMutation.reset(); - } - }} - > - - - Delete account - - -
-

- Char is a local-first app. Your notes, transcripts, and - meeting data stay on your device. Deleting your account only - removes cloud-stored data. -

- - {showDeleteConfirm ? ( -
-

- This permanently deletes your account and cloud data. -

- - {deleteAccountMutation.isError && ( -

- {deleteAccountMutation.error?.message || - "Failed to delete account"} -

- )} - -
- - -
-
- ) : ( - - )} -
-
-
-
-
-
- ); -} diff --git a/apps/web/src/routes/_view/app/-account-integrations.tsx b/apps/web/src/routes/_view/app/-account-integrations.tsx deleted file mode 100644 index 8d29efa65c..0000000000 --- a/apps/web/src/routes/_view/app/-account-integrations.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { Link, useNavigate } from "@tanstack/react-router"; -import { ChevronDown, PlusIcon } from "lucide-react"; - -import type { ConnectionItem, WhoAmIItem } from "@hypr/api-client"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@hypr/ui/components/ui/dropdown-menu"; - -import { useBilling } from "@/hooks/use-billing"; -import { useConnections } from "@/hooks/use-connections"; -import { useWhoAmI } from "@/hooks/use-whoami"; - -const INTEGRATIONS = [ - { id: "google-calendar", name: "Google Calendar" }, - { id: "outlook", name: "Outlook Calendar" }, -] as const; - -export function IntegrationsSettingsCard() { - const navigate = useNavigate(); - const { isPaid } = useBilling(); - const { data: connections, isLoading } = useConnections(isPaid); - const { data: accounts } = useWhoAmI(isPaid); - - const getProviderConnections = (integrationId: string) => { - return connections?.filter((c) => c.integration_id === integrationId) ?? []; - }; - - const getAccountInfo = (connectionId: string) => { - return accounts?.find((a) => a.connection_id === connectionId); - }; - - return ( -
-
-

Integrations

-

- Connect third-party services to enhance your experience -

-
- - {INTEGRATIONS.map((integration) => { - const providerConnections = getProviderConnections(integration.id); - - return ( -
-
-
-
{integration.name}
-
- - {!isPaid ? ( - - Upgrade - - ) : isLoading ? ( - - ) : ( - - )} -
- - {providerConnections.length > 0 && ( -
- {providerConnections.map((connection) => ( - - ))} -
- )} -
- ); - })} -
- ); -} - -function ConnectionRow({ - connection, - integrationId, - account, -}: { - connection: ConnectionItem; - integrationId: string; - account?: WhoAmIItem; -}) { - const navigate = useNavigate(); - const isReconnectRequired = connection.status === "reconnect_required"; - const displayLabel = - account?.email ?? account?.display_name ?? connection.connection_id; - - return ( -
-
- - {displayLabel} - {isReconnectRequired && ( - Reconnect required - )} -
- - - - - - - - navigate({ - to: "/app/integration/", - search: { - flow: "web", - integration_id: integrationId, - action: "reconnect", - connection_id: connection.connection_id, - }, - }) - } - > - Reconnect - - - navigate({ - to: "/app/integration/", - search: { - flow: "web", - action: "disconnect", - integration_id: integrationId, - connection_id: connection.connection_id, - }, - }) - } - className="text-red-600 focus:text-red-600" - > - Disconnect - - - -
- ); -} diff --git a/apps/web/src/routes/_view/app/-account-profile-info.tsx b/apps/web/src/routes/_view/app/-account-profile-info.tsx deleted file mode 100644 index 13d65f33cf..0000000000 --- a/apps/web/src/routes/_view/app/-account-profile-info.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { useState } from "react"; - -import { updateUserEmail } from "@/functions/auth"; - -export function ProfileInfoSection({ email }: { email?: string }) { - const [isEditing, setIsEditing] = useState(false); - const [newEmail, setNewEmail] = useState(""); - const [successMessage, setSuccessMessage] = useState(null); - - const updateEmailMutation = useMutation({ - mutationFn: async (email: string) => { - const res = await updateUserEmail({ data: { email } }); - if ("error" in res && res.error) { - throw new Error(res.error); - } - return res; - }, - onSuccess: (data) => { - if ("message" in data && data.message) { - setSuccessMessage(data.message); - } - setIsEditing(false); - setNewEmail(""); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (newEmail && newEmail !== email) { - updateEmailMutation.mutate(newEmail); - } - }; - - const handleCancel = () => { - setIsEditing(false); - setNewEmail(""); - updateEmailMutation.reset(); - }; - - return ( -
-
-

Profile

-

Your personal information

-
- -
-
-
Email
- {isEditing ? ( -
-
- setNewEmail(e.target.value)} - placeholder={email || "Enter new email"} - className="flex-1 rounded-md border border-neutral-200 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-stone-500 focus:outline-none" - autoFocus - /> -
- {updateEmailMutation.isError && ( -

- {updateEmailMutation.error?.message || - "Failed to update email"} -

- )} -
- - -
-
- ) : ( -
-
{email || "Not available"}
- -
- )} -
- - {successMessage && ( -
-

{successMessage}

-
- )} -
-
- ); -} diff --git a/apps/web/src/routes/_view/app/-account-settings.tsx b/apps/web/src/routes/_view/app/-account-settings.tsx deleted file mode 100644 index b79521bd01..0000000000 --- a/apps/web/src/routes/_view/app/-account-settings.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; - -import type { PlanTierData, TierAction } from "@hypr/pricing"; -import { PlanGrid } from "@hypr/pricing/ui"; -import { cn } from "@hypr/utils"; - -import { - canStartTrial, - createPlanSwitchSession, - createPortalSession, - startTrial, -} from "@/functions/billing"; -import { useBilling } from "@/hooks/use-billing"; - -export function AccountSettingsCard() { - const billing = useBilling(); - - const currentTier = billing.plan === "trial" ? "pro" : billing.plan; - - const canTrialQuery = useQuery({ - queryKey: ["canStartTrial"], - queryFn: () => canStartTrial(), - enabled: billing.isReady && billing.plan === "free", - }); - - const manageBillingMutation = useMutation({ - mutationFn: async () => { - const { url } = await createPortalSession(); - if (url) { - window.location.href = url; - } - }, - }); - - const switchPlanMutation = useMutation({ - mutationFn: async (targetPlan: "lite" | "pro") => { - const { url } = await createPlanSwitchSession({ - data: { targetPlan, targetPeriod: "monthly" }, - }); - if (url) { - window.location.href = url; - } - }, - }); - - const startTrialMutation = useMutation({ - mutationFn: () => startTrial(), - onSuccess: () => { - billing.refreshBilling(); - canTrialQuery.refetch(); - }, - }); - - const isLoading = - !billing.isReady || (billing.plan === "free" && canTrialQuery.isLoading); - - if (isLoading) { - return ( -
-

- Plan & Billing -

-

Loading...

-
- ); - } - - return ( - ( - - )} - renderAction={(_tier: PlanTierData, action: TierAction) => { - if (action == null) return null; - - if (action.style === "current") { - return ( -
- {action.label} -
- ); - } - - const buttonClass = cn([ - "flex h-8 w-full items-center justify-center rounded-full text-xs font-medium transition-all hover:scale-[102%] active:scale-[98%]", - action.style === "upgrade" - ? "bg-linear-to-t from-stone-600 to-stone-500 text-white shadow-md hover:shadow-lg" - : "border border-neutral-300 bg-linear-to-b from-white to-stone-50 text-neutral-700 shadow-xs hover:shadow-md", - ]); - - if (action.targetPlan && currentTier === "free") { - if (action.label === "Start free trial") { - return ( - - ); - } - return ( - - {action.label} - - ); - } - - return ( - - ); - }} - /> - ); -} 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 1d712edeb2..8129536965 100644 --- a/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx +++ b/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx @@ -49,10 +49,10 @@ export function UpgradePrompt({ ) : ( - Back to account + Back )}
diff --git a/apps/web/src/routes/_view/app/account.tsx b/apps/web/src/routes/_view/app/account.tsx index f6dba122f9..9b4c2448c0 100644 --- a/apps/web/src/routes/_view/app/account.tsx +++ b/apps/web/src/routes/_view/app/account.tsx @@ -1,91 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { z } from "zod"; - -import { desktopSchemeSchema } from "@/functions/desktop-flow"; - -import { AccountAccessSection } from "./-account-access"; -import { IntegrationsSettingsCard } from "./-account-integrations"; -import { ProfileInfoSection } from "./-account-profile-info"; -import { AccountSettingsCard } from "./-account-settings"; - -const validateSearch = z - .object({ - success: z.coerce.boolean(), - trial: z.enum(["started"]), - scheme: desktopSchemeSchema, - }) - .partial(); export const Route = createFileRoute("/_view/app/account")({ - validateSearch, - component: Component, - loader: async ({ context }) => ({ user: context.user }), + component: () => null, }); - -function Component() { - const { user } = Route.useLoaderData(); - const search = Route.useSearch(); - - useEffect(() => { - if ((search.success || search.trial === "started") && search.scheme) { - window.location.href = `${search.scheme}://billing/refresh`; - } - }, [search.success, search.trial, search.scheme]); - - return ( -
-
-
-

- Welcome back {user?.email?.split("@")[0] || "Guest"} -

-
- -
-
-
-

- Account -

-
-

- Profile, billing, and connected services -

-

- Update the essentials without burying routine settings behind - destructive actions. -

-
-
- -
- - - -
-
- -
-
-

- Access -

-
-

- Session controls -

-

- Sign out quickly, while keeping account deletion tucked behind - an extra deliberate step. -

-
-
- - -
-
-
-
- ); -} diff --git a/apps/web/src/routes/_view/app/checkout.tsx b/apps/web/src/routes/_view/app/checkout.tsx index 2357820422..44b40b9aec 100644 --- a/apps/web/src/routes/_view/app/checkout.tsx +++ b/apps/web/src/routes/_view/app/checkout.tsx @@ -13,6 +13,10 @@ const validateSearch = z.object({ export const Route = createFileRoute("/_view/app/checkout")({ validateSearch, beforeLoad: async ({ search }) => { + if (!search.scheme) { + throw redirect({ to: "/download/" }); + } + const { url } = await createCheckoutSession({ data: { period: search.period, plan: search.plan, scheme: search.scheme }, }); @@ -21,6 +25,6 @@ export const Route = createFileRoute("/_view/app/checkout")({ throw redirect({ href: url } as any); } - throw redirect({ to: "/app/account/" }); + throw redirect({ to: "/" }); }, }); diff --git a/apps/web/src/routes/_view/app/index.tsx b/apps/web/src/routes/_view/app/index.tsx index 4c9323529e..1e4c52409a 100644 --- a/apps/web/src/routes/_view/app/index.tsx +++ b/apps/web/src/routes/_view/app/index.tsx @@ -3,7 +3,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/_view/app/")({ beforeLoad: async () => { throw redirect({ - to: "/app/account/", + to: "/", }); }, }); diff --git a/apps/web/src/routes/_view/app/portal.tsx b/apps/web/src/routes/_view/app/portal.tsx new file mode 100644 index 0000000000..448b1a4461 --- /dev/null +++ b/apps/web/src/routes/_view/app/portal.tsx @@ -0,0 +1,28 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createPortalSession } from "@/functions/billing"; +import { desktopSchemeSchema } from "@/functions/desktop-flow"; + +const validateSearch = z.object({ + scheme: desktopSchemeSchema.optional(), +}); + +export const Route = createFileRoute("/_view/app/portal")({ + validateSearch, + beforeLoad: async ({ search }) => { + if (!search.scheme) { + throw redirect({ to: "/download/" }); + } + + const { url } = await createPortalSession({ + data: { scheme: search.scheme }, + }); + + if (url) { + throw redirect({ href: url } as any); + } + + throw redirect({ to: "/" }); + }, +}); diff --git a/apps/web/src/routes/_view/app/switch-plan.tsx b/apps/web/src/routes/_view/app/switch-plan.tsx index 0d566f5d8d..b8c04f164e 100644 --- a/apps/web/src/routes/_view/app/switch-plan.tsx +++ b/apps/web/src/routes/_view/app/switch-plan.tsx @@ -13,6 +13,10 @@ const validateSearch = z.object({ export const Route = createFileRoute("/_view/app/switch-plan")({ validateSearch, beforeLoad: async ({ search }) => { + if (!search.scheme) { + throw redirect({ to: "/download/" }); + } + const { url } = await createPlanSwitchSession({ data: { targetPlan: search.targetPlan, @@ -25,6 +29,6 @@ export const Route = createFileRoute("/_view/app/switch-plan")({ throw redirect({ href: url } as any); } - throw redirect({ to: "/app/account/" }); + throw redirect({ to: "/" }); }, }); diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index 438e1388e7..cb8259f4e1 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -49,11 +49,19 @@ export const Route = createFileRoute("/_view/callback/auth")({ throw redirect({ to: "/update-password/", search: {} }); } throw redirect({ - to: search.redirect || "/app/account/", + to: search.redirect || "/", search: {}, }); } else { - console.error(result.error); + throw redirect({ + to: "/callback/auth/", + search: { + flow: "web", + scheme: search.scheme, + error: "exchange_failed", + error_description: result.error ?? undefined, + }, + }); } } @@ -73,7 +81,15 @@ export const Route = createFileRoute("/_view/callback/auth")({ }, }); } else { - console.error(result.error); + throw redirect({ + to: "/callback/auth/", + search: { + flow: "desktop", + scheme: search.scheme, + error: "exchange_failed", + error_description: result.error ?? undefined, + }, + }); } } @@ -90,7 +106,15 @@ export const Route = createFileRoute("/_view/callback/auth")({ if (result.success) { throw redirect({ to: "/update-password/", search: {} }); } else { - console.error(result.error); + throw redirect({ + to: "/callback/auth/", + search: { + flow: search.flow, + scheme: search.scheme, + error: "exchange_failed", + error_description: result.error ?? undefined, + }, + }); } } else { const result = await exchangeOtpToken({ @@ -104,7 +128,7 @@ export const Route = createFileRoute("/_view/callback/auth")({ if (result.success) { if (search.flow === "web") { throw redirect({ - to: search.redirect || "/app/account/", + to: search.redirect || "/", search: {}, }); } @@ -121,7 +145,15 @@ export const Route = createFileRoute("/_view/callback/auth")({ }); } } else { - console.error(result.error); + throw redirect({ + to: "/callback/auth/", + search: { + flow: search.flow, + scheme: search.scheme, + error: "exchange_failed", + error_description: result.error ?? undefined, + }, + }); } } } @@ -184,7 +216,7 @@ function Component() { useEffect(() => { if (search.flow === "web" && !search.error) { navigate({ - to: search.redirect || "/app/account/", + to: search.redirect || "/", search: {}, replace: true, }); diff --git a/apps/web/src/routes/_view/callback/billing.tsx b/apps/web/src/routes/_view/callback/billing.tsx new file mode 100644 index 0000000000..ab084a9c4b --- /dev/null +++ b/apps/web/src/routes/_view/callback/billing.tsx @@ -0,0 +1,102 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { z } from "zod"; + +import { cn } from "@hypr/utils"; + +import { desktopSchemeSchema } from "@/functions/desktop-flow"; + +const validateSearch = z.object({ + scheme: desktopSchemeSchema.optional(), +}); + +export const Route = createFileRoute("/_view/callback/billing")({ + validateSearch, + component: Component, + head: () => ({ + meta: [{ name: "robots", content: "noindex, nofollow" }], + }), +}); + +function Component() { + const search = Route.useSearch(); + const scheme = search.scheme ?? "hyprnote"; + const [copied, setCopied] = useState(false); + + const deeplink = `${scheme}://billing/refresh`; + + const handleDeeplink = () => { + window.location.href = deeplink; + }; + + const handleCopy = async () => { + await navigator.clipboard.writeText(deeplink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + useEffect(() => { + const timer = setTimeout(() => { + window.location.href = deeplink; + }, 250); + return () => clearTimeout(timer); + }, [deeplink]); + + return ( +
+
+
+

+ Returning to Char +

+

+ Click the button below if the app does not open automatically +

+
+ +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/routes/_view/callback/integration.tsx b/apps/web/src/routes/_view/callback/integration.tsx index 5283b0d0c2..4128c5aa39 100644 --- a/apps/web/src/routes/_view/callback/integration.tsx +++ b/apps/web/src/routes/_view/callback/integration.tsx @@ -82,7 +82,7 @@ function Component() { void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === "integration-status", }); - void navigate({ to: "/app/account/" }); + void navigate({ to: "/" }); } }, [search.flow, navigate, queryClient]); diff --git a/apps/web/src/routes/_view/pricing.tsx b/apps/web/src/routes/_view/pricing.tsx index 95f9056d8c..3d652cd52f 100644 --- a/apps/web/src/routes/_view/pricing.tsx +++ b/apps/web/src/routes/_view/pricing.tsx @@ -259,33 +259,17 @@ function PricingCard({ plan }: { plan: PricingPlan }) {
- {plan.price ? ( - - Get Started - - ) : ( - - Download for free - - )} + + {plan.price ? "Get Started" : "Download for free"} +
diff --git a/apps/web/src/routes/auth.tsx b/apps/web/src/routes/auth.tsx index 8ee38bf9ac..f0df8cf186 100644 --- a/apps/web/src/routes/auth.tsx +++ b/apps/web/src/routes/auth.tsx @@ -40,7 +40,7 @@ export const Route = createFileRoute("/auth")({ search.flow === "web" && !!search.provider; if (search.flow === "web" && !shouldReauthWithProvider) { - throw redirect({ to: search.redirect || "/app/account/" } as any); + throw redirect({ to: search.redirect || "/" } as any); } if (search.flow === "desktop") { @@ -557,7 +557,7 @@ function handlePasswordSuccess( params.set("refresh_token", refreshToken); window.location.href = `/callback/auth?${params.toString()}`; } else { - window.location.href = redirectPath || "/app/account/"; + window.location.href = redirectPath || "/"; } } diff --git a/packages/pricing/src/plan-grid.tsx b/packages/pricing/src/plan-grid.tsx index 64ae8d769e..7c9f341acf 100644 --- a/packages/pricing/src/plan-grid.tsx +++ b/packages/pricing/src/plan-grid.tsx @@ -1,4 +1,4 @@ -import { CheckCircle2, Construction, XCircle } from "lucide-react"; +import { CheckCircle2, Construction, RefreshCw, XCircle } from "lucide-react"; import type { ReactNode } from "react"; import { cn } from "@hypr/utils"; @@ -19,6 +19,7 @@ export function PlanGrid({ isPaid, renderAction, renderManageBilling, + onRefresh, }: { currentPlan: PlanTier; isTrialing: boolean; @@ -27,6 +28,7 @@ export function PlanGrid({ isPaid: boolean; renderAction: (tier: PlanTierData, action: TierAction) => ReactNode; renderManageBilling?: () => ReactNode; + onRefresh?: () => void; }) { const statusText = isTrialing ? `Pro trial${trialDaysRemaining != null ? ` \u2014 ${trialDaysRemaining} day${trialDaysRemaining === 1 ? "" : "s"} left` : ""}` @@ -39,7 +41,19 @@ export function PlanGrid({

Plan & Billing

-

{statusText}

+
+

{statusText}

+ {onRefresh && ( + + )} +
{isPaid && renderManageBilling?.()}