feat: мультиязычные лендинги + переключатель языка + исправления по ревью

Мультиязычность:
- LanguageSwitcher на публичной странице покупки
- Вкладки локалей в админ-редакторе (ru/en/zh/fa)
- LocaleTabs и LocalizedInput компоненты
- Типы: LocaleDict, AdminLandingFeature, toLocaleDict хелпер
- ?lang= параметр при запросе конфига лендинга

Исправления по ревью:
- contentIndicators включает все локализуемые поля
- LocaleTabs вынесен над секциями аккордеона
- text-left → text-start для RTL
- nonEmptyDict: защита от undefined
- featureIds/methodIds обёрнуты в useMemo
- toggleSection/updateFeature* обёрнуты в useCallback
- formatPrice вынесен в shared utils/format.ts
- RTL через LOCALE_META.rtl вместо хардкода
- Переводы zh/fa для валидации и landing
- Variable shadowing: t → tariff
This commit is contained in:
Fringg
2026-03-06 16:11:58 +03:00
parent e01c9f5143
commit ab13616b0f
11 changed files with 375 additions and 122 deletions

View File

@@ -76,10 +76,35 @@ export interface PurchaseStatus {
tariff_name: string | null;
}
// ============================================================
// Locale helpers
// ============================================================
/** Locale dict for multi-language text fields (admin API) */
export type LocaleDict = Record<string, string>;
/** Supported locales for the admin editor */
export const SUPPORTED_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export const LOCALE_META: Record<SupportedLocale, { flag: string; name: string; rtl: boolean }> = {
ru: { flag: '\u{1F1F7}\u{1F1FA}', name: 'RU', rtl: false },
en: { flag: '\u{1F1EC}\u{1F1E7}', name: 'EN', rtl: false },
zh: { flag: '\u{1F1E8}\u{1F1F3}', name: 'ZH', rtl: false },
fa: { flag: '\u{1F1EE}\u{1F1F7}', name: 'FA', rtl: true },
};
// ============================================================
// Admin types
// ============================================================
/** Admin feature type with localized title/description */
export interface AdminLandingFeature {
icon: string;
title: LocaleDict;
description: LocaleDict;
}
export interface LandingListItem {
id: number;
slug: string;
@@ -102,18 +127,18 @@ export interface LandingListItem {
export interface LandingDetail {
id: number;
slug: string;
title: string;
subtitle: string | null;
title: LocaleDict;
subtitle: LocaleDict | null;
is_active: boolean;
features: LandingFeature[];
footer_text: string | null;
features: AdminLandingFeature[];
footer_text: LocaleDict | null;
allowed_tariff_ids: number[];
allowed_periods: Record<string, number[]>;
payment_methods: LandingPaymentMethod[];
gift_enabled: boolean;
custom_css: string | null;
meta_title: string | null;
meta_description: string | null;
meta_title: LocaleDict | null;
meta_description: LocaleDict | null;
display_order: number;
created_at: string | null;
updated_at: string | null;
@@ -121,29 +146,44 @@ export interface LandingDetail {
export interface LandingCreateRequest {
slug: string;
title: string;
subtitle?: string;
title: LocaleDict;
subtitle?: LocaleDict;
is_active?: boolean;
features?: LandingFeature[];
footer_text?: string;
features?: AdminLandingFeature[];
footer_text?: LocaleDict;
allowed_tariff_ids?: number[];
allowed_periods?: Record<string, number[]>;
payment_methods?: LandingPaymentMethod[];
gift_enabled?: boolean;
custom_css?: string;
meta_title?: string;
meta_description?: string;
meta_title?: LocaleDict;
meta_description?: LocaleDict;
}
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
/**
* Normalize a value that might be a plain string (old API) or a LocaleDict.
* If it's a string, wraps it as `{ ru: value }`.
* If null/undefined, returns the fallback.
*/
export function toLocaleDict(
value: string | LocaleDict | null | undefined,
fallback: LocaleDict = {},
): LocaleDict {
if (value === null || value === undefined) return fallback;
if (typeof value === 'string') return value ? { ru: value } : fallback;
return value;
}
// ============================================================
// Public API
// ============================================================
export const landingApi = {
getConfig: async (slug: string): Promise<LandingConfig> => {
const response = await apiClient.get(`/cabinet/landing/${slug}`);
getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
const params = lang ? `?lang=${lang}` : '';
const response = await apiClient.get(`/cabinet/landing/${slug}${params}`);
return response.data;
},