import {
BridgeHostProvider,
useBridgeHost,
useBridgeCapability,
useBridgeHostEvent,
} from "@gbg/go-bridge-web-react";
import { BridgeActions } from "@gbg/go-bridge-web";
import type { BridgeHostDelegate, BridgeResponder } from "@gbg/go-bridge-web";
import { useEffect, useMemo, useRef, useState } from "react";
interface JourneyConfig {
url: string;
requiredCapabilities: string[];
hostVersion: string;
}
const JOURNEYS: Record<string, JourneyConfig> = {
documentOnly: {
url: "https://journey.example.com/document",
requiredCapabilities: ["camera.document"],
hostVersion: "1.0.0",
},
passportPlus: {
url: "https://journey.example.com/passport",
requiredCapabilities: ["camera.document", "device.info"],
hostVersion: "1.0.0",
},
};
interface DeviceCapabilities {
cameraAvailable: boolean;
cameraPermissionState: string;
}
async function detectCapabilities(): Promise<DeviceCapabilities> {
const cameraAvailable = !!navigator.mediaDevices?.getUserMedia;
let cameraPermissionState = "prompt";
if (navigator.permissions) {
try {
const status = await navigator.permissions.query({ name: "camera" as PermissionName });
cameraPermissionState = status.state;
} catch {
// Permission API doesn't support 'camera' on older browsers — leave as prompt
}
}
return { cameraAvailable, cameraPermissionState };
}
export default function App() {
const [activeJourney, setActiveJourney] = useState<keyof typeof JOURNEYS | null>(null);
const [capabilities, setCapabilities] = useState<DeviceCapabilities | null>(null);
useEffect(() => {
detectCapabilities().then(setCapabilities);
}, []);
if (!capabilities) return <div>Detecting capabilities…</div>;
if (activeJourney) {
return (
<JourneyContainer
config={JOURNEYS[activeJourney]}
capabilities={capabilities}
onExit={() => setActiveJourney(null)}
/>
);
}
return (
<Launcher
capabilities={capabilities}
onLaunch={(key) => setActiveJourney(key)}
/>
);
}
function Launcher({
capabilities,
onLaunch,
}: {
capabilities: DeviceCapabilities;
onLaunch: (key: keyof typeof JOURNEYS) => void;
}) {
const [warning, setWarning] = useState<{
journey: keyof typeof JOURNEYS;
missing: string[];
} | null>(null);
function isSupported(capability: string): boolean {
if (capability === "camera.document") return capabilities.cameraAvailable;
return true; // device.info is always available
}
function attemptLaunch(key: keyof typeof JOURNEYS) {
const missing = JOURNEYS[key].requiredCapabilities.filter((c) => !isSupported(c));
if (missing.length === 0) {
onLaunch(key);
} else {
setWarning({ journey: key, missing });
}
}
return (
<div style={{ padding: 24, maxWidth: 600, margin: "0 auto" }}>
<h1>Identity Verification</h1>
<JourneyOption
title="Document Verification"
subtitle="Document capture only"
requirements={JOURNEYS.documentOnly.requiredCapabilities}
capabilities={capabilities}
onClick={() => attemptLaunch("documentOnly")}
/>
<JourneyOption
title="Passport Plus"
subtitle="Document + device info"
requirements={JOURNEYS.passportPlus.requiredCapabilities}
capabilities={capabilities}
onClick={() => attemptLaunch("passportPlus")}
/>
<DeviceCapabilitySummary capabilities={capabilities} />
{warning && (
<div role="dialog" style={dialogStyle}>
<h3>Capability Warning</h3>
<p>
This journey requires <strong>{warning.missing.join(", ")}</strong>{" "}
which {warning.missing.length === 1 ? "is" : "are"} not available on
this device. The journey may not complete successfully.
</p>
<button onClick={() => { onLaunch(warning.journey); setWarning(null); }}>
Continue Anyway
</button>
<button onClick={() => setWarning(null)}>Cancel</button>
</div>
)}
</div>
);
}
type JourneyState =
| { kind: "loading" }
| { kind: "active" }
| { kind: "completed"; data: Record<string, unknown> }
| { kind: "failed"; reason: string }
| { kind: "error"; message: string };
function JourneyContainer({
config,
capabilities,
onExit,
}: {
config: JourneyConfig;
capabilities: DeviceCapabilities;
onExit: () => void;
}) {
const [iframe, setIframe] = useState<HTMLIFrameElement | null>(null);
const [state, setState] = useState<JourneyState>({ kind: "loading" });
const delegate = useMemo<BridgeHostDelegate>(
() => ({
onError(_host, error) {
console.error("[Journey bridge error]", error);
setState({ kind: "error", message: error.message });
},
}),
[],
);
return (
<div style={{ position: "relative", height: "100vh" }}>
<BridgeHostProvider
iframe={iframe}
allowedOrigins={[new URL(config.url).origin]}
hostVersion={config.hostVersion}
delegate={delegate}
capabilities={{
"camera.document": {
supported: capabilities.cameraAvailable,
version: "1.0",
permissionState: capabilities.cameraPermissionState,
},
}}
>
<DocumentCaptureHandler />
<DeviceInfoCapability />
<LifecycleForwarder />
<JourneyStateRouter onState={setState} />
<iframe
ref={setIframe}
src={config.url}
allow="camera"
style={{
width: "100%",
height: "100%",
border: 0,
opacity: state.kind === "active" ? 1 : 0,
}}
/>
</BridgeHostProvider>
{state.kind === "loading" && <Overlay>Loading verification…</Overlay>}
{state.kind === "completed" && (
<CompletionOverlay
message="Verification complete"
tone="success"
onDismiss={onExit}
/>
)}
{state.kind === "failed" && (
<CompletionOverlay
message={`Verification failed: ${state.reason}`}
tone="error"
onDismiss={onExit}
/>
)}
{state.kind === "error" && (
<CompletionOverlay
message={`Bridge error: ${state.message}`}
tone="error"
onDismiss={onExit}
/>
)}
<button onClick={onExit} style={cancelStyle}>
Cancel
</button>
</div>
);
}
function JourneyStateRouter({
onState,
}: {
onState: (state: JourneyState) => void;
}) {
useBridgeHostEvent(BridgeActions.JOURNEY_STARTED, () => {
onState({ kind: "active" });
});
useBridgeHostEvent(BridgeActions.JOURNEY_COMPLETED, (data) => {
onState({ kind: "completed", data });
});
useBridgeHostEvent(BridgeActions.JOURNEY_FAILED, (data) => {
onState({ kind: "failed", reason: String(data.reason ?? "unknown") });
});
useBridgeHostEvent(BridgeActions.JOURNEY_ABANDONED, () => {
onState({ kind: "failed", reason: "abandoned" });
});
return null;
}
function LifecycleForwarder() {
const host = useBridgeHost();
useEffect(() => {
if (!host) return;
function onVisibilityChange() {
if (document.visibilityState === "hidden") {
host?.sendEvent("host.background", {});
} else {
host?.sendEvent("host.foreground", {});
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
}, [host]);
return null;
}
function DocumentCaptureHandler() {
const responderRef = useRef<BridgeResponder | null>(null);
const [active, setActive] = useState(false);
useBridgeCapability("camera.document.capture", {
handle: async (_request, responder) => {
if (responderRef.current) {
responder.error({
code: "BUSY",
message: "A capture is already active",
recoverable: true,
});
return;
}
responderRef.current = responder;
setActive(true);
},
});
useEffect(() => {
return () => {
responderRef.current?.cancelled("Component unmounted");
responderRef.current = null;
};
}, []);
function complete(imageBase64: string) {
responderRef.current?.success({
imageBase64,
mimeType: "image/jpeg",
});
responderRef.current = null;
setActive(false);
}
function cancel() {
responderRef.current?.cancelled("User dismissed");
responderRef.current = null;
setActive(false);
}
if (!active) return null;
return (
<div style={captureOverlayStyle}>
<h2 style={{ color: "white" }}>Capture document</h2>
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
complete(dataUrl.split(",")[1]);
};
reader.readAsDataURL(file);
}}
/>
<button onClick={cancel} style={{ marginTop: 12 }}>
Cancel
</button>
</div>
);
}
function DeviceInfoCapability() {
const host = useBridgeHost();
useEffect(() => {
if (!host) return;
host.registerCustomCapability("device.info", "1.0", {
handle: (_request, responder) => {
responder.success({
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
viewport: { width: window.innerWidth, height: window.innerHeight },
});
},
});
}, [host]);
return null;
}
function JourneyOption({ title, subtitle, requirements, capabilities, onClick }: any) {
const allMet = requirements.every((c: string) =>
c === "camera.document" ? capabilities.cameraAvailable : true,
);
return (
<button onClick={onClick} style={journeyOptionStyle}>
<div style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600 }}>{title}</div>
<div style={{ fontSize: 12, color: "#666" }}>{subtitle}</div>
</div>
<span style={{ color: allMet ? "green" : "orange" }}>
{allMet ? "✓" : "⚠"}
</span>
</button>
);
}
function DeviceCapabilitySummary({ capabilities }: { capabilities: DeviceCapabilities }) {
return (
<div style={{ marginTop: 24, fontSize: 14, color: "#666" }}>
<div>
Camera: {capabilities.cameraAvailable ? "✓ available" : "✗ unavailable"} ·{" "}
permission: {capabilities.cameraPermissionState}
</div>
</div>
);
}
function Overlay({ children }: { children: React.ReactNode }) {
return <div style={overlayStyle}>{children}</div>;
}
function CompletionOverlay({
message,
tone,
onDismiss,
}: {
message: string;
tone: "success" | "error";
onDismiss: () => void;
}) {
return (
<div style={overlayStyle}>
<h2 style={{ color: tone === "success" ? "#4CAF50" : "#F44336" }}>{message}</h2>
<button onClick={onDismiss}>Done</button>
</div>
);
}
const dialogStyle: React.CSSProperties = { padding: 16, border: "1px solid #ddd", borderRadius: 8, marginTop: 16 };
const journeyOptionStyle: React.CSSProperties = { display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", padding: 16, marginBottom: 12, background: "#f8f8f8", border: 0, borderRadius: 8, cursor: "pointer" };
const overlayStyle: React.CSSProperties = { position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: "rgba(255,255,255,0.95)" };
const captureOverlayStyle: React.CSSProperties = { position: "fixed", inset: 0, background: "rgba(0,0,0,0.95)", zIndex: 1000, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" };
const cancelStyle: React.CSSProperties = { position: "fixed", top: 12, left: 12, padding: "8px 16px", background: "rgba(0,0,0,0.6)", color: "white", border: 0, borderRadius: 6, cursor: "pointer", zIndex: 999 };