Skip to content

Commit 416309c

Browse files
committed
feat: add ERC-721 NFT demo page for Soroban TicketNFT contract
1 parent a37aff0 commit 416309c

2 files changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client";
2+
3+
import { SorobanProvider } from "@/contexts/SorobanContext";
4+
import { WalletProvider } from "@/contexts/WalletContext";
5+
import { NFT721Demo } from "@/components/examples/NFT721Demo";
6+
7+
const sorobanConfig = {
8+
horizonUrl:
9+
process.env.NEXT_PUBLIC_HORIZON_URL || "https://horizon-testnet.stellar.org",
10+
sorobanRpcUrl:
11+
process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ||
12+
"https://soroban-testnet.stellar.org",
13+
networkPassphrase:
14+
process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ||
15+
"Test SDF Network ; September 2015",
16+
contracts: {
17+
ticketNft: process.env.NEXT_PUBLIC_TICKET_NFT_CONTRACT || "",
18+
},
19+
};
20+
21+
export default function NFT721Page() {
22+
return (
23+
<WalletProvider>
24+
<SorobanProvider config={sorobanConfig}>
25+
<div className="min-h-screen bg-gray-50">
26+
<div className="max-w-4xl mx-auto px-4 py-8">
27+
<header className="mb-8">
28+
<h1 className="text-3xl font-bold mb-2">ERC-721 NFT Demo</h1>
29+
<p className="text-gray-600">
30+
Interact with the <strong>TicketNFT</strong> Soroban contract — an
31+
ERC-721 compliant NFT deployed on Stellar testnet.
32+
</p>
33+
</header>
34+
35+
<main className="bg-white rounded-lg shadow-sm p-6">
36+
<NFT721Demo />
37+
</main>
38+
39+
<footer className="mt-6 text-xs text-gray-500 text-center">
40+
Set <code>NEXT_PUBLIC_TICKET_NFT_CONTRACT</code> in{" "}
41+
<code>.env.local</code> to point at your deployed contract.
42+
</footer>
43+
</div>
44+
</div>
45+
</SorobanProvider>
46+
</WalletProvider>
47+
);
48+
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"use client";
2+
3+
import { useState, useCallback } from "react";
4+
import { useSoroban } from "@/contexts/SorobanContext";
5+
import { useWallet } from "@/contexts/WalletContext";
6+
import {
7+
useOwnerOf,
8+
useBalanceOf,
9+
useIsValid,
10+
useTransferNFT,
11+
} from "@/hooks/useTicketNFT";
12+
13+
// ── Read panel: query ownerOf / balanceOf / isValid ──────────────────────────
14+
15+
function ReadPanel() {
16+
const { sdk } = useSoroban();
17+
const [tokenId, setTokenId] = useState<string>("1");
18+
const [ownerQuery, setOwnerQuery] = useState<bigint>(1n);
19+
const [balanceAddr, setBalanceAddr] = useState<string>("");
20+
const [validQuery, setValidQuery] = useState<bigint>(1n);
21+
22+
const { data: owner, loading: ownerLoading, error: ownerError, refetch: refetchOwner } =
23+
useOwnerOf(sdk, ownerQuery, { enabled: false });
24+
25+
const { data: balance, loading: balLoading, error: balError, refetch: refetchBalance } =
26+
useBalanceOf(sdk, balanceAddr, { enabled: false });
27+
28+
const { data: isValid, loading: validLoading, error: validError, refetch: refetchValid } =
29+
useIsValid(sdk, validQuery, { enabled: false });
30+
31+
return (
32+
<div className="space-y-6">
33+
{/* ownerOf */}
34+
<div className="border rounded-lg p-4">
35+
<h3 className="font-semibold mb-3">ownerOf(tokenId)</h3>
36+
<div className="flex gap-2">
37+
<input
38+
type="number"
39+
min="1"
40+
value={tokenId}
41+
onChange={(e) => {
42+
setTokenId(e.target.value);
43+
setOwnerQuery(BigInt(e.target.value || "1"));
44+
}}
45+
className="flex-1 px-3 py-2 border rounded text-sm"
46+
placeholder="Token ID"
47+
/>
48+
<button
49+
onClick={() => refetchOwner()}
50+
disabled={ownerLoading}
51+
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:bg-gray-400"
52+
>
53+
{ownerLoading ? "…" : "Query"}
54+
</button>
55+
</div>
56+
{ownerError && <p className="mt-2 text-sm text-red-600">{ownerError.message}</p>}
57+
{owner && <p className="mt-2 text-sm font-mono break-all text-green-700">{owner}</p>}
58+
</div>
59+
60+
{/* balanceOf */}
61+
<div className="border rounded-lg p-4">
62+
<h3 className="font-semibold mb-3">balanceOf(address)</h3>
63+
<div className="flex gap-2">
64+
<input
65+
value={balanceAddr}
66+
onChange={(e) => setBalanceAddr(e.target.value)}
67+
className="flex-1 px-3 py-2 border rounded text-sm font-mono"
68+
placeholder="G... Stellar address"
69+
/>
70+
<button
71+
onClick={() => refetchBalance()}
72+
disabled={balLoading || !balanceAddr}
73+
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:bg-gray-400"
74+
>
75+
{balLoading ? "…" : "Query"}
76+
</button>
77+
</div>
78+
{balError && <p className="mt-2 text-sm text-red-600">{balError.message}</p>}
79+
{balance !== null && balance !== undefined && (
80+
<p className="mt-2 text-sm text-green-700">Balance: <strong>{balance.toString()}</strong></p>
81+
)}
82+
</div>
83+
84+
{/* isValid */}
85+
<div className="border rounded-lg p-4">
86+
<h3 className="font-semibold mb-3">isValid(tokenId)</h3>
87+
<div className="flex gap-2">
88+
<input
89+
type="number"
90+
min="1"
91+
value={validQuery.toString()}
92+
onChange={(e) => setValidQuery(BigInt(e.target.value || "1"))}
93+
className="flex-1 px-3 py-2 border rounded text-sm"
94+
placeholder="Token ID"
95+
/>
96+
<button
97+
onClick={() => refetchValid()}
98+
disabled={validLoading}
99+
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:bg-gray-400"
100+
>
101+
{validLoading ? "…" : "Query"}
102+
</button>
103+
</div>
104+
{validError && <p className="mt-2 text-sm text-red-600">{validError.message}</p>}
105+
{isValid !== null && isValid !== undefined && (
106+
<p className={`mt-2 text-sm font-semibold ${isValid ? "text-green-700" : "text-red-600"}`}>
107+
Token is {isValid ? "valid ✓" : "invalid ✗"}
108+
</p>
109+
)}
110+
</div>
111+
</div>
112+
);
113+
}
114+
115+
// ── Write panel: transferFrom ─────────────────────────────────────────────────
116+
117+
function TransferPanel() {
118+
const { sdk } = useSoroban();
119+
const { address, signTransaction, isConnected } = useWallet();
120+
121+
const [from, setFrom] = useState<string>("");
122+
const [to, setTo] = useState<string>("");
123+
const [tokenId, setTokenId] = useState<bigint>(1n);
124+
125+
const signFn = useCallback(
126+
(xdr: string, opts: { networkPassphrase: string; address: string }) =>
127+
signTransaction(xdr, opts),
128+
[signTransaction]
129+
);
130+
131+
const { write, loading, error, isSuccess, reset } = useTransferNFT(
132+
sdk,
133+
from || address || "",
134+
to,
135+
tokenId,
136+
{ signTransaction: signFn }
137+
);
138+
139+
const handleTransfer = async () => {
140+
if (!isConnected) {
141+
alert("Connect your wallet first");
142+
return;
143+
}
144+
await write();
145+
};
146+
147+
if (isSuccess) {
148+
return (
149+
<div className="border border-green-200 bg-green-50 rounded-lg p-4">
150+
<p className="text-green-800 font-semibold">Transfer submitted ✓</p>
151+
<button onClick={reset} className="mt-3 text-sm text-green-700 underline">
152+
Make another transfer
153+
</button>
154+
</div>
155+
);
156+
}
157+
158+
return (
159+
<div className="border rounded-lg p-4 space-y-3">
160+
<h3 className="font-semibold">transferFrom(from, to, tokenId)</h3>
161+
162+
<div>
163+
<label className="block text-xs text-gray-500 mb-1">From (leave blank to use connected wallet)</label>
164+
<input
165+
value={from}
166+
onChange={(e) => setFrom(e.target.value)}
167+
className="w-full px-3 py-2 border rounded text-sm font-mono"
168+
placeholder={address || "G... Stellar address"}
169+
/>
170+
</div>
171+
172+
<div>
173+
<label className="block text-xs text-gray-500 mb-1">To *</label>
174+
<input
175+
value={to}
176+
onChange={(e) => setTo(e.target.value)}
177+
className="w-full px-3 py-2 border rounded text-sm font-mono"
178+
placeholder="G... recipient address"
179+
/>
180+
</div>
181+
182+
<div>
183+
<label className="block text-xs text-gray-500 mb-1">Token ID *</label>
184+
<input
185+
type="number"
186+
min="1"
187+
value={tokenId.toString()}
188+
onChange={(e) => setTokenId(BigInt(e.target.value || "1"))}
189+
className="w-full px-3 py-2 border rounded text-sm"
190+
/>
191+
</div>
192+
193+
{error && (
194+
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-800">
195+
{error.message}
196+
</div>
197+
)}
198+
199+
<button
200+
onClick={handleTransfer}
201+
disabled={loading || !to}
202+
className="w-full px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
203+
>
204+
{loading ? "Submitting…" : "Transfer NFT"}
205+
</button>
206+
207+
{!isConnected && (
208+
<p className="text-xs text-gray-500 text-center">Connect your wallet to sign transactions</p>
209+
)}
210+
</div>
211+
);
212+
}
213+
214+
// ── Main demo component ───────────────────────────────────────────────────────
215+
216+
type Tab = "read" | "transfer";
217+
218+
export function NFT721Demo() {
219+
const [tab, setTab] = useState<Tab>("read");
220+
221+
const tabs: { id: Tab; label: string }[] = [
222+
{ id: "read", label: "Read (ownerOf / balanceOf / isValid)" },
223+
{ id: "transfer", label: "Write (transferFrom)" },
224+
];
225+
226+
return (
227+
<div className="max-w-2xl">
228+
{/* Contract info banner */}
229+
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm">
230+
<p className="font-semibold text-blue-900 mb-1">ERC-721 on Soroban — TicketNFT contract</p>
231+
<p className="text-blue-700">
232+
The <code className="bg-blue-100 px-1 rounded">ticket_nft</code> contract implements
233+
ERC-721 semantics: each token has a unique owner, supports{" "}
234+
<code className="bg-blue-100 px-1 rounded">ownerOf</code>,{" "}
235+
<code className="bg-blue-100 px-1 rounded">balanceOf</code>, and{" "}
236+
<code className="bg-blue-100 px-1 rounded">transferFrom</code>.
237+
Calls are made via the <code className="bg-blue-100 px-1 rounded">useTicketNFT</code> hooks
238+
which wrap <code className="bg-blue-100 px-1 rounded">useSorobanContractRead/Write</code>.
239+
</p>
240+
</div>
241+
242+
{/* Tabs */}
243+
<div className="border-b mb-6">
244+
<nav className="flex space-x-4">
245+
{tabs.map(({ id, label }) => (
246+
<button
247+
key={id}
248+
onClick={() => setTab(id)}
249+
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
250+
tab === id
251+
? "border-blue-600 text-blue-600"
252+
: "border-transparent text-gray-600 hover:text-gray-900"
253+
}`}
254+
>
255+
{label}
256+
</button>
257+
))}
258+
</nav>
259+
</div>
260+
261+
{tab === "read" && <ReadPanel />}
262+
{tab === "transfer" && <TransferPanel />}
263+
</div>
264+
);
265+
}

0 commit comments

Comments
 (0)