Small onboarding fixes (#1489)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/hexclave/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Improves onboarding and analytics visuals. Adds clear Stripe setup
actions (Connect or Do Later) with safe redirects and precise
loading/disabled states, and fixes a stuck dashboard reload after
linking an existing project.

- **New Features**
- Payments onboarding: separate Connect/Do Later actions with per-button
loading and mutual disabling.
- US-only: “Do Later” creates a deferred Stripe account before finishing
onboarding.
- Secure redirect: enforce HTTPS on Connect and navigate via
window.location.href.
- Refresh Stripe account cache after `setupPayments()` to avoid stale
data on return.
- Analytics: smoother pie hover transitions, fading center label,
optional `showDateRange`; dashboard donut hides date range and adjusts
radii; revenue hover chart uses split bars for rounded tops and an avg
line.
- Tests cover deferred/unsupported payments setup and button loading
isolation.

- **Bug Fixes**
- After linking an existing config, use a full page navigation to the
project to prevent the dashboard from getting stuck on initial load.

<sup>Written for commit c80034ad1f.
Summary will update on new commits. <a
href="https://cubic.dev/pr/hexclave/stack-auth/pull/1489?utm_source=github">Review
in cubic</a></sup>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Option to defer payments setup during project onboarding;
connect/defer actions show targeted loading states.

* **Improvements**
* Linking existing projects now performs full navigation when
applicable.
* Charts: refined donut/pie sizing, smoother center fade animation,
optional date-range display, and improved color/stack rendering.
  * Payments setup now refreshes account info after setup.

* **Tests**
* Added tests covering payments deferral, connect flows, and UI/loading
behavior.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1489?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Armaan Jain 2026-05-26 17:45:50 -07:00 committed by Madison
parent 72a69b8355
commit 9a2ffbe8d1
7 changed files with 359 additions and 43 deletions

View File

@ -531,7 +531,12 @@ function PageClientInner() {
setOnboardingState={(nextState) => setSelectedProjectOnboardingState(selectedProject, nextState)}
clearOnboardingState={() => setSelectedProjectOnboardingState(selectedProject, null)}
onComplete={() => {
router.push(`/projects/${encodeURIComponent(selectedProject.id)}`);
const projectUrl = `/projects/${encodeURIComponent(selectedProject.id)}`;
if (mode === "link-existing") {
window.location.href = projectUrl;
return;
}
router.push(projectUrl);
}}
/>
</div>

View File

@ -24,11 +24,11 @@ vi.mock("@/components/design-components/button", () => ({
DesignButton: ({
children,
type,
loading: _loading,
loading,
variant: _variant,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean, variant?: string }) => (
<button type={type ?? "button"} {...props}>{children}</button>
<button type={type ?? "button"} data-loading={loading ? "true" : "false"} {...props}>{children}</button>
),
}));
@ -231,6 +231,236 @@ describe("ProjectOnboardingWizard", () => {
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 setupPayments = vi.fn(async () => ({ url: "https://example.com" }));
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,
useEmailThemes: () => [],
useStripeAccountInfo: () => null,
},
} as never}
status="payments_setup"
onboardingState={null}
mode={null}
setMode={vi.fn()}
setStatus={setStatus}
setOnboardingState={setOnboardingState}
clearOnboardingState={vi.fn(async () => {})}
onComplete={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Do Later"));
await waitFor(() => {
expect(setupPayments).toHaveBeenCalledOnce();
});
expect(setOnboardingState).toHaveBeenCalledOnce();
expect(setStatus).toHaveBeenCalledWith("welcome");
});
it("does not create a Stripe account when payments setup is deferred for an unsupported country", async () => {
const setStatus = vi.fn(async () => {});
const setupPayments = vi.fn(async () => ({ url: "https://example.com" }));
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,
useEmailThemes: () => [],
useStripeAccountInfo: () => null,
},
} as never}
status="payments_setup"
onboardingState={{
selected_config_choice: "create-new",
selected_apps: ["authentication", "emails", "payments"],
selected_sign_in_methods: ["credential"],
selected_email_theme_id: "default",
selected_payments_country: "OTHER",
}}
mode={null}
setMode={vi.fn()}
setStatus={setStatus}
setOnboardingState={vi.fn(async () => {})}
clearOnboardingState={vi.fn(async () => {})}
onComplete={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Do Later"));
await waitFor(() => {
expect(setStatus).toHaveBeenCalledWith("welcome");
});
expect(setupPayments).not.toHaveBeenCalled();
});
it("only shows a loading indicator on the deferred payments action while disabling connect", async () => {
const setupPayments = vi.fn(() => new Promise<{ url: string }>(() => {}));
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,
useEmailThemes: () => [],
useStripeAccountInfo: () => null,
},
} as never}
status="payments_setup"
onboardingState={null}
mode={null}
setMode={vi.fn()}
setStatus={vi.fn(async () => {})}
setOnboardingState={vi.fn(async () => {})}
clearOnboardingState={vi.fn(async () => {})}
onComplete={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Do Later" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Do Later" }).getAttribute("data-loading")).toBe("true");
});
expect(screen.getByRole("button", { name: "Do Later" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "Connect" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "Connect" }).getAttribute("data-loading")).toBe("false");
});
it("only shows a loading indicator on the connect payments action while disabling defer", async () => {
const setupPayments = vi.fn(() => new Promise<{ url: string }>(() => {}));
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,
useEmailThemes: () => [],
useStripeAccountInfo: () => null,
},
} as never}
status="payments_setup"
onboardingState={null}
mode={null}
setMode={vi.fn()}
setStatus={vi.fn(async () => {})}
setOnboardingState={vi.fn(async () => {})}
clearOnboardingState={vi.fn(async () => {})}
onComplete={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Connect" }).getAttribute("data-loading")).toBe("true");
});
expect(screen.getByRole("button", { name: "Connect" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "Do Later" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "Do Later" }).getAttribute("data-loading")).toBe("false");
});
it("persists shared OAuth providers selected during onboarding before completing", async () => {
const setStatus = vi.fn(async () => {});
const clearOnboardingState = vi.fn(async () => {});

View File

@ -117,6 +117,7 @@ export function ProjectOnboardingWizard(props: {
const [authSetupMobileTab, setAuthSetupMobileTab] = useState<"methods" | "preview">("methods");
const [domainSetupAutoAdvanceError, setDomainSetupAutoAdvanceError] = useState<string | null>(null);
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();
@ -166,6 +167,7 @@ export function ProjectOnboardingWizard(props: {
setAuthSetupMobileTab("methods");
setDomainSetupAutoAdvanceError(null);
setDomainSetupAutoAdvancing(false);
setPaymentsSetupAction(null);
paymentsAutoCompletingRef.current = false;
}, [completeConfig, deriveCurrentOnboardingState, project, project.id, status]);
@ -375,6 +377,37 @@ export function ProjectOnboardingWizard(props: {
updateConfig,
]);
const deferPaymentsSetup = useCallback(async () => {
await runWithSaving(async () => {
setPaymentsSetupAction("defer");
try {
if (selectedPaymentsCountry === "US") {
await props.project.app.setupPayments();
}
await persistOnboardingState();
await setStatus("welcome");
} finally {
setPaymentsSetupAction(null);
}
});
}, [persistOnboardingState, props.project.app, runWithSaving, selectedPaymentsCountry, setStatus]);
const connectPaymentsSetup = useCallback(async () => {
await runWithSaving(async () => {
setPaymentsSetupAction("connect");
try {
const setup = await props.project.app.setupPayments();
const redirectUrl = new URL(setup.url);
if (redirectUrl.protocol !== "https:") {
throw new Error("Payments setup redirect URL must use HTTPS.");
}
window.location.href = redirectUrl.toString();
} finally {
setPaymentsSetupAction(null);
}
});
}, [props.project.app, runWithSaving]);
useEffect(() => {
if (status !== "payments_setup" || stripeAccountInfo?.details_submitted !== true || paymentsAutoCompletingRef.current) {
return;
@ -893,12 +926,9 @@ export function ProjectOnboardingWizard(props: {
primaryAction={
<DesignButton
className="rounded-full px-6"
loading={saving}
onClick={() => runAsynchronouslyWithAlert(() => runWithSaving(async () => {
await persistOnboardingState();
await props.setStatus("welcome");
}))}
disabled={saving || paymentsSetupAction != null}
loading={paymentsSetupAction === "defer"}
onClick={() => runAsynchronouslyWithAlert(deferPaymentsSetup)}
>
Do Later
</DesignButton>
@ -907,15 +937,9 @@ export function ProjectOnboardingWizard(props: {
<DesignButton
className="rounded-full px-6"
variant="outline"
loading={saving}
onClick={() => runAsynchronouslyWithAlert(() => runWithSaving(async () => {
const setup = await props.project.app.setupPayments();
const redirectUrl = new URL(setup.url);
if (redirectUrl.protocol !== "https:") {
throw new Error("Payments setup redirect URL must use HTTPS.");
}
window.location.href = redirectUrl.toString();
}))}
disabled={saving || paymentsSetupAction != null}
loading={paymentsSetupAction === "connect"}
onClick={() => runAsynchronouslyWithAlert(connectPaymentsSetup)}
>
Connect
</DesignButton>

View File

@ -1970,8 +1970,8 @@ export function DonutChartDisplay({
}), [chartState, chartSegments, chartSegmentSeries]);
const pieSize = compact
? { innerRadius: 32, outerRadius: 48, className: "h-[140px]" }
: { innerRadius: 48, outerRadius: 72, className: "h-[180px]" };
? { innerRadius: 42, outerRadius: 56, className: "h-[140px]", showDateRange: false }
: { innerRadius: 62, outerRadius: 78, className: "h-[180px]", showDateRange: false };
return (
<ChartCard
@ -2443,7 +2443,7 @@ export function VisitorsHoverChart({
);
}
// ── Revenue hover chart (new_cents + refund_cents stacked bar) ───────────────
// ── Revenue hover chart (revenue bar + avg line) ────────────────────────────
const revenueHoverChartConfig: ChartConfig = {
new_cents: {
@ -2485,14 +2485,14 @@ function RevenueHoverTooltip({ active, payload }: TooltipProps<number, string>)
<span className="text-[11px] font-medium text-muted-foreground tracking-wide">{formattedDate}</span>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: "hsl(268, 82%, 66%)" }} />
<span className="h-2 w-2 rounded-full ring-2 ring-white/20" style={{ backgroundColor: "var(--color-new_cents)" }} />
<span className="text-[11px] text-muted-foreground">Revenue</span>
<span className="ml-auto font-mono text-xs font-semibold tabular-nums text-foreground">
{formatUsdCompact(row.new_cents)}
</span>
</div>
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: "hsl(355, 70%, 68%)" }} />
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: "var(--color-refund_cents)" }} />
<span className="text-[11px] text-muted-foreground">Refunds</span>
<span className="ml-auto font-mono text-xs font-semibold tabular-nums text-foreground">
{formatUsdCompact(row.refund_cents)}
@ -2541,6 +2541,8 @@ export function RevenueHoverChart({
const isHighlightedAvg = hoveredIndex != null && i <= hoveredIndex && i >= hoveredIndex - 6;
return {
...p,
new_cents_square: p.refund_cents > 0 ? p.new_cents : 0,
new_cents_rounded: p.refund_cents > 0 ? 0 : p.new_cents,
movingAvg,
avg7d: sevenDayAvgs[i],
highlightedAvg: isHighlightedAvg ? movingAvg : null,
@ -2576,13 +2578,27 @@ export function RevenueHoverChart({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
<Bar dataKey="new_cents" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
<Bar dataKey="new_cents_square" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
return (
<Cell
key={`nc-${index}`}
key={`ncs-${index}`}
opacity={getDimmedOpacity(baseOpacity, index, hoveredIndex)}
stroke={isActiveBar ? "hsl(var(--background))" : undefined}
strokeWidth={isActiveBar ? 1 : 0}
/>
);
})}
</Bar>
<Bar dataKey="new_cents_rounded" stackId="revenue" fill="var(--color-new_cents)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
return (
<Cell
key={`ncr-${index}`}
opacity={getDimmedOpacity(baseOpacity, index, hoveredIndex)}
stroke={isActiveBar ? "hsl(var(--background))" : undefined}
strokeWidth={isActiveBar ? 1 : 0}

View File

@ -1,7 +1,7 @@
import { MagnifyingGlassMinusIcon } from "@phosphor-icons/react";
import { cn } from "@stackframe/stack-ui";
import { type DesignChartConfig, DesignChartContainer } from "../chart-container";
import { type CSSProperties, type Ref, useMemo } from "react";
import { type CSSProperties, type Ref, useEffect, useMemo, useState } from "react";
import { Cell, Pie, PieChart } from "recharts";
import { TrendPill } from "./default-analytics-chart-tooltip";
import { formatDelta } from "./format";
@ -75,6 +75,11 @@ export function AnalyticsChartPie({
const compareInnerRadius = pie?.compareInnerRadius ?? 36;
const compareOuterRadius = pie?.compareOuterRadius ?? 52;
const containerClassName = pie?.className ?? DEFAULT_PIE_CLASSNAME;
const showDateRange = pie?.showDateRange ?? true;
const centerTextMaxWidth = Math.max(64, innerRadius * 2 - 28);
const segmentTransitionStyle: CSSProperties = {
transition: "opacity 180ms ease-out",
};
const canonicalSeries = primarySegmentSeries.length > 0
? primarySegmentSeries
@ -157,9 +162,31 @@ export function AnalyticsChartPie({
return config;
}, [canonicalSeries, segmentColors]);
const activeRow = hoverKey
const activeRow = hoverKey != null
? legendRows.find((r) => r.key === hoverKey) ?? null
: null;
const [centerDisplayKey, setCenterDisplayKey] = useState<string | null>(null);
const [centerDisplayVisible, setCenterDisplayVisible] = useState(true);
const centerDisplayRow = centerDisplayKey != null
? legendRows.find((r) => r.key === centerDisplayKey) ?? null
: null;
useEffect(() => {
const nextKey = activeRow?.key ?? null;
if (nextKey === centerDisplayKey) {
return;
}
setCenterDisplayVisible(false);
const timeoutId = window.setTimeout(() => {
setCenterDisplayKey(nextKey);
setCenterDisplayVisible(true);
}, 120);
return () => {
window.clearTimeout(timeoutId);
};
}, [activeRow?.key, centerDisplayKey]);
const activeDelta = activeRow
? formatDelta(activeRow.value, activeRow.prevValue)
: formatDelta(aggregatedPrimaryTotal, aggregatedCompareTotal);
@ -170,7 +197,7 @@ export function AnalyticsChartPie({
const outerData = legendRows.map((r) => ({ name: cssIdent(r.key), hoverKey: r.key, value: r.value, fill: r.fill }));
const innerData = legendRows.map((r) => ({ name: cssIdent(r.key), hoverKey: r.key, value: r.prevValue, fill: r.fillCompare }));
const activeIdx = hoverKey ? legendRows.findIndex((r) => r.key === hoverKey) : -1;
const activeIdx = hoverKey != null ? legendRows.findIndex((r) => r.key === hoverKey) : -1;
return (
<div
@ -224,6 +251,7 @@ export function AnalyticsChartPie({
key={`outer-${d.name}`}
fill={`var(--color-${d.name})`}
opacity={inactive ? 0.22 : 1}
style={segmentTransitionStyle}
onMouseEnter={() => setHoverKey(d.hoverKey)}
onMouseLeave={() => setHoverKey(null)}
/>
@ -253,6 +281,7 @@ export function AnalyticsChartPie({
key={`inner-${d.name}`}
fill={`var(--color-compare-${d.name})`}
opacity={inactive ? 0.22 : 0.95}
style={segmentTransitionStyle}
onMouseEnter={() => setHoverKey(d.hoverKey)}
onMouseLeave={() => setHoverKey(null)}
/>
@ -263,24 +292,33 @@ export function AnalyticsChartPie({
</PieChart>
</DesignChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="block max-w-[68px] truncate font-mono text-[9px] uppercase tracking-wider text-muted-foreground">
{activeRow ? activeRow.label : strings.pieTotalCenter}
<div
className={cn(
"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center transition-opacity duration-200 ease-out",
centerDisplayVisible ? "opacity-100" : "opacity-35",
)}
>
<span className="block truncate font-mono text-[9px] uppercase tracking-wider text-muted-foreground" style={{ maxWidth: centerTextMaxWidth }}>
{centerDisplayRow ? centerDisplayRow.label : strings.pieTotalCenter}
</span>
<span className="mt-0.5 block max-w-[72px] truncate font-mono text-xl font-semibold leading-none tabular-nums text-foreground">
{fmtValue(activeRow ? activeRow.value : canonicalTotal, yFormatKind)}
<span className="mt-0.5 block truncate font-mono text-xl font-semibold leading-none tabular-nums text-foreground" style={{ maxWidth: centerTextMaxWidth }}>
{fmtValue(centerDisplayRow ? centerDisplayRow.value : canonicalTotal, yFormatKind)}
</span>
</div>
</div>
<div className="flex items-center gap-2 text-center">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/80">
{startLabel} {endLabel}
</span>
{showCompare && (
<TrendPill delta={activeDelta} size="sm" label={strings.pieVsPrev} />
)}
</div>
{(showDateRange || showCompare) && (
<div className="flex items-center gap-2 text-center">
{showDateRange && (
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/80">
{startLabel} {endLabel}
</span>
)}
{showCompare && (
<TrendPill delta={activeDelta} size="sm" label={strings.pieVsPrev} />
)}
</div>
)}
</div>
<ul className="flex min-w-[200px] max-w-[300px] flex-col gap-1">
@ -297,7 +335,7 @@ export function AnalyticsChartPie({
onFocus={() => setHoverKey(r.key)}
onBlur={() => setHoverKey(null)}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-[background-color,opacity] duration-150 hover:bg-foreground/[0.04] hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20",
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-[background-color,opacity] duration-200 ease-out hover:bg-foreground/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20",
isActive && "bg-foreground/[0.05]",
dimmed && "opacity-50",
)}

View File

@ -189,6 +189,7 @@ export type AnalyticsChartPieProps = {
compareInnerRadius?: number,
compareOuterRadius?: number,
className?: string,
showDateRange?: boolean,
};
export type AnalyticsChartSegmentRamp =

View File

@ -814,7 +814,9 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
}
async setupPayments(): Promise<{ url: string }> {
return await this._interface.setupPayments();
const result = await this._interface.setupPayments();
await this._stripeAccountInfoCache.refresh([]);
return result;
}
async createStripeWidgetAccountSession(): Promise<{ client_secret: string }> {