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

@@ -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<string, string>;
/** 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<SupportedLocale, { flag: string; name: string; rtl: boolean }> = {
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<string, number[]>;
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<string, number[]>;
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<LandingCreateRequest>;
/**
* 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<LandingConfig> => {
const response = await apiClient.get(`/cabinet/landing/${slug}`);
getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
const params = lang ? `?lang=${lang}` : '';
const response = await apiClient.get(`/cabinet/landing/${slug}${params}`);
return response.data;
},

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';

View File

@@ -3193,7 +3193,9 @@
"noSystemMethods": "No payment methods configured in the system",
"methodOrder": "Drag to reorder",
"loadingPeriods": "Loading...",
"periodDaySuffix": "d"
"periodDaySuffix": "d",
"localeTab": "Language",
"localeHint": "Switch language to edit text in that language"
}
},
"adminUpdates": {

View File

@@ -2911,15 +2911,17 @@
"content": "محتوا",
"save": "ذخیره",
"back": "بازگشت",
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
"titleRequired": "Title is required",
"noTariffs": "Select at least one tariff",
"noPaymentMethods": "Add at least one payment method",
"invalidSlug": "Slug \u0641\u0642\u0637 \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u062f \u0634\u0627\u0645\u0644 \u062d\u0631\u0648\u0641 \u06a9\u0648\u0686\u06a9\u060c \u0627\u0639\u062f\u0627\u062f \u0648 \u062e\u0637 \u062a\u06cc\u0631\u0647 \u0628\u0627\u0634\u062f",
"titleRequired": "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0632\u0627\u0645\u06cc \u0627\u0633\u062a",
"noTariffs": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u062a\u0639\u0631\u0641\u0647 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646\u06cc\u062f",
"noPaymentMethods": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u0631\u0648\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0627\u0636\u0627\u0641\u0647 \u06a9\u0646\u06cc\u062f",
"selectMethods": "انتخاب روش‌های پرداخت موجود",
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
"methodOrder": "برای تغییر ترتیب بکشید",
"loadingPeriods": "در حال بارگذاری...",
"periodDaySuffix": "روز"
"periodDaySuffix": "روز",
"localeTab": "زبان",
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
}
},
"adminUpdates": {
@@ -3511,8 +3513,8 @@
"choosePeriod": "انتخاب دوره",
"copied": "کپی شد!",
"copyLink": "کپی لینک",
"giftToggleLabel": "Purchase type",
"pollTimedOut": "Taking longer than expected",
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again."
"giftToggleLabel": "\u0646\u0648\u0639 \u062e\u0631\u06cc\u062f",
"pollTimedOut": "\u0632\u0645\u0627\u0646 \u0628\u06cc\u0634\u062a\u0631\u06cc \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f",
"pollTimedOutDesc": "\u067e\u0631\u062f\u0627\u0632\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0628\u06cc\u0634\u062a\u0631 \u0627\u0632 \u062d\u062f \u0645\u0639\u0645\u0648\u0644 \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f\u0647 \u0627\u0633\u062a. \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc\u062f \u062f\u0648\u0628\u0627\u0631\u0647 \u0628\u0631\u0631\u0633\u06cc \u06a9\u0646\u06cc\u062f."
}
}

View File

@@ -3748,7 +3748,9 @@
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
"methodOrder": "Перетащите для изменения порядка",
"loadingPeriods": "Загрузка...",
"periodDaySuffix": "д"
"periodDaySuffix": "д",
"localeTab": "Язык",
"localeHint": "Переключите язык для редактирования текста на этом языке"
}
},
"adminUpdates": {

View File

@@ -2910,15 +2910,17 @@
"content": "内容",
"save": "保存",
"back": "返回",
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
"titleRequired": "Title is required",
"noTariffs": "Select at least one tariff",
"noPaymentMethods": "Add at least one payment method",
"invalidSlug": "Slug\u53ea\u80fd\u5305\u542b\u5c0f\u5199\u5b57\u6bcd\u3001\u6570\u5b57\u548c\u8fde\u5b57\u7b26",
"titleRequired": "\u6807\u9898\u4e3a\u5fc5\u586b\u9879",
"noTariffs": "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u4e2a\u5957\u9910",
"noPaymentMethods": "\u8bf7\u81f3\u5c11\u6dfb\u52a0\u4e00\u4e2a\u652f\u4ed8\u65b9\u5f0f",
"selectMethods": "选择可用的支付方式",
"noSystemMethods": "系统中未配置任何支付方式",
"methodOrder": "拖动以调整顺序",
"loadingPeriods": "加载中...",
"periodDaySuffix": "天"
"periodDaySuffix": "天",
"localeTab": "语言",
"localeHint": "切换语言以编辑该语言的文本"
}
},
"adminUpdates": {
@@ -3510,8 +3512,8 @@
"choosePeriod": "选择时长",
"copied": "已复制!",
"copyLink": "复制链接",
"giftToggleLabel": "Purchase type",
"pollTimedOut": "Taking longer than expected",
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again."
"giftToggleLabel": "\u8d2d\u4e70\u7c7b\u578b",
"pollTimedOut": "\u5904\u7406\u65f6\u95f4\u8f83\u957f",
"pollTimedOutDesc": "\u4ed8\u6b3e\u5904\u7406\u65f6\u95f4\u6bd4\u5e73\u65f6\u957f\u3002\u60a8\u53ef\u4ee5\u5c1d\u8bd5\u518d\u6b21\u68c0\u67e5\u3002"
}
}

View File

@@ -4,13 +4,17 @@ import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/rea
import { useTranslation } from 'react-i18next';
import {
adminLandingsApi,
LandingCreateRequest,
LandingFeature,
LandingPaymentMethod,
type LandingCreateRequest,
type AdminLandingFeature,
type LandingPaymentMethod,
type LocaleDict,
type SupportedLocale,
toLocaleDict,
} from '../api/landings';
import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
import { formatPrice } from '../utils/format';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import { Toggle } from '../components/admin';
import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
import { useNotify } from '@/platform';
import { usePlatform } from '../platform/hooks/usePlatform';
import { getApiErrorMessage } from '../utils/api-error';
@@ -34,7 +38,7 @@ import { CSS } from '@dnd-kit/utilities';
import { cn } from '../lib/utils';
// Types with stable IDs for DnD
type FeatureWithId = LandingFeature & { _id: string };
type FeatureWithId = AdminLandingFeature & { _id: string };
type MethodWithId = LandingPaymentMethod & { _id: string };
const ChevronDownIcon = ({ open }: { open: boolean }) => (
@@ -49,20 +53,25 @@ const ChevronDownIcon = ({ open }: { open: boolean }) => (
</svg>
);
function formatPrice(kopeks: number): string {
return `${(kopeks / 100).toLocaleString('ru-RU')} \u20BD`;
}
// ============ Sortable Feature Item ============
interface SortableFeatureProps {
feature: FeatureWithId;
index: number;
onUpdate: (index: number, field: keyof LandingFeature, value: string) => void;
locale: SupportedLocale;
onUpdateIcon: (index: number, value: string) => void;
onUpdateLocalized: (index: number, field: 'title' | 'description', value: LocaleDict) => void;
onRemove: (index: number) => void;
}
function SortableFeatureItem({ feature, index, onUpdate, onRemove }: SortableFeatureProps) {
function SortableFeatureItem({
feature,
index,
locale,
onUpdateIcon,
onUpdateLocalized,
onRemove,
}: SortableFeatureProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature._id,
@@ -96,22 +105,22 @@ function SortableFeatureItem({ feature, index, onUpdate, onRemove }: SortableFea
<input
type="text"
value={feature.icon}
onChange={(e) => onUpdate(index, 'icon', e.target.value)}
onChange={(e) => onUpdateIcon(index, e.target.value)}
placeholder={t('admin.landings.featureIcon')}
className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
/>
<input
type="text"
<LocalizedInput
value={feature.title}
onChange={(e) => onUpdate(index, 'title', e.target.value)}
onChange={(v) => onUpdateLocalized(index, 'title', v)}
locale={locale}
placeholder={t('admin.landings.featureTitle')}
className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<input
type="text"
<LocalizedInput
value={feature.description}
onChange={(e) => onUpdate(index, 'description', e.target.value)}
onChange={(v) => onUpdateLocalized(index, 'description', v)}
locale={locale}
placeholder={t('admin.landings.featureDesc')}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
@@ -186,7 +195,7 @@ function Section({ title, open, onToggle, children }: SectionProps) {
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-900/50">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-dark-100 hover:bg-dark-800/50"
className="flex w-full items-center justify-between px-4 py-3 text-start text-sm font-medium text-dark-100 hover:bg-dark-800/50"
>
{title}
<ChevronDownIcon open={open} />
@@ -217,23 +226,26 @@ export default function AdminLandingEditor() {
footer: false,
});
const toggleSection = (key: string) => {
const toggleSection = useCallback((key: string) => {
setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
};
}, []);
// Form state
// Locale editing state
const [editingLocale, setEditingLocale] = useState<SupportedLocale>('ru');
// Form state — text fields are now LocaleDict
const [slug, setSlug] = useState('');
const [title, setTitle] = useState('');
const [subtitle, setSubtitle] = useState('');
const [title, setTitle] = useState<LocaleDict>({ ru: '' });
const [subtitle, setSubtitle] = useState<LocaleDict>({});
const [isActive, setIsActive] = useState(true);
const [metaTitle, setMetaTitle] = useState('');
const [metaDescription, setMetaDescription] = useState('');
const [metaTitle, setMetaTitle] = useState<LocaleDict>({});
const [metaDescription, setMetaDescription] = useState<LocaleDict>({});
const [features, setFeatures] = useState<FeatureWithId[]>([]);
const [selectedTariffIds, setSelectedTariffIds] = useState<number[]>([]);
const [allowedPeriods, setAllowedPeriods] = useState<Record<string, number[]>>({});
const [paymentMethods, setPaymentMethods] = useState<MethodWithId[]>([]);
const [giftEnabled, setGiftEnabled] = useState(false);
const [footerText, setFooterText] = useState('');
const [footerText, setFooterText] = useState<LocaleDict>({});
const [customCss, setCustomCss] = useState('');
// DnD sensors
@@ -273,15 +285,17 @@ export default function AdminLandingEditor() {
})),
});
const tariffPeriodsData = tariffDetailQueries.map((q) => q.data);
const tariffPeriodsMap = useMemo(() => {
const map: Record<number, PeriodPrice[]> = {};
tariffDetailQueries.forEach((q, i) => {
if (q.data) {
map[selectedTariffIds[i]] = q.data.period_prices;
tariffPeriodsData.forEach((data, i) => {
if (data) {
map[selectedTariffIds[i]] = data.period_prices;
}
});
return map;
}, [tariffDetailQueries, selectedTariffIds]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(tariffPeriodsData.map((d) => d?.id)), selectedTariffIds]);
// Fetch landing for editing
const { data: landingData } = useQuery({
@@ -306,19 +320,26 @@ export default function AdminLandingEditor() {
if (!landingData || formPopulated.current) return;
formPopulated.current = true;
setSlug(landingData.slug);
setTitle(landingData.title);
setSubtitle(landingData.subtitle ?? '');
setTitle(toLocaleDict(landingData.title, { ru: '' }));
setSubtitle(toLocaleDict(landingData.subtitle));
setIsActive(landingData.is_active);
setMetaTitle(landingData.meta_title ?? '');
setMetaDescription(landingData.meta_description ?? '');
setFeatures((landingData.features ?? []).map((f) => ({ ...f, _id: crypto.randomUUID() })));
setMetaTitle(toLocaleDict(landingData.meta_title));
setMetaDescription(toLocaleDict(landingData.meta_description));
setFeatures(
(landingData.features ?? []).map((f) => ({
...f,
_id: crypto.randomUUID(),
title: toLocaleDict(f.title),
description: toLocaleDict(f.description),
})),
);
setSelectedTariffIds(landingData.allowed_tariff_ids ?? []);
setAllowedPeriods(landingData.allowed_periods ?? {});
setPaymentMethods(
(landingData.payment_methods ?? []).map((m) => ({ ...m, _id: crypto.randomUUID() })),
);
setGiftEnabled(landingData.gift_enabled);
setFooterText(landingData.footer_text ?? '');
setFooterText(toLocaleDict(landingData.footer_text));
setCustomCss(landingData.custom_css ?? '');
}, [landingData]);
@@ -350,6 +371,14 @@ export default function AdminLandingEditor() {
},
});
/** Return a LocaleDict only if it has at least one non-empty value, else undefined */
const nonEmptyDict = (dict: LocaleDict): LocaleDict | undefined => {
const filtered = Object.fromEntries(
Object.entries(dict).filter(([, v]) => typeof v === 'string' && v.trim().length > 0),
);
return Object.keys(filtered).length > 0 ? filtered : undefined;
};
const handleSubmit = () => {
// Client-side validation
if (!isEdit && !/^[a-z0-9-]+$/.test(slug)) {
@@ -361,7 +390,8 @@ export default function AdminLandingEditor() {
);
return;
}
if (!title.trim()) {
const titleHasContent = Object.values(title).some((v) => v.trim().length > 0);
if (!titleHasContent) {
notify.error(t('admin.landings.titleRequired', 'Title is required'));
return;
}
@@ -375,23 +405,23 @@ export default function AdminLandingEditor() {
}
// Strip _id before sending to API
const cleanFeatures = features.map(({ _id: _, ...rest }) => rest);
const cleanFeatures: AdminLandingFeature[] = features.map(({ _id: _, ...rest }) => rest);
const cleanMethods = paymentMethods.map(({ _id: _, ...rest }) => rest);
const data: LandingCreateRequest = {
slug,
title,
subtitle: subtitle || undefined,
subtitle: nonEmptyDict(subtitle),
is_active: isActive,
features: cleanFeatures,
footer_text: footerText || undefined,
footer_text: nonEmptyDict(footerText),
allowed_tariff_ids: selectedTariffIds,
allowed_periods: allowedPeriods,
payment_methods: cleanMethods,
gift_enabled: giftEnabled,
custom_css: customCss || undefined,
meta_title: metaTitle || undefined,
meta_description: metaDescription || undefined,
meta_title: nonEmptyDict(metaTitle),
meta_description: nonEmptyDict(metaDescription),
};
if (isEdit) {
@@ -407,17 +437,24 @@ export default function AdminLandingEditor() {
const addFeature = () => {
setFeatures((prev) => [
...prev,
{ _id: crypto.randomUUID(), icon: '', title: '', description: '' },
{ _id: crypto.randomUUID(), icon: '', title: {}, description: {} },
]);
};
const updateFeature = (index: number, field: keyof LandingFeature, value: string) => {
setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, [field]: value } : f)));
};
const updateFeatureIcon = useCallback((index: number, value: string) => {
setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, icon: value } : f)));
}, []);
const removeFeature = (index: number) => {
const updateFeatureLocalized = useCallback(
(index: number, field: 'title' | 'description', value: LocaleDict) => {
setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, [field]: value } : f)));
},
[],
);
const removeFeature = useCallback((index: number) => {
setFeatures((prev) => prev.filter((_, i) => i !== index));
};
}, []);
const handleFeatureDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
@@ -503,8 +540,8 @@ export default function AdminLandingEditor() {
};
// Feature IDs for DnD
const featureIds = features.map((f) => f._id);
const methodIds = paymentMethods.map((m) => m._id);
const featureIds = useMemo(() => features.map((f) => f._id), [features]);
const methodIds = useMemo(() => paymentMethods.map((m) => m._id), [paymentMethods]);
return (
<div className="animate-fade-in">
@@ -532,7 +569,7 @@ export default function AdminLandingEditor() {
</button>
<button
onClick={handleSubmit}
disabled={isPending || !slug || !title}
disabled={isPending || !slug || !Object.values(title).some((v) => v.trim())}
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{isPending && (
@@ -543,6 +580,21 @@ export default function AdminLandingEditor() {
</div>
</div>
{/* Locale tabs — always visible above sections */}
<LocaleTabs
activeLocale={editingLocale}
onChange={setEditingLocale}
contentIndicators={[
title,
subtitle,
metaTitle,
metaDescription,
footerText,
...features.flatMap((f) => [f.title, f.description]),
]}
className="mb-4"
/>
<div className="space-y-4">
{/* General Section */}
<Section
@@ -571,12 +623,11 @@ export default function AdminLandingEditor() {
<label htmlFor="landing-title" className="mb-1 block text-sm text-dark-400">
{t('admin.landings.pageTitle')}
</label>
<input
<LocalizedInput
id="landing-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="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"
onChange={setTitle}
locale={editingLocale}
/>
</div>
@@ -584,12 +635,13 @@ export default function AdminLandingEditor() {
<label htmlFor="landing-subtitle" className="mb-1 block text-sm text-dark-400">
{t('admin.landings.subtitle')}
</label>
<textarea
<LocalizedInput
id="landing-subtitle"
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
onChange={setSubtitle}
locale={editingLocale}
multiline
rows={2}
className="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"
/>
</div>
@@ -606,22 +658,22 @@ export default function AdminLandingEditor() {
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.metaTitle')}
</label>
<input
type="text"
<LocalizedInput
value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)}
className="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"
onChange={setMetaTitle}
locale={editingLocale}
/>
</div>
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.metaDesc')}
</label>
<textarea
<LocalizedInput
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
onChange={setMetaDescription}
locale={editingLocale}
multiline
rows={2}
className="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"
/>
</div>
</div>
@@ -643,7 +695,9 @@ export default function AdminLandingEditor() {
key={feature._id}
feature={feature}
index={index}
onUpdate={updateFeature}
locale={editingLocale}
onUpdateIcon={updateFeatureIcon}
onUpdateLocalized={updateFeatureLocalized}
onRemove={removeFeature}
/>
))}
@@ -816,11 +870,12 @@ export default function AdminLandingEditor() {
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.footerText')}
</label>
<textarea
<LocalizedInput
value={footerText}
onChange={(e) => setFooterText(e.target.value)}
onChange={setFooterText}
locale={editingLocale}
multiline
rows={4}
className="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"
/>
</div>
<div>

View File

@@ -12,21 +12,10 @@ import type {
LandingPaymentMethod,
PurchaseRequest,
} from '../api/landings';
import LanguageSwitcher from '../components/LanguageSwitcher';
import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error';
// ============================================================
// Helpers
// ============================================================
function formatPrice(kopeks: number): string {
const rubles = kopeks / 100;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(rubles);
}
import { formatPrice } from '../utils/format';
function detectContactType(value: string): 'email' | 'telegram' {
return value.startsWith('@') ? 'telegram' : 'email';
@@ -258,7 +247,7 @@ function TariffCard({
aria-checked={isSelected}
onClick={onSelect}
className={cn(
'relative flex w-full flex-col rounded-2xl border p-5 text-left transition-all duration-200',
'relative flex w-full flex-col rounded-2xl border p-5 text-start transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5 ring-1 ring-accent-500/25'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
@@ -356,7 +345,7 @@ function PaymentMethodCard({
aria-checked={isSelected}
onClick={onSelect}
className={cn(
'flex w-full items-center gap-4 rounded-2xl border p-4 text-left transition-all duration-200',
'flex w-full items-center gap-4 rounded-2xl border p-4 text-start transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
@@ -535,7 +524,7 @@ function SummaryCard({
export default function QuickPurchase() {
const { slug } = useParams<{ slug: string }>();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Fetch config
const {
@@ -543,8 +532,8 @@ export default function QuickPurchase() {
isLoading,
error,
} = useQuery({
queryKey: ['landing-config', slug],
queryFn: () => landingApi.getConfig(slug!),
queryKey: ['landing-config', slug, i18n.language],
queryFn: () => landingApi.getConfig(slug!, i18n.language),
enabled: !!slug,
staleTime: 60_000,
retry: 1,
@@ -586,7 +575,9 @@ export default function QuickPurchase() {
// Filter tariffs to only those that have the selected period
const visibleTariffs = useMemo(() => {
if (!config || !selectedPeriodDays) return config?.tariffs ?? [];
return config.tariffs.filter((t) => t.periods.some((p) => p.days === selectedPeriodDays));
return config.tariffs.filter((tariff) =>
tariff.periods.some((p) => p.days === selectedPeriodDays),
);
}, [config, selectedPeriodDays]);
// Auto-select first tariff, period, method on config load
@@ -611,7 +602,7 @@ export default function QuickPurchase() {
// When period changes, auto-select first visible tariff if current is hidden
useEffect(() => {
if (!visibleTariffs.length) return;
const currentVisible = visibleTariffs.find((t) => t.id === selectedTariffId);
const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
if (!currentVisible) {
setSelectedTariffId(visibleTariffs[0].id);
}
@@ -752,6 +743,11 @@ export default function QuickPurchase() {
return (
<div className="min-h-dvh bg-dark-950">
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
{/* Language switcher */}
<div className="mb-4 flex justify-end">
<LanguageSwitcher />
</div>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}

8
src/utils/format.ts Normal file
View File

@@ -0,0 +1,8 @@
export function formatPrice(kopeks: number): string {
const rubles = kopeks / 100;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(rubles);
}