fix(web): onboarding modal — escape hatch + trial_failed analytics

When the trial mutation either fails repeatedly or just hangs past 30s,
new users were trapped staring at the spinner with no way out. Add:

- A `trial_failed` capture on every mutation error (Onboarding funnel can
  finally measure the activation failure rate).
- After 2 failures OR 30s in provisioning, surface a "still trying"
  panel with "Destek ile konuş" (opens Chatwoot) and "Yine de kapat"
  options, and let interact-outside / Esc dismiss the modal again.
- An openChatwoot() helper + typed `toggle` on the ChatwootApi so other
  components can open the support widget without poking the global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:24:20 +03:00
parent 209e98cb41
commit eb7b75fbe8
4 changed files with 85 additions and 9 deletions

View File

@@ -1,4 +1,5 @@
import { api } from "@/lib/api-client";
import { openChatwoot } from "@/lib/chatwoot";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { getUserSettings } from "@/lib/user-settings";
@@ -60,8 +61,14 @@ export function WelcomeOnboardingModal({
const queryClient = useQueryClient();
const [phase, setPhase] = useState<"provisioning" | "completed">("provisioning");
const [animationEnded, setAnimationEnded] = useState(false);
const [failureCount, setFailureCount] = useState(0);
// The user gets a way out (close + contact support) once the trial has either
// failed twice or just hung for too long — never trap them inside the modal.
const [escapeHatchEnabled, setEscapeHatchEnabled] = useState(false);
const provisionedRef = useRef(false);
const refAppliedRef = useRef(false);
const MAX_FAILURES = 2;
const STUCK_TIMEOUT_MS = 30_000;
const [isDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
return theme === "system"
@@ -78,8 +85,14 @@ export function WelcomeOnboardingModal({
const trialMutation = useMutation({
mutationFn: () => api.post("/subscriptions/trial"),
onSuccess: () => {},
onError: () => {},
onError: (err) => {
setFailureCount((c) => c + 1);
capture("trial_failed", {
source: "welcome_onboarding",
error: err instanceof Error ? err.message : String(err),
attempt: failureCount + 1,
});
},
});
// Apply a carried-over referral code once (covers the Google OAuth signup path,
@@ -109,6 +122,21 @@ export function WelcomeOnboardingModal({
return () => clearTimeout(timer);
}, []);
// Safety net: if we've been stuck on provisioning past the threshold, OR the
// trial mutation has failed enough times, surface an escape hatch so the user
// can leave the modal and reach support instead of staring at a stuck loader.
useEffect(() => {
if (escapeHatchEnabled || phase === "completed") return;
const timer = setTimeout(() => setEscapeHatchEnabled(true), STUCK_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [escapeHatchEnabled, phase]);
useEffect(() => {
if (failureCount >= MAX_FAILURES) setEscapeHatchEnabled(true);
}, [failureCount]);
const canDismiss = phase === "completed" || escapeHatchEnabled;
// Move to the completed state once the animation finished AND the trial provisioned.
useEffect(() => {
if (phase !== "provisioning") return;
@@ -124,17 +152,17 @@ export function WelcomeOnboardingModal({
<Dialog
open
onOpenChange={(open) => {
// Only the completed-state CTA / Esc may dismiss; ignore closes mid-provisioning.
if (!open && phase === "completed") onFinished();
// Completed or escape-hatch may dismiss; everything else ignores closes.
if (!open && canDismiss) onFinished();
}}
>
<DialogContent
className="max-w-[calc(100vw-2rem)] overflow-hidden border-brand/25 [&>button]:hidden sm:max-w-lg"
onInteractOutside={(e) => {
if (phase !== "completed") e.preventDefault();
if (!canDismiss) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (phase !== "completed") e.preventDefault();
if (!canDismiss) e.preventDefault();
}}
>
{phase === "provisioning" ? (
@@ -181,7 +209,7 @@ export function WelcomeOnboardingModal({
{t("subscription.onboarding.step4")}...
</div>
)}
{trialMutation.isError && (
{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">
{t("subscription.onboarding.error")}
@@ -191,6 +219,38 @@ export function WelcomeOnboardingModal({
</Button>
</div>
)}
{escapeHatchEnabled && (
<div className="flex w-full flex-col items-center gap-3 rounded-xl border border-border bg-muted/40 p-4">
<p className="text-sm font-medium leading-5">
{t("subscription.onboarding.stillTryingTitle")}
</p>
<p className="text-center text-xs leading-relaxed text-muted-foreground">
{t("subscription.onboarding.stillTryingHint")}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
openChatwoot();
capture("onboarding_support_clicked", { failures: failureCount });
}}
>
{t("subscription.onboarding.contactSupport")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
capture("onboarding_escaped", { failures: failureCount });
onFinished();
}}
>
{t("subscription.onboarding.closeAnyway")}
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center gap-6 py-4">

View File

@@ -24,6 +24,7 @@ interface ChatwootApi {
setCustomAttributes: (attrs: Record<string, string | number | boolean>) => void;
deleteCustomAttribute: (key: string) => void;
reset: () => void;
toggle: (state?: "open" | "close") => void;
}
declare global {
@@ -127,3 +128,8 @@ export function clearChatwootVehicle(): void {
window.$chatwoot?.deleteCustomAttribute("son_arac");
});
}
/** Programmatically open the support chat widget (no-op if it failed to load). */
export function openChatwoot(): void {
whenReady(() => window.$chatwoot?.toggle("open"));
}

View File

@@ -288,7 +288,12 @@
"trialDuration": "30-Day Trial",
"startSearching": "Start Searching",
"error": "An error occurred while starting your trial.",
"retry": "Try Again"
"retry": "Try Again",
"stillTryingTitle": "This is taking longer than expected",
"stillTryingHint": "We can't confirm your subscription right now. You can close this and start searching, or reach out to support.",
"contactSupport": "Contact support",
"closeAnyway": "Close anyway",
"skip": "Skip"
},
"downgradeOffer": {
"title": "Downgrade instead of cancelling",

View File

@@ -288,7 +288,12 @@
"trialDuration": "30 Gün Deneme",
"startSearching": "Şase Aramaya Başla",
"error": "Deneme başlatılırken bir hata oluştu.",
"retry": "Tekrar Dene"
"retry": "Tekrar Dene",
"stillTryingTitle": "Beklenenden uzun sürdü",
"stillTryingHint": "Aboneliği şu an doğrulayamıyoruz. Modal'ı kapatıp aramaya geçebilir ya da destek ekibimize yazabilirsiniz.",
"contactSupport": "Destek ile konuş",
"closeAnyway": "Yine de kapat",
"skip": "Atla"
},
"downgradeOffer": {
"title": "Planınızı düşürün, iptal etmeyin",