Add i18n foundation (fa-IR/en-US) and localize landing + auth.

Introduce path-prefixed locale routing under app/[lang] with a middleware
that detects locale from cookie/Accept-Language (default fa-IR) and
redirects. Add fa-IR (source of truth) and en-US dictionaries, a server
getDictionary, a client I18nProvider/useT, locale-aware Link + router
helpers, and a language switcher. The root [lang] layout sets html
lang/dir and the per-locale font (Peyda for fa, Inter for en).

Landing sections and the login/register/auth shell now read all copy from
the dictionaries; dashboard localization follows in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-11 12:05:03 +03:30
parent 2b16846f67
commit 34993d417f
48 changed files with 785 additions and 200 deletions
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { notFound } from 'next/navigation';
import { peyda } from '../fonts';
import '../globals.css';
import { Providers } from '@/components/providers';
import { I18nProvider } from '@/i18n/I18nProvider';
import { getDictionary } from '@/i18n/dictionaries';
import { isLocale, dirFor, locales, type Locale } from '@/i18n/config';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export function generateStaticParams() {
return locales.map((lang) => ({ lang }));
}
export async function generateMetadata({
params,
}: {
params: { lang: string };
}): Promise<Metadata> {
if (!isLocale(params.lang)) return {};
const dict = await getDictionary(params.lang);
return { title: dict.meta.title, description: dict.meta.description };
}
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: { lang: string };
}) {
if (!isLocale(params.lang)) notFound();
const locale: Locale = params.lang;
const dict = await getDictionary(locale);
const dir = dirFor(locale);
const fontClass = locale === 'fa-IR' ? 'font-peyda' : 'font-sans';
return (
<html lang={locale} dir={dir}>
<body className={`${peyda.variable} ${inter.variable} ${fontClass}`}>
<I18nProvider locale={locale} dict={dict}>
<Providers>{children}</Providers>
</I18nProvider>
</body>
</html>
);
}
@@ -1,29 +1,31 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import { LogIn, ArrowLeft } from 'lucide-react';
import { AuthShell } from '@/components/auth/AuthShell';
import { AuthField } from '@/components/auth/AuthField';
import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
export default function LoginPage() {
const tl = useT().auth.login;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const login = useAuthStore((s) => s.login);
const router = useRouter();
const router = useLocalizedRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await login(email, password);
toast.success('با موفقیت وارد شدی!');
toast.success(tl.success);
router.push('/dashboard');
} catch (err: any) {
toast.error(err.response?.data?.message || 'ورود ناموفق بود');
toast.error(err.response?.data?.message || tl.error);
} finally {
setIsLoading(false);
}
@@ -32,16 +34,16 @@ export default function LoginPage() {
return (
<AuthShell
icon={<LogIn className="h-7 w-7" />}
title="خوش آمدی"
subtitle="به حساب ابربان خود وارد شو"
altPrompt="هنوز حساب نداری؟"
title={tl.title}
subtitle={tl.subtitle}
altPrompt={tl.altPrompt}
altHref="/register"
altLabel="ثبت‌نام کن"
altLabel={tl.altLabel}
>
<form className="space-y-5" onSubmit={handleSubmit}>
<AuthField
id="email"
label="ایمیل"
label={tl.email}
type="email"
required
dir="ltr"
@@ -53,7 +55,7 @@ export default function LoginPage() {
<AuthField
id="password"
label="رمز عبور"
label={tl.password}
type="password"
required
dir="ltr"
@@ -71,12 +73,12 @@ export default function LoginPage() {
{isLoading ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
در حال ورود
{tl.submitting}
</>
) : (
<>
ورود
<ArrowLeft className="h-4 w-4" />
{tl.submit}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</>
)}
</button>
+5
View File
@@ -0,0 +1,5 @@
import { LandingPage } from '@/components/landing/LandingPage';
export default function Home() {
return <LandingPage />;
}
@@ -1,28 +1,30 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import { UserPlus, ArrowLeft } from 'lucide-react';
import { AuthShell } from '@/components/auth/AuthShell';
import { AuthField } from '@/components/auth/AuthField';
import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
export default function RegisterPage() {
const tr = useT().auth.register;
const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '' });
const [isLoading, setIsLoading] = useState(false);
const register = useAuthStore((s) => s.register);
const router = useRouter();
const router = useLocalizedRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await register(form);
toast.success('حساب با موفقیت ساخته شد!');
toast.success(tr.success);
router.push('/dashboard');
} catch (err: any) {
toast.error(err.response?.data?.message || 'ثبت‌نام ناموفق بود');
toast.error(err.response?.data?.message || tr.error);
} finally {
setIsLoading(false);
}
@@ -31,31 +33,31 @@ export default function RegisterPage() {
return (
<AuthShell
icon={<UserPlus className="h-7 w-7" />}
title="ساخت حساب"
subtitle="در چند دقیقه اولین اپت را منتشر کن"
altPrompt="قبلاً حساب ساخته‌ای؟"
title={tr.title}
subtitle={tr.subtitle}
altPrompt={tr.altPrompt}
altHref="/login"
altLabel="وارد شو"
altLabel={tr.altLabel}
>
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<AuthField
id="firstName"
label="نام"
label={tr.firstName}
type="text"
required
autoComplete="given-name"
placeholder="مثلاً علی"
placeholder={tr.firstNamePlaceholder}
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
/>
<AuthField
id="lastName"
label="نام خانوادگی"
label={tr.lastName}
type="text"
required
autoComplete="family-name"
placeholder="مثلاً رضایی"
placeholder={tr.lastNamePlaceholder}
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
/>
@@ -63,7 +65,7 @@ export default function RegisterPage() {
<AuthField
id="email"
label="ایمیل"
label={tr.email}
type="email"
required
dir="ltr"
@@ -75,13 +77,13 @@ export default function RegisterPage() {
<AuthField
id="password"
label="رمز عبور"
label={tr.password}
type="password"
required
minLength={8}
dir="ltr"
autoComplete="new-password"
placeholder="حداقل ۸ کاراکتر"
placeholder={tr.passwordPlaceholder}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
@@ -94,12 +96,12 @@ export default function RegisterPage() {
{isLoading ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
در حال ساخت حساب
{tr.submitting}
</>
) : (
<>
ساخت حساب
<ArrowLeft className="h-4 w-4" />
{tr.submit}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</>
)}
</button>
-25
View File
@@ -1,25 +0,0 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from '@/components/providers';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'CloudHost - Self-Service PaaS',
description: 'Deploy your applications to Kubernetes with ease',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
-17
View File
@@ -1,17 +0,0 @@
import type { Metadata } from 'next';
import { peyda } from './fonts';
import { LandingPage } from '@/components/landing/LandingPage';
export const metadata: Metadata = {
title: 'ابربان | زیرساخت ابری، در کنترل تو',
description:
'ابربان؛ پلتفرم ابریِ خودسرویس روی کوبرنتیز. اپ خود را در چند ثانیه منتشر کن — دیتابیس مدیریت‌شده، دامنهٔ اختصاصی، SSL خودکار و لاگ زنده.',
};
export default function Home() {
return (
<div dir="rtl" className={`${peyda.variable} font-peyda`}>
<LandingPage />
</div>
);
}