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
+41
View File
@@ -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;
}
+36
View File
@@ -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>
);
}
+18
View File
@@ -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;
+28
View File
@@ -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];
}
+11
View File
@@ -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 };
+163
View File
@@ -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; its 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: 'Dont 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;
+163
View File
@@ -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;
+63
View File
@@ -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]);
}