@clockpay/react-native 1.0.0 → 1.0.1

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 (50) hide show
  1. package/README.md +50 -13
  2. package/lib/commonjs/api/client.js +39 -0
  3. package/lib/commonjs/api/client.js.map +1 -1
  4. package/lib/commonjs/api/endpoints.js +3 -0
  5. package/lib/commonjs/api/endpoints.js.map +1 -1
  6. package/lib/commonjs/components/icons.js +19 -0
  7. package/lib/commonjs/components/icons.js.map +1 -1
  8. package/lib/commonjs/components/payment-button.js +53 -39
  9. package/lib/commonjs/components/payment-button.js.map +1 -1
  10. package/lib/commonjs/components/selection-sheet.js +393 -0
  11. package/lib/commonjs/components/selection-sheet.js.map +1 -0
  12. package/lib/commonjs/internal/format.js +11 -0
  13. package/lib/commonjs/internal/format.js.map +1 -1
  14. package/lib/module/api/client.js +39 -0
  15. package/lib/module/api/client.js.map +1 -1
  16. package/lib/module/api/endpoints.js +3 -0
  17. package/lib/module/api/endpoints.js.map +1 -1
  18. package/lib/module/components/icons.js +18 -0
  19. package/lib/module/components/icons.js.map +1 -1
  20. package/lib/module/components/payment-button.js +56 -42
  21. package/lib/module/components/payment-button.js.map +1 -1
  22. package/lib/module/components/selection-sheet.js +391 -0
  23. package/lib/module/components/selection-sheet.js.map +1 -0
  24. package/lib/module/internal/format.js +10 -0
  25. package/lib/module/internal/format.js.map +1 -1
  26. package/lib/typescript/api/client.d.ts +7 -1
  27. package/lib/typescript/api/client.d.ts.map +1 -1
  28. package/lib/typescript/api/endpoints.d.ts +3 -0
  29. package/lib/typescript/api/endpoints.d.ts.map +1 -1
  30. package/lib/typescript/components/icons.d.ts +1 -0
  31. package/lib/typescript/components/icons.d.ts.map +1 -1
  32. package/lib/typescript/components/payment-button.d.ts +3 -2
  33. package/lib/typescript/components/payment-button.d.ts.map +1 -1
  34. package/lib/typescript/components/selection-sheet.d.ts +25 -0
  35. package/lib/typescript/components/selection-sheet.d.ts.map +1 -0
  36. package/lib/typescript/index.d.ts +1 -1
  37. package/lib/typescript/index.d.ts.map +1 -1
  38. package/lib/typescript/internal/format.d.ts +2 -0
  39. package/lib/typescript/internal/format.d.ts.map +1 -1
  40. package/lib/typescript/types.d.ts +65 -2
  41. package/lib/typescript/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/api/client.ts +45 -0
  44. package/src/api/endpoints.ts +3 -0
  45. package/src/components/icons.tsx +14 -0
  46. package/src/components/payment-button.tsx +54 -38
  47. package/src/components/selection-sheet.tsx +383 -0
  48. package/src/index.ts +10 -0
  49. package/src/internal/format.ts +8 -0
  50. package/src/types.ts +75 -2
