Skip to content

Commit d174880

Browse files
authored
feat(scan): add gift codes (#2541)
1 parent b1375d3 commit d174880

File tree

14 files changed

+536
-14
lines changed

14 files changed

+536
-14
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"@react-navigation/native-stack": "7.2.0",
4848
"@reduxjs/toolkit": "2.2.6",
4949
"@shopify/react-native-skia": "next",
50-
"@synonymdev/blocktank-lsp-http-client": "2.2.0",
50+
"@synonymdev/blocktank-lsp-http-client": "2.5.0",
5151
"@synonymdev/react-native-ldk": "0.0.159",
5252
"@synonymdev/react-native-lnurl": "0.0.10",
5353
"@synonymdev/react-native-pubky": "^0.3.0",

src/navigation/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99

1010
import type { RecoveryStackParamList } from '../../screens/Recovery/RecoveryNavigator';
1111
import type { BackupStackParamList } from '../../sheets/BackupNavigation';
12+
import type { GiftStackParamList } from '../../sheets/GiftNavigation';
1213
import type { LNURLWithdrawStackParamList } from '../../sheets/LNURLWithdrawNavigation';
1314
import type { OrangeTicketStackParamList } from '../../sheets/OrangeTicketNavigation';
1415
import type { PinStackParamList } from '../../sheets/PINNavigation';
@@ -103,6 +104,9 @@ export type OrangeTicketScreenProps<
103104
T extends keyof OrangeTicketStackParamList,
104105
> = NativeStackScreenProps<OrangeTicketStackParamList, T>;
105106

107+
export type GiftScreenProps<T extends keyof GiftStackParamList> =
108+
NativeStackScreenProps<GiftStackParamList, T>;
109+
106110
export type TreasureHuntScreenProps<
107111
T extends keyof TreasureHuntStackParamList,
108112
> = NativeStackScreenProps<TreasureHuntStackParamList, T>;

src/screens/Gift/Error.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React, { ReactElement, memo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { Image, StyleSheet, View } from 'react-native';
4+
5+
import BottomSheetNavigationHeader from '../../components/BottomSheetNavigationHeader';
6+
import GradientView from '../../components/GradientView';
7+
import SafeAreaInset from '../../components/SafeAreaInset';
8+
import Button from '../../components/buttons/Button';
9+
import { useSheetRef } from '../../sheets/SheetRefsProvider';
10+
import { BodyM } from '../../styles/text';
11+
12+
const imageSrc = require('../../assets/illustrations/exclamation-mark.png');
13+
14+
const ErrorScreen = (): ReactElement => {
15+
const { t } = useTranslation('other');
16+
const sheetRef = useSheetRef('gift');
17+
18+
const onContinue = (): void => {
19+
sheetRef.current?.close();
20+
};
21+
22+
return (
23+
<GradientView style={styles.root}>
24+
<BottomSheetNavigationHeader
25+
title={t('gift.error.title')}
26+
showBackButton={false}
27+
/>
28+
29+
<View style={styles.content}>
30+
<BodyM color="secondary">{t('gift.error.text')}</BodyM>
31+
32+
<View style={styles.imageContainer}>
33+
<Image style={styles.image} source={imageSrc} />
34+
</View>
35+
36+
<View style={styles.buttonContainer}>
37+
<Button
38+
style={styles.button}
39+
size="large"
40+
text={t('ok')}
41+
onPress={onContinue}
42+
/>
43+
</View>
44+
</View>
45+
<SafeAreaInset type="bottom" minPadding={16} />
46+
</GradientView>
47+
);
48+
};
49+
50+
const styles = StyleSheet.create({
51+
root: {
52+
flex: 1,
53+
},
54+
content: {
55+
flex: 1,
56+
paddingHorizontal: 16,
57+
},
58+
imageContainer: {
59+
flexShrink: 1,
60+
justifyContent: 'center',
61+
alignItems: 'center',
62+
alignSelf: 'center',
63+
width: 256,
64+
aspectRatio: 1,
65+
marginTop: 'auto',
66+
},
67+
image: {
68+
flex: 1,
69+
resizeMode: 'contain',
70+
},
71+
buttonContainer: {
72+
flexDirection: 'row',
73+
justifyContent: 'center',
74+
marginTop: 'auto',
75+
gap: 16,
76+
},
77+
button: {
78+
flex: 1,
79+
},
80+
});
81+
82+
export default memo(ErrorScreen);

src/screens/Gift/Loading.tsx

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { EPaymentType } from 'beignet';
2+
import React, { ReactElement, useCallback, useEffect } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
import { Image, StyleSheet, View } from 'react-native';
5+
6+
import { ActivityIndicator } from '../../components/ActivityIndicator';
7+
import AmountToggle from '../../components/AmountToggle';
8+
import BottomSheetNavigationHeader from '../../components/BottomSheetNavigationHeader';
9+
import GradientView from '../../components/GradientView';
10+
import SafeAreaInset from '../../components/SafeAreaInset';
11+
import { useLightningMaxInboundCapacity } from '../../hooks/lightning';
12+
import { GiftScreenProps } from '../../navigation/types';
13+
import { useSheetRef } from '../../sheets/SheetRefsProvider';
14+
import { dispatch } from '../../store/helpers';
15+
import { addActivityItem } from '../../store/slices/activity';
16+
import { updateSettings } from '../../store/slices/settings';
17+
import {
18+
EActivityType,
19+
TLightningActivityItem,
20+
} from '../../store/types/activity';
21+
import { createLightningInvoice } from '../../store/utils/lightning';
22+
import { showSheet } from '../../store/utils/ui';
23+
import { BodyM } from '../../styles/text';
24+
import { giftOrder, giftPay, openChannel } from '../../utils/blocktank';
25+
import { vibrate } from '../../utils/helpers';
26+
27+
const imageSrc = require('../../assets/illustrations/gift.png');
28+
29+
const Loading = ({
30+
navigation,
31+
route,
32+
}: GiftScreenProps<'Loading'>): ReactElement => {
33+
const { code, amount } = route.params;
34+
const { t } = useTranslation('other');
35+
const sheetRef = useSheetRef('gift');
36+
const maxInboundCapacity = useLightningMaxInboundCapacity();
37+
38+
// biome-ignore lint/correctness/useExhaustiveDependencies: on mount
39+
const getGift = useCallback(async (): Promise<void> => {
40+
const getWithoutLiquidity = async (): Promise<void> => {
41+
const orderResult = await giftOrder(code);
42+
43+
if (orderResult.isErr()) {
44+
if (orderResult.error.message.includes('GIFT_CODE_ALREADY_USED')) {
45+
navigation.navigate('Used', { amount });
46+
} else {
47+
navigation.navigate('Error');
48+
}
49+
50+
return;
51+
}
52+
53+
const { orderId } = orderResult.value;
54+
55+
if (!orderId) {
56+
navigation.navigate('Error');
57+
return;
58+
}
59+
60+
const openResult = await openChannel(orderId);
61+
62+
if (openResult.isErr()) {
63+
navigation.navigate('Error');
64+
return;
65+
}
66+
67+
const order = openResult.value;
68+
69+
const activityItem: TLightningActivityItem = {
70+
id: order.channel?.fundingTx.id ?? '',
71+
activityType: EActivityType.lightning,
72+
txType: EPaymentType.received,
73+
status: 'successful',
74+
message: code,
75+
address: '',
76+
value: order.clientBalanceSat,
77+
confirmed: true,
78+
timestamp: new Date().getTime(),
79+
};
80+
81+
dispatch(addActivityItem(activityItem));
82+
dispatch(updateSettings({ hideOnboardingMessage: true }));
83+
vibrate({ type: 'default' });
84+
sheetRef.current?.close();
85+
showSheet('receivedTx', {
86+
id: activityItem.id,
87+
activityType: EActivityType.lightning,
88+
value: activityItem.value,
89+
});
90+
};
91+
92+
const getWithLiquidity = async (): Promise<void> => {
93+
const invoiceResult = await createLightningInvoice({
94+
amountSats: 0,
95+
description: `blocktank-gift-code:${code}`,
96+
expiryDeltaSeconds: 3600,
97+
});
98+
99+
if (invoiceResult.isErr()) {
100+
navigation.navigate('Error');
101+
return;
102+
}
103+
104+
const invoice = invoiceResult.value.to_str;
105+
const result = await giftPay(invoice);
106+
107+
if (result.isErr()) {
108+
if (result.error.message.includes('GIFT_CODE_ALREADY_USED')) {
109+
navigation.navigate('Used', { amount });
110+
} else {
111+
navigation.navigate('Error');
112+
}
113+
114+
return;
115+
}
116+
117+
sheetRef.current?.close();
118+
};
119+
120+
if (maxInboundCapacity >= amount) {
121+
await getWithLiquidity();
122+
} else {
123+
await getWithoutLiquidity();
124+
}
125+
}, []);
126+
127+
useEffect(() => {
128+
getGift();
129+
}, [getGift]);
130+
131+
return (
132+
<GradientView style={styles.root}>
133+
<BottomSheetNavigationHeader title={t('gift.claiming.title')} />
134+
135+
<View style={styles.content}>
136+
<AmountToggle amount={amount} />
137+
138+
<BodyM style={styles.text} color="secondary">
139+
{t('gift.claiming.text')}
140+
</BodyM>
141+
142+
<View style={styles.imageContainer}>
143+
<Image style={styles.image} source={imageSrc} />
144+
</View>
145+
146+
<View style={styles.footer}>
147+
<ActivityIndicator size={32} />
148+
</View>
149+
</View>
150+
<SafeAreaInset type="bottom" minPadding={16} />
151+
</GradientView>
152+
);
153+
};
154+
155+
const styles = StyleSheet.create({
156+
root: {
157+
flex: 1,
158+
},
159+
content: {
160+
flex: 1,
161+
paddingHorizontal: 16,
162+
},
163+
text: {
164+
marginTop: 32,
165+
},
166+
imageContainer: {
167+
flexShrink: 1,
168+
justifyContent: 'center',
169+
alignItems: 'center',
170+
alignSelf: 'center',
171+
width: 256,
172+
aspectRatio: 1,
173+
marginTop: 'auto',
174+
},
175+
image: {
176+
flex: 1,
177+
resizeMode: 'contain',
178+
},
179+
footer: {
180+
marginTop: 'auto',
181+
marginBottom: 16,
182+
justifyContent: 'center',
183+
alignItems: 'center',
184+
},
185+
});
186+
187+
export default Loading;

src/screens/Gift/Used.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React, { ReactElement, memo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { Image, StyleSheet, View } from 'react-native';
4+
5+
import AmountToggle from '../../components/AmountToggle';
6+
import BottomSheetNavigationHeader from '../../components/BottomSheetNavigationHeader';
7+
import GradientView from '../../components/GradientView';
8+
import SafeAreaInset from '../../components/SafeAreaInset';
9+
import Button from '../../components/buttons/Button';
10+
import { GiftScreenProps } from '../../navigation/types';
11+
import { useSheetRef } from '../../sheets/SheetRefsProvider';
12+
import { BodyM } from '../../styles/text';
13+
14+
const imageSrc = require('../../assets/illustrations/exclamation-mark.png');
15+
16+
const UsedCard = ({ route }: GiftScreenProps<'Used'>): ReactElement => {
17+
const { amount } = route.params;
18+
const { t } = useTranslation('other');
19+
const sheetRef = useSheetRef('gift');
20+
21+
const onContinue = (): void => {
22+
sheetRef.current?.close();
23+
};
24+
25+
return (
26+
<GradientView style={styles.root}>
27+
<BottomSheetNavigationHeader
28+
title={t('gift.used.title')}
29+
showBackButton={false}
30+
/>
31+
32+
<View style={styles.content}>
33+
<AmountToggle amount={amount} />
34+
35+
<BodyM style={styles.text} color="secondary">
36+
{t('gift.used.text')}
37+
</BodyM>
38+
39+
<View style={styles.imageContainer}>
40+
<Image style={styles.image} source={imageSrc} />
41+
</View>
42+
43+
<View style={styles.buttonContainer}>
44+
<Button
45+
style={styles.button}
46+
size="large"
47+
text={t('ok')}
48+
onPress={onContinue}
49+
/>
50+
</View>
51+
</View>
52+
<SafeAreaInset type="bottom" minPadding={16} />
53+
</GradientView>
54+
);
55+
};
56+
57+
const styles = StyleSheet.create({
58+
root: {
59+
flex: 1,
60+
},
61+
content: {
62+
flex: 1,
63+
paddingHorizontal: 16,
64+
},
65+
text: {
66+
marginTop: 32,
67+
},
68+
imageContainer: {
69+
flexShrink: 1,
70+
justifyContent: 'center',
71+
alignItems: 'center',
72+
alignSelf: 'center',
73+
width: 256,
74+
aspectRatio: 1,
75+
marginTop: 'auto',
76+
},
77+
image: {
78+
flex: 1,
79+
resizeMode: 'contain',
80+
},
81+
buttonContainer: {
82+
flexDirection: 'row',
83+
justifyContent: 'center',
84+
marginTop: 'auto',
85+
gap: 16,
86+
},
87+
button: {
88+
flex: 1,
89+
},
90+
});
91+
92+
export default memo(UsedCard);

0 commit comments

Comments
 (0)