From 0132ec151a632cdb10150efe82d2f16ce6d1229c Mon Sep 17 00:00:00 2001 From: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:59:38 -0700 Subject: [PATCH] Project onboarding speedup (#1596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speeds up project onboarding by consolidating API calls and adding prefetching: - Single PATCH endpoint for saving onboarding status + state together - Background config save on welcome step (with proper retry/error handling) - Prefetch email themes and Stripe info for upcoming steps - Suspense-based skeleton loaders instead of full-page spinners - Extracted shared types and lightweight step components ## Summary by CodeRabbit * **New Features** * Unified onboarding progress saving for the new project flow, persisting status plus optional onboarding state in a single update path. * **Improvements** * Onboarding wizard now derives status/state from owned project data and removes extra internal fetching/loading gates. * Added skeleton/Suspense loading for email theme and payments steps, with more reliable “final config”/completion sequencing. * Admin project data now includes onboarding state; Stripe account info uses cached retrieval. * Backend avoids source-of-truth override changes during metadata-only updates. * **Tests** * Updated onboarding wizard tests and added end-to-end coverage for updating onboarding status/state together. * **Documentation** * Added the “clickmaps” app icon to docs. --------- Co-authored-by: Cursor Co-authored-by: armaan Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/src/lib/projects.tsx | 17 +- .../new-project/page-client-parts/content.tsx | 162 ++--- .../project-onboarding-wizard.test.tsx | 588 +++++++++++++++++- .../project-onboarding-wizard.tsx | 570 +++++++++++------ .../new-project/page-client-parts/shared.ts | 5 + .../new-project/page-client.test.tsx | 15 + .../api/v1/internal/projects.test.ts | 37 ++ .../apps/implementations/admin-app-impl.ts | 3 +- .../src/lib/hexclave-app/projects/index.ts | 1 + 9 files changed, 1040 insertions(+), 358 deletions(-) diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index ef0340bd9..70308c104 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -208,13 +208,16 @@ export async function createOrUpdateProjectWithLegacyConfig( return [project.id, branchId]; }); - // Update project config override - await overrideProjectConfigOverride({ - projectId: projectId, - projectConfigOverrideOverride: { - sourceOfTruth: options.sourceOfTruth || (JSON.parse(getEnvVariable("STACK_OVERRIDE_SOURCE_OF_TRUTH", "null")) ?? undefined), - }, - }); + // Metadata-only onboarding updates should stay cheap and avoid touching config + // source state; creation still needs the default project config override. + if (options.type === "create" || options.sourceOfTruth !== undefined) { + await overrideProjectConfigOverride({ + projectId: projectId, + projectConfigOverrideOverride: { + sourceOfTruth: options.sourceOfTruth || (JSON.parse(getEnvVariable("STACK_OVERRIDE_SOURCE_OF_TRUTH", "null")) ?? undefined), + }, + }); + } // Update environment config override const translateDefaultPermissions = (permissions: { id: string }[] | undefined) => { diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx index 61eed8dbe..cc2724410 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx @@ -24,20 +24,20 @@ import { import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import { PlusCircleIcon } from "@phosphor-icons/react"; -import { AdminOwnedProject, useStackApp } from "@hexclave/next"; +import { type AdminOwnedProject } from "@hexclave/next"; import { runAsynchronouslyWithAlert, wait } from "@hexclave/shared/dist/utils/promises"; import { useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields"; import { ProjectOnboardingWizard } from "./project-onboarding-wizard"; import { beginPendingAction, endPendingAction, getStackAppInternals, isProjectOnboardingState, - isProjectOnboardingStatus, + type OnboardingProgressUpdate, type ProjectOnboardingState, + type ProjectOnboardingStatus, } from "./shared"; export default function PageClient() { @@ -55,8 +55,6 @@ export default function PageClient() { } function PageClientInner() { - const app = useStackApp(); - const appInternals = useMemo(() => getStackAppInternals(app), [app]); const user = useDashboardInternalUser(); const teams = user.useTeams(); const projects = user.useOwnedProjects(); @@ -74,7 +72,6 @@ function PageClientInner() { const [projectStatuses, setProjectStatuses] = useState>(new Map()); const [projectOnboardingStates, setProjectOnboardingStates] = useState>(new Map()); - const [loadingStatuses, setLoadingStatuses] = useState(true); const [projectName, setProjectName] = useState(displayNameFromSearch ?? ""); const hasProjectName = projectName.trim().length > 0; const [selectedTeamId, setSelectedTeamId] = useState(null); @@ -117,58 +114,6 @@ function PageClientInner() { router.replace(query.length > 0 ? `/new-project?${query}` : "/new-project"); }, [router, searchParams]); - useEffect(() => { - let cancelled = false; - - runAsynchronouslyWithAlert(async () => { - setLoadingStatuses(true); - try { - const response = await appInternals.sendRequest("/internal/projects", {}, "client"); - if (!response.ok) { - throw new Error(`Failed to load projects: ${response.status} ${await response.text()}`); - } - - const body = await response.json(); - if (body == null || typeof body !== "object" || !("items" in body) || !Array.isArray(body.items)) { - throw new Error("Project list endpoint returned an invalid response."); - } - - const statusMap = new Map(); - const onboardingStateMap = new Map(); - for (const item of body.items) { - if (item == null || typeof item !== "object" || !("id" in item) || typeof item.id !== "string") { - continue; - } - - const onboardingStatus = "onboarding_status" in item ? item.onboarding_status : undefined; - if (!isProjectOnboardingStatus(onboardingStatus)) { - throw new Error(`Project ${item.id} returned an invalid onboarding status.`); - } - statusMap.set(item.id, onboardingStatus); - - const onboardingState = "onboarding_state" in item ? item.onboarding_state : null; - if (onboardingState != null && !isProjectOnboardingState(onboardingState)) { - throw new Error(`Project ${item.id} returned an invalid onboarding state.`); - } - onboardingStateMap.set(item.id, onboardingState); - } - - if (!cancelled) { - setProjectStatuses(statusMap); - setProjectOnboardingStates(onboardingStateMap); - } - } finally { - if (!cancelled) { - setLoadingStatuses(false); - } - } - }); - - return () => { - cancelled = true; - }; - }, [appInternals, projects.length]); - const selectedProject = useMemo(() => { if (selectedProjectId == null) { return null; @@ -177,29 +122,46 @@ function PageClientInner() { }, [projects, selectedProjectId]); const selectedProjectStatus = useMemo(() => { - if (selectedProjectId == null) { + if (selectedProjectId == null || selectedProject == null) { return null; } - return projectStatuses.get(selectedProjectId) ?? null; - }, [projectStatuses, selectedProjectId]); + return projectStatuses.get(selectedProjectId) ?? selectedProject.onboardingStatus; + }, [projectStatuses, selectedProject, selectedProjectId]); const selectedProjectOnboardingState = useMemo(() => { - if (selectedProjectId == null) { + if (selectedProjectId == null || selectedProject == null) { return null; } - return projectOnboardingStates.get(selectedProjectId) ?? null; - }, [projectOnboardingStates, selectedProjectId]); + if (projectOnboardingStates.has(selectedProjectId)) { + return projectOnboardingStates.get(selectedProjectId) ?? null; + } + const onboardingState = selectedProject.onboardingState; + if (onboardingState == null) { + return null; + } + if (!isProjectOnboardingState(onboardingState)) { + throw new Error(`Project ${selectedProject.id} returned an invalid onboarding state.`); + } + return onboardingState; + }, [projectOnboardingStates, selectedProject, selectedProjectId]); useEffect(() => { - if (selectedProject == null || loadingStatuses || selectedProjectStatus !== "completed") { + if (selectedProject == null || selectedProjectStatus !== "completed") { return; } router.replace(`/projects/${encodeURIComponent(selectedProject.id)}`); - }, [loadingStatuses, router, selectedProject, selectedProjectStatus]); + }, [router, selectedProject, selectedProjectStatus]); - const setSelectedProjectStatus = async (project: AdminOwnedProject, status: ProjectOnboardingStatus) => { + const saveSelectedProjectOnboardingProgress = async (project: AdminOwnedProject, update: OnboardingProgressUpdate) => { const projectInternals = getStackAppInternals(project.app); + const body: Record = {}; + if (update.status !== undefined) { + body.onboarding_status = update.status; + } + if ("onboardingState" in update) { + body.onboarding_state = update.onboardingState ?? null; + } const response = await projectInternals.sendRequest( "/internal/projects/current", @@ -208,48 +170,32 @@ function PageClientInner() { headers: { "content-type": "application/json", }, - body: JSON.stringify({ onboarding_status: status }), + body: JSON.stringify(body), }, "admin", ); if (!response.ok) { - throw new Error(`Failed to update onboarding status: ${response.status} ${await response.text()}`); + throw new Error(`Failed to update onboarding progress: ${response.status} ${await response.text()}`); } - setProjectStatuses((previous) => { - const next = new Map(previous); - next.set(project.id, status); - return next; - }); - - await appInternals.refreshOwnedProjects(); - }; - - const setSelectedProjectOnboardingState = async (project: AdminOwnedProject, onboardingState: ProjectOnboardingState | null) => { - const projectInternals = getStackAppInternals(project.app); - - const response = await projectInternals.sendRequest( - "/internal/projects/current", - { - method: "PATCH", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ onboarding_state: onboardingState }), - }, - "admin", - ); - - if (!response.ok) { - throw new Error(`Failed to update onboarding state: ${response.status} ${await response.text()}`); + const nextStatus = update.status; + if (nextStatus !== undefined) { + setProjectStatuses((previous) => { + const next = new Map(previous); + next.set(project.id, nextStatus); + return next; + }); } - setProjectOnboardingStates((previous) => { - const next = new Map(previous); - next.set(project.id, onboardingState); - return next; - }); + if ("onboardingState" in update) { + const nextOnboardingState = update.onboardingState ?? null; + setProjectOnboardingStates((previous) => { + const next = new Map(previous); + next.set(project.id, nextOnboardingState); + return next; + }); + } }; if (isDevelopmentEnvironment && selectedProjectId == null) { @@ -274,14 +220,6 @@ function PageClientInner() { ); } - if (loadingStatuses && selectedProjectId != null) { - return ( -
- -
- ); - } - if (selectedProjectId != null && selectedProject == null) { return (
@@ -298,7 +236,7 @@ function PageClientInner() { ); } - if (selectedProject != null && !loadingStatuses && selectedProjectStatus === "completed") { + if (selectedProject != null && selectedProjectStatus === "completed") { return (
@@ -306,7 +244,7 @@ function PageClientInner() { ); } - if (selectedProject != null && !loadingStatuses && selectedProjectStatus == null) { + if (selectedProject != null && selectedProjectStatus == null) { throw new Error(`Missing onboarding status for project ${selectedProject.id}.`); } @@ -527,9 +465,7 @@ function PageClientInner() { onboardingState={selectedProjectOnboardingState} mode={mode} setMode={(nextMode) => updateSearchParams({ mode: nextMode })} - setStatus={(nextStatus) => setSelectedProjectStatus(selectedProject, nextStatus)} - setOnboardingState={(nextState) => setSelectedProjectOnboardingState(selectedProject, nextState)} - clearOnboardingState={() => setSelectedProjectOnboardingState(selectedProject, null)} + saveOnboardingProgress={(update) => saveSelectedProjectOnboardingProgress(selectedProject, update)} onComplete={() => { const projectUrl = `/projects/${encodeURIComponent(selectedProject.id)}`; if (mode === "link-existing") { diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx index 81fc04449..70dac621b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import type { ButtonHTMLAttributes, ReactNode } from "react"; +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; @@ -73,6 +73,9 @@ vi.mock("@/components/ui", () => ({ Button: ({ children, type, ...props }: ButtonHTMLAttributes) => ( ), + Skeleton: ({ children, ...props }: { children?: ReactNode } & HTMLAttributes) => ( +
{children}
+ ), Switch: () => , TooltipProvider: ({ children }: { children: ReactNode }) =>
{children}
, Typography: ({ children }: { children: ReactNode }) =>
{children}
, @@ -98,6 +101,9 @@ vi.mock("@hexclave/shared/dist/utils/oauth", () => ({ })); vi.mock("@hexclave/shared/dist/utils/promises", () => ({ + runAsynchronously: (promiseOrFn: Promise | (() => Promise)) => ( + typeof promiseOrFn === "function" ? promiseOrFn() : promiseOrFn + ), runAsynchronouslyWithAlert: (fn: () => Promise) => fn(), })); @@ -148,6 +154,19 @@ afterEach(() => { mockUpdateConfig.mockClear(); }); +function createDeferred() { + let resolveDeferred: (value: T | PromiseLike) => void = () => { + throw new Error("Deferred promise was resolved before initialization."); + }; + const promise = new Promise((resolve) => { + resolveDeferred = resolve; + }); + return { + promise, + resolve: resolveDeferred, + }; +} + describe("ProjectOnboardingWizard", () => { it("keeps required apps when normalizing persisted onboarding state", () => { const normalizedState = normalizeProjectOnboardingState({ @@ -207,8 +226,401 @@ describe("ProjectOnboardingWizard", () => { } }); + it("prefetches email themes on early steps without mounting heavy hooks", () => { + const useEmailThemes = vi.fn(() => { + throw new Error("Email themes should not load on the app selection step."); + }); + const useStripeAccountInfo = vi.fn(() => { + throw new Error("Stripe account info should not load on the app selection step."); + }); + const listEmailThemes = vi.fn(async () => []); + const getStripeAccountInfo = vi.fn(async () => null); + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: true }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app: { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes, + getStripeAccountInfo, + useEmailThemes, + useStripeAccountInfo, + }, + } as never} + status="apps_selection" + onboardingState={null} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={vi.fn(async () => {})} + onComplete={vi.fn()} + />, + ); + + expect(listEmailThemes).toHaveBeenCalledOnce(); + expect(getStripeAccountInfo).not.toHaveBeenCalled(); + expect(useEmailThemes).not.toHaveBeenCalled(); + expect(useStripeAccountInfo).not.toHaveBeenCalled(); + }); + + it("saves app selection state and status in one request", async () => { + const saveOnboardingProgress = vi.fn(async () => {}); + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: true }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app: { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), + useEmailThemes: () => [], + useStripeAccountInfo: () => null, + }, + } as never} + status="apps_selection" + onboardingState={null} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={saveOnboardingProgress} + onComplete={vi.fn()} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => { + expect(saveOnboardingProgress).toHaveBeenCalledOnce(); + }); + expect(saveOnboardingProgress).toHaveBeenCalledWith({ + status: "auth_setup", + onboardingState: expect.objectContaining({ + selected_apps: expect.arrayContaining(["authentication", "emails", "payments"]), + }), + }); + }); + + it("saves auth setup state and status in one request", async () => { + const saveOnboardingProgress = vi.fn(async () => {}); + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: false }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app: { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), + useEmailThemes: () => [], + useStripeAccountInfo: () => null, + }, + } as never} + status="auth_setup" + onboardingState={null} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={saveOnboardingProgress} + onComplete={vi.fn()} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => { + expect(saveOnboardingProgress).toHaveBeenCalledOnce(); + }); + expect(saveOnboardingProgress).toHaveBeenCalledWith({ + status: "email_theme_setup", + onboardingState: expect.objectContaining({ + selected_sign_in_methods: expect.arrayContaining(["credential"]), + }), + }); + }); + + it("saves email theme state and status in one request", async () => { + const saveOnboardingProgress = vi.fn(async () => {}); + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: true }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app: { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), + useEmailThemes: () => [{ id: "default", displayName: "Default" }], + useStripeAccountInfo: () => null, + }, + } as never} + status="email_theme_setup" + onboardingState={null} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={saveOnboardingProgress} + onComplete={vi.fn()} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => { + expect(saveOnboardingProgress).toHaveBeenCalledOnce(); + }); + expect(saveOnboardingProgress).toHaveBeenCalledWith({ + status: "payments_setup", + onboardingState: expect.objectContaining({ + selected_email_theme_id: "default", + }), + }); + }); + + it("prefetches Stripe account info on the email theme step without mounting the payments hook", () => { + const getStripeAccountInfo = vi.fn(async () => null); + const useStripeAccountInfo = vi.fn(() => { + throw new Error("Stripe account info should not load before the payments step."); + }); + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: true }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app: { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo, + useEmailThemes: () => [{ id: "default", displayName: "Default" }], + useStripeAccountInfo, + }, + } as never} + status="email_theme_setup" + onboardingState={null} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={vi.fn(async () => {})} + onComplete={vi.fn()} + />, + ); + + expect(getStripeAccountInfo).toHaveBeenCalledOnce(); + expect(useStripeAccountInfo).not.toHaveBeenCalled(); + }); + + it("shows an email-theme shimmer instead of the page spinner while themes load", () => { + const pendingEmailThemes = new Promise(() => {}); + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: true }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app: { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), + useEmailThemes: () => { + throw pendingEmailThemes; + }, + useStripeAccountInfo: () => null, + }, + } as never} + status="email_theme_setup" + onboardingState={null} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={vi.fn(async () => {})} + onComplete={vi.fn()} + />, + ); + + expect(screen.getByText("Select an email theme")).toBeTruthy(); + expect(screen.getByTestId("email-theme-step-skeleton")).toBeTruthy(); + }); + + it("shows a payments shimmer instead of the page spinner while Stripe status loads", () => { + const pendingStripeAccountInfo = new Promise(() => {}); + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: true }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app: { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), + useEmailThemes: () => [], + useStripeAccountInfo: () => { + throw pendingStripeAccountInfo; + }, + }, + } as never} + status="payments_setup" + onboardingState={null} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={vi.fn(async () => {})} + onComplete={vi.fn()} + />, + ); + + expect(screen.getByText("Set up payments")).toBeTruthy(); + expect(screen.getByTestId("payments-setup-step-skeleton")).toBeTruthy(); + }); + it("completes onboarding automatically after Stripe setup returns successfully", async () => { - const setStatus = vi.fn(async () => {}); + const saveOnboardingProgress = vi.fn(async () => {}); const onComplete = vi.fn(); const project = { @@ -237,6 +649,8 @@ describe("ProjectOnboardingWizard", () => { }), app: { setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), useEmailThemes: () => [], useStripeAccountInfo: () => ({ account_id: "acct_123", @@ -254,22 +668,24 @@ describe("ProjectOnboardingWizard", () => { onboardingState={null} mode={null} setMode={vi.fn()} - setStatus={setStatus} - setOnboardingState={vi.fn(async () => {})} - clearOnboardingState={vi.fn(async () => {})} + saveOnboardingProgress={saveOnboardingProgress} onComplete={onComplete} />, ); await waitFor(() => { - expect(setStatus).toHaveBeenCalledWith("welcome"); + expect(saveOnboardingProgress).toHaveBeenCalledWith({ + status: "welcome", + onboardingState: expect.objectContaining({ + selected_payments_country: "US", + }), + }); }); expect(onComplete).not.toHaveBeenCalled(); }); it("creates a deferred Stripe account when payments setup is deferred for a US project", async () => { - const setStatus = vi.fn(async () => {}); - const setOnboardingState = vi.fn(async () => {}); + const saveOnboardingProgress = vi.fn(async () => {}); const setupPayments = vi.fn(async () => ({ url: "https://example.com" })); render( @@ -300,6 +716,8 @@ describe("ProjectOnboardingWizard", () => { }), app: { setupPayments, + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), useEmailThemes: () => [], useStripeAccountInfo: () => null, }, @@ -308,9 +726,7 @@ describe("ProjectOnboardingWizard", () => { onboardingState={null} mode={null} setMode={vi.fn()} - setStatus={setStatus} - setOnboardingState={setOnboardingState} - clearOnboardingState={vi.fn(async () => {})} + saveOnboardingProgress={saveOnboardingProgress} onComplete={vi.fn()} />, ); @@ -320,12 +736,16 @@ describe("ProjectOnboardingWizard", () => { await waitFor(() => { expect(setupPayments).toHaveBeenCalledOnce(); }); - expect(setOnboardingState).toHaveBeenCalledOnce(); - expect(setStatus).toHaveBeenCalledWith("welcome"); + expect(saveOnboardingProgress).toHaveBeenCalledWith({ + status: "welcome", + onboardingState: expect.objectContaining({ + selected_payments_country: "US", + }), + }); }); it("does not create a Stripe account when payments setup is deferred for an unsupported country", async () => { - const setStatus = vi.fn(async () => {}); + const saveOnboardingProgress = vi.fn(async () => {}); const setupPayments = vi.fn(async () => ({ url: "https://example.com" })); render( @@ -356,6 +776,8 @@ describe("ProjectOnboardingWizard", () => { }), app: { setupPayments, + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), useEmailThemes: () => [], useStripeAccountInfo: () => null, }, @@ -370,9 +792,7 @@ describe("ProjectOnboardingWizard", () => { }} mode={null} setMode={vi.fn()} - setStatus={setStatus} - setOnboardingState={vi.fn(async () => {})} - clearOnboardingState={vi.fn(async () => {})} + saveOnboardingProgress={saveOnboardingProgress} onComplete={vi.fn()} />, ); @@ -380,7 +800,12 @@ describe("ProjectOnboardingWizard", () => { fireEvent.click(screen.getByText("Do Later")); await waitFor(() => { - expect(setStatus).toHaveBeenCalledWith("welcome"); + expect(saveOnboardingProgress).toHaveBeenCalledWith({ + status: "welcome", + onboardingState: expect.objectContaining({ + selected_payments_country: "OTHER", + }), + }); }); expect(setupPayments).not.toHaveBeenCalled(); }); @@ -416,6 +841,8 @@ describe("ProjectOnboardingWizard", () => { }), app: { setupPayments, + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), useEmailThemes: () => [], useStripeAccountInfo: () => null, }, @@ -424,9 +851,7 @@ describe("ProjectOnboardingWizard", () => { onboardingState={null} mode={null} setMode={vi.fn()} - setStatus={vi.fn(async () => {})} - setOnboardingState={vi.fn(async () => {})} - clearOnboardingState={vi.fn(async () => {})} + saveOnboardingProgress={vi.fn(async () => {})} onComplete={vi.fn()} />, ); @@ -472,6 +897,8 @@ describe("ProjectOnboardingWizard", () => { }), app: { setupPayments, + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), useEmailThemes: () => [], useStripeAccountInfo: () => null, }, @@ -480,9 +907,7 @@ describe("ProjectOnboardingWizard", () => { onboardingState={null} mode={null} setMode={vi.fn()} - setStatus={vi.fn(async () => {})} - setOnboardingState={vi.fn(async () => {})} - clearOnboardingState={vi.fn(async () => {})} + saveOnboardingProgress={vi.fn(async () => {})} onComplete={vi.fn()} />, ); @@ -498,11 +923,12 @@ describe("ProjectOnboardingWizard", () => { }); it("persists shared OAuth providers selected during onboarding before completing", async () => { - const setStatus = vi.fn(async () => {}); - const clearOnboardingState = vi.fn(async () => {}); + const saveOnboardingProgress = vi.fn(async () => {}); const onComplete = vi.fn(); const app = { setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), useEmailThemes: () => [], useStripeAccountInfo: () => null, }; @@ -531,6 +957,7 @@ describe("ProjectOnboardingWizard", () => { }, }), app, + getPushedConfigSource: vi.fn(async () => ({ type: "unlinked" })), }; render( @@ -546,17 +973,22 @@ describe("ProjectOnboardingWizard", () => { }} mode={null} setMode={vi.fn()} - setStatus={setStatus} - setOnboardingState={vi.fn(async () => {})} - clearOnboardingState={clearOnboardingState} + saveOnboardingProgress={saveOnboardingProgress} onComplete={onComplete} />, ); - fireEvent.click(screen.getByRole("button", { name: "Get Started" })); - await waitFor(() => { expect(mockUpdateConfig).toHaveBeenCalledTimes(2); + expect(mockUpdateConfig).toHaveBeenNthCalledWith(1, { + adminApp: app, + configUpdate: expect.objectContaining({ + "auth.password.allowSignIn": true, + "apps.installed.authentication.enabled": true, + "apps.installed.emails.enabled": true, + }), + pushable: true, + }); expect(mockUpdateConfig).toHaveBeenNthCalledWith(2, { adminApp: app, configUpdate: { @@ -571,8 +1003,98 @@ describe("ProjectOnboardingWizard", () => { }, pushable: false, }); - expect(setStatus).toHaveBeenCalledWith("completed"); - expect(clearOnboardingState).toHaveBeenCalled(); + }); + expect(saveOnboardingProgress).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Get Started" })); + + await waitFor(() => { + expect(saveOnboardingProgress).toHaveBeenCalledWith({ status: "completed", onboardingState: null }); + expect(onComplete).toHaveBeenCalled(); + }); + }); + + it("waits for the in-flight welcome config save before marking onboarding completed", async () => { + const saveOnboardingProgress = vi.fn(async () => {}); + const onComplete = vi.fn(); + const branchConfigSave = createDeferred(); + const environmentConfigSave = createDeferred(); + mockUpdateConfig.mockImplementationOnce(async () => await branchConfigSave.promise); + mockUpdateConfig.mockImplementationOnce(async () => await environmentConfigSave.promise); + const app = { + setupPayments: vi.fn(async () => ({ url: "https://example.com" })), + listEmailThemes: vi.fn(async () => []), + getStripeAccountInfo: vi.fn(async () => null), + useEmailThemes: () => [], + useStripeAccountInfo: () => null, + }; + + render( + ({ + apps: { + installed: { + authentication: { enabled: true }, + emails: { enabled: true }, + payments: { enabled: false }, + }, + }, + domains: { + trustedDomains: {}, + }, + emails: { + selectedThemeId: "default", + server: {}, + }, + }), + app, + getPushedConfigSource: vi.fn(async () => ({ type: "unlinked" })), + } as never} + status="welcome" + onboardingState={{ + selected_config_choice: "create-new", + selected_apps: ["authentication", "emails"], + selected_sign_in_methods: ["credential"], + selected_email_theme_id: "default", + selected_payments_country: "US", + }} + mode={null} + setMode={vi.fn()} + saveOnboardingProgress={saveOnboardingProgress} + onComplete={onComplete} + />, + ); + + await waitFor(() => { + expect(mockUpdateConfig).toHaveBeenCalledOnce(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Get Started" })); + + await Promise.resolve(); + expect(saveOnboardingProgress).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + + branchConfigSave.resolve(true); + + await waitFor(() => { + expect(mockUpdateConfig).toHaveBeenCalledTimes(2); + }); + expect(saveOnboardingProgress).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + + environmentConfigSave.resolve(true); + + await waitFor(() => { + expect(saveOnboardingProgress).toHaveBeenCalledWith({ status: "completed", onboardingState: null }); expect(onComplete).toHaveBeenCalled(); }); }); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx index f7541b633..929adf18c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx @@ -12,6 +12,7 @@ import { AlertTitle, BrowserFrame, Button, + Skeleton, cn, Switch, TooltipProvider, @@ -29,12 +30,11 @@ import { WarningCircleIcon, WebhooksLogoIcon, } from "@phosphor-icons/react"; -import { AdminOwnedProject, AuthPage } from "@hexclave/next"; +import { AuthPage, type AdminOwnedProject } from "@hexclave/next"; import { type AppId } from "@hexclave/shared/dist/apps/apps-config"; import { type EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; -import { projectOnboardingStatusValues, type ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields"; -import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DomainSetupTransitionState, @@ -55,10 +55,12 @@ import { OAUTH_SIGN_IN_METHODS, type OnboardingConfigChoice, type OnboardingPaymentsCountry, + type OnboardingProgressUpdate, orderedAppIds, PAYMENT_COUNTRY_OPTIONS, PRIMARY_APP_IDS, type ProjectOnboardingState, + type ProjectOnboardingStatus, REQUIRED_APP_IDS, SHARED_OAUTH_SIGN_IN_METHODS, SIGN_IN_METHODS, @@ -66,27 +68,22 @@ import { } from "./shared"; import { LinkExistingOnboarding } from "./link-existing-onboarding"; -const PROJECT_ONBOARDING_STATUSES = projectOnboardingStatusValues; - export function ProjectOnboardingWizard(props: { project: AdminOwnedProject, status: ProjectOnboardingStatus, onboardingState: ProjectOnboardingState | null, mode: string | null, setMode: (mode: string | null) => void, - setStatus: (status: ProjectOnboardingStatus) => Promise, - setOnboardingState: (state: ProjectOnboardingState) => Promise, - clearOnboardingState: () => Promise, + saveOnboardingProgress: (update: OnboardingProgressUpdate) => Promise, onComplete: () => void, }) { const router = useRouter(); - const { project, status, onboardingState, setMode, setStatus, setOnboardingState, clearOnboardingState, onComplete } = props; + const { project, status, onboardingState, setMode, saveOnboardingProgress, onComplete } = props; const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; const isDevelopmentEnvironment = isLocalEmulator || isRemoteDevelopmentEnvironment; const completeConfig = project.useConfig(); const updateConfig = useUpdateConfig(); - const setProjectOnboardingStatus = setStatus; const finishProjectOnboarding = onComplete; const deriveCurrentOnboardingState = useCallback((onboardingStatus: ProjectOnboardingStatus): ProjectOnboardingState => { const defaultState = createProjectOnboardingState({ @@ -120,8 +117,7 @@ export function ProjectOnboardingWizard(props: { const [domainSetupAutoAdvancing, setDomainSetupAutoAdvancing] = useState(false); const [paymentsSetupAction, setPaymentsSetupAction] = useState<"defer" | "connect" | null>(null); const previousProjectId = useRef(null); - const paymentsAutoCompletingRef = useRef(false); - const stripeAccountInfo = props.project.app.useStripeAccountInfo(); + const finalConfigSavePromiseRef = useRef | null>(null); const runWithSaving = useCallback(async (fn: () => Promise) => { setSaving(true); @@ -169,10 +165,9 @@ export function ProjectOnboardingWizard(props: { setDomainSetupAutoAdvanceError(null); setDomainSetupAutoAdvancing(false); setPaymentsSetupAction(null); - paymentsAutoCompletingRef.current = false; + finalConfigSavePromiseRef.current = null; }, [completeConfig, deriveCurrentOnboardingState, project, project.id, status]); - const emailThemes = project.app.useEmailThemes(); const isLinkExistingMode = !isDevelopmentEnvironment && props.mode === "link-existing"; const paymentsAppEnabledInConfig = completeConfig.apps.installed.payments?.enabled === true; const includePayments = ( @@ -186,6 +181,26 @@ export function ProjectOnboardingWizard(props: { ); const currentTimelineIndex = useMemo(() => getStepIndex(timelineSteps, status), [status, timelineSteps]); + useEffect(() => { + if (isLinkExistingMode || (status !== "apps_selection" && status !== "auth_setup")) { + return; + } + + runAsynchronously(async () => { + await project.app.listEmailThemes(); + }, { noErrorLogging: true }); + }, [isLinkExistingMode, project.app, status]); + + useEffect(() => { + if (status !== "email_theme_setup" || !includePayments) { + return; + } + + runAsynchronously(async () => { + await project.app.getStripeAccountInfo(); + }, { noErrorLogging: true }); + }, [includePayments, project.app, status]); + const handleTimelineStepClick = useCallback((step: ProjectOnboardingStatus) => { const targetIndex = getStepIndex(timelineSteps, step); if (targetIndex < 0 || targetIndex >= currentTimelineIndex) { @@ -196,9 +211,9 @@ export function ProjectOnboardingWizard(props: { if (step === "config_choice" && props.mode !== "link-existing") { setMode(null); } - await setStatus(step); + await saveOnboardingProgress({ status: step }); }); - }, [currentTimelineIndex, props.mode, setMode, setStatus, timelineSteps]); + }, [currentTimelineIndex, props.mode, saveOnboardingProgress, setMode, timelineSteps]); const handleBack = useMemo(() => { if (currentTimelineIndex <= 0) { @@ -213,7 +228,7 @@ export function ProjectOnboardingWizard(props: { setDomainSetupAutoAdvanceError(null); setDomainSetupAutoAdvancing(true); try { - await setStatus("email_theme_setup"); + await saveOnboardingProgress({ status: "email_theme_setup" }); } catch (error) { setDomainSetupAutoAdvanceError(error instanceof Error ? error.message : "Failed to continue to the email theme step."); throw error; @@ -221,7 +236,7 @@ export function ProjectOnboardingWizard(props: { setDomainSetupAutoAdvancing(false); } }); - }, [setStatus]); + }, [saveOnboardingProgress]); useEffect(() => { if (status !== "domain_setup") { @@ -287,9 +302,12 @@ export function ProjectOnboardingWizard(props: { }); }, [completeConfig.emails.selectedThemeId, isDevelopmentEnvironment, isLocalEmulator, selectedApps, selectedConfigChoice, selectedEmailThemeId, selectedPaymentsCountry, signInMethods]); - const persistOnboardingState = useCallback(async () => { - await setOnboardingState(buildOnboardingState()); - }, [buildOnboardingState, setOnboardingState]); + const saveCurrentOnboardingProgress = useCallback(async (nextStatus: ProjectOnboardingStatus) => { + await saveOnboardingProgress({ + status: nextStatus, + onboardingState: buildOnboardingState(), + }); + }, [buildOnboardingState, saveOnboardingProgress]); const buildBranchConfigUpdate = useCallback(() => { const emailThemeId = selectedEmailThemeId ?? completeConfig.emails.selectedThemeId; @@ -335,50 +353,88 @@ export function ProjectOnboardingWizard(props: { return configUpdate; }, [signInMethods]); - const finalizeOnboarding = useCallback(async () => { - await runWithSaving(async () => { - if (!isLinkExistingMode) { - await persistOnboardingState(); + const saveFinalConfig = useCallback(async (): Promise => { + if (isLinkExistingMode) { + return true; + } - const branchConfigUpdated = await updateConfig({ - adminApp: props.project.app, - configUpdate: buildBranchConfigUpdate(), - pushable: true, - }); - if (!branchConfigUpdated) { - return; - } - - if (!isLocalEmulator) { - const providersUpdated = await updateConfig({ - adminApp: props.project.app, - configUpdate: buildEnvironmentOAuthConfigUpdate(), - pushable: false, - }); - if (!providersUpdated) { - return; - } - } - } - - await setProjectOnboardingStatus("completed"); - await clearOnboardingState(); - finishProjectOnboarding(); + const branchConfigUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: buildBranchConfigUpdate(), + pushable: true, }); + if (!branchConfigUpdated) { + return false; + } + + if (!isLocalEmulator) { + const providersUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: buildEnvironmentOAuthConfigUpdate(), + pushable: false, + }); + if (!providersUpdated) { + return false; + } + } + + return true; }, [ buildBranchConfigUpdate, buildEnvironmentOAuthConfigUpdate, - finishProjectOnboarding, isLinkExistingMode, isLocalEmulator, - persistOnboardingState, props.project.app, - clearOnboardingState, - runWithSaving, - setProjectOnboardingStatus, updateConfig, ]); + useEffect(() => { + if (status !== "welcome" || isLinkExistingMode || isLocalEmulator || finalConfigSavePromiseRef.current != null) { + return; + } + + finalConfigSavePromiseRef.current = (async () => { + const pushedConfigSource = await props.project.getPushedConfigSource(); + if (pushedConfigSource.type !== "unlinked") { + return false; + } + return await saveFinalConfig(); + })(); + runAsynchronously(finalConfigSavePromiseRef.current, { noErrorLogging: true }); + }, [isLinkExistingMode, isLocalEmulator, props.project, saveFinalConfig, status]); + + const finalizeOnboarding = useCallback(async () => { + await runWithSaving(async () => { + const backgroundConfigSave = finalConfigSavePromiseRef.current; + let configSaved: boolean; + try { + configSaved = backgroundConfigSave != null + ? await backgroundConfigSave + : await saveFinalConfig(); + } catch { + finalConfigSavePromiseRef.current = null; + configSaved = false; + } + + if (!configSaved) { + finalConfigSavePromiseRef.current = null; + configSaved = await saveFinalConfig(); + } + + if (!configSaved) { + throw new Error("Failed to save project configuration. Please try again."); + } + + await saveOnboardingProgress({ status: "completed", onboardingState: null }); + finishProjectOnboarding(); + }); + }, [ + finishProjectOnboarding, + runWithSaving, + saveFinalConfig, + saveOnboardingProgress, + ]); + const deferPaymentsSetup = useCallback(async () => { await runWithSaving(async () => { setPaymentsSetupAction("defer"); @@ -386,13 +442,12 @@ export function ProjectOnboardingWizard(props: { if (selectedPaymentsCountry === "US") { await props.project.app.setupPayments(); } - await persistOnboardingState(); - await setStatus("welcome"); + await saveCurrentOnboardingProgress("welcome"); } finally { setPaymentsSetupAction(null); } }); - }, [persistOnboardingState, props.project.app, runWithSaving, selectedPaymentsCountry, setStatus]); + }, [props.project.app, runWithSaving, saveCurrentOnboardingProgress, selectedPaymentsCountry]); const connectPaymentsSetup = useCallback(async () => { await runWithSaving(async () => { @@ -410,23 +465,6 @@ export function ProjectOnboardingWizard(props: { }); }, [props.project.app, runWithSaving]); - useEffect(() => { - if (status !== "payments_setup" || stripeAccountInfo?.details_submitted !== true || paymentsAutoCompletingRef.current) { - return; - } - - paymentsAutoCompletingRef.current = true; - runAsynchronouslyWithAlert(async () => { - try { - await persistOnboardingState(); - await setStatus("welcome"); - } catch (error) { - paymentsAutoCompletingRef.current = false; - throw error; - } - }); - }, [persistOnboardingState, setStatus, status, stripeAccountInfo?.details_submitted]); - if (props.status === "welcome") { return ( @@ -479,8 +517,7 @@ export function ProjectOnboardingWizard(props: { className="w-full rounded-full" loading={saving} onClick={() => runAsynchronouslyWithAlert(() => runWithSaving(async () => { - await persistOnboardingState(); - await props.setStatus("apps_selection"); + await saveCurrentOnboardingProgress("apps_selection"); }))} > Continue @@ -517,10 +554,10 @@ export function ProjectOnboardingWizard(props: { className="w-full rounded-full" loading={saving} onClick={() => runAsynchronouslyWithAlert(() => runWithSaving(async () => { - await persistOnboardingState(); if (selectedConfigChoice === "create-new") { - await props.setStatus("apps_selection"); + await saveCurrentOnboardingProgress("apps_selection"); } else { + await saveOnboardingProgress({ onboardingState: buildOnboardingState() }); props.setMode("link-existing"); } }))} @@ -614,8 +651,7 @@ export function ProjectOnboardingWizard(props: { className="w-full rounded-full" loading={saving} onClick={() => runAsynchronouslyWithAlert(() => runWithSaving(async () => { - await persistOnboardingState(); - await props.setStatus("auth_setup"); + await saveCurrentOnboardingProgress("auth_setup"); }))} > Continue @@ -722,8 +758,7 @@ export function ProjectOnboardingWizard(props: { if (signInMethods.size === 0) { throw new Error("Select at least one sign-in method before continuing."); } - await persistOnboardingState(); - await props.setStatus("email_theme_setup"); + await saveCurrentOnboardingProgress("email_theme_setup"); }))} > Continue @@ -833,12 +868,10 @@ export function ProjectOnboardingWizard(props: { className="w-full rounded-full" loading={saving} onClick={() => runAsynchronouslyWithAlert(() => runWithSaving(async () => { - await persistOnboardingState(); - if (includePayments) { - await props.setStatus("payments_setup"); + await saveCurrentOnboardingProgress("payments_setup"); } else { - await props.setStatus("welcome"); + await saveCurrentOnboardingProgress("welcome"); } }))} > @@ -846,68 +879,14 @@ export function ProjectOnboardingWizard(props: { } > -
- {emailThemes.length === 0 && ( - - )} -
- {emailThemes.map((theme) => { - const isSelected = selectedEmailThemeId === theme.id; - return ( - - ); - })} -
-
+ }> + + ); } @@ -946,59 +925,17 @@ export function ProjectOnboardingWizard(props: { ) : undefined} > -
- -
- - Built-in Billing - - -
-
- - No webhooks or syncing required -
-
- - One-time and recurring payments -
-
- - Usage-based billing support -
-
- -
- Country of residence - { - if (value !== "US" && value !== "OTHER") { - throw new Error(`Invalid payments country: ${value}`); - } - setSelectedPaymentsCountry(value); - }} - options={PAYMENT_COUNTRY_OPTIONS.map((country) => ({ value: country.value, label: country.label }))} - size="md" - /> -
- - Powered by - -
- {selectedPaymentsCountry !== "US" && ( - - Payments is currently only available in the United States. - - )} -
-
-
-
+ }> + + + ); } @@ -1018,3 +955,228 @@ export function ProjectOnboardingWizard(props: {
); } + +function EmailThemeSetupStepSkeleton() { + return ( +
+ {["theme-skeleton-one", "theme-skeleton-two", "theme-skeleton-three"].map((id) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +function EmailThemeSetupStep({ + project, + saving, + selectedEmailThemeId, + setSelectedEmailThemeId, +}: { + project: AdminOwnedProject, + saving: boolean, + selectedEmailThemeId: string | null, + setSelectedEmailThemeId: (themeId: string) => void, +}) { + const emailThemes = project.app.useEmailThemes(); + + return ( +
+ {emailThemes.length === 0 && ( + + )} +
+ {emailThemes.map((theme) => { + const isSelected = selectedEmailThemeId === theme.id; + return ( + + ); + })} +
+
+ ); +} + +function PaymentsSetupStepSkeleton() { + return ( +
+
+
+ +
+ {["feature-skeleton-one", "feature-skeleton-two", "feature-skeleton-three"].map((id) => ( +
+ + +
+ ))} +
+
+ + +
+ + + +
+
+
+
+
+ ); +} + +function PaymentsSetupStepContent({ + selectedPaymentsCountry, + setSelectedPaymentsCountry, +}: { + selectedPaymentsCountry: OnboardingPaymentsCountry, + setSelectedPaymentsCountry: (country: OnboardingPaymentsCountry) => void, +}) { + return ( +
+ +
+ + Built-in Billing + + +
+
+ + No webhooks or syncing required +
+
+ + One-time and recurring payments +
+
+ + Usage-based billing support +
+
+ +
+ Country of residence + { + if (value !== "US" && value !== "OTHER") { + throw new Error(`Invalid payments country: ${value}`); + } + setSelectedPaymentsCountry(value); + }} + options={PAYMENT_COUNTRY_OPTIONS.map((country) => ({ value: country.value, label: country.label }))} + size="md" + /> +
+ + Powered by + +
+ {selectedPaymentsCountry !== "US" && ( + + Payments is currently only available in the United States. + + )} +
+
+
+
+ ); +} + +function PaymentsSetupAutoComplete({ + project, + buildOnboardingState, + saveOnboardingProgress, +}: { + project: AdminOwnedProject, + buildOnboardingState: () => ProjectOnboardingState, + saveOnboardingProgress: (update: OnboardingProgressUpdate) => Promise, +}) { + const stripeAccountInfo = project.app.useStripeAccountInfo(); + const autoCompletingRef = useRef(false); + + useEffect(() => { + if (stripeAccountInfo?.details_submitted !== true || autoCompletingRef.current) { + return; + } + + autoCompletingRef.current = true; + runAsynchronouslyWithAlert(async () => { + try { + await saveOnboardingProgress({ + status: "welcome", + onboardingState: buildOnboardingState(), + }); + } catch (error) { + autoCompletingRef.current = false; + throw error; + } + }); + }, [buildOnboardingState, saveOnboardingProgress, stripeAccountInfo?.details_submitted]); + + return null; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/shared.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/shared.ts index d1852b25c..fae1c234a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/shared.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/shared.ts @@ -2,6 +2,7 @@ import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals"; import { AdminOwnedProject } from "@hexclave/next"; import { ALL_APPS, getParentAppId, type AppId } from "@hexclave/shared/dist/apps/apps-config"; import { projectOnboardingStatusValues, type ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields"; +export type { ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields"; import { sharedProviders } from "@hexclave/shared/dist/utils/oauth"; import { stringCompare } from "@hexclave/shared/dist/utils/strings"; @@ -37,6 +38,10 @@ export type ProjectOnboardingState = { selected_payments_country: OnboardingPaymentsCountry, }; +export type OnboardingProgressUpdate = + | { status: ProjectOnboardingStatus, onboardingState?: ProjectOnboardingState | null } + | { status?: ProjectOnboardingStatus, onboardingState: ProjectOnboardingState | null }; + export type HexclaveAppInternals = { sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, refreshOwnedProjects: () => Promise, diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx index 5ec49fc0d..2f0a955dd 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx @@ -1,6 +1,9 @@ // @vitest-environment jsdom import type { ButtonHTMLAttributes } from "react"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; @@ -73,6 +76,18 @@ describe("beginPendingAction", () => { }); }); +describe("new project page data loading", () => { + it("does not manually refetch the internal projects list for onboarding status", () => { + const testDir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join( + testDir, + "page-client-parts/content.tsx", + ), "utf-8"); + + expect(source).not.toMatch(/sendRequest\(\s*["'`]\/internal\/projects["'`]/); + }); +}); + describe("OnboardingPage", () => { it("uses hover-exit-only transitions and accessible labels for progress dots", () => { render( diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts index eb9cdd912..81774bb70 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts @@ -120,6 +120,43 @@ it("creates a new project", async ({ expect }) => { `); }); +it("updates onboarding status and state together without modifying config", async ({ expect }) => { + await Project.createAndSwitch(); + const beforeResponse = await niceBackendFetch("/api/v1/internal/projects/current", { + accessType: "admin", + }); + expect(beforeResponse.status).toBe(200); + + const onboardingState = { + selected_config_choice: "create-new" as const, + selected_apps: ["authentication", "emails", "payments"] as const, + selected_sign_in_methods: ["credential", "magicLink"] as const, + selected_email_theme_id: "a0172b5d-cff0-463b-83bb-85124697373a", + selected_payments_country: "US" as const, + }; + + const patchResponse = await niceBackendFetch("/api/v1/internal/projects/current", { + accessType: "admin", + method: "PATCH", + body: { + onboarding_status: "auth_setup", + onboarding_state: onboardingState, + }, + }); + expect(patchResponse.status).toBe(200); + expect(patchResponse.body.onboarding_status).toBe("auth_setup"); + expect(patchResponse.body.onboarding_state).toEqual(onboardingState); + expect(patchResponse.body.config).toEqual(beforeResponse.body.config); + + const afterResponse = await niceBackendFetch("/api/v1/internal/projects/current", { + accessType: "admin", + }); + expect(afterResponse.status).toBe(200); + expect(afterResponse.body.onboarding_status).toBe("auth_setup"); + expect(afterResponse.body.onboarding_state).toEqual(onboardingState); + expect(afterResponse.body.config).toEqual(beforeResponse.body.config); +}); + it("creates a new project with different configurations", async ({ expect }) => { backendContext.set({ projectKeys: InternalProjectKeys }); await Auth.fastSignUp(); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index 96091e5b1..e8fe88bc9 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -197,6 +197,7 @@ export class _HexclaveAdminAppImplIncomplete { - return await this._interface.getStripeAccountInfo(); + return Result.orThrow(await this._stripeAccountInfoCache.getOrWait([], "write-only")); } // IF_PLATFORM react-like diff --git a/packages/template/src/lib/hexclave-app/projects/index.ts b/packages/template/src/lib/hexclave-app/projects/index.ts index 07a352ea0..3602d006b 100644 --- a/packages/template/src/lib/hexclave-app/projects/index.ts +++ b/packages/template/src/lib/hexclave-app/projects/index.ts @@ -40,6 +40,7 @@ export type AdminProject = { readonly isDevelopmentEnvironment: boolean, readonly ownerTeamId: string | null, readonly onboardingStatus: ProjectOnboardingStatus, + readonly onboardingState: NonNullable | null, readonly logoUrl: string | null | undefined, readonly logoFullUrl: string | null | undefined, readonly logoDarkModeUrl: string | null | undefined,