mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
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:
parent
28b559b7a0
commit
0132ec151a
@ -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) => {
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user