@@ -0,0 +1,383 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ } from 'react-native';
11
+ import { SvgUri } from 'react-native-svg';
12
+ import type {
13
+ CheckoutCurrency,
14
+ CheckoutData,
15
+ CheckoutMeta,
16
+ Coin,
17
+ Network,
18
+ } from '../types';
19
+ import type { ResolvedTheme } from '../internal/theme';
20
+ import { ClockPayClient } from '../api/client';
21
+ import { formatMoney } from '../internal/format';
22
+ import { BottomSheet } from './bottom-sheet';
23
+ import { ChevronDownIcon, CloseIcon, LogoIcon } from './icons';
24
+
25
+ /** Renders a coin's remote SVG logo. Falls back to a neutral placeholder circle. */
26
+ function CoinLogo({ uri }: { uri?: string }) {
27
+ if (!uri) return null;
28
+ return (
29
+ <View style={styles.logo}>
30
+ <SvgUri width={20} height={20} uri={uri} />
31
+ </View>
32
+ );
33
+ }
34
+
35
+ interface SelectionSheetProps {
36
+ visible: boolean;
37
+ publicKey: string;
38
+ apiBaseUrl?: string;
39
+ theme: ResolvedTheme;
40
+ amount: number;
41
+ currency: CheckoutCurrency;
42
+ reference: string;
43
+ meta: CheckoutMeta;
44
+ onRequestClose: () => void;
45
+ onClosed: () => void;
46
+ onProceed: (checkout: CheckoutData) => void;
47
+ onError?: (error: Error) => void;
48
+ }
49
+
50
+ interface Option {
51
+ id: string;
52
+ label: string;
53
+ image?: string;
54
+ }
55
+
56
+ interface DropdownProps {
57
+ label: string;
58
+ placeholder: string;
59
+ value: string;
60
+ options: Option[];
61
+ disabled?: boolean;
62
+ loading?: boolean;
63
+ theme: ResolvedTheme;
64
+ onSelect: (id: string) => void;
65
+ }
66
+
67
+ /** A lightweight inline dropdown — taps to expand a list of options in place. */
68
+ function Dropdown({
69
+ label,
70
+ placeholder,
71
+ value,
72
+ options,
73
+ disabled,
74
+ loading,
75
+ theme,
76
+ onSelect,
77
+ }: DropdownProps) {
78
+ const [open, setOpen] = useState(false);
79
+ const selected = options.find((o) => o.id === value);
80
+
81
+ return (
82
+ <View style={[styles.field, open && styles.fieldOpen]}>
83
+ <Text style={styles.label}>
84
+ {label} <Text style={styles.req}>*</Text>
85
+ </Text>
86
+ <TouchableOpacity
87
+ activeOpacity={0.7}
88
+ disabled={disabled}
89
+ style={[styles.select, disabled && styles.selectDisabled]}
90
+ onPress={() => setOpen((o) => !o)}
91
+ >
92
+ <View style={styles.selectValue}>
93
+ {selected?.image && <CoinLogo uri={selected.image} />}
94
+ <Text style={[styles.selectText, !selected && styles.placeholder]}>
95
+ {loading ? 'Loading…' : selected ? selected.label : placeholder}
96
+ </Text>
97
+ </View>
98
+ {loading ? (
99
+ <ActivityIndicator size='small' color='#94a3b8' />
100
+ ) : (
101
+ <ChevronDownIcon />
102
+ )}
103
+ </TouchableOpacity>
104
+
105
+ {open && !disabled && (
106
+ <View style={styles.options}>
107
+ {options.map((o) => (
108
+ <TouchableOpacity
109
+ key={o.id}
110
+ style={styles.option}
111
+ onPress={() => {
112
+ onSelect(o.id);
113
+ setOpen(false);
114
+ }}
115
+ >
116
+ {o.image && <CoinLogo uri={o.image} />}
117
+ <Text
118
+ style={[
119
+ styles.optionText,
120
+ o.id === value && { color: theme.accent, fontWeight: '600' },
121
+ ]}
122
+ >
123
+ {o.label}
124
+ </Text>
125
+ </TouchableOpacity>
126
+ ))}
127
+ </View>
128
+ )}
129
+ </View>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * First step of the payment flow: the buyer picks a coin and then a settlement
135
+ * network. On continue, a payment link is generated (`create-link`) and the
136
+ * resulting checkout initiated, handing a {@link CheckoutData} to the caller so
137
+ * the payment sheet can open.
138
+ */
139
+ export function SelectionSheet({
140
+ visible,
141
+ publicKey,
142
+ apiBaseUrl,
143
+ theme,
144
+ amount,
145
+ currency,
146
+ reference,
147
+ meta,
148
+ onRequestClose,
149
+ onClosed,
150
+ onProceed,
151
+ onError,
152
+ }: SelectionSheetProps) {
153
+ const client = useMemo(
154
+ () => new ClockPayClient(publicKey, apiBaseUrl),
155
+ [publicKey, apiBaseUrl]
156
+ );
157
+ const [coins, setCoins] = useState<Coin[]>([]);
158
+ const [networks, setNetworks] = useState<Network[]>([]);
159
+ const [coinId, setCoinId] = useState('');
160
+ const [networkId, setNetworkId] = useState('');
161
+ const [loadingCoins, setLoadingCoins] = useState(false);
162
+ const [loadingNetworks, setLoadingNetworks] = useState(false);
163
+ const [submitting, setSubmitting] = useState(false);
164
+ const [error, setError] = useState<string | null>(null);
165
+
166
+ // Load the available coins once the sheet opens.
167
+ useEffect(() => {
168
+ if (!visible) return;
169
+ let active = true;
170
+ setLoadingCoins(true);
171
+ setError(null);
172
+ client
173
+ .getCoins()
174
+ .then((list) => active && setCoins(list))
175
+ .catch((e: Error) => active && setError(e.message))
176
+ .finally(() => active && setLoadingCoins(false));
177
+ return () => {
178
+ active = false;
179
+ };
180
+ }, [visible, client]);
181
+
182
+ // Load the networks for the selected coin.
183
+ useEffect(() => {
184
+ setNetworks([]);
185
+ setNetworkId('');
186
+ if (!coinId) return;
187
+
188
+ let active = true;
189
+ setLoadingNetworks(true);
190
+ setError(null);
191
+ client
192
+ .getNetworks(coinId)
193
+ .then((list) => active && setNetworks(list))
194
+ .catch((e: Error) => active && setError(e.message))
195
+ .finally(() => active && setLoadingNetworks(false));
196
+ return () => {
197
+ active = false;
198
+ };
199
+ }, [coinId, client]);
200
+
201
+ const canSubmit = Boolean(coinId && networkId && !submitting);
202
+
203
+ const proceed = async () => {
204
+ if (!canSubmit) return;
205
+ setSubmitting(true);
206
+ setError(null);
207
+ try {
208
+ const link = await client.createLink({
209
+ amount,
210
+ currency,
211
+ coinId,
212
+ networkId,
213
+ reference,
214
+ meta,
215
+ });
216
+ const response = await client.initiatePayment(link.clientSecret);
217
+ onProceed(response.data);
218
+ } catch (e) {
219
+ const err = e instanceof Error ? e : new Error('Failed to start checkout.');
220
+ setError(err.message);
221
+ onError?.(err);
222
+ } finally {
223
+ setSubmitting(false);
224
+ }
225
+ };
226
+
227
+ const coinOptions = coins.map((c) => ({
228
+ id: c.id,
229
+ label: c.name.toUpperCase(),
230
+ image: c.image,
231
+ }));
232
+ const networkOptions = networks.map((n) => ({
233
+ id: n.id,
234
+ label: n.name.toUpperCase(),
235
+ }));
236
+
237
+ return (
238
+ <BottomSheet
239
+ visible={visible}
240
+ onClose={onRequestClose}
241
+ onClosed={onClosed}
242
+ closeOnBackdropPress={false}
243
+ >
244
+ <View style={styles.header}>
245
+ <LogoIcon size={18} />
246
+ <Text style={[styles.title, { color: theme.accent }]}>Pay with Clockpay</Text>
247
+ <Pressable
248
+ onPress={onRequestClose}
249
+ style={styles.cancel}
250
+ hitSlop={8}
251
+ accessibilityRole='button'
252
+ accessibilityLabel='Cancel'
253
+ >
254
+ <CloseIcon size={22} />
255
+ </Pressable>
256
+ </View>
257
+
258
+ <ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
259
+ <View style={styles.card}>
260
+ <Text style={styles.muted}>You are paying</Text>
261
+ <Text style={styles.amount}>{formatMoney(amount, currency)}</Text>
262
+ </View>
263
+
264
+ <Dropdown
265
+ label='Select coin'
266
+ placeholder='Select a coin'
267
+ value={coinId}
268
+ options={coinOptions}
269
+ disabled={submitting}
270
+ loading={loadingCoins}
271
+ theme={theme}
272
+ onSelect={setCoinId}
273
+ />
274
+
275
+ <Dropdown
276
+ label='Select preferred network'
277
+ placeholder={!coinId ? 'Select a coin first' : 'Select a network'}
278
+ value={networkId}
279
+ options={networkOptions}
280
+ disabled={!coinId || submitting}
281
+ loading={loadingNetworks}
282
+ theme={theme}
283
+ onSelect={setNetworkId}
284
+ />
285
+
286
+ {error && <Text style={styles.error}>{error}</Text>}
287
+
288
+ <TouchableOpacity
289
+ style={[
290
+ styles.primaryBtn,
291
+ { backgroundColor: canSubmit ? theme.accent : '#dff0f7', borderRadius: theme.radius },
292
+ ]}
293
+ disabled={!canSubmit}
294
+ onPress={proceed}
295
+ >
296
+ {submitting ? (
297
+ <ActivityIndicator size='small' color='#fff' />
298
+ ) : (
299
+ <Text style={[styles.primaryBtnText, { color: canSubmit ? '#fff' : '#9ca3af' }]}>
300
+ Continue
301
+ </Text>
302
+ )}
303
+ </TouchableOpacity>
304
+ </ScrollView>
305
+ </BottomSheet>
306
+ );
307
+ }
308
+
309
+ const styles = StyleSheet.create({
310
+ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingBottom: 16 },
311
+ title: { fontSize: 16, fontWeight: '700' },
312
+ cancel: { position: 'absolute', right: 0, top: -2, padding: 4 },
313
+ card: {
314
+ borderWidth: 1,
315
+ borderColor: '#edf1f6',
316
+ borderRadius: 16,
317
+ padding: 20,
318
+ alignItems: 'center',
319
+ marginBottom: 20,
320
+ },
321
+ muted: { fontSize: 13, color: '#94a3b8' },
322
+ amount: { fontSize: 24, fontWeight: '700', color: '#000', marginTop: 4 },
323
+ field: { marginBottom: 16, position: 'relative' },
324
+ // Lift the open dropdown (and its overlay list) above the fields below it.
325
+ fieldOpen: { zIndex: 20, elevation: 20 },
326
+ label: { fontSize: 14, fontWeight: '500', color: '#1e1e1e', marginBottom: 6 },
327
+ req: { color: '#fb7185' },
328
+ select: {
329
+ flexDirection: 'row',
330
+ alignItems: 'center',
331
+ justifyContent: 'space-between',
332
+ borderWidth: 1,
333
+ borderColor: '#d9d9d9',
334
+ borderRadius: 8,
335
+ paddingVertical: 12,
336
+ paddingHorizontal: 12,
337
+ },
338
+ selectDisabled: { backgroundColor: '#f5f5f5' },
339
+ selectValue: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 1 },
340
+ selectText: { fontSize: 14, color: '#1e1e1e' },
341
+ placeholder: { color: '#9ca3af' },
342
+ logo: {
343
+ width: 20,
344
+ height: 20,
345
+ borderRadius: 10,
346
+ overflow: 'hidden',
347
+ backgroundColor: '#f0f2f5',
348
+ alignItems: 'center',
349
+ justifyContent: 'center',
350
+ },
351
+ options: {
352
+ position: 'absolute',
353
+ top: '100%',
354
+ left: 0,
355
+ right: 0,
356
+ marginTop: 6,
357
+ zIndex: 20,
358
+ elevation: 8,
359
+ backgroundColor: '#ffffff',
360
+ borderWidth: 1,
361
+ borderColor: '#edf1f6',
362
+ borderRadius: 8,
363
+ maxHeight: 220,
364
+ overflow: 'hidden',
365
+ shadowColor: '#0f172a',
366
+ shadowOpacity: 0.18,
367
+ shadowRadius: 12,
368
+ shadowOffset: { width: 0, height: 6 },
369
+ },
370
+ option: {
371
+ flexDirection: 'row',
372
+ alignItems: 'center',
373
+ gap: 8,
374
+ paddingVertical: 12,
375
+ paddingHorizontal: 12,
376
+ borderBottomWidth: 1,
377
+ borderBottomColor: '#f1f5f9',
378
+ },
379
+ optionText: { fontSize: 14, color: '#1e1e1e' },
380
+ error: { color: '#ef4444', fontSize: 13, marginBottom: 12 },
381
+ primaryBtn: { width: '100%', paddingVertical: 15, alignItems: 'center', marginTop: 4 },
382
+ primaryBtnText: { fontSize: 16, fontWeight: '600' },
383
+ });
package/src/index.ts CHANGED
@@ -3,8 +3,18 @@ export type {
3
3
  PaymentButtonProps,
4
4
  ClockPayAppearance,
5
5
  ClockPayEnvironment,
6
+ CheckoutCurrency,
7
+ CheckoutMeta,
8
+ CheckoutPhoneNumber,
6
9
  CheckoutData,
7
10
  CheckoutResponse,
11
+ Coin,
12
+ CoinsResponse,
13
+ Network,
14
+ NetworksResponse,
15
+ CreateLinkPayload,
16
+ CreateLinkData,
17
+ CreateLinkResponse,
8
18
  VerifyCryptoPayload,
9
19
  VerifyCryptoResponse,
10
20
  } from './types';
