mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +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;
|
max_amount_kopeks: number | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
return_url: string | null;
|
return_url: string | null;
|
||||||
|
sub_options: Record<string, boolean> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Editable fields on a payment method in the landing editor */
|
/** 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",
|
"selectMethods": "Select available methods",
|
||||||
"noSystemMethods": "No payment methods configured in the system",
|
"noSystemMethods": "No payment methods configured in the system",
|
||||||
"methodOrder": "Drag to reorder",
|
"methodOrder": "Drag to reorder",
|
||||||
|
"methodSubOptions": "Payment sub-options",
|
||||||
"loadingPeriods": "Loading...",
|
"loadingPeriods": "Loading...",
|
||||||
"periodDaySuffix": "d",
|
"periodDaySuffix": "d",
|
||||||
"localeTab": "Language",
|
"localeTab": "Language",
|
||||||
|
|||||||
@@ -2918,6 +2918,7 @@
|
|||||||
"selectMethods": "انتخاب روشهای پرداخت موجود",
|
"selectMethods": "انتخاب روشهای پرداخت موجود",
|
||||||
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
|
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
|
||||||
"methodOrder": "برای تغییر ترتیب بکشید",
|
"methodOrder": "برای تغییر ترتیب بکشید",
|
||||||
|
"methodSubOptions": "گزینههای فرعی پرداخت",
|
||||||
"loadingPeriods": "در حال بارگذاری...",
|
"loadingPeriods": "در حال بارگذاری...",
|
||||||
"periodDaySuffix": "روز",
|
"periodDaySuffix": "روز",
|
||||||
"localeTab": "زبان",
|
"localeTab": "زبان",
|
||||||
|
|||||||
@@ -3747,6 +3747,7 @@
|
|||||||
"selectMethods": "Выберите доступные методы",
|
"selectMethods": "Выберите доступные методы",
|
||||||
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
|
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
|
||||||
"methodOrder": "Перетащите для изменения порядка",
|
"methodOrder": "Перетащите для изменения порядка",
|
||||||
|
"methodSubOptions": "Варианты оплаты",
|
||||||
"loadingPeriods": "Загрузка...",
|
"loadingPeriods": "Загрузка...",
|
||||||
"periodDaySuffix": "д",
|
"periodDaySuffix": "д",
|
||||||
"localeTab": "Язык",
|
"localeTab": "Язык",
|
||||||
|
|||||||
@@ -2917,6 +2917,7 @@
|
|||||||
"selectMethods": "选择可用的支付方式",
|
"selectMethods": "选择可用的支付方式",
|
||||||
"noSystemMethods": "系统中未配置任何支付方式",
|
"noSystemMethods": "系统中未配置任何支付方式",
|
||||||
"methodOrder": "拖动以调整顺序",
|
"methodOrder": "拖动以调整顺序",
|
||||||
|
"methodSubOptions": "支付子选项",
|
||||||
"loadingPeriods": "加载中...",
|
"loadingPeriods": "加载中...",
|
||||||
"periodDaySuffix": "天",
|
"periodDaySuffix": "天",
|
||||||
"localeTab": "语言",
|
"localeTab": "语言",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
adminLandingsApi,
|
adminLandingsApi,
|
||||||
type LandingCreateRequest,
|
type LandingCreateRequest,
|
||||||
type AdminLandingFeature,
|
type AdminLandingFeature,
|
||||||
type LandingPaymentMethod,
|
|
||||||
type EditableMethodField,
|
type EditableMethodField,
|
||||||
type LocaleDict,
|
type LocaleDict,
|
||||||
type SupportedLocale,
|
type SupportedLocale,
|
||||||
@@ -16,10 +15,15 @@ import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
|
|||||||
import { formatPrice } from '../utils/format';
|
import { formatPrice } from '../utils/format';
|
||||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
|
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 { 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';
|
||||||
import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
|
import { BackIcon, PlusIcon } from '../components/icons/LandingIcons';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
@@ -32,15 +36,10 @@ import {
|
|||||||
arrayMove,
|
arrayMove,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import type { PaymentMethodSubOptionInfo } from '../types';
|
||||||
// Types with stable IDs for DnD
|
|
||||||
type FeatureWithId = AdminLandingFeature & { _id: string };
|
|
||||||
type MethodWithId = LandingPaymentMethod & { _id: string };
|
|
||||||
|
|
||||||
const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
||||||
<svg
|
<svg
|
||||||
@@ -54,255 +53,6 @@ const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
|||||||
</svg>
|
</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 ============
|
// ============ Collapsible Section ============
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
@@ -397,6 +147,16 @@ export default function AdminLandingEditor() {
|
|||||||
[systemMethods],
|
[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
|
// Fetch tariff details for period info
|
||||||
const tariffDetailQueries = useQueries({
|
const tariffDetailQueries = useQueries({
|
||||||
queries: selectedTariffIds.map((tariffId) => ({
|
queries: selectedTariffIds.map((tariffId) => ({
|
||||||
@@ -465,6 +225,7 @@ export default function AdminLandingEditor() {
|
|||||||
max_amount_kopeks: m.max_amount_kopeks ?? null,
|
max_amount_kopeks: m.max_amount_kopeks ?? null,
|
||||||
currency: m.currency ?? null,
|
currency: m.currency ?? null,
|
||||||
return_url: m.return_url ?? null,
|
return_url: m.return_url ?? null,
|
||||||
|
sub_options: m.sub_options ?? null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setGiftEnabled(landingData.gift_enabled);
|
setGiftEnabled(landingData.gift_enabled);
|
||||||
@@ -543,6 +304,7 @@ export default function AdminLandingEditor() {
|
|||||||
max_amount_kopeks: rest.max_amount_kopeks ?? null,
|
max_amount_kopeks: rest.max_amount_kopeks ?? null,
|
||||||
currency: rest.currency || null,
|
currency: rest.currency || null,
|
||||||
return_url: rest.return_url || null,
|
return_url: rest.return_url || null,
|
||||||
|
sub_options: rest.sub_options ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filter out empty period arrays and periods for non-selected tariffs
|
// 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);
|
const systemMethod = availablePaymentMethods.find((m) => m.method_id === methodId);
|
||||||
if (!systemMethod) return prev;
|
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 [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -635,6 +405,7 @@ export default function AdminLandingEditor() {
|
|||||||
max_amount_kopeks: systemMethod.max_amount_kopeks ?? null,
|
max_amount_kopeks: systemMethod.max_amount_kopeks ?? null,
|
||||||
currency: null,
|
currency: null,
|
||||||
return_url: 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) => {
|
const removePaymentMethod = useCallback((methodId: string) => {
|
||||||
setPaymentMethods((prev) => prev.filter((m) => m.method_id !== methodId));
|
setPaymentMethods((prev) => prev.filter((m) => m.method_id !== methodId));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -983,8 +760,14 @@ export default function AdminLandingEditor() {
|
|||||||
onChange={() => togglePaymentMethod(sysMethod.method_id)}
|
onChange={() => togglePaymentMethod(sysMethod.method_id)}
|
||||||
className="h-4 w-4 rounded border-dark-600 bg-dark-700 text-accent-500"
|
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.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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -1006,7 +789,9 @@ export default function AdminLandingEditor() {
|
|||||||
<SortableSelectedMethodCard
|
<SortableSelectedMethodCard
|
||||||
key={method._id}
|
key={method._id}
|
||||||
method={method}
|
method={method}
|
||||||
|
availableSubOptions={subOptionsMap[method.method_id] ?? null}
|
||||||
onUpdate={updatePaymentMethod}
|
onUpdate={updatePaymentMethod}
|
||||||
|
onSubOptionsChange={updateSubOptions}
|
||||||
onRemove={removePaymentMethod}
|
onRemove={removePaymentMethod}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user