Typescript前端

Icon Link构建前端

Icon Link设置

使用以下命令在与您的合约相同的父文件夹中初始化一个新的带有 TypeScript 的 React 应用。

npx create-react-app frontend --template typescript

让我们进入前端文件夹:

cd frontend

接下来,在您的 frontend 文件夹中安装以下包:

npm install fuels @fuels/react @fuels/connectors @tanstack/react-query

Icon Link生成合约类型

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

Icon Link钱包提供者

在您的 index.tsx 文件中,使用 FuelProviderQueryClientProvider 组件包装您的 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>
);

Icon Link连接合约

接下来,打开 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]);

Icon Link样式

将下面的 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;
}

Icon Link用户界面

在我们的应用程序中,我们将有两个选项卡:一个用于查看所有列出的待售物品,另一个用于列出新的待售物品。

我们使用另一个名为 active 的状态变量,我们可以使用它来在我们的选项卡之间切换。我们可以将默认选项卡设置为显示所有列出的物品。

const [active, setActive] = useState<"all-items" | "list-item">("all-items");

接下来,我们可以创建我们的组件来显示和列出物品。

Icon Link列出物品

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){

要列出一个物品,我们将创建一个表单,用户可以在其中输入要列出的物品的元数据字符串和价格。 让我们从为 metadataprice 添加一些状态变量开始。我们还可以添加一个 status 变量来跟踪提交状态。

const [metadata, setMetadata] = useState<string>("");
const [price, setPrice] = useState<string>("0");
const [status, setStatus] = useState<'success' | 'error' | 'loading' | 'none'>('none');

我们需要添加 handleSubmit 函数。 我们可以使用合约属性调用 list_item 函数,并从表单中传入 pricemetadata

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!

Icon Link显示所有物品

接下来,在 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>
  );
}

Icon Link物品卡

现在让我们创建物品卡组件。 在组件文件夹中创建一个名为 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>
  );
}

现在,您应该能够查看并购买合约中列出的所有物品。

Icon Link检查点

通过检查以下代码,确保所有文件都已正确配置。如果需要额外的帮助,请参考此处的存储库 Icon Link

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>
    )
}

Icon Link运行项目

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!