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

@@ -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 (
<div className={cn('space-y-1', className)}>
<div className="flex items-center gap-1.5">
{SUPPORTED_LOCALES.map((locale) => {
const meta = LOCALE_META[locale];
const isActive = locale === activeLocale;
const filled = hasContent(locale);
const isRtl = meta.rtl;
return (
<button
key={locale}
type="button"
onClick={() => onChange(locale)}
dir={isRtl ? 'rtl' : 'ltr'}
className={cn(
'relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
isActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300',
)}
aria-label={`${t('admin.landings.localeTab')}: ${meta.name}`}
aria-pressed={isActive}
>
<span>{meta.flag}</span>
<span>{meta.name}</span>
{filled && !isActive && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-success-500" />
)}
</button>
);
})}
</div>
<p className="text-xs text-dark-500">{t('admin.landings.localeHint')}</p>
</div>
);
}