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:
@@ -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">
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user