diff --git a/src/components/subscription/sheets/DeviceTopupSheet.tsx b/src/components/subscription/sheets/DeviceTopupSheet.tsx
new file mode 100644
index 0000000..e59dacf
--- /dev/null
+++ b/src/components/subscription/sheets/DeviceTopupSheet.tsx
@@ -0,0 +1,254 @@
+import { useTranslation } from 'react-i18next';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { subscriptionApi } from '../../../api/subscription';
+import { getErrorMessage } from '../../../utils/subscriptionHelpers';
+import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
+import type { PurchaseOptions, Subscription } from '../../../types';
+
+// ──────────────────────────────────────────────────────────────────
+// Buy-devices sheet. Self-owns its devicePrice query + purchase mutation;
+// parent only passes the subscription / id / open state and the shared
+// purchaseOptions (which it already holds for sibling sheets and balance
+// gating).
+//
+// Extracted from Subscription.tsx to drop ~190 lines from the god page.
+// ──────────────────────────────────────────────────────────────────
+
+export interface DeviceTopupSheetProps {
+ open: boolean;
+ onOpen: () => void;
+ onClose: () => void;
+ subscription: Subscription;
+ subscriptionId: number | undefined;
+ devicesToAdd: number;
+ onDevicesToAddChange: (n: number) => void;
+ purchaseOptions: PurchaseOptions | undefined;
+ isDark: boolean;
+}
+
+export function DeviceTopupSheet({
+ open,
+ onOpen,
+ onClose,
+ subscription,
+ subscriptionId,
+ devicesToAdd,
+ onDevicesToAddChange,
+ purchaseOptions,
+ isDark,
+}: DeviceTopupSheetProps) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ const formatPrice = (kopeks: number) => {
+ const rubles = kopeks / 100;
+ return rubles % 1 === 0 ? `${rubles} ₽` : `${rubles.toFixed(2)} ₽`;
+ };
+
+ const { data: devicePriceData } = useQuery({
+ queryKey: ['device-price', devicesToAdd, subscriptionId],
+ queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd, subscriptionId),
+ enabled: open && !!subscription,
+ });
+
+ const devicePurchaseMutation = useMutation({
+ mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd, subscriptionId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
+ queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
+ queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
+ queryClient.invalidateQueries({ queryKey: ['device-price'] });
+ queryClient.invalidateQueries({ queryKey: ['balance'] });
+ onClose();
+ onDevicesToAddChange(1);
+ },
+ });
+
+ if (!open) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
{t('subscription.buyDevices')}
+
+
+
+ {devicePriceData?.available === false ? (
+
+ {devicePriceData.reason || t('subscription.additionalOptions.devicesUnavailable')}
+
+ ) : (
+
+ {/* Device counter */}
+
+
+
+
{devicesToAdd}
+
+ {t('subscription.additionalOptions.devicesUnit')}
+
+
+
+
+
+ {devicePriceData?.max_device_limit && (
+
+ {t('subscription.additionalOptions.currentDeviceLimit', {
+ count: devicePriceData.current_device_limit || subscription.device_limit,
+ })}{' '}
+ /{' '}
+ {t('subscription.additionalOptions.maxDevices', {
+ count: devicePriceData.max_device_limit,
+ })}
+
+ )}
+
+ {/* Price info */}
+ {devicePriceData?.available && devicePriceData.price_per_device_label && (
+
+
+ {devicePriceData.discount_percent &&
+ devicePriceData.discount_percent > 0 &&
+ devicePriceData.original_price_per_device_kopeks ? (
+
+
+ {formatPrice(devicePriceData.original_price_per_device_kopeks)}
+
+ {devicePriceData.price_per_device_label}
+
+ ) : (
+ devicePriceData.price_per_device_label
+ )}
+ /{t('subscription.perDevice').replace('/ ', '')} (
+ {t('subscription.days', { count: devicePriceData.days_left })})
+
+ {devicePriceData.discount_percent && devicePriceData.discount_percent > 0 && (
+
+
+ -{devicePriceData.discount_percent}%
+
+
+ )}
+ {devicePriceData.total_price_kopeks === 0 ? (
+
+ {t('subscription.switchTariff.free')}
+
+ ) : (
+
+ {devicePriceData.discount_percent &&
+ devicePriceData.discount_percent > 0 &&
+ devicePriceData.base_total_price_kopeks && (
+
+ {formatPrice(devicePriceData.base_total_price_kopeks)}
+
+ )}
+ {devicePriceData.total_price_label}
+
+ )}
+
+ )}
+
+ {/* Insufficient balance */}
+ {devicePriceData?.available &&
+ purchaseOptions &&
+ devicePriceData.total_price_kopeks &&
+ devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
+
{
+ await subscriptionApi.saveDevicesCart(devicesToAdd, subscriptionId);
+ }}
+ />
+ )}
+
+
+
+ {devicePurchaseMutation.isError && (
+
+ {getErrorMessage(devicePurchaseMutation.error)}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index ba083b6..97296c0 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -27,6 +27,7 @@ import {
getFlagEmoji,
} from '../utils/subscriptionHelpers';
import Twemoji from 'react-twemoji';
+import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
/** Isolated countdown so 1s interval doesn't re-render the whole page */
const CountdownTimer = memo(function CountdownTimer({
@@ -362,26 +363,7 @@ export default function Subscription() {
}, []);
useCloseOnSuccessNotification(handleCloseAllModals);
- // Device price query
- const { data: devicePriceData } = useQuery({
- queryKey: ['device-price', devicesToAdd, subscriptionId],
- queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd, subscriptionId),
- enabled: showDeviceTopup && !!subscription,
- });
-
- // Device purchase mutation
- const devicePurchaseMutation = useMutation({
- mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd, subscriptionId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
- queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
- queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
- queryClient.invalidateQueries({ queryKey: ['device-price'] });
- queryClient.invalidateQueries({ queryKey: ['balance'] });
- setShowDeviceTopup(false);
- setDevicesToAdd(1);
- },
- });
+ // (device price + purchase moved into )
// Device reduction info query
const { data: deviceReductionInfo } = useQuery({
@@ -1537,193 +1519,17 @@ export default function Subscription() {
{/* Buy Devices */}
- {!showDeviceTopup ? (
-
- ) : (
-
-
-
{t('subscription.buyDevices')}
-
-
-
- {/* Check if completely unavailable (no subscription, price not set, etc.) */}
- {devicePriceData?.available === false ? (
-
- {devicePriceData.reason ||
- t('subscription.additionalOptions.devicesUnavailable')}
-
- ) : (
-
- {/* Device selector - show even at max limit */}
-
-
-
-
{devicesToAdd}
-
- {t('subscription.additionalOptions.devicesUnit')}
-
-
-
-
-
- {/* Show limit info when at or near max */}
- {devicePriceData?.max_device_limit && (
-
- {t('subscription.additionalOptions.currentDeviceLimit', {
- count: devicePriceData.current_device_limit || subscription.device_limit,
- })}{' '}
- /{' '}
- {t('subscription.additionalOptions.maxDevices', {
- count: devicePriceData.max_device_limit,
- })}
-
- )}
-
- {/* Price info - only when available */}
- {devicePriceData?.available && devicePriceData.price_per_device_label && (
-
-
- {/* Show original price with strikethrough if discount */}
- {devicePriceData.discount_percent &&
- devicePriceData.discount_percent > 0 &&
- devicePriceData.original_price_per_device_kopeks ? (
-
-
- {formatPrice(devicePriceData.original_price_per_device_kopeks)}
-
- {devicePriceData.price_per_device_label}
-
- ) : (
- devicePriceData.price_per_device_label
- )}
- /{t('subscription.perDevice').replace('/ ', '')} (
- {t('subscription.days', { count: devicePriceData.days_left })})
-
- {/* Discount badge */}
- {devicePriceData.discount_percent &&
- devicePriceData.discount_percent > 0 && (
-
-
- -{devicePriceData.discount_percent}%
-
-
- )}
- {/* Total price - show as free if 100% discount or 0 */}
- {devicePriceData.total_price_kopeks === 0 ? (
-
- {t('subscription.switchTariff.free')}
-
- ) : (
-
- {/* Show original total with strikethrough if discount */}
- {devicePriceData.discount_percent &&
- devicePriceData.discount_percent > 0 &&
- devicePriceData.base_total_price_kopeks && (
-
- {formatPrice(devicePriceData.base_total_price_kopeks)}
-
- )}
- {devicePriceData.total_price_label}
-
- )}
-
- )}
-
- {devicePriceData?.available &&
- purchaseOptions &&
- devicePriceData.total_price_kopeks &&
- devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
-
{
- await subscriptionApi.saveDevicesCart(devicesToAdd, subscriptionId);
- }}
- />
- )}
-
-
-
- {devicePurchaseMutation.isError && (
-
- {getErrorMessage(devicePurchaseMutation.error)}
-
- )}
-
- )}
-
- )}
+ setShowDeviceTopup(true)}
+ onClose={() => setShowDeviceTopup(false)}
+ subscription={subscription}
+ subscriptionId={subscriptionId}
+ devicesToAdd={devicesToAdd}
+ onDevicesToAddChange={setDevicesToAdd}
+ purchaseOptions={purchaseOptions}
+ isDark={isDark}
+ />
{/* Reduce Devices */}