feat(lava): покупка привязкой, продукт тарифа в админке, CTA

Фронтовые хвосты автопродления Lava (бэкенд: de3518b7).

- Поле «Продукт Lava» в форме создания/редактирования тарифа: раньше
  lava_product_id можно было задать только через API, из-за чего фичу нельзя
  было включить из интерфейса. Пустая строка отвязывает тариф от продукта.
- CTA «Оформить с автооплатой Lava» в форме покупки тарифа — рядом с
  СБП-кнопкой, в обеих ветках рендера; флаг lava_recurrent_enabled из
  purchase-options наконец получил потребителя.
- API-метод purchaseWithLavaRecurring + типы lava_product_id в списке,
  детали и запросах тарифа.
- Локали ru/en/zh/fa: подписи покупки и поля продукта (плейсхолдеры сверены).
This commit is contained in:
Fringg
2026-07-29 20:23:35 +03:00
parent 2dce539da2
commit a28f1167f7
10 changed files with 123 additions and 8 deletions

View File

@@ -423,6 +423,21 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
/**
* Оформление подписки на тариф привязкой Lava: первое списание оплачивается
* по возвращённой ссылке и активирует подписку.
*/
purchaseWithLavaRecurring: async (
tariffId: number,
): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => {
const response = await apiClient.post(
'/cabinet/subscription/lava-recurrent/purchase',
{},
{ params: { tariff_id: tariffId } },
);
return response.data;
},
// ── Trial ─────────────────────────────────────────────────────────── // ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => { getTrialInfo: async (): Promise<TrialInfo> => {

View File

@@ -35,6 +35,8 @@ export interface TariffListItem {
show_in_gift: boolean; show_in_gift: boolean;
is_daily: boolean; is_daily: boolean;
daily_price_kopeks: number; daily_price_kopeks: number;
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
lava_product_id?: string | null;
traffic_limit_gb: number; traffic_limit_gb: number;
device_limit: number; device_limit: number;
tier_level: number; tier_level: number;
@@ -85,6 +87,8 @@ export interface TariffDetail {
// Дневной тариф // Дневной тариф
is_daily: boolean; is_daily: boolean;
daily_price_kopeks: number; daily_price_kopeks: number;
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
lava_product_id?: string | null;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка
// Внешний сквад Remnawave // Внешний сквад Remnawave
@@ -124,6 +128,8 @@ export interface TariffCreateRequest {
// Дневной тариф // Дневной тариф
is_daily?: boolean; is_daily?: boolean;
daily_price_kopeks?: number; daily_price_kopeks?: number;
// Автопродление Lava: продукт из кабинета Lava
lava_product_id?: string | null;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null; traffic_reset_mode?: string | null;
// Внешний сквад Remnawave // Внешний сквад Remnawave
@@ -168,6 +174,8 @@ export interface TariffUpdateRequest {
// Дневной тариф // Дневной тариф
is_daily?: boolean; is_daily?: boolean;
daily_price_kopeks?: number; daily_price_kopeks?: number;
// Автопродление Lava: продукт из кабинета Lava
lava_product_id?: string | null;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null; traffic_reset_mode?: string | null;
// Внешний сквад Remnawave // Внешний сквад Remnawave

View File

@@ -36,6 +36,8 @@ export interface TariffPurchaseFormProps {
balanceKopeks: number | undefined; balanceKopeks: number | undefined;
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */ /** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
sbpPurchaseEnabled?: boolean; sbpPurchaseEnabled?: boolean;
/** Оформление привязкой Lava доступно — показать вторую CTA. */
lavaPurchaseEnabled?: boolean;
onBack: () => void; onBack: () => void;
} }
@@ -44,6 +46,7 @@ export function TariffPurchaseForm({
subscriptionId, subscriptionId,
balanceKopeks, balanceKopeks,
sbpPurchaseEnabled = false, sbpPurchaseEnabled = false,
lavaPurchaseEnabled = false,
onBack, onBack,
}: TariffPurchaseFormProps) { }: TariffPurchaseFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -143,6 +146,47 @@ export function TariffPurchaseForm({
</> </>
); );
const lavaPurchaseMutation = useMutation({
mutationFn: () => subscriptionApi.purchaseWithLavaRecurring(tariff.id),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['lava-recurring', data.subscription_id] });
navigate('/subscriptions', { replace: true });
},
});
const lavaPurchaseButton = lavaPurchaseEnabled && (
<>
<button
onClick={() => lavaPurchaseMutation.mutate()}
disabled={lavaPurchaseMutation.isPending || purchaseMutation.isPending}
className="mt-2 w-full rounded-xl border border-accent-500/40 bg-accent-500/10 py-3 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/20 disabled:opacity-50"
>
{lavaPurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('common.loading')}
</span>
) : (
t('subscription.lavaRecurring.purchaseButton')
)}
</button>
<div className="mt-1.5 text-center text-[11px] text-dark-500">
{t('subscription.lavaRecurring.purchaseHint')}
</div>
{lavaPurchaseMutation.isError && (
<div className="mt-2 text-center text-sm text-error-400">
{getErrorMessage(lavaPurchaseMutation.error)}
</div>
)}
</>
);
// Smooth scroll the form into view when first mounted. // Smooth scroll the form into view when first mounted.
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
@@ -241,6 +285,7 @@ export function TariffPurchaseForm({
</button> </button>
{sbpPurchaseButton} {sbpPurchaseButton}
{lavaPurchaseButton}
{purchaseMutation.isError && {purchaseMutation.isError &&
!getInsufficientBalanceError(purchaseMutation.error) && ( !getInsufficientBalanceError(purchaseMutation.error) && (
@@ -666,6 +711,7 @@ export function TariffPurchaseForm({
</button> </button>
{sbpPurchaseButton} {sbpPurchaseButton}
{lavaPurchaseButton}
</> </>
); );
})()} })()}

