@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.
Files changed (109) hide show
  1. package/README.md +113 -0
  2. package/lib/commonjs/api/client.js +73 -0
  3. package/lib/commonjs/api/client.js.map +1 -0
  4. package/lib/commonjs/api/endpoints.js +11 -0
  5. package/lib/commonjs/api/endpoints.js.map +1 -0
  6. package/lib/commonjs/components/bottom-sheet.js +137 -0
  7. package/lib/commonjs/components/bottom-sheet.js.map +1 -0
  8. package/lib/commonjs/components/icons.js +129 -0
  9. package/lib/commonjs/components/icons.js.map +1 -0
  10. package/lib/commonjs/components/payment-button.js +106 -0
  11. package/lib/commonjs/components/payment-button.js.map +1 -0
  12. package/lib/commonjs/components/payment-sheet.js +156 -0
  13. package/lib/commonjs/components/payment-sheet.js.map +1 -0
  14. package/lib/commonjs/components/views/status-view.js +109 -0
  15. package/lib/commonjs/components/views/status-view.js.map +1 -0
  16. package/lib/commonjs/components/views/transfer-view.js +162 -0
  17. package/lib/commonjs/components/views/transfer-view.js.map +1 -0
  18. package/lib/commonjs/components/views/verify-view.js +98 -0
  19. package/lib/commonjs/components/views/verify-view.js.map +1 -0
  20. package/lib/commonjs/hooks/use-countdown.js +24 -0
  21. package/lib/commonjs/hooks/use-countdown.js.map +1 -0
  22. package/lib/commonjs/index.js +13 -0
  23. package/lib/commonjs/index.js.map +1 -0
  24. package/lib/commonjs/internal/constants.js +42 -0
  25. package/lib/commonjs/internal/constants.js.map +1 -0
  26. package/lib/commonjs/internal/format.js +29 -0
  27. package/lib/commonjs/internal/format.js.map +1 -0
  28. package/lib/commonjs/internal/theme.js +24 -0
  29. package/lib/commonjs/internal/theme.js.map +1 -0
  30. package/lib/commonjs/package.json +1 -0
  31. package/lib/commonjs/types.js +6 -0
  32. package/lib/commonjs/types.js.map +1 -0
  33. package/lib/module/api/client.js +67 -0
  34. package/lib/module/api/client.js.map +1 -0
  35. package/lib/module/api/endpoints.js +7 -0
  36. package/lib/module/api/endpoints.js.map +1 -0
  37. package/lib/module/components/bottom-sheet.js +133 -0
  38. package/lib/module/components/bottom-sheet.js.map +1 -0
  39. package/lib/module/components/icons.js +120 -0
  40. package/lib/module/components/icons.js.map +1 -0
  41. package/lib/module/components/payment-button.js +103 -0
  42. package/lib/module/components/payment-button.js.map +1 -0
  43. package/lib/module/components/payment-sheet.js +152 -0
  44. package/lib/module/components/payment-sheet.js.map +1 -0
  45. package/lib/module/components/views/status-view.js +105 -0
  46. package/lib/module/components/views/status-view.js.map +1 -0
  47. package/lib/module/components/views/transfer-view.js +157 -0
  48. package/lib/module/components/views/transfer-view.js.map +1 -0
  49. package/lib/module/components/views/verify-view.js +94 -0
  50. package/lib/module/components/views/verify-view.js.map +1 -0
  51. package/lib/module/hooks/use-countdown.js +21 -0
  52. package/lib/module/hooks/use-countdown.js.map +1 -0
  53. package/lib/module/index.js +4 -0
  54. package/lib/module/index.js.map +1 -0
  55. package/lib/module/internal/constants.js +37 -0
  56. package/lib/module/internal/constants.js.map +1 -0
  57. package/lib/module/internal/format.js +24 -0
  58. package/lib/module/internal/format.js.map +1 -0
  59. package/lib/module/internal/theme.js +21 -0
  60. package/lib/module/internal/theme.js.map +1 -0
  61. package/lib/module/package.json +1 -0
  62. package/lib/module/types.js +4 -0
  63. package/lib/module/types.js.map +1 -0
  64. package/lib/typescript/api/client.d.ts +17 -0
  65. package/lib/typescript/api/client.d.ts.map +1 -0
  66. package/lib/typescript/api/endpoints.d.ts +5 -0
  67. package/lib/typescript/api/endpoints.d.ts.map +1 -0
  68. package/lib/typescript/components/bottom-sheet.d.ts +18 -0
  69. package/lib/typescript/components/bottom-sheet.d.ts.map +1 -0
  70. package/lib/typescript/components/icons.d.ts +12 -0
  71. package/lib/typescript/components/icons.d.ts.map +1 -0
  72. package/lib/typescript/components/payment-button.d.ts +7 -0
  73. package/lib/typescript/components/payment-button.d.ts.map +1 -0
  74. package/lib/typescript/components/payment-sheet.d.ts +17 -0
  75. package/lib/typescript/components/payment-sheet.d.ts.map +1 -0
  76. package/lib/typescript/components/views/status-view.d.ts +12 -0
  77. package/lib/typescript/components/views/status-view.d.ts.map +1 -0
  78. package/lib/typescript/components/views/transfer-view.d.ts +13 -0
  79. package/lib/typescript/components/views/transfer-view.d.ts.map +1 -0
  80. package/lib/typescript/components/views/verify-view.d.ts +10 -0
  81. package/lib/typescript/components/views/verify-view.d.ts.map +1 -0
  82. package/lib/typescript/hooks/use-countdown.d.ts +8 -0
  83. package/lib/typescript/hooks/use-countdown.d.ts.map +1 -0
  84. package/lib/typescript/index.d.ts +3 -0
  85. package/lib/typescript/index.d.ts.map +1 -0
  86. package/lib/typescript/internal/constants.d.ts +20 -0
  87. package/lib/typescript/internal/constants.d.ts.map +1 -0
  88. package/lib/typescript/internal/format.d.ts +7 -0
  89. package/lib/typescript/internal/format.d.ts.map +1 -0
  90. package/lib/typescript/internal/theme.d.ts +13 -0
  91. package/lib/typescript/internal/theme.d.ts.map +1 -0
  92. package/lib/typescript/types.d.ts +67 -0
  93. package/lib/typescript/types.d.ts.map +1 -0
  94. package/package.json +70 -0
  95. package/src/api/client.ts +76 -0
  96. package/src/api/endpoints.ts +4 -0
  97. package/src/components/bottom-sheet.tsx +144 -0
  98. package/src/components/icons.tsx +90 -0
  99. package/src/components/payment-button.tsx +112 -0
  100. package/src/components/payment-sheet.tsx +149 -0
  101. package/src/components/views/status-view.tsx +61 -0
  102. package/src/components/views/transfer-view.tsx +112 -0
  103. package/src/components/views/verify-view.tsx +69 -0
  104. package/src/hooks/use-countdown.ts +20 -0
  105. package/src/index.ts +10 -0
  106. package/src/internal/constants.ts +34 -0
  107. package/src/internal/format.ts +21 -0
  108. package/src/internal/theme.ts +28 -0
  109. 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
+ });