Detect locale with is-china-user and show English copy for non-Chinese visitors.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "~/components/locale-provider";
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-dvh flex-col bg-[#f8f7fc] px-5 py-10"
|
||||
@@ -10,16 +15,15 @@ export default function AboutPage() {
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<h1 className="text-xl font-bold text-neutral-900">关于 Nomadro</h1>
|
||||
<h1 className="text-xl font-bold text-neutral-900">{t.about.title}</h1>
|
||||
<p className="mt-4 text-sm leading-relaxed text-neutral-600">
|
||||
Nomadro 是中文数字游民操作系统,提供游民导航、地理套利、直播工具箱等模块,
|
||||
帮助更多人从固定工位走向自由生活与自由收入。
|
||||
{t.about.body}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-block text-sm text-violet-600 active:text-violet-800"
|
||||
>
|
||||
← 返回首页
|
||||
{t.about.backHome}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "~/components/locale-provider";
|
||||
|
||||
export default function ContactPage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-dvh flex-col bg-[#f8f7fc] px-5 py-10"
|
||||
@@ -10,9 +15,9 @@ export default function ContactPage() {
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<h1 className="text-xl font-bold text-neutral-900">联系我们</h1>
|
||||
<h1 className="text-xl font-bold text-neutral-900">{t.contact.title}</h1>
|
||||
<p className="mt-4 text-sm leading-relaxed text-neutral-600">
|
||||
加入数字游民社群,获取最新动态与交流机会。
|
||||
{t.contact.body}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<Link
|
||||
@@ -21,7 +26,7 @@ export default function ContactPage() {
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-12 items-center justify-center rounded-2xl bg-violet-600 text-sm font-medium text-white active:bg-violet-700"
|
||||
>
|
||||
🎬 直播工具箱
|
||||
{t.contact.liveCta}
|
||||
</Link>
|
||||
<Link
|
||||
href="https://vip.hackrobot.cn"
|
||||
@@ -29,14 +34,14 @@ export default function ContactPage() {
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-12 items-center justify-center rounded-2xl border border-neutral-200 bg-white text-sm font-medium text-neutral-700 active:bg-neutral-50"
|
||||
>
|
||||
👑 会员社群
|
||||
{t.contact.vipCta}
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-block text-sm text-violet-600 active:text-violet-800"
|
||||
>
|
||||
← 返回首页
|
||||
{t.contact.backHome}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./globals.css";
|
||||
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { LocaleProvider } from "~/components/locale-provider";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -52,7 +53,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN" className={`${geist.variable} ${geistMono.variable}`}>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
<LocaleProvider>{children}</LocaleProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Hero } from "~/components/sections/hero";
|
||||
import { useLocale } from "~/components/locale-provider";
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useLocale();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-dvh flex-col"
|
||||
@@ -13,7 +19,7 @@ export default function HomePage() {
|
||||
className="shrink-0 py-5 text-center text-[11px] text-neutral-400"
|
||||
style={{ paddingBottom: "max(1.25rem, var(--safe-bottom))" }}
|
||||
>
|
||||
© {new Date().getFullYear()} Nomadro
|
||||
{t.copyright.replace("{year}", String(year))}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
75
components/locale-provider.tsx
Normal file
75
components/locale-provider.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { isChinaUser } from "is-china-user";
|
||||
import { dictionaries, type Dictionary, type Locale } from "~/lib/i18n";
|
||||
|
||||
type LocaleContextValue = {
|
||||
locale: Locale;
|
||||
isChina: boolean;
|
||||
ready: boolean;
|
||||
t: Dictionary;
|
||||
};
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
// Default zh to match primary audience; switch after client detection.
|
||||
const [locale, setLocale] = useState<Locale>("zh");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
try {
|
||||
const china = Boolean(isChinaUser());
|
||||
if (!cancelled) {
|
||||
setLocale(china ? "zh" : "en");
|
||||
setReady(true);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLocale("zh");
|
||||
setReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
document.documentElement.lang = dictionaries[locale].htmlLang;
|
||||
}, [locale, ready]);
|
||||
|
||||
const value = useMemo<LocaleContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
isChina: locale === "zh",
|
||||
ready,
|
||||
t: dictionaries[locale],
|
||||
}),
|
||||
[locale, ready],
|
||||
);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useLocale must be used within LocaleProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ENTRY_CARDS } from "~/lib/data";
|
||||
import { useLocale } from "~/components/locale-provider";
|
||||
|
||||
const CARD_STYLE = {
|
||||
blue: {
|
||||
@@ -23,6 +24,8 @@ const CARD_STYLE = {
|
||||
} as const;
|
||||
|
||||
export function Hero() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-1 flex-col justify-center px-5 pb-8"
|
||||
@@ -32,25 +35,29 @@ export function Hero() {
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-md sm:max-w-lg">
|
||||
{/* 标题 */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-[2rem] font-bold leading-tight sm:text-5xl">
|
||||
<span className="text-gradient">数字游民</span>
|
||||
<span className="text-gradient">{t.brandSubtitle}</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-lg font-medium tracking-[0.2em] text-neutral-400 sm:text-xl">
|
||||
nomadro
|
||||
</p>
|
||||
<p className="mx-auto mt-4 max-w-xs text-[13px] leading-relaxed text-neutral-500 sm:max-w-sm sm:text-sm">
|
||||
游民导航 · 地理套利 · 直播工具箱
|
||||
{t.heroTagline}
|
||||
<br />
|
||||
一站开启自由生活
|
||||
{t.heroTaglineLine2}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 入口卡片 */}
|
||||
<div className="mt-10 flex flex-col gap-3">
|
||||
{ENTRY_CARDS.map((card) => {
|
||||
const style = CARD_STYLE[card.accent];
|
||||
const copy = t.entryCards[card.id] ?? {
|
||||
label: card.label,
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={card.id}
|
||||
@@ -72,19 +79,19 @@ export function Hero() {
|
||||
<div className="min-w-0 flex-1 pr-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<h2 className="text-[15px] font-semibold text-neutral-900 sm:text-base">
|
||||
{card.label}
|
||||
{copy.label}
|
||||
</h2>
|
||||
{card.id === "live" && (
|
||||
<span className="rounded bg-rose-500 px-1.5 py-px text-[10px] font-semibold leading-4 text-white">
|
||||
LIVE
|
||||
{t.liveBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm font-medium text-neutral-700">
|
||||
{card.title}
|
||||
{copy.title}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-neutral-400">
|
||||
{card.description}
|
||||
{copy.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
106
lib/i18n.ts
Normal file
106
lib/i18n.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
export type EntryCardCopy = {
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type Dictionary = {
|
||||
htmlLang: string;
|
||||
brandSubtitle: string;
|
||||
heroTagline: string;
|
||||
heroTaglineLine2: string;
|
||||
copyright: string;
|
||||
liveBadge: string;
|
||||
entryCards: Record<string, EntryCardCopy>;
|
||||
about: {
|
||||
title: string;
|
||||
body: string;
|
||||
backHome: string;
|
||||
};
|
||||
contact: {
|
||||
title: string;
|
||||
body: string;
|
||||
liveCta: string;
|
||||
vipCta: string;
|
||||
backHome: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const dictionaries: Record<Locale, Dictionary> = {
|
||||
zh: {
|
||||
htmlLang: "zh-CN",
|
||||
brandSubtitle: "数字游民",
|
||||
heroTagline: "游民导航 · 地理套利 · 直播工具箱",
|
||||
heroTaglineLine2: "一站开启自由生活",
|
||||
copyright: "© {year} Nomadro",
|
||||
liveBadge: "LIVE",
|
||||
entryCards: {
|
||||
nav: {
|
||||
label: "游民导航",
|
||||
title: "出海赚美金",
|
||||
description: "资源导航、工具推荐、出海指南",
|
||||
},
|
||||
arbitrage: {
|
||||
label: "地理套利",
|
||||
title: "AI 自动化杠杆",
|
||||
description: "低成本工作室、自建云手机、自动化系统",
|
||||
},
|
||||
live: {
|
||||
label: "直播工具箱",
|
||||
title: "随时随地开播",
|
||||
description: "推流工具、场景模板、直播变现一站搞定",
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "关于 Nomadro",
|
||||
body: "Nomadro 是中文数字游民操作系统,提供游民导航、地理套利、直播工具箱等模块,帮助更多人从固定工位走向自由生活与自由收入。",
|
||||
backHome: "← 返回首页",
|
||||
},
|
||||
contact: {
|
||||
title: "联系我们",
|
||||
body: "加入数字游民社群,获取最新动态与交流机会。",
|
||||
liveCta: "🎬 直播工具箱",
|
||||
vipCta: "👑 会员社群",
|
||||
backHome: "← 返回首页",
|
||||
},
|
||||
},
|
||||
en: {
|
||||
htmlLang: "en",
|
||||
brandSubtitle: "Digital Nomad",
|
||||
heroTagline: "Nomad Nav · Geo Arbitrage · Live Toolkit",
|
||||
heroTaglineLine2: "Your freedom lifestyle, in one place",
|
||||
copyright: "© {year} Nomadro",
|
||||
liveBadge: "LIVE",
|
||||
entryCards: {
|
||||
nav: {
|
||||
label: "Nomad Nav",
|
||||
title: "Earn in USD overseas",
|
||||
description: "Resource maps, tool picks, and go-abroad guides",
|
||||
},
|
||||
arbitrage: {
|
||||
label: "Geo Arbitrage",
|
||||
title: "AI automation leverage",
|
||||
description: "Low-cost studios, self-hosted cloud phones, automation systems",
|
||||
},
|
||||
live: {
|
||||
label: "Live Toolkit",
|
||||
title: "Go live anywhere",
|
||||
description: "Streaming tools, scene templates, and monetization in one place",
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "About Nomadro",
|
||||
body: "Nomadro is a digital nomad operating system for Chinese creators—covering nomad navigation, geo-arbitrage, and a live streaming toolkit—to help more people move from a fixed desk to freedom of life and income.",
|
||||
backHome: "← Back to home",
|
||||
},
|
||||
contact: {
|
||||
title: "Contact us",
|
||||
body: "Join the digital nomad community for updates and connections.",
|
||||
liveCta: "🎬 Live Toolkit",
|
||||
vipCta: "👑 Members community",
|
||||
backHome: "← Back to home",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.36.0",
|
||||
"is-china-user": "^1.7.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
framer-motion:
|
||||
specifier: ^12.36.0
|
||||
version: 12.36.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
is-china-user:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.3)
|
||||
@@ -1254,6 +1257,9 @@ packages:
|
||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-china-user@1.7.0:
|
||||
resolution: {integrity: sha512-qVhmz+TwK8+WWtEILYZd9tgt7oNsVj0T6VaMqiUlGOltZ64/HTJ4iszNu2KBRM5marxN9BNiMcJjRENsJau6Aw==}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3284,6 +3290,8 @@ snapshots:
|
||||
|
||||
is-callable@1.2.7: {}
|
||||
|
||||
is-china-user@1.7.0: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
|
||||
Reference in New Issue
Block a user