View File

@@ -724,7 +724,9 @@
"halfYear": "every six months", "halfYear": "every six months",
"year": "yearly" "year": "yearly"
}, },
"autopayHint": "The subscription will renew automatically via Lava" "autopayHint": "The subscription will renew automatically via Lava",
"purchaseButton": "⚡ Subscribe with Lava auto-renewal",
"purchaseHint": "The first invoice is paid via a Lava link; renewals are then charged automatically on the products schedule."
}, },
"backToList": "Back to subscriptions", "backToList": "Back to subscriptions",
"confirmDelete": "Delete subscription?", "confirmDelete": "Delete subscription?",
@@ -2765,7 +2767,9 @@
"periodsRequired": "Add at least one period", "periodsRequired": "Add at least one period",
"dailyPriceRequired": "Enter a price per day greater than 0", "dailyPriceRequired": "Enter a price per day greater than 0",
"trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)" "trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)"
} },
"lavaProductLabel": "Lava product (auto-renewal)",
"lavaProductDesc": "Product UUID from the Lava dashboard: price and charge period are configured there. Empty — auto-renewal is unavailable for this tariff."
}, },
"servers": { "servers": {
"title": "Squad Management", "title": "Squad Management",

View File

@@ -623,7 +623,9 @@
"halfYear": "هر شش ماه", "halfYear": "هر شش ماه",
"year": "سالانه" "year": "سالانه"
}, },
"autopayHint": "اشتراک به‌طور خودکار از طریق Lava تمدید می‌شود" "autopayHint": "اشتراک به‌طور خودکار از طریق Lava تمدید می‌شود",
"purchaseButton": "⚡ ثبت‌نام با تمدید خودکار Lava",
"purchaseHint": "صورتحساب اول از طریق لینک Lava پرداخت می‌شود؛ سپس تمدیدها بر اساس دوره محصول به‌طور خودکار کسر می‌شوند."
}, },
"backToList": "بازگشت به فهرست اشتراک‌ها", "backToList": "بازگشت به فهرست اشتراک‌ها",
"confirmDelete": "اشتراک حذف شود؟", "confirmDelete": "اشتراک حذف شود؟",
@@ -2303,7 +2305,9 @@
"periodsRequired": "حداقل یک دوره اضافه کنید", "periodsRequired": "حداقل یک دوره اضافه کنید",
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید", "dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)" "trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
} },
"lavaProductLabel": "محصول Lava (تمدید خودکار)",
"lavaProductDesc": "شناسه UUID محصول از پنل Lava: قیمت و دوره کسر در آنجا تنظیم می‌شود. خالی — تمدید خودکار برای این تعرفه در دسترس نیست."
}, },
"servers": { "servers": {
"title": "مدیریت اسکوادها", "title": "مدیریت اسکوادها",

View File

@@ -743,7 +743,9 @@
"halfYear": "раз в полгода", "halfYear": "раз в полгода",
"year": "ежегодно" "year": "ежегодно"
}, },
"autopayHint": "Подписка будет автоматически продлеваться через Lava" "autopayHint": "Подписка будет автоматически продлеваться через Lava",
"purchaseButton": "⚡ Оформить с автооплатой Lava",
"purchaseHint": "Первое списание оплачивается по ссылке Lava; дальше продления списываются автоматически по периоду продукта."
}, },
"backToList": "К списку подписок", "backToList": "К списку подписок",
"confirmDelete": "Удалить подписку?", "confirmDelete": "Удалить подписку?",
@@ -3154,7 +3156,9 @@
"periodsRequired": "Добавьте хотя бы один период", "periodsRequired": "Добавьте хотя бы один период",
"dailyPriceRequired": "Укажите цену за день больше 0", "dailyPriceRequired": "Укажите цену за день больше 0",
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)" "trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
} },
"lavaProductLabel": "Продукт Lava (автопродление)",
"lavaProductDesc": "UUID продукта из кабинета Lava: цена и периодичность списаний задаются там. Пусто — автопродление для тарифа недоступно."
}, },
"servers": { "servers": {
"title": "Управление сквадами", "title": "Управление сквадами",

View File

@@ -623,7 +623,9 @@
"halfYear": "每半年", "halfYear": "每半年",
"year": "每年" "year": "每年"
}, },
"autopayHint": "订阅将通过 Lava 自动续订" "autopayHint": "订阅将通过 Lava 自动续订",
"purchaseButton": "⚡ 使用 Lava 自动续订下单",
"purchaseHint": "首期账单通过 Lava 链接支付;之后按产品周期自动扣款续订。"
}, },
"backToList": "返回订阅列表", "backToList": "返回订阅列表",
"confirmDelete": "删除订阅?", "confirmDelete": "删除订阅?",
@@ -2369,7 +2371,9 @@
"periodsRequired": "请添加至少一个期限", "periodsRequired": "请添加至少一个期限",
"dailyPriceRequired": "请输入大于0的每日价格", "dailyPriceRequired": "请输入大于0的每日价格",
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)" "trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
} },
"lavaProductLabel": "Lava 产品(自动续订)",
"lavaProductDesc": "来自 Lava 后台的产品 UUID价格与扣款周期在那里设置。留空则该套餐不支持自动续订。"
}, },
"servers": { "servers": {
"title": "服务器管理", "title": "服务器管理",

View File

@@ -50,6 +50,7 @@ export default function AdminTariffCreate() {
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null); const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]); const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0); const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
const [lavaProductId, setLavaProductId] = useState('');
// Traffic topup // Traffic topup
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false); const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false);
@@ -122,6 +123,7 @@ export default function AdminTariffCreate() {
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [], data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
); );
setDailyPriceKopeks(data.daily_price_kopeks || 0); setDailyPriceKopeks(data.daily_price_kopeks || 0);
setLavaProductId(data.lava_product_id || '');
setTrafficTopupEnabled(data.traffic_topup_enabled || false); setTrafficTopupEnabled(data.traffic_topup_enabled || false);
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0); setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
setTrafficTopupPackages(data.traffic_topup_packages || {}); setTrafficTopupPackages(data.traffic_topup_packages || {});
@@ -176,6 +178,8 @@ export default function AdminTariffCreate() {
max_topup_traffic_gb: toNumber(maxTopupTrafficGb), max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
is_daily: isDaily, is_daily: isDaily,
daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0, daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0,
// Пустая строка отвязывает тариф от продукта Lava
lava_product_id: lavaProductId.trim(),
traffic_reset_mode: trafficResetMode, traffic_reset_mode: trafficResetMode,
}; };
@@ -460,6 +464,25 @@ export default function AdminTariffCreate() {
</div> </div>
)} )}
{/* Lava recurrent product */}
<div>
<label
htmlFor="tariff-lava-product"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.tariffs.lavaProductLabel')}
</label>
<input
id="tariff-lava-product"
type="text"
value={lavaProductId}
onChange={(e) => setLavaProductId(e.target.value)}
className="input w-full"
placeholder="6be21df9-0bcd-44ac-9c2c-3be7bc94decc"
/>
<p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.lavaProductDesc')}</p>
</div>
{/* Traffic Limit */} {/* Traffic Limit */}
<div> <div>
<label <label

View File

@@ -292,6 +292,12 @@ export default function SubscriptionPurchase() {
'platega_recurrent_enabled' in purchaseOptions && 'platega_recurrent_enabled' in purchaseOptions &&
purchaseOptions.platega_recurrent_enabled === true purchaseOptions.platega_recurrent_enabled === true
} }
lavaPurchaseEnabled={
isTariffsMode &&
purchaseOptions !== undefined &&
'lava_recurrent_enabled' in purchaseOptions &&
purchaseOptions.lava_recurrent_enabled === true
}
onBack={() => { onBack={() => {
setShowTariffPurchase(false); setShowTariffPurchase(false);
setSelectedTariff(null); setSelectedTariff(null);

View File

@@ -373,6 +373,7 @@ export interface TariffsPurchaseOptions {
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с // СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
// автооплатой СБП» рядом с покупкой с баланса // автооплатой СБП» рядом с покупкой с баланса
platega_recurrent_enabled?: boolean; platega_recurrent_enabled?: boolean;
lava_recurrent_enabled?: boolean;
} }
export interface ClassicPurchaseOptions { export interface ClassicPurchaseOptions {