@agent-cards/wallet-react-native 0.1.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 +30 -0
- package/package.json +44 -0
- package/src/index.tsx +151 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @agent-cards/wallet-react-native
|
|
2
|
+
|
|
3
|
+
The Agentcard wallet as a React Native component: your users add cards and
|
|
4
|
+
approve agent payments without leaving your app. Card numbers never touch your
|
|
5
|
+
code — capture runs on Agentcard's hosted surface inside the WebView.
|
|
6
|
+
|
|
7
|
+
Same integration shape as the web SDK: your server creates a wallet link
|
|
8
|
+
(`POST /api/v2/wallet_links`), your app renders the component.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { AgentcardWallet } from "@agent-cards/wallet-react-native";
|
|
12
|
+
|
|
13
|
+
<AgentcardWallet
|
|
14
|
+
link={link.url}
|
|
15
|
+
pay={{ merchant: "DoorDash", amountCents: 2350 }}
|
|
16
|
+
onSuccess={(d) => d.type === "payment_completed" && markOrderPaid()}
|
|
17
|
+
onTokenExpired={() => refreshLink()}
|
|
18
|
+
onEvent={(name, data) => analytics.capture(name, data)}
|
|
19
|
+
/>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires `react-native-webview`. Bank approvals open in the system browser
|
|
23
|
+
(`open_url`); everything else stays in the sheet.
|
|
24
|
+
|
|
25
|
+
**Note on adding cards inside React Native:** the add-card ceremony ends in a
|
|
26
|
+
card-network passkey. WKWebView-based WebViews handle it on iOS 17+, but if
|
|
27
|
+
your target matrix is broader, run the add-card step through a texted wallet
|
|
28
|
+
link (the hosted page or App Clip) and use this component for the wallet and
|
|
29
|
+
pay flows.
|
|
30
|
+
For fully native iOS, use `AgentcardWalletKit` instead.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cards/wallet-react-native",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop the Agentcard wallet into a React Native app: add cards, show the wallet, approve agent payments.",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"types": "src/index.tsx",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"homepage": "https://docs.agentcard.sh/platforms/react-native",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/tiny-agent-company/tiny-agent-company.git",
|
|
19
|
+
"directory": "apps/agent-cards/packages/wallet-react-native"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"agentcard",
|
|
23
|
+
"wallet",
|
|
24
|
+
"react-native",
|
|
25
|
+
"payments",
|
|
26
|
+
"virtual-cards",
|
|
27
|
+
"ai-agents"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=18",
|
|
31
|
+
"react-native": ">=0.73",
|
|
32
|
+
"react-native-webview": ">=13"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "^18.3.3",
|
|
36
|
+
"react": "18.2.0",
|
|
37
|
+
"react-native": "0.74.0",
|
|
38
|
+
"react-native-webview": "14.0.1",
|
|
39
|
+
"typescript": "^5.5.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// @agent-cards/wallet-react-native — the Agentcard wallet as a React Native
|
|
2
|
+
// component. The RN twin of the web SDK (public/js/v1/agentcard.js) and the
|
|
3
|
+
// native iOS kit (AgentcardWalletKit): same wallet-link token in, same event
|
|
4
|
+
// vocabulary out.
|
|
5
|
+
//
|
|
6
|
+
// It renders the hosted wallet sheet in a WebView and speaks the SDK bridge
|
|
7
|
+
// over react-native-webview's message channel (the page detects
|
|
8
|
+
// window.ReactNativeWebView and posts the same envelopes it posts to an
|
|
9
|
+
// iframe host). Bank-approval pages open in the system browser via open_url —
|
|
10
|
+
// they refuse to render inside webviews we don't control.
|
|
11
|
+
|
|
12
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
13
|
+
import { Linking, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
|
14
|
+
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_BASE = "https://app.agentcard.sh";
|
|
17
|
+
|
|
18
|
+
export type AgentcardPayIntent = {
|
|
19
|
+
merchant?: string;
|
|
20
|
+
amountCents: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AgentcardSuccess =
|
|
24
|
+
| { type: "card_attached"; cards: Array<{ id: string; last4: string; network: string | null }> }
|
|
25
|
+
| { type: "payment_completed"; merchant: string | null; amount_cents: number | null };
|
|
26
|
+
|
|
27
|
+
export type AgentcardWalletProps = {
|
|
28
|
+
/** The wallet link from POST /api/v2/wallet_links — its url or raw token. */
|
|
29
|
+
link: string;
|
|
30
|
+
/** Open the pay sheet for a specific payment instead of the wallet. */
|
|
31
|
+
pay?: AgentcardPayIntent;
|
|
32
|
+
/** Override for staging/previews. Defaults to https://app.agentcard.sh. */
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
onSuccess?: (data: AgentcardSuccess) => void;
|
|
35
|
+
/** The link or its 1h session died — create a fresh link and swap `link`. */
|
|
36
|
+
onTokenExpired?: () => void;
|
|
37
|
+
/** Every bridge event, for analytics (ready, step_changed, success, …). */
|
|
38
|
+
onEvent?: (name: string, data: Record<string, unknown>) => void;
|
|
39
|
+
/** Override how bank-approval URLs open. Default: Linking.openURL. */
|
|
40
|
+
onOpenUrl?: (url: string) => void;
|
|
41
|
+
style?: StyleProp<ViewStyle>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Accept a raw wallet-link token or the full texted URL (…/w/<token>). */
|
|
45
|
+
function extractToken(value: string): string | null {
|
|
46
|
+
if (!value) return null;
|
|
47
|
+
if (!value.includes("://")) return value;
|
|
48
|
+
try {
|
|
49
|
+
const segments = new URL(value).pathname.split("/").filter(Boolean);
|
|
50
|
+
return segments.length ? decodeURIComponent(segments[segments.length - 1]) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function AgentcardWallet({
|
|
57
|
+
link,
|
|
58
|
+
pay,
|
|
59
|
+
baseUrl = DEFAULT_BASE,
|
|
60
|
+
onSuccess,
|
|
61
|
+
onTokenExpired,
|
|
62
|
+
onEvent,
|
|
63
|
+
onOpenUrl,
|
|
64
|
+
style,
|
|
65
|
+
}: AgentcardWalletProps) {
|
|
66
|
+
const uri = useMemo(() => {
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
params.set("sdk", "1");
|
|
69
|
+
const token = extractToken(link);
|
|
70
|
+
if (token) params.set("link_token", token);
|
|
71
|
+
if (pay) {
|
|
72
|
+
if (pay.merchant) params.set("merchant", pay.merchant);
|
|
73
|
+
params.set("amount", String(pay.amountCents));
|
|
74
|
+
}
|
|
75
|
+
return `${baseUrl.replace(/\/+$/, "")}/embed/wallet?${params.toString()}`;
|
|
76
|
+
}, [link, pay, baseUrl]);
|
|
77
|
+
|
|
78
|
+
// Late messages from a dying WebView (e.g. right after the host swapped the
|
|
79
|
+
// link) must not fire stale callbacks against the new state.
|
|
80
|
+
const currentUri = useRef(uri);
|
|
81
|
+
currentUri.current = uri;
|
|
82
|
+
|
|
83
|
+
const handleMessage = useCallback(
|
|
84
|
+
(event: WebViewMessageEvent) => {
|
|
85
|
+
// Stale-closure session gate, independent of nativeEvent.url: this
|
|
86
|
+
// closure captures the session URI it was created for (uri is a dep),
|
|
87
|
+
// and a swapped link both remounts the keyed WebView and moves
|
|
88
|
+
// currentUri forward. A dying WebView delivers its queued messages to
|
|
89
|
+
// the OLD closure — whose captured uri no longer matches — so stale
|
|
90
|
+
// success / token_expired / open_url die here even when the platform
|
|
91
|
+
// drops nativeEvent.url (which would bypass a URL-only check). The
|
|
92
|
+
// parent component itself never unmounts on a swap, so an instance
|
|
93
|
+
// liveness flag cannot provide this guarantee; the closure identity is
|
|
94
|
+
// the session identity.
|
|
95
|
+
if (uri !== currentUri.current) return;
|
|
96
|
+
// Exact-match the emitting document against the CURRENT session URI: a
|
|
97
|
+
// dying WebView from a swapped link would otherwise deliver its last
|
|
98
|
+
// events against the new session's state (a prefix check on baseUrl
|
|
99
|
+
// passes for every session equally).
|
|
100
|
+
if (event.nativeEvent.url && event.nativeEvent.url !== currentUri.current) return;
|
|
101
|
+
let msg: { source?: string; type?: string; data?: Record<string, unknown> };
|
|
102
|
+
try {
|
|
103
|
+
msg = JSON.parse(event.nativeEvent.data);
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (msg?.source !== "agentcard-wallet" || typeof msg.type !== "string") return;
|
|
108
|
+
const data = msg.data ?? {};
|
|
109
|
+
onEvent?.(msg.type, data);
|
|
110
|
+
if (msg.type === "success") {
|
|
111
|
+
onSuccess?.(data as AgentcardSuccess);
|
|
112
|
+
} else if (msg.type === "token_expired") {
|
|
113
|
+
onTokenExpired?.();
|
|
114
|
+
} else if (msg.type === "open_url" && typeof data.url === "string") {
|
|
115
|
+
if (onOpenUrl) onOpenUrl(data.url);
|
|
116
|
+
else void Linking.openURL(data.url);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
[uri, baseUrl, onEvent, onSuccess, onTokenExpired, onOpenUrl],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<WebView
|
|
124
|
+
// A changed link is a NEW session: remount instead of navigating, so
|
|
125
|
+
// the old document is torn down rather than left emitting.
|
|
126
|
+
key={uri}
|
|
127
|
+
source={{ uri }}
|
|
128
|
+
onMessage={handleMessage}
|
|
129
|
+
style={[styles.webview, style]}
|
|
130
|
+
// The sheet is a fixed-height card flow — page scrolling inside the
|
|
131
|
+
// WebView just fights the host screen's own scroll.
|
|
132
|
+
scrollEnabled={false}
|
|
133
|
+
incognito
|
|
134
|
+
setSupportMultipleWindows={false}
|
|
135
|
+
originWhitelist={["https://*", "http://localhost:*"]}
|
|
136
|
+
// Basis Theory's capture elements are same-page iframes.
|
|
137
|
+
javaScriptEnabled
|
|
138
|
+
domStorageEnabled
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const styles = StyleSheet.create({
|
|
144
|
+
webview: {
|
|
145
|
+
backgroundColor: "transparent",
|
|
146
|
+
height: 684,
|
|
147
|
+
width: "100%",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export default AgentcardWallet;
|