feat: saas new user modal (#7098)

Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
sp.wack 2025-03-13 19:15:57 +04:00 committed by GitHub
parent 2a7f926591
commit e1f6929d98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 290 additions and 101 deletions

View File

@ -26,33 +26,32 @@ const createAxiosNotFoundErrorObject = () =>
},
);
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const RouterStub = createRoutesStub([
{
// layout route
Component: MainApp,
path: "/",
children: [
{
// home route
Component: Home,
path: "/",
},
{
Component: SettingsScreen,
path: "/settings",
},
],
},
]);
afterEach(() => {
vi.clearAllMocks();
});
describe("Home Screen", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const RouterStub = createRoutesStub([
{
// layout route
Component: MainApp,
path: "/",
children: [
{
// home route
Component: Home,
path: "/",
},
{
Component: SettingsScreen,
path: "/settings",
},
],
},
]);
afterEach(() => {
vi.clearAllMocks();
});
it("should render the home screen", () => {
renderWithProviders(<RouterStub initialEntries={["/"]} />);
});
@ -79,57 +78,82 @@ describe("Home Screen", () => {
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
});
describe("Settings 404", () => {
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
describe("Settings 404", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
renderWithProviders(<RouterStub initialEntries={["/"]} />);
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
renderWithProviders(<RouterStub initialEntries={["/"]} />);
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// TODO: Remove HIDE_LLM_SETTINGS check once released
vi.spyOn(FeatureFlags, "HIDE_LLM_SETTINGS").mockReturnValue(true);
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({ APP_MODE: "saas" });
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
renderWithProviders(<RouterStub initialEntries={["/"]} />);
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// TODO: Remove HIDE_LLM_SETTINGS check once released
vi.spyOn(FeatureFlags, "HIDE_LLM_SETTINGS").mockReturnValue(true);
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({ APP_MODE: "saas" });
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
// small hack to wait for the modal to not appear
await expect(
screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }),
).rejects.toThrow();
});
renderWithProviders(<RouterStub initialEntries={["/"]} />);
// small hack to wait for the modal to not appear
await expect(
screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }),
).rejects.toThrow();
});
});
describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
afterEach(() => {
vi.resetAllMocks();
});
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true);
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const setupPaymentModal = await screen.findByTestId("proceed-to-stripe-button");
expect(setupPaymentModal).toBeInTheDocument();
});
});

View File

