mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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.
This commit is contained in:
@@ -37,6 +37,7 @@ export interface LandingPaymentMethod {
|
||||
max_amount_kopeks: number | null;
|
||||
currency: string | null;
|
||||
return_url: string | null;
|
||||
sub_options: Record<string, boolean> | null;
|
||||
}
|
||||
|
||||
/** Editable fields on a payment method in the landing editor */
|
||||
|
||||
89
src/components/admin/SortableFeatureItem.tsx
Normal file
89
src/components/admin/SortableFeatureItem.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-lg border p-3',
|
||||
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={feature.icon}
|
||||
onChange={(e) => onUpdateIcon(index, e.target.value)}
|
||||
placeholder={t('admin.landings.featureIcon')}
|
||||
className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
<LocalizedInput
|
||||
value={feature.title}
|
||||
onChange={(v) => onUpdateLocalized(index, 'title', v)}
|
||||
locale={locale}
|
||||
placeholder={t('admin.landings.featureTitle')}
|
||||
className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<LocalizedInput
|
||||
value={feature.description}
|
||||
onChange={(v) => onUpdateLocalized(index, 'description', v)}
|
||||
locale={locale}
|
||||
placeholder={t('admin.landings.featureDesc')}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal file
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal file
@@ -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 }) => (
|
||||
<svg
|
||||
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface SortableSelectedMethodProps {
|
||||
method: MethodWithId;
|
||||
availableSubOptions: PaymentMethodSubOptionInfo[] | null;
|
||||
onUpdate: (methodId: string, field: EditableMethodField, value: string | number | null) => void;
|
||||
onSubOptionsChange: (methodId: string, subOptions: Record<string, boolean>) => 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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'rounded-lg border',
|
||||
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
<button onClick={() => setExpanded((v) => !v)} className="min-w-0 flex-1 text-start">
|
||||
<span className="truncate text-sm text-dark-100">{method.display_name}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex-shrink-0 text-dark-500 hover:text-dark-300"
|
||||
>
|
||||
<ChevronDownIcon open={expanded} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(method.method_id)}
|
||||
className="flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="space-y-3 border-t border-dark-700 px-3 py-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodDisplayName', 'Display name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.display_name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodDescription', 'Description')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.description ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodIconUrl', 'Icon URL')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.icon_url ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodMinAmount', 'Min amount (kopeks)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={method.min_amount_kopeks ?? ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodMaxAmount', 'Max amount (kopeks)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={method.max_amount_kopeks ?? ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodCurrency', 'Currency')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.currency ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodReturnUrl', 'Return URL after payment')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.return_url ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{availableSubOptions && availableSubOptions.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodSubOptions', 'Payment sub-options')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSubOptions.map((opt) => {
|
||||
// Missing keys treated as enabled (opt-out model)
|
||||
const enabled = method.sub_options?.[opt.id] !== false;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={enabled}
|
||||
onClick={() => {
|
||||
const current =
|
||||
method.sub_options ??
|
||||
Object.fromEntries(availableSubOptions.map((o) => [o.id, true]));
|
||||
onSubOptionsChange(method.method_id, { ...current, [opt.id]: !enabled });
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors',
|
||||
enabled
|
||||
? 'border-accent-500/30 bg-accent-500/10 text-accent-300'
|
||||
: 'border-dark-700 bg-dark-800 text-dark-500',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex h-3.5 w-3.5 items-center justify-center rounded',
|
||||
enabled
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'border border-dark-600 bg-dark-700',
|
||||
)}
|
||||
>
|
||||
{enabled && (
|
||||
<svg
|
||||
className="h-2.5 w-2.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{opt.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -2918,6 +2918,7 @@
|
||||
"selectMethods": "انتخاب روشهای پرداخت موجود",
|
||||
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
|
||||
"methodOrder": "برای تغییر ترتیب بکشید",
|
||||
"methodSubOptions": "گزینههای فرعی پرداخت",
|
||||
"loadingPeriods": "در حال بارگذاری...",
|
||||
"periodDaySuffix": "روز",
|
||||
"localeTab": "زبان",
|
||||
|
||||
@@ -3747,6 +3747,7 @@
|
||||
"selectMethods": "Выберите доступные методы",
|
||||
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
|
||||
"methodOrder": "Перетащите для изменения порядка",
|
||||
"methodSubOptions": "Варианты оплаты",
|
||||
"loadingPeriods": "Загрузка...",
|
||||
"periodDaySuffix": "д",
|
||||
"localeTab": "Язык",
|
||||
|
||||
@@ -2917,6 +2917,7 @@
|
||||
"selectMethods": "选择可用的支付方式",
|
||||
"noSystemMethods": "系统中未配置任何支付方式",
|
||||
"methodOrder": "拖动以调整顺序",
|
||||
"methodSubOptions": "支付子选项",
|
||||
"loadingPeriods": "加载中...",
|
||||
"periodDaySuffix": "天",
|
||||
"localeTab": "语言",
|
||||
|
||||
@@ -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 }) => (
|
||||
<svg
|
||||
@@ -54,255 +53,6 @@ const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ 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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-lg border p-3',
|
||||
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={feature.icon}
|
||||
onChange={(e) => onUpdateIcon(index, e.target.value)}
|
||||
placeholder={t('admin.landings.featureIcon')}
|
||||
className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
<LocalizedInput
|
||||
value={feature.title}
|
||||
onChange={(v) => onUpdateLocalized(index, 'title', v)}
|
||||
locale={locale}
|
||||
placeholder={t('admin.landings.featureTitle')}
|
||||
className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<LocalizedInput
|
||||
value={feature.description}
|
||||
onChange={(v) => onUpdateLocalized(index, 'description', v)}
|
||||
locale={locale}
|
||||
placeholder={t('admin.landings.featureDesc')}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'rounded-lg border',
|
||||
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
<button onClick={() => setExpanded((v) => !v)} className="min-w-0 flex-1 text-start">
|
||||
<span className="truncate text-sm text-dark-100">{method.display_name}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex-shrink-0 text-dark-500 hover:text-dark-300"
|
||||
>
|
||||
<ChevronDownIcon open={expanded} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(method.method_id)}
|
||||
className="flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="space-y-3 border-t border-dark-700 px-3 py-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodDisplayName', 'Display name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.display_name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodDescription', 'Description')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.description ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodIconUrl', 'Icon URL')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.icon_url ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodMinAmount', 'Min amount (kopeks)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={method.min_amount_kopeks ?? ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodMaxAmount', 'Max amount (kopeks)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={method.max_amount_kopeks ?? ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodCurrency', 'Currency')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.currency ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.landings.methodReturnUrl', 'Return URL after payment')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.return_url ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Collapsible Section ============
|
||||
|
||||
interface SectionProps {
|
||||
@@ -397,6 +147,16 @@ export default function AdminLandingEditor() {
|
||||
[systemMethods],
|
||||
);
|
||||
|
||||
const subOptionsMap = useMemo(() => {
|
||||
const map: Record<string, PaymentMethodSubOptionInfo[]> = {};
|
||||
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<string, boolean>) => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm font-medium text-dark-100">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-dark-100">
|
||||
{sysMethod.display_name ?? sysMethod.default_display_name}
|
||||
{sysMethod.available_sub_options &&
|
||||
sysMethod.available_sub_options.length > 0 && (
|
||||
<span className="rounded-full bg-dark-700 px-1.5 py-0.5 text-[10px] text-dark-400">
|
||||
{sysMethod.available_sub_options.map((o) => o.name).join(' / ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -1006,7 +789,9 @@ export default function AdminLandingEditor() {
|
||||
<SortableSelectedMethodCard
|
||||
key={method._id}
|
||||
method={method}
|
||||
availableSubOptions={subOptionsMap[method.method_id] ?? null}
|
||||
onUpdate={updatePaymentMethod}
|
||||
onSubOptionsChange={updateSubOptions}
|
||||
onRemove={removePaymentMethod}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user