Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/desktop/src/components/onboarding/final.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Route } from "../../routes/app/onboarding/_layout.index";
import * as settings from "../../store/tinybase/store/settings";
import { commands } from "../../types/tauri.gen";
import { configureProSettings } from "../../utils";
import { pollForTrialActivation } from "../../utils/poll-trial-activation";
import { getBack, type StepProps } from "./config";
import { OnboardingContainer } from "./shared";

Expand Down Expand Up @@ -51,9 +52,14 @@ export function Final({ onNavigate }: StepProps) {
const started = await tryStartTrial(headers, store);
setTrialStarted(started);
if (started) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const result = await pollForTrialActivation({
refreshSession: () => auth.refreshSession(),
signal: abortController.signal,
});
if (result.status === "aborted") return;
} else {
await auth.refreshSession();
}
await auth.refreshSession();
} catch (e) {
Sentry.captureException(e);
console.error(e);
Expand All @@ -62,7 +68,12 @@ export function Final({ onNavigate }: StepProps) {
setIsLoading(false);
};

const abortController = new AbortController();
void handle();

return () => {
abortController.abort();
};
}, [auth, store]);

if (isLoading) {
Expand Down
45 changes: 6 additions & 39 deletions apps/desktop/src/components/settings/general/account.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Brain,
Cloud,
Expand All @@ -9,7 +9,7 @@ import {
} from "lucide-react";
import { type ReactNode, useCallback, useEffect, useState } from "react";

import { getRpcCanStartTrial, postBillingStartTrial } from "@hypr/api-client";
import { getRpcCanStartTrial } from "@hypr/api-client";
import { createClient } from "@hypr/api-client/client";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";
import { type SubscriptionStatus } from "@hypr/plugin-auth";
Expand All @@ -22,6 +22,7 @@ import { cn } from "@hypr/utils";
import { useAuth } from "../../../auth";
import { useBillingAccess } from "../../../billing";
import { env } from "../../../env";
import { useTrialActivation } from "../../../hooks/useTrialActivation";

const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000";

Expand Down Expand Up @@ -333,41 +334,7 @@ function BillingButton() {
},
});

const startTrialMutation = 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 postBillingStartTrial({
client,
query: { interval: "monthly" },
});
if (error) {
throw error;
}

await new Promise((resolve) => setTimeout(resolve, 3000));
},
onSuccess: async () => {
void analyticsCommands.event({
event: "trial_started",
plan: "pro",
});
const trialEndDate = new Date();
trialEndDate.setDate(trialEndDate.getDate() + 14);
void analyticsCommands.setProperties({
email: auth?.session?.user.email,
user_id: auth?.session?.user.id,
set: {
plan: "pro",
trial_end_date: trialEndDate.toISOString(),
},
});
await auth?.refreshSession();
},
});
const { startTrial, isPending: isTrialPending } = useTrialActivation();

const handleProUpgrade = useCallback(() => {
void analyticsCommands.event({
Expand Down Expand Up @@ -401,8 +368,8 @@ function BillingButton() {
return (
<Button
variant="outline"
onClick={() => startTrialMutation.mutate()}
disabled={startTrialMutation.isPending}
onClick={() => startTrial()}
disabled={isTrialPending}
>
<span> Start Pro Trial</span>
</Button>
Expand Down
92 changes: 92 additions & 0 deletions apps/desktop/src/hooks/useTrialActivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";

import { postBillingStartTrial } from "@hypr/api-client";
import { createClient } from "@hypr/api-client/client";
import { commands as analyticsCommands } from "@hypr/plugin-analytics";

import { useAuth } from "../auth";
import { env } from "../env";
import {
pollForTrialActivation,
type PollResult,
} from "../utils/poll-trial-activation";

type UseTrialActivationOptions = {
onActivated?: () => void;
onTimeout?: () => void;
onError?: (error: unknown) => void;
};

export function useTrialActivation(options: UseTrialActivationOptions = {}) {
const auth = useAuth();
const abortControllerRef = useRef<AbortController | null>(null);

useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);

const mutation = useMutation({
mutationFn: async (): Promise<PollResult> => {
const headers = auth?.getHeaders();
if (!headers) {
throw new Error("Not authenticated");
}

const client = createClient({ baseUrl: env.VITE_API_URL, headers });
const { error } = await postBillingStartTrial({
client,
query: { interval: "monthly" },
});
if (error) {
throw error;
}

abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;

return pollForTrialActivation({
refreshSession: () => auth.refreshSession(),
signal: abortController.signal,
});
},
onSuccess: (result) => {
if (result.status === "activated") {
void analyticsCommands.event({ event: "trial_started", plan: "pro" });
const trialEndDate = new Date();
trialEndDate.setDate(trialEndDate.getDate() + 14);
void analyticsCommands.setProperties({
email: auth?.session?.user.email,
user_id: auth?.session?.user.id,
set: {
plan: "pro",
trial_end_date: trialEndDate.toISOString(),
},
});
options.onActivated?.();
} else if (result.status === "timeout") {
options.onTimeout?.();
}
},
onError: (error) => {
options.onError?.(error);
},
});

const cancel = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);

return {
startTrial: mutation.mutate,
startTrialAsync: mutation.mutateAsync,
isPending: mutation.isPending,
isError: mutation.isError,
error: mutation.error,
cancel,
};
}
74 changes: 74 additions & 0 deletions apps/desktop/src/utils/poll-trial-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Session } from "@supabase/supabase-js";

import { commands as authCommands } from "@hypr/plugin-auth";

const INITIAL_DELAY_MS = 1000;
const MAX_DELAY_MS = 5000;
const BACKOFF_FACTOR = 1.5;
const MAX_ATTEMPTS = 10;

export type PollResult =
| { status: "activated"; session: Session }
| { status: "timeout" }
| { status: "aborted" };

type PollOptions = {
refreshSession: () => Promise<Session | null>;
signal?: AbortSignal;
};

export async function pollForTrialActivation(
options: PollOptions,
): Promise<PollResult> {
let delay = INITIAL_DELAY_MS;

for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
if (options.signal?.aborted) {
return { status: "aborted" };
}

try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
if (options.signal) {
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
options.signal.addEventListener("abort", onAbort, { once: true });
}
});
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") {
return { status: "aborted" };
}
throw e;
}

if (options.signal?.aborted) {
return { status: "aborted" };
}

try {
const session = await options.refreshSession();
if (session) {
const result = await authCommands.decodeClaims(session.access_token);
if (result.status === "ok") {
const entitlements = result.data.entitlements ?? [];
if (entitlements.includes("hyprnote_pro")) {
return { status: "activated", session };
}
}
}
} catch (error) {
console.warn(
`Trial activation poll attempt ${attempt + 1} failed:`,
error,
);
}

delay = Math.min(delay * BACKOFF_FACTOR, MAX_DELAY_MS);
}

return { status: "timeout" };
}
Loading