@ -281,6 +281,13 @@ class OpenHands {
return data.redirect_url;
}
static async createBillingSessionResponse(): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-customer-setup-session",
);
return data.redirect_url;
}
static async getBalance(): Promise<string> {
const { data } = await openHands.get<{ credits: string }>(
"/api/billing/credits",

View File

@ -48,6 +48,7 @@ export interface GetConfigResponse {
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
}
export interface GetVSCodeUrlResponse {

View File

@ -0,0 +1,48 @@
import { useMutation } from "@tanstack/react-query";
import { Trans, useTranslation } from "react-i18next";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import OpenHands from "#/api/open-hands";
import { BrandButton } from "../settings/brand-button";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
export function SetupPaymentModal() {
const { t } = useTranslation();
const { mutate, isPending } = useMutation({
mutationFn: OpenHands.createBillingSessionResponse,
onSuccess: (data) => {
window.location.href = data;
},
onError: () => {
displayErrorToast(t("BILLING$ERROR_WHILE_CREATING_SESSION"));
},
});
return (
<ModalBackdrop>
<ModalBody className="border border-tertiary">
<AllHandsLogo width={68} height={46} />
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">{t("BILLING$YOUVE_GOT_50")}</h1>
<p>
<Trans
i18nKey="BILLING$CLAIM_YOUR_50"
components={{ b: <strong /> }}
/>
</p>
</div>
<BrandButton
testId="proceed-to-stripe-button"
type="submit"
variant="primary"
className="w-full"
isDisabled={isPending}
onClick={mutate}
>
{t("BILLING$PROCEED_TO_STRIPE")}
</BrandButton>
</ModalBody>
</ModalBackdrop>
);
}

View File

@ -61,7 +61,7 @@ export function Sidebar() {
displayErrorToast(
"Something went wrong while fetching settings. Please reload the page.",
);
} else if (settingsError?.status === 404) {
} else if (config?.APP_MODE === "oss" && settingsError?.status === 404) {
setSettingsModalIsOpen(true);
}
}, [

View File

@ -21,6 +21,7 @@ const getSettingsQueryFn = async () => {
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
IS_NEW_USER: false,
};
};

View File

@ -312,4 +312,9 @@ export enum I18nKey {
BUTTON$MARK_NOT_HELPFUL = "BUTTON$MARK_NOT_HELPFUL",
BUTTON$EXPORT_CONVERSATION = "BUTTON$EXPORT_CONVERSATION",
BILLING$CLICK_TO_TOP_UP = "BILLING$CLICK_TO_TOP_UP",
BILLING$YOUVE_GOT_50 = "BILLING$YOUVE_GOT_50",
BILLING$ERROR_WHILE_CREATING_SESSION = "BILLING$ERROR_WHILE_CREATING_SESSION",
BILLING$CLAIM_YOUR_50 = "BILLING$CLAIM_YOUR_50",
BILLING$PROCEED_TO_STRIPE = "BILLING$PROCEED_TO_STRIPE",
BILLING$YOURE_IN = "BILLING$YOURE_IN",
}

View File

@ -4647,5 +4647,80 @@
"fr": "Ajouter des fonds à votre compte",
"tr": "Hesabınıza bakiye ekleyin",
"de": "Guthaben zu Ihrem Konto hinzufügen"
},
"BILLING$YOUVE_GOT_50": {
"en": "You've got $50 in free OpenHands credits",
"ja": "OpenHandsの無料クレジット$50を獲得しました",
"zh-CN": "您获得了 $50 的 OpenHands 免费额度",
"zh-TW": "您獲得了 $50 的 OpenHands 免費額度",
"ko-KR": "OpenHands 무료 크레딧 $50를 받았습니다",
"no": "Du har fått $50 i gratis OpenHands-kreditter",
"it": "Hai ottenuto $50 in crediti gratuiti OpenHands",
"pt": "Você ganhou $50 em créditos gratuitos OpenHands",
"es": "Has recibido $50 en créditos gratuitos de OpenHands",
"ar": "لديك 50$ من رصيد OpenHands المجاني",
"fr": "Vous avez reçu $50 de crédits OpenHands gratuits",
"tr": "OpenHands'de $50 ücretsiz kredi kazandınız",
"de": "Sie haben $50 in kostenlosen OpenHands-Guthaben erhalten"
},
"BILLING$ERROR_WHILE_CREATING_SESSION": {
"en": "Error occurred while setting up your payment session. Please try again later.",
"ja": "お支払いセッションの設定中にエラーが発生しました。後ほど再度お試しください。",
"zh-CN": "设置支付会话时发生错误。请稍后再试。",
"zh-TW": "設置支付會話時發生錯誤。請稍後再試。",
"ko-KR": "결제 세션 설정 중 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"no": "Det oppstod en feil under oppsett av betalingsøkten. Vennligst prøv igjen senere.",
"it": "Si è verificato un errore durante la configurazione della sessione di pagamento. Si prega di riprovare più tardi.",
"pt": "Ocorreu um erro ao configurar sua sessão de pagamento. Por favor, tente novamente mais tarde.",
"es": "Se produjo un error al configurar tu sesión de pago. Por favor, inténtalo de nuevo más tarde.",
"ar": "حدث خطأ أثناء إعداد جلسة الدفع الخاصة بك. يرجى المحاولة مرة أخرى لاحقًا.",
"fr": "Une erreur s'est produite lors de la configuration de votre session de paiement. Veuillez réessayer plus tard.",
"tr": "Ödeme oturumunuz kurulurken bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
"de": "Beim Einrichten Ihrer Zahlungssitzung ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut."
},
"BILLING$CLAIM_YOUR_50": {
"en": "Add a credit card with Stripe to claim your $50. <b>We won't charge you without asking first!</b>",
"ja": "Stripeでクレジットカードを追加して$50を獲得。<b>事前の確認なしで請求することはありません!</b>",
"zh-CN": "添加 Stripe 信用卡以领取 $50。<b>我们不会在未经您同意的情况下收费!</b>",
"zh-TW": "添加 Stripe 信用卡以領取 $50。<b>我們不會在未經您同意的情況下收費!</b>",
"ko-KR": "Stripe에 신용카드를 추가하여 $50를 받으세요. <b>사전 동의 없이 요금이 청구되지 않습니다!</b>",
"no": "Legg til et kredittkort med Stripe for å få $50. <b>Vi belaster deg ikke uten å spørre først!</b>",
"it": "Aggiungi una carta di credito con Stripe per ottenere $50. <b>Non ti addebiteremo nulla senza chiedere prima!</b>",
"pt": "Adicione um cartão de crédito com Stripe para receber $50. <b>Não cobraremos sem perguntar primeiro!</b>",
"es": "Añade una tarjeta de crédito con Stripe para reclamar tus $50. <b>¡No te cobraremos sin preguntarte primero!</b>",
"ar": "أضف بطاقة ائتمان مع Stripe للحصول على 50$. <b>لن نقوم بالخصم دون إذن مسبق!</b>",
"fr": "Ajoutez une carte de crédit avec Stripe pour obtenir 50$. <b>Nous ne vous facturerons pas sans vous demander d'abord !</b>",
"tr": "50$ almak için Stripe ile kredi kartı ekleyin. <b>Önce sormadan ücret almayacağız!</b>",
"de": "Fügen Sie eine Kreditkarte mit Stripe hinzu, um $50 zu erhalten. <b>Wir belasten Sie nicht ohne vorherige Zustimmung!</b>"
},
"BILLING$PROCEED_TO_STRIPE": {
"en": "Add Billing Info",
"ja": "請求情報を追加",
"zh-CN": "添加账单信息",
"zh-TW": "添加帳單資訊",
"ko-KR": "결제 정보 추가",
"no": "Legg til betalingsinformasjon",
"it": "Aggiungi informazioni di fatturazione",
"pt": "Adicionar informações de pagamento",
"es": "Añadir información de facturación",
"ar": "إضافة معلومات الفواتير",
"fr": "Ajouter les informations de facturation",
"tr": "Fatura Bilgisi Ekle",
"de": "Zahlungsinformationen hinzufügen"
},
"BILLING$YOURE_IN": {
"en": "You're in! You can start using your $50 in free credits now.",
"ja": "登録完了!$50分の無料クレジットを今すぐご利用いただけます。",
"zh-CN": "您已加入!现在可以开始使用$50的免费额度了。",
"zh-TW": "您已加入!現在可以開始使用$50的免費額度了。",
"ko-KR": "가입 완료! 지금 바로 $50 상당의 무료 크레딧을 사용하실 수 있습니다.",
"no": "Du er med! Du kan begynne å bruke dine $50 i gratis kreditter nå.",
"it": "Ci sei! Puoi iniziare a utilizzare i tuoi $50 in crediti gratuiti ora.",
"pt": "Você está dentro! Você pode começar a usar seus $50 em créditos gratuitos agora.",
"es": "¡Ya estás dentro! Puedes empezar a usar tus $50 en créditos gratuitos ahora.",
"ar": "أنت معنا! يمكنك البدء في استخدام رصيدك المجاني البالغ 50 دولارًا الآن.",
"fr": "C'est fait ! Vous pouvez commencer à utiliser vos 50 $ de crédits gratuits maintenant.",
"tr": "Başardın! Şimdi $50 değerindeki ücretsiz kredilerini kullanmaya başlayabilirsin.",
"de": "Du bist dabei! Du kannst jetzt deine $50 an kostenlosen Guthaben nutzen."
}
}

