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) ─────────────────────────── */}