mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
fix(landing): point dashboard CTAs at the real workspace route
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 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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 (
|
||||
<section id="how-it-works" className="bg-[#05070b] text-white">
|
||||
@@ -41,7 +43,7 @@ export function HowItWorksSection() {
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-wrap items-center gap-4">
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
<Link href={ctaHref} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
discordUrl,
|
||||
} from "./shared";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
import { useDashboardCtaHref } from "../utils/use-dashboard-cta";
|
||||
|
||||
export function LandingFooter() {
|
||||
const { t, locale, setLocale } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const ctaHref = useDashboardCtaHref();
|
||||
const groups = Object.values(t.footer.groups);
|
||||
|
||||
return (
|
||||
@@ -64,7 +66,7 @@ export function LandingFooter() {
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={user ? "/" : "/login"}
|
||||
href={ctaHref}
|
||||
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
|
||||
>
|
||||
{user ? t.header.dashboard : t.footer.cta}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative min-h-full overflow-hidden bg-[#05070b] text-white">
|
||||
@@ -39,7 +41,7 @@ export function LandingHero() {
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
<Link href={ctaHref} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
76
apps/web/features/landing/utils/use-dashboard-cta.test.ts
Normal file
76
apps/web/features/landing/utils/use-dashboard-cta.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { resolveDashboardCtaHref } from "./use-dashboard-cta";
|
||||
|
||||
function makeWs(slug: string): Workspace {
|
||||
return {
|
||||
id: `id-${slug}`,
|
||||
name: slug,
|
||||
slug,
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: slug.toUpperCase(),
|
||||
avatar_url: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
const fetched = (workspaces: Workspace[], hasOnboarded = true) => ({
|
||||
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("/");
|
||||
});
|
||||
});
|
||||
68
apps/web/features/landing/utils/use-dashboard-cta.ts
Normal file
68
apps/web/features/landing/utils/use-dashboard-cta.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user