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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { peyda } from '@/app/fonts';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||
import { Logo } from '@/components/landing/Logo';
|
||||
|
||||
// Cinematic auth shell that echoes the Abrban landing: a calm blue sky (the
|
||||
// "clear sky" finale), soft drifting clouds, a warm sun glow, the brand logo,
|
||||
// and the form inside a frosted dark-glass panel. Persian / RTL.
|
||||
// and the form inside a frosted dark-glass panel. Direction is inherited from
|
||||
// the locale-aware root layout.
|
||||
export function AuthShell({
|
||||
icon,
|
||||
title,
|
||||
@@ -23,11 +27,9 @@ export function AuthShell({
|
||||
altHref: string;
|
||||
altLabel: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className={`${peyda.variable} font-peyda relative min-h-screen w-full overflow-hidden bg-gradient-to-b from-[#1b6fd0] via-[#589add] to-[#cfe6f7] text-white`}
|
||||
>
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-gradient-to-b from-[#1b6fd0] via-[#589add] to-[#cfe6f7] text-white">
|
||||
{/* Sky atmosphere: warm sun glow, drifting clouds, soft vignette */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute right-[10%] top-[-10%] h-80 w-80 rounded-full bg-[radial-gradient(circle,_rgba(255,244,214,0.9),_rgba(255,236,190,0.28)_45%,_transparent_70%)] blur-2xl" />
|
||||
@@ -47,15 +49,18 @@ export function AuthShell({
|
||||
|
||||
{/* Header */}
|
||||
<header className="relative z-10 mx-auto flex max-w-5xl items-center justify-between px-6 py-5">
|
||||
<Link href="/" aria-label="ابربان" className="transition hover:opacity-90">
|
||||
<Link href="/" aria-label={t.common.appName} className="transition hover:opacity-90">
|
||||
<Logo className="abrban-ink" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-white/85 transition hover:text-white"
|
||||
>
|
||||
بازگشت به خانه
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<LanguageSwitcher className="text-white/85 hover:text-white" />
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-white/85 transition hover:text-white"
|
||||
>
|
||||
{t.auth.backHome}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Form card */}
|
||||
|
||||
@@ -13,55 +13,17 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const BRAND = 'ابربان';
|
||||
|
||||
export const hero = {
|
||||
title: 'ابربان',
|
||||
tagline: 'زیرساخت ابری، در کنترل تو',
|
||||
subtitle:
|
||||
'اپلیکیشنت را در چند ثانیه روی کوبرنتیز منتشر کن — بدون دردسر سرور، بدون پیچیدگیِ DevOps.',
|
||||
ctaPrimary: 'رایگان شروع کن',
|
||||
ctaSecondary: 'ورود',
|
||||
};
|
||||
|
||||
interface Feature {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const features: Feature[] = [
|
||||
{ icon: Rocket, title: 'دیپلوی برقآسا', desc: 'Node.js، لاراول و وردپرس را با یک کلیک منتشر کن؛ بیلد و انتشار خودکار.' },
|
||||
{ icon: Database, title: 'دیتابیس مدیریتشده', desc: 'PostgreSQL، MySQL، Redis، RabbitMQ و Elasticsearch، آماده و پایدار.' },
|
||||
{ icon: ShieldCheck, title: 'SSL خودکار', desc: 'دامنهٔ اختصاصی وصل کن؛ گواهی SSL خودکار صادر و تمدید میشود.' },
|
||||
{ icon: Link2, title: 'لینک پیشنمایش', desc: 'برای هر دیپلوی یک Preview URL پایدار روی TLS بگیر و سریع تست کن.' },
|
||||
{ icon: ScrollText, title: 'لاگ زنده', desc: 'لاگ بیلد و اجرای اپ را همان لحظه و بهصورت زنده دنبال کن.' },
|
||||
{ icon: History, title: 'اسنپشات و بکاپ', desc: 'از وضعیت اپ اسنپشات بگیر و هر زمان خواستی بازگردان.' },
|
||||
{ icon: CreditCard, title: 'بیلینگ شفاف', desc: 'کیفپول پیشپرداخت، فاکتور دقیق و مصرف لحظهای — بدون سورپرایز.' },
|
||||
{ icon: LifeBuoy, title: 'پشتیبانی فارسی', desc: 'تیم پشتیبانی و سیستم تیکت، فارسی و همیشه کنارت.' },
|
||||
// Icons stay in code (they aren't translatable); the matching copy lives in the
|
||||
// i18n dictionaries under `landing.features` / `landing.trust`, index-aligned.
|
||||
export const featureIcons: LucideIcon[] = [
|
||||
Rocket,
|
||||
Database,
|
||||
ShieldCheck,
|
||||
Link2,
|
||||
ScrollText,
|
||||
History,
|
||||
CreditCard,
|
||||
LifeBuoy,
|
||||
];
|
||||
|
||||
interface Step {
|
||||
n: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const steps: Step[] = [
|
||||
{ n: '۱', title: 'کدت را بده', desc: 'ریپازیتوری یا اپت را وصل کن.' },
|
||||
{ n: '۲', title: 'ما میسازیم', desc: 'ابربان بهصورت خودکار بیلد و روی کوبرنتیز دیپلوی میکند.' },
|
||||
{ n: '۳', title: 'آنلاین شو', desc: 'دامنه و SSL آماده است؛ اپت زنده میشود.' },
|
||||
];
|
||||
|
||||
interface Kpi {
|
||||
icon: LucideIcon;
|
||||
kpi: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const trust: Kpi[] = [
|
||||
{ icon: Zap, kpi: '< ۶۰ ثانیه', label: 'میانگین زمان دیپلوی' },
|
||||
{ icon: Server, kpi: '۹۹٫۹٪', label: 'پایداریِ زیرساخت' },
|
||||
{ icon: ShieldCheck, kpi: 'TLS خودکار', label: 'امنیتِ پیشفرض' },
|
||||
{ icon: Globe, kpi: 'بومیِ ایران', label: 'بهینه برای کاربر ایرانی' },
|
||||
];
|
||||
export const trustIcons: LucideIcon[] = [Zap, Server, ShieldCheck, Globe];
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { features } from '../content';
|
||||
import { featureIcons } from '../content';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
|
||||
export function Features() {
|
||||
const f = useT().landing.features;
|
||||
return (
|
||||
<section className="relative px-6 py-28">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal className="mb-14 flex justify-center">
|
||||
<div className="abrban-panel rounded-3xl px-8 py-7 text-center">
|
||||
<h2 className="abrban-ink text-4xl font-bold text-white">هرچه برای ساختن لازم داری</h2>
|
||||
<p className="abrban-ink mt-4 text-lg text-white/85">یک جعبهابزارِ کامل برای انتشار و نگهداریِ اپ.</p>
|
||||
<h2 className="abrban-ink text-4xl font-bold text-white">{f.title}</h2>
|
||||
<p className="abrban-ink mt-4 text-lg text-white/85">{f.subtitle}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((f, i) => (
|
||||
<Reveal key={f.title} delay={(i % 4) * 0.07}>
|
||||
<div className="abrban-panel group h-full rounded-2xl p-6 transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary-500/25 text-primary-100 ring-1 ring-primary-400/40">
|
||||
<f.icon className="h-6 w-6" />
|
||||
{f.items.map((item, i) => {
|
||||
const Icon = featureIcons[i];
|
||||
return (
|
||||
<Reveal key={item.title} delay={(i % 4) * 0.07}>
|
||||
<div className="abrban-panel group h-full rounded-2xl p-6 transition hover:-translate-y-1 hover:ring-1 hover:ring-primary-400/50">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-primary-500/25 text-primary-100 ring-1 ring-primary-400/40">
|
||||
{Icon && <Icon className="h-6 w-6" />}
|
||||
</div>
|
||||
<h3 className="abrban-ink mt-5 text-lg font-bold text-white">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-white/80">{item.desc}</p>
|
||||
</div>
|
||||
<h3 className="abrban-ink mt-5 text-lg font-bold text-white">{f.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-white/80">{f.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
|
||||
export function FinalCta() {
|
||||
const cta = useT().landing.finalCta;
|
||||
return (
|
||||
<section className="relative px-6 py-36">
|
||||
<Reveal className="mx-auto max-w-3xl">
|
||||
<div className="abrban-panel rounded-[2rem] px-8 py-14 text-center sm:px-14">
|
||||
<h2 className="abrban-ink text-4xl font-black leading-tight text-white sm:text-5xl">
|
||||
آسمان صاف است؛ وقتِ <span className="abrban-shimmer">زنده</span> کردنِ اپِ توست.
|
||||
{cta.titleLead}
|
||||
<span className="abrban-shimmer">{cta.titleHighlight}</span>
|
||||
{cta.titleTail}
|
||||
</h2>
|
||||
<p className="abrban-ink mt-6 text-lg text-white/85">
|
||||
همین حالا حسابت را بساز و اولین دیپلوی را تجربه کن.
|
||||
</p>
|
||||
<p className="abrban-ink mt-6 text-lg text-white/85">{cta.body}</p>
|
||||
<div className="mt-9 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-xl bg-primary-600 px-8 py-4 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
|
||||
>
|
||||
همین حالا شروع کن
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-xl border border-white/25 bg-white/10 px-8 py-4 font-bold text-white transition hover:bg-white/20"
|
||||
>
|
||||
داشبورد
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-xl bg-primary-600 px-8 py-4 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
|
||||
>
|
||||
{cta.ctaPrimary}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-xl border border-white/25 bg-white/10 px-8 py-4 font-bold text-white transition hover:bg-white/20"
|
||||
>
|
||||
{cta.ctaSecondary}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import Link from 'next/link';
|
||||
'use client';
|
||||
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { Logo } from '../Logo';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
|
||||
export function Footer() {
|
||||
const t = useT();
|
||||
return (
|
||||
<footer className="relative border-t border-white/15 bg-slate-950/35 px-6 py-12 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-6 sm:flex-row">
|
||||
<Logo className="abrban-ink" />
|
||||
<nav className="abrban-ink flex items-center gap-6 text-sm text-white/80">
|
||||
<Link href="/login" className="transition hover:text-white">
|
||||
ورود
|
||||
{t.common.login}
|
||||
</Link>
|
||||
<Link href="/register" className="transition hover:text-white">
|
||||
ثبتنام
|
||||
{t.common.register}
|
||||
</Link>
|
||||
<Link href="/dashboard" className="transition hover:text-white">
|
||||
داشبورد
|
||||
{t.common.dashboard}
|
||||
</Link>
|
||||
</nav>
|
||||
<p className="abrban-ink text-sm text-white/70">© ابربان — همهٔ حقوق محفوظ است</p>
|
||||
<p className="abrban-ink text-sm text-white/70">{t.landing.footer.copyright}</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, ChevronDown } from 'lucide-react';
|
||||
import { hero } from '../content';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
|
||||
export function Hero() {
|
||||
const t = useT();
|
||||
const hero = t.landing.hero;
|
||||
return (
|
||||
<section className="relative flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<motion.div
|
||||
@@ -16,7 +18,7 @@ export function Hero() {
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1.5 text-sm font-medium text-white/90 backdrop-blur-md">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary-400" />
|
||||
پلتفرم ابریِ خودسرویس
|
||||
{t.landing.badge}
|
||||
</span>
|
||||
|
||||
<h1 className="mt-7 text-6xl font-black tracking-tight sm:text-7xl md:text-8xl">
|
||||
@@ -34,7 +36,7 @@ export function Hero() {
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-primary-600 px-7 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 hover:shadow-primary-500/40"
|
||||
>
|
||||
{hero.ctaPrimary}
|
||||
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-1" />
|
||||
<ArrowLeft className="h-4 w-4 transition group-hover:-translate-x-1 ltr:rotate-180" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
@@ -46,7 +48,7 @@ export function Hero() {
|
||||
</motion.div>
|
||||
|
||||
<div className="abrban-ink absolute bottom-10 flex flex-col items-center text-white/80">
|
||||
<span className="text-xs font-medium">به دلِ ابرها بزن</span>
|
||||
<span className="text-xs font-medium">{hero.scrollHint}</span>
|
||||
<ChevronDown className="abrban-scroll-hint mt-1 h-5 w-5" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { steps } from '../content';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
|
||||
export function HowItWorks() {
|
||||
const how = useT().landing.how;
|
||||
return (
|
||||
<section className="relative px-6 py-28">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<Reveal className="mb-16 flex justify-center">
|
||||
<h2 className="abrban-panel abrban-ink rounded-3xl px-8 py-5 text-4xl font-bold text-white">
|
||||
به سادگیِ سه قدم
|
||||
{how.title}
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{steps.map((s, i) => (
|
||||
{how.steps.map((s, i) => (
|
||||
<Reveal key={s.n} delay={i * 0.1}>
|
||||
<div className="abrban-panel relative rounded-2xl p-8 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary-600/35 text-2xl font-black text-primary-100 ring-1 ring-primary-400/50">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { Logo } from '../Logo';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||
|
||||
export function SiteHeader() {
|
||||
const t = useT();
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
@@ -15,17 +18,18 @@ export function SiteHeader() {
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
|
||||
<Logo className="abrban-ink" />
|
||||
<div className="flex items-center gap-2 rounded-xl bg-slate-950/30 p-1.5 backdrop-blur-md ring-1 ring-white/10">
|
||||
<LanguageSwitcher className="text-white/90 hover:text-white" />
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-white/90 transition hover:text-white"
|
||||
>
|
||||
ورود
|
||||
{t.common.login}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500"
|
||||
>
|
||||
ثبتنام
|
||||
{t.common.register}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { trust } from '../content';
|
||||
import { trustIcons } from '../content';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
|
||||
export function Trust() {
|
||||
const trust = useT().landing.trust;
|
||||
return (
|
||||
<section className="relative px-6 py-28">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Reveal className="mb-14 flex justify-center">
|
||||
<h2 className="abrban-panel abrban-ink rounded-3xl px-8 py-5 text-center text-3xl font-bold text-white sm:text-4xl">
|
||||
چرا تیمها به ابربان اعتماد میکنند
|
||||
{trust.title}
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
||||
{trust.map((t, i) => (
|
||||
<Reveal key={t.label} delay={(i % 4) * 0.07}>
|
||||
<div className="abrban-panel rounded-2xl p-7 text-center">
|
||||
<t.icon className="mx-auto h-7 w-7 text-primary-200" />
|
||||
<div className="abrban-ink mt-4 text-3xl font-black text-white">{t.kpi}</div>
|
||||
<div className="mt-1 text-sm text-white/75">{t.label}</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
{trust.items.map((item, i) => {
|
||||
const Icon = trustIcons[i];
|
||||
return (
|
||||
<Reveal key={item.label} delay={(i % 4) * 0.07}>
|
||||
<div className="abrban-panel rounded-2xl p-7 text-center">
|
||||
{Icon && <Icon className="mx-auto h-7 w-7 text-primary-200" />}
|
||||
<div className="abrban-ink mt-4 text-3xl font-black text-white">{item.kpi}</div>
|
||||
<div className="mt-1 text-sm text-white/75">{item.label}</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
|
||||
export function Value() {
|
||||
const v = useT().landing.value;
|
||||
return (
|
||||
<section className="relative px-6 py-32">
|
||||
<Reveal className="mx-auto max-w-3xl">
|
||||
<div className="abrban-panel rounded-3xl px-8 py-12 text-center sm:px-12">
|
||||
<h2 className="abrban-ink text-3xl font-bold leading-snug text-white sm:text-4xl">
|
||||
یک پلتفرمِ <span className="text-primary-300">PaaS خودسرویس</span> روی کوبرنتیز —
|
||||
تمامِ قدرتِ زیرساختِ ابری، بدون پیچیدگیِ طوفانیاش.
|
||||
{v.lead}
|
||||
<span className="text-primary-300">{v.highlight}</span>
|
||||
{v.tail}
|
||||
</h2>
|
||||
<p className="abrban-ink mt-6 text-lg leading-9 text-white/85">
|
||||
ابربان لایههای سختِ DevOps را برایت مدیریت میکند تا فقط روی محصولت تمرکز کنی.
|
||||
</p>
|
||||
<p className="abrban-ink mt-6 text-lg leading-9 text-white/85">{v.body}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { Locale } from './config';
|
||||
import type { Dictionary } from './dictionaries/fa';
|
||||
|
||||
type I18nContextValue = {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function I18nProvider({
|
||||
locale,
|
||||
dict,
|
||||
children,
|
||||
}: {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <I18nContext.Provider value={{ locale, dict }}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useI18n / useT / useLocale must be used within an I18nProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Returns the active dictionary. Access copy via nested keys, e.g. t.auth.login.title */
|
||||
export function useT(): Dictionary {
|
||||
return useI18n().dict;
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
return useI18n().locale;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname as useNextPathname } from 'next/navigation';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { locales, localeNames, LOCALE_COOKIE, type Locale } from './config';
|
||||
import { useLocale } from './I18nProvider';
|
||||
import { swapLocale } from './navigation';
|
||||
|
||||
/**
|
||||
* Toggles between the supported locales. Persists the choice in a cookie (so the
|
||||
* middleware honours it) and navigates to the same page under the other locale.
|
||||
*/
|
||||
export function LanguageSwitcher({ className = '' }: { className?: string }) {
|
||||
const locale = useLocale();
|
||||
const pathname = useNextPathname() ?? '/';
|
||||
const router = useRouter();
|
||||
const other = (locales.find((l) => l !== locale) ?? locale) as Locale;
|
||||
|
||||
const switchTo = (target: Locale) => {
|
||||
document.cookie = `${LOCALE_COOKIE}=${target}; path=/; max-age=${60 * 60 * 24 * 365}`;
|
||||
router.push(swapLocale(pathname, target));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchTo(other)}
|
||||
aria-label={localeNames[other]}
|
||||
title={localeNames[other]}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl px-2.5 py-1.5 text-sm font-medium transition ${className}`}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{localeNames[other]}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import NextLink from 'next/link';
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { useLocale } from './I18nProvider';
|
||||
import { localizeHref } from './navigation';
|
||||
|
||||
type LinkProps = Omit<ComponentPropsWithoutRef<typeof NextLink>, 'href'> & {
|
||||
href: string;
|
||||
};
|
||||
|
||||
/** Drop-in replacement for next/link that auto-prefixes the active locale on internal hrefs. */
|
||||
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link({ href, ...rest }, ref) {
|
||||
const locale = useLocale();
|
||||
return <NextLink ref={ref} href={localizeHref(href, locale)} {...rest} />;
|
||||
});
|
||||
|
||||
export default Link;
|
||||
@@ -0,0 +1,28 @@
|
||||
// Supported locales for the app. fa-IR is the default (Persian, RTL); en-US is
|
||||
// the secondary (English, LTR). Routing is path-prefixed: /fa-IR/... , /en-US/...
|
||||
export const locales = ['fa-IR', 'en-US'] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = 'fa-IR';
|
||||
|
||||
export const localeDirection: Record<Locale, 'rtl' | 'ltr'> = {
|
||||
'fa-IR': 'rtl',
|
||||
'en-US': 'ltr',
|
||||
};
|
||||
|
||||
// Short, human-facing labels for the language switcher.
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
'fa-IR': 'فارسی',
|
||||
'en-US': 'English',
|
||||
};
|
||||
|
||||
export const LOCALE_COOKIE = 'NEXT_LOCALE';
|
||||
|
||||
export function isLocale(value: string | undefined | null): value is Locale {
|
||||
return !!value && (locales as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function dirFor(locale: Locale): 'rtl' | 'ltr' {
|
||||
return localeDirection[locale];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Locale } from './config';
|
||||
import type { Dictionary } from './dictionaries/fa';
|
||||
|
||||
const loaders: Record<Locale, () => Promise<Dictionary>> = {
|
||||
'fa-IR': () => import('./dictionaries/fa').then((m) => m.default),
|
||||
'en-US': () => import('./dictionaries/en').then((m) => m.default),
|
||||
};
|
||||
|
||||
export const getDictionary = async (locale: Locale): Promise<Dictionary> => loaders[locale]();
|
||||
|
||||
export type { Dictionary };
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Dictionary } from './fa';
|
||||
|
||||
// English (en-US) dictionary. Must mirror the shape of fa.ts (the source of truth).
|
||||
const en: Dictionary = {
|
||||
common: {
|
||||
appName: 'Abrban',
|
||||
login: 'Sign in',
|
||||
register: 'Sign up',
|
||||
logout: 'Sign out',
|
||||
signOut: 'Sign out',
|
||||
dashboard: 'Dashboard',
|
||||
backHome: 'Back to home',
|
||||
loading: 'Loading…',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
create: 'Create',
|
||||
back: 'Back',
|
||||
search: 'Search',
|
||||
confirm: 'Confirm',
|
||||
close: 'Close',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
currency: 'Toman',
|
||||
currencyShort: 'T',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
},
|
||||
|
||||
meta: {
|
||||
title: 'Abrban | Cloud infrastructure, in your control',
|
||||
description:
|
||||
'Abrban — a self-service cloud platform on Kubernetes. Ship your app in seconds: managed databases, custom domains, automatic SSL and live logs.',
|
||||
},
|
||||
|
||||
roles: {
|
||||
admin: 'Admin',
|
||||
technical: 'Technical',
|
||||
sales: 'Sales',
|
||||
},
|
||||
|
||||
landing: {
|
||||
badge: 'Self-service cloud platform',
|
||||
hero: {
|
||||
title: 'Abrban',
|
||||
tagline: 'Cloud infrastructure, in your control',
|
||||
subtitle:
|
||||
'Ship your app to Kubernetes in seconds — no server hassle, no DevOps complexity.',
|
||||
ctaPrimary: 'Start for free',
|
||||
ctaSecondary: 'Sign in',
|
||||
scrollHint: 'Dive into the clouds',
|
||||
},
|
||||
value: {
|
||||
lead: 'A ',
|
||||
highlight: 'self-service PaaS',
|
||||
tail: ' on Kubernetes — all the power of cloud infrastructure, without the storm of complexity.',
|
||||
body: 'Abrban handles the hard DevOps layers for you, so you can focus only on your product.',
|
||||
},
|
||||
how: {
|
||||
title: 'As simple as three steps',
|
||||
steps: [
|
||||
{ n: '1', title: 'Hand us your code', desc: 'Connect your repository or app.' },
|
||||
{ n: '2', title: 'We build it', desc: 'Abrban automatically builds and deploys to Kubernetes.' },
|
||||
{ n: '3', title: 'Go live', desc: 'Domain and SSL are ready; your app goes live.' },
|
||||
],
|
||||
},
|
||||
features: {
|
||||
title: 'Everything you need to build',
|
||||
subtitle: 'A complete toolkit to ship and maintain your app.',
|
||||
items: [
|
||||
{ title: 'Lightning deploys', desc: 'Ship Node.js, Laravel and WordPress with one click; automatic build and release.' },
|
||||
{ title: 'Managed databases', desc: 'PostgreSQL, MySQL, Redis, RabbitMQ and Elasticsearch — ready and reliable.' },
|
||||
{ title: 'Automatic SSL', desc: 'Connect a custom domain; SSL certificates are issued and renewed automatically.' },
|
||||
{ title: 'Preview links', desc: 'Get a stable TLS Preview URL for every deploy and test fast.' },
|
||||
{ title: 'Live logs', desc: 'Follow build and runtime logs live, the moment they happen.' },
|
||||
{ title: 'Snapshots & backups', desc: 'Snapshot your app state and restore it whenever you want.' },
|
||||
{ title: 'Transparent billing', desc: 'Prepaid wallet, precise invoices and real-time usage — no surprises.' },
|
||||
{ title: 'Persian support', desc: 'A support team and ticketing system, in Persian and always by your side.' },
|
||||
],
|
||||
},
|
||||
trust: {
|
||||
title: 'Why teams trust Abrban',
|
||||
items: [
|
||||
{ kpi: '< 60 sec', label: 'Average deploy time' },
|
||||
{ kpi: '99.9%', label: 'Infrastructure uptime' },
|
||||
{ kpi: 'Auto TLS', label: 'Security by default' },
|
||||
{ kpi: 'Iran-native', label: 'Optimized for Iranian users' },
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
titleLead: 'The sky is clear; it’s time to ',
|
||||
titleHighlight: 'go live',
|
||||
titleTail: ' with your app.',
|
||||
body: 'Create your account now and experience your first deploy.',
|
||||
ctaPrimary: 'Get started now',
|
||||
ctaSecondary: 'Dashboard',
|
||||
},
|
||||
footer: {
|
||||
copyright: '© Abrban — All rights reserved',
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
backHome: 'Back to home',
|
||||
login: {
|
||||
title: 'Welcome back',
|
||||
subtitle: 'Sign in to your Abrban account',
|
||||
altPrompt: 'Don’t have an account yet?',
|
||||
altLabel: 'Sign up',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
submit: 'Sign in',
|
||||
submitting: 'Signing in…',
|
||||
success: 'Signed in successfully!',
|
||||
error: 'Sign in failed',
|
||||
},
|
||||
register: {
|
||||
title: 'Create account',
|
||||
subtitle: 'Ship your first app in a few minutes',
|
||||
altPrompt: 'Already have an account?',
|
||||
altLabel: 'Sign in',
|
||||
firstName: 'First name',
|
||||
firstNamePlaceholder: 'e.g. Ali',
|
||||
lastName: 'Last name',
|
||||
lastNamePlaceholder: 'e.g. Rezaei',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
submit: 'Create account',
|
||||
submitting: 'Creating account…',
|
||||
success: 'Account created successfully!',
|
||||
error: 'Sign up failed',
|
||||
},
|
||||
},
|
||||
|
||||
nav: {
|
||||
sectionAdmin: 'Admin',
|
||||
sectionTechnical: 'Technical',
|
||||
sectionSales: 'Sales',
|
||||
walletBalanceTitle: 'Wallet balance',
|
||||
dashboard: 'Dashboard',
|
||||
applications: 'Applications',
|
||||
services: 'Databases & Services',
|
||||
logs: 'Logs',
|
||||
newDeploy: 'New Deploy',
|
||||
wallet: 'Wallet',
|
||||
invoices: 'Invoices',
|
||||
tickets: 'Tickets',
|
||||
users: 'Users',
|
||||
allApplications: 'All Applications',
|
||||
billingPlans: 'Billing Plans',
|
||||
clusters: 'Clusters',
|
||||
clusterPools: 'Cluster Pools',
|
||||
allTickets: 'All Tickets',
|
||||
technicalTickets: 'Technical Tickets',
|
||||
salesTickets: 'Sales Tickets',
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -0,0 +1,163 @@
|
||||
// Persian (fa-IR) dictionary — the source of truth for the app's copy.
|
||||
// The `Dictionary` type is derived from this object; en.ts must match its shape.
|
||||
const fa = {
|
||||
common: {
|
||||
appName: 'ابربان',
|
||||
login: 'ورود',
|
||||
register: 'ثبتنام',
|
||||
logout: 'خروج',
|
||||
signOut: 'خروج از حساب',
|
||||
dashboard: 'داشبورد',
|
||||
backHome: 'بازگشت به خانه',
|
||||
loading: 'در حال بارگذاری…',
|
||||
save: 'ذخیره',
|
||||
cancel: 'انصراف',
|
||||
delete: 'حذف',
|
||||
edit: 'ویرایش',
|
||||
create: 'ایجاد',
|
||||
back: 'بازگشت',
|
||||
search: 'جستجو',
|
||||
confirm: 'تأیید',
|
||||
close: 'بستن',
|
||||
yes: 'بله',
|
||||
no: 'خیر',
|
||||
currency: 'تومان',
|
||||
currencyShort: 'ت',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'زبان',
|
||||
},
|
||||
|
||||
meta: {
|
||||
title: 'ابربان | زیرساخت ابری، در کنترل تو',
|
||||
description:
|
||||
'ابربان؛ پلتفرم ابریِ خودسرویس روی کوبرنتیز. اپ خود را در چند ثانیه منتشر کن — دیتابیس مدیریتشده، دامنهٔ اختصاصی، SSL خودکار و لاگ زنده.',
|
||||
},
|
||||
|
||||
roles: {
|
||||
admin: 'مدیر',
|
||||
technical: 'فنی',
|
||||
sales: 'فروش',
|
||||
},
|
||||
|
||||
landing: {
|
||||
badge: 'پلتفرم ابریِ خودسرویس',
|
||||
hero: {
|
||||
title: 'ابربان',
|
||||
tagline: 'زیرساخت ابری، در کنترل تو',
|
||||
subtitle:
|
||||
'اپلیکیشنت را در چند ثانیه روی کوبرنتیز منتشر کن — بدون دردسر سرور، بدون پیچیدگیِ DevOps.',
|
||||
ctaPrimary: 'رایگان شروع کن',
|
||||
ctaSecondary: 'ورود',
|
||||
scrollHint: 'به دلِ ابرها بزن',
|
||||
},
|
||||
value: {
|
||||
lead: 'یک پلتفرمِ ',
|
||||
highlight: 'PaaS خودسرویس',
|
||||
tail: ' روی کوبرنتیز — تمامِ قدرتِ زیرساختِ ابری، بدون پیچیدگیِ طوفانیاش.',
|
||||
body: 'ابربان لایههای سختِ DevOps را برایت مدیریت میکند تا فقط روی محصولت تمرکز کنی.',
|
||||
},
|
||||
how: {
|
||||
title: 'به سادگیِ سه قدم',
|
||||
steps: [
|
||||
{ n: '۱', title: 'کدت را بده', desc: 'ریپازیتوری یا اپت را وصل کن.' },
|
||||
{ n: '۲', title: 'ما میسازیم', desc: 'ابربان بهصورت خودکار بیلد و روی کوبرنتیز دیپلوی میکند.' },
|
||||
{ n: '۳', title: 'آنلاین شو', desc: 'دامنه و SSL آماده است؛ اپت زنده میشود.' },
|
||||
],
|
||||
},
|
||||
features: {
|
||||
title: 'هرچه برای ساختن لازم داری',
|
||||
subtitle: 'یک جعبهابزارِ کامل برای انتشار و نگهداریِ اپ.',
|
||||
items: [
|
||||
{ title: 'دیپلوی برقآسا', desc: 'Node.js، لاراول و وردپرس را با یک کلیک منتشر کن؛ بیلد و انتشار خودکار.' },
|
||||
{ title: 'دیتابیس مدیریتشده', desc: 'PostgreSQL، MySQL، Redis، RabbitMQ و Elasticsearch، آماده و پایدار.' },
|
||||
{ title: 'SSL خودکار', desc: 'دامنهٔ اختصاصی وصل کن؛ گواهی SSL خودکار صادر و تمدید میشود.' },
|
||||
{ title: 'لینک پیشنمایش', desc: 'برای هر دیپلوی یک Preview URL پایدار روی TLS بگیر و سریع تست کن.' },
|
||||
{ title: 'لاگ زنده', desc: 'لاگ بیلد و اجرای اپ را همان لحظه و بهصورت زنده دنبال کن.' },
|
||||
{ title: 'اسنپشات و بکاپ', desc: 'از وضعیت اپ اسنپشات بگیر و هر زمان خواستی بازگردان.' },
|
||||
{ title: 'بیلینگ شفاف', desc: 'کیفپول پیشپرداخت، فاکتور دقیق و مصرف لحظهای — بدون سورپرایز.' },
|
||||
{ title: 'پشتیبانی فارسی', desc: 'تیم پشتیبانی و سیستم تیکت، فارسی و همیشه کنارت.' },
|
||||
],
|
||||
},
|
||||
trust: {
|
||||
title: 'چرا تیمها به ابربان اعتماد میکنند',
|
||||
items: [
|
||||
{ kpi: '< ۶۰ ثانیه', label: 'میانگین زمان دیپلوی' },
|
||||
{ kpi: '۹۹٫۹٪', label: 'پایداریِ زیرساخت' },
|
||||
{ kpi: 'TLS خودکار', label: 'امنیتِ پیشفرض' },
|
||||
{ kpi: 'بومیِ ایران', label: 'بهینه برای کاربر ایرانی' },
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
titleLead: 'آسمان صاف است؛ وقتِ ',
|
||||
titleHighlight: 'زنده',
|
||||
titleTail: ' کردنِ اپِ توست.',
|
||||
body: 'همین حالا حسابت را بساز و اولین دیپلوی را تجربه کن.',
|
||||
ctaPrimary: 'همین حالا شروع کن',
|
||||
ctaSecondary: 'داشبورد',
|
||||
},
|
||||
footer: {
|
||||
copyright: '© ابربان — همهٔ حقوق محفوظ است',
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
backHome: 'بازگشت به خانه',
|
||||
login: {
|
||||
title: 'خوش آمدی',
|
||||
subtitle: 'به حساب ابربان خود وارد شو',
|
||||
altPrompt: 'هنوز حساب نداری؟',
|
||||
altLabel: 'ثبتنام کن',
|
||||
email: 'ایمیل',
|
||||
password: 'رمز عبور',
|
||||
submit: 'ورود',
|
||||
submitting: 'در حال ورود…',
|
||||
success: 'با موفقیت وارد شدی!',
|
||||
error: 'ورود ناموفق بود',
|
||||
},
|
||||
register: {
|
||||
title: 'ساخت حساب',
|
||||
subtitle: 'در چند دقیقه اولین اپت را منتشر کن',
|
||||
altPrompt: 'قبلاً حساب ساختهای؟',
|
||||
altLabel: 'وارد شو',
|
||||
firstName: 'نام',
|
||||
firstNamePlaceholder: 'مثلاً علی',
|
||||
lastName: 'نام خانوادگی',
|
||||
lastNamePlaceholder: 'مثلاً رضایی',
|
||||
email: 'ایمیل',
|
||||
password: 'رمز عبور',
|
||||
passwordPlaceholder: 'حداقل ۸ کاراکتر',
|
||||
submit: 'ساخت حساب',
|
||||
submitting: 'در حال ساخت حساب…',
|
||||
success: 'حساب با موفقیت ساخته شد!',
|
||||
error: 'ثبتنام ناموفق بود',
|
||||
},
|
||||
},
|
||||
|
||||
nav: {
|
||||
sectionAdmin: 'مدیریت',
|
||||
sectionTechnical: 'فنی',
|
||||
sectionSales: 'فروش',
|
||||
walletBalanceTitle: 'موجودی کیفپول',
|
||||
dashboard: 'داشبورد',
|
||||
applications: 'اپلیکیشنها',
|
||||
services: 'دیتابیسها و سرویسها',
|
||||
logs: 'لاگها',
|
||||
newDeploy: 'دیپلوی جدید',
|
||||
wallet: 'کیفپول',
|
||||
invoices: 'فاکتورها',
|
||||
tickets: 'تیکتها',
|
||||
users: 'کاربران',
|
||||
allApplications: 'همهٔ اپلیکیشنها',
|
||||
billingPlans: 'پلنهای صورتحساب',
|
||||
clusters: 'کلاسترها',
|
||||
clusterPools: 'پولهای کلاستر',
|
||||
allTickets: 'همهٔ تیکتها',
|
||||
technicalTickets: 'تیکتهای فنی',
|
||||
salesTickets: 'تیکتهای فروش',
|
||||
},
|
||||
};
|
||||
|
||||
export type Dictionary = typeof fa;
|
||||
export default fa;
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
useRouter as useNextRouter,
|
||||
usePathname as useNextPathname,
|
||||
} from 'next/navigation';
|
||||
import { locales, type Locale } from './config';
|
||||
import { useLocale } from './I18nProvider';
|
||||
|
||||
const localePrefixes = locales.map((l) => `/${l}`);
|
||||
|
||||
/** Prefix an internal href with the given locale. Leaves external/hash/already-prefixed hrefs untouched. */
|
||||
export function localizeHref(href: string, locale: Locale): string {
|
||||
if (!href.startsWith('/')) return href; // external, hash, or relative
|
||||
// already locale-prefixed?
|
||||
if (localePrefixes.some((p) => href === p || href.startsWith(p + '/'))) return href;
|
||||
return href === '/' ? `/${locale}` : `/${locale}${href}`;
|
||||
}
|
||||
|
||||
/** Strip a leading locale segment from a pathname, returning the locale-agnostic path. */
|
||||
export function stripLocale(pathname: string): string {
|
||||
for (const p of localePrefixes) {
|
||||
if (pathname === p) return '/';
|
||||
if (pathname.startsWith(p + '/')) return pathname.slice(p.length);
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/** Replace (or add) the locale segment of a pathname. Used by the language switcher. */
|
||||
export function swapLocale(pathname: string, locale: Locale): string {
|
||||
return localizeHref(stripLocale(pathname), locale);
|
||||
}
|
||||
|
||||
/** A next/navigation router whose push/replace/prefetch auto-prefix the active locale. */
|
||||
export function useLocalizedRouter() {
|
||||
const router = useNextRouter();
|
||||
const locale = useLocale();
|
||||
return useMemo(
|
||||
() => ({
|
||||
...router,
|
||||
push: (href: string, opts?: Parameters<typeof router.push>[1]) =>
|
||||
router.push(localizeHref(href, locale), opts),
|
||||
replace: (href: string, opts?: Parameters<typeof router.replace>[1]) =>
|
||||
router.replace(localizeHref(href, locale), opts),
|
||||
prefetch: (href: string, opts?: Parameters<typeof router.prefetch>[1]) =>
|
||||
router.prefetch(localizeHref(href, locale), opts),
|
||||
}),
|
||||
[router, locale],
|
||||
);
|
||||
}
|
||||
|
||||
/** The current pathname with the locale segment stripped (e.g. /dashboard/apps). */
|
||||
export function usePathname(): string {
|
||||
const pathname = useNextPathname();
|
||||
return useMemo(() => stripLocale(pathname ?? '/'), [pathname]);
|
||||
}
|
||||
|
||||
/** Returns a memoized localizer bound to the active locale. */
|
||||
export function useLocalize() {
|
||||
const locale = useLocale();
|
||||
return useCallback((href: string) => localizeHref(href, locale), [locale]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { locales, defaultLocale, isLocale, LOCALE_COOKIE, type Locale } from '@/i18n/config';
|
||||
|
||||
// Parse the Accept-Language header into an ordered list of base language codes.
|
||||
function parseAcceptLanguage(header: string | null): string[] {
|
||||
if (!header) return [];
|
||||
return header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [tag, q] = part.trim().split(';q=');
|
||||
return { tag: tag.trim().toLowerCase(), q: q ? parseFloat(q) : 1 };
|
||||
})
|
||||
.sort((a, b) => b.q - a.q)
|
||||
.map((x) => x.tag);
|
||||
}
|
||||
|
||||
function detectLocale(request: NextRequest): Locale {
|
||||
// 1. Explicit choice via cookie
|
||||
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
|
||||
if (isLocale(cookieLocale)) return cookieLocale;
|
||||
|
||||
// 2. Browser preference via Accept-Language
|
||||
const accepted = parseAcceptLanguage(request.headers.get('accept-language'));
|
||||
for (const tag of accepted) {
|
||||
const exact = locales.find((l) => l.toLowerCase() === tag);
|
||||
if (exact) return exact;
|
||||
const base = locales.find((l) => l.toLowerCase().split('-')[0] === tag.split('-')[0]);
|
||||
if (base) return base;
|
||||
}
|
||||
|
||||
// 3. Fallback
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const pathnameHasLocale = locales.some(
|
||||
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
|
||||
);
|
||||
if (pathnameHasLocale) return;
|
||||
|
||||
const locale = detectLocale(request);
|
||||
request.nextUrl.pathname = `/${locale}${pathname === '/' ? '' : pathname}`;
|
||||
const response = NextResponse.redirect(request.nextUrl);
|
||||
response.cookies.set(LOCALE_COOKIE, locale, { path: '/', maxAge: 60 * 60 * 24 * 365 });
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Skip Next internals, the API, and any path containing a dot (static files).
|
||||
matcher: ['/((?!_next|api|.*\\..*).*)'],
|
||||
};
|
||||
@@ -9,6 +9,8 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
|
||||
inter: ['var(--font-inter)', 'system-ui', 'sans-serif'],
|
||||
peyda: ['var(--font-peyda)', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user