feat(web): onboarding — skip affordance, stable layout, robust timing, dismissible close

- Show a "Atla" link once the trial actually provisioned so impatient or
  returning users can jump straight to the confirmation card; captures
  onboarding_skipped.
- Reserve a min-height on the provisioning container so the dialog
  doesn't jerk when the loader/error blocks appear or disappear.
- Re-check animation-ended on visibilitychange — background tabs throttle
  setTimeout, which could leave a user stuck if they tabbed away during
  the 7.5s window.
- Show the default close X again when the modal is dismissible (completed
  or escape-hatch active) instead of hiding it for the whole lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:26:45 +03:00
parent 469a7af67e
commit 229ada8adf

View File

@@ -116,10 +116,23 @@ export function WelcomeOnboardingModal({
trialMutation.mutate();
}, [subData, eligibleForTrial, onFinished, trialMutation.mutate]);
// The animation runs ~7s; mark it ended a touch after.
// The animation runs ~7s; mark it ended a touch after. Background tabs
// throttle setTimeout, so if the tab was hidden during the window we
// re-check on visibilitychange and force the flag if the animation should
// already be over.
useEffect(() => {
const start = Date.now();
const timer = setTimeout(() => setAnimationEnded(true), 7500);
return () => clearTimeout(timer);
const onVisibility = () => {
if (document.visibilityState === "visible" && Date.now() - start >= 7500) {
setAnimationEnded(true);
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
// Safety net: if we've been stuck on provisioning past the threshold, OR the
@@ -157,7 +170,9 @@ export function WelcomeOnboardingModal({
}}
>
<DialogContent
className="max-w-[calc(100vw-2rem)] overflow-hidden border-brand/25 [&>button]:hidden sm:max-w-lg"
className={`max-w-[calc(100vw-2rem)] overflow-hidden border-brand/25 sm:max-w-lg ${
canDismiss ? "" : "[&>button]:hidden"
}`}
onInteractOutside={(e) => {
if (!canDismiss) e.preventDefault();
}}
@@ -166,7 +181,7 @@ export function WelcomeOnboardingModal({
}}
>
{phase === "provisioning" ? (
<div className="flex flex-col items-center gap-6 py-4">
<div className="flex min-h-[300px] flex-col items-center gap-6 py-4">
<DialogHeader className="items-center">
<DialogTitle className="flex items-center gap-2 text-xl">
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
@@ -209,6 +224,20 @@ export function WelcomeOnboardingModal({
{t("subscription.onboarding.step4")}...
</div>
)}
{/* Once the trial is real, let impatient or returning users skip
the rest of the animation and jump to the confirmation card. */}
{trialMutation.isSuccess && !animationEnded && (
<button
type="button"
onClick={() => {
capture("onboarding_skipped", { source: "welcome_onboarding" });
setAnimationEnded(true);
}}
className="text-xs text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
{t("subscription.onboarding.skip")}
</button>
)}
{trialMutation.isError && !escapeHatchEnabled && (
<div className="flex flex-col items-center gap-3">
<p className="text-sm leading-5 text-red-600 dark:text-red-400">