View File

@ -1,8 +1,4 @@
import { delay, http, HttpResponse } from "msw";
import Stripe from "stripe";
const TEST_STRIPE_SECRET_KEY = "";
const PRICE_ID = "";
export const STRIPE_BILLING_HANDLERS = [
http.get("/api/billing/credits", async () => {
@ -10,27 +6,17 @@ export const STRIPE_BILLING_HANDLERS = [
return HttpResponse.json({ credits: "100" });
}),
http.post("/api/billing/create-checkout-session", async ({ request }) => {
http.post("/api/billing/create-checkout-session", async () => {
await delay();
const body = await request.json();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-checkout",
});
}),
if (body && typeof body === "object" && body.amount) {
const stripe = new Stripe(TEST_STRIPE_SECRET_KEY);
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: PRICE_ID,
quantity: body.amount,
},
],
mode: "payment",
success_url: "http://localhost:3001/settings/billing/?checkout=success",
cancel_url: "http://localhost:3001/settings/billing/?checkout=cancel",
});
if (session.url) return HttpResponse.json({ redirect_url: session.url });
}
return HttpResponse.json({ message: "Invalid request" }, { status: 400 });
http.post("/api/billing/create-customer-setup-session", async () => {
await delay();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-customer-setup",
});
}),
];

View File

