feat: replace payment modals with page-based navigation

- Add /balance/top-up route for payment method selection
- Add /balance/top-up/:methodId route for amount entry and payment
- Remove TopUpModal component (619 lines)
- Simplify InsufficientBalancePrompt to use navigate instead of modals
- Support ?amount and ?returnTo query params for cross-page data flow
- Fix nav isActive to highlight Balance on sub-routes
- Remove unused MainButton platform abstraction
- Increase toast accent opacity for better visibility
This commit is contained in:
c0mrade
2026-02-04 14:36:16 +03:00
parent 6e98a19d6b
commit 576893f5c6
13 changed files with 333 additions and 766 deletions

View File

@@ -5,9 +5,6 @@ import {
onBackButtonClick,
offBackButtonClick,
isBackButtonVisible,
setMainButtonParams,
onMainButtonClick,
offMainButtonClick,
hapticFeedbackImpactOccurred,
hapticFeedbackNotificationOccurred,
hapticFeedbackSelectionChanged,
@@ -30,12 +27,10 @@ import type {
PlatformContext,
PlatformCapabilities,
BackButtonController,
MainButtonController,
HapticController,
DialogController,
ThemeController,
CloudStorageController,
MainButtonConfig,
PopupOptions,
InvoiceStatus,
HapticImpactStyle,
@@ -47,7 +42,7 @@ function createCapabilities(): PlatformCapabilities {
return {
hasBackButton: inTelegram,
hasMainButton: inTelegram,
hasHapticFeedback: inTelegram,
hasNativeDialogs: inTelegram,
hasThemeSync: inTelegram,
@@ -112,91 +107,6 @@ function createBackButtonController(): BackButtonController {
};
}
function createMainButtonController(): MainButtonController {
const inTelegram = isInTelegramWebApp();
let currentCallback: (() => void) | null = null;
return {
get isVisible() {
return false; // SDK v3 doesn't expose this as a simple getter
},
show(config: MainButtonConfig) {
if (!inTelegram) return;
if (currentCallback) {
try {
offMainButtonClick(currentCallback);
} catch {
// ignore
}
}
currentCallback = config.onClick;
try {
setMainButtonParams({
text: config.text,
isVisible: true,
isEnabled: config.isActive !== false,
isLoaderVisible: config.isLoading ?? false,
backgroundColor: config.color as `#${string}` | undefined,
textColor: config.textColor as `#${string}` | undefined,
});
onMainButtonClick(config.onClick);
} catch {
// Main button not mounted
}
},
hide() {
if (!inTelegram) return;
if (currentCallback) {
try {
offMainButtonClick(currentCallback);
} catch {
// ignore
}
currentCallback = null;
}
try {
setMainButtonParams({ isVisible: false, isLoaderVisible: false });
} catch {
// Main button not mounted
}
},
showProgress(show: boolean) {
if (!inTelegram) return;
try {
setMainButtonParams({ isLoaderVisible: show });
} catch {
// Main button not mounted
}
},
setText(text: string) {
if (!inTelegram) return;
try {
setMainButtonParams({ text });
} catch {
// Main button not mounted
}
},
setActive(active: boolean) {
if (!inTelegram) return;
try {
setMainButtonParams({ isEnabled: active });
} catch {
// Main button not mounted
}
},
};
}
function createHapticController(): HapticController {
const inTelegram = isInTelegramWebApp();
@@ -382,7 +292,7 @@ export function createTelegramAdapter(): PlatformContext {
platform: 'telegram',
capabilities: createCapabilities(),
backButton: createBackButtonController(),
mainButton: createMainButtonController(),
haptic: createHapticController(),
dialog: createDialogController(),
theme: createThemeController(),

View File

@@ -2,12 +2,10 @@ import type {
PlatformContext,
PlatformCapabilities,
BackButtonController,
MainButtonController,
HapticController,
DialogController,
ThemeController,
CloudStorageController,
MainButtonConfig,
PopupOptions,
InvoiceStatus,
HapticImpactStyle,
@@ -20,7 +18,7 @@ const STORAGE_PREFIX = 'bedolaga_';
function createCapabilities(): PlatformCapabilities {
return {
hasBackButton: false, // No native back button in web
hasMainButton: false, // No native main button in web
hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API
hasNativeDialogs: false, // Use custom dialogs
hasThemeSync: false, // No header/bottom bar sync in web
@@ -47,34 +45,6 @@ function createBackButtonController(): BackButtonController {
};
}
function createMainButtonController(): MainButtonController {
// Web doesn't have a native main button - this is a no-op
// The UI will render its own submit buttons
return {
isVisible: false,
show(_config: MainButtonConfig) {
// No-op in web - handled by UI components
},
hide() {
// No-op in web
},
showProgress(_show: boolean) {
// No-op in web
},
setText(_text: string) {
// No-op in web
},
setActive(_active: boolean) {
// No-op in web
},
};
}
function createHapticController(): HapticController {
// Web Vibration API fallback (works on mobile browsers)
const canVibrate = 'vibrate' in navigator;
@@ -219,7 +189,7 @@ export function createWebAdapter(): PlatformContext {
platform: 'web',
capabilities: createCapabilities(),
backButton: createBackButtonController(),
mainButton: createMainButtonController(),
haptic: createHapticController(),
dialog: createDialogController(),
theme: createThemeController(),

View File

@@ -1,111 +0,0 @@
import { useEffect, useRef, useCallback } from 'react';
import { usePlatform } from '@/platform/hooks/usePlatform';
import type { MainButtonConfig } from '@/platform/types';
interface UseMainButtonOptions extends Omit<MainButtonConfig, 'onClick'> {
/**
* Whether the main button should be visible
* @default true
*/
visible?: boolean;
}
/**
* Hook to manage the Telegram MainButton
* Automatically shows/hides based on component lifecycle
*
* @param onClick - Callback when main button is pressed
* @param options - Configuration options
*
* @example
* ```tsx
* function SubmitForm() {
* const mutation = useMutation(...);
*
* useMainButton(handleSubmit, {
* text: t('actions.submit'),
* isLoading: mutation.isPending,
* isActive: isFormValid,
* });
* }
* ```
*/
export function useMainButton(
onClick: (() => void) | null | undefined,
options: UseMainButtonOptions = { text: '' },
): void {
const { mainButton, capabilities } = usePlatform();
const { visible = true, text, isLoading, isActive, color, textColor } = options;
// Use ref to prevent callback recreation issues
const callbackRef = useRef(onClick);
callbackRef.current = onClick;
// Stable callback wrapper
const handleClick = useCallback(() => {
callbackRef.current?.();
}, []);
useEffect(() => {
// If no native main button support, do nothing
// UI components will render their own submit buttons
if (!capabilities.hasMainButton) {
return;
}
// If callback is null/undefined or visible is false, hide button
if (!onClick || !visible || !text) {
mainButton.hide();
return;
}
// Show the main button with configuration
mainButton.show({
text,
onClick: handleClick,
isLoading,
isActive,
color,
textColor,
});
// Cleanup: hide button when component unmounts
return () => {
mainButton.hide();
};
}, [
mainButton,
capabilities.hasMainButton,
handleClick,
onClick,
visible,
text,
isLoading,
isActive,
color,
textColor,
]);
}
/**
* Hook for simple main button usage
* Shows button only when conditions are met
*
* @param text - Button text
* @param onClick - Click handler
* @param enabled - Whether button should be shown and active
* @param loading - Whether to show loading state
*/
export function useSimpleMainButton(
text: string,
onClick: () => void,
enabled: boolean = true,
loading: boolean = false,
): void {
useMainButton(enabled ? onClick : null, {
text,
isLoading: loading,
isActive: enabled && !loading,
visible: enabled,
});
}

View File

@@ -10,14 +10,12 @@ export type {
PlatformType,
PlatformContext as PlatformContextType,
PlatformCapabilities,
MainButtonConfig,
PopupOptions,
PopupButton,
InvoiceStatus,
HapticImpactStyle,
HapticNotificationType,
BackButtonController,
MainButtonController,
HapticController,
DialogController,
ThemeController,
@@ -28,7 +26,6 @@ export type {
// Hooks
export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform';
export { useBackButton, useConditionalBackButton } from './hooks/useBackButton';
export { useMainButton, useSimpleMainButton } from './hooks/useMainButton';
export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic';
export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog';
export { useNotify } from './hooks/useNotify';

View File

@@ -7,15 +7,6 @@ export type HapticNotificationType = 'success' | 'warning' | 'error';
export type InvoiceStatus = 'paid' | 'cancelled' | 'failed' | 'pending';
export interface MainButtonConfig {
text: string;
onClick: () => void;
isLoading?: boolean;
isActive?: boolean;
color?: string;
textColor?: string;
}
export interface PopupButton {
id: string;
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
@@ -30,7 +21,7 @@ export interface PopupOptions {
export interface PlatformCapabilities {
hasBackButton: boolean;
hasMainButton: boolean;
hasHapticFeedback: boolean;
hasNativeDialogs: boolean;
hasThemeSync: boolean;
@@ -46,15 +37,6 @@ export interface BackButtonController {
isVisible: boolean;
}
export interface MainButtonController {
show: (config: MainButtonConfig) => void;
hide: () => void;
showProgress: (show: boolean) => void;
setText: (text: string) => void;
setActive: (active: boolean) => void;
isVisible: boolean;
}
export interface HapticController {
impact: (style?: HapticImpactStyle) => void;
notification: (type: HapticNotificationType) => void;
@@ -104,9 +86,6 @@ export interface PlatformContext {
// Navigation
backButton: BackButtonController;
// Main action button
mainButton: MainButtonController;
// Haptic feedback
haptic: HapticController;