- Global Rule Group
+ {uiText('ui_globalRuleGroup')}
{wafQuery.data.global_rule_group.name}
diff --git a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts
index 1d91019d..32a622b8 100644
--- a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts
@@ -1,3 +1,4 @@
+import { locales } from '@/i18n/config';
import type { EdgeChange, Node, NodeChange } from '@xyflow/react';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
@@ -140,7 +141,7 @@ function findMessageId(
return [...ids]
.sort((a, b) => b.length - a.length)
.find((id) =>
- (['en', 'zh-CN'] as const).some((locale) => {
+ locales.some((locale) => {
const t = getRuntimeTranslator(locale);
const token = `${escapeRegExp(t(tokenKey))}(?:\\s+ID)?`;
const pattern = t
diff --git a/frontend/app/(main)/waf/rules/editor/components/ua-options.ts b/frontend/app/(main)/waf/rules/editor/components/ua-options.ts
index ed431655..ca99a9a7 100644
--- a/frontend/app/(main)/waf/rules/editor/components/ua-options.ts
+++ b/frontend/app/(main)/waf/rules/editor/components/ua-options.ts
@@ -14,9 +14,24 @@ export const UA_BROWSER_OPTIONS = [
},
{ value: 'Postman', label: 'Postman' },
{ value: 'CLI', label: 'CLI' },
- { value: 'Bot', label: 'Bot' },
- { value: 'Unknown', label: 'Unknown' },
- { value: 'Other', label: 'Other' },
+ {
+ value: 'Bot',
+ get label() {
+ return runtimeText('ui_botLabel');
+ },
+ },
+ {
+ value: 'Unknown',
+ get label() {
+ return runtimeText('ui_unknownLabel');
+ },
+ },
+ {
+ value: 'Other',
+ get label() {
+ return runtimeText('ui_otherLabel');
+ },
+ },
] as const;
export const UA_OS_OPTIONS = [
@@ -26,9 +41,24 @@ export const UA_OS_OPTIONS = [
{ value: 'macOS', label: 'macOS' },
{ value: 'Chrome OS', label: 'Chrome OS' },
{ value: 'Linux', label: 'Linux' },
- { value: 'Bot', label: 'Bot' },
- { value: 'Unknown', label: 'Unknown' },
- { value: 'Other', label: 'Other' },
+ {
+ value: 'Bot',
+ get label() {
+ return runtimeText('ui_botLabel');
+ },
+ },
+ {
+ value: 'Unknown',
+ get label() {
+ return runtimeText('ui_unknownLabel');
+ },
+ },
+ {
+ value: 'Other',
+ get label() {
+ return runtimeText('ui_otherLabel');
+ },
+ },
] as const;
export const UA_BROWSER_LABELS = new Set(
diff --git a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx
index a0142bc7..ebc8a4ee 100644
--- a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx
+++ b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx
@@ -65,6 +65,8 @@ export function QuickCreateZoneDomainDialog({
zones?: ZoneItem[];
onCreated(domain: ZoneDomainItem): void | Promise;
}) {
+ const uiText = useTranslations('runtime');
+
const t = useTranslations('websites');
const tc = useTranslations('common');
const queryClient = useQueryClient();
@@ -214,7 +216,7 @@ export function QuickCreateZoneDomainDialog({
>
{!fixedZoneId ? (
-
+
-
+
@@ -133,7 +133,7 @@ export function AuthSourceModal({
/>
-
+
@@ -156,7 +156,7 @@ export function AuthSourceModal({
/>
-
+
diff --git a/frontend/components/home/footer-section.tsx b/frontend/components/home/footer-section.tsx
index 22a06a2e..6a5a502c 100644
--- a/frontend/components/home/footer-section.tsx
+++ b/frontend/components/home/footer-section.tsx
@@ -117,7 +117,7 @@ export const FooterSection = React.memo(function FooterSection({
-
© 2026 Modern Platform. All rights reserved.
+
{rt('ui_copyright')}
- Stack
+ {rt('ui_technologyStack')}
@@ -161,10 +161,10 @@ export const HeroSection = React.memo(function HeroSection({
- Ready for
+ {rt('ui_readyFor')}
- Develop
+ {rt('ui_development')}
@@ -186,9 +186,11 @@ export const HeroSection = React.memo(function HeroSection({
-
Features
+
+ {rt('ui_features')}
+
- Modern
+ {rt('ui_modern')}
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx
index fddb22de..9c0ae22a 100644
--- a/frontend/components/layout/sidebar.tsx
+++ b/frontend/components/layout/sidebar.tsx
@@ -125,6 +125,8 @@ function parseMenuDisplayConfig(
* @returns {React.ReactNode} 应用侧边栏组件
*/
export function AppSidebar({ ...props }: React.ComponentProps
) {
+ const uiText = useTranslations('runtime');
+
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar();
const { user, logout, loading: userLoading } = useUser();
const { config } = usePublicConfig();
@@ -440,8 +442,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
-
Version {APP_VERSION}
- {APP_BUILD_DATE &&
Build At {APP_BUILD_DATE}
}
+
+ {uiText('ui_versionLabel')} {APP_VERSION}
+
+ {APP_BUILD_DATE && (
+
+ {uiText('ui_builtAt')} {APP_BUILD_DATE}
+
+ )}
diff --git a/frontend/components/providers/intl-provider.test.tsx b/frontend/components/providers/intl-provider.test.tsx
index 4db15c6e..8b6c0f9b 100644
--- a/frontend/components/providers/intl-provider.test.tsx
+++ b/frontend/components/providers/intl-provider.test.tsx
@@ -1,17 +1,29 @@
import { render, screen } from '@testing-library/react';
-import { useTranslations } from 'next-intl';
+import { useLocale, useTranslations } from 'next-intl';
+import { formatDateTime, formatNumber } from '@/i18n/format';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import enMessages from '@/messages/en.json';
import zhCNMessages from '@/messages/zh-CN.json';
+import ruMessages from '@/messages/ru.json';
import { AppIntlProvider } from './intl-provider';
+import { createLocaleBootstrapScript } from '@/i18n/bootstrap';
function Probe() {
const t = useTranslations('common');
return ;
}
+function FormattingProbe() {
+ const locale = useLocale();
+ return (
+
+ {locale}|{formatNumber(1234.5)}|{formatDateTime('2026-01-15T00:00:00Z')}
+
+ );
+}
+
const zhSave = (zhCNMessages.common as Record).save;
const enSave = (enMessages.common as Record).save;
@@ -31,6 +43,7 @@ describe('AppIntlProvider (static export locale resolution)', () => {
afterEach(() => {
document.cookie = 'NEXT_LOCALE=; Max-Age=0; Path=/';
+ document.documentElement.removeAttribute('data-locale-pending');
});
it('flips to the NEXT_LOCALE cookie locale after mount when SSR locale is stale', async () => {
@@ -61,6 +74,51 @@ describe('AppIntlProvider (static export locale resolution)', () => {
expect(document.documentElement.lang).toBe('zh-CN');
});
+ it('applies Russian cookies before showing the hydrated English page', () => {
+ document.cookie = 'NEXT_LOCALE=ru; Path=/';
+ new Function(createLocaleBootstrapScript('en'))();
+ expect(document.documentElement.hasAttribute('data-locale-pending')).toBe(
+ true,
+ );
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(ruMessages.common.save)).toBeInTheDocument();
+ expect(document.documentElement.lang).toBe('ru');
+ expect(document.documentElement.hasAttribute('data-locale-pending')).toBe(
+ false,
+ );
+ });
+
+ it('recognizes Russian browser variants when there is no preference', () => {
+ Object.defineProperty(navigator, 'languages', {
+ value: ['ru-RU', 'en-US'],
+ configurable: true,
+ });
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(ruMessages.common.save)).toBeInTheDocument();
+ expect(document.documentElement.lang).toBe('ru');
+ });
+
+ it('formats existing data in Russian on the first localized render', () => {
+ document.documentElement.lang = 'en';
+ document.cookie = 'NEXT_LOCALE=ru; Path=/';
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId('formats').textContent).toBe(
+ 'ru|1\u00a0234,5|15.01.2026, 08:00',
+ );
+ });
+
it('does not flip without a cookie preference', () => {
render(
diff --git a/frontend/components/providers/intl-provider.tsx b/frontend/components/providers/intl-provider.tsx
index de368ebc..d37f406a 100644
--- a/frontend/components/providers/intl-provider.tsx
+++ b/frontend/components/providers/intl-provider.tsx
@@ -7,11 +7,13 @@ import type { ReactNode } from 'react';
import { resolveClientLocale } from '@/i18n/client';
import type { AppLocale } from '@/i18n/config';
import enMessages from '@/messages/en.json';
+import ruMessages from '@/messages/ru.json';
import zhCNMessages from '@/messages/zh-CN.json';
const messagesByLocale: Record = {
'zh-CN': zhCNMessages,
en: enMessages,
+ ru: ruMessages,
};
type Props = {
@@ -32,14 +34,25 @@ export function AppIntlProvider({
useLayoutEffect(() => {
const resolved = resolveClientLocale();
+ // Formatting utilities run during the context update's render. Publish
+ // its locale first, while the prepaint gate still hides stale markup.
+ document.documentElement.lang = resolved;
if (resolved !== ssrLocale) {
setClientLocale(resolved);
}
- document.documentElement.lang = resolved;
}, [ssrLocale]);
const locale = clientLocale ?? ssrLocale;
+ useLayoutEffect(() => {
+ if (locale === resolveClientLocale()) {
+ document.documentElement.lang = locale;
+ const fallback = document.getElementById('locale-load-fallback');
+ if (fallback) fallback.hidden = true;
+ document.documentElement.removeAttribute('data-locale-pending');
+ }
+ }, [locale]);
+
return (
) {
- return ;
+ const uiText = useTranslations('runtime');
+
+ return (
+
+ );
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
@@ -84,6 +95,8 @@ function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
+ const uiText = useTranslations('runtime');
+
return (
- More
+ {uiText('ui_more')}
);
}
diff --git a/frontend/components/ui/calendar.test.tsx b/frontend/components/ui/calendar.test.tsx
new file mode 100644
index 00000000..49276d2c
--- /dev/null
+++ b/frontend/components/ui/calendar.test.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from '@testing-library/react';
+import { NextIntlClientProvider } from 'next-intl';
+import { describe, expect, it } from 'vitest';
+import { Calendar } from './calendar';
+
+describe('Calendar accessibility translations', () => {
+ it('localizes Russian navigation, dropdowns and selected/today labels', () => {
+ const date = new Date(2026, 0, 15);
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getByRole('button', { name: 'Перейти к предыдущему месяцу' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Перейти к следующему месяцу' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('combobox', { name: 'Выберите месяц' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('combobox', { name: 'Выберите год' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /Сегодня.*выбрано/ }),
+ ).toBeInTheDocument();
+ });
+
+ it('localizes Chinese navigation too', () => {
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getByRole('button', { name: '前往上个月' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: '前往下个月' }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx
index 20846acf..adbf6df7 100644
--- a/frontend/components/ui/calendar.tsx
+++ b/frontend/components/ui/calendar.tsx
@@ -2,7 +2,7 @@
import * as React from 'react';
import { useLocale } from 'next-intl';
-import { enUS, zhCN } from 'date-fns/locale';
+import { enUS, ru, zhCN } from 'react-day-picker/locale';
import {
ChevronDownIcon,
ChevronLeftIcon,
@@ -27,7 +27,9 @@ function Calendar({
buttonVariant?: React.ComponentProps['variant'];
}) {
const uiLocale = useLocale();
- const locale = localeOverride ?? (uiLocale === 'zh-CN' ? zhCN : enUS);
+ const locale =
+ localeOverride ??
+ (uiLocale === 'zh-CN' ? zhCN : uiLocale === 'ru' ? ru : enUS);
const defaultClassNames = getDefaultClassNames();
return (
diff --git a/frontend/components/ui/carousel.tsx b/frontend/components/ui/carousel.tsx
index b58cf280..90315804 100644
--- a/frontend/components/ui/carousel.tsx
+++ b/frontend/components/ui/carousel.tsx
@@ -1,5 +1,7 @@
'use client';
+import { useTranslations } from 'next-intl';
+
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
@@ -177,6 +179,8 @@ function CarouselPrevious({
size = 'icon',
...props
}: React.ComponentProps) {
+ const uiText = useTranslations('runtime');
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
@@ -196,7 +200,7 @@ function CarouselPrevious({
{...props}
>
- Previous slide
+ {uiText('ui_previousSlide')}
);
}
@@ -207,6 +211,8 @@ function CarouselNext({
size = 'icon',
...props
}: React.ComponentProps) {
+ const uiText = useTranslations('runtime');
+
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
@@ -226,7 +232,7 @@ function CarouselNext({
{...props}
>
- Next slide
+ {uiText('ui_nextSlide')}
);
}
diff --git a/frontend/components/ui/command.tsx b/frontend/components/ui/command.tsx
index 5ef1c75d..906b1fe2 100644
--- a/frontend/components/ui/command.tsx
+++ b/frontend/components/ui/command.tsx
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
+import { useTranslations } from 'next-intl';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { motion } from 'motion/react';
@@ -31,8 +32,8 @@ function Command({
}
function CommandDialog({
- title = 'Command Palette',
- description = 'Search for a command to run...',
+ title,
+ description,
children,
className,
showCloseButton = false,
@@ -45,11 +46,14 @@ function CommandDialog({
showCloseButton?: boolean;
shouldFilter?: boolean;
}) {
+ const t = useTranslations('runtime');
return (