mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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 -->
[](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:
parent
72a69b8355
commit
9a2ffbe8d1
@ -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>
|
||||
|
||||
@ -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 () => {});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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",
|
||||
)}
|
||||
|
||||
@ -189,6 +189,7 @@ export type AnalyticsChartPieProps = {
|
||||
compareInnerRadius?: number,
|
||||
compareOuterRadius?: number,
|
||||
className?: string,
|
||||
showDateRange?: boolean,
|
||||
};
|
||||
|
||||
export type AnalyticsChartSegmentRamp =
|
||||
|
||||
@ -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 }> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user