このガイドでは、Sequenceスタックのシンプルなツールを使ってカスタムマーケットプレイスを作成する手順を説明します。

これらのツールで以下のことが可能になります:

  1. ミント:Sequence Builderからウォレットへトークンをミント
  2. ウォレット認証:Web SDKを使ったユーザー認証
  3. Marketplace SDKのセットアップ:Marketplace SDKの基本的な設定ガイド
  4. コレクションの表示:マーケットプレイスで利用可能なコレクションを取得し、表示します。
  5. 出品とオファー:ユーザーがトークンを出品したり、オファーを出せるようにします。

1. ミント

最初のステップは、Sequence Builderでコレクティブルを作成し、いくつかのトークンをミントすることです。詳しくはこちらのガイドを参照し、ミントしたtokenIdを使って今後のステップで注文のクエリや実行を行います。

2. ウォレット認証

プロジェクトには、ユーザーをウォレットで認証する仕組みが必要です。

このガイドでは、GoogleやApple認証に加え、CoinbaseやMetamaskなどユーザー自身のウォレットも利用できる、Embedded WalletWeb SDKコネクタを使用します。

パッケージのインストール

すでに優れたUIと統合されたMarketplace Boilerplateから始めることもできますし、ここではReactをゼロから使う方法も順を追って説明します。

まず、任意のフォルダ名でプロジェクトを作成します。

npm
npm create vite --template react-ts
# or
pnpm create vite --template react-ts
# or
yarn create vite --template react-ts

次に、<project_name>フォルダで必要なパッケージをインストールします。

pnpm
npm install @0xsequence/connect @0xsequence/hooks wagmi ethers viem 0xsequence @tanstack/react-query
# or
pnpm install @0xsequence/connect @0xsequence/hooks wagmi ethers viem 0xsequence @tanstack/react-query
# or
yarn add @0xsequence/connect @0xsequence/hooks wagmi ethers viem 0xsequence @tanstack/react-query

設定ファイルを作成する

config.ts
import { createConfig } from "@0xsequence/connect";

const projectAccessKey = import.meta.env.VITE_PROJECT_ACCESS_KEY;
const waasConfigKey = import.meta.env.VITE_WAAS_CONFIG_KEY;
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const walletConnectId = import.meta.env.VITE_WALLET_CONNECT_ID;

export const config: any = createConfig("waas", {
  projectAccessKey,
  chainIds: [1, 137],
  defaultChainId: 1,
  appName: "Demo Dapp",
  waasConfigKey, // for waas
  google: {
    clientId: googleClientId,
  },
  walletConnect: {
    projectId: walletConnectId,
  },
});

使用するすべてのchainIdを追加してください。

main.tsxでレイアウトを作成します。

main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import "./index.css";
import { config } from "./config";
import { SequenceConnect } from "@0xsequence/connect";

function Dapp() {
  return (
    <SequenceConnect config={config}>
      <App />
    </SequenceConnect>
  );
}

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <Dapp />
  </StrictMode>,
)

認証コンポーネント

認証を完了するには、認証用コンポーネントが必要です。

App.tsx
import { useAccount, useDisconnect } from "wagmi";
import "./App.css";
import { useOpenConnectModal } from "@0xsequence/connect";

function App() {
  const { setOpenConnectModal } = useOpenConnectModal();
  const { isConnected, address } = useAccount();
  const { disconnect } = useDisconnect();

  function handleDisconnect() {
    disconnect();
  }

  if (isConnected && address)
    return (
      <>
        <p>User Address: {address}</p>
        <button onClick={handleDisconnect}>Disconnect</button>
      </>
    );

  return (
    <>
      <button onClick={() => setOpenConnectModal(true)}>Connect</button>
    </>
  );
}

export default App;

3. Marketplace SDKのセットアップ

Marketplace SDKは、マーケットプレイスをアプリケーションにシームレスに統合できる包括的なツールキットです。詳しくはMarketplace SDKをご覧ください。

