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>
);
}

View File

@@ -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 (
<textarea
id={id}
dir={isRtl ? 'rtl' : 'ltr'}
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
rows={rows}
maxLength={maxLength}
className={inputClasses}
/>
);
}
return (
<input
id={id}
type="text"
dir={isRtl ? 'rtl' : 'ltr'}
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
className={inputClasses}
/>
);
}

View File

@@ -12,6 +12,8 @@ export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';
export * from './LocaleTabs';
export * from './LocalizedInput';
// Constants and utils
export * from './constants';