@ -25,9 +25,9 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
};
const MOCK_USER_PREFERENCES: {
settings: ApiSettings | PostApiSettings;
settings: ApiSettings | PostApiSettings | null;
} = {
settings: MOCK_DEFAULT_USER_SETTINGS,
settings: null,
};
const conversations: Conversation[] = [
@ -174,20 +174,22 @@ export const handlers = [
),
http.get("/api/options/config", () => {
const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
const config: GetConfigResponse = {
APP_MODE: mockSaas ? "saas" : "oss",
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
STRIPE_PUBLISHABLE_KEY: "",
};
return HttpResponse.json(config);
}),
http.get("/api/settings", async () => {
await delay();
const settings: ApiSettings = {
...MOCK_USER_PREFERENCES.settings,
language: "no",
};
const { settings } = MOCK_USER_PREFERENCES;
if (!settings) return HttpResponse.json(null, { status: 404 });
// @ts-expect-error - mock types
if (settings.github_token) settings.github_token_is_set = true;
@ -207,11 +209,13 @@ export const handlers = [
}
}
MOCK_USER_PREFERENCES.settings = {
const fullSettings = {
...MOCK_DEFAULT_USER_SETTINGS,
...MOCK_USER_PREFERENCES.settings,
...newSettings,
};
MOCK_USER_PREFERENCES.settings = fullSettings;
return HttpResponse.json(null, { status: 200 });
}

View File

@ -24,7 +24,10 @@ function Home() {
});
return (
<div className="bg-base-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2">
<div
data-testid="home-screen"
className="bg-base-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
>
<HeroHeading />
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">

View File

@ -1,5 +1,13 @@
import React from "react";
import { useRouteError, isRouteErrorResponse, Outlet } from "react-router";
import {
useRouteError,
isRouteErrorResponse,
Outlet,
useNavigate,
useLocation,
useSearchParams,
} from "react-router";
import { useTranslation } from "react-i18next";
import i18n from "#/i18n";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
@ -10,6 +18,10 @@ import { AnalyticsConsentFormModal } from "#/components/features/analytics/analy
import { useSettings } from "#/hooks/query/use-settings";
import { useAuth } from "#/context/auth-context";
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
import { useBalance } from "#/hooks/query/use-balance";
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
import { BILLING_SETTINGS } from "#/utils/feature-flags";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
export function ErrorBoundary() {
const error = useRouteError();
@ -44,11 +56,14 @@ export function ErrorBoundary() {
}
export default function MainApp() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const { githubTokenIsSet } = useAuth();
const { data: settings } = useSettings();
const { error, isFetching } = useBalance();
const { migrateUserConsent } = useMigrateUserConsent();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
const { t } = useTranslation();
const config = useConfig();
const {
@ -62,6 +77,8 @@ export default function MainApp() {
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
});
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
React.useEffect(() => {
if (settings?.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
@ -84,6 +101,17 @@ export default function MainApp() {
});
}, []);
React.useEffect(() => {
// Don't allow users to use the app if it 402s
if (error?.status === 402 && pathname !== "/") {
navigate("/");
} else if (!isFetching && searchParams.get("free_credits") === "success") {
displaySuccessToast(t("BILLING$YOURE_IN"));
searchParams.delete("free_credits");
navigate("/");
}
}, [error?.status, pathname, isFetching]);
const userIsAuthed = !!isAuthed && !authError;
const renderWaitlistModal =
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
@ -116,6 +144,10 @@ export default function MainApp() {
}}
/>
)}
{BILLING_SETTINGS() &&
config.data?.APP_MODE === "saas" &&
settings?.IS_NEW_USER && <SetupPaymentModal />}
</div>
);
}

View File

@ -15,6 +15,7 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
IS_NEW_USER: true,
};
/**

View File

@ -11,6 +11,7 @@ export type Settings = {
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
IS_NEW_USER?: boolean;
};
export type ApiSettings = {

View File

@ -17,6 +17,7 @@ export default {
tertiary: "#454545", // gray, used for inputs
"tertiary-light": "#B7BDC2", // lighter gray, used for borders and placeholder text
content: "#ECEDEE", // light gray, used mostly for text
"content-2": "#F9FBFE",
},
},
animation: {