npm install @0xsequence/connect @0xsequence/checkout @0xsequence/wallet-widget @0xsequence/marketplace-sdk @0xsequence/design-system @0xsequence/network @0xsequence/indexer @0xsequence/metadata wagmi ethers@^6 viem 0xsequence @tanstack/react-query @tanstack/react-query-devtools @legendapp/state framer-motion pino-pretty tailwindcss @tailwindcss/vite
# or
pnpm add @0xsequence/connect @0xsequence/checkout @0xsequence/wallet-widget @0xsequence/marketplace-sdk @0xsequence/design-system @0xsequence/network @0xsequence/indexer @0xsequence/metadata wagmi ethers@^6 viem 0xsequence @tanstack/react-query @tanstack/react-query-devtools @legendapp/state framer-motion pino-pretty tailwindcss @tailwindcss/vite
# or
yarn add @0xsequence/connect @0xsequence/checkout @0xsequence/wallet-widget @0xsequence/marketplace-sdk @0xsequence/design-system @0xsequence/network @0xsequence/indexer @0xsequence/metadata wagmi ethers@^6 viem 0xsequence @tanstack/react-query @tanstack/react-query-devtools @legendapp/state framer-motion pino-pretty tailwindcss @tailwindcss/vite

必要なCSSインポートをメインのスタイルファイルに追加します。

index.css
@import 'tailwindcss';
@import '@0xsequence/marketplace-sdk/styles/preset';
@import '@0xsequence/design-system/preset';

vite.config.tsでTailwind CSSプラグインを設定します。

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

設定ファイルを作成する

config.ts
import type { SdkConfig } from '@0xsequence/marketplace-sdk';

const projectAccessKey = import.meta.env.VITE_PROJECT_ACCESS_KEY;
const projectId = import.meta.env.VITE_PROJECT_ID!;
const walletConnectId = import.meta.env.VITE_WALLET_CONNECT_ID;

export function getConfig() {
  const config = {
    projectId,
    projectAccessKey,
    walletConnectProjectId: walletConnectId
  } satisfies SdkConfig;

  return config;
}

Marketplace SDK プロバイダーを Web SDK プロバイダーと一緒に追加

App.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import "./index.css";
import { config } from "./config";
import { SequenceConnect } from "@0xsequence/connect";
import {
  MarketplaceProvider,
  ModalProvider,
} from "@0xsequence/marketplace-sdk/react";
import { getConfig } from "./config";
import { ThemeProvider, ToastProvider } from '@0xsequence/design-system';

const sdkConfig = getConfig();

function Dapp() {
  return (
    <ThemeProvider>
      <ToastProvider>
        <SequenceConnect config={config}>
          <MarketplaceProvider config={sdkConfig}>
            <App />
            <ModalProvider />
          </MarketplaceProvider>
        </SequenceConnect>
      </ToastProvider>
    </ThemeProvider>
  );
}

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <Dapp />
  </StrictMode>,
)

4. コレクションの表示

マーケットプレイスのコレクションを表示するには、src/app/componentsディレクトリ内にCollectionsというフォルダを作成します。このフォルダ内に、index.tsxCollection.tsxの2つのファイルを作成します。

Collection.tsx
import type { MarketplaceCollection } from "@0xsequence/marketplace-sdk";
import { useCollection } from "@0xsequence/marketplace-sdk/react";

export const Collection = ({
  collection,
  onSelectCollection,
}: {
  collection: MarketplaceCollection;
  onSelectCollection: (value: MarketplaceCollection) => void;
}) => {
  const { data } = useCollection({
    chainId: collection.chainId,
    collectionAddress: collection.address,
  });

  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
  const logoURI = data?.logoURI;
  // You can add a placeholder image to improve the UX
  const image = data?.extensions.ogImage || collection.bannerUrl || logoURI;
  const description = data?.extensions.description;
  const name = data?.name;
  const symbol = data?.symbol;
  const contractType = data?.type;

  function handleOnSelectCollection() {
    onSelectCollection(collection);
  }

  return (
    <div
      onClick={handleOnSelectCollection}
      className="flex flex-col gap-2 border rounded-lg cursor-pointer w-[400px]"
    >
      <h2>
        {name} {symbol && <span>({symbol})</span>}
      </h2>
      <p>{collection.address}</p>
      <p>Chain ID: {collection.chainId}</p>
      <img
        src={image}
        alt={collection.address}
        className="w-[200px] h-[200px] object-cover object-center"
      />
      <p>Contract type: {contractType}</p>
      <p>{description}</p>
    </div>
  );
};
index.tsx
import type { MarketplaceCollection } from "@0xsequence/marketplace-sdk";
import { Collection } from "./Collection";
import { useMarketplaceConfig } from "@0xsequence/marketplace-sdk/react";

