feat(web): land new signups on VIN search, activation as a modal

New signups were redirected to /dashboard/subscription (the paywall):
91% of them hit it and 78% bounced without ever searching a VIN (PostHog,
30d). Acquisition is strong (homepage -> register) but activation was the
cliff — only 13% of new users ever reached the search page.

- register: redirect signups to /dashboard/search?welcome=1 (value-first),
  carrying the landing-page VIN + referral code across.
- search: read welcome/vin/ref params; prefill a carried VIN and auto-decode
  it once the activation modal is dismissed (no re-typing); strip params after.
- WelcomeOnboardingModal: the old full-page provisioning view is now a modal
  over the search page. It applies the referral (covers the Google OAuth
  signup path), starts the trial, plays the Remotion onboarding motion, then
  steps aside so the user lands on their vehicle's parts.
- trial_started now fires for every eligible new user (was a rarely-clicked
  button) — this populates the Onboarding funnel, which previously read 0.
- tests: add useSearch to the router stub; refresh the stale retry-CTA tests
  to the shipped submit-doubles-as-retry behavior (retry CTA dropped in a33deef).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 23:24:53 +03:00
parent 49891a8df6
commit 195b3204c3
4 changed files with 340 additions and 42 deletions

View File

@@ -0,0 +1,256 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { getUserSettings } from "@/lib/user-settings";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Separator,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, Check, CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
const LazyPlayer = lazy(() => import("@remotion/player").then((mod) => ({ default: mod.Player })));
const LazyOnboardingProgress = lazy(() =>
import("@/remotion/OnboardingProgress").then((mod) => ({
default: mod.OnboardingProgress as React.FC<Record<string, unknown>>,
})),
);
interface SubMe {
subscription: {
status: string;
plan?: { name: string; key: string };
billingPeriod?: string;
endDate?: string;
} | null;
eligibleForTrial: boolean;
}
/**
* Post-signup "account activation" motion, shown as a modal over the search page.
*
* Replaces the old full-page provisioning view that lived on /dashboard/subscription.
* New users now land directly on /dashboard/search (value-first) — this modal runs the
* trial provisioning + onboarding animation on top, then steps out of the way so the
* user can immediately decode a VIN.
*
* Side effects, fired once the subscription state is known:
* - applies a carried-over referral code (needed for the Google OAuth signup path,
* which can't apply it before the redirect)
* - starts the free trial (`POST /subscriptions/trial`) and fires `trial_started` for
* EVERY eligible new user — previously this only fired on a rarely-clicked button,
* which is why the Onboarding funnel read 0 at the trial step.
*/
export function WelcomeOnboardingModal({
refCode,
onFinished,
}: {
refCode: string | null;
onFinished: () => void;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [phase, setPhase] = useState<"provisioning" | "completed">("provisioning");
const [animationEnded, setAnimationEnded] = useState(false);
const provisionedRef = useRef(false);
const refAppliedRef = useRef(false);
const [isDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
return theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
: theme === "dark";
});
const { data: subData } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<SubMe>("/subscriptions/me"),
});
const eligibleForTrial = subData?.eligibleForTrial ?? false;
const subscription = subData?.subscription ?? null;
const trialMutation = useMutation({
mutationFn: () => api.post("/subscriptions/trial"),
onSuccess: () => {},
onError: () => {},
});
// Apply a carried-over referral code once (covers the Google OAuth signup path,
// where register.tsx can't apply it before redirecting away).
useEffect(() => {
if (refAppliedRef.current || !refCode) return;
refAppliedRef.current = true;
api.post("/referrals/apply", { code: refCode.toUpperCase().trim() }).catch(() => {});
}, [refCode]);
// Kick off trial provisioning once we know the subscription state.
useEffect(() => {
if (provisionedRef.current || !subData) return;
provisionedRef.current = true;
if (!eligibleForTrial) {
// Already on a plan / trial already used — no motion, just step aside.
onFinished();
return;
}
capture("trial_started", { source: "welcome_onboarding" });
trialMutation.mutate();
}, [subData, eligibleForTrial, onFinished, trialMutation.mutate]);
// The animation runs ~7s; mark it ended a touch after.
useEffect(() => {
const timer = setTimeout(() => setAnimationEnded(true), 7500);
return () => clearTimeout(timer);
}, []);
// Move to the completed state once the animation finished AND the trial provisioned.
useEffect(() => {
if (phase !== "provisioning") return;
if (!animationEnded || !trialMutation.isSuccess) return;
setPhase("completed");
queryClient.invalidateQueries({ queryKey: ["subscription"] });
import("canvas-confetti").then((mod) => {
mod.default({ particleCount: 150, spread: 80, origin: { y: 0.6 } });
});
}, [phase, animationEnded, trialMutation.isSuccess, queryClient]);
return (
<Dialog
open
onOpenChange={(open) => {
// Only the completed-state CTA / Esc may dismiss; ignore closes mid-provisioning.
if (!open && phase === "completed") 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();
}}
onEscapeKeyDown={(e) => {
if (phase !== "completed") e.preventDefault();
}}
>
{phase === "provisioning" ? (
<div className="flex 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" />
{t("subscription.onboarding.provisioning")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("subscription.onboarding.provisioning")}
</DialogDescription>
</DialogHeader>
<Suspense
fallback={
<div className="flex h-[180px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-brand" />
</div>
}
>
<LazyPlayer
component={LazyOnboardingProgress}
inputProps={{
isDark,
stepLabels: [
t("subscription.onboarding.step1"),
t("subscription.onboarding.step2"),
t("subscription.onboarding.step3"),
t("subscription.onboarding.step4"),
],
}}
durationInFrames={210}
fps={30}
compositionWidth={800}
compositionHeight={200}
autoPlay
style={{ width: "100%", maxWidth: 520, aspectRatio: "800 / 200" }}
controls={false}
/>
</Suspense>
{animationEnded && trialMutation.isPending && (
<div className="flex items-center gap-2 text-sm leading-5 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("subscription.onboarding.step4")}...
</div>
)}
{trialMutation.isError && (
<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")}
</p>
<Button variant="outline" onClick={() => trialMutation.mutate()}>
{t("subscription.onboarding.retry")}
</Button>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center gap-6 py-4">
<DialogHeader className="items-center">
<CheckCircle2 className="mx-auto h-14 w-14 text-brand" />
<DialogTitle className="text-center text-2xl">
{t("subscription.onboarding.completed")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("subscription.onboarding.completed")}
</DialogDescription>
</DialogHeader>
<div className="w-full space-y-4 rounded-xl border border-brand/20 bg-background/60 p-5">
<div className="flex items-center justify-between">
<span className="text-sm leading-5 text-muted-foreground">
{t("subscription.currentPlan")}
</span>
<Badge className="bg-brand text-brand-foreground">
{t("subscription.plans.full.name")}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm leading-5 text-muted-foreground">
{t("subscription.billingPeriod")}
</span>
<span className="text-sm font-medium leading-5">
{t("subscription.onboarding.trialDuration")}
</span>
</div>
{subscription?.endDate && (
<div className="flex items-center justify-between">
<span className="text-sm leading-5 text-muted-foreground">
{t("subscription.endDate")}
</span>
<span className="text-sm font-medium leading-5">
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
<Separator />
<ul className="space-y-2 text-sm leading-relaxed">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-foreground/85">
<Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)}
</li>
))}
</ul>
</div>
<Button
size="lg"
className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={onFinished}
>
{t("subscription.onboarding.startSearching")}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -43,7 +43,7 @@ vi.mock("@tanstack/react-router", async () => {
return {
...actual,
useNavigate: () => vi.fn(),
createFileRoute: () => (options: any) => ({ options }),
createFileRoute: () => (options: any) => ({ options, useSearch: () => ({}) }),
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
@@ -98,7 +98,7 @@ async function submitForm() {
const TEST_VIN = "WVWZZZ1JZ3W597935";
test("retry happy path: shows banner, retry button calls api twice and emits events", async () => {
test("decode retry: banner shows, re-submitting calls the API again and then succeeds", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");
(api.post as any)
.mockRejectedValueOnce(new (MockApiError as any)(503, "Servis geçici olarak kullanılamıyor"))
@@ -115,10 +115,12 @@ test("retry happy path: shows banner, retry button calls api twice and emits eve
expect(screen.getByRole("alert")).toHaveTextContent("Şase çözümlenemedi");
expect(screen.getByRole("alert")).toHaveTextContent("Servis geçici olarak kullanılamıyor");
const retryButton = screen.getByRole("button", { name: /Tekrar Dene/i });
expect(retryButton).toBeInTheDocument();
// There is no separate "Tekrar Dene" CTA — the submit button doubles as the
// retry (it re-enables after a transient error because the VIN is still valid),
// so re-submitting is the retry path. (FN-415 / a33deef dropped the duplicate.)
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
fireEvent.click(retryButton);
await submitForm();
await waitFor(() => {
expect(api.post).toHaveBeenCalledTimes(2);
@@ -127,11 +129,6 @@ test("retry happy path: shows banner, retry button calls api twice and emits eve
expect(api.post).toHaveBeenNthCalledWith(1, "/vehicles/decode", { vin: TEST_VIN });
expect(api.post).toHaveBeenNthCalledWith(2, "/vehicles/decode", { vin: TEST_VIN });
expect(capture).toHaveBeenCalledWith(
"vin_decode_retry_clicked",
expect.objectContaining({ attempt: 2 }),
);
await waitFor(() => {
expect(capture).toHaveBeenCalledWith(
"vin_decode_success",
@@ -199,41 +196,23 @@ test("locator stability: canonical Faro/ARIA hooks resolve and no data-testid is
expect(alert).toHaveAttribute("aria-live", "assertive");
expect(alert).toHaveTextContent("Şase çözümlenemedi");
// (a) Retry — [data-faro-user-action-name="vin-decode-retry"].
const retryByFaro = container.querySelector(
'[data-faro-user-action-name="vin-decode-retry"]',
) as HTMLButtonElement | null;
expect(retryByFaro).not.toBeNull();
// (c) Retry accessible-name fallback.
const retryByName = screen.getByRole("button", { name: "Tekrar Dene" });
expect(retryByName).toBe(retryByFaro);
// (a) No separate retry CTA — the submit button doubles as retry post-error
// (FN-415 / a33deef dropped the duplicate "Tekrar Dene" affordance).
expect(screen.queryByRole("button", { name: "Tekrar Dene" })).toBeNull();
// AC 4 (drift hardening) — no data-testid attributes anywhere in the render.
expect(container.querySelector("[data-testid]")).toBeNull();
});
test("hit-target audit: retry button meets the 44px Apple HIG / Material floor", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");
(api.post as any).mockRejectedValueOnce(
new (MockApiError as any)(503, "Servis geçici olarak kullanılamıyor"),
);
test("hit-target audit: the submit CTA meets the 44px Apple HIG / Material floor", async () => {
renderSearch();
typeVin(TEST_VIN);
await submitForm();
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
const retry = screen.getByRole("button", { name: "Tekrar Dene" });
// The retry button uses Tailwind `h-11` (= 2.75rem = 44px at the default
// 16px root) and `w-full`, so it inherits the parent error-banner width on
// any viewport ≥ ~280px (well below the iPhone SE 375px baseline). Assert
// the class contract — jsdom does not run layout, so a getBoundingClientRect
// measurement would always be 0×0 and is intentionally avoided here.
expect(retry.className).toMatch(/(^|\s)h-11(\s|$)/);
expect(retry.className).toMatch(/(^|\s)w-full(\s|$)/);
// The retry CTA was folded into the submit button (FN-415 / a33deef), which is
// the primary tappable affordance and also the post-error retry. It uses
// Tailwind `h-12` (= 3rem = 48px at the 16px root) and `w-full`, clearing the
// 44px floor. jsdom does not run layout, so assert the class contract — a
// getBoundingClientRect measurement would always be 0×0 here.
const submit = screen.getByRole("button", { name: "Şase Çöz" });
expect(submit.className).toMatch(/(^|\s)h-12(\s|$)/);
expect(submit.className).toMatch(/(^|\s)w-full(\s|$)/);
});

View File

@@ -26,8 +26,11 @@ function RegisterPage() {
const [password, setPassword] = useState("");
const [refCode, setRefCode] = useState(ref || "");
const [loading, setLoading] = useState(false);
// Land new users straight on the VIN search (value-first). The activation
// "motion" + trial provisioning now runs as a modal on the search page
// (see WelcomeOnboardingModal), so we carry welcome/vin/ref across.
const redirectUrl = [
"/dashboard/subscription?welcome=1",
"/dashboard/search?welcome=1",
vin ? `&vin=${encodeURIComponent(vin)}` : "",
refCode.trim() ? `&ref=${encodeURIComponent(refCode.trim().toUpperCase())}` : "",
].join("");

View File

@@ -1,3 +1,4 @@
import { WelcomeOnboardingModal } from "@/components/onboarding/welcome-onboarding-modal";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { ApiError, api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
@@ -42,13 +43,21 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
export const Route = createFileRoute("/dashboard/search")({
component: SearchPage,
validateSearch: (
search: Record<string, unknown>,
): { vin?: string; welcome?: string; ref?: string } => ({
vin: search.vin ? String(search.vin) : undefined,
welcome: search.welcome ? String(search.welcome) : undefined,
ref: search.ref ? String(search.ref) : undefined,
}),
});
function SearchPage() {
const navigate = useNavigate();
const { vin: vinParam, welcome: welcomeParam, ref: refParam } = Route.useSearch();
const inputRef = useRef<HTMLInputElement>(null);
const focusFiredRef = useRef(false);
const querySourceRef = useRef<"manual" | "paste" | "history">("manual");
const querySourceRef = useRef<"manual" | "paste" | "history" | "landing">("manual");
const candidatesShownAtRef = useRef<number | null>(null);
const lastAttemptedVinRef = useRef<string | null>(null);
const attemptCountRef = useRef<number>(0);
@@ -58,6 +67,12 @@ function SearchPage() {
const [reportSending, setReportSending] = useState(false);
const [reportSent, setReportSent] = useState(false);
// Post-signup activation modal + landing-page VIN hand-off (see register.tsx).
const [showWelcome, setShowWelcome] = useState(() => welcomeParam === "1");
const [initialRef] = useState(() => refParam ?? null);
const pendingVinRef = useRef<string | null>(null);
const didInitFromUrlRef = useRef(false);
// Vehicle candidate selection (PartsCatalogs or EMEX multi-result)
const [candidates, setCandidates] = useState<any[] | null>(null);
const [candidateVin, setCandidateVin] = useState("");
@@ -213,6 +228,48 @@ function SearchPage() {
await runDecode(cleanVin, 1);
}
// Decode a VIN carried over from the landing page — no manual re-typing needed.
function autoDecode(rawVin: string) {
const cleanVin = rawVin.toUpperCase().trim();
querySourceRef.current = "landing";
capture("vin_decoded", { vin: cleanVin, query_source: "landing" });
if (!isValidVin(cleanVin)) {
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
lastAttemptedVinRef.current = cleanVin;
attemptCountRef.current = 1;
void runDecode(cleanVin, 1);
}
// Dismiss the activation modal, then decode any VIN that rode in from landing.
function handleWelcomeFinished() {
setShowWelcome(false);
const pending = pendingVinRef.current;
pendingVinRef.current = null;
if (pending) autoDecode(pending);
}
// On first mount: prefill a carried VIN and decode it (deferring past the welcome
// modal when present), then strip the params so a refresh won't replay onboarding.
// biome-ignore lint/correctness/useExhaustiveDependencies: one-time mount init from the initial URL params; re-runs are guarded by didInitFromUrlRef
useEffect(() => {
if (didInitFromUrlRef.current) return;
didInitFromUrlRef.current = true;
const carried = vinParam?.toUpperCase().trim();
if (carried) {
setVin(carried);
if (welcomeParam === "1") {
pendingVinRef.current = carried;
} else {
autoDecode(carried);
}
}
if (vinParam || welcomeParam || refParam) {
navigate({ to: "/dashboard/search", replace: true, search: {} });
}
}, []);
async function handleReportVin() {
setReportSending(true);
try {
@@ -321,6 +378,9 @@ function SearchPage() {
return (
<div className="mx-auto max-w-3xl space-y-8">
{showWelcome && (
<WelcomeOnboardingModal refCode={initialRef} onFinished={handleWelcomeFinished} />
)}
{/* ─── SECTION 1: Header + Form (no card) ─────────────────────────── */}
<header className="flex items-start gap-4">
<div