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:
Fringg
2026-03-06 23:53:31 +03:00
parent d0be127d30
commit 58e93cd2b7
3 changed files with 113 additions and 36 deletions

View File

@@ -27,7 +27,27 @@ export interface LandingTariff {
periods: LandingTariffPeriod[]; periods: LandingTariffPeriod[];
} }
export interface LandingPaymentMethodSubOption {
id: string;
name: string;
}
/** Payment method as returned by the public landing config API */
export interface LandingPaymentMethod { 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; method_id: string;
display_name: string; display_name: string;
description: string | null; description: string | null;
@@ -158,7 +178,7 @@ export interface LandingDetail {
footer_text: LocaleDict | null; footer_text: LocaleDict | null;
allowed_tariff_ids: number[]; allowed_tariff_ids: number[];
allowed_periods: Record<string, number[]>; allowed_periods: Record<string, number[]>;
payment_methods: LandingPaymentMethod[]; payment_methods: AdminLandingPaymentMethod[];
gift_enabled: boolean; gift_enabled: boolean;
custom_css: string | null; custom_css: string | null;
meta_title: LocaleDict | null; meta_title: LocaleDict | null;
@@ -177,7 +197,7 @@ export interface LandingCreateRequest {
footer_text?: LocaleDict; footer_text?: LocaleDict;
allowed_tariff_ids?: number[]; allowed_tariff_ids?: number[];
allowed_periods?: Record<string, number[]>; allowed_periods?: Record<string, number[]>;
payment_methods?: LandingPaymentMethod[]; payment_methods?: AdminLandingPaymentMethod[];
gift_enabled?: boolean; gift_enabled?: boolean;
custom_css?: string; custom_css?: string;
meta_title?: LocaleDict; meta_title?: LocaleDict;

View File

@@ -4,10 +4,10 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { GripIcon, TrashIcon } from '../icons/LandingIcons'; 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'; import type { PaymentMethodSubOptionInfo } from '../../types';
export type MethodWithId = LandingPaymentMethod & { _id: string }; export type MethodWithId = AdminLandingPaymentMethod & { _id: string };
const ChevronDownIcon = ({ open }: { open: boolean }) => ( const ChevronDownIcon = ({ open }: { open: boolean }) => (
<svg <svg

View File

@@ -332,50 +332,83 @@ function TariffCard({
function PaymentMethodCard({ function PaymentMethodCard({
method, method,
isSelected, isSelected,
selectedSubOption,
onSelect, onSelect,
onSelectSubOption,
}: { }: {
method: LandingPaymentMethod; method: LandingPaymentMethod;
isSelected: boolean; isSelected: boolean;
selectedSubOption: string | null;
onSelect: () => void; onSelect: () => void;
onSelectSubOption: (subOptionId: string) => void;
}) { }) {
const hasSubOptions = method.sub_options && method.sub_options.length > 1;
return ( return (
<button <div
type="button"
role="radio"
aria-checked={isSelected}
onClick={onSelect}
className={cn( 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 isSelected
? 'border-accent-500/50 bg-accent-500/5' ? 'border-accent-500/50 bg-accent-500/5'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30', : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
)} )}
> >
{/* Icon */} <button
{method.icon_url && ( type="button"
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-dark-800/50"> role="radio"
<img src={method.icon_url} alt="" className="h-6 w-6 object-contain" /> 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> </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>
); );
} }
@@ -547,6 +580,7 @@ export default function QuickPurchase() {
const [giftRecipient, setGiftRecipient] = useState(''); const [giftRecipient, setGiftRecipient] = useState('');
const [giftMessage, setGiftMessage] = useState(''); const [giftMessage, setGiftMessage] = useState('');
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedSubOption, setSelectedSubOption] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const redirectTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined); const redirectTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -595,7 +629,11 @@ export default function QuickPurchase() {
} }
if (config.payment_methods.length > 0 && selectedMethod === null) { 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]); }, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
@@ -709,12 +747,19 @@ export default function QuickPurchase() {
setIsSubmitting(true); setIsSubmitting(true);
setSubmitError(null); 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 = { const data: PurchaseRequest = {
tariff_id: selectedTariffId!, tariff_id: selectedTariffId!,
period_days: selectedPeriodDays!, period_days: selectedPeriodDays!,
contact_type: detectContactType(contactValue), contact_type: detectContactType(contactValue),
contact_value: contactValue.trim(), contact_value: contactValue.trim(),
payment_method: selectedMethod!, payment_method: paymentMethod,
is_gift: isGift, is_gift: isGift,
}; };
@@ -849,7 +894,19 @@ export default function QuickPurchase() {
key={method.method_id} key={method.method_id}
method={method} method={method}
isSelected={method.method_id === selectedMethod} 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> </div>