export const Collections = ({ onSelectCollection } : { onSelectCollection: (value: MarketplaceCollection) => void }) => {
	const { data } = useMarketplaceConfig();

	const collections: MarketplaceCollection[] = data?.collections || [];
  return (
    <div>
      {collections?.map((collection) => (
        <Collection key={collection.address} collection={collection} onSelectCollection={onSelectCollection}/>
      ))}
    </div>
  );
};

最後に、App.tsxにCollectionsコンポーネントを追加します。

import { useAccount, useDisconnect } from "wagmi";
import "./App.css";
import { useOpenConnectModal } from "@0xsequence/connect";
import { Collections } from "./components/Collections";
import type { MarketplaceCollection } from "@0xsequence/marketplace-sdk";
import { useState } from "react";
import { Collectibles } from "./components/Collectibles";
import type { Address } from "viem";

function App() {
  const { setOpenConnectModal } = useOpenConnectModal();
  const { isConnected, address } = useAccount();
  const { disconnect } = useDisconnect();
  const [collectionSelected, setCollectionSelected] =
    useState<MarketplaceCollection | null>(null);

  function handleDisconnect() {
    disconnect();
  }
  
  function onSelectCollection(value: MarketplaceCollection) {
    setCollectionSelected(value);
  }

  function onRestartSelectedCollectionValue() {
    setCollectionSelected(null);
  }

  if (!isConnected && !address) {
    return (
      <>
        <button onClick={() => setOpenConnectModal(true)}>Connect</button>
      </>
    );
  }

  return (
    <>
      <p>User Address: {address}</p>
      <button onClick={handleDisconnect}>Disconnect</button>
      {!collectionSelected ? (
        <Collections onSelectCollection={onSelectCollection} />
      ) : (
        <>
          <button onClick={onRestartSelectedCollectionValue}>Show Collections</button>
          <div>{JSON.stringify(collectionSelected)}</div>
        </>
      )}
    </>
  );
}

export default App;

5. 出品とオファー

このセクションでは、マーケットプレイスの出品とオファーをUIに統合します。

型定義ファイルの作成

始める前に、src/utils内にコンポーネント用の型をまとめたファイルを用意します。これを使って関数に型を付け、コードベースの一貫性と保守性を高めます。

types.ts
import type {
  MarketplaceKind,
  Order,
  OrderbookKind,
  Step,
} from "@0xsequence/marketplace-sdk";
import type { Address, Hash, Hex } from "viem";

type ModalCallbacks = {
  onSuccess?: ({ hash, orderId }: { hash?: Hash; orderId?: string }) => void;
  onError?: (error: Error) => void;
  onBuyAtFloorPrice?: () => void;
};

export type ShowBuyModalArgs = {
  orderId: string;
  chainId: number;
  collectionAddress: Address;
  collectibleId: string;
  marketplace: MarketplaceKind;
  customCreditCardProviderCallback?: (buyStep: Step) => void;
  skipNativeBalanceCheck?: boolean;
  nativeTokenAddress?: Address;
};

export type ShowCreateListingModalArgs = {
  collectionAddress: Hex;
  chainId: number;
  collectibleId: string;
  orderbookKind?: OrderbookKind;
  callbacks?: ModalCallbacks;
};
export type ShowMakeOfferModalArgs = {
  collectionAddress: Hex;
  chainId: number;
  collectibleId: string;
  orderbookKind?: OrderbookKind;
  callbacks?: ModalCallbacks;
};

