@clockpay/react-native 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/lib/commonjs/api/client.js +73 -0
- package/lib/commonjs/api/client.js.map +1 -0
- package/lib/commonjs/api/endpoints.js +11 -0
- package/lib/commonjs/api/endpoints.js.map +1 -0
- package/lib/commonjs/components/bottom-sheet.js +137 -0
- package/lib/commonjs/components/bottom-sheet.js.map +1 -0
- package/lib/commonjs/components/icons.js +129 -0
- package/lib/commonjs/components/icons.js.map +1 -0
- package/lib/commonjs/components/payment-button.js +106 -0
- package/lib/commonjs/components/payment-button.js.map +1 -0
- package/lib/commonjs/components/payment-sheet.js +156 -0
- package/lib/commonjs/components/payment-sheet.js.map +1 -0
- package/lib/commonjs/components/views/status-view.js +109 -0
- package/lib/commonjs/components/views/status-view.js.map +1 -0
- package/lib/commonjs/components/views/transfer-view.js +162 -0
- package/lib/commonjs/components/views/transfer-view.js.map +1 -0
- package/lib/commonjs/components/views/verify-view.js +98 -0
- package/lib/commonjs/components/views/verify-view.js.map +1 -0
- package/lib/commonjs/hooks/use-countdown.js +24 -0
- package/lib/commonjs/hooks/use-countdown.js.map +1 -0
- package/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/internal/constants.js +42 -0
- package/lib/commonjs/internal/constants.js.map +1 -0
- package/lib/commonjs/internal/format.js +29 -0
- package/lib/commonjs/internal/format.js.map +1 -0
- package/lib/commonjs/internal/theme.js +24 -0
- package/lib/commonjs/internal/theme.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +6 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/api/client.js +67 -0
- package/lib/module/api/client.js.map +1 -0
- package/lib/module/api/endpoints.js +7 -0
- package/lib/module/api/endpoints.js.map +1 -0
- package/lib/module/components/bottom-sheet.js +133 -0
- package/lib/module/components/bottom-sheet.js.map +1 -0
- package/lib/module/components/icons.js +120 -0
- package/lib/module/components/icons.js.map +1 -0
- package/lib/module/components/payment-button.js +103 -0
- package/lib/module/components/payment-button.js.map +1 -0
- package/lib/module/components/payment-sheet.js +152 -0
- package/lib/module/components/payment-sheet.js.map +1 -0
- package/lib/module/components/views/status-view.js +105 -0
- package/lib/module/components/views/status-view.js.map +1 -0
- package/lib/module/components/views/transfer-view.js +157 -0
- package/lib/module/components/views/transfer-view.js.map +1 -0
- package/lib/module/components/views/verify-view.js +94 -0
- package/lib/module/components/views/verify-view.js.map +1 -0
- package/lib/module/hooks/use-countdown.js +21 -0
- package/lib/module/hooks/use-countdown.js.map +1 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/internal/constants.js +37 -0
- package/lib/module/internal/constants.js.map +1 -0
- package/lib/module/internal/format.js +24 -0
- package/lib/module/internal/format.js.map +1 -0
- package/lib/module/internal/theme.js +21 -0
- package/lib/module/internal/theme.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/api/client.d.ts +17 -0
- package/lib/typescript/api/client.d.ts.map +1 -0
- package/lib/typescript/api/endpoints.d.ts +5 -0
- package/lib/typescript/api/endpoints.d.ts.map +1 -0
- package/lib/typescript/components/bottom-sheet.d.ts +18 -0
- package/lib/typescript/components/bottom-sheet.d.ts.map +1 -0
- package/lib/typescript/components/icons.d.ts +12 -0
- package/lib/typescript/components/icons.d.ts.map +1 -0
- package/lib/typescript/components/payment-button.d.ts +7 -0
- package/lib/typescript/components/payment-button.d.ts.map +1 -0
- package/lib/typescript/components/payment-sheet.d.ts +17 -0
- package/lib/typescript/components/payment-sheet.d.ts.map +1 -0
- package/lib/typescript/components/views/status-view.d.ts +12 -0
- package/lib/typescript/components/views/status-view.d.ts.map +1 -0
- package/lib/typescript/components/views/transfer-view.d.ts +13 -0
- package/lib/typescript/components/views/transfer-view.d.ts.map +1 -0
- package/lib/typescript/components/views/verify-view.d.ts +10 -0
- package/lib/typescript/components/views/verify-view.d.ts.map +1 -0
- package/lib/typescript/hooks/use-countdown.d.ts +8 -0
- package/lib/typescript/hooks/use-countdown.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +3 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/internal/constants.d.ts +20 -0
- package/lib/typescript/internal/constants.d.ts.map +1 -0
- package/lib/typescript/internal/format.d.ts +7 -0
- package/lib/typescript/internal/format.d.ts.map +1 -0
- package/lib/typescript/internal/theme.d.ts +13 -0
- package/lib/typescript/internal/theme.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +67 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +70 -0
- package/src/api/client.ts +76 -0
- package/src/api/endpoints.ts +4 -0
- package/src/components/bottom-sheet.tsx +144 -0
- package/src/components/icons.tsx +90 -0
- package/src/components/payment-button.tsx +112 -0
- package/src/components/payment-sheet.tsx +149 -0
- package/src/components/views/status-view.tsx +61 -0
- package/src/components/views/transfer-view.tsx +112 -0
- package/src/components/views/verify-view.tsx +69 -0
- package/src/hooks/use-countdown.ts +20 -0
- package/src/index.ts +10 -0
- package/src/internal/constants.ts +34 -0
- package/src/internal/format.ts +21 -0
- package/src/internal/theme.ts +28 -0
- package/src/types.ts +73 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Dimensions,
|
|
5
|
+
Modal,
|
|
6
|
+
PanResponder,
|
|
7
|
+
Pressable,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
interface BottomSheetProps {
|
|
13
|
+
visible: boolean;
|
|
14
|
+
/** Called when the user requests to close (backdrop, drag, hardware back). */
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
/** Called after the exit animation completes — safe to unmount/reset here. */
|
|
17
|
+
onClosed?: () => void;
|
|
18
|
+
/** When false, tapping the dimmed backdrop will not dismiss the sheet. Defaults to true. */
|
|
19
|
+
closeOnBackdropPress?: boolean;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SCREEN_HEIGHT = Dimensions.get('window').height;
|
|
24
|
+
const DISMISS_THRESHOLD = 120;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A Stripe-style bottom sheet: slides up from the bottom over a dimmed backdrop,
|
|
28
|
+
* with drag-to-dismiss on the handle. Built on RN primitives — no native deps.
|
|
29
|
+
*/
|
|
30
|
+
export function BottomSheet({
|
|
31
|
+
visible,
|
|
32
|
+
onClose,
|
|
33
|
+
onClosed,
|
|
34
|
+
closeOnBackdropPress = true,
|
|
35
|
+
children,
|
|
36
|
+
}: BottomSheetProps) {
|
|
37
|
+
const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
|
|
38
|
+
const backdrop = useRef(new Animated.Value(0)).current;
|
|
39
|
+
const [mounted, setMounted] = useState(visible);
|
|
40
|
+
|
|
41
|
+
const animateIn = () => {
|
|
42
|
+
Animated.parallel([
|
|
43
|
+
Animated.timing(translateY, {
|
|
44
|
+
toValue: 0,
|
|
45
|
+
duration: 280,
|
|
46
|
+
useNativeDriver: true,
|
|
47
|
+
}),
|
|
48
|
+
Animated.timing(backdrop, {
|
|
49
|
+
toValue: 1,
|
|
50
|
+
duration: 280,
|
|
51
|
+
useNativeDriver: true,
|
|
52
|
+
}),
|
|
53
|
+
]).start();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const animateOut = () => {
|
|
57
|
+
Animated.parallel([
|
|
58
|
+
Animated.timing(translateY, {
|
|
59
|
+
toValue: SCREEN_HEIGHT,
|
|
60
|
+
duration: 220,
|
|
61
|
+
useNativeDriver: true,
|
|
62
|
+
}),
|
|
63
|
+
Animated.timing(backdrop, {
|
|
64
|
+
toValue: 0,
|
|
65
|
+
duration: 220,
|
|
66
|
+
useNativeDriver: true,
|
|
67
|
+
}),
|
|
68
|
+
]).start(() => {
|
|
69
|
+
setMounted(false);
|
|
70
|
+
onClosed?.();
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (visible) {
|
|
76
|
+
setMounted(true);
|
|
77
|
+
// Defer so the sheet starts off-screen before animating in.
|
|
78
|
+
requestAnimationFrame(animateIn);
|
|
79
|
+
} else if (mounted) {
|
|
80
|
+
animateOut();
|
|
81
|
+
}
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [visible]);
|
|
84
|
+
|
|
85
|
+
const panResponder = useRef(
|
|
86
|
+
PanResponder.create({
|
|
87
|
+
onMoveShouldSetPanResponder: (_, g) => g.dy > 6 && Math.abs(g.dy) > Math.abs(g.dx),
|
|
88
|
+
onPanResponderMove: (_, g) => {
|
|
89
|
+
if (g.dy > 0) translateY.setValue(g.dy);
|
|
90
|
+
},
|
|
91
|
+
onPanResponderRelease: (_, g) => {
|
|
92
|
+
if (g.dy > DISMISS_THRESHOLD) onClose();
|
|
93
|
+
else
|
|
94
|
+
Animated.spring(translateY, {
|
|
95
|
+
toValue: 0,
|
|
96
|
+
useNativeDriver: true,
|
|
97
|
+
bounciness: 4,
|
|
98
|
+
}).start();
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
).current;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Modal
|
|
105
|
+
visible={mounted}
|
|
106
|
+
transparent
|
|
107
|
+
animationType='none'
|
|
108
|
+
statusBarTranslucent
|
|
109
|
+
onRequestClose={onClose}
|
|
110
|
+
>
|
|
111
|
+
<View style={styles.root}>
|
|
112
|
+
<Animated.View style={[styles.backdrop, { opacity: backdrop }]}>
|
|
113
|
+
{/* Absorbs taps; only dismisses when backdrop dismissal is enabled. */}
|
|
114
|
+
<Pressable
|
|
115
|
+
style={StyleSheet.absoluteFill}
|
|
116
|
+
onPress={closeOnBackdropPress ? onClose : undefined}
|
|
117
|
+
/>
|
|
118
|
+
</Animated.View>
|
|
119
|
+
|
|
120
|
+
<Animated.View style={[styles.sheet, { transform: [{ translateY }] }]}>
|
|
121
|
+
<View style={styles.handleArea} {...panResponder.panHandlers}>
|
|
122
|
+
<View style={styles.handle} />
|
|
123
|
+
</View>
|
|
124
|
+
{children}
|
|
125
|
+
</Animated.View>
|
|
126
|
+
</View>
|
|
127
|
+
</Modal>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const styles = StyleSheet.create({
|
|
132
|
+
root: { flex: 1, justifyContent: 'flex-end' },
|
|
133
|
+
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(15,23,42,0.45)' },
|
|
134
|
+
sheet: {
|
|
135
|
+
backgroundColor: '#ffffff',
|
|
136
|
+
borderTopLeftRadius: 24,
|
|
137
|
+
borderTopRightRadius: 24,
|
|
138
|
+
paddingHorizontal: 20,
|
|
139
|
+
paddingBottom: 32,
|
|
140
|
+
maxHeight: '92%',
|
|
141
|
+
},
|
|
142
|
+
handleArea: { alignItems: 'center', paddingTop: 10, paddingBottom: 6 },
|
|
143
|
+
handle: { width: 40, height: 5, borderRadius: 3, backgroundColor: '#CBD5E1' },
|
|
144
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import Svg, { Path, Circle } from 'react-native-svg';
|
|
2
|
+
|
|
3
|
+
interface IconProps {
|
|
4
|
+
size?: number;
|
|
5
|
+
color?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** ClockPay brandmark. */
|
|
9
|
+
export function LogoIcon({ size = 16 }: IconProps) {
|
|
10
|
+
const height = size;
|
|
11
|
+
const width = (23 / 28) * size;
|
|
12
|
+
return (
|
|
13
|
+
<Svg width={width} height={height} viewBox='0 0 23 28' fill='none'>
|
|
14
|
+
<Path d='M22.7193 20.6621H6.78613V27.548H22.7193V20.6621Z' fill='#26A9E1' />
|
|
15
|
+
<Path
|
|
16
|
+
d='M11.3619 15.9327C12.5385 15.9327 13.4923 14.9649 13.4923 13.7711C13.4923 12.5772 12.5385 11.6094 11.3619 11.6094C10.1854 11.6094 9.23157 12.5772 9.23157 13.7711C9.23157 14.9649 10.1854 15.9327 11.3619 15.9327Z'
|
|
17
|
+
fill='#26A9E1'
|
|
18
|
+
/>
|
|
19
|
+
<Path d='M22.7193 0V6.88589H6.79088V20.6577H0V0H22.7193Z' fill='#26A9E1' />
|
|
20
|
+
</Svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CloseIcon({ size = 24, color = '#1E1E1E' }: IconProps) {
|
|
25
|
+
return (
|
|
26
|
+
<Svg width={size} height={size} viewBox='0 0 32 32' fill='none'>
|
|
27
|
+
<Path
|
|
28
|
+
d='M20 12L12 19.9995M20 20L12 12.0005'
|
|
29
|
+
stroke={color}
|
|
30
|
+
strokeWidth={2}
|
|
31
|
+
strokeLinecap='round'
|
|
32
|
+
strokeLinejoin='round'
|
|
33
|
+
/>
|
|
34
|
+
<Path
|
|
35
|
+
d='M3.33325 15.9999C3.33325 10.0288 3.33325 7.04324 5.18824 5.18824C7.04324 3.33325 10.0288 3.33325 15.9999 3.33325C21.971 3.33325 24.9566 3.33325 26.8117 5.18824C28.6666 7.04324 28.6666 10.0288 28.6666 15.9999C28.6666 21.971 28.6666 24.9566 26.8117 26.8117C24.9566 28.6666 21.971 28.6666 15.9999 28.6666C10.0288 28.6666 7.04324 28.6666 5.18824 26.8117C3.33325 24.9566 3.33325 21.971 3.33325 15.9999Z'
|
|
36
|
+
stroke={color}
|
|
37
|
+
strokeWidth={2}
|
|
38
|
+
/>
|
|
39
|
+
</Svg>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function CopyIcon({ size = 18, color = '#27AAE1' }: IconProps) {
|
|
44
|
+
return (
|
|
45
|
+
<Svg width={size} height={size} viewBox='0 0 20 21' fill='none'>
|
|
46
|
+
<Path
|
|
47
|
+
d='M7.5 13C7.5 10.643 7.5 9.4645 8.23223 8.73223C8.9645 8 10.143 8 12.5 8H13.3333C15.6903 8 16.8688 8 17.6011 8.73223C18.3333 9.4645 18.3333 10.643 18.3333 13V13.8333C18.3333 16.1903 18.3333 17.3688 17.6011 18.1011C16.8688 18.8333 15.6903 18.8333 13.3333 18.8333H12.5C10.143 18.8333 8.9645 18.8333 8.23223 18.1011C7.5 17.3688 7.5 16.1903 7.5 13.8333V13Z'
|
|
48
|
+
stroke={color}
|
|
49
|
+
strokeWidth={1.25}
|
|
50
|
+
strokeLinecap='round'
|
|
51
|
+
strokeLinejoin='round'
|
|
52
|
+
/>
|
|
53
|
+
<Path
|
|
54
|
+
d='M14.1664 8.00033C14.1644 5.53608 14.1272 4.25967 13.4098 3.38568C13.2713 3.2169 13.1166 3.06214 12.9478 2.92363C12.0258 2.16699 10.6561 2.16699 7.9165 2.16699C5.17694 2.16699 3.80715 2.16699 2.8852 2.92363C2.71641 3.06213 2.56165 3.2169 2.42314 3.38568C1.6665 4.30764 1.6665 5.67743 1.6665 8.41699C1.6665 11.1566 1.6665 12.5263 2.42314 13.4483C2.56165 13.6171 2.71641 13.7718 2.8852 13.9103C3.75918 14.6277 5.0356 14.6649 7.49984 14.6669'
|
|
55
|
+
stroke={color}
|
|
56
|
+
strokeWidth={1.25}
|
|
57
|
+
strokeLinecap='round'
|
|
58
|
+
strokeLinejoin='round'
|
|
59
|
+
/>
|
|
60
|
+
</Svg>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function InfoIcon({ size = 16 }: IconProps) {
|
|
65
|
+
return (
|
|
66
|
+
<Svg width={size} height={size} viewBox='0 0 12 12' fill='none'>
|
|
67
|
+
<Path
|
|
68
|
+
d='M6 11C8.76142 11 11 8.76142 11 6C11 3.23858 8.76142 1 6 1C3.23858 1 1 3.23858 1 6C1 8.76142 3.23858 11 6 11Z'
|
|
69
|
+
fill='#9DAAD2'
|
|
70
|
+
/>
|
|
71
|
+
<Path d='M6 8V6' stroke='#FAFBFF' strokeLinecap='round' strokeLinejoin='round' />
|
|
72
|
+
<Path d='M6 4H6.005' stroke='#FAFBFF' strokeLinecap='round' strokeLinejoin='round' />
|
|
73
|
+
</Svg>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function CheckIcon({ size = 48 }: IconProps) {
|
|
78
|
+
return (
|
|
79
|
+
<Svg width={size} height={size} viewBox='0 0 24 24' fill='none'>
|
|
80
|
+
<Circle cx={12} cy={12} r={10} fill='#22c55e' />
|
|
81
|
+
<Path
|
|
82
|
+
d='M8 12.5L11 15.5L16 9.5'
|
|
83
|
+
stroke='#fff'
|
|
84
|
+
strokeWidth={2}
|
|
85
|
+
strokeLinecap='round'
|
|
86
|
+
strokeLinejoin='round'
|
|
87
|
+
/>
|
|
88
|
+
</Svg>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
3
|
+
import type { CheckoutData, PaymentButtonProps } from '../types';
|
|
4
|
+
import { ClockPayClient } from '../api/client';
|
|
5
|
+
import { resolveTheme } from '../internal/theme';
|
|
6
|
+
import { PaymentSheet } from './payment-sheet';
|
|
7
|
+
import { LogoIcon } from './icons';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drop-in payment button for React Native. Renders a "Pay with Clockpay" trigger
|
|
11
|
+
* that initiates a checkout and opens a Stripe-style bottom-sheet payment modal.
|
|
12
|
+
*/
|
|
13
|
+
export function PaymentButton({
|
|
14
|
+
publicKey,
|
|
15
|
+
clientSecret,
|
|
16
|
+
appearance,
|
|
17
|
+
label = 'Pay with Clockpay',
|
|
18
|
+
disabled,
|
|
19
|
+
apiBaseUrl,
|
|
20
|
+
redirectUrl,
|
|
21
|
+
onSuccess,
|
|
22
|
+
onError,
|
|
23
|
+
onClose,
|
|
24
|
+
}: PaymentButtonProps) {
|
|
25
|
+
const [checkout, setCheckout] = useState<CheckoutData | null>(null);
|
|
26
|
+
const [visible, setVisible] = useState(false);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
|
|
29
|
+
const theme = useMemo(() => resolveTheme(appearance), [appearance]);
|
|
30
|
+
|
|
31
|
+
const initiate = async () => {
|
|
32
|
+
if (loading) return;
|
|
33
|
+
setLoading(true);
|
|
34
|
+
try {
|
|
35
|
+
const client = new ClockPayClient(publicKey, apiBaseUrl);
|
|
36
|
+
const response = await client.initiatePayment(clientSecret);
|
|
37
|
+
setCheckout(response.data);
|
|
38
|
+
setVisible(true);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
onError?.(error instanceof Error ? error : new Error('Payment initialization failed.'));
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleClosed = () => {
|
|
47
|
+
setCheckout(null);
|
|
48
|
+
onClose?.();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View>
|
|
53
|
+
<TouchableOpacity
|
|
54
|
+
activeOpacity={0.8}
|
|
55
|
+
disabled={disabled || loading}
|
|
56
|
+
onPress={initiate}
|
|
57
|
+
style={[
|
|
58
|
+
styles.button,
|
|
59
|
+
{
|
|
60
|
+
backgroundColor: theme.buttonBg,
|
|
61
|
+
borderColor: theme.buttonBorder,
|
|
62
|
+
borderRadius: theme.radius,
|
|
63
|
+
opacity: disabled || loading ? 0.6 : 1,
|
|
64
|
+
},
|
|
65
|
+
]}
|
|
66
|
+
>
|
|
67
|
+
{loading ? (
|
|
68
|
+
<ActivityIndicator size='small' color={theme.buttonFg} />
|
|
69
|
+
) : (
|
|
70
|
+
<>
|
|
71
|
+
<LogoIcon size={16} />
|
|
72
|
+
{typeof label === 'string' ? (
|
|
73
|
+
<Text style={[styles.label, { color: theme.buttonFg, fontFamily: theme.fontFamily }]}>
|
|
74
|
+
{label}
|
|
75
|
+
</Text>
|
|
76
|
+
) : (
|
|
77
|
+
label
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
</TouchableOpacity>
|
|
82
|
+
|
|
83
|
+
{checkout && (
|
|
84
|
+
<PaymentSheet
|
|
85
|
+
visible={visible}
|
|
86
|
+
checkout={checkout}
|
|
87
|
+
publicKey={publicKey}
|
|
88
|
+
apiBaseUrl={apiBaseUrl}
|
|
89
|
+
theme={theme}
|
|
90
|
+
redirectUrl={redirectUrl}
|
|
91
|
+
onRequestClose={() => setVisible(false)}
|
|
92
|
+
onClosed={handleClosed}
|
|
93
|
+
onSuccess={onSuccess}
|
|
94
|
+
onError={onError}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
</View>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const styles = StyleSheet.create({
|
|
102
|
+
button: {
|
|
103
|
+
flexDirection: 'row',
|
|
104
|
+
alignItems: 'center',
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
gap: 8,
|
|
107
|
+
paddingVertical: 14,
|
|
108
|
+
paddingHorizontal: 16,
|
|
109
|
+
borderWidth: 2,
|
|
110
|
+
},
|
|
111
|
+
label: { fontSize: 15, fontWeight: '600' },
|
|
112
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import type { CheckoutData, VerifyCryptoResponse } from '../types';
|
|
4
|
+
import type { ResolvedTheme } from '../internal/theme';
|
|
5
|
+
import { COUNTDOWN_SECONDS } from '../internal/constants';
|
|
6
|
+
import { resolveNetworkCode } from '../internal/format';
|
|
7
|
+
import { ClockPayClient } from '../api/client';
|
|
8
|
+
import { useCountdown } from '../hooks/use-countdown';
|
|
9
|
+
import { BottomSheet } from './bottom-sheet';
|
|
10
|
+
import { CloseIcon, LogoIcon } from './icons';
|
|
11
|
+
import { TransferView } from './views/transfer-view';
|
|
12
|
+
import { VerifyView } from './views/verify-view';
|
|
13
|
+
import { StatusView } from './views/status-view';
|
|
14
|
+
|
|
15
|
+
type Step = 'pay' | 'verify' | 'status';
|
|
16
|
+
|
|
17
|
+
interface PaymentSheetProps {
|
|
18
|
+
visible: boolean;
|
|
19
|
+
checkout: CheckoutData;
|
|
20
|
+
publicKey: string;
|
|
21
|
+
apiBaseUrl?: string;
|
|
22
|
+
theme: ResolvedTheme;
|
|
23
|
+
redirectUrl?: string;
|
|
24
|
+
onRequestClose: () => void;
|
|
25
|
+
onClosed: () => void;
|
|
26
|
+
onSuccess?: (data: VerifyCryptoResponse) => void;
|
|
27
|
+
onError?: (error: Error) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function PaymentSheet({
|
|
31
|
+
visible,
|
|
32
|
+
checkout,
|
|
33
|
+
publicKey,
|
|
34
|
+
apiBaseUrl,
|
|
35
|
+
theme,
|
|
36
|
+
redirectUrl,
|
|
37
|
+
onRequestClose,
|
|
38
|
+
onClosed,
|
|
39
|
+
onSuccess,
|
|
40
|
+
onError,
|
|
41
|
+
}: PaymentSheetProps) {
|
|
42
|
+
const [step, setStep] = useState<Step>('pay');
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const [success, setSuccess] = useState(false);
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const { formatted, expired } = useCountdown(COUNTDOWN_SECONDS);
|
|
47
|
+
|
|
48
|
+
const client = useMemo(
|
|
49
|
+
() => new ClockPayClient(publicKey, apiBaseUrl),
|
|
50
|
+
[publicKey, apiBaseUrl]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const startVerification = async (trxHash: string) => {
|
|
54
|
+
setStep('status');
|
|
55
|
+
setBusy(true);
|
|
56
|
+
setError(null);
|
|
57
|
+
try {
|
|
58
|
+
const data = await client.verifyCrypto({
|
|
59
|
+
reference: checkout.reference,
|
|
60
|
+
trxHash,
|
|
61
|
+
code: resolveNetworkCode(checkout.network, checkout.networkCode),
|
|
62
|
+
});
|
|
63
|
+
setSuccess(true);
|
|
64
|
+
onSuccess?.(data);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const e = err instanceof Error ? err : new Error('Verification failed.');
|
|
67
|
+
setError(e.message);
|
|
68
|
+
onError?.(e);
|
|
69
|
+
} finally {
|
|
70
|
+
setBusy(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleClosed = () => {
|
|
75
|
+
onClosed();
|
|
76
|
+
if (success && redirectUrl) Linking.openURL(redirectUrl).catch(() => {});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<BottomSheet
|
|
81
|
+
visible={visible}
|
|
82
|
+
onClose={onRequestClose}
|
|
83
|
+
onClosed={handleClosed}
|
|
84
|
+
closeOnBackdropPress={false}
|
|
85
|
+
>
|
|
86
|
+
<View style={styles.header}>
|
|
87
|
+
<LogoIcon size={18} />
|
|
88
|
+
<Text style={[styles.title, { color: theme.accent }]}>Pay with Clockpay</Text>
|
|
89
|
+
<Pressable
|
|
90
|
+
onPress={onRequestClose}
|
|
91
|
+
style={styles.cancel}
|
|
92
|
+
hitSlop={8}
|
|
93
|
+
accessibilityRole='button'
|
|
94
|
+
accessibilityLabel='Cancel'
|
|
95
|
+
>
|
|
96
|
+
<CloseIcon size={22} />
|
|
97
|
+
</Pressable>
|
|
98
|
+
</View>
|
|
99
|
+
|
|
100
|
+
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
|
|
101
|
+
{step === 'pay' && (
|
|
102
|
+
<View style={styles.card}>
|
|
103
|
+
<Text style={styles.muted}>Total amount to be paid</Text>
|
|
104
|
+
<Text style={styles.amount}>
|
|
105
|
+
{checkout.amount}
|
|
106
|
+
{checkout.coin?.toUpperCase()}
|
|
107
|
+
</Text>
|
|
108
|
+
<TransferView
|
|
109
|
+
checkout={checkout}
|
|
110
|
+
theme={theme}
|
|
111
|
+
formattedTime={formatted}
|
|
112
|
+
expired={expired}
|
|
113
|
+
onNext={() => setStep('verify')}
|
|
114
|
+
/>
|
|
115
|
+
</View>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{step === 'verify' && (
|
|
119
|
+
<VerifyView theme={theme} busy={busy} onSubmit={startVerification} />
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{step === 'status' && (
|
|
123
|
+
<StatusView
|
|
124
|
+
theme={theme}
|
|
125
|
+
busy={busy}
|
|
126
|
+
success={success}
|
|
127
|
+
error={error}
|
|
128
|
+
onClose={onRequestClose}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</ScrollView>
|
|
132
|
+
</BottomSheet>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const styles = StyleSheet.create({
|
|
137
|
+
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingBottom: 16 },
|
|
138
|
+
title: { fontSize: 16, fontWeight: '700' },
|
|
139
|
+
cancel: { position: 'absolute', right: 0, top: -2, padding: 4 },
|
|
140
|
+
card: {
|
|
141
|
+
borderWidth: 1,
|
|
142
|
+
borderColor: '#edf1f6',
|
|
143
|
+
borderRadius: 16,
|
|
144
|
+
padding: 20,
|
|
145
|
+
alignItems: 'center',
|
|
146
|
+
},
|
|
147
|
+
muted: { fontSize: 13, color: '#94a3b8' },
|
|
148
|
+
amount: { fontSize: 24, fontWeight: '700', color: '#000', marginTop: 4 },
|
|
149
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
2
|
+
import type { ResolvedTheme } from '../../internal/theme';
|
|
3
|
+
import { CheckIcon } from '../icons';
|
|
4
|
+
|
|
5
|
+
interface StatusViewProps {
|
|
6
|
+
theme: ResolvedTheme;
|
|
7
|
+
busy: boolean;
|
|
8
|
+
success: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Terminal step: processing spinner, success confirmation, or an error. */
|
|
14
|
+
export function StatusView({ theme, busy, success, error, onClose }: StatusViewProps) {
|
|
15
|
+
if (busy) {
|
|
16
|
+
return (
|
|
17
|
+
<View style={styles.center}>
|
|
18
|
+
<ActivityIndicator size='large' color={theme.accent} />
|
|
19
|
+
<Text style={styles.muted}>Please wait while we verify your transaction…</Text>
|
|
20
|
+
</View>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (success) {
|
|
25
|
+
return (
|
|
26
|
+
<View style={styles.center}>
|
|
27
|
+
<CheckIcon size={48} />
|
|
28
|
+
<Text style={styles.title}>Payment received!</Text>
|
|
29
|
+
<Text style={styles.muted}>You can safely close this window.</Text>
|
|
30
|
+
<TouchableOpacity
|
|
31
|
+
style={[styles.primaryBtn, { backgroundColor: theme.accent, borderRadius: theme.radius }]}
|
|
32
|
+
onPress={onClose}
|
|
33
|
+
>
|
|
34
|
+
<Text style={styles.primaryBtnText}>Close</Text>
|
|
35
|
+
</TouchableOpacity>
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<View style={styles.center}>
|
|
42
|
+
<Text style={styles.title}>Verification failed</Text>
|
|
43
|
+
<Text style={styles.error}>{error ?? 'We could not verify your transaction.'}</Text>
|
|
44
|
+
<TouchableOpacity
|
|
45
|
+
style={[styles.primaryBtn, { backgroundColor: theme.accent, borderRadius: theme.radius }]}
|
|
46
|
+
onPress={onClose}
|
|
47
|
+
>
|
|
48
|
+
<Text style={styles.primaryBtnText}>Close</Text>
|
|
49
|
+
</TouchableOpacity>
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const styles = StyleSheet.create({
|
|
55
|
+
center: { alignItems: 'center', paddingVertical: 24, gap: 8 },
|
|
56
|
+
title: { fontSize: 18, fontWeight: '700', color: '#000', marginTop: 8 },
|
|
57
|
+
muted: { fontSize: 13, color: '#94a3b8', textAlign: 'center' },
|
|
58
|
+
error: { fontSize: 13, color: '#ef4444', textAlign: 'center' },
|
|
59
|
+
primaryBtn: { width: '100%', paddingVertical: 15, alignItems: 'center', marginTop: 16 },
|
|
60
|
+
primaryBtnText: { fontSize: 16, fontWeight: '600', color: '#fff' },
|
|
61
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
3
|
+
import QRCode from 'react-native-qrcode-svg';
|
|
4
|
+
import Clipboard from '@react-native-clipboard/clipboard';
|
|
5
|
+
import type { CheckoutData } from '../../types';
|
|
6
|
+
import type { ResolvedTheme } from '../../internal/theme';
|
|
7
|
+
import { CopyIcon, InfoIcon } from '../icons';
|
|
8
|
+
|
|
9
|
+
interface TransferViewProps {
|
|
10
|
+
checkout: CheckoutData;
|
|
11
|
+
theme: ResolvedTheme;
|
|
12
|
+
formattedTime: string;
|
|
13
|
+
expired: boolean;
|
|
14
|
+
onNext: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Manual-transfer flow: QR + deposit address + notes + countdown. */
|
|
18
|
+
export function TransferView({
|
|
19
|
+
checkout,
|
|
20
|
+
theme,
|
|
21
|
+
formattedTime,
|
|
22
|
+
expired,
|
|
23
|
+
onNext,
|
|
24
|
+
}: TransferViewProps) {
|
|
25
|
+
const [copied, setCopied] = useState(false);
|
|
26
|
+
const coin = checkout.coin?.toUpperCase() ?? '';
|
|
27
|
+
|
|
28
|
+
const copyAddress = () => {
|
|
29
|
+
Clipboard.setString(checkout.address);
|
|
30
|
+
setCopied(true);
|
|
31
|
+
setTimeout(() => setCopied(false), 1500);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View style={styles.container}>
|
|
36
|
+
<View style={[styles.qr, { borderColor: theme.accent }]}>
|
|
37
|
+
<QRCode value={checkout.address} size={108} />
|
|
38
|
+
</View>
|
|
39
|
+
|
|
40
|
+
<Text style={styles.addressLabel}>Address:</Text>
|
|
41
|
+
<Text style={styles.address}>{checkout.address}</Text>
|
|
42
|
+
|
|
43
|
+
<TouchableOpacity style={styles.copyRow} onPress={copyAddress}>
|
|
44
|
+
<Text style={[styles.copyText, { color: theme.accent }]}>
|
|
45
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
46
|
+
</Text>
|
|
47
|
+
<CopyIcon size={18} color={theme.accent} />
|
|
48
|
+
</TouchableOpacity>
|
|
49
|
+
|
|
50
|
+
<View style={styles.note}>
|
|
51
|
+
<View style={styles.noteRow}>
|
|
52
|
+
<InfoIcon size={16} />
|
|
53
|
+
<Text style={styles.noteText}>
|
|
54
|
+
Only send {coin} ({checkout.networkCode}) assets to this address. Other
|
|
55
|
+
assets will be lost forever.
|
|
56
|
+
</Text>
|
|
57
|
+
</View>
|
|
58
|
+
<View style={styles.noteRow}>
|
|
59
|
+
<InfoIcon size={16} />
|
|
60
|
+
<Text style={styles.noteText}>
|
|
61
|
+
When you make a transfer, you'll paste the transaction hash on the next
|
|
62
|
+
screen.
|
|
63
|
+
</Text>
|
|
64
|
+
</View>
|
|
65
|
+
</View>
|
|
66
|
+
|
|
67
|
+
<Text style={styles.timer}>{formattedTime}</Text>
|
|
68
|
+
|
|
69
|
+
<TouchableOpacity
|
|
70
|
+
style={[
|
|
71
|
+
styles.primaryBtn,
|
|
72
|
+
{ backgroundColor: expired ? '#dff0f7' : theme.accent, borderRadius: theme.radius },
|
|
73
|
+
]}
|
|
74
|
+
disabled={expired}
|
|
75
|
+
onPress={onNext}
|
|
76
|
+
>
|
|
77
|
+
<Text style={[styles.primaryBtnText, { color: expired ? '#9ca3af' : '#fff' }]}>
|
|
78
|
+
Next
|
|
79
|
+
</Text>
|
|
80
|
+
</TouchableOpacity>
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const styles = StyleSheet.create({
|
|
86
|
+
container: { alignItems: 'center' },
|
|
87
|
+
qr: { borderWidth: 1, borderStyle: 'dashed', borderRadius: 8, padding: 10, marginVertical: 12 },
|
|
88
|
+
addressLabel: { fontSize: 13, fontWeight: '700', color: '#64748b' },
|
|
89
|
+
address: {
|
|
90
|
+
fontSize: 13,
|
|
91
|
+
fontWeight: '700',
|
|
92
|
+
color: '#64748b',
|
|
93
|
+
textAlign: 'center',
|
|
94
|
+
marginTop: 4,
|
|
95
|
+
marginBottom: 8,
|
|
96
|
+
},
|
|
97
|
+
copyRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 16 },
|
|
98
|
+
copyText: { fontSize: 13, fontWeight: '700' },
|
|
99
|
+
note: {
|
|
100
|
+
width: '100%',
|
|
101
|
+
backgroundColor: '#eefaff',
|
|
102
|
+
borderRadius: 8,
|
|
103
|
+
padding: 16,
|
|
104
|
+
gap: 8,
|
|
105
|
+
marginBottom: 16,
|
|
106
|
+
},
|
|
107
|
+
noteRow: { flexDirection: 'row', gap: 8 },
|
|
108
|
+
noteText: { flex: 1, color: '#7785b0', fontSize: 12, lineHeight: 18 },
|
|
109
|
+
timer: { color: '#3bc5ff', fontSize: 14, marginBottom: 12 },
|
|
110
|
+
primaryBtn: { width: '100%', paddingVertical: 15, alignItems: 'center' },
|
|
111
|
+
primaryBtnText: { fontSize: 16, fontWeight: '600' },
|
|
112
|
+
});
|