Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 205 additions & 9 deletions example/screens/PlaidLinkHeadlessSessionScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,212 @@
// app/PlaidLinkHeadlessSessionScreen.tsx
import { SafeAreaView, Text, Button, StyleSheet } from "react-native";
import { useRef, useState } from "react";
import {
Button,
SafeAreaView,
ScrollView,
Text,
View,
ActivityIndicator,
StyleSheet,
} from "react-native";
import {
createPlaidHeadlessSession,
LinkExit,
LinkEvent,
LinkSuccess,
PlaidHeadlessSession,
LinkEventName,
} from "react-native-plaid-link-sdk";
import ReactNativePlaidLinkSdk from "react-native-plaid-link-sdk";
import {
ErrorView,
SdkVersionView,
TokenInputView,
} from "../components/components";
import { styles } from "../styles/common";
import { SessionState } from "../types/types";
import { isValidToken } from "../utils/validation";
import { LinkExitScreen } from "./LinkExitScreen";
import { LinkSuccessScreen } from "./LinkSuccessScreen";

type Props = { onBack: () => void };

export function PlaidLinkHeadlessSessionScreen({ onBack }: Props) {
const [token, setToken] = useState("");
const [state, setState] = useState<SessionState>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [linkExit, setLinkExit] = useState<LinkExit | null>(null);
const [linkSuccess, setLinkSuccess] = useState<LinkSuccess | null>(null);
const sessionRef = useRef<PlaidHeadlessSession | null>(null);
const events = useRef<LinkEvent[]>([]);

const createSession = async () => {
if (!token.trim()) {
setErrorMessage("Please enter a link token");
return;
}

if (!isValidToken(token)) {
setErrorMessage("Invalid token format");
return;
}

setState("loading");
setErrorMessage(null);
events.current = [];
try {
sessionRef.current = await createPlaidHeadlessSession({
token: token.trim(),
onSuccess: (success) => {
console.log(
"[PlaidHeadless] onSuccess:",
JSON.stringify(success, null, 2)
);
setLinkSuccess(success);
},
onExit: (exit) => {
console.log("[PlaidHeadless] onExit:", JSON.stringify(exit, null, 2));
setLinkExit(exit);
},
onEvent: (event) => {
console.log(
"[PlaidHeadless] onEvent:",
JSON.stringify(event, null, 2)
);
events.current = [...events.current, event];

if (event.eventName === LinkEventName.ERROR) {
setState("error");
setErrorMessage(
event.metadata.errorMessage ?? "Failed to create session."
);
}
},
onLoad: () => {
console.log("[PlaidHeadless] onLoad - session ready");
setState("ready");
},
});
} catch (e: any) {
setState("error");
setErrorMessage(e.message ?? "Failed to create session.");
}
};

const handleStart = async () => {
console.log("[PlaidHeadless] handleStart - session:", sessionRef.current);
try {
await sessionRef.current?.start();
} catch (e: any) {
setState("error");
setErrorMessage(e.message ?? "Failed to start session.");
}
};

const isLoading = state === "loading";
const isReady = state === "ready";
const isIdle = state === "idle" || state === "error";
const canCreateSession = isIdle && isValidToken(token);
const isEnabled = canCreateSession || isReady;

let buttonTitle = "Launch Payment Flow";
if (isLoading) {
buttonTitle = "Initializing...";
} else if (isIdle) {
buttonTitle = "Create Headless Session";
}

export function PlaidLinkHeadlessSessionScreen({
onBack,
}: {
onBack: () => void;
}) {
return (
<SafeAreaView style={styles.container}>
<Button title="← Back" onPress={onBack} />
<Text style={styles.title}>PlaidLinkHeadlessSession</Text>
<ScrollView style={styles.container}>
<View style={styles.backButton}>
<Button title="← Back" onPress={onBack} />
</View>

<View style={styles.content}>
<Text style={styles.title}>Plaid Link Headless Session Example</Text>
<SdkVersionView version={ReactNativePlaidLinkSdk.sdkVersion} />

<View style={headlessStyles.warningBox}>
<Text style={headlessStyles.warningIcon}>⚠️</Text>
<Text style={headlessStyles.warningText}>
Important: The link token must be a payment token that triggers a
"headless" flow, otherwise the flow will consistently error out.
</Text>
</View>

<TokenInputView token={token} onTokenChange={setToken} />

{state === "error" && errorMessage && (
<ErrorView message={errorMessage} />
)}

<View
style={[styles.button, !isEnabled && styles.buttonDisabled]}
pointerEvents={isEnabled ? "auto" : "none"}
>
<Button
title={buttonTitle}
onPress={isReady ? handleStart : createSession}
disabled={!isEnabled}
color="#fff"
/>
{isLoading && (
<ActivityIndicator color="#fff" style={styles.spinner} />
)}
</View>
</View>
</ScrollView>

{linkSuccess && (
<LinkSuccessScreen
linkSuccess={linkSuccess}
events={events.current}
onClose={() => {
setLinkSuccess(null);
setToken("");
setState("idle");
setErrorMessage(null);
sessionRef.current = null;
events.current = [];
}}
/>
)}

{linkExit && (
<LinkExitScreen
linkExit={linkExit}
events={events.current}
onClose={() => {
setLinkExit(null);
setToken("");
setState("idle");
setErrorMessage(null);
sessionRef.current = null;
events.current = [];
}}
/>
)}
</SafeAreaView>
);
}

const headlessStyles = StyleSheet.create({
warningBox: {
alignItems: "center",
gap: 8,
padding: 16,
backgroundColor: "#fff7ed",
borderRadius: 12,
width: "100%",
marginBottom: 8,
},
warningIcon: {
fontSize: 28,
},
warningText: {
fontSize: 14,
color: "#ea580c",
textAlign: "center",
fontWeight: "500",
},
});
76 changes: 76 additions & 0 deletions ios/src/ReactNativePlaidLinkSdkModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,46 @@ public class ReactNativePlaidLinkSdkModule: Module {
}
}

