@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.
Files changed (3) hide show
  1. package/README.md +30 -0
  2. package/package.json +44 -0
  3. 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;