Skip to content

Commit 7a25c6a

Browse files
committed
✨ implement exa host interface
1 parent 5a74f0f commit 7a25c6a

3 files changed

Lines changed: 180 additions & 69 deletions

File tree

index.html

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,27 +74,115 @@
7474
transform 300ms ease,
7575
visibility 0s linear 300ms;
7676
}
77-
button {
78-
position: fixed;
79-
top: 15%;
80-
left: 50%;
81-
transform: translate(-50%, -50%);
82-
padding: 10px 12px;
77+
/* #endregion */
78+
79+
/* #region host page content */
80+
main {
81+
font-family: system-ui, -apple-system, sans-serif;
82+
max-width: 480px;
83+
margin: 0 auto;
84+
padding: 15vh 24px 24px;
85+
color: #1a1a1a;
86+
}
87+
main > h1 {
88+
font-size: 1.75rem;
89+
font-weight: 700;
90+
margin: 0 0 8px;
91+
letter-spacing: -0.02em;
92+
}
93+
main > p {
94+
font-size: 0.9rem;
95+
line-height: 1.5;
96+
color: #666;
97+
margin: 0 0 32px;
98+
}
99+
main > header {
100+
display: flex;
101+
flex-wrap: wrap;
102+
gap: 8px;
103+
margin-bottom: 24px;
104+
}
105+
main > header > code {
106+
font-family: ui-monospace, "SF Mono", "Cascadia Code", monospace;
107+
font-size: 0.8rem;
108+
padding: 6px 12px;
109+
border-radius: 100px;
110+
background: #f0f0f0;
111+
color: #444;
112+
overflow: hidden;
113+
text-overflow: ellipsis;
114+
white-space: nowrap;
115+
max-width: 220px;
116+
}
117+
main > header > output {
118+
font-size: 0.8rem;
119+
padding: 6px 12px;
120+
border-radius: 100px;
121+
font-weight: 500;
122+
background: #f5f5f5;
123+
color: #888;
124+
}
125+
main > header > output[data-active] {
126+
background: #e6faf2;
127+
color: #0d7d56;
128+
}
129+
main > nav {
130+
display: flex;
131+
flex-wrap: wrap;
132+
gap: 10px;
133+
}
134+
main > nav > button {
135+
padding: 10px 20px;
83136
border-radius: 8px;
84-
border: 1px solid rgba(0, 0, 0, 0.12);
85-
background: #ffffffcc;
86-
color: #111;
137+
font-size: 0.875rem;
138+
font-weight: 500;
87139
cursor: pointer;
140+
transition: opacity 150ms;
141+
border: 1px solid rgba(0, 0, 0, 0.1);
142+
background: #f5f5f5;
143+
color: #333;
144+
}
145+
main > nav > button:disabled {
146+
opacity: 0.5;
147+
cursor: default;
148+
}
149+
main > nav > button:first-child {
150+
background: #2ed4a2;
151+
color: #fff;
152+
border-color: transparent;
88153
}
89154
@media (prefers-color-scheme: dark) {
90155
iframe {
91156
outline-color: rgba(255, 255, 255, 0.08); /* subtle border in dark mode */
92157
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
93158
}
94-
button {
95-
border-color: rgba(255, 255, 255, 0.12);
96-
background: #111111cc;
159+
main {
160+
color: #e5e5e5;
161+
}
162+
main > p {
163+
color: #999;
164+
}
165+
main > header > code {
166+
background: #2a2a2a;
167+
color: #bbb;
168+
}
169+
main > header > output {
170+
background: #2a2a2a;
171+
color: #777;
172+
}
173+
main > header > output[data-active] {
174+
background: #0d3326;
175+
color: #2ed4a2;
176+
}
177+
main > nav > button {
178+
background: #2a2a2a;
179+
color: #ddd;
180+
border-color: rgba(255, 255, 255, 0.1);
181+
}
182+
main > nav > button:first-child {
183+
background: #2ed4a2;
97184
color: #fff;
185+
border-color: transparent;
98186
}
99187
}
100188
/* #endregion */

src/App.tsx

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { sendTransactions, SequenceConnect, useOpenConnectModal } from "@0xsequence/connect";
22
import { SequenceIndexer } from "@0xsequence/indexer";
3+
import { ChainId, networks } from "@0xsequence/network";
34
import {
45
getAccount,
56
getCallsStatus,
@@ -9,19 +10,22 @@ import {
910
signMessage,
1011
switchChain,
1112
} from "@wagmi/core";
12-
import { useLayoutEffect, useRef } from "react";
13+
import { useLayoutEffect, useRef, useState } from "react";
1314
import { concat, numberToHex } from "viem";
1415
import { useAccount, useDisconnect } from "wagmi";
1516

16-
import { ChainId, networks } from "@0xsequence/network";
17-
import hostExaApp from "./hostExaApp"; // host SDK: expose APIs to the iframe
17+
import type { ExaHost } from "./hostExaApp";
18+
import hostExaApp from "./hostExaApp";
1819
import { connectConfig, wagmiConfig } from "./sequence";
1920

2021
function App() {
2122
const { disconnect } = useDisconnect();
2223
const { setOpenConnectModal } = useOpenConnectModal();
2324
const { isConnected, isConnecting } = useAccount();
2425
const exaApp = useRef<HTMLIFrameElement>(null); // hold iframe element reference
26+
const [address, setAddress] = useState<string | null>(null);
27+
const [hasCard, setHasCard] = useState(false);
28+
const [exa, setExa] = useState<ExaHost | null>(null);
2529

2630
useLayoutEffect(() => {
2731
const iframe = exaApp.current;
@@ -83,6 +87,13 @@ function App() {
8387
throw new Error(`${method} not supported`);
8488
}
8589
},
90+
ready(exa: ExaHost) {
91+
setExa(() => exa);
92+
exa
93+
.getAddress()
94+
.then(setAddress)
95+
.catch(() => setAddress(null));
96+
},
8697
});
8798

8899
return () => host.cleanup(); // teardown host SDK on unmount
@@ -98,13 +109,52 @@ function App() {
98109
loading="eager" // load immediately; primary content
99110
className={isConnected ? undefined : "closed"}
100111
/>
101-
<button
102-
type="button"
103-
disabled={isConnecting}
104-
onClick={() => (isConnected ? disconnect() : setOpenConnectModal(true))}
105-
>
106-
{isConnected ? "Sign Out" : "Sign In"}
107-
</button>
112+
<main>
113+
<h1>exa app embed example</h1>
114+
<p>
115+
Embed the Exa Account in your app, provide a crypto-backed credit card to your users. Free, permissionless,
116+
and effortless to integrate.
117+
</p>
118+
<header>
119+
<code>{address ?? "not connected"}</code>
120+
<output data-active={hasCard || undefined}>{hasCard ? "card active" : "no card"}</output>
121+
</header>
122+
<nav>
123+
<button
124+
type="button"
125+
disabled={isConnecting}
126+
onClick={() => (isConnected ? disconnect() : setOpenConnectModal(true))}
127+
>
128+
{isConnected ? "Sign Out" : "Sign In"}
129+
</button>
130+
<button
131+
type="button"
132+
disabled={!exa}
133+
onClick={() => {
134+
if (!exa) return;
135+
exa
136+
.getAddress()
137+
.then(setAddress)
138+
.catch(() => setAddress(null));
139+
}}
140+
>
141+
Refresh Address
142+
</button>
143+
<button
144+
type="button"
145+
disabled={!exa}
146+
onClick={() => {
147+
if (!exa) return;
148+
exa
149+
.hasCard()
150+
.then(setHasCard)
151+
.catch(() => setHasCard(false));
152+
}}
153+
>
154+
Refresh Card
155+
</button>
156+
</nav>
157+
</main>
108158
</>
109159
);
110160
}

src/hostExaApp.ts

Lines changed: 20 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -28,56 +28,29 @@ export default function hostExaApp({
2828
/** Optional. Should open external URLs; defaults to safe window.open. */
2929
openUrl?: (url: string) => void;
3030
/** Optional. Called when Exa signals readiness (hide splash, etc.). */
31-
ready?: () => void;
31+
ready?: (exa: ExaHost) => void;
3232
}) {
3333
return exposeToIframe({
3434
iframe,
3535
miniAppOrigin: new URL(iframe.src).origin,
36-
sdk: {
37-
context: { client: { clientFid, platformType, appUrl, added: false }, user: { fid: 0 } },
38-
getChains: async () => [`eip155:${chainId}`],
39-
getCapabilities: async () => ["actions.openUrl", "actions.ready"],
40-
ethProviderRequestV2: async ({ id, method, params }) => ({
41-
jsonrpc: "2.0",
42-
id,
43-
result: await request(method, params),
44-
}),
45-
openUrl,
46-
ready,
47-
48-
// #region currently unused by exa app
49-
close: () => {},
50-
setPrimaryButton: () => {},
51-
addMiniApp: async () => ({}),
52-
viewCast: async () => {},
53-
viewProfile: async () => {},
54-
composeCast: async () => undefined as never,
55-
viewToken: async () => {},
56-
sendToken: async () => ({ success: false, reason: "send_failed" }),
57-
swapToken: async () => ({ success: false, reason: "swap_failed" }),
58-
openMiniApp: async () => {},
59-
signIn: async () => {
60-
throw new Error("unimplemented");
61-
},
62-
updateBackState: async () => {},
63-
impactOccurred: async () => {},
64-
notificationOccurred: async () => {},
65-
selectionChanged: async () => {},
66-
// #endregion
67-
68-
// #region unnecessary
69-
eip6963RequestProvider: () => {}, // handled by miniapp-sdk's ethereum provider
70-
requestCameraAndMicrophoneAccess: async () => {}, // handled by web api `navigator.mediaDevices.getUserMedia()`
71-
addFrame: () => {
72-
throw new Error("deprecated");
73-
},
74-
ethProviderRequest: () => {
75-
throw new Error("deprecated");
76-
},
77-
signManifest: () => {
78-
throw new Error("unsupported");
79-
},
80-
// #endregion
81-
} as MiniAppHost,
36+
sdk: new Proxy(
37+
{
38+
context: { client: { clientFid, platformType, appUrl, added: false }, user: { fid: 0 } },
39+
getChains: async () => [`eip155:${chainId}`],
40+
getCapabilities: async () => ["actions.openUrl", "actions.ready"],
41+
ethProviderRequestV2: async ({ id, method, params }: { id: number; method: string; params?: unknown }) => ({
42+
jsonrpc: "2.0",
43+
id,
44+
result: await request(method, params),
45+
}),
46+
openUrl,
47+
ready: (exa?: ExaHost) => {
48+
if (exa) ready(exa);
49+
},
50+
} as unknown as MiniAppHost,
51+
{ get: (target, property, receiver) => Reflect.get(target, property, receiver) ?? (() => {}) },
52+
),
8253
});
8354
}
55+
56+
export type ExaHost = { getAddress: () => Promise<string | null>; hasCard: () => Promise<boolean> };

0 commit comments

Comments
 (0)