export type ShowSellModalArgs = {
  collectionAddress: Hex;
  chainId: number;
  tokenId: string;
  order: Order;
  callbacks?: ModalCallbacks;
};

コレクティブル用コンポーネントのセットアップ

アイテムページとそのコンポーネントを作成するには、src/app/componentsディレクトリ内にCollectiblesという新しいフォルダを追加します。このフォルダ内に、index.tsxCollectible.tsxの2つのファイルを作成します。

Collectible.tsx
import type {
  ShowBuyModalArgs,
  ShowCreateListingModalArgs,
  ShowMakeOfferModalArgs,
  ShowSellModalArgs,
} from "../../utils/types";
import type {
  CollectibleOrder,
  MarketplaceKind,
  OrderbookKind,
} from "@0xsequence/marketplace-sdk";
import { useBalanceOfCollectible } from "@0xsequence/marketplace-sdk/react";
import type { Address } from "viem";

export const Collectible = ({
  collectible,
  chainId,
  collectionAddress,
  showBuyModal,
  showListModal,
  showOfferModal,
  showSellModal,
  address,
  isConnected,
  orderbookKind,
}: {
  collectible: CollectibleOrder;
  chainId: string;
  collectionAddress: Address;
  showBuyModal: (args: ShowBuyModalArgs) => void;
  showListModal: (args: ShowCreateListingModalArgs) => void;
  showOfferModal: (args: ShowMakeOfferModalArgs) => void;
  showSellModal: (args: ShowSellModalArgs) => void;
  address?: Address;
  isConnected: boolean;
  orderbookKind: OrderbookKind;
}) => {
  const { name, image, tokenId } = collectible.metadata;

  // @ts-ignore: unused variable 'isBalanceLoading'
  const { data: userBalanceResp, isLoading: isBalanceLoading } =
    useBalanceOfCollectible({
      chainId: Number(chainId),
      collectionAddress,
      collectableId: tokenId,
      userAddress: address,
      query: {
        enabled: !!isConnected && !!address,
      },
    });

  const tokenBalance = userBalanceResp?.balance;

  const onClickBuy = () =>
    showBuyModal({
      chainId: Number(chainId),
      collectionAddress,
      collectibleId: tokenId,
      orderId: collectible!.order!.orderId,
      marketplace: orderbookKind as unknown as MarketplaceKind,
    });

  const onClickList = () => {
    showListModal({
      collectionAddress,
      chainId: Number(chainId),
      collectibleId: tokenId,
      orderbookKind,
    });
  };

  const onClickOffer = () => {
    showOfferModal({
      collectionAddress,
      chainId: Number(chainId),
      collectibleId: tokenId,
      orderbookKind,
    });
  };

  const onAcceptOffer = () => {
    showSellModal({
      collectionAddress,
      chainId: Number(chainId),
      tokenId,
      order: collectible!.offer!,
    });
  };

  const hasOffer = Boolean(collectible?.offer);
  const sellDisabled = !isConnected || !hasOffer || !tokenBalance;
  const showActionButtons = address && isConnected;

  return (
    <div className="flex flex-col justify-center items-center border border-white">
      <h2>{name}</h2>
      <img
        src={image}
        alt={name}
        className="w-[200px] h-[200px] object-cover object-center"
      />
      {showActionButtons && (
        <>
          {collectible.order && <button onClick={onClickBuy}>Buy</button>}
          {tokenBalance && <button onClick={onClickList}>List</button>}
          <button onClick={onClickOffer}>Make offer</button>
          {!sellDisabled && (
            <button onClick={onAcceptOffer}>Accept Offer</button>
          )}
        </>
      )}
    </div>
  );
};
index.tsx
import { OrderbookKind, OrderSide } from "@0xsequence/marketplace-sdk";
import {
  useBuyModal,
  useCreateListingModal,
  useListCollectibles,
  useMakeOfferModal,
  useMarketplaceConfig,
  useSellModal,
} from "@0xsequence/marketplace-sdk/react";
import { Collectible } from "./Collectible";
import type { Address } from "viem";
import { useAccount } from "wagmi";

