Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: holders otp support #1250

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions app/components/holders/PaymentOtpBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { memo, useEffect, useState } from "react";
import Animated, { FadeInUp, FadeOutDown } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { StyleSheet, View, Text, Pressable, ActivityIndicator } from "react-native";
import { useHoldersOtp, useNetwork, useTheme } from "../../engine/hooks";
import { Typography } from "../styles";
import { t } from "../../i18n/t";
import { Image } from "expo-image";
import { getHoldersToken } from "../../engine/hooks/holders/useHoldersAccountStatus";
import { holdersUrl } from "../../engine/api/holders/fetchUserState";
import { useTypedNavigation } from "../../utils/useTypedNavigation";
import { HoldersAppParamsType } from "../../fragments/holders/HoldersAppFragment";
import { useLockAppWithAuthState } from "../../engine/hooks/settings";
import { queryClient } from "../../engine/clients";
import { Queries } from "../../engine/queries";
import { Address } from "@ton/core";
import { fetchOtpAnswer } from "../../engine/api/holders/fetchOtpAnswer";

const gradientColors = ['#478CF3', '#372DC8'];

const OtpTimer = memo(({ expireAt, onExpired }: { expireAt: Date, onExpired: () => void }) => {
const theme = useTheme();

const [timeLeft, setTimeLeft] = useState(expireAt.getTime() - Date.now());

useEffect(() => {
const interval = setInterval(() => {
setTimeLeft(expireAt.getTime() - Date.now());
}, 1000);

return () => clearInterval(interval);
}, [expireAt]);

const minutes = Math.floor(timeLeft / 60000);
const seconds = Math.floor(timeLeft / 1000) % 60;

return (
<View style={styles.timer}>
<View style={styles.timerItem}>
<View style={styles.timerItemBack} />
<Text style={[{ color: theme.textUnchangeable }, Typography.semiBold15_20]}>
{minutes.toString().padStart(2, '0')}
</Text>
</View>
<Text style={[{ color: theme.textUnchangeable }, Typography.semiBold15_20]}>
:
</Text>
<View style={styles.timerItem}>
<View style={styles.timerItemBack} />
<Text style={[{ color: theme.textUnchangeable }, Typography.semiBold15_20]}>
{seconds.toString().padStart(2, '0')}
</Text>
</View>
</View>
);
});

const AcceptIcon = <Image style={{ height: 16, width: 16 }} source={require('@assets/ic-accept-otp.png')} />;
const DeclineIcon = <Image style={{ height: 16, width: 16 }} source={require('@assets/ic-decline-otp.png')} />;

const fetchOtpAnswerFn = ({ id, accept, token, isTestnet, address }: { id: string, accept: boolean, token: string, isTestnet: boolean, address: string }) => (async () => {
try {
await fetchOtpAnswer({ id, accept, token, isTestnet });
queryClient.refetchQueries(Queries.Holders(address).Invite());
} catch {}
});

const OtpAction = memo((
{ id, accept, address, setActionDisabled, disabled }:
{ id: string, accept: boolean, address: string, setActionDisabled: (disabled: boolean) => void, disabled: boolean }
) => {
const navigation = useTypedNavigation();
const { isTestnet } = useNetwork();
const url = holdersUrl(isTestnet);
const [lockAppWithAuth] = useLockAppWithAuthState();

const [loading, setLoading] = useState(false);

const onNoTokenFound = () => {
navigation.navigateHoldersLanding({ endpoint: url, onEnrollType: { type: HoldersAppParamsType.Create } }, isTestnet);
};

const answer = async () => {
if (loading || disabled) {
return;
}
setLoading(true);
try {
const token = getHoldersToken(address);
// should not happen
if (!token) {
onNoTokenFound();
return;
}

const answer = fetchOtpAnswerFn({ id, accept, token, isTestnet, address });

if (!lockAppWithAuth) {
navigation.navigateMandatoryAuthSetup({ callback: answer });
return;
}

await answer();
} catch { } finally {
setLoading(false);
}
}

useEffect(() => {
setActionDisabled(loading);
}, [loading]);

const icon = accept ? AcceptIcon : DeclineIcon;
const textColor = accept ? '#000' : '#fff';
const title = accept ? t('products.holders.otpBanner.accept') : t('products.holders.otpBanner.decline');

return (
<Pressable
disabled={loading || disabled}
style={({ pressed }) => [
styles.action,
{ opacity: pressed ? 0.8 : 1 }
]}
onPress={answer}
>
<View style={accept ? styles.actionAccept : styles.actionDecline} />
{loading ? <ActivityIndicator color={textColor} size='small' /> : icon}
<Text style={[{ color: textColor }, Typography.semiBold15_20]}>
{title}
</Text>
</Pressable>
);
});

export const PaymentOtpBanner = memo(({ address }: { address: Address }) => {
const theme = useTheme();
const { isTestnet } = useNetwork();
const otp = useHoldersOtp(address, isTestnet);
const addressString = address.toString({ testOnly: isTestnet });
const [actionDisabled, setActionDisabled] = useState(false);

const onExpired = () => {
queryClient.invalidateQueries(Queries.Holders(addressString).OPT());
}

let expiresAt = null;

if (otp && otp.expiresAt) {
try {
expiresAt = new Date(otp.expiresAt);
} catch (error) { }
}

const hasExpired = expiresAt && expiresAt.getTime() < Date.now();

if (!otp || hasExpired) {
return null;
}

if (otp.type !== 'confirmation_request') {
return null;
}

let message = `${otp.amount} ${otp.currency}`;

if (otp.merchant) {
message += ` ${otp.merchant.cleanName ?? otp.merchant.dirtyName}`;
}

return (
<Animated.View
entering={FadeInUp}
exiting={FadeOutDown}
style={styles.container}
>
<LinearGradient
style={styles.gradient}
colors={gradientColors}
start={[0, 1]}
end={[1, 0]}
/>
<View style={styles.body}>
<View>
<Text style={[{ color: theme.textUnchangeable }, Typography.semiBold17_24]}>
{t('products.holders.otpBanner.title')}
</Text>
<Text style={[{ color: theme.textUnchangeable, opacity: 0.8 }, Typography.regular15_20]}>
{message}
</Text>
</View>
{expiresAt && <OtpTimer expireAt={expiresAt} onExpired={onExpired} />}
</View>
<View style={styles.actions}>
<OtpAction
id={otp.txnId}
accept
address={addressString}
disabled={actionDisabled}
setActionDisabled={setActionDisabled}
/>
<OtpAction
id={otp.txnId}
accept={false}
address={addressString}
disabled={actionDisabled}
setActionDisabled={setActionDisabled}
/>
</View>
</Animated.View>
);
});

PaymentOtpBanner.displayName = "PaymentOtpBanner";

const styles = StyleSheet.create({
container: { borderRadius: 20, padding: 20, gap: 20, margin: 20, marginBottom: 0 },
gradient: {
position: 'absolute',
borderRadius: 20,
left: 0, right: 0,
top: 0, bottom: 0
},
body: { flexDirection: 'row', gap: 16, justifyContent: 'space-between', alignItems: 'center' },
timer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
alignSelf: 'flex-start'
},
timerItem: {
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 6,
minWidth: 40,
},
timerItemBack: {
position: 'absolute',
top: 0, bottom: 0, left: 0, right: 0,
opacity: 0.16, borderRadius: 8,
backgroundColor: '#fff'
},
actions: { flexDirection: 'row', gap: 12, justifyContent: 'space-evenly', alignItems: 'center', width: '100%' },
action: { flexDirection: 'row', gap: 4, paddingVertical: 8, borderRadius: 200, flex: 1, justifyContent: 'center', alignItems: 'center', overflow: 'hidden' },
actionAccept: { backgroundColor: '#fff', position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 },
actionDecline: { backgroundColor: '#fff', opacity: 0.16, position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }
});
6 changes: 3 additions & 3 deletions app/components/products/ProductsComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { HoldersBanner } from "./HoldersBanner"
import { SavingsProduct } from "./SavingsProduct"

import OldWalletIcon from '@assets/ic_old_wallet.svg';
import { PaymentOtpBanner } from "../holders/PaymentOtpBanner"

export type HoldersBannerType = { type: 'built-in' } | { type: 'custom', banner: HoldersCustomBanner };

Expand Down Expand Up @@ -101,9 +102,8 @@ export const ProductsComponent = memo(({ selected }: { selected: SelectedAccount

return (
<View>
<View style={{
backgroundColor: theme.backgroundPrimary,
}}>
<View style={{ backgroundColor: theme.backgroundPrimary }}>
<PaymentOtpBanner address={selected.address} />
<AddressFormatUpdate />
<W5Banner />
<DappsRequests />
Expand Down
49 changes: 49 additions & 0 deletions app/engine/api/holders/fetchOtpAnswer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import axios from "axios";
import { z } from "zod";
import { holdersEndpoint } from "./fetchUserState";

export const otpRequestConfirmationCodec = z.object({
id: z.string(),
status: z.enum(['REJECTED', 'CONFIRMED'])
});
export type OtpRequestConfirmation = z.infer<typeof otpRequestConfirmationCodec>;

const otpAnswerResultCodec = z.union([
z.object({
ok: z.literal(false),
error: z.enum([
'not_confirmation_type',
'expired',
'already_rejected',
'already_confirmed',
'disabled_for_provider'
]),
}),
z.object({
ok: z.literal(true),
})
]);

export type OtpAnswerResult = z.infer<typeof otpAnswerResultCodec>;

export async function fetchOtpAnswer(params: { token: string, id: string, accept: boolean, isTestnet: boolean }): Promise<OtpAnswerResult> {
const { token, id, accept, isTestnet } = params;
const url = `https://${holdersEndpoint(isTestnet)}/v2/user/otp/requests/confirm`;

const res = await axios.post(url, {
token,
data: { id, status: accept ? 'CONFIRMED' : 'REJECTED' }
});

if (res.status !== 200) {
throw Error('Unable to request phone verification');
}

const parsed = otpAnswerResultCodec.safeParse(res.data);

if (!parsed.success) {
throw new Error(`Failed to parse response: ${parsed.error.errors}`);
}

return parsed.data
}
Loading