@cosmoola/cosmoola-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 +119 -0
- package/package.json +25 -0
- package/src/index.js +539 -0
- package/types/index.d.ts +156 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @cosmoola/cosmoola-native
|
|
2
|
+
|
|
3
|
+
React Native SDK for Cosmoola — payments + currency converter.
|
|
4
|
+
Works with Expo and bare React Native (iOS + Android).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @cosmoola/cosmoola-native @react-native-async-storage/async-storage
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
If bare RN (not Expo):
|
|
13
|
+
```bash
|
|
14
|
+
cd ios && pod install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Currency Converter
|
|
20
|
+
|
|
21
|
+
### 1. Wrap your app ONCE in App.js
|
|
22
|
+
|
|
23
|
+
```jsx
|
|
24
|
+
import { CurrencyProvider } from "@cosmoola/cosmoola-native";
|
|
25
|
+
|
|
26
|
+
export default function App() {
|
|
27
|
+
return (
|
|
28
|
+
<CurrencyProvider apiKey="pk_live_xxx">
|
|
29
|
+
<NavigationContainer>
|
|
30
|
+
<RootNavigator />
|
|
31
|
+
</NavigationContainer>
|
|
32
|
+
</CurrencyProvider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Use anywhere in any screen
|
|
38
|
+
|
|
39
|
+
```jsx
|
|
40
|
+
import { useCurrency, CsmPrice } from "@cosmoola/cosmoola-native";
|
|
41
|
+
import { Text, View } from "react-native";
|
|
42
|
+
|
|
43
|
+
// Hook
|
|
44
|
+
function ProductScreen() {
|
|
45
|
+
const { convert, currency, setCurrency } = useCurrency();
|
|
46
|
+
return (
|
|
47
|
+
<View>
|
|
48
|
+
<Text>{convert(99.99)}</Text> // → "£79.23"
|
|
49
|
+
<Text>{convert(99.99, "USD")}</Text> // explicit base
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Drop-in component
|
|
55
|
+
function PriceTag({ amount }) {
|
|
56
|
+
return <CsmPrice amount={amount} from="USD" style={{ fontSize: 18, fontWeight: "bold" }} />;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Payments
|
|
63
|
+
|
|
64
|
+
```jsx
|
|
65
|
+
import CosmoolaNative, { useMobilePayment } from "@cosmoola/cosmoola-native";
|
|
66
|
+
|
|
67
|
+
const cosmoola = new CosmoolaNative("pk_live_xxx");
|
|
68
|
+
|
|
69
|
+
// Card payment
|
|
70
|
+
const { paymentIntent, error } = await cosmoola.confirmCardPayment(clientSecret, {
|
|
71
|
+
payment_method: {
|
|
72
|
+
card: { number: "4242424242424242", exp_month: "12", exp_year: "26", cvc: "123" }
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Mobile Money hook
|
|
77
|
+
function PayScreen() {
|
|
78
|
+
const { initiate, status, error } = useMobilePayment("pk_live_xxx");
|
|
79
|
+
|
|
80
|
+
const pay = async () => {
|
|
81
|
+
await initiate({
|
|
82
|
+
clientSecret: "pi_xxx_secret_xxx",
|
|
83
|
+
phone: "+254712345678",
|
|
84
|
+
country: "KE",
|
|
85
|
+
amount: 500,
|
|
86
|
+
currency: "KES",
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Button title={status === "polling" ? "Waiting..." : "Pay with M-Pesa"} onPress={pay} />
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Folder structure
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
sdk/
|
|
102
|
+
cosmoola-js/ ← Web browser SDK
|
|
103
|
+
cosmoola-node/ ← Node.js server SDK
|
|
104
|
+
cosmoola-react/ ← React web SDK (hooks + components)
|
|
105
|
+
cosmoola-native/ ← React Native SDK (this package)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Differences from cosmoola-react
|
|
111
|
+
|
|
112
|
+
| Feature | cosmoola-react | cosmoola-native |
|
|
113
|
+
|---------|---------------|-----------------|
|
|
114
|
+
| Cache | localStorage | AsyncStorage |
|
|
115
|
+
| Country detect | ipapi.co + navigator.language | ipapi.co |
|
|
116
|
+
| Price component | `<CsmPrice />` (web) | `<CsmPrice />` (RN Text) |
|
|
117
|
+
| Currency switcher | `<CsmSwitcher />` | Build your own with `allCurrencies` |
|
|
118
|
+
| Payment elements | DOM-based CardElement | Use your own RN inputs |
|
|
119
|
+
| No-build | ✓ | ✓ |
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmoola/cosmoola-native",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cosmoola React Native SDK — payments + currency converter",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"module": "src/index.js",
|
|
7
|
+
"types": "types/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"types"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"cosmoola",
|
|
14
|
+
"payments",
|
|
15
|
+
"react-native",
|
|
16
|
+
"currency",
|
|
17
|
+
"mobile-money"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@react-native-async-storage/async-storage": "^3.0.2",
|
|
21
|
+
"react": ">=17.0.0",
|
|
22
|
+
"react-native": ">=0.70.0"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT"
|
|
25
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @cosmoola/cosmoola-native — React Native SDK v1.0.0
|
|
3
|
+
* ==========================================================
|
|
4
|
+
* Cosmoola SDK for React Native (Expo + bare workflow).
|
|
5
|
+
*
|
|
6
|
+
* USAGE:
|
|
7
|
+
*
|
|
8
|
+
* import { CurrencyProvider, useCurrency, CsmPrice } from "@cosmoola/cosmoola-native";
|
|
9
|
+
*
|
|
10
|
+
* // Wrap your app once in App.js
|
|
11
|
+
* <CurrencyProvider apiKey="pk_live_xxx">
|
|
12
|
+
* <App />
|
|
13
|
+
* </CurrencyProvider>
|
|
14
|
+
*
|
|
15
|
+
* // Use anywhere
|
|
16
|
+
* const { convert, currency, setCurrency } = useCurrency();
|
|
17
|
+
* <Text>{convert(99.99)}</Text>
|
|
18
|
+
*
|
|
19
|
+
* // Drop-in price component
|
|
20
|
+
* <CsmPrice amount={99.99} from="USD" />
|
|
21
|
+
*
|
|
22
|
+
* Payment methods (same as cosmoola-js):
|
|
23
|
+
* const { paymentIntent, error } = await cosmoola.confirmCardPayment(clientSecret, { ... });
|
|
24
|
+
* await cosmoola.confirmMobileMoneyPayment(clientSecret, { phone, country });
|
|
25
|
+
*
|
|
26
|
+
* No DOM, no localStorage — fully React Native compatible.
|
|
27
|
+
* ==========================================================
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import React, {
|
|
31
|
+
createContext,
|
|
32
|
+
useContext,
|
|
33
|
+
useEffect,
|
|
34
|
+
useState,
|
|
35
|
+
useCallback,
|
|
36
|
+
useRef,
|
|
37
|
+
} from "react";
|
|
38
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
39
|
+
import { Text } from "react-native";
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// CONSTANTS
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const API_BASE = "https://cosmoola.com/api";
|
|
46
|
+
const CURRENCY_API_BASE = "https://cosmoola.com/currency";
|
|
47
|
+
const SDK_VERSION = "1.0.0";
|
|
48
|
+
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
// CACHE — AsyncStorage instead of localStorage
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const CACHE_KEY = "csm_rates_v1";
|
|
54
|
+
const CACHE_TS_KEY = "csm_rates_ts";
|
|
55
|
+
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
56
|
+
|
|
57
|
+
async function _cacheGet() {
|
|
58
|
+
try {
|
|
59
|
+
const ts = parseInt((await AsyncStorage.getItem(CACHE_TS_KEY)) || "0", 10);
|
|
60
|
+
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
|
61
|
+
if (cached && Date.now() - ts < CACHE_TTL) return JSON.parse(cached);
|
|
62
|
+
} catch {}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function _cacheSet(data) {
|
|
67
|
+
try {
|
|
68
|
+
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
|
69
|
+
await AsyncStorage.setItem(CACHE_TS_KEY, String(Date.now()));
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
// COUNTRY DETECTION — IP based, no window/navigator needed
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async function _detectCountry() {
|
|
78
|
+
try {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
81
|
+
const res = await fetch("https://ipapi.co/json/", { signal: controller.signal });
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
const d = await res.json();
|
|
84
|
+
return d.country_code || null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// HTTP HELPERS — pure fetch, works in RN
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async function _apiCall(method, endpoint, key, body = null, base = API_BASE) {
|
|
95
|
+
try {
|
|
96
|
+
const opts = {
|
|
97
|
+
method,
|
|
98
|
+
headers: {
|
|
99
|
+
"Authorization": `Bearer ${key}`,
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
"Cosmoola-Version": SDK_VERSION,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
if (body && method !== "GET") opts.body = JSON.stringify(body);
|
|
105
|
+
const res = await fetch(`${base}/${endpoint}`, opts);
|
|
106
|
+
const data = await res.json();
|
|
107
|
+
if (!res.ok) return { error: data?.error || data };
|
|
108
|
+
return data;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { error: { message: e.message, type: "network_error" } };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const _post = (endpoint, key, body, base = API_BASE) =>
|
|
115
|
+
_apiCall("POST", endpoint, key, body, base);
|
|
116
|
+
|
|
117
|
+
const _get = (endpoint, key, params = {}, base = API_BASE) => {
|
|
118
|
+
const qs = Object.keys(params).length
|
|
119
|
+
? "?" + new URLSearchParams(params).toString()
|
|
120
|
+
: "";
|
|
121
|
+
return _apiCall("GET", `${endpoint}${qs}`, key, null, base);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// CURRENCY HELPERS
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function _convertRaw(amount, from, to, rates) {
|
|
129
|
+
if (!rates || from === to) return amount;
|
|
130
|
+
const fromRate = rates[from] || 1;
|
|
131
|
+
const toRate = rates[to] || 1;
|
|
132
|
+
return (amount / fromRate) * toRate;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _format(amount, currencyCode, symbols) {
|
|
136
|
+
const symbol = symbols?.[currencyCode] || currencyCode;
|
|
137
|
+
const noDecimals = new Set([
|
|
138
|
+
"JPY","KRW","IDR","VND","CLP","ISK","PYG","UGX","TZS",
|
|
139
|
+
"BIF","RWF","GNF","MGA","KMF","XAF","XOF","XPF","DJF",
|
|
140
|
+
]);
|
|
141
|
+
const decimals = noDecimals.has(currencyCode) ? 0 : 2;
|
|
142
|
+
const formatted = amount
|
|
143
|
+
.toFixed(decimals)
|
|
144
|
+
.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
145
|
+
return `${symbol}${formatted}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// MOBILE MONEY COUNTRIES
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const MOBILE_MONEY_COUNTRIES = {
|
|
153
|
+
KE: { provider:"safaricom", method:"mpesa", label:"M-Pesa", prefix:"254", currency:"KES" },
|
|
154
|
+
TZ: { provider:"vodacom", method:"mpesa", label:"M-Pesa", prefix:"255", currency:"TZS" },
|
|
155
|
+
NG: { provider:"mtn", method:"momo", label:"MTN MoMo", prefix:"234", currency:"NGN" },
|
|
156
|
+
GH: { provider:"mtn", method:"momo", label:"MTN MoMo", prefix:"233", currency:"GHS" },
|
|
157
|
+
UG: { provider:"airtel", method:"airtel", label:"Airtel Money", prefix:"256", currency:"UGX" },
|
|
158
|
+
RW: { provider:"mtn", method:"momo", label:"MTN MoMo", prefix:"250", currency:"RWF" },
|
|
159
|
+
ZM: { provider:"airtel", method:"airtel", label:"Airtel Money", prefix:"260", currency:"ZMW" },
|
|
160
|
+
MW: { provider:"airtel", method:"airtel", label:"Airtel Money", prefix:"265", currency:"MWK" },
|
|
161
|
+
SN: { provider:"orange", method:"orange", label:"Orange Money", prefix:"221", currency:"XOF" },
|
|
162
|
+
CI: { provider:"orange", method:"orange", label:"Orange Money", prefix:"225", currency:"XOF" },
|
|
163
|
+
CM: { provider:"mtn", method:"momo", label:"MTN MoMo", prefix:"237", currency:"XAF" },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
// CosmoolaNative — main class (mirrors cosmoola-js CosmooolaInstance)
|
|
168
|
+
// Use this if you want the imperative API without React hooks
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export class CosmoolaNative {
|
|
172
|
+
constructor(publishableKey, options = {}) {
|
|
173
|
+
if (!publishableKey) throw new Error("[Cosmoola] publishableKey is required.");
|
|
174
|
+
if (!publishableKey.startsWith("pk_live_") && !publishableKey.startsWith("pk_test_"))
|
|
175
|
+
throw new Error("[Cosmoola] Invalid key. Must start with pk_live_ or pk_test_");
|
|
176
|
+
|
|
177
|
+
this._key = publishableKey;
|
|
178
|
+
this._mode = publishableKey.includes("_test_") ? "test" : "live";
|
|
179
|
+
this._country = options.country || null;
|
|
180
|
+
this.currency = new NativeCurrency(publishableKey);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── confirmCardPayment ────────────────────────────────────────────────────
|
|
184
|
+
async confirmCardPayment(clientSecret, data = {}) {
|
|
185
|
+
try {
|
|
186
|
+
const intentId = _extractIntentId(clientSecret);
|
|
187
|
+
const res = await _post("confirmPaymentIntent", this._key, {
|
|
188
|
+
id: intentId,
|
|
189
|
+
payment_method: data.payment_method,
|
|
190
|
+
return_url: data.return_url || "",
|
|
191
|
+
});
|
|
192
|
+
if (res.error) return { error: res.error };
|
|
193
|
+
return { paymentIntent: res };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { error: { type: "api_error", message: e.message } };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── confirmMobileMoneyPayment ─────────────────────────────────────────────
|
|
200
|
+
async confirmMobileMoneyPayment(clientSecret, data = {}) {
|
|
201
|
+
try {
|
|
202
|
+
if (!data.phone) throw new Error("phone is required");
|
|
203
|
+
if (!data.country) throw new Error("country is required");
|
|
204
|
+
|
|
205
|
+
const country = data.country.toUpperCase();
|
|
206
|
+
const config = MOBILE_MONEY_COUNTRIES[country];
|
|
207
|
+
if (!config) return { error: { message: `Mobile money not supported for: ${country}` } };
|
|
208
|
+
|
|
209
|
+
const intentId = _extractIntentId(clientSecret);
|
|
210
|
+
const endpoint = {
|
|
211
|
+
mpesa: "initiateMpesa",
|
|
212
|
+
momo: "initiateMtnMomo",
|
|
213
|
+
airtel: "initiateAirtel",
|
|
214
|
+
orange: "initiateOrange",
|
|
215
|
+
}[config.method];
|
|
216
|
+
|
|
217
|
+
const res = await _post(endpoint, this._key, {
|
|
218
|
+
payment_intent_id: intentId,
|
|
219
|
+
phone: data.phone,
|
|
220
|
+
amount: data.amount,
|
|
221
|
+
currency: data.currency || config.currency,
|
|
222
|
+
country,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (res.error) return { error: res.error };
|
|
226
|
+
return { paymentIntent: { id: intentId, status: "processing" }, mobile_money: res };
|
|
227
|
+
} catch (e) {
|
|
228
|
+
return { error: { type: "api_error", message: e.message } };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── retrievePaymentIntent ─────────────────────────────────────────────────
|
|
233
|
+
async retrievePaymentIntent(clientSecret) {
|
|
234
|
+
try {
|
|
235
|
+
const intentId = _extractIntentId(clientSecret);
|
|
236
|
+
const res = await _get("getPaymentStatus", this._key, { id: intentId });
|
|
237
|
+
if (res.error) return { error: res.error };
|
|
238
|
+
return { paymentIntent: res };
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return { error: { type: "api_error", message: e.message } };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── pollPaymentStatus ─────────────────────────────────────────────────────
|
|
245
|
+
async pollPaymentStatus(clientSecret, options = {}) {
|
|
246
|
+
const { interval = 3000, maxChecks = 40, onUpdate } = options;
|
|
247
|
+
const TERMINAL = ["succeeded", "canceled", "failed"];
|
|
248
|
+
let count = 0;
|
|
249
|
+
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
const timer = setInterval(async () => {
|
|
252
|
+
count++;
|
|
253
|
+
const { paymentIntent, error } = await this.retrievePaymentIntent(clientSecret);
|
|
254
|
+
if (error) { clearInterval(timer); resolve({ error }); return; }
|
|
255
|
+
if (onUpdate) onUpdate(paymentIntent);
|
|
256
|
+
if (TERMINAL.includes(paymentIntent.status)) {
|
|
257
|
+
clearInterval(timer);
|
|
258
|
+
resolve({ paymentIntent });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (count >= maxChecks) {
|
|
262
|
+
clearInterval(timer);
|
|
263
|
+
resolve({ error: { type: "timeout_error", message: "Payment polling timed out." } });
|
|
264
|
+
}
|
|
265
|
+
}, interval);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getPublishableKey() { return this._key; }
|
|
270
|
+
getMode() { return this._mode; }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
// NativeCurrency — currency methods for React Native
|
|
275
|
+
// Accessed via cosmoola.currency or via useCurrency() hook
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
class NativeCurrency {
|
|
279
|
+
constructor(key) {
|
|
280
|
+
this._key = key;
|
|
281
|
+
this._rates = null;
|
|
282
|
+
this._symbols = {};
|
|
283
|
+
this._names = {};
|
|
284
|
+
this._countryMap = {};
|
|
285
|
+
this._active = "USD";
|
|
286
|
+
this._readyPromise = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async init(country = null) {
|
|
290
|
+
if (this._readyPromise) return this._readyPromise;
|
|
291
|
+
this._readyPromise = (async () => {
|
|
292
|
+
const cached = await _cacheGet();
|
|
293
|
+
if (cached) {
|
|
294
|
+
this._rates = cached.rates;
|
|
295
|
+
this._symbols = cached.symbols || {};
|
|
296
|
+
this._names = cached.names || {};
|
|
297
|
+
this._countryMap = cached.countryMap || {};
|
|
298
|
+
this._active = cached.detected || "USD";
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const detectedCountry = country || await _detectCountry();
|
|
302
|
+
const params = detectedCountry ? { country: detectedCountry } : {};
|
|
303
|
+
const data = await _get("getCurrencyRates", this._key, params, CURRENCY_API_BASE);
|
|
304
|
+
if (data.error) { console.warn("[Cosmoola] Currency init failed:", data.error.message); return; }
|
|
305
|
+
this._rates = data.rates;
|
|
306
|
+
this._symbols = data.symbols || {};
|
|
307
|
+
this._names = data.names || {};
|
|
308
|
+
this._countryMap = data.countryMap || {};
|
|
309
|
+
this._active = data.detected || "USD";
|
|
310
|
+
await _cacheSet(data);
|
|
311
|
+
})();
|
|
312
|
+
return this._readyPromise;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async getRates(country = null) {
|
|
316
|
+
await this.init(country);
|
|
317
|
+
return { rates: this._rates, symbols: this._symbols, names: this._names, countryMap: this._countryMap, detected: this._active, base: "USD" };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async convert(amount, from = "USD", to = null) {
|
|
321
|
+
await this.init();
|
|
322
|
+
const target = to || this._active || "USD";
|
|
323
|
+
return _format(_convertRaw(amount, from, target, this._rates || {}), target, this._symbols);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async convertRaw(amount, from = "USD", to = null) {
|
|
327
|
+
await this.init();
|
|
328
|
+
const target = to || this._active || "USD";
|
|
329
|
+
return _convertRaw(amount, from, target, this._rates || {});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
setCurrency(code) { this._active = code.toUpperCase(); }
|
|
333
|
+
getActiveCurrency() { return this._active; }
|
|
334
|
+
getSymbol(code = null) { return this._symbols[code || this._active] || "$"; }
|
|
335
|
+
getAllCurrencies() {
|
|
336
|
+
if (!this._rates) return [];
|
|
337
|
+
return Object.keys(this._rates).map(code => ({
|
|
338
|
+
code, symbol: this._symbols[code] || code, name: this._names[code] || code, rate: this._rates[code],
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
// React Native Context + Provider
|
|
345
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
const CurrencyContext = createContext(null);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Wrap your entire app ONCE in App.js — works everywhere inside.
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* // App.js
|
|
354
|
+
* import { CurrencyProvider } from "@cosmoola/cosmoola-native";
|
|
355
|
+
*
|
|
356
|
+
* export default function App() {
|
|
357
|
+
* return (
|
|
358
|
+
* <CurrencyProvider apiKey="pk_live_xxx">
|
|
359
|
+
* <NavigationContainer>...</NavigationContainer>
|
|
360
|
+
* </CurrencyProvider>
|
|
361
|
+
* );
|
|
362
|
+
* }
|
|
363
|
+
*/
|
|
364
|
+
export function CurrencyProvider({ apiKey, defaultCurrency = "USD", country = null, children }) {
|
|
365
|
+
const [rates, setRates] = useState(null);
|
|
366
|
+
const [symbols, setSymbols] = useState({});
|
|
367
|
+
const [names, setNames] = useState({});
|
|
368
|
+
const [currency, setCurrency] = useState(defaultCurrency);
|
|
369
|
+
const [loading, setLoading] = useState(true);
|
|
370
|
+
const [error, setError] = useState(null);
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
async function init() {
|
|
374
|
+
try {
|
|
375
|
+
// Try AsyncStorage cache first
|
|
376
|
+
const cached = await _cacheGet();
|
|
377
|
+
if (cached) {
|
|
378
|
+
setRates(cached.rates);
|
|
379
|
+
setSymbols(cached.symbols || {});
|
|
380
|
+
setNames(cached.names || {});
|
|
381
|
+
setCurrency(cached.detected || defaultCurrency);
|
|
382
|
+
setLoading(false);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Detect country then fetch
|
|
387
|
+
const detectedCountry = country || await _detectCountry();
|
|
388
|
+
const params = detectedCountry ? { country: detectedCountry } : {};
|
|
389
|
+
const data = await _get("getCurrencyRates", apiKey, params, CURRENCY_API_BASE);
|
|
390
|
+
|
|
391
|
+
if (data.error) throw new Error(data.error.message);
|
|
392
|
+
|
|
393
|
+
setRates(data.rates);
|
|
394
|
+
setSymbols(data.symbols || {});
|
|
395
|
+
setNames(data.names || {});
|
|
396
|
+
setCurrency(data.detected || defaultCurrency);
|
|
397
|
+
await _cacheSet(data);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
setError(e.message);
|
|
400
|
+
console.warn("[Cosmoola Native] Failed to load rates:", e.message);
|
|
401
|
+
} finally {
|
|
402
|
+
setLoading(false);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
init();
|
|
406
|
+
}, [apiKey, country]);
|
|
407
|
+
|
|
408
|
+
const convert = useCallback((amount, from = "USD") => {
|
|
409
|
+
if (!rates) return String(amount);
|
|
410
|
+
return _format(_convertRaw(amount, from, currency, rates), currency, symbols);
|
|
411
|
+
}, [rates, symbols, currency]);
|
|
412
|
+
|
|
413
|
+
const convertRaw = useCallback((amount, from = "USD") => {
|
|
414
|
+
if (!rates) return amount;
|
|
415
|
+
return _convertRaw(amount, from, currency, rates);
|
|
416
|
+
}, [rates, currency]);
|
|
417
|
+
|
|
418
|
+
const allCurrencies = rates
|
|
419
|
+
? Object.keys(rates).map(code => ({ code, symbol: symbols[code] || code, name: names[code] || code, rate: rates[code] }))
|
|
420
|
+
: [];
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<CurrencyContext.Provider value={{
|
|
424
|
+
loading, error, currency, setCurrency,
|
|
425
|
+
symbol: symbols[currency] || "$",
|
|
426
|
+
convert, convertRaw,
|
|
427
|
+
rates, symbols, names, allCurrencies,
|
|
428
|
+
}}>
|
|
429
|
+
{children}
|
|
430
|
+
</CurrencyContext.Provider>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
435
|
+
// useCurrency — hook, use anywhere inside <CurrencyProvider>
|
|
436
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* @example
|
|
440
|
+
* const { convert, currency, setCurrency } = useCurrency();
|
|
441
|
+
* <Text>{convert(99.99)}</Text> // → "£79.23"
|
|
442
|
+
*/
|
|
443
|
+
export function useCurrency() {
|
|
444
|
+
const ctx = useContext(CurrencyContext);
|
|
445
|
+
if (!ctx) throw new Error("[Cosmoola] useCurrency() must be called inside <CurrencyProvider>");
|
|
446
|
+
return ctx;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
450
|
+
// CsmPrice — drop-in React Native price component
|
|
451
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @example
|
|
455
|
+
* <CsmPrice amount={99.99} />
|
|
456
|
+
* <CsmPrice amount={99.99} from="USD" style={{ fontSize: 18, fontWeight: "bold" }} />
|
|
457
|
+
*/
|
|
458
|
+
export function CsmPrice({ amount, from = "USD", style }) {
|
|
459
|
+
const { convert, loading } = useCurrency();
|
|
460
|
+
return (
|
|
461
|
+
<Text style={style}>
|
|
462
|
+
{loading ? String(amount) : convert(amount, from)}
|
|
463
|
+
</Text>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
468
|
+
// useMobilePayment — hook for mobile money flows in React Native
|
|
469
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @example
|
|
473
|
+
* const { initiate, status, error } = useMobilePayment("pk_live_xxx");
|
|
474
|
+
*
|
|
475
|
+
* await initiate({
|
|
476
|
+
* clientSecret: "pi_xxx_secret_xxx",
|
|
477
|
+
* phone: "+254712345678",
|
|
478
|
+
* country: "KE",
|
|
479
|
+
* amount: 500,
|
|
480
|
+
* });
|
|
481
|
+
*/
|
|
482
|
+
export function useMobilePayment(publishableKey) {
|
|
483
|
+
const cosmoolaRef = useRef(null);
|
|
484
|
+
if (!cosmoolaRef.current) cosmoolaRef.current = new CosmoolaNative(publishableKey);
|
|
485
|
+
|
|
486
|
+
const [status, setStatus] = useState("idle");
|
|
487
|
+
const [error, setError] = useState(null);
|
|
488
|
+
const [receipt, setReceipt] = useState(null);
|
|
489
|
+
|
|
490
|
+
const reset = useCallback(() => {
|
|
491
|
+
setStatus("idle"); setError(null); setReceipt(null);
|
|
492
|
+
}, []);
|
|
493
|
+
|
|
494
|
+
const initiate = useCallback(async ({ clientSecret, phone, country, amount, currency }) => {
|
|
495
|
+
setStatus("pending"); setError(null);
|
|
496
|
+
const cosmoola = cosmoolaRef.current;
|
|
497
|
+
|
|
498
|
+
const result = await cosmoola.confirmMobileMoneyPayment(clientSecret, {
|
|
499
|
+
phone, country, amount, currency,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
if (result.error) { setError(result.error); setStatus("failed"); return; }
|
|
503
|
+
|
|
504
|
+
setStatus("polling");
|
|
505
|
+
setReceipt(result.mobile_money);
|
|
506
|
+
|
|
507
|
+
const { paymentIntent, error: pollError } = await cosmoola.pollPaymentStatus(clientSecret, {
|
|
508
|
+
interval: 3000, maxChecks: 40,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
if (pollError) { setError(pollError); setStatus("failed"); }
|
|
512
|
+
else { setStatus(paymentIntent.status === "succeeded" ? "succeeded" : "failed"); }
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
return { initiate, status, error, receipt, reset };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
519
|
+
// UTILITIES
|
|
520
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
function _extractIntentId(clientSecret) {
|
|
523
|
+
const parts = (clientSecret || "").split("_secret_");
|
|
524
|
+
if (parts.length < 2) throw new Error("[Cosmoola] Invalid client_secret format.");
|
|
525
|
+
return parts[0];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
529
|
+
// EXPORTS
|
|
530
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
export {
|
|
533
|
+
MOBILE_MONEY_COUNTRIES,
|
|
534
|
+
API_BASE,
|
|
535
|
+
CURRENCY_API_BASE,
|
|
536
|
+
SDK_VERSION,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
export default CosmoolaNative;
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @cosmoola/cosmoola-native — TypeScript definitions v1.0.0
|
|
2
|
+
|
|
3
|
+
import React, { ReactNode } from "react";
|
|
4
|
+
import { TextStyle } from "react-native";
|
|
5
|
+
|
|
6
|
+
// ─── Core ────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export declare const API_BASE: string;
|
|
9
|
+
export declare const CURRENCY_API_BASE: string;
|
|
10
|
+
export declare const SDK_VERSION: string;
|
|
11
|
+
|
|
12
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface PaymentIntent {
|
|
15
|
+
id: string;
|
|
16
|
+
status: "requires_payment_method" | "processing" | "succeeded" | "canceled" | "failed" | "requires_action";
|
|
17
|
+
amount: number;
|
|
18
|
+
currency: string;
|
|
19
|
+
client_secret?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CosmoolaError {
|
|
23
|
+
type: string;
|
|
24
|
+
code?: string;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MobileMoneyResult {
|
|
29
|
+
id: string;
|
|
30
|
+
status: string;
|
|
31
|
+
method: string;
|
|
32
|
+
phone?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CurrencyData {
|
|
36
|
+
rates: Record<string, number>;
|
|
37
|
+
symbols: Record<string, string>;
|
|
38
|
+
names: Record<string, string>;
|
|
39
|
+
countryMap: Record<string, string>;
|
|
40
|
+
detected: string;
|
|
41
|
+
base: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CurrencyItem {
|
|
45
|
+
code: string;
|
|
46
|
+
symbol: string;
|
|
47
|
+
name: string;
|
|
48
|
+
rate: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── CosmoolaNative ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export declare class CosmoolaNative {
|
|
54
|
+
constructor(publishableKey: string, options?: { country?: string });
|
|
55
|
+
|
|
56
|
+
currency: NativeCurrency;
|
|
57
|
+
|
|
58
|
+
confirmCardPayment(
|
|
59
|
+
clientSecret: string,
|
|
60
|
+
data: {
|
|
61
|
+
payment_method: { card: { number: string; exp_month: string; exp_year: string; cvc: string; name?: string } };
|
|
62
|
+
return_url?: string;
|
|
63
|
+
}
|
|
64
|
+
): Promise<{ paymentIntent?: PaymentIntent; error?: CosmoolaError }>;
|
|
65
|
+
|
|
66
|
+
confirmMobileMoneyPayment(
|
|
67
|
+
clientSecret: string,
|
|
68
|
+
data: { phone: string; country: string; amount?: number; currency?: string }
|
|
69
|
+
): Promise<{ paymentIntent?: PaymentIntent; mobile_money?: MobileMoneyResult; error?: CosmoolaError }>;
|
|
70
|
+
|
|
71
|
+
retrievePaymentIntent(
|
|
72
|
+
clientSecret: string
|
|
73
|
+
): Promise<{ paymentIntent?: PaymentIntent; error?: CosmoolaError }>;
|
|
74
|
+
|
|
75
|
+
pollPaymentStatus(
|
|
76
|
+
clientSecret: string,
|
|
77
|
+
options?: { interval?: number; maxChecks?: number; onUpdate?: (pi: PaymentIntent) => void }
|
|
78
|
+
): Promise<{ paymentIntent?: PaymentIntent; error?: CosmoolaError }>;
|
|
79
|
+
|
|
80
|
+
getPublishableKey(): string;
|
|
81
|
+
getMode(): "live" | "test";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── NativeCurrency ──────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export declare class NativeCurrency {
|
|
87
|
+
init(country?: string): Promise<void>;
|
|
88
|
+
getRates(country?: string): Promise<CurrencyData>;
|
|
89
|
+
convert(amount: number, from?: string, to?: string): Promise<string>;
|
|
90
|
+
convertRaw(amount: number, from?: string, to?: string): Promise<number>;
|
|
91
|
+
setCurrency(code: string): void;
|
|
92
|
+
getActiveCurrency(): string;
|
|
93
|
+
getSymbol(code?: string): string;
|
|
94
|
+
getAllCurrencies(): CurrencyItem[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── CurrencyProvider ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export interface CurrencyContextValue {
|
|
100
|
+
loading: boolean;
|
|
101
|
+
error: string | null;
|
|
102
|
+
currency: string;
|
|
103
|
+
setCurrency: (code: string) => void;
|
|
104
|
+
symbol: string;
|
|
105
|
+
convert: (amount: number, from?: string) => string;
|
|
106
|
+
convertRaw: (amount: number, from?: string) => number;
|
|
107
|
+
rates: Record<string, number> | null;
|
|
108
|
+
symbols: Record<string, string>;
|
|
109
|
+
names: Record<string, string>;
|
|
110
|
+
allCurrencies: CurrencyItem[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export declare function CurrencyProvider(props: {
|
|
114
|
+
apiKey: string;
|
|
115
|
+
defaultCurrency?: string;
|
|
116
|
+
country?: string;
|
|
117
|
+
children: ReactNode;
|
|
118
|
+
}): React.ReactElement;
|
|
119
|
+
|
|
120
|
+
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export declare function useCurrency(): CurrencyContextValue;
|
|
123
|
+
|
|
124
|
+
export declare function useMobilePayment(publishableKey: string): {
|
|
125
|
+
initiate: (params: {
|
|
126
|
+
clientSecret: string;
|
|
127
|
+
phone: string;
|
|
128
|
+
country: string;
|
|
129
|
+
amount?: number;
|
|
130
|
+
currency?: string;
|
|
131
|
+
}) => Promise<void>;
|
|
132
|
+
status: "idle" | "pending" | "polling" | "succeeded" | "failed";
|
|
133
|
+
error: CosmoolaError | null;
|
|
134
|
+
receipt: MobileMoneyResult | null;
|
|
135
|
+
reset: () => void;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ─── Components ──────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export declare function CsmPrice(props: {
|
|
141
|
+
amount: number;
|
|
142
|
+
from?: string;
|
|
143
|
+
style?: TextStyle;
|
|
144
|
+
}): React.ReactElement;
|
|
145
|
+
|
|
146
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export declare const MOBILE_MONEY_COUNTRIES: Record<string, {
|
|
149
|
+
provider: string;
|
|
150
|
+
method: string;
|
|
151
|
+
label: string;
|
|
152
|
+
prefix: string;
|
|
153
|
+
currency: string;
|
|
154
|
+
}>;
|
|
155
|
+
|
|
156
|
+
export default CosmoolaNative;
|