From 195b3204c30e38a1a3d9091d11af7e22a63a92d8 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Sun, 24 May 2026 23:24:53 +0300 Subject: [PATCH] feat(web): land new signups on VIN search, activation as a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../onboarding/welcome-onboarding-modal.tsx | 256 ++++++++++++++++++ .../__tests__/dashboard-search.test.tsx | 59 ++-- apps/web/src/routes/_auth/register.tsx | 5 +- apps/web/src/routes/dashboard/search.tsx | 62 ++++- 4 files changed, 340 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/onboarding/welcome-onboarding-modal.tsx diff --git a/apps/web/src/components/onboarding/welcome-onboarding-modal.tsx b/apps/web/src/components/onboarding/welcome-onboarding-modal.tsx new file mode 100644 index 0000000..7ae4869 --- /dev/null +++ b/apps/web/src/components/onboarding/welcome-onboarding-modal.tsx @@ -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>, + })), +); + +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("/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 ( + { + // Only the completed-state CTA / Esc may dismiss; ignore closes mid-provisioning. + if (!open && phase === "completed") onFinished(); + }} + > + { + if (phase !== "completed") e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (phase !== "completed") e.preventDefault(); + }} + > + {phase === "provisioning" ? ( +
+ + + + {t("subscription.onboarding.provisioning")} + + + {t("subscription.onboarding.provisioning")} + + + + +
+ } + > + + + {animationEnded && trialMutation.isPending && ( +
+ + {t("subscription.onboarding.step4")}... +
+ )} + {trialMutation.isError && ( +
+

+ {t("subscription.onboarding.error")} +

+ +
+ )} + + ) : ( +
+ + + + {t("subscription.onboarding.completed")} + + + {t("subscription.onboarding.completed")} + + +
+
+ + {t("subscription.currentPlan")} + + + {t("subscription.plans.full.name")} + +
+
+ + {t("subscription.billingPeriod")} + + + {t("subscription.onboarding.trialDuration")} + +
+ {subscription?.endDate && ( +
+ + {t("subscription.endDate")} + + + {new Date(subscription.endDate).toLocaleDateString("tr-TR")} + +
+ )} + +
    + {["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => ( +
  • + + {t(`subscription.features.${f}`)} +
  • + ))} +
+
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/routes/__tests__/dashboard-search.test.tsx b/apps/web/src/routes/__tests__/dashboard-search.test.tsx index ab5ef69..9a81371 100644 --- a/apps/web/src/routes/__tests__/dashboard-search.test.tsx +++ b/apps/web/src/routes/__tests__/dashboard-search.test.tsx @@ -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) => ( {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|$)/); }); diff --git a/apps/web/src/routes/_auth/register.tsx b/apps/web/src/routes/_auth/register.tsx index bc8da65..b1de200 100644 --- a/apps/web/src/routes/_auth/register.tsx +++ b/apps/web/src/routes/_auth/register.tsx @@ -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(""); diff --git a/apps/web/src/routes/dashboard/search.tsx b/apps/web/src/routes/dashboard/search.tsx index 60664a3..e016001 100644 --- a/apps/web/src/routes/dashboard/search.tsx +++ b/apps/web/src/routes/dashboard/search.tsx @@ -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, + ): { 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(null); const focusFiredRef = useRef(false); - const querySourceRef = useRef<"manual" | "paste" | "history">("manual"); + const querySourceRef = useRef<"manual" | "paste" | "history" | "landing">("manual"); const candidatesShownAtRef = useRef(null); const lastAttemptedVinRef = useRef(null); const attemptCountRef = useRef(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(null); + const didInitFromUrlRef = useRef(false); + // Vehicle candidate selection (PartsCatalogs or EMEX multi-result) const [candidates, setCandidates] = useState(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 (
+ {showWelcome && ( + + )} {/* ─── SECTION 1: Header + Form (no card) ─────────────────────────── */}