AsyncFunction(ModuleFunctionName.createPlaidHeadlessSession.rawValue) { (token: String, onLoadPromise: Promise) in
let onSuccess: OnSuccessHandler = { [weak self] success in
self?.sendEvent(ModuleEventName.onSuccess.rawValue, success.asDictionary)
self?.headlessSession = nil
}

let onExit: OnExitHandler = { [weak self] exit in
self?.sendEvent(ModuleEventName.onExit.rawValue, exit.asDictionary)
self?.headlessSession = nil
}

let onEvent: OnEventHandler = { [weak self] event in
self?.sendEvent(ModuleEventName.onEvent.rawValue, event.asDictionary)
}

let onLoad: OnLoadHandler = {
DispatchQueue.main.async {
onLoadPromise.resolve()
}
}

let config = LinkTokenConfiguration(
token: token,
onSuccess: onSuccess,
onExit: onExit,
onEvent: onEvent,
onLoad: onLoad
)

do {
let session = try Plaid.createHeadlessSession(configuration: config)
self.headlessSession = session
} catch {
self.sessionCreationError = error
DispatchQueue.main.async {
onLoadPromise.reject("HEADLESS_SESSION_CREATE_ERROR", error.localizedDescription)
}
}
}

AsyncFunction(ModuleFunctionName.openLinkSession.rawValue) { (fullScreen: Bool, promise: Promise) in
guard let session = self.linkSession else {
let errorMessage = self.sessionCreationError?.localizedDescription ?? "createPlaidLinkSession was not called."
Expand Down Expand Up @@ -203,6 +243,39 @@ public class ReactNativePlaidLinkSdkModule: Module {
promise.resolve()
}
}

AsyncFunction(ModuleFunctionName.startHeadlessSession.rawValue) { (promise: Promise) in
guard let headlessSession = self.headlessSession else {
let errorMessage = self.sessionCreationError?.localizedDescription ?? "createPlaidHeadlessSession was not called."
let errorCode = self.sessionCreationError.map { String($0._code) } ?? "-1"
self.sendEvent(ModuleEventName.onExit.rawValue, [
"displayMessage": errorMessage,
"errorCode": errorCode,
"errorType": "creation error",
"errorMessage": errorMessage,
"errorDisplayMessage": errorMessage,
"errorJson": NSNull(),
"metadata": [
"linkSessionId": NSNull(),
"institution": NSNull(),
"status": NSNull(),
"requestId": NSNull(),
"metadataJson": NSNull(),
]
])

DispatchQueue.main.async {
promise.resolve()
}

return
}

DispatchQueue.main.async {
headlessSession.start()
promise.resolve()
}
}
}

// MARK: Enums
Expand All @@ -218,15 +291,18 @@ public class ReactNativePlaidLinkSdkModule: Module {
enum ModuleFunctionName: String, CaseIterable {
case createPlaidLinkSession
case createPlaidLayerSession
case createPlaidHeadlessSession
case openLinkSession
case openLayerSession
case submitLayerData
case startHeadlessSession
}

// MARK: Private

private var linkSession: PlaidLinkSession?
private var layerSession: PlaidLayerSession?
private var headlessSession: PlaidHeadlessSession?
private var sessionCreationError: Error?
}

Expand Down
8 changes: 6 additions & 2 deletions src/ReactNativePlaidLinkSdk.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,10 +808,10 @@ export interface LayerTokenConfiguration {
onSuccess: LinkSuccessListener;

/** Called when a user has specifically exited the Layer flow. */
onExit?: LinkExitListener;
onExit: LinkExitListener;

/** Called when the user has reached certain points in the Layer flow. */
onEvent?: LinkOnEventListener;
onEvent: LinkOnEventListener;
}

export interface PlaidLinkSession {
Expand All @@ -828,3 +828,7 @@ export interface PlaidLayerSession {
open: () => Promise<void>;
submit: (data: SubmissionData) => Promise<void>;
}

export interface PlaidHeadlessSession {
start: () => Promise<void>;
}
2 changes: 2 additions & 0 deletions src/ReactNativePlaidLinkSdkModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ declare class ReactNativePlaidLinkSdkModule extends NativeModule<ReactNativePlai
sdkVersion: string;
createPlaidLinkSession(token: string): Promise<void>;
createPlaidLayerSession(token: string): Promise<void>;
createPlaidHeadlessSession(token: string): Promise<void>;
openLinkSession(fullScreen: boolean): Promise<void>;
openLayerSession(): Promise<void>;
startHeadlessSession(): Promise<void>;
submitLayerData(
phoneNumber?: string,
dateOfBirth?: string,
Expand Down
4 changes: 4 additions & 0 deletions src/__mocks__/ReactNativePlaidLinkSdkModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ const mockNativeModule = {

createPlaidLayerSession: jest.fn(() => Promise.resolve()),

createPlaidHeadlessSession: jest.fn(() => Promise.resolve()),

openLinkSession: jest.fn((fullScreen: boolean) => Promise.resolve()),

openLayerSession: jest.fn(() => Promise.resolve()),

startHeadlessSession: jest.fn(() => Promise.resolve()),

submitLayerData: jest.fn(
(phone?: string, dob?: string, params?: Record<string, string>) =>
Promise.resolve(),
Expand Down
Loading
Loading