使用以下命令在与您的合约相同的父文件夹中初始化一个新的带有 TypeScript 的 React 应用。
npx create-react-app frontend --template typescript
让我们进入前端文件夹:
cd frontend
接下来,在您的 frontend
文件夹中安装以下包:
npm install fuels @fuels/react @fuels/connectors @tanstack/react-query
fuels init
命令生成一个 fuels.config.ts
文件,该文件由 SDK 使用以生成合约类型。
使用 contracts
标志定义您的合约文件夹的位置,使用 output
标志定义您想要创建生成文件的位置。
在您的前端文件夹中运行以下命令生成配置文件:
npx fuels init --contracts ../contract/ --output ./src/contracts
现在您有了一个 fuels.config.ts
文件,您可以使用 fuels build
命令重新构建您的合约并生成类型。
运行此命令将解释您的合约的输出 ABI JSON,并生成正确的 TypeScript 定义。
如果您在 fuel-project/counter-contract/out
文件夹中看到了 ABI JSON,则将能够在那里看到。
在 fuel-project/frontend
目录中运行:
npx fuels build
成功的过程应该打印并输出如下:
Building..
Building Sway programs using built-in 'forc' binary
Generating types..
🎉 Build completed successfully!
现在您应该能够找到一个新的文件夹 fuel-project/frontend/src/contracts
。
在您的 index.tsx
文件中,使用 FuelProvider
和 QueryClientProvider
组件包装您的 App
组件,以启用 Fuel 的自定义 React 钩子以实现钱包功能。
在这里,您可以传递自定义的钱包连接器,以自定义用户可以用来连接到您的应用程序的钱包。
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { FuelProvider } from '@fuels/react';
import {
FuelWalletConnector,
FuelWalletDevelopmentConnector,
FueletWalletConnector,
} from '@fuels/connectors';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<FuelProvider
fuelConfig={{
connectors: [
new FuelWalletConnector(),
new FuelWalletDevelopmentConnector(),
new FueletWalletConnector(),
],
}}
>
<App />
</FuelProvider>
</QueryClientProvider>
</React.StrictMode>
);
接下来,打开 src/App.tsx
文件,并将样板代码替换为以下模板:
import { useState, useMemo } from "react";
import { useConnectUI, useIsConnected, useWallet } from "@fuels/react";
import { ContractAbi__factory } from "./contracts";
import AllItems from "./components/AllItems";
import ListItem from "./components/ListItem";
import "./App.css";
const CONTRACT_ID =
"0x797d208d0104131c2ab1f1e09c4914c7aef5b699fb494be864a5c37057076921";
function App() {
const [active, setActive] = useState<"all-items" | "list-item">("all-items");
const { isConnected } = useIsConnected();
const { connect, isConnecting } = useConnectUI();
const { wallet } = useWallet();
const contract = useMemo(() => {
if (wallet) {
const contract = ContractAbi__factory.connect(CONTRACT_ID, wallet);
return contract;
}
return null;
}, [wallet]);
return (
<div className="App">
<header>
<h1>Sway Marketplace</h1>
</header>
<nav>
<ul>
<li
className={active === "all-items" ? "active-tab" : ""}
onClick={() => setActive("all-items")}
>
See All Items
</li>
<li
className={active === "list-item" ? "active-tab" : ""}
onClick={() => setActive("list-item")}
>
List an Item
</li>
</ul>
</nav>
<div>
{isConnected ? (
<div>
{active === "all-items" && <AllItems contract={contract} />}
{active === "list-item" && <ListItem contract={contract} />}
</div>
) : (
<div>
<button
onClick={() => {
connect();
}}
>
{isConnecting ? "Connecting" : "Connect"}
</button>
</div>
)}
</div>
</div>
);
}
export default App;
在文件顶部,将 CONTRACT_ID
更改为您之前部署并设置为常量的合约 ID。
const CONTRACT_ID =
"0x797d208d0104131c2ab1f1e09c4914c7aef5b699fb494be864a5c37057076921";
使用 @fuels/react
包中的 React 钩子以将我们的钱包连接到 dapp。在 App
函数中,我们可以像这样调用这些钩子:
const { isConnected } = useIsConnected();
const { connect, isConnecting } = useConnectUI();
const { wallet } = useWallet();
useWallet
钩子的 wallet
变量将具有类型 FuelWalletLocked
。
您可以将锁定的钱包视为您无法为其签署交易的用户钱包,并将解锁的钱包视为您拥有私钥并能够签署交易的钱包。
const { wallet } = useWallet();
useMemo
钩子用于使用已连接的钱包连接到我们的合约。
const contract = useMemo(() => {
if (wallet) {
const contract = ContractAbi__factory.connect(CONTRACT_ID, wallet);
return contract;
}
return null;
}, [wallet]);
将下面的 CSS 代码复制并粘贴到您的 App.css
文件中以添加一些简单的样式。
.App {
text-align: center;
}
nav > ul {
list-style-type: none;
display: flex;
justify-content: center;
gap: 1rem;
padding-inline-start: 0;
}
nav > ul > li {
cursor: pointer;
}
.form-control{
text-align: left;
font-size: 18px;
display: flex;
flex-direction: column;
margin: 0 auto;
max-width: 400px;
}
.form-control > input {
margin-bottom: 1rem;
}
.form-control > button {
cursor: pointer;
background: #054a9f;
color: white;
border: none;
border-radius: 8px;
padding: 10px 0;
font-size: 20px;
}
.items-container{
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin: 1rem 0;
}
.item-card{
box-shadow: 0px 0px 10px 2px rgba(0, 0, 0, 0.2);
border-radius: 8px;
max-width: 300px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.active-tab{
border-bottom: 4px solid #77b6d8;
}
button {
cursor: pointer;
background: #054a9f;
border: none;
border-radius: 12px;
padding: 10px 20px;
margin-top: 20px;
font-size: 20px;
color: white;
}
在我们的应用程序中,我们将有两个选项卡:一个用于查看所有列出的待售物品,另一个用于列出新的待售物品。
我们使用另一个名为 active
的状态变量,我们可以使用它来在我们的选项卡之间切换。我们可以将默认选项卡设置为显示所有列出的物品。
const [active, setActive] = useState<"all-items" | "list-item">("all-items");
接下来,我们可以创建我们的组件来显示和列出物品。
在 src
文件夹中创建一个名为 components
的新文件夹。
mkdir components
然后在其中创建一个名为 ListItem.tsx
的文件。
touch ListItem.tsx
在文件顶部,从 react
中导入 useState
钩子,从 contracts
文件夹中导入生成的合约 ABI,以及从 fuels
中导入 bn
(big number)类型。
import { useState } from "react";
import { ContractAbi } from "../contracts";
import { bn } from "fuels";
此组件将以我们在 App.tsx
中创建的合约作为属性,因此让我们为组件创建一个接口。
interface ListItemsProps {
contract: ContractAbi | null;
}
我们可以设置如下模板函数的模板。
export default function ListItem({contract}: ListItemsProps){
要列出一个物品,我们将创建一个表单,用户可以在其中输入要列出的物品的元数据字符串和价格。
让我们从为 metadata
和 price
添加一些状态变量开始。我们还可以添加一个 status
变量来跟踪提交状态。
const [metadata, setMetadata] = useState<string>("");
const [price, setPrice] = useState<string>("0");
const [status, setStatus] = useState<'success' | 'error' | 'loading' | 'none'>('none');
我们需要添加 handleSubmit
函数。
我们可以使用合约属性调用 list_item
函数,并从表单中传入 price
和 metadata
。
async function handleSubmit(e: React.FormEvent<HTMLFormElement>){
e.preventDefault();
setStatus('loading')
if(contract !== null){
try {
const priceInput = bn.parseUnits(price.toString());
await contract.functions
.list_item(priceInput, metadata)
.txParams({
gasLimit: 300_000,
})
.call();
setStatus('success')
} catch (e) {
console.log("ERROR:", e);
setStatus('error')
}
} else {
console.log("ERROR: Contract is null");
}
}
在标题下,为表单添加以下代码:
return (
<div>
<h2>List an Item</h2>
{status === 'none' &&
<form onSubmit={handleSubmit}>
<div className="form-control">
<label htmlFor="metadata">Item Metadata:</label>
<input
id="metadata"
type="text"
pattern="\w{20}"
title="The metatdata must be 20 characters"
required
onChange={(e) => setMetadata(e.target.value)}
/>
</div>
<div className="form-control">
<label htmlFor="price">Item Price:</label>
<input
id="price"
type="number"
required
min="0"
step="any"
inputMode="decimal"
placeholder="0.00"
onChange={(e) => {
setPrice(e.target.value);
}}
/>
</div>
<div className="form-control">
<button type="submit">List item</button>
</div>
</form>
}
{status === 'success' && <div>Item successfully listed!</div>}
{status === 'error' && <div>Error listing item. Please try again.</div>}
{status === 'loading' && <div>Listing item...</div>}
</div>
)
}
现在,尝试列出一个物品以确保它能正常工作。
您应该会看到消息 Item successfully listed!
。
接下来,在 components
文件夹中创建一个名为 AllItems.tsx
的新文件。
touch AllItems.tsx
复制并粘贴以下组件的模板代码:
import { useState, useEffect } from "react";
import { ContractAbi } from "../contracts";
import ItemCard from "./ItemCard";
import { BN } from "fuels";
import { ItemOutput } from "../contracts/contracts/ContractAbi";
interface AllItemsProps {
contract: ContractAbi | null;
}
export default function AllItems({ contract }: AllItemsProps) {
在这里,我们可以获取物品计数以查看列出了多少个物品,然后遍历每个物品以获取物品详情。
首先,让我们创建一些状态变量来存储列出的物品数量、物品详情数组和加载状态。
const [items, setItems] = useState<ItemOutput[]>([]);
const [itemCount, setItemCount] = useState<number>(0);
const [status, setStatus] = useState<"success" | "loading" | "error">(
"loading"
);
接下来,让我们在 useEffect
钩子中获取物品。
因为这些是只读函数,我们可以使用 get
方法而不是 call
方法来模拟交易的干扰,以便用户不必签署任何内容。
useEffect(() => {
async function getAllItems() {
if (contract !== null) {
try {
let { value } = await contract.functions
.get_count()
.txParams({
gasLimit: 100_000,
})
.get();
let formattedValue = new BN(value).toNumber();
setItemCount(formattedValue);
let max = formattedValue + 1;
let tempItems = [];
for (let i = 1; i < max; i++) {
let resp = await contract.functions
.get_item(i)
.txParams({
gasLimit: 100_000,
})
.get();
tempItems.push(resp.value);
}
setItems(tempItems);
setStatus("success");
} catch (e) {
setStatus("error");
console.log("ERROR:", e);
}
}
}
getAllItems();
}, [contract]);
如果物品计数大于 0
,并且我们能够成功加载物品,我们可以遍历它们并显示一个物品卡。
物品卡将显示物品详情和一个购买按钮来购买该物品,因此我们需要将合约和物品作为属性传递。
return (
<div>
<h2>All Items</h2>
{status === "success" && (
<div>
{itemCount === 0 ? (
<div>Uh oh! No items have been listed yet</div>
) : (
<div>
<div>Total items: {itemCount}</div>
<div className="items-container">
{items.map((item) => (
<ItemCard
key={item.id.format()}
contract={contract}
item={item}
/>
))}
</div>
</div>
)}
</div>
)}
{status === "error" && (
<div>Something went wrong, try reloading the page.</div>
)}
{status === "loading" && <div>Loading...</div>}
</div>
);
}
现在让我们创建物品卡组件。
在组件文件夹中创建一个名为 ItemCard.tsx
的新文件。
touch ItemCard.tsx
然后,复制并粘贴以下模板代码。
import { useState } from "react";
import { ItemOutput } from "../contracts/contracts/ContractAbi";
import { ContractAbi } from "../contracts";
import { BN } from 'fuels';
interface ItemCardProps {
contract: ContractAbi | null;
item: ItemOutput;
}
export default function ItemCard({ item, contract }: ItemCardProps) {
添加一个 status
变量来跟踪购买按钮的状态。
const [status, setStatus] = useState<'success' | 'error' | 'loading' | 'none'>('none');
创建一个新的异步函数名为 handleBuyItem
。
因为这个函数是可支付的,并且会将硬币转移到物品所有者,所以我们需要在这里执行一些特殊的操作。
每当我们调用使用 Sway 中的转移或铸造功能的任何函数时,我们都必须将匹配的变量输出附加到带有 txParams
方法的调用中。因为 buy_item
函数只是将资产转移给物品所有者,所以变量输出的数量是 1
。
接下来,因为这个函数是可支付的,并且用户需要转移物品的价格,所以我们将使用 callParams
方法转发金额。使用 Fuel 您可以转移任何类型的资产,因此我们需要指定金额和资产 ID。
async function handleBuyItem() {
if (contract !== null) {
setStatus('loading')
try {
const baseAssetId = contract.provider.getBaseAssetId();
await contract.functions.buy_item(item.id)
.txParams({
variableOutputs: 1,
})
.callParams({
forward: [item.price, baseAssetId],
})
.call()
setStatus("success");
} catch (e) {
console.log("ERROR:", e);
setStatus("error");
}
}
}
然后将物品详情和状态消息添加到卡片中。
return (
<div className="item-card">
<div>Id: {new BN(item.id).toNumber()}</div>
<div>Metadata: {item.metadata}</div>
<div>Price: {new BN(item.price).formatUnits()} ETH</div>
<h3>Total Bought: {new BN(item.total_bought).toNumber()}</h3>
{status === 'success' && <div>Purchased ✅</div>}
{status === 'error' && <div>Something went wrong ❌</div>}
{status === 'none' && <button data-testid={`buy-button-${item.id}`} onClick={handleBuyItem}>Buy Item</button>}
{status === 'loading' && <div>Buying item..</div>}
</div>
);
}
现在,您应该能够查看并购买合约中列出的所有物品。
通过检查以下代码,确保所有文件都已正确配置。如果需要额外的帮助,请参考此处的存储库
App.tsx
import { useState, useMemo } from "react";
import { useConnectUI, useIsConnected, useWallet } from "@fuels/react";
import { ContractAbi__factory } from "./contracts";
import AllItems from "./components/AllItems";
import ListItem from "./components/ListItem";
import "./App.css";
const CONTRACT_ID =
"0x797d208d0104131c2ab1f1e09c4914c7aef5b699fb494be864a5c37057076921";
function App() {
const [active, setActive] = useState<"all-items" | "list-item">("all-items");
const { isConnected } = useIsConnected();
const { connect, isConnecting } = useConnectUI();
const { wallet } = useWallet();
const contract = useMemo(() => {
if (wallet) {
const contract = ContractAbi__factory.connect(CONTRACT_ID, wallet);
return contract;
}
return null;
}, [wallet]);
return (
<div className="App">
<header>
<h1>Sway Marketplace</h1>
</header>
<nav>
<ul>
<li
className={active === "all-items" ? "active-tab" : ""}
onClick={() => setActive("all-items")}
>
See All Items
</li>
<li
className={active === "list-item" ? "active-tab" : ""}
onClick={() => setActive("list-item")}
>
List an Item
</li>
</ul>
</nav>
<div>
{isConnected ? (
<div>
{active === "all-items" && <AllItems contract={contract} />}
{active === "list-item" && <ListItem contract={contract} />}
</div>
) : (
<div>
<button
onClick={() => {
connect();
}}
>
{isConnecting ? "Connecting" : "Connect"}
</button>
</div>
)}
</div>
</div>
);
}
export default App;
AllItems.tsx
import { useState, useEffect } from "react";
import { ContractAbi } from "../contracts";
import ItemCard from "./ItemCard";
import { BN } from "fuels";
import { ItemOutput } from "../contracts/contracts/ContractAbi";
interface AllItemsProps {
contract: ContractAbi | null;
}
export default function AllItems({ contract }: AllItemsProps) {
const [items, setItems] = useState<ItemOutput[]>([]);
const [itemCount, setItemCount] = useState<number>(0);
const [status, setStatus] = useState<"success" | "loading" | "error">(
"loading"
);
useEffect(() => {
async function getAllItems() {
if (contract !== null) {
try {
let { value } = await contract.functions
.get_count()
.txParams({
gasLimit: 100_000,
})
.get();
let formattedValue = new BN(value).toNumber();
setItemCount(formattedValue);
let max = formattedValue + 1;
let tempItems = [];
for (let i = 1; i < max; i++) {
let resp = await contract.functions
.get_item(i)
.txParams({
gasLimit: 100_000,
})
.get();
tempItems.push(resp.value);
}
setItems(tempItems);
setStatus("success");
} catch (e) {
setStatus("error");
console.log("ERROR:", e);
}
}
}
getAllItems();
}, [contract]);
return (
<div>
<h2>All Items</h2>
{status === "success" && (
<div>
{itemCount === 0 ? (
<div>Uh oh! No items have been listed yet</div>
) : (
<div>
<div>Total items: {itemCount}</div>
<div className="items-container">
{items.map((item) => (
<ItemCard
key={item.id.format()}
contract={contract}
item={item}
/>
))}
</div>
</div>
)}
</div>
)}
{status === "error" && (
<div>Something went wrong, try reloading the page.</div>
)}
{status === "loading" && <div>Loading...</div>}
</div>
);
}
ItemCard.tsx
import { useState } from "react";
import { ItemOutput } from "../contracts/contracts/ContractAbi";
import { ContractAbi } from "../contracts";
import { BN } from 'fuels';
interface ItemCardProps {
contract: ContractAbi | null;
item: ItemOutput;
}
export default function ItemCard({ item, contract }: ItemCardProps) {
const [status, setStatus] = useState<'success' | 'error' | 'loading' | 'none'>('none');
async function handleBuyItem() {
if (contract !== null) {
setStatus('loading')
try {
const baseAssetId = contract.provider.getBaseAssetId();
await contract.functions.buy_item(item.id)
.txParams({
variableOutputs: 1,
})
.callParams({
forward: [item.price, baseAssetId],
})
.call()
setStatus("success");
} catch (e) {
console.log("ERROR:", e);
setStatus("error");
}
}
}
return (
<div className="item-card">
<div>Id: {new BN(item.id).toNumber()}</div>
<div>Metadata: {item.metadata}</div>
<div>Price: {new BN(item.price).formatUnits()} ETH</div>
<h3>Total Bought: {new BN(item.total_bought).toNumber()}</h3>
{status === 'success' && <div>Purchased ✅</div>}
{status === 'error' && <div>Something went wrong ❌</div>}
{status === 'none' && <button data-testid={`buy-button-${item.id}`} onClick={handleBuyItem}>Buy Item</button>}
{status === 'loading' && <div>Buying item..</div>}
</div>
);
}
ListItem.tsx
import { useState } from "react";
import { ContractAbi } from "../contracts";
import { bn } from "fuels";
interface ListItemsProps {
contract: ContractAbi | null;
}
export default function ListItem({contract}: ListItemsProps){
const [metadata, setMetadata] = useState<string>("");
const [price, setPrice] = useState<string>("0");
const [status, setStatus] = useState<'success' | 'error' | 'loading' | 'none'>('none');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>){
e.preventDefault();
setStatus('loading')
if(contract !== null){
try {
const priceInput = bn.parseUnits(price.toString());
await contract.functions
.list_item(priceInput, metadata)
.txParams({
gasLimit: 300_000,
})
.call();
setStatus('success')
} catch (e) {
console.log("ERROR:", e);
setStatus('error')
}
} else {
console.log("ERROR: Contract is null");
}
}
return (
<div>
<h2>List an Item</h2>
{status === 'none' &&
<form onSubmit={handleSubmit}>
<div className="form-control">
<label htmlFor="metadata">Item Metadata:</label>
<input
id="metadata"
type="text"
pattern="\w{20}"
title="The metatdata must be 20 characters"
required
onChange={(e) => setMetadata(e.target.value)}
/>
</div>
<div className="form-control">
<label htmlFor="price">Item Price:</label>
<input
id="price"
type="number"
required
min="0"
step="any"
inputMode="decimal"
placeholder="0.00"
onChange={(e) => {
setPrice(e.target.value);
}}
/>
</div>
<div className="form-control">
<button type="submit">List item</button>
</div>
</form>
}
{status === 'success' && <div>Item successfully listed!</div>}
{status === 'error' && <div>Error listing item. Please try again.</div>}
{status === 'loading' && <div>Listing item...</div>}
</div>
)
}
在 fuel-project/frontend
目录中运行以下命令:
npm start
Compiled successfully!
You can now view frontend in the browser.
Local: http://localhost:3000
On Your Network: http://192.168.4.48:3000
Note that the development build is not optimized.
To create a production build, use npm run build.
至此,前端部分完成!您刚刚在 Fuel 上创建了一个完整的 DApp!