mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +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:
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
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 './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';
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3748,7 +3748,9 @@
|
|||||||
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
|
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
|
||||||
"methodOrder": "Перетащите для изменения порядка",
|
"methodOrder": "Перетащите для изменения порядка",
|
||||||
"loadingPeriods": "Загрузка...",
|
"loadingPeriods": "Загрузка...",
|
||||||
"periodDaySuffix": "д"
|
"periodDaySuffix": "д",
|
||||||
|
"localeTab": "Язык",
|
||||||
|
"localeHint": "Переключите язык для редактирования текста на этом языке"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
8
src/utils/format.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user