mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add payment sub-option selection on quick purchase page
Show sub-options (e.g. СБП, Карта) as selectable buttons when a payment method has multiple enabled variants. Send suffixed payment_method ID to backend for correct provider routing. Split LandingPaymentMethod into public and admin types.
This commit is contained in:
@@ -27,7 +27,27 @@ export interface LandingTariff {
|
||||
periods: LandingTariffPeriod[];
|
||||
}
|
||||
|
||||
export interface LandingPaymentMethodSubOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Payment method as returned by the public landing config API */
|
||||
export interface LandingPaymentMethod {
|
||||
method_id: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
icon_url: string | null;
|
||||
sort_order: number;
|
||||
min_amount_kopeks: number | null;
|
||||
max_amount_kopeks: number | null;
|
||||
currency: string | null;
|
||||
return_url: string | null;
|
||||
sub_options: LandingPaymentMethodSubOption[] | null;
|
||||
}
|
||||
|
||||
/** Payment method as stored/returned by the admin landing API (sub_options is a dict) */
|
||||
export interface AdminLandingPaymentMethod {
|
||||
method_id: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
@@ -158,7 +178,7 @@ export interface LandingDetail {
|
||||
footer_text: LocaleDict | null;
|
||||
allowed_tariff_ids: number[];
|
||||
allowed_periods: Record<string, number[]>;
|
||||
payment_methods: LandingPaymentMethod[];
|
||||
payment_methods: AdminLandingPaymentMethod[];
|
||||
gift_enabled: boolean;
|
||||
custom_css: string | null;
|
||||
meta_title: LocaleDict | null;
|
||||
@@ -177,7 +197,7 @@ export interface LandingCreateRequest {
|
||||
footer_text?: LocaleDict;
|
||||
allowed_tariff_ids?: number[];
|
||||
allowed_periods?: Record<string, number[]>;
|
||||
payment_methods?: LandingPaymentMethod[];
|
||||
payment_methods?: AdminLandingPaymentMethod[];
|
||||
gift_enabled?: boolean;
|
||||
custom_css?: string;
|
||||
meta_title?: LocaleDict;
|
||||
|
||||
@@ -4,10 +4,10 @@ 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 { AdminLandingPaymentMethod, EditableMethodField } from '../../api/landings';
|
||||
import type { PaymentMethodSubOptionInfo } from '../../types';
|
||||
|
||||
export type MethodWithId = LandingPaymentMethod & { _id: string };
|
||||
export type MethodWithId = AdminLandingPaymentMethod & { _id: string };
|
||||
|
||||
const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
||||
<svg
|
||||
|
||||
@@ -332,50 +332,83 @@ function TariffCard({
|
||||
function PaymentMethodCard({
|
||||
method,
|
||||
isSelected,
|
||||
selectedSubOption,
|
||||
onSelect,
|
||||
onSelectSubOption,
|
||||
}: {
|
||||
method: LandingPaymentMethod;
|
||||
isSelected: boolean;
|
||||
selectedSubOption: string | null;
|
||||
onSelect: () => void;
|
||||
onSelectSubOption: (subOptionId: string) => void;
|
||||
}) {
|
||||
const hasSubOptions = method.sub_options && method.sub_options.length > 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={onSelect}
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center gap-4 rounded-2xl border p-4 text-start transition-all duration-200',
|
||||
'rounded-2xl border transition-all duration-200',
|
||||
isSelected
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
{method.icon_url && (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-dark-800/50">
|
||||
<img src={method.icon_url} alt="" className="h-6 w-6 object-contain" />
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={onSelect}
|
||||
className="flex w-full items-center gap-4 p-4 text-start"
|
||||
>
|
||||
{/* Icon */}
|
||||
{method.icon_url && (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-dark-800/50">
|
||||
<img src={method.icon_url} alt="" className="h-6 w-6 object-contain" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-dark-100">{method.display_name}</p>
|
||||
{method.description && (
|
||||
<p className="mt-0.5 truncate text-xs text-dark-400">{method.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radio */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
|
||||
)}
|
||||
>
|
||||
{isSelected && <div className="h-2 w-2 rounded-full bg-white" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Sub-options */}
|
||||
{isSelected && hasSubOptions && (
|
||||
<div className="border-t border-dark-800/30 px-4 pb-4 pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{method.sub_options!.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => onSelectSubOption(opt.id)}
|
||||
className={cn(
|
||||
'rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||
selectedSubOption === opt.id
|
||||
? 'bg-accent-500 text-white shadow-sm shadow-accent-500/25'
|
||||
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
{opt.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-dark-100">{method.display_name}</p>
|
||||
{method.description && (
|
||||
<p className="mt-0.5 truncate text-xs text-dark-400">{method.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radio */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
|
||||
)}
|
||||
>
|
||||
{isSelected && <div className="h-2 w-2 rounded-full bg-white" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -547,6 +580,7 @@ export default function QuickPurchase() {
|
||||
const [giftRecipient, setGiftRecipient] = useState('');
|
||||
const [giftMessage, setGiftMessage] = useState('');
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [selectedSubOption, setSelectedSubOption] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const redirectTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
@@ -595,7 +629,11 @@ export default function QuickPurchase() {
|
||||
}
|
||||
|
||||
if (config.payment_methods.length > 0 && selectedMethod === null) {
|
||||
setSelectedMethod(config.payment_methods[0].method_id);
|
||||
const firstMethod = config.payment_methods[0];
|
||||
setSelectedMethod(firstMethod.method_id);
|
||||
if (firstMethod.sub_options && firstMethod.sub_options.length > 1) {
|
||||
setSelectedSubOption(firstMethod.sub_options[0].id);
|
||||
}
|
||||
}
|
||||
}, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
|
||||
|
||||
@@ -709,12 +747,19 @@ export default function QuickPurchase() {
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
// Build the payment_method string: append sub-option suffix if selected
|
||||
// e.g. "platega" + "2" → "platega_2", "yookassa" + "sbp" → "yookassa_sbp"
|
||||
let paymentMethod = selectedMethod!;
|
||||
if (selectedSubOption) {
|
||||
paymentMethod = `${paymentMethod}_${selectedSubOption}`;
|
||||
}
|
||||
|
||||
const data: PurchaseRequest = {
|
||||
tariff_id: selectedTariffId!,
|
||||
period_days: selectedPeriodDays!,
|
||||
contact_type: detectContactType(contactValue),
|
||||
contact_value: contactValue.trim(),
|
||||
payment_method: selectedMethod!,
|
||||
payment_method: paymentMethod,
|
||||
is_gift: isGift,
|
||||
};
|
||||
|
||||
@@ -849,7 +894,19 @@ export default function QuickPurchase() {
|
||||
key={method.method_id}
|
||||
method={method}
|
||||
isSelected={method.method_id === selectedMethod}
|
||||
onSelect={() => setSelectedMethod(method.method_id)}
|
||||
selectedSubOption={
|
||||
method.method_id === selectedMethod ? selectedSubOption : null
|
||||
}
|
||||
onSelect={() => {
|
||||
setSelectedMethod(method.method_id);
|
||||
// Auto-select first sub-option when switching methods
|
||||
if (method.sub_options && method.sub_options.length > 1) {
|
||||
setSelectedSubOption(method.sub_options[0].id);
|
||||
} else {
|
||||
setSelectedSubOption(null);
|
||||
}
|
||||
}}
|
||||
onSelectSubOption={setSelectedSubOption}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user