export const Collectibles = ({
  collectionId,
  chainId,
}: {
  collectionId: Address;
  chainId: number;
}) => {
  const { address, isConnected } = useAccount();
  const {
    data: collectibles,
    // @ts-ignore: unused variable 'collectiblesLoading'
    isLoading: collectiblesLoading,
    // @ts-ignore: unused variable 'fetchNextCollectibles'
    fetchNextPage: fetchNextCollectibles,
  } = useListCollectibles({
    chainId: Number(chainId),
    collectionAddress: collectionId,
    filter: {
      // # Optional filters
      includeEmpty: true,
      // searchText: text,
      // properties,
    },
    side: OrderSide.listing,
  });

  const { data } = useMarketplaceConfig();

  const onError = (error: Error) => {
    console.error(error.message);
  };

  const { show: showBuyModal } = useBuyModal({
    onSuccess(hash) {
      console.log("Buy transaction sent with hash: ", hash);
    },
    onError,
  });

  const { show: showListModal } = useCreateListingModal({ onError });
  const { show: showOfferModal } = useMakeOfferModal({
    onError,
  });
  const { show: showSellModal } = useSellModal({ onError });

  const collectiblesFlat =
    collectibles?.pages.flatMap((p) => p.collectibles) ?? [];
  const collectionData =
    data?.collections?.find(
      (collection) => collection.address === collectionId
    ) || null;
  const orderbookKind: OrderbookKind =
    (collectionData?.destinationMarketplace || "") as unknown as OrderbookKind;

  return (
    <div>
      <h1 className="text-[32px] font-semibold">Collectibles</h1>
      {collectiblesFlat?.map((collectible) => (
        <Collectible
          key={collectible.metadata.tokenId}
          collectible={collectible}
          chainId={String(chainId)}
          collectionAddress={collectionId}
          showBuyModal={showBuyModal}
          showListModal={showListModal}
          showOfferModal={showOfferModal}
          showSellModal={showSellModal}
          address={address}
          isConnected={isConnected}
          orderbookKind={orderbookKind}
        />
      ))}
    </div>
  );
};

最後に、出品とオファー用のコンポーネントをApp.tsxファイルに追加します。

App.tsx
import { useAccount, useDisconnect } from "wagmi";
import "./App.css";
import { useOpenConnectModal } from "@0xsequence/connect";
import { Collections } from "./components/Collections";
import type { MarketplaceCollection } from "@0xsequence/marketplace-sdk";
import { useState } from "react";
import { Collectibles } from "./components/Collectibles";
import type { Address } from "viem";

function App() {
  const { setOpenConnectModal } = useOpenConnectModal();
  const { isConnected, address } = useAccount();
  const { disconnect } = useDisconnect();
  const [collectionSelected, setCollectionSelected] =
    useState<MarketplaceCollection | null>(null);

  function handleDisconnect() {
    disconnect();
  }

  function onSelectCollection(value: MarketplaceCollection) {
    setCollectionSelected(value);
  }

  function onRestartSelectedCollectionValue() {
    setCollectionSelected(null);
  }

  if (!isConnected && !address) {
    return (
      <>
        <button onClick={() => setOpenConnectModal(true)}>Connect</button>
      </>
    );
  }

  return (
    <>
      <p>User Address: {address}</p>
      <button onClick={handleDisconnect}>Disconnect</button>
      {!collectionSelected ? (
        <Collections onSelectCollection={onSelectCollection} />
      ) : (
        <>
          <button onClick={onRestartSelectedCollectionValue}>
            Show Collections
          </button>
          <Collectibles
            chainId={collectionSelected.chainId}
            collectionId={collectionSelected.address as Address}
          />
        </>
      )}
    </>
  );
}

export default App;

実行とテスト

新しいページにアイテムが正常に追加され、UI上で表示されるようになりました。動作を確認するには、pnpm devを実行してください。ランディングページから任意のコレクションを選択し、その中のNFTを表示できます。出品、購入、オファーの作成や承認など、主要なフローをテストして、すべてが期待通りに動作するか確認しましょう。