@@ -8,6 +8,14 @@ export function formatTime(totalSeconds: number): string {
8
8
  return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
9
9
  }
10
10
 
11
+ const CURRENCY_SYMBOLS: Record<string, string> = { ngn: '₦', usd: '$' };
12
+
13
+ /** Formats a fiat amount with its currency symbol, e.g. `formatMoney(20000, 'ngn')` → `₦20,000`. */
14
+ export function formatMoney(amount: number, currency: string): string {
15
+ const symbol = CURRENCY_SYMBOLS[currency?.toLowerCase()] ?? '';
16
+ return `${symbol}${amount.toLocaleString()}`;
17
+ }
18
+
11
19
  /** Resolves the on-chain code used to verify a transaction for a given checkout. */
12
20
  export function resolveNetworkCode(network?: string, fallback?: string): string {
13
21
  const key = network?.toLowerCase() ?? '';
package/src/types.ts CHANGED
@@ -22,6 +22,73 @@ export interface ClockPayAppearance {
22
22
  /** The environment a public key belongs to. */
23
23
  export type ClockPayEnvironment = 'test' | 'live';
24
24
 
25
+ /** Fiat currency the order amount is denominated in. */
26
+ export type CheckoutCurrency = 'ngn' | 'usd';
27
+
28
+ /** A coin the buyer can pay with, from `GET /wallet/checkout/coins`. */
29
+ export interface Coin {
30
+ id: string;
31
+ name: string;
32
+ image: string;
33
+ code: string;
34
+ }
35
+
36
+ /** A settlement network for a coin, from `GET /wallet/checkout/networks/{coinId}`. */
37
+ export interface Network {
38
+ id: string;
39
+ tag: string;
40
+ code: string;
41
+ name: string;
42
+ }
43
+
44
+ export interface CoinsResponse {
45
+ data: Coin[];
46
+ }
47
+
48
+ export interface NetworksResponse {
49
+ data: Network[];
50
+ }
51
+
52
+ /** Buyer's phone number, split into dialling code and local number. */
53
+ export interface CheckoutPhoneNumber {
54
+ code: string;
55
+ number: string;
56
+ }
57
+
58
+ /** Order/buyer details the business provides for the payment link. */
59
+ export interface CheckoutMeta {
60
+ title: string;
61
+ description: string;
62
+ /** Buyer email. Optional. */
63
+ email?: string;
64
+ /** Business's own id for the buyer. Optional. */
65
+ customerId?: string;
66
+ fullName: string;
67
+ /** Buyer phone number. Optional. */
68
+ phoneNumber?: CheckoutPhoneNumber;
69
+ }
70
+
71
+ /** Body for `POST /payment/client/create-link`. */
72
+ export interface CreateLinkPayload {
73
+ amount: number;
74
+ currency: CheckoutCurrency;
75
+ coinId: string;
76
+ networkId: string;
77
+ reference: string;
78
+ meta: CheckoutMeta;
79
+ }
80
+
81
+ /** The generated payment link — its reference and single-use client secret. */
82
+ export interface CreateLinkData {
83
+ reference: string;
84
+ clientSecret: string;
85
+ }
86
+
87
+ export interface CreateLinkResponse {
88
+ message: string;
89
+ data: CreateLinkData;
90
+ }
91
+
25
92
  /** Payload returned by the checkout endpoint and rendered inside the sheet. */
26
93
  export interface CheckoutData {
27
94
  reference: string;
@@ -52,8 +119,14 @@ export interface VerifyCryptoResponse {
52
119
  export interface PaymentButtonProps {
53
120
  /** Public key from the business developer dashboard (e.g. `cpay_test_pk_...`). Required. */
54
121
  publicKey: string;
55
- /** Client secret returned from your server-side checkout call. Required. */
56
- clientSecret: string;
122
+ /** Order amount in the given `currency`. Required. */
123
+ amount: number;
124
+ /** Fiat currency of the `amount`. Required. */
125
+ currency: CheckoutCurrency;
126
+ /** Your own reference for this order. Required. */
127
+ reference: string;
128
+ /** Order and buyer details. The business supplies these; the buyer only picks a coin & network. Required. */
129
+ meta: CheckoutMeta;
57
130
  /** Visual customization for the button and sheet. */
58
131
  appearance?: ClockPayAppearance;
59
132
  /** Button label. Defaults to "Pay with Clockpay". */