From ab13616b0f0d31eac007a4c4b7f4f360f0f3c9b4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 6 Mar 2026 16:11:58 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=D0=B7=D1=8B=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B3=D0=B8=20+=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D1=8F?= =?UTF-8?q?=D0=B7=D1=8B=D0=BA=D0=B0=20+=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мультиязычность: - 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 --- src/api/landings.ts | 68 +++++++-- src/components/admin/LocaleTabs.tsx | 74 +++++++++ src/components/admin/LocalizedInput.tsx | 70 +++++++++ src/components/admin/index.ts | 2 + src/locales/en.json | 4 +- src/locales/fa.json | 18 ++- src/locales/ru.json | 4 +- src/locales/zh.json | 18 ++- src/pages/AdminLandingEditor.tsx | 195 +++++++++++++++--------- src/pages/QuickPurchase.tsx | 36 ++--- src/utils/format.ts | 8 + 11 files changed, 375 insertions(+), 122 deletions(-) create mode 100644 src/components/admin/LocaleTabs.tsx create mode 100644 src/components/admin/LocalizedInput.tsx create mode 100644 src/utils/format.ts diff --git a/src/api/landings.ts b/src/api/landings.ts index 5fe13f3..b40ecfc 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -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; + +/** 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 = { + 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; 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; 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; +/** + * 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 => { - const response = await apiClient.get(`/cabinet/landing/${slug}`); + getConfig: async (slug: string, lang?: string): Promise => { + const params = lang ? `?lang=${lang}` : ''; + const response = await apiClient.get(`/cabinet/landing/${slug}${params}`); return response.data; }, diff --git a/src/components/admin/LocaleTabs.tsx b/src/components/admin/LocaleTabs.tsx new file mode 100644 index 0000000..db192e4 --- /dev/null +++ b/src/components/admin/LocaleTabs.tsx @@ -0,0 +1,74 @@ +import { useTranslation } from 'react-i18next'; +import { + SUPPORTED_LOCALES, + LOCALE_META, + type SupportedLocale, + type LocaleDict, +} from '../../api/landings'; +import { cn } from '../../lib/utils'; + +interface LocaleTabsProps { + activeLocale: SupportedLocale; + onChange: (locale: SupportedLocale) => void; + /** Pass locale dicts to show a green dot indicator when content exists */ + contentIndicators?: LocaleDict[]; + className?: string; +} + +/** + * Horizontal locale tab bar for the admin landing editor. + * Shows a green dot on tabs that have content filled in. + */ +export function LocaleTabs({ + activeLocale, + onChange, + contentIndicators, + className, +}: LocaleTabsProps) { + const { t } = useTranslation(); + + const hasContent = (locale: SupportedLocale): boolean => { + if (!contentIndicators || contentIndicators.length === 0) return false; + return contentIndicators.some((dict) => { + const value = dict[locale]; + return typeof value === 'string' && value.trim().length > 0; + }); + }; + + return ( +
+
+ {SUPPORTED_LOCALES.map((locale) => { + const meta = LOCALE_META[locale]; + const isActive = locale === activeLocale; + const filled = hasContent(locale); + const isRtl = meta.rtl; + + return ( + + ); + })} +
+

{t('admin.landings.localeHint')}

+
+ ); +} diff --git a/src/components/admin/LocalizedInput.tsx b/src/components/admin/LocalizedInput.tsx new file mode 100644 index 0000000..faa3edd --- /dev/null +++ b/src/components/admin/LocalizedInput.tsx @@ -0,0 +1,70 @@ +import { LOCALE_META, type LocaleDict, type SupportedLocale } from '../../api/landings'; +import { cn } from '../../lib/utils'; + +interface LocalizedInputProps { + value: LocaleDict; + onChange: (value: LocaleDict) => void; + locale: SupportedLocale; + placeholder?: string; + multiline?: boolean; + rows?: number; + maxLength?: number; + id?: string; + className?: string; +} + +/** + * An input (or textarea) that edits a single locale key within a LocaleDict. + * Shows `value[locale]` and updates the dict immutably on change. + */ +export function LocalizedInput({ + value, + onChange, + locale, + placeholder, + multiline = false, + rows = 2, + maxLength, + id, + className, +}: LocalizedInputProps) { + const currentValue = value[locale] ?? ''; + const isRtl = LOCALE_META[locale].rtl; + + const handleChange = (newText: string) => { + onChange({ ...value, [locale]: newText }); + }; + + const inputClasses = cn( + 'w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500', + className, + ); + + if (multiline) { + return ( +