mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
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:
74
src/components/admin/LocaleTabs.tsx
Normal file
74
src/components/admin/LocaleTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/admin/LocalizedInput.tsx
Normal file
70
src/components/admin/LocalizedInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user