Project onboarding speedup (#1596)

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: armaan <armaan@stack-auth.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Armaan Jain 2026-06-23 10:59:38 -07:00 committed by GitHub
parent 28b559b7a0
commit 0132ec151a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1040 additions and 358 deletions

View File

@ -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) => {

View File

@ -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<Map<string, ProjectOnboardingStatus>>(new Map());
const [projectOnboardingStates, setProjectOnboardingStates] = useState<Map<string, ProjectOnboardingState | null>>(new Map());
const [loadingStatuses, setLoadingStatuses] = useState(true);
const [projectName, setProjectName] = useState(displayNameFromSearch ?? "");
const hasProjectName = projectName.trim().length > 0;
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(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<string, ProjectOnboardingStatus>();
const onboardingStateMap = new Map<string, ProjectOnboardingState | null>();
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<string, unknown> = {};
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 (
<div className="flex w-full flex-grow items-center justify-center">
<Spinner size={24} />
</div>
);
}
if (selectedProjectId != null && selectedProject == null) {
return (
<div className="w-full flex-grow flex items-center justify-center p-4">
@ -298,7 +236,7 @@ function PageClientInner() {
);
}
if (selectedProject != null && !loadingStatuses && selectedProjectStatus === "completed") {
if (selectedProject != null && selectedProjectStatus === "completed") {
return (
<div className="flex w-full flex-grow items-center justify-center">
<Spinner size={24} />
@ -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") {

View File

@ -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<HTMLButtonElement>) => (
<button type={type ?? "button"} {...props}>{children}</button>
),
Skeleton: ({ children, ...props }: { children?: ReactNode } & HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
Switch: () => <button type="button">switch</button>,
TooltipProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Typography: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@ -98,6 +101,9 @@ vi.mock("@hexclave/shared/dist/utils/oauth", () => ({
}));
vi.mock("@hexclave/shared/dist/utils/promises", () => ({
runAsynchronously: (promiseOrFn: Promise<unknown> | (() => Promise<unknown>)) => (
typeof promiseOrFn === "function" ? promiseOrFn() : promiseOrFn
),
runAsynchronouslyWithAlert: (fn: () => Promise<unknown>) => fn(),
}));
@ -148,6 +154,19 @@ afterEach(() => {
mockUpdateConfig.mockClear();
});
function createDeferred<T>() {
let resolveDeferred: (value: T | PromiseLike<T>) => void = () => {
throw new Error("Deferred promise was resolved before initialization.");
};
const promise = new Promise<T>((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(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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<never>(() => {});
render(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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<never>(() => {});
render(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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<boolean>();
const environmentConfigSave = createDeferred<boolean>();
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(
<ProjectOnboardingWizard
project={{
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
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();
});
});

View File

@ -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<void>,
setOnboardingState: (state: ProjectOnboardingState) => Promise<void>,
clearOnboardingState: () => Promise<void>,
saveOnboardingProgress: (update: OnboardingProgressUpdate) => Promise<void>,
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<string | null>(null);
const paymentsAutoCompletingRef = useRef(false);
const stripeAccountInfo = props.project.app.useStripeAccountInfo();
const finalConfigSavePromiseRef = useRef<Promise<boolean> | null>(null);
const runWithSaving = useCallback(async (fn: () => Promise<void>) => {
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<boolean> => {
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 (
<WelcomeSlide
@ -454,9 +492,9 @@ export function ProjectOnboardingWizard(props: {
const latestConfig = await props.project.getConfig();
const paymentsEnabledInLatestConfig = latestConfig.apps.installed.payments?.enabled === true;
if (paymentsEnabledInLatestConfig) {
await props.setStatus("payments_setup");
await saveOnboardingProgress({ status: "payments_setup" });
} else {
await props.setStatus("welcome");
await saveOnboardingProgress({ status: "welcome" });
}
}}
/>
@ -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: {
</DesignButton>
}
>
<div className="space-y-4">
{emailThemes.length === 0 && (
<DesignAlert
variant="warning"
title="No themes found"
description="Theme selection is temporarily unavailable. You can still continue."
/>
)}
<div className="grid gap-4 sm:grid-cols-3">
{emailThemes.map((theme) => {
const isSelected = selectedEmailThemeId === theme.id;
return (
<button
key={theme.id}
type="button"
onClick={() => setSelectedEmailThemeId(theme.id)}
disabled={saving}
className={cn(
"relative flex flex-col overflow-hidden rounded-2xl text-left transition-[box-shadow,background-color] duration-150 hover:transition-none",
"disabled:cursor-not-allowed disabled:opacity-60",
isSelected
? cn(
"bg-blue-500/[0.06] dark:bg-blue-500/[0.04] ring-1 ring-blue-500/40",
"shadow-[0_12px_40px_-8px_rgba(59,130,246,0.45),0_0_1px_rgba(59,130,246,0.2)]",
"dark:shadow-[0_14px_48px_-10px_rgba(96,165,250,0.38),0_0_1px_rgba(96,165,250,0.25)]",
)
: cn(
"bg-white/90 dark:bg-white/[0.06]",
"ring-1 ring-black/[0.06] hover:ring-black/[0.10] dark:ring-white/[0.10] dark:hover:ring-white/[0.14]",
),
)}
>
<div
className={cn(
"aspect-[4/3] overflow-hidden border-b border-black/[0.06] dark:border-white/[0.06] bg-background transition-opacity duration-150",
!isSelected && "opacity-[0.65]",
)}
>
<div style={{ transform: "scale(0.5)", transformOrigin: "top left", width: "200%", height: "200%" }}>
<OnboardingEmailThemePreview adminApp={props.project.app} themeId={theme.id} />
</div>
</div>
<div className="flex items-center justify-between gap-2 p-3">
<Typography
className={cn(
"min-w-0 flex-1 text-sm font-medium transition-colors duration-150",
isSelected ? "text-foreground" : "text-muted-foreground",
)}
>
{theme.displayName}
</Typography>
{isSelected && (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white shadow-sm">
<CheckCircleIcon className="h-4 w-4" weight="fill" />
</div>
)}
</div>
</button>
);
})}
</div>
</div>
<Suspense fallback={<EmailThemeSetupStepSkeleton />}>
<EmailThemeSetupStep
project={props.project}
saving={saving}
selectedEmailThemeId={selectedEmailThemeId}
setSelectedEmailThemeId={setSelectedEmailThemeId}
/>
</Suspense>
</OnboardingPage>
);
}
@ -946,59 +925,17 @@ export function ProjectOnboardingWizard(props: {
</DesignButton>
) : undefined}
>
<div className="mx-auto w-full max-w-sm">
<DesignCard
glassmorphic={false}
className="border-0 bg-white/90 ring-1 ring-black/[0.06] dark:bg-white/[0.06] dark:ring-white/[0.10]"
contentClassName="!p-6 md:!p-7"
>
<div className="flex flex-col items-center gap-6 md:gap-7">
<Typography type="h2" className="text-center tracking-tight text-balance">
Built-in Billing
</Typography>
<div className="flex w-full flex-col gap-3 rounded-xl bg-foreground/[0.03] px-5 py-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2.5">
<WebhooksLogoIcon className="h-3.5 w-3.5 shrink-0 text-foreground/50" />
<span>No webhooks or syncing required</span>
</div>
<div className="flex items-center gap-2.5">
<ArrowsClockwiseIcon className="h-3.5 w-3.5 shrink-0 text-foreground/50" />
<span>One-time and recurring payments</span>
</div>
<div className="flex items-center gap-2.5">
<ChartBarIcon className="h-3.5 w-3.5 shrink-0 text-foreground/50" />
<span>Usage-based billing support</span>
</div>
</div>
<div className="w-full space-y-2.5">
<Typography className="text-xs font-medium text-muted-foreground">Country of residence</Typography>
<DesignSelectorDropdown
value={selectedPaymentsCountry}
onValueChange={(value) => {
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"
/>
<div className="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1 text-center text-xs text-muted-foreground">
<ShieldCheckIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span>Powered by</span>
<StripeWordmark className="h-3 w-auto shrink-0 translate-y-px text-[#635BFF] dark:text-[#8b87ff]" />
</div>
{selectedPaymentsCountry !== "US" && (
<Typography className="text-center text-xs text-amber-600 dark:text-amber-400">
Payments is currently only available in the United States.
</Typography>
)}
</div>
</div>
</DesignCard>
</div>
<Suspense fallback={<PaymentsSetupStepSkeleton />}>
<PaymentsSetupAutoComplete
project={props.project}
buildOnboardingState={buildOnboardingState}
saveOnboardingProgress={saveOnboardingProgress}
/>
<PaymentsSetupStepContent
selectedPaymentsCountry={selectedPaymentsCountry}
setSelectedPaymentsCountry={setSelectedPaymentsCountry}
/>
</Suspense>
</OnboardingPage>
);
}
@ -1018,3 +955,228 @@ export function ProjectOnboardingWizard(props: {
</div>
);
}
function EmailThemeSetupStepSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-3" data-testid="email-theme-step-skeleton">
{["theme-skeleton-one", "theme-skeleton-two", "theme-skeleton-three"].map((id) => (
<div
key={id}
className="relative flex flex-col overflow-hidden rounded-2xl bg-white/90 ring-1 ring-black/[0.06] dark:bg-white/[0.06] dark:ring-white/[0.10]"
>
<Skeleton className="aspect-[4/3] rounded-none border-b border-black/[0.06] bg-foreground/[0.08] dark:border-white/[0.06]" />
<div className="flex items-center justify-between gap-2 p-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-5 rounded-full" />
</div>
</div>
))}
</div>
);
}
function EmailThemeSetupStep({
project,
saving,
selectedEmailThemeId,
setSelectedEmailThemeId,
}: {
project: AdminOwnedProject,
saving: boolean,
selectedEmailThemeId: string | null,
setSelectedEmailThemeId: (themeId: string) => void,
}) {
const emailThemes = project.app.useEmailThemes();
return (
<div className="space-y-4">
{emailThemes.length === 0 && (
<DesignAlert
variant="warning"
title="No themes found"
description="Theme selection is temporarily unavailable. You can still continue."
/>
)}
<div className="grid gap-4 sm:grid-cols-3">
{emailThemes.map((theme) => {
const isSelected = selectedEmailThemeId === theme.id;
return (
<button
key={theme.id}
type="button"
onClick={() => setSelectedEmailThemeId(theme.id)}
disabled={saving}
className={cn(
"relative flex flex-col overflow-hidden rounded-2xl text-left transition-[box-shadow,background-color] duration-150 hover:transition-none",
"disabled:cursor-not-allowed disabled:opacity-60",
isSelected
? cn(
"bg-blue-500/[0.06] dark:bg-blue-500/[0.04] ring-1 ring-blue-500/40",
"shadow-[0_12px_40px_-8px_rgba(59,130,246,0.45),0_0_1px_rgba(59,130,246,0.2)]",
"dark:shadow-[0_14px_48px_-10px_rgba(96,165,250,0.38),0_0_1px_rgba(96,165,250,0.25)]",
)
: cn(
"bg-white/90 dark:bg-white/[0.06]",
"ring-1 ring-black/[0.06] hover:ring-black/[0.10] dark:ring-white/[0.10] dark:hover:ring-white/[0.14]",
),
)}
>
<div
className={cn(
"aspect-[4/3] overflow-hidden border-b border-black/[0.06] dark:border-white/[0.06] bg-background transition-opacity duration-150",
!isSelected && "opacity-[0.65]",
)}
>
<div style={{ transform: "scale(0.5)", transformOrigin: "top left", width: "200%", height: "200%" }}>
<OnboardingEmailThemePreview adminApp={project.app} themeId={theme.id} />
</div>
</div>
<div className="flex items-center justify-between gap-2 p-3">
<Typography
className={cn(
"min-w-0 flex-1 text-sm font-medium transition-colors duration-150",
isSelected ? "text-foreground" : "text-muted-foreground",
)}
>
{theme.displayName}
</Typography>
{isSelected && (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white shadow-sm">
<CheckCircleIcon className="h-4 w-4" weight="fill" />
</div>
)}
</div>
</button>
);
})}
</div>
</div>
);
}
function PaymentsSetupStepSkeleton() {
return (
<div className="mx-auto w-full max-w-sm" data-testid="payments-setup-step-skeleton">
<div className="rounded-2xl bg-white/90 p-6 ring-1 ring-black/[0.06] dark:bg-white/[0.06] dark:ring-white/[0.10] md:p-7">
<div className="flex flex-col items-center gap-6 md:gap-7">
<Skeleton className="h-7 w-40" />
<div className="flex w-full flex-col gap-3 rounded-xl bg-foreground/[0.03] px-5 py-4">
{["feature-skeleton-one", "feature-skeleton-two", "feature-skeleton-three"].map((id) => (
<div key={id} className="flex items-center gap-2.5">
<Skeleton className="h-3.5 w-3.5 rounded-full" />
<Skeleton className="h-4 w-full max-w-[220px]" />
</div>
))}
</div>
<div className="w-full space-y-2.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-10 w-full rounded-xl" />
<div className="flex items-center justify-center gap-1.5">
<Skeleton className="h-3.5 w-3.5 rounded-full" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</div>
</div>
</div>
);
}
function PaymentsSetupStepContent({
selectedPaymentsCountry,
setSelectedPaymentsCountry,
}: {
selectedPaymentsCountry: OnboardingPaymentsCountry,
setSelectedPaymentsCountry: (country: OnboardingPaymentsCountry) => void,
}) {
return (
<div className="mx-auto w-full max-w-sm">
<DesignCard
glassmorphic={false}
className="border-0 bg-white/90 ring-1 ring-black/[0.06] dark:bg-white/[0.06] dark:ring-white/[0.10]"
contentClassName="!p-6 md:!p-7"
>
<div className="flex flex-col items-center gap-6 md:gap-7">
<Typography type="h2" className="text-center tracking-tight text-balance">
Built-in Billing
</Typography>
<div className="flex w-full flex-col gap-3 rounded-xl bg-foreground/[0.03] px-5 py-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2.5">
<WebhooksLogoIcon className="h-3.5 w-3.5 shrink-0 text-foreground/50" />
<span>No webhooks or syncing required</span>
</div>
<div className="flex items-center gap-2.5">
<ArrowsClockwiseIcon className="h-3.5 w-3.5 shrink-0 text-foreground/50" />
<span>One-time and recurring payments</span>
</div>
<div className="flex items-center gap-2.5">
<ChartBarIcon className="h-3.5 w-3.5 shrink-0 text-foreground/50" />
<span>Usage-based billing support</span>
</div>
</div>
<div className="w-full space-y-2.5">
<Typography className="text-xs font-medium text-muted-foreground">Country of residence</Typography>
<DesignSelectorDropdown
value={selectedPaymentsCountry}
onValueChange={(value) => {
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"
/>
<div className="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1 text-center text-xs text-muted-foreground">
<ShieldCheckIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span>Powered by</span>
<StripeWordmark className="h-3 w-auto shrink-0 translate-y-px text-[#635BFF] dark:text-[#8b87ff]" />
</div>
{selectedPaymentsCountry !== "US" && (
<Typography className="text-center text-xs text-amber-600 dark:text-amber-400">
Payments is currently only available in the United States.
</Typography>
)}
</div>
</div>
</DesignCard>
</div>
);
}
function PaymentsSetupAutoComplete({
project,
buildOnboardingState,
saveOnboardingProgress,
}: {
project: AdminOwnedProject,
buildOnboardingState: () => ProjectOnboardingState,
saveOnboardingProgress: (update: OnboardingProgressUpdate) => Promise<void>,
}) {
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;
}

View File

@ -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<Response>,
refreshOwnedProjects: () => Promise<void>,

View File

@ -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(

View File

@ -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();

View File

@ -197,6 +197,7 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
isDevelopmentEnvironment: data.is_development_environment,
ownerTeamId: data.owner_team_id,
onboardingStatus: data.onboarding_status,
onboardingState: data.onboarding_state ?? null,
logoUrl: data.logo_url,
logoFullUrl: data.logo_full_url,
logoDarkModeUrl: data.logo_dark_mode_url,
@ -1206,7 +1207,7 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
// END_PLATFORM
async getStripeAccountInfo(): Promise<null | { account_id: string, charges_enabled: boolean, details_submitted: boolean, payouts_enabled: boolean }> {
return await this._interface.getStripeAccountInfo();
return Result.orThrow(await this._stripeAccountInfoCache.getOrWait([], "write-only"));
}
// IF_PLATFORM react-like

View File

@ -40,6 +40,7 @@ export type AdminProject = {
readonly isDevelopmentEnvironment: boolean,
readonly ownerTeamId: string | null,
readonly onboardingStatus: ProjectOnboardingStatus,
readonly onboardingState: NonNullable<ProjectsCrud["Admin"]["Read"]["onboarding_state"]> | null,
readonly logoUrl: string | null | undefined,
readonly logoFullUrl: string | null | undefined,
readonly logoDarkModeUrl: string | null | undefined,