From 08bec8bda355476e20a9352071b2eb1f6af34c6d Mon Sep 17 00:00:00 2001 From: Walt Date: Wed, 15 Jul 2026 13:56:24 +0800 Subject: [PATCH] fix(landing): point dashboard CTAs at the real workspace route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four landing "Dashboard" CTAs used href="/" and relied on the proxy bouncing logged-in visitors from the root path to their workspace. Once #5363 kept "/" public on the official marketing host so logged-in users could browse the site (#5326), that bounce stopped — and the CTAs began resolving to the page the visitor was already on, so clicking them did nothing at all. Resolve the destination in the CTA instead of leaning on a redirect side effect elsewhere: useDashboardCtaHref() runs the same resolvePostAuthDestination() that RedirectIfAuthenticated already uses, so "where the dashboard lives" has one source of truth. This also collapses the same `user ? "/" : "/login"` line that had been copy-pasted across four files. proxy.ts and redirect-if-authenticated.tsx are untouched — #5363's behavior is correct and proxy.test.ts still passes unchanged. Closes MUL-4794 Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- .../components/how-it-works-section.tsx | 4 +- .../landing/components/landing-footer.tsx | 4 +- .../landing/components/landing-header.tsx | 3 +- .../landing/components/landing-hero.tsx | 4 +- .../landing/utils/use-dashboard-cta.test.ts | 76 +++++++++++++++++++ .../landing/utils/use-dashboard-cta.ts | 68 +++++++++++++++++ 6 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 apps/web/features/landing/utils/use-dashboard-cta.test.ts create mode 100644 apps/web/features/landing/utils/use-dashboard-cta.ts diff --git a/apps/web/features/landing/components/how-it-works-section.tsx b/apps/web/features/landing/components/how-it-works-section.tsx index e260347b32..30453f7c2c 100644 --- a/apps/web/features/landing/components/how-it-works-section.tsx +++ b/apps/web/features/landing/components/how-it-works-section.tsx @@ -3,11 +3,13 @@ import Link from "next/link"; import { useAuthStore } from "@multica/core/auth"; import { docsHrefForLocale, useLocale } from "../i18n"; +import { useDashboardCtaHref } from "../utils/use-dashboard-cta"; import { GitHubMark, githubUrl, heroButtonClassName } from "./shared"; export function HowItWorksSection() { const { t, locale } = useLocale(); const user = useAuthStore((s) => s.user); + const ctaHref = useDashboardCtaHref(); return (
@@ -41,7 +43,7 @@ export function HowItWorksSection() {
- + {user ? t.header.dashboard : t.howItWorks.cta} s.user); + const ctaHref = useDashboardCtaHref(); const groups = Object.values(t.footer.groups); return ( @@ -64,7 +66,7 @@ export function LandingFooter() {
{user ? t.header.dashboard : t.footer.cta} diff --git a/apps/web/features/landing/components/landing-header.tsx b/apps/web/features/landing/components/landing-header.tsx index e30ba1f6ec..ca4c9fd229 100644 --- a/apps/web/features/landing/components/landing-header.tsx +++ b/apps/web/features/landing/components/landing-header.tsx @@ -7,6 +7,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { cn } from "@multica/ui/lib/utils"; import { useAuthStore } from "@multica/core/auth"; import { docsHrefForLocale, useLocale } from "../i18n"; +import { useDashboardCtaHref } from "../utils/use-dashboard-cta"; import { formatStarCount, useGithubStars } from "../utils/use-github-stars"; import { GitHubMark, githubUrl, headerButtonClassName } from "./shared"; @@ -26,7 +27,7 @@ export function LandingHeader({ { href: docsHref, label: t.header.docs }, { href: "/changelog", label: t.header.changelog }, ]; - const ctaHref = user ? "/" : "/login"; + const ctaHref = useDashboardCtaHref(); const ctaLabel = user ? t.header.dashboard : t.header.cta; return ( diff --git a/apps/web/features/landing/components/landing-hero.tsx b/apps/web/features/landing/components/landing-hero.tsx index 3c29635cc2..8f92c0487f 100644 --- a/apps/web/features/landing/components/landing-hero.tsx +++ b/apps/web/features/landing/components/landing-hero.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { ArrowRight, Download } from "lucide-react"; import { useAuthStore } from "@multica/core/auth"; import { useLocale } from "../i18n"; +import { useDashboardCtaHref } from "../utils/use-dashboard-cta"; import { ClaudeCodeLogo, CodexLogo, @@ -17,6 +18,7 @@ import { export function LandingHero() { const { t } = useLocale(); const user = useAuthStore((s) => s.user); + const ctaHref = useDashboardCtaHref(); return (
@@ -39,7 +41,7 @@ export function LandingHero() {

- + {user ? t.header.dashboard : t.hero.cta} ({ + isAuthenticated: true, + isWorkspaceListFetched: true, + workspaces, + hasOnboarded, +}); + +describe("resolveDashboardCtaHref", () => { + it("sends logged-out visitors to /login", () => { + expect( + resolveDashboardCtaHref({ + isAuthenticated: false, + isWorkspaceListFetched: false, + workspaces: undefined, + hasOnboarded: false, + }), + ).toBe(paths.login()); + }); + + // The bug this hook exists to fix: the CTA used to be `/`, which on the + // public marketing host resolves to the page the visitor is already on, so + // the click did nothing. It must resolve to a real workspace route. + it("sends an onboarded visitor to their workspace, never back to the landing page", () => { + const href = resolveDashboardCtaHref(fetched([makeWs("acme")])); + expect(href).toBe(paths.workspace("acme").issues()); + expect(href).not.toBe("/"); + }); + + it("sends an un-onboarded visitor to /onboarding", () => { + expect(resolveDashboardCtaHref(fetched([makeWs("acme")], false))).toBe( + paths.onboarding(), + ); + }); + + it("sends an onboarded visitor with no workspace to /workspaces/new", () => { + expect(resolveDashboardCtaHref(fetched([]))).toBe(paths.newWorkspace()); + }); + + it.each([ + ["the list has not resolved yet", false, undefined], + ["the list resolved as undefined", true, undefined], + ])("falls back to /issues while %s", (_label, isWorkspaceListFetched, workspaces) => { + // /issues is a legacy route the proxy rewrites to the last workspace, so + // the button still works during hydration. It must not fall back to `/`, + // which is what made the CTA dead in the first place. + const href = resolveDashboardCtaHref({ + isAuthenticated: true, + isWorkspaceListFetched, + workspaces, + hasOnboarded: true, + }); + expect(href).toBe("/issues"); + expect(href).not.toBe("/"); + }); +}); diff --git a/apps/web/features/landing/utils/use-dashboard-cta.ts b/apps/web/features/landing/utils/use-dashboard-cta.ts new file mode 100644 index 0000000000..943fae4a78 --- /dev/null +++ b/apps/web/features/landing/utils/use-dashboard-cta.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@multica/core/auth"; +import { workspaceListOptions } from "@multica/core/workspace"; +import { + paths, + resolvePostAuthDestination, + useHasOnboarded, +} from "@multica/core/paths"; +import type { Workspace } from "@multica/core/types"; + +/** + * While the workspace list is in flight the CTA points at `/issues`, which the + * proxy rewrites to the last workspace. That keeps the button working during + * hydration without hiding or disabling it (which would flicker). A visitor + * with no workspace history lands back on the landing page in that window and + * can click again once the list resolves. + */ +const LOADING_FALLBACK_HREF = "/issues"; + +export function resolveDashboardCtaHref({ + isAuthenticated, + isWorkspaceListFetched, + workspaces, + hasOnboarded, +}: { + isAuthenticated: boolean; + isWorkspaceListFetched: boolean; + workspaces: Workspace[] | undefined; + hasOnboarded: boolean; +}): string { + if (!isAuthenticated) return paths.login(); + if (!isWorkspaceListFetched || !workspaces) return LOADING_FALLBACK_HREF; + return resolvePostAuthDestination(workspaces, hasOnboarded); +} + +/** + * Destination for the landing "Dashboard" CTA. + * + * These CTAs used to point at `/` and lean on the proxy bouncing logged-in + * visitors from the root path to their workspace. Once `/` stayed public on the + * official marketing host, that bounce stopped and the CTA resolved to the page + * the visitor was already on — a click with no visible effect. Resolve the real + * destination here instead, through the same resolver + * `RedirectIfAuthenticated` uses, so "where the dashboard lives" has one source + * of truth. + * + * Shares `workspaceListOptions()`' query key with `RedirectIfAuthenticated`, so + * on the landing page the list is typically already in flight or cached and this + * adds no request. + */ +export function useDashboardCtaHref(): string { + const user = useAuthStore((s) => s.user); + const hasOnboarded = useHasOnboarded(); + + const { data, isFetched } = useQuery({ + ...workspaceListOptions(), + enabled: !!user, + }); + + return resolveDashboardCtaHref({ + isAuthenticated: !!user, + isWorkspaceListFetched: isFetched, + workspaces: data, + hasOnboarded, + }); +}