Files
rnpay/screens/HomeScreen.tsx
2026-05-31 00:05:21 +08:00

1340 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { Component } from 'react';
import {
Alert,
AppState,
AppStateStatus,
Image,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
ActivityIndicator,
} from 'react-native';
import * as Animatable from 'react-native-animatable';
import DeviceInfo from 'react-native-device-info';
import {
GooglePayBusinessBind,
PhonePeBusinessBind,
WalletType,
PaytmBusinessBindResult,
PaytmPersonalBind,
FreechargePersonalBindResult,
GooglePayBusinessBindResult,
BharatPeBusinessBindResult,
PhonePePersonalBind,
FreechargePersonalBind,
proxyBackgroundService,
type TokenAutoRebindDeps,
PhonePePersonalBindResult,
PaytmPersonalBindResult,
BindErrorCode,
} from 'rnwalletman';
import {
FreeChargeBind,
MobikwikPersonalTokenBind,
MobikwikPersonalOTPBind,
PayTmPersonalOTPBind,
PhonePePersonalOTPBind,
BharatPeBusinessOTPBind,
PaytmBusinessOTPBind,
PhonePeBusinessOTPBind,
AmazonPayOTPBind,
} from '../components/WalletBindComponents';
import { WalletSelectModal, WALLET_ICONS, WALLET_TYPE_COLORS } from '../components/WalletSelectModal';
import { ServerSettingsModal } from '../components/ServerSettingsModal';
import Api, {
WalletItem,
loadServerDomain,
getServerDomain,
getTokenAutoRebindEnabled,
getTokenAutoRebindOptions,
saveTokenAutoRebindEnabled,
} from '../services/api';
function formatWalletTypeLabel(walletType: string) {
return walletType.replace(/\b\w/g, (c) => c.toUpperCase());
}
function groupBoundWallets(wallets: WalletItem[]) {
const order: string[] = [];
const map = new Map<string, WalletItem[]>();
for (const w of wallets) {
if (!map.has(w.walletType)) {
map.set(w.walletType, []);
order.push(w.walletType);
}
map.get(w.walletType)!.push(w);
}
return order.map((walletType) => ({
walletType,
items: map.get(walletType)!,
}));
}
function getBindKeyForWallet(item: WalletItem): string | null {
const otp = item.otpMode === true;
switch (item.walletType) {
case 'paytm':
return otp ? 'paytm_personal_otp' : 'paytm_personal_token';
case 'phonepe':
return otp ? 'phonepe_personal_otp' : 'phonepe_personal_token';
case 'paytm business':
return 'paytm_business';
case 'phonepe business':
return otp ? 'phonepe_business_otp' : 'phonepe_business_web';
case 'googlepay business':
return 'googlepay_business';
case 'bharatpe business':
return 'bharatpe_business';
case 'mobikwik':
return otp ? 'mobikwik_personal' : 'mobikwik_personal_token';
case 'freecharge':
return otp ? 'freecharge_personal' : 'freecharge_personal_token';
case 'amazonpay':
return 'amazonpay_personal';
default:
return null;
}
}
interface HomeScreenState {
// bind modals
showPaytmPersonalBind: boolean;
paytmPersonalBindType: 'otpMode' | 'tokenMode';
showPaytmBusinessBind: boolean;
showPhonePePersonalBind: boolean;
phonePePersonalBindType: 'otpMode' | 'tokenMode';
showPhonePeBusinessBind: boolean;
phonePeBusinessBindType: 'otpMode' | 'webviewMode';
freechargePersonalBindType: 'otpMode' | 'tokenMode';
showGooglePayBusinessBind: boolean;
showBharatPeBusinessBind: boolean;
showMobikwikPersonalBind: boolean;
mobikwikPersonalBindType: 'otpMode' | 'tokenMode';
showFreechargePersonalBind: boolean;
showAmazonPayPersonalBind: boolean;
// proxy
proxyStatus: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
proxyError?: string;
tokenAutoRebind: boolean;
// server settings
showServerSettings: boolean;
settingsHost: string;
settingsPort: string;
// wallet list
wallets: WalletItem[];
loadingWallets: boolean;
// vpa modal
vpaModalWallet: WalletItem | null;
vpaModalVpas: string[];
vpaModalLoading: boolean;
vpaModalSelected: string;
// add wallet
showAddWallet: boolean;
bindPrefillMobile: string;
expandedBoundWalletGroups: Record<string, boolean>;
}
export default class HomeScreen extends Component<any, HomeScreenState> {
private deviceId: string;
private androidId: string;
private tuneUserId: string;
private clientId: string = '';
private appStateSubscription?: any;
constructor(props: any) {
super(props);
this.state = {
paytmPersonalBindType: 'otpMode',
showPaytmPersonalBind: false,
showPaytmBusinessBind: false,
showPhonePePersonalBind: false,
phonePePersonalBindType: 'otpMode',
showPhonePeBusinessBind: false,
phonePeBusinessBindType: 'otpMode',
showGooglePayBusinessBind: false,
showBharatPeBusinessBind: false,
showMobikwikPersonalBind: false,
mobikwikPersonalBindType: 'otpMode',
showFreechargePersonalBind: false,
freechargePersonalBindType: 'otpMode',
showAmazonPayPersonalBind: false,
proxyStatus: 'idle',
tokenAutoRebind: false,
showServerSettings: false,
settingsHost: '',
settingsPort: '',
wallets: [],
loadingWallets: false,
vpaModalWallet: null,
vpaModalVpas: [],
vpaModalLoading: false,
vpaModalSelected: '',
showAddWallet: false,
bindPrefillMobile: '',
expandedBoundWalletGroups: {},
};
this.deviceId = DeviceInfo.getUniqueIdSync();
this.tuneUserId = "yz8mxybytus";//Math.random().toString(36).substring(2, 15);
this.androidId = DeviceInfo.getAndroidIdSync();
}
async componentDidMount() {
await loadServerDomain();
const tokenAutoRebind = getTokenAutoRebindEnabled();
this.setState({ tokenAutoRebind });
proxyBackgroundService.setTokenAutoRebindEnabled(tokenAutoRebind);
const doLogin = () => {
Api.instance.login('test123', '123456').then(async () => {
await this.startProxyClient();
this.fetchWallets();
}).catch((error) => {
console.log('[Login] retry in 3s:', error);
setTimeout(doLogin, 3000);
});
};
doLogin();
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
}
componentWillUnmount() {
this.stopProxyClient();
this.appStateSubscription?.remove();
}
handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') this.fetchWallets();
};
buildTokenAutoRebindDeps = (): TokenAutoRebindDeps => ({
listWallets: () => Api.instance.listWallets(),
register: (_wallet, walletType: WalletType, params: Record<string, unknown>) =>
Api.instance.register(walletType, params),
getUserToken: () => Api.instance.getUserToken(),
onRebound: () => this.fetchWallets(),
isActive: async (w) => w.status === 'ACTIVE' || w.otpMode === true,
log: this.state.tokenAutoRebind
? (...args: unknown[]) => console.log('[TokenAutoRebind]', ...args)
: undefined,
});
async startProxyClient() {
try {
this.clientId = DeviceInfo.getUniqueIdSync();
const userId = Api.instance.getUserId();
this.setState({ proxyStatus: 'connecting' });
proxyBackgroundService.configureTokenAutoRebind(
this.buildTokenAutoRebindDeps(),
getTokenAutoRebindOptions(),
);
proxyBackgroundService.setTokenAutoRebindEnabled(this.state.tokenAutoRebind);
await proxyBackgroundService.start({
wsUrl: Api.WS_URL, clientId: this.clientId || '', userId,
debug: true, heartbeatInterval: 10000, reconnectInterval: 5000, reconnectMaxAttempts: Infinity,
onConnected: () => this.setState({ proxyStatus: 'connected' }),
onDisconnected: () => this.setState({ proxyStatus: 'disconnected' }),
onError: (error: string) => this.setState({ proxyStatus: 'error', proxyError: error }),
});
} catch (error) {
console.error('[Proxy] init failed:', error);
}
}
stopProxyClient() {
try {
proxyBackgroundService.configureTokenAutoRebind(null);
proxyBackgroundService.stop();
} catch {
/* ignore */
}
}
toggleTokenAutoRebind = async (enabled: boolean) => {
this.setState({ tokenAutoRebind: enabled }, () => {
proxyBackgroundService.configureTokenAutoRebind(
this.buildTokenAutoRebindDeps(),
getTokenAutoRebindOptions(),
);
});
await saveTokenAutoRebindEnabled(enabled);
proxyBackgroundService.setTokenAutoRebindEnabled(enabled);
};
/** OTP / bindAPI catch → { success:false, message } */
private wrapOtpCall = async (fn: () => Promise<any>): Promise<any> => {
try {
return await fn();
} catch (e) {
return { success: false, message: (e as Error).message };
}
};
fetchWallets = async () => {
this.setState({ loadingWallets: true });
try {
const wallets = await Api.instance.listWallets();
this.setState({ wallets });
} catch (e) {
console.log('[fetchWallets]', e);
} finally {
this.setState({ loadingWallets: false });
}
};
openVpaModal = async (item: WalletItem) => {
this.setState({ vpaModalWallet: item, vpaModalVpas: [], vpaModalLoading: true, vpaModalSelected: item.upi ?? '' });
try {
const vpas = await Api.instance.getWalletVpas(item.id);
this.setState({ vpaModalVpas: vpas, vpaModalLoading: false });
} catch {
this.setState({ vpaModalLoading: false });
}
};
closeVpaModal = () => this.setState({ vpaModalWallet: null });
confirmVpa = async () => {
const { vpaModalWallet, vpaModalVpas, vpaModalSelected } = this.state;
if (!vpaModalWallet || !vpaModalSelected) return;
const idx = vpaModalVpas.indexOf(vpaModalSelected);
if (idx < 0) return;
try {
await Api.instance.setCurrentVpa(vpaModalWallet.id, idx);
const walletId = vpaModalWallet.id;
const vpa = vpaModalSelected;
this.setState((s) => ({
vpaModalWallet: null,
wallets: s.wallets.map((w) => (w.id === walletId ? { ...w, upi: vpa } : w)),
}));
} catch (e) {
Alert.alert('Set Failed', (e as Error).message);
}
};
toggleWalletActive = async (item: WalletItem, active: boolean) => {
try {
await Api.instance.setWalletStatus(item.id, active);
this.setState((s) => ({
wallets: s.wallets.map((w) =>
w.id === item.id ? { ...w, status: active ? 'ACTIVE' : 'INACTIVE' } : w),
}));
} catch (e) {
Alert.alert('Failed', (e as Error).message);
}
};
handleRebind = (item: WalletItem) => {
const key = getBindKeyForWallet(item);
if (!key) {
Alert.alert('Rebind', 'Unsupported wallet type');
return;
}
this.openWalletBind(key, item.phone);
};
// ---- bind handlers ----
/** Token bind: client calls register (do not infer modal key from wallet type alone) */
handleBindSuccess = (key: keyof HomeScreenState, walletType: WalletType, msg: string) =>
async (result: any) => {
try {
await Api.instance.register(walletType, result);
this.setState({ [key]: false } as any);
Alert.alert('Bind Success', msg);
this.fetchWallets();
} catch (error) {
this.setState({ [key]: false } as any);
Alert.alert('Bind Failed', (error as Error).message);
}
};
/** OTP bind: server registers on verify; only show success and close modal */
onOtpBindSuccess = (key: keyof HomeScreenState, msg: string) => () => {
Alert.alert('Bind Success', msg);
this.setState({ [key]: false } as any);
this.fetchWallets();
};
// ---- modals ----
renderBindModal = () => {
const {
showPaytmPersonalBind, paytmPersonalBindType, showPhonePePersonalBind, phonePePersonalBindType,
showPaytmBusinessBind, showPhonePeBusinessBind, phonePeBusinessBindType, showGooglePayBusinessBind, showBharatPeBusinessBind,
showMobikwikPersonalBind, mobikwikPersonalBindType, showFreechargePersonalBind, freechargePersonalBindType,
showAmazonPayPersonalBind,
bindPrefillMobile,
} = this.state;
const close = (key: keyof HomeScreenState) => () =>
this.setState({ [key]: false, bindPrefillMobile: '' } as any);
if (showPaytmPersonalBind && paytmPersonalBindType === 'tokenMode') {
const remoteVersion = '1';
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PaytmPersonalBind
processString="Processing..."
isDebug
onSuccess={(result: PaytmPersonalBindResult) => {
if (result.chType != 'ipay') {
Alert.alert('Bind Failed', 'Patched Paytm app not installed. Reinstall required');
return;
}
if (result.chVersion != remoteVersion) {
Alert.alert('Bind Failed', 'App version outdated. Reinstall required');
return;
}
this.handleBindSuccess('showPaytmPersonalBind', WalletType.PAYTM_PERSONAL, 'Paytm Personal bound successfully')(result);
}}
onError={(code: string, message: string) => {
switch (code) {
case BindErrorCode.NATIVE_MODULE_UNAVAILABLE:
Alert.alert('Bind Failed', 'Native module not available');
break;
case BindErrorCode.NOT_LOGGED_IN:
Alert.alert('Bind Failed', 'Please login in paytm app first');
break;
case BindErrorCode.ERROR:
Alert.alert('Bind Failed', message);
break;
case BindErrorCode.NO_DATA:
Alert.alert('Bind Failed', 'No data received from Paytm');
break;
case BindErrorCode.SERVICE_DISCONNECTED:
Alert.alert('Bind Failed', 'Paytm service disconnected');
break;
case BindErrorCode.NOT_INSTALLED:
Alert.alert('Bind Failed', 'Patched Paytm app not installed. Reinstall required');
break;
case BindErrorCode.BIND_ERROR:
Alert.alert('Bind Failed', 'Paytm bind error');
break;
default:
Alert.alert('Bind Failed', `[${code}] ${message}`);
break;
}
close('showPaytmPersonalBind')();
}}
/>
</Modal>
);
}
if (showPaytmPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PayTmPersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }));
}}
onSuccess={this.onOtpBindSuccess('showPaytmPersonalBind', 'Paytm Personal OTP')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePePersonalBind && phonePePersonalBindType === 'tokenMode') {
const remoteVersion = '1';
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalBind
processString="Processing..."
userToken={Api.instance.getUserToken()}
isDebug
onSuccess={(result: PhonePePersonalBindResult) => {
if (result.chType != 'ipay') {
Alert.alert('Bind Failed', 'Patched PhonePe app not installed. Reinstall required');
return;
}
if (result.chVersion != remoteVersion) {
Alert.alert('Bind Failed', 'App version outdated. Reinstall required');
return;
}
this.handleBindSuccess('showPhonePePersonalBind', WalletType.PHONEPE_PERSONAL, 'PhonePe Personal bound successfully')(result);
}}
onError={(code: string, message: string) => {
switch (code) {
case BindErrorCode.NOT_INSTALLED:
Alert.alert('Bind Failed', 'Patched PhonePe app not installed. Reinstall required');
break;
case BindErrorCode.SERVICE_DISCONNECTED:
Alert.alert('Bind Failed', 'Service unavailable');
break;
case BindErrorCode.NO_DATA:
Alert.alert('Bind Failed', 'Invalid response from PhonePe');
break;
case BindErrorCode.BIND_ERROR:
Alert.alert('Bind Failed', 'Bind failed. Open PhonePe manually and try again');
break;
case BindErrorCode.ERROR:
Alert.alert('Bind Failed', message);
break;
default:
Alert.alert('Bind Failed', 'Unknown error');
break;
}
close('showPhonePePersonalBind')();
}}
/>
</Modal>
);
}
if (showPhonePePersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }));
}}
onSuccess={this.onOtpBindSuccess('showPhonePePersonalBind', 'PhonePe Personal OTP')}
onError={() => {}}
/>
</Modal>
);
}
if (showPaytmBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmBusinessBind')}
>
<PaytmBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }));
}}
onSuccess={this.onOtpBindSuccess('showPaytmBusinessBind', 'Paytm Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePeBusinessBind && phonePeBusinessBindType === 'otpMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePeBusinessBind')}
>
<PhonePeBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }));
}}
onSuccess={this.onOtpBindSuccess('showPhonePeBusinessBind', 'PhonePe Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePeBusinessBind && phonePeBusinessBindType === 'webviewMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePeBusinessBind')}
>
<PhonePeBusinessBind
processString="Processing..."
isDebug
clearCookie
onSuccess={this.handleBindSuccess('showPhonePeBusinessBind', WalletType.PHONEPE_BUSINESS, 'PhonePe Business bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showPhonePeBusinessBind')(); }}
/>
</Modal>
);
}
if (showGooglePayBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showGooglePayBusinessBind')}
>
<GooglePayBusinessBind
processString="Processing..."
isDebug
onSuccess={this.handleBindSuccess('showGooglePayBusinessBind', WalletType.GOOGLEPAY_BUSINESS, 'GooglePay Business bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showGooglePayBusinessBind')(); }}
/>
</Modal>
);
}
if (showBharatPeBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showBharatPeBusinessBind')}
>
<BharatPeBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }));
}}
onSuccess={this.onOtpBindSuccess('showBharatPeBusinessBind', 'BharatPe Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showMobikwikPersonalBind && mobikwikPersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showMobikwikPersonalBind')}
>
<MobikwikPersonalTokenBind
userToken={Api.instance.getUserToken()}
isDebug
onClose={close('showMobikwikPersonalBind')}
onSuccess={this.handleBindSuccess(
'showMobikwikPersonalBind',
WalletType.MOBIKWIK_PERSONAL,
'Mobikwik bound successfully',
)}
/>
</Modal>
);
}
if (showMobikwikPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showMobikwikPersonalBind')}
>
<MobikwikPersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {
androidId: this.androidId,
tuneUserId: this.tuneUserId,
}))
}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, p))
}
onSuccess={this.onOtpBindSuccess('showMobikwikPersonalBind', 'Mobikwik bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showFreechargePersonalBind && freechargePersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showFreechargePersonalBind')}
>
<FreechargePersonalBind
processString="Processing..."
isDebug
onSuccess={this.handleBindSuccess('showFreechargePersonalBind', WalletType.FREECHARGE_PERSONAL, 'Freecharge bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showFreechargePersonalBind')(); }}
/>
</Modal>
);
}
if (showFreechargePersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showFreechargePersonalBind')}
>
<FreeChargeBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { otpId: p.otpId, deviceId: p.deviceId, csrfId: p.csrfId, appFc: p.appFc }));
}}
onSuccess={this.onOtpBindSuccess('showFreechargePersonalBind', 'Freecharge bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showAmazonPayPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showAmazonPayPersonalBind')}
>
<AmazonPayOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {
...(p.sessionId ? { sessionId: p.sessionId } : {}),
...(p.password ? { password: p.password } : {}),
}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, {
sessionId: p.sessionId,
...(p.password ? { password: p.password } : {}),
}));
}}
onSuccess={this.onOtpBindSuccess('showAmazonPayPersonalBind', 'Amazon Pay bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
return null;
};
openWalletBind = (key: string, prefillMobile?: string) => {
this.setState({ showAddWallet: false, bindPrefillMobile: prefillMobile ?? '' });
setTimeout(() => {
switch (key) {
case 'paytm_personal_otp':
this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'otpMode' });
break;
case 'paytm_personal_token':
this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'tokenMode' });
break;
case 'paytm_business':
this.setState({ showPaytmBusinessBind: true });
break;
case 'phonepe_personal_otp':
this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'otpMode' });
break;
case 'phonepe_personal_token':
this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'tokenMode' });
break;
case 'phonepe_business_otp':
this.setState({ showPhonePeBusinessBind: true, phonePeBusinessBindType: 'otpMode' });
break;
case 'phonepe_business_web':
this.setState({ showPhonePeBusinessBind: true, phonePeBusinessBindType: 'webviewMode' });
break;
case 'googlepay_business':
this.setState({ showGooglePayBusinessBind: true });
break;
case 'bharatpe_business':
this.setState({ showBharatPeBusinessBind: true });
break;
case 'mobikwik_personal':
this.setState({ showMobikwikPersonalBind: true, mobikwikPersonalBindType: 'otpMode' });
break;
case 'mobikwik_personal_token':
this.setState({ showMobikwikPersonalBind: true, mobikwikPersonalBindType: 'tokenMode' });
break;
case 'freecharge_personal':
this.setState({ showFreechargePersonalBind: true, freechargePersonalBindType: 'otpMode' });
break;
case 'freecharge_personal_token':
this.setState({ showFreechargePersonalBind: true, freechargePersonalBindType: 'tokenMode' });
break;
case 'amazonpay_personal':
this.setState({ showAmazonPayPersonalBind: true });
break;
}
}, 300);
};
toggleBoundWalletGroup = (walletType: string) => {
this.setState((prev) => ({
expandedBoundWalletGroups: {
...prev.expandedBoundWalletGroups,
[walletType]: !prev.expandedBoundWalletGroups[walletType],
},
}));
};
renderWalletItem = (item: WalletItem) => {
const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888';
const isActive = item.status === 'ACTIVE';
return (
<View style={s.walletCard}>
<TouchableOpacity
onPress={() => this.openVpaModal(item)}
activeOpacity={0.8}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={[s.walletBadge, !isActive && s.walletBadgeInactive]}>
{WALLET_ICONS[item.walletType] ? (
<Image source={WALLET_ICONS[item.walletType]} style={[s.walletIcon, !isActive && s.walletIconInactive]} resizeMode="contain" />
) : (
<View style={[s.walletIconFallback, { backgroundColor: isActive ? color : '#ccc' }]}>
<Text style={s.walletBadgeText}>{item.walletType.split('_')[0]}</Text>
</View>
)}
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.walletPhone}>{item.phone || '—'}</Text>
<Text style={s.walletUpi} numberOfLines={1}>{item.upi || 'No UPI'}</Text>
</View>
<View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
</View>
</TouchableOpacity>
<View style={s.walletActions}>
{!isActive && (
<TouchableOpacity style={s.rebindBtn} onPress={() => this.handleRebind(item)}>
<Text style={s.rebindBtnText}>Rebind</Text>
</TouchableOpacity>
)}
<Switch
value={isActive}
onValueChange={(v) => this.toggleWalletActive(item, v)}
trackColor={{ false: '#ddd', true: '#2ecc7180' }}
thumbColor={isActive ? '#2ecc71' : '#999'}
/>
</View>
</View>
);
};
renderBoundWalletList = () => {
const { wallets, loadingWallets, expandedBoundWalletGroups } = this.state;
if (loadingWallets && wallets.length === 0) {
return (
<View style={{ alignItems: 'center', marginTop: 60 }}>
<Text style={{ color: '#aaa', fontSize: 14 }}>Loading</Text>
</View>
);
}
if (wallets.length === 0) {
return (
<View style={{ alignItems: 'center', marginTop: 60 }}>
<Text style={{ color: '#aaa', fontSize: 14 }}>No wallets. Tap + Add to get started.</Text>
</View>
);
}
const groups = groupBoundWallets(wallets);
return (
<ScrollView
style={{ flex: 1, width: '100%' }}
contentContainerStyle={{ paddingHorizontal: 14, paddingBottom: 20 }}
bounces={false}
>
{groups.map(({ walletType, items }) => {
const expanded = !!expandedBoundWalletGroups[walletType];
const color = WALLET_TYPE_COLORS[walletType] ?? '#888';
return (
<View key={walletType} style={s.boundWalletGroup}>
<TouchableOpacity
style={s.boundWalletGroupHeader}
onPress={() => this.toggleBoundWalletGroup(walletType)}
activeOpacity={0.7}
>
<View style={s.walletBadge}>
{WALLET_ICONS[walletType] ? (
<Image source={WALLET_ICONS[walletType]} style={s.walletIcon} resizeMode="contain" />
) : (
<View style={[s.walletIconFallback, { backgroundColor: color }]}>
<Text style={s.walletBadgeText}>{walletType.split(' ')[0]}</Text>
</View>
)}
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.boundWalletGroupTitle}>{formatWalletTypeLabel(walletType)}</Text>
<Text style={s.boundWalletGroupSub}>{items.length} account{items.length > 1 ? 's' : ''}</Text>
</View>
<Text style={s.walletGroupChevron}>{expanded ? '▼' : '▶'}</Text>
</TouchableOpacity>
{expanded && items.map((item) => (
<View key={`${item.id}-wrap`} style={s.boundWalletGroupItem}>
{this.renderWalletItem(item)}
</View>
))}
</View>
);
})}
</ScrollView>
);
};
renderVpaModal() {
const { vpaModalWallet, vpaModalVpas, vpaModalLoading, vpaModalSelected } = this.state;
const color = WALLET_TYPE_COLORS[vpaModalWallet?.walletType ?? ''] ?? '#3498db';
return (
<Modal
visible={!!vpaModalWallet}
transparent
animationType="none"
onRequestClose={this.closeVpaModal}
>
<View style={s.modalOverlay}>
<Animatable.View
animation="zoomIn"
duration={220}
easing="ease-out-back"
useNativeDriver
style={s.vpaModalBox}
>
<Text style={s.vpaModalTitle}>Select VPA</Text>
<Text style={s.vpaModalSub}>{vpaModalWallet?.phone}</Text>
<ScrollView style={s.vpaModalList} bounces={false}>
{vpaModalLoading ? (
<ActivityIndicator size="large" color={color} style={{ marginVertical: 28 }} />
) : vpaModalVpas.length === 0 ? (
<Text style={{ color: '#aaa', textAlign: 'center', marginVertical: 28 }}>No VPA data</Text>
) : (
vpaModalVpas.map((vpa) => (
<TouchableOpacity
key={vpa}
style={[s.vpaOptionRow, vpa === vpaModalSelected && { borderColor: color, backgroundColor: `${color}10` }]}
onPress={() => this.setState({ vpaModalSelected: vpa })}
activeOpacity={0.7}
>
<View style={[s.radioOuter, vpa === vpaModalSelected && { borderColor: color }]}>
{vpa === vpaModalSelected && <View style={[s.radioInner, { backgroundColor: color }]} />}
</View>
<Text style={[s.vpaOptionText, vpa === vpaModalSelected && { color, fontWeight: '600' }]}>{vpa}</Text>
</TouchableOpacity>
))
)}
</ScrollView>
<View style={s.vpaModalFooter}>
<TouchableOpacity style={s.vpaModalCancelBtn} onPress={this.closeVpaModal}>
<Text style={{ color: '#666', fontSize: 15 }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.vpaModalConfirmBtn, { backgroundColor: vpaModalSelected ? color : '#ccc' }]}
onPress={this.confirmVpa}
disabled={!vpaModalSelected}
>
<Text style={{ color: '#fff', fontSize: 15, fontWeight: '600' }}>Confirm</Text>
</TouchableOpacity>
</View>
</Animatable.View>
</View>
</Modal>
);
}
render() {
const { proxyStatus, proxyError, loadingWallets } = this.state;
const proxyCfg: Record<string, { label: string; color: string }> = {
idle: { label: 'Idle', color: '#95a5a6' },
connecting: { label: 'Connecting…', color: '#f39c12' },
connected: { label: 'Connected', color: '#2ecc71' },
disconnected: { label: 'Disconnected', color: '#e74c3c' },
error: { label: 'Error', color: '#e74c3c' },
};
const { label, color } = proxyCfg[proxyStatus];
const { tokenAutoRebind } = this.state;
return (
<View style={s.container}>
{/* top bar */}
<View style={s.topBar}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color, marginRight: 6 }} />
<Text style={{ color, fontSize: 13 }}>
Proxy {label}{proxyStatus === 'error' && proxyError ? `: ${proxyError}` : ''}
</Text>
</View>
<View style={s.autoRebindRow}>
<Text style={s.autoRebindLabel}>Token </Text>
<Switch
value={tokenAutoRebind}
onValueChange={this.toggleTokenAutoRebind}
trackColor={{ false: '#ddd', true: '#3498db80' }}
thumbColor={tokenAutoRebind ? '#3498db' : '#999'}
/>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<TouchableOpacity
onPress={() => {
const domain = getServerDomain();
const i = domain.lastIndexOf(':');
const host = i > 0 ? domain.slice(0, i) : domain;
const port = i > 0 ? domain.slice(i + 1) : '';
this.setState({ showServerSettings: true, settingsHost: host, settingsPort: port });
}}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={{ padding: 6 }}
>
<Text style={{ fontSize: 20, color: '#3498db' }}></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => this.setState({ showAddWallet: true })}
style={s.addBtn}
>
<Text style={s.addBtnText}>+ Add</Text>
</TouchableOpacity>
</View>
</View>
{/* wallet list */}
<View style={s.listHeader}>
<Text style={s.listHeaderText}>Bound Wallets</Text>
<TouchableOpacity onPress={this.fetchWallets} disabled={loadingWallets}>
<Text style={{ fontSize: 13, color: '#3498db' }}>{loadingWallets ? 'Refreshing…' : 'Refresh'}</Text>
</TouchableOpacity>
</View>
{this.renderBoundWalletList()}
{this.renderBindModal()}
<ServerSettingsModal
visible={this.state.showServerSettings}
host={this.state.settingsHost}
port={this.state.settingsPort}
onHostChange={(settingsHost) => this.setState({ settingsHost })}
onPortChange={(settingsPort) => this.setState({ settingsPort })}
onClose={() => this.setState({ showServerSettings: false })}
/>
<WalletSelectModal
visible={this.state.showAddWallet}
onClose={() => this.setState({ showAddWallet: false })}
onSelectBind={this.openWalletBind}
/>
{this.renderVpaModal()}
</View>
);
}
}
const s = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f0f0f0',
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 8,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
autoRebindRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 6,
gap: 8,
},
autoRebindLabel: {
fontSize: 12,
color: '#666',
},
addBtn: {
backgroundColor: '#3498db',
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 4,
},
addBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
listHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 10,
},
listHeaderText: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
walletCard: {
backgroundColor: '#fff',
borderRadius: 10,
padding: 14,
marginBottom: 10,
elevation: 1,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
walletActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 10,
paddingTop: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#eee',
},
rebindBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: '#3498db',
marginRight: 12,
},
rebindBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
walletBadge: {
width: 52,
height: 52,
borderRadius: 10,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
},
walletIcon: {
width: 52,
height: 52,
borderRadius: 10,
},
walletIconInactive: { opacity: 0.3 },
walletBadgeInactive: { backgroundColor: '#f0f0f0' },
walletIconFallback: {
width: 52,
height: 52,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
walletBadgeText: {
color: '#fff',
fontSize: 9,
fontWeight: '700',
textAlign: 'center',
},
walletPhone: {
fontSize: 15,
fontWeight: '600',
color: '#222',
},
walletUpi: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
vpaModalBox: {
backgroundColor: '#fff',
borderRadius: 20,
width: '88%',
maxHeight: '72%',
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.18,
shadowRadius: 20,
shadowOffset: { width: 0, height: 8 },
elevation: 10,
},
vpaModalTitle: {
fontSize: 17,
fontWeight: '700',
color: '#222',
paddingHorizontal: 20,
paddingTop: 22,
},
vpaModalSub: {
fontSize: 13,
color: '#999',
paddingHorizontal: 20,
marginTop: 3,
marginBottom: 14,
},
vpaModalList: {
paddingHorizontal: 14,
maxHeight: 260,
},
vpaOptionRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1.5,
borderColor: '#eee',
marginBottom: 8,
},
radioOuter: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: '#ccc',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
radioInner: {
width: 10,
height: 10,
borderRadius: 5,
},
vpaOptionText: {
fontSize: 14,
color: '#333',
flex: 1,
},
vpaModalFooter: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
marginTop: 8,
},
vpaModalCancelBtn: {
flex: 1,
paddingVertical: 16,
alignItems: 'center',
borderRightWidth: 1,
borderRightColor: '#f0f0f0',
},
vpaModalConfirmBtn: {
flex: 1,
paddingVertical: 16,
alignItems: 'center',
borderRadius: 0,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
boundWalletGroup: {
marginBottom: 4,
},
boundWalletGroupHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 10,
padding: 12,
marginBottom: 8,
borderWidth: 1,
borderColor: '#eee',
},
boundWalletGroupTitle: {
fontSize: 15,
fontWeight: '600',
color: '#222',
},
boundWalletGroupSub: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
boundWalletGroupItem: {
marginLeft: 10,
},
walletGroupChevron: {
fontSize: 12,
color: '#999',
marginLeft: 8,
width: 16,
textAlign: 'right',
},
otpBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#fff',
padding: 16,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
gap: 10,
},
otpInput: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#333',
},
otpBtn: {
backgroundColor: '#5a2d9c',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
otpBtnText: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
errText: {
color: '#e53935',
fontSize: 13,
},
});