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; 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 types
// ============================================================ // ============================================================
/** Admin feature type with localized title/description */
export interface AdminLandingFeature {
icon: string;
title: LocaleDict;
description: LocaleDict;
}
export interface LandingListItem { export interface LandingListItem {
id: number; id: number;
slug: string; slug: string;
@@ -102,18 +127,18 @@ export interface LandingListItem {
export interface LandingDetail { export interface LandingDetail {
id: number; id: number;
slug: string; slug: string;
title: string; title: LocaleDict;
subtitle: string | null; subtitle: LocaleDict | null;
is_active: boolean; is_active: boolean;
features: LandingFeature[]; features: AdminLandingFeature[];
footer_text: string | null; footer_text: LocaleDict | null;
allowed_tariff_ids: number[]; allowed_tariff_ids: number[];
allowed_periods: Record<string, number[]>; allowed_periods: Record<string, number[]>;
payment_methods: LandingPaymentMethod[]; payment_methods: LandingPaymentMethod[];
gift_enabled: boolean; gift_enabled: boolean;
custom_css: string | null; custom_css: string | null;
meta_title: string | null; meta_title: LocaleDict | null;
meta_description: string | null; meta_description: LocaleDict | null;
display_order: number; display_order: number;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
@@ -121,29 +146,44 @@ export interface LandingDetail {
export interface LandingCreateRequest { export interface LandingCreateRequest {
slug: string; slug: string;
title: string; title: LocaleDict;
subtitle?: string; subtitle?: LocaleDict;
is_active?: boolean; is_active?: boolean;
features?: LandingFeature[]; features?: AdminLandingFeature[];
footer_text?: string; footer_text?: LocaleDict;
allowed_tariff_ids?: number[]; allowed_tariff_ids?: number[];
allowed_periods?: Record<string, number[]>; allowed_periods?: Record<string, number[]>;
payment_methods?: LandingPaymentMethod[]; payment_methods?: LandingPaymentMethod[];
gift_enabled?: boolean; gift_enabled?: boolean;
custom_css?: string; custom_css?: string;
meta_title?: string; meta_title?: LocaleDict;
meta_description?: string; meta_description?: LocaleDict;
} }
export type LandingUpdateRequest = Partial<LandingCreateRequest>; 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 // Public API
// ============================================================ // ============================================================
export const landingApi = { export const landingApi = {
getConfig: async (slug: string): Promise<LandingConfig> => { getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
const response = await apiClient.get(`/cabinet/landing/${slug}`); const params = lang ? `?lang=${lang}` : '';
const response = await apiClient.get(`/cabinet/landing/${slug}${params}`);
return response.data; 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 './SettingsTab';
export * from './SettingsMobileTabs'; export * from './SettingsMobileTabs';
export * from './SettingsSearch'; export * from './SettingsSearch';
export * from './LocaleTabs';
export * from './LocalizedInput';
// Constants and utils // Constants and utils
export * from './constants'; export * from './constants';

View File

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

View File

@@ -2911,15 +2911,17 @@
"content": "محتوا", "content": "محتوا",
"save": "ذخیره", "save": "ذخیره",
"back": "بازگشت", "back": "بازگشت",
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens", "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": "Title is required", "titleRequired": "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0632\u0627\u0645\u06cc \u0627\u0633\u062a",
"noTariffs": "Select at least one tariff", "noTariffs": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u062a\u0639\u0631\u0641\u0647 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646\u06cc\u062f",
"noPaymentMethods": "Add at least one payment method", "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": "انتخاب روش‌های پرداخت موجود", "selectMethods": "انتخاب روش‌های پرداخت موجود",
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است", "noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
"methodOrder": "برای تغییر ترتیب بکشید", "methodOrder": "برای تغییر ترتیب بکشید",
"loadingPeriods": "در حال بارگذاری...", "loadingPeriods": "در حال بارگذاری...",
"periodDaySuffix": "روز" "periodDaySuffix": "روز",
"localeTab": "زبان",
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
} }
}, },
"adminUpdates": { "adminUpdates": {
@@ -3511,8 +3513,8 @@
"choosePeriod": "انتخاب دوره", "choosePeriod": "انتخاب دوره",
"copied": "کپی شد!", "copied": "کپی شد!",
"copyLink": "کپی لینک", "copyLink": "کپی لینک",
"giftToggleLabel": "Purchase type", "giftToggleLabel": "\u0646\u0648\u0639 \u062e\u0631\u06cc\u062f",
"pollTimedOut": "Taking longer than expected", "pollTimedOut": "\u0632\u0645\u0627\u0646 \u0628\u06cc\u0634\u062a\u0631\u06cc \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f",
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again." "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": "В системе не настроено ни одного способа оплаты", "noSystemMethods": "В системе не настроено ни одного способа оплаты",
"methodOrder": "Перетащите для изменения порядка", "methodOrder": "Перетащите для изменения порядка",
"loadingPeriods": "Загрузка...", "loadingPeriods": "Загрузка...",
"periodDaySuffix": "д" "periodDaySuffix": "д",
"localeTab": "Язык",
"localeHint": "Переключите язык для редактирования текста на этом языке"
} }
}, },
"adminUpdates": { "adminUpdates": {

View File

@@ -2910,15 +2910,17 @@
"content": "内容", "content": "内容",
"save": "保存", "save": "保存",
"back": "返回", "back": "返回",
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens", "invalidSlug": "Slug\u53ea\u80fd\u5305\u542b\u5c0f\u5199\u5b57\u6bcd\u3001\u6570\u5b57\u548c\u8fde\u5b57\u7b26",
"titleRequired": "Title is required", "titleRequired": "\u6807\u9898\u4e3a\u5fc5\u586b\u9879",
"noTariffs": "Select at least one tariff", "noTariffs": "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u4e2a\u5957\u9910",
"noPaymentMethods": "Add at least one payment method", "noPaymentMethods": "\u8bf7\u81f3\u5c11\u6dfb\u52a0\u4e00\u4e2a\u652f\u4ed8\u65b9\u5f0f",
"selectMethods": "选择可用的支付方式", "selectMethods": "选择可用的支付方式",
"noSystemMethods": "系统中未配置任何支付方式", "noSystemMethods": "系统中未配置任何支付方式",
"methodOrder": "拖动以调整顺序", "methodOrder": "拖动以调整顺序",
"loadingPeriods": "加载中...", "loadingPeriods": "加载中...",
"periodDaySuffix": "天" "periodDaySuffix": "天",
"localeTab": "语言",
"localeHint": "切换语言以编辑该语言的文本"
} }
}, },
"adminUpdates": { "adminUpdates": {
@@ -3510,8 +3512,8 @@
"choosePeriod": "选择时长", "choosePeriod": "选择时长",
"copied": "已复制!", "copied": "已复制!",
"copyLink": "复制链接", "copyLink": "复制链接",
"giftToggleLabel": "Purchase type", "giftToggleLabel": "\u8d2d\u4e70\u7c7b\u578b",
"pollTimedOut": "Taking longer than expected", "pollTimedOut": "\u5904\u7406\u65f6\u95f4\u8f83\u957f",
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again." "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 { useTranslation } from 'react-i18next';
import { import {
adminLandingsApi, adminLandingsApi,
LandingCreateRequest, type LandingCreateRequest,
LandingFeature, type AdminLandingFeature,
LandingPaymentMethod, type LandingPaymentMethod,
type LocaleDict,
type SupportedLocale,
toLocaleDict,
} from '../api/landings'; } from '../api/landings';
import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs'; import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
import { formatPrice } from '../utils/format';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import { Toggle } from '../components/admin'; import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
import { useNotify } from '@/platform'; import { useNotify } from '@/platform';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { getApiErrorMessage } from '../utils/api-error'; import { getApiErrorMessage } from '../utils/api-error';
@@ -34,7 +38,7 @@ import { CSS } from '@dnd-kit/utilities';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
// Types with stable IDs for DnD // Types with stable IDs for DnD
type FeatureWithId = LandingFeature & { _id: string }; type FeatureWithId = AdminLandingFeature & { _id: string };
type MethodWithId = LandingPaymentMethod & { _id: string }; type MethodWithId = LandingPaymentMethod & { _id: string };
const ChevronDownIcon = ({ open }: { open: boolean }) => ( const ChevronDownIcon = ({ open }: { open: boolean }) => (
@@ -49,20 +53,25 @@ const ChevronDownIcon = ({ open }: { open: boolean }) => (
</svg> </svg>
); );
function formatPrice(kopeks: number): string {
return `${(kopeks / 100).toLocaleString('ru-RU')} \u20BD`;
}
// ============ Sortable Feature Item ============ // ============ Sortable Feature Item ============
interface SortableFeatureProps { interface SortableFeatureProps {
feature: FeatureWithId; feature: FeatureWithId;
index: number; 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; onRemove: (index: number) => void;
} }
function SortableFeatureItem({ feature, index, onUpdate, onRemove }: SortableFeatureProps) { function SortableFeatureItem({
feature,
index,
locale,
onUpdateIcon,
onUpdateLocalized,
onRemove,
}: SortableFeatureProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature._id, id: feature._id,
@@ -96,22 +105,22 @@ function SortableFeatureItem({ feature, index, onUpdate, onRemove }: SortableFea
<input <input
type="text" type="text"
value={feature.icon} value={feature.icon}
onChange={(e) => onUpdate(index, 'icon', e.target.value)} onChange={(e) => onUpdateIcon(index, e.target.value)}
placeholder={t('admin.landings.featureIcon')} 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" 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 <LocalizedInput
type="text"
value={feature.title} value={feature.title}
onChange={(e) => onUpdate(index, 'title', e.target.value)} onChange={(v) => onUpdateLocalized(index, 'title', v)}
locale={locale}
placeholder={t('admin.landings.featureTitle')} 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" 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> </div>
<input <LocalizedInput
type="text"
value={feature.description} value={feature.description}
onChange={(e) => onUpdate(index, 'description', e.target.value)} onChange={(v) => onUpdateLocalized(index, 'description', v)}
locale={locale}
placeholder={t('admin.landings.featureDesc')} 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" 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"> <div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-900/50">
<button <button
onClick={onToggle} 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} {title}
<ChevronDownIcon open={open} /> <ChevronDownIcon open={open} />
@@ -217,23 +226,26 @@ export default function AdminLandingEditor() {
footer: false, footer: false,
}); });
const toggleSection = (key: string) => { const toggleSection = useCallback((key: string) => {
setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); 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 [slug, setSlug] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState<LocaleDict>({ ru: '' });
const [subtitle, setSubtitle] = useState(''); const [subtitle, setSubtitle] = useState<LocaleDict>({});
const [isActive, setIsActive] = useState(true); const [isActive, setIsActive] = useState(true);
const [metaTitle, setMetaTitle] = useState(''); const [metaTitle, setMetaTitle] = useState<LocaleDict>({});
const [metaDescription, setMetaDescription] = useState(''); const [metaDescription, setMetaDescription] = useState<LocaleDict>({});
const [features, setFeatures] = useState<FeatureWithId[]>([]); const [features, setFeatures] = useState<FeatureWithId[]>([]);
const [selectedTariffIds, setSelectedTariffIds] = useState<number[]>([]); const [selectedTariffIds, setSelectedTariffIds] = useState<number[]>([]);
const [allowedPeriods, setAllowedPeriods] = useState<Record<string, number[]>>({}); const [allowedPeriods, setAllowedPeriods] = useState<Record<string, number[]>>({});
const [paymentMethods, setPaymentMethods] = useState<MethodWithId[]>([]); const [paymentMethods, setPaymentMethods] = useState<MethodWithId[]>([]);
const [giftEnabled, setGiftEnabled] = useState(false); const [giftEnabled, setGiftEnabled] = useState(false);
const [footerText, setFooterText] = useState(''); const [footerText, setFooterText] = useState<LocaleDict>({});
const [customCss, setCustomCss] = useState(''); const [customCss, setCustomCss] = useState('');
// DnD sensors // DnD sensors
@@ -273,15 +285,17 @@ export default function AdminLandingEditor() {
})), })),
}); });
const tariffPeriodsData = tariffDetailQueries.map((q) => q.data);
const tariffPeriodsMap = useMemo(() => { const tariffPeriodsMap = useMemo(() => {
const map: Record<number, PeriodPrice[]> = {}; const map: Record<number, PeriodPrice[]> = {};
tariffDetailQueries.forEach((q, i) => { tariffPeriodsData.forEach((data, i) => {
if (q.data) { if (data) {
map[selectedTariffIds[i]] = q.data.period_prices; map[selectedTariffIds[i]] = data.period_prices;
} }
}); });
return map; return map;
}, [tariffDetailQueries, selectedTariffIds]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(tariffPeriodsData.map((d) => d?.id)), selectedTariffIds]);
// Fetch landing for editing // Fetch landing for editing
const { data: landingData } = useQuery({ const { data: landingData } = useQuery({
@@ -306,19 +320,26 @@ export default function AdminLandingEditor() {
if (!landingData || formPopulated.current) return; if (!landingData || formPopulated.current) return;
formPopulated.current = true; formPopulated.current = true;
setSlug(landingData.slug); setSlug(landingData.slug);
setTitle(landingData.title); setTitle(toLocaleDict(landingData.title, { ru: '' }));
setSubtitle(landingData.subtitle ?? ''); setSubtitle(toLocaleDict(landingData.subtitle));
setIsActive(landingData.is_active); setIsActive(landingData.is_active);
setMetaTitle(landingData.meta_title ?? ''); setMetaTitle(toLocaleDict(landingData.meta_title));
setMetaDescription(landingData.meta_description ?? ''); setMetaDescription(toLocaleDict(landingData.meta_description));
setFeatures((landingData.features ?? []).map((f) => ({ ...f, _id: crypto.randomUUID() }))); setFeatures(
(landingData.features ?? []).map((f) => ({
...f,
_id: crypto.randomUUID(),
title: toLocaleDict(f.title),
description: toLocaleDict(f.description),
})),
);
setSelectedTariffIds(landingData.allowed_tariff_ids ?? []); setSelectedTariffIds(landingData.allowed_tariff_ids ?? []);
setAllowedPeriods(landingData.allowed_periods ?? {}); setAllowedPeriods(landingData.allowed_periods ?? {});
setPaymentMethods( setPaymentMethods(
(landingData.payment_methods ?? []).map((m) => ({ ...m, _id: crypto.randomUUID() })), (landingData.payment_methods ?? []).map((m) => ({ ...m, _id: crypto.randomUUID() })),
); );
setGiftEnabled(landingData.gift_enabled); setGiftEnabled(landingData.gift_enabled);
setFooterText(landingData.footer_text ?? ''); setFooterText(toLocaleDict(landingData.footer_text));
setCustomCss(landingData.custom_css ?? ''); setCustomCss(landingData.custom_css ?? '');
}, [landingData]); }, [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 = () => { const handleSubmit = () => {
// Client-side validation // Client-side validation
if (!isEdit && !/^[a-z0-9-]+$/.test(slug)) { if (!isEdit && !/^[a-z0-9-]+$/.test(slug)) {
@@ -361,7 +390,8 @@ export default function AdminLandingEditor() {
); );
return; 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')); notify.error(t('admin.landings.titleRequired', 'Title is required'));
return; return;
} }
@@ -375,23 +405,23 @@ export default function AdminLandingEditor() {
} }
// Strip _id before sending to API // 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 cleanMethods = paymentMethods.map(({ _id: _, ...rest }) => rest);
const data: LandingCreateRequest = { const data: LandingCreateRequest = {
slug, slug,
title, title,
subtitle: subtitle || undefined, subtitle: nonEmptyDict(subtitle),
is_active: isActive, is_active: isActive,
features: cleanFeatures, features: cleanFeatures,
footer_text: footerText || undefined, footer_text: nonEmptyDict(footerText),
allowed_tariff_ids: selectedTariffIds, allowed_tariff_ids: selectedTariffIds,
allowed_periods: allowedPeriods, allowed_periods: allowedPeriods,
payment_methods: cleanMethods, payment_methods: cleanMethods,
gift_enabled: giftEnabled, gift_enabled: giftEnabled,
custom_css: customCss || undefined, custom_css: customCss || undefined,
meta_title: metaTitle || undefined, meta_title: nonEmptyDict(metaTitle),
meta_description: metaDescription || undefined, meta_description: nonEmptyDict(metaDescription),
}; };
if (isEdit) { if (isEdit) {
@@ -407,17 +437,24 @@ export default function AdminLandingEditor() {
const addFeature = () => { const addFeature = () => {
setFeatures((prev) => [ setFeatures((prev) => [
...prev, ...prev,
{ _id: crypto.randomUUID(), icon: '', title: '', description: '' }, { _id: crypto.randomUUID(), icon: '', title: {}, description: {} },
]); ]);
}; };
const updateFeature = (index: number, field: keyof LandingFeature, value: string) => { const updateFeatureIcon = useCallback((index: number, value: string) => {
setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, [field]: value } : f))); 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)); setFeatures((prev) => prev.filter((_, i) => i !== index));
}; }, []);
const handleFeatureDragEnd = useCallback((event: DragEndEvent) => { const handleFeatureDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -503,8 +540,8 @@ export default function AdminLandingEditor() {
}; };
// Feature IDs for DnD // Feature IDs for DnD
const featureIds = features.map((f) => f._id); const featureIds = useMemo(() => features.map((f) => f._id), [features]);
const methodIds = paymentMethods.map((m) => m._id); const methodIds = useMemo(() => paymentMethods.map((m) => m._id), [paymentMethods]);
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -532,7 +569,7 @@ export default function AdminLandingEditor() {
</button> </button>
<button <button
onClick={handleSubmit} 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" 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 && ( {isPending && (
@@ -543,6 +580,21 @@ export default function AdminLandingEditor() {
</div> </div>
</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"> <div className="space-y-4">
{/* General Section */} {/* General Section */}
<Section <Section
@@ -571,12 +623,11 @@ export default function AdminLandingEditor() {
<label htmlFor="landing-title" className="mb-1 block text-sm text-dark-400"> <label htmlFor="landing-title" className="mb-1 block text-sm text-dark-400">
{t('admin.landings.pageTitle')} {t('admin.landings.pageTitle')}
</label> </label>
<input <LocalizedInput
id="landing-title" id="landing-title"
type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={setTitle}
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" locale={editingLocale}
/> />
</div> </div>
@@ -584,12 +635,13 @@ export default function AdminLandingEditor() {
<label htmlFor="landing-subtitle" className="mb-1 block text-sm text-dark-400"> <label htmlFor="landing-subtitle" className="mb-1 block text-sm text-dark-400">
{t('admin.landings.subtitle')} {t('admin.landings.subtitle')}
</label> </label>
<textarea <LocalizedInput
id="landing-subtitle" id="landing-subtitle"
value={subtitle} value={subtitle}
onChange={(e) => setSubtitle(e.target.value)} onChange={setSubtitle}
locale={editingLocale}
multiline
rows={2} 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>
@@ -606,22 +658,22 @@ export default function AdminLandingEditor() {
<label className="mb-1 block text-sm text-dark-400"> <label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.metaTitle')} {t('admin.landings.metaTitle')}
</label> </label>
<input <LocalizedInput
type="text"
value={metaTitle} value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)} onChange={setMetaTitle}
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" locale={editingLocale}
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm text-dark-400"> <label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.metaDesc')} {t('admin.landings.metaDesc')}
</label> </label>
<textarea <LocalizedInput
value={metaDescription} value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)} onChange={setMetaDescription}
locale={editingLocale}
multiline
rows={2} 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>
</div> </div>
@@ -643,7 +695,9 @@ export default function AdminLandingEditor() {
key={feature._id} key={feature._id}
feature={feature} feature={feature}
index={index} index={index}
onUpdate={updateFeature} locale={editingLocale}
onUpdateIcon={updateFeatureIcon}
onUpdateLocalized={updateFeatureLocalized}
onRemove={removeFeature} onRemove={removeFeature}
/> />
))} ))}
@@ -816,11 +870,12 @@ export default function AdminLandingEditor() {
<label className="mb-1 block text-sm text-dark-400"> <label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.footerText')} {t('admin.landings.footerText')}
</label> </label>
<textarea <LocalizedInput
value={footerText} value={footerText}
onChange={(e) => setFooterText(e.target.value)} onChange={setFooterText}
locale={editingLocale}
multiline
rows={4} 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>
<div> <div>

View File

@@ -12,21 +12,10 @@ import type {
LandingPaymentMethod, LandingPaymentMethod,
PurchaseRequest, PurchaseRequest,
} from '../api/landings'; } from '../api/landings';
import LanguageSwitcher from '../components/LanguageSwitcher';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error'; import { getApiErrorMessage } from '../utils/api-error';
import { formatPrice } from '../utils/format';
// ============================================================
// Helpers
// ============================================================
function formatPrice(kopeks: number): string {
const rubles = kopeks / 100;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(rubles);
}
function detectContactType(value: string): 'email' | 'telegram' { function detectContactType(value: string): 'email' | 'telegram' {
return value.startsWith('@') ? 'telegram' : 'email'; return value.startsWith('@') ? 'telegram' : 'email';
@@ -258,7 +247,7 @@ function TariffCard({
aria-checked={isSelected} aria-checked={isSelected}
onClick={onSelect} onClick={onSelect}
className={cn( 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 isSelected
? 'border-accent-500/50 bg-accent-500/5 ring-1 ring-accent-500/25' ? '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', : '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} aria-checked={isSelected}
onClick={onSelect} onClick={onSelect}
className={cn( 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 isSelected
? 'border-accent-500/50 bg-accent-500/5' ? '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', : '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() { export default function QuickPurchase() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
// Fetch config // Fetch config
const { const {
@@ -543,8 +532,8 @@ export default function QuickPurchase() {
isLoading, isLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ['landing-config', slug], queryKey: ['landing-config', slug, i18n.language],
queryFn: () => landingApi.getConfig(slug!), queryFn: () => landingApi.getConfig(slug!, i18n.language),
enabled: !!slug, enabled: !!slug,
staleTime: 60_000, staleTime: 60_000,
retry: 1, retry: 1,
@@ -586,7 +575,9 @@ export default function QuickPurchase() {
// Filter tariffs to only those that have the selected period // Filter tariffs to only those that have the selected period
const visibleTariffs = useMemo(() => { const visibleTariffs = useMemo(() => {
if (!config || !selectedPeriodDays) return config?.tariffs ?? []; 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]); }, [config, selectedPeriodDays]);
// Auto-select first tariff, period, method on config load // 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 // When period changes, auto-select first visible tariff if current is hidden
useEffect(() => { useEffect(() => {
if (!visibleTariffs.length) return; if (!visibleTariffs.length) return;
const currentVisible = visibleTariffs.find((t) => t.id === selectedTariffId); const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
if (!currentVisible) { if (!currentVisible) {
setSelectedTariffId(visibleTariffs[0].id); setSelectedTariffId(visibleTariffs[0].id);
} }
@@ -752,6 +743,11 @@ export default function QuickPurchase() {
return ( return (
<div className="min-h-dvh bg-dark-950"> <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"> <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 */} {/* Header */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} 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);
}