Merge pull request #268 from multica-ai/feature/homepage-seo-optimization
feat(web): SEO optimization and auth flow improvements
This commit is contained in:
commit
fb8829fc12
24 changed files with 392 additions and 176 deletions
|
|
@ -46,11 +46,20 @@ function redirectToCliCallback(
|
|||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const sendCode = useAuthStore((s) => s.sendCode);
|
||||
const verifyCode = useAuthStore((s) => s.verifyCode);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Already authenticated — redirect to dashboard
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !searchParams.get("cli_callback")) {
|
||||
router.replace(searchParams.get("next") || "/issues");
|
||||
}
|
||||
}, [isLoading, user, router, searchParams]);
|
||||
|
||||
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
|
|
|
|||
|
|
@ -76,9 +76,9 @@ export function AppSidebar() {
|
|||
const unreadCount = useInboxStore((s) => s.unreadCount());
|
||||
|
||||
const logout = () => {
|
||||
router.push("/");
|
||||
authLogout();
|
||||
useWorkspaceStore.getState().clearWorkspace();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default function DashboardLayout({
|
|||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.push("/login");
|
||||
router.push("/");
|
||||
}
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,62 +1,21 @@
|
|||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { AboutPageClient } from "@/features/landing/components/about-page-client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LandingHeader } from "@/features/landing/components/landing-header";
|
||||
import { LandingFooter } from "@/features/landing/components/landing-footer";
|
||||
import { GitHubMark, githubUrl } from "@/features/landing/components/shared";
|
||||
import { useLocale } from "@/features/landing/i18n";
|
||||
export const metadata: Metadata = {
|
||||
title: "About",
|
||||
description:
|
||||
"Learn about Multica — multiplexed information and computing agent. An open-source AI-native task management platform.",
|
||||
openGraph: {
|
||||
title: "About Multica",
|
||||
description:
|
||||
"The story behind Multica and why we're building AI-native task management.",
|
||||
url: "/about",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/about",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t } = useLocale();
|
||||
const n = t.about.nameLine;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.about.title}
|
||||
</h1>
|
||||
<div className="mt-8 space-y-6 text-[15px] leading-[1.8] text-[#0a0d12]/70 sm:text-[16px]">
|
||||
<p>
|
||||
{n.prefix}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.mul}
|
||||
</strong>
|
||||
{n.tiplexed}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.i}
|
||||
</strong>
|
||||
{n.nformationAnd}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.c}
|
||||
</strong>
|
||||
{n.omputing}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.a}
|
||||
</strong>
|
||||
{n.gent}
|
||||
</p>
|
||||
{t.about.paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.about.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
return <AboutPageClient />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,20 @@
|
|||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { ChangelogPageClient } from "@/features/landing/components/changelog-page-client";
|
||||
|
||||
import { LandingHeader } from "@/features/landing/components/landing-header";
|
||||
import { LandingFooter } from "@/features/landing/components/landing-footer";
|
||||
import { useLocale } from "@/features/landing/i18n";
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog",
|
||||
description:
|
||||
"See what's new in Multica — latest features, improvements, and fixes.",
|
||||
openGraph: {
|
||||
title: "Changelog | Multica",
|
||||
description: "Latest updates and releases from Multica.",
|
||||
url: "/changelog",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/changelog",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.changelog.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.changelog.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{t.changelog.entries.map((release) => (
|
||||
<div key={release.version} className="relative">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[13px] font-semibold tabular-nums">
|
||||
v{release.version}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#0a0d12]/40">
|
||||
{release.date}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
|
||||
{release.title}
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{release.changes.map((change) => (
|
||||
<li
|
||||
key={change}
|
||||
className="flex items-start gap-2.5 text-[14px] leading-[1.7] text-[#0a0d12]/60 sm:text-[15px]"
|
||||
>
|
||||
<span className="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-[#0a0d12]/30" />
|
||||
{change}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
return <ChangelogPageClient />;
|
||||
}
|
||||
|
|
|
|||
21
apps/web/app/(landing)/homepage/page.tsx
Normal file
21
apps/web/app/(landing)/homepage/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Metadata } from "next";
|
||||
import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Homepage",
|
||||
description:
|
||||
"Multica — open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
openGraph: {
|
||||
title: "Multica — AI-Native Task Management",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/homepage",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/homepage",
|
||||
},
|
||||
};
|
||||
|
||||
export default function HomepagePage() {
|
||||
return <MulticaLanding />;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { cookies, headers } from "next/headers";
|
||||
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
|
||||
import { LocaleProvider } from "@/features/landing/i18n";
|
||||
import type { Locale } from "@/features/landing/i18n";
|
||||
|
||||
const instrumentSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -13,14 +15,61 @@ const notoSerifSC = Noto_Serif_SC({
|
|||
variable: "--font-serif-zh",
|
||||
});
|
||||
|
||||
export default function LandingLayout({
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
name: "Multica",
|
||||
url: "https://www.multica.ai",
|
||||
sameAs: ["https://github.com/multica-ai/multica"],
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Multica",
|
||||
applicationCategory: "ProjectManagement",
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"AI-native task management platform that turns coding agents into real teammates.",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function getInitialLocale(): Promise<Locale> {
|
||||
// 1. User's explicit preference (cookie set when they switch language)
|
||||
const cookieStore = await cookies();
|
||||
const stored = cookieStore.get("multica-locale")?.value;
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
|
||||
// 2. Detect from Accept-Language header
|
||||
const headersList = await headers();
|
||||
const acceptLang = headersList.get("accept-language") ?? "";
|
||||
if (acceptLang.includes("zh")) return "zh";
|
||||
|
||||
return "en";
|
||||
}
|
||||
|
||||
export default async function LandingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const initialLocale = await getInitialLocale();
|
||||
|
||||
return (
|
||||
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
|
||||
<LocaleProvider>{children}</LocaleProvider>
|
||||
</div>
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
|
||||
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useNavigationStore } from "@/features/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "Multica — AI-Native Task Management",
|
||||
},
|
||||
description:
|
||||
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
openGraph: {
|
||||
title: "Multica — AI-Native Task Management",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
const lastPath = useNavigationStore.getState().lastPath;
|
||||
router.replace(lastPath);
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading || user) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MulticaLanding />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
|
@ -11,23 +12,49 @@ import "./globals.css";
|
|||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#05070b" },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Multica",
|
||||
description: "AI-native task management",
|
||||
metadataBase: new URL("https://www.multica.ai"),
|
||||
title: {
|
||||
default: "Multica — AI-Native Task Management",
|
||||
template: "%s | Multica",
|
||||
},
|
||||
description:
|
||||
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
icons: {
|
||||
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
|
||||
shortcut: ["/favicon.svg"],
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Multica",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("multica-locale")?.value;
|
||||
const lang = locale === "zh" ? "zh" : "en";
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang={lang}
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
|
|
|
|||
28
apps/web/app/robots.ts
Normal file
28
apps/web/app/robots.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = "https://www.multica.ai";
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/about", "/changelog"],
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/ws",
|
||||
"/auth/",
|
||||
"/issues",
|
||||
"/board",
|
||||
"/inbox",
|
||||
"/agents",
|
||||
"/settings",
|
||||
"/my-issues",
|
||||
"/runtimes",
|
||||
"/skills",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
26
apps/web/app/sitemap.ts
Normal file
26
apps/web/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://www.multica.ai";
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date("2026-04-01"),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/about`,
|
||||
lastModified: new Date("2026-04-01"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/changelog`,
|
||||
lastModified: new Date("2026-04-01"),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.6,
|
||||
},
|
||||
];
|
||||
}
|
||||
62
apps/web/features/landing/components/about-page-client.tsx
Normal file
62
apps/web/features/landing/components/about-page-client.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { GitHubMark, githubUrl } from "./shared";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function AboutPageClient() {
|
||||
const { t } = useLocale();
|
||||
const n = t.about.nameLine;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.about.title}
|
||||
</h1>
|
||||
<div className="mt-8 space-y-6 text-[15px] leading-[1.8] text-[#0a0d12]/70 sm:text-[16px]">
|
||||
<p>
|
||||
{n.prefix}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.mul}
|
||||
</strong>
|
||||
{n.tiplexed}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.i}
|
||||
</strong>
|
||||
{n.nformationAnd}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.c}
|
||||
</strong>
|
||||
{n.omputing}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.a}
|
||||
</strong>
|
||||
{n.gent}
|
||||
</p>
|
||||
{t.about.paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.about.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function ChangelogPageClient() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.changelog.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.changelog.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{t.changelog.entries.map((release) => (
|
||||
<div key={release.version} className="relative">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[13px] font-semibold tabular-nums">
|
||||
v{release.version}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#0a0d12]/40">
|
||||
{release.date}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
|
||||
{release.title}
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{release.changes.map((change) => (
|
||||
<li
|
||||
key={change}
|
||||
className="flex items-start gap-2.5 text-[14px] leading-[1.7] text-[#0a0d12]/60 sm:text-[15px]"
|
||||
>
|
||||
<span className="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-[#0a0d12]/30" />
|
||||
{change}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
|
|
@ -1047,8 +1048,16 @@ export function FeaturesSection() {
|
|||
{/* Visual */}
|
||||
<div className="mt-14 sm:mt-18">
|
||||
{feature.visual ? (
|
||||
<div className="relative overflow-hidden rounded-sm" style={{ backgroundImage: `url(${feature.bgImage ?? "/images/feature-bg.jpg"})`, backgroundSize: "cover", backgroundPosition: "center" }}>
|
||||
<div className="px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16">
|
||||
<div className="relative overflow-hidden rounded-sm">
|
||||
<Image
|
||||
src={feature.bgImage ?? "/images/feature-bg.jpg"}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
sizes="(max-width: 1320px) 100vw, 1320px"
|
||||
quality={80}
|
||||
/>
|
||||
<div className="relative px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16">
|
||||
<feature.visual />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
|
||||
|
||||
export function HowItWorksSection() {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<section id="how-it-works" className="bg-[#05070b] text-white">
|
||||
|
|
@ -39,8 +41,8 @@ export function HowItWorksSection() {
|
|||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-wrap items-center gap-4">
|
||||
<Link href="/login" className={heroButtonClassName("solid")}>
|
||||
{t.howItWorks.cta}
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
export function LandingFooter() {
|
||||
const { t, locale, setLocale } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const groups = Object.values(t.footer.groups);
|
||||
|
||||
return (
|
||||
|
|
@ -27,10 +29,10 @@ export function LandingFooter() {
|
|||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href="/login"
|
||||
href={user ? "/issues" : "/login"}
|
||||
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"
|
||||
>
|
||||
{t.footer.cta}
|
||||
{user ? t.header.dashboard : t.footer.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ export function LandingHeader({
|
|||
variant?: "dark" | "light";
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<header
|
||||
|
|
@ -52,10 +54,10 @@ export function LandingHeader({
|
|||
{t.header.github}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
href={user ? "/issues" : "/login"}
|
||||
className={headerButtonClassName("solid", variant)}
|
||||
>
|
||||
{t.header.login}
|
||||
{user ? t.header.dashboard : t.header.login}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import {
|
||||
ClaudeCodeLogo,
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
|
||||
export function LandingHero() {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full overflow-hidden bg-[#05070b] text-white">
|
||||
|
|
@ -35,8 +37,8 @@ export function LandingHero() {
|
|||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href="/login" className={heroButtonClassName("solid")}>
|
||||
{t.hero.cta}
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
|
|
@ -93,11 +95,14 @@ function ProductImage({ alt }: { alt: string }) {
|
|||
return (
|
||||
<div>
|
||||
<div className="relative overflow-hidden border border-white/14">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<Image
|
||||
src="/images/landing-hero.png"
|
||||
alt={alt}
|
||||
className="block w-full"
|
||||
width={3532}
|
||||
height={2382}
|
||||
className="block h-auto w-full"
|
||||
sizes="(max-width: 1320px) 100vw, 1320px"
|
||||
quality={85}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,17 +7,8 @@ import type { LandingDict, Locale } from "./types";
|
|||
|
||||
const dictionaries: Record<Locale, LandingDict> = { en, zh };
|
||||
|
||||
const STORAGE_KEY = "multica-locale";
|
||||
|
||||
function getInitialLocale(): Locale {
|
||||
if (typeof window === "undefined") return "en";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
// Detect from browser language
|
||||
const lang = navigator.language;
|
||||
if (lang.startsWith("zh")) return "zh";
|
||||
return "en";
|
||||
}
|
||||
const COOKIE_NAME = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
type LocaleContextValue = {
|
||||
locale: Locale;
|
||||
|
|
@ -27,12 +18,18 @@ type LocaleContextValue = {
|
|||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function LocaleProvider({ children }: { children: React.ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
initialLocale = "en",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
localStorage.setItem(STORAGE_KEY, l);
|
||||
document.cookie = `${COOKIE_NAME}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const en: LandingDict = {
|
|||
header: {
|
||||
github: "GitHub",
|
||||
login: "Log in",
|
||||
dashboard: "Dashboard",
|
||||
},
|
||||
|
||||
hero: {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ type FooterGroup = {
|
|||
};
|
||||
|
||||
export type LandingDict = {
|
||||
header: { github: string; login: string };
|
||||
header: { github: string; login: string; dashboard: string };
|
||||
hero: {
|
||||
headlineLine1: string;
|
||||
headlineLine2: string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const zh: LandingDict = {
|
|||
header: {
|
||||
github: "GitHub",
|
||||
login: "\u767b\u5f55",
|
||||
dashboard: "\u8fdb\u5165\u5de5\u4f5c\u53f0",
|
||||
},
|
||||
|
||||
hero: {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ config({ path: resolve(__dirname, "../../.env") });
|
|||
const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
qualities: [75, 80, 85],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export class ApiClient {
|
|||
localStorage.removeItem("multica_workspace_id");
|
||||
this.token = null;
|
||||
this.workspaceId = null;
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue