From d0be127d30574af1cb90503943bfa721dda8e645 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 6 Mar 2026 23:30:53 +0300 Subject: [PATCH] feat: add sub-options UI for landing payment methods + extract components Add interactive sub-option toggles (Card/SBP/Crypto) to the landing editor payment method configuration. Extract SortableFeatureItem and SortableSelectedMethodCard into dedicated component files to reduce AdminLandingEditor from ~1160 to ~860 lines. --- src/api/landings.ts | 1 + src/components/admin/SortableFeatureItem.tsx | 89 ++++++ .../admin/SortableSelectedMethodCard.tsx | 251 +++++++++++++++ src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/AdminLandingEditor.tsx | 301 +++--------------- 8 files changed, 388 insertions(+), 258 deletions(-) create mode 100644 src/components/admin/SortableFeatureItem.tsx create mode 100644 src/components/admin/SortableSelectedMethodCard.tsx diff --git a/src/api/landings.ts b/src/api/landings.ts index c6addc0..27fd3ae 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -37,6 +37,7 @@ export interface LandingPaymentMethod { max_amount_kopeks: number | null; currency: string | null; return_url: string | null; + sub_options: Record | null; } /** Editable fields on a payment method in the landing editor */ diff --git a/src/components/admin/SortableFeatureItem.tsx b/src/components/admin/SortableFeatureItem.tsx new file mode 100644 index 0000000..c8ce4a0 --- /dev/null +++ b/src/components/admin/SortableFeatureItem.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from 'react-i18next'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { cn } from '../../lib/utils'; +import { GripIcon, TrashIcon } from '../icons/LandingIcons'; +import { LocalizedInput } from './LocalizedInput'; +import type { AdminLandingFeature, LocaleDict, SupportedLocale } from '../../api/landings'; + +export type FeatureWithId = AdminLandingFeature & { _id: string }; + +interface SortableFeatureProps { + feature: FeatureWithId; + index: number; + locale: SupportedLocale; + onUpdateIcon: (index: number, value: string) => void; + onUpdateLocalized: (index: number, field: 'title' | 'description', value: LocaleDict) => void; + onRemove: (index: number) => void; +} + +export function SortableFeatureItem({ + feature, + index, + locale, + onUpdateIcon, + onUpdateLocalized, + onRemove, +}: SortableFeatureProps) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: feature._id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + position: isDragging ? 'relative' : undefined, + }; + + return ( +
+ +
+
+ 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" + /> + 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" + /> +
+ 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" + /> +
+ +
+ ); +} diff --git a/src/components/admin/SortableSelectedMethodCard.tsx b/src/components/admin/SortableSelectedMethodCard.tsx new file mode 100644 index 0000000..e86ed6d --- /dev/null +++ b/src/components/admin/SortableSelectedMethodCard.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { cn } from '../../lib/utils'; +import { GripIcon, TrashIcon } from '../icons/LandingIcons'; +import type { LandingPaymentMethod, EditableMethodField } from '../../api/landings'; +import type { PaymentMethodSubOptionInfo } from '../../types'; + +export type MethodWithId = LandingPaymentMethod & { _id: string }; + +const ChevronDownIcon = ({ open }: { open: boolean }) => ( + + + +); + +interface SortableSelectedMethodProps { + method: MethodWithId; + availableSubOptions: PaymentMethodSubOptionInfo[] | null; + onUpdate: (methodId: string, field: EditableMethodField, value: string | number | null) => void; + onSubOptionsChange: (methodId: string, subOptions: Record) => void; + onRemove: (methodId: string) => void; +} + +export function SortableSelectedMethodCard({ + method, + availableSubOptions, + onUpdate, + onSubOptionsChange, + onRemove, +}: SortableSelectedMethodProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: method._id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + position: isDragging ? 'relative' : undefined, + }; + + return ( +
+
+ + + + +
+ {expanded && ( +
+
+ + onUpdate(method.method_id, 'display_name', e.target.value)} + 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" + /> +
+
+ + onUpdate(method.method_id, 'description', e.target.value || null)} + placeholder={t('admin.landings.methodDescPlaceholder', 'Optional description')} + 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" + /> +
+
+ + onUpdate(method.method_id, 'icon_url', e.target.value || null)} + placeholder="https://..." + 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" + /> +
+
+
+ + + onUpdate( + method.method_id, + 'min_amount_kopeks', + e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null, + ) + } + placeholder="—" + 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" + /> +
+
+ + + onUpdate( + method.method_id, + 'max_amount_kopeks', + e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null, + ) + } + placeholder="—" + 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" + /> +
+
+
+ + onUpdate(method.method_id, 'currency', e.target.value || null)} + placeholder="RUB" + 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" + /> +
+
+ + onUpdate(method.method_id, 'return_url', e.target.value || null)} + placeholder={t( + 'admin.landings.methodReturnUrlPlaceholder', + 'Default: cabinet success page. Use {token} for purchase token', + )} + 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" + /> +
+ {availableSubOptions && availableSubOptions.length > 0 && ( +
+ +
+ {availableSubOptions.map((opt) => { + // Missing keys treated as enabled (opt-out model) + const enabled = method.sub_options?.[opt.id] !== false; + return ( + + ); + })} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index f6ca25d..64544e9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3192,6 +3192,7 @@ "selectMethods": "Select available methods", "noSystemMethods": "No payment methods configured in the system", "methodOrder": "Drag to reorder", + "methodSubOptions": "Payment sub-options", "loadingPeriods": "Loading...", "periodDaySuffix": "d", "localeTab": "Language", diff --git a/src/locales/fa.json b/src/locales/fa.json index 5b1bbb9..1108247 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2918,6 +2918,7 @@ "selectMethods": "انتخاب روش‌های پرداخت موجود", "noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است", "methodOrder": "برای تغییر ترتیب بکشید", + "methodSubOptions": "گزینه‌های فرعی پرداخت", "loadingPeriods": "در حال بارگذاری...", "periodDaySuffix": "روز", "localeTab": "زبان", diff --git a/src/locales/ru.json b/src/locales/ru.json index 774e1a8..c6d5fb8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3747,6 +3747,7 @@ "selectMethods": "Выберите доступные методы", "noSystemMethods": "В системе не настроено ни одного способа оплаты", "methodOrder": "Перетащите для изменения порядка", + "methodSubOptions": "Варианты оплаты", "loadingPeriods": "Загрузка...", "periodDaySuffix": "д", "localeTab": "Язык", diff --git a/src/locales/zh.json b/src/locales/zh.json index fd4cb1f..8f100c2 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2917,6 +2917,7 @@ "selectMethods": "选择可用的支付方式", "noSystemMethods": "系统中未配置任何支付方式", "methodOrder": "拖动以调整顺序", + "methodSubOptions": "支付子选项", "loadingPeriods": "加载中...", "periodDaySuffix": "天", "localeTab": "语言", diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index 32349da..5e09214 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -6,7 +6,6 @@ import { adminLandingsApi, type LandingCreateRequest, type AdminLandingFeature, - type LandingPaymentMethod, type EditableMethodField, type LocaleDict, type SupportedLocale, @@ -16,10 +15,15 @@ import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs'; import { formatPrice } from '../utils/format'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin'; +import { SortableFeatureItem, type FeatureWithId } from '../components/admin/SortableFeatureItem'; +import { + SortableSelectedMethodCard, + type MethodWithId, +} from '../components/admin/SortableSelectedMethodCard'; import { useNotify } from '@/platform'; import { usePlatform } from '../platform/hooks/usePlatform'; import { getApiErrorMessage } from '../utils/api-error'; -import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons'; +import { BackIcon, PlusIcon } from '../components/icons/LandingIcons'; import { DndContext, KeyboardSensor, @@ -32,15 +36,10 @@ import { arrayMove, SortableContext, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; import { cn } from '../lib/utils'; - -// Types with stable IDs for DnD -type FeatureWithId = AdminLandingFeature & { _id: string }; -type MethodWithId = LandingPaymentMethod & { _id: string }; +import type { PaymentMethodSubOptionInfo } from '../types'; const ChevronDownIcon = ({ open }: { open: boolean }) => ( ( ); -// ============ Sortable Feature Item ============ - -interface SortableFeatureProps { - feature: FeatureWithId; - index: number; - 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, - locale, - onUpdateIcon, - onUpdateLocalized, - onRemove, -}: SortableFeatureProps) { - const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: feature._id, - }); - - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 50 : undefined, - position: isDragging ? 'relative' : undefined, - }; - - return ( -
- -
-
- 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" - /> - 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" - /> -
- 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" - /> -
- -
- ); -} - -// ============ Sortable Selected Payment Method Card ============ - -interface SortableSelectedMethodProps { - method: MethodWithId; - onUpdate: (methodId: string, field: EditableMethodField, value: string | number | null) => void; - onRemove: (methodId: string) => void; -} - -function SortableSelectedMethodCard({ method, onUpdate, onRemove }: SortableSelectedMethodProps) { - const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: method._id, - }); - - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 50 : undefined, - position: isDragging ? 'relative' : undefined, - }; - - return ( -
-
- - - - -
- {expanded && ( -
-
- - onUpdate(method.method_id, 'display_name', e.target.value)} - 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" - /> -
-
- - onUpdate(method.method_id, 'description', e.target.value || null)} - placeholder={t('admin.landings.methodDescPlaceholder', 'Optional description')} - 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" - /> -
-
- - onUpdate(method.method_id, 'icon_url', e.target.value || null)} - placeholder="https://..." - 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" - /> -
-
-
- - - onUpdate( - method.method_id, - 'min_amount_kopeks', - e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null, - ) - } - placeholder="—" - 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" - /> -
-
- - - onUpdate( - method.method_id, - 'max_amount_kopeks', - e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null, - ) - } - placeholder="—" - 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" - /> -
-
-
- - onUpdate(method.method_id, 'currency', e.target.value || null)} - placeholder="RUB" - 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" - /> -
-
- - onUpdate(method.method_id, 'return_url', e.target.value || null)} - placeholder={t( - 'admin.landings.methodReturnUrlPlaceholder', - 'Default: cabinet success page. Use {token} for purchase token', - )} - 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" - /> -
-
- )} -
- ); -} - // ============ Collapsible Section ============ interface SectionProps { @@ -397,6 +147,16 @@ export default function AdminLandingEditor() { [systemMethods], ); + const subOptionsMap = useMemo(() => { + const map: Record = {}; + for (const m of availablePaymentMethods) { + if (m.available_sub_options && m.available_sub_options.length > 0) { + map[m.method_id] = m.available_sub_options; + } + } + return map; + }, [availablePaymentMethods]); + // Fetch tariff details for period info const tariffDetailQueries = useQueries({ queries: selectedTariffIds.map((tariffId) => ({ @@ -465,6 +225,7 @@ export default function AdminLandingEditor() { max_amount_kopeks: m.max_amount_kopeks ?? null, currency: m.currency ?? null, return_url: m.return_url ?? null, + sub_options: m.sub_options ?? null, })), ); setGiftEnabled(landingData.gift_enabled); @@ -543,6 +304,7 @@ export default function AdminLandingEditor() { max_amount_kopeks: rest.max_amount_kopeks ?? null, currency: rest.currency || null, return_url: rest.return_url || null, + sub_options: rest.sub_options ?? null, })); // Filter out empty period arrays and periods for non-selected tariffs @@ -622,6 +384,14 @@ export default function AdminLandingEditor() { } const systemMethod = availablePaymentMethods.find((m) => m.method_id === methodId); if (!systemMethod) return prev; + // Seed sub_options from global config defaults; if no global overrides exist, + // explicitly set all available sub-options to enabled + const subOptions = + systemMethod.available_sub_options && systemMethod.available_sub_options.length > 0 + ? systemMethod.sub_options + ? { ...systemMethod.sub_options } + : Object.fromEntries(systemMethod.available_sub_options.map((o) => [o.id, true])) + : null; return [ ...prev, { @@ -635,6 +405,7 @@ export default function AdminLandingEditor() { max_amount_kopeks: systemMethod.max_amount_kopeks ?? null, currency: null, return_url: null, + sub_options: subOptions, }, ]; }); @@ -651,6 +422,12 @@ export default function AdminLandingEditor() { [], ); + const updateSubOptions = useCallback((methodId: string, subOptions: Record) => { + setPaymentMethods((prev) => + prev.map((m) => (m.method_id === methodId ? { ...m, sub_options: subOptions } : m)), + ); + }, []); + const removePaymentMethod = useCallback((methodId: string) => { setPaymentMethods((prev) => prev.filter((m) => m.method_id !== methodId)); }, []); @@ -983,8 +760,14 @@ export default function AdminLandingEditor() { onChange={() => togglePaymentMethod(sysMethod.method_id)} className="h-4 w-4 rounded border-dark-600 bg-dark-700 text-accent-500" /> - + {sysMethod.display_name ?? sysMethod.default_display_name} + {sysMethod.available_sub_options && + sysMethod.available_sub_options.length > 0 && ( + + {sysMethod.available_sub_options.map((o) => o.name).join(' / ')} + + )} ); @@ -1006,7 +789,9 @@ export default function AdminLandingEditor() { ))}