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 ( +