跳到主要内容

编写个人主页组件

个人主页应包含个人信息、拥有的iCat、egg和物品。我们会对这几个部分分别编写 components 然后引用进同一个页面。

编写后端

个人信息,包括头像、背景、昵称、简介等,属于隐私信息,而区块链具有开源是属性,因此个人信息不适合上传到区块链上,我们仍需要使用传统的数据库来存放隐私数据。

提示

Solidity 中的private类型并不能让数据真正的变成隐私数据。我们仍可以通过读取智能合约特定的 slot 来获取变量的值。

你可以选用任何数据库来存放这些数据,这里我们使用提供免费 PostgreSQL 额度的 supabase 作为后端数据库。

配置 supabase

官网注册,然后在本页选择New project,根据创建一个新的组织,在组织中创建一个新的项目,如果你想要从外部连接数据库的话,请牢记创建项目时设置的数据库密码

创建成功后进入项目,根据下图的顺序依次复制数据库的 url 以及 public anon。 supabase

在前端项目根目录下的.env.local文件中加入以下两个环境变量:

#supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=

然后将上述复制的 url 以及 public anon 填入其中并保存。使用 VSCode 或其他软件连接上该数据库,在public scheme 下创建一张新表 users。表中数据项名称和类型如下所示(数据项:数据类型):

  • uid:uuid(用户唯一id,主键)
  • address:text(用户钱包地址)
  • nick_name:text(用户昵称)
  • avatar:text(用户头像 url)
  • bio:text(用户个人简介)
  • cover:text(用户封面背景图 url)

编写后端接口

个人主页的后端接口应该能实现以下功能:

  • 根据钱包地址获取个人信息;
  • 如果结果为空则在users表中创建一个随机数据项并返回。

根据以上要求,在pages/api文件夹下创建get_profile.js文件,并粘贴以下内容:

import { ironOptions } from '@/lib/iron';
import { createClient } from '@supabase/supabase-js';
import { withIronSessionApiRoute } from 'iron-session/next';
import { parse } from 'url';
import { v4 as uuid } from 'uuid';

const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);


const handler = async (req, res) => {
const { method, url } = req;
const { query } = parse(url, true); // 解析查询参数

switch (method) {
case "GET":
const address = query.address || "0x"; // 获取 nick_name 参数,如果没有则使用默认值
const { data, error } = await supabase.from('users').select("*").eq('address', address);
if(Object.keys(data).length === 0) {
const { data: newData, error } = await supabase
.from('users')
.insert({
uid: uuid(),
address: address,
nick_name:'User' + Math.floor(1000000 + Math.random() * 9000000).toString(),
bio: "这个人很懒,还没有留下简介"
})
.select();
res.send(newData);
}
else {
res.send(data);
}
break;
default:
res.setHeader("Allow", ["GET"]);
res.status(405).end(`Method ${method} Not Allowed`);
}
};

export default withIronSessionApiRoute(handler, ironOptions);

编写前端

为了便于 debug,我们将个人主页(Profile)拆分成多个 components 进行编写。

头像(ProfileImage

该组件接受profile参数并返回ProfileImage组件,如果profile不存在的话则返回默认头像。在components文件夹下创建ProfileImage.jsx,添加以下代码:

import Image from "next/image";

export const ProfileImage = ({ profile }) => {
return (
<div className="px-8 pt-8 relative z-10 bg-transparent">
{profile?.avatar ? (
<Image
src={profile?.avatar}
alt=""
width={200}
height={200}
className="relative rounded-full overflow-auto max-h-[800px]"
/>
) : (
<Image
src={"/images/defaultAvatar.png"}
width={200}
height={200}
className='relative rounded-full overflow-auto max-h-[800px]'
/>
)}
</div>
);
};

背景(ProfileCover

该组件接受profile参数并返回ProfileCover组件,如果profile不存在的话则返回默认背景。在components文件夹下创建ProfileCover.jsx,添加以下代码:

import Image from "next/image";

export const ProfileCover = ({ profile }) => {
return (
<div className="relative z-0 bg-local bg-clip-border bg-transparent bg-gradient-to-br from-gray-300 to-gray-300/25 bg-origin-padding bg-top box-border text-black block font-sans h-250px opacity-80 lg:w-screen md:h-full h-[250px] antialiased">
{profile?.cover ? (
<Image
src={profile?.cover}
alt=""
width={1706}
height={133}
className="relative bg-cover "
/>
) : (
<Image
src={"/images/defaultCover.png"}
width={1706}
height={133}
className='relative bg-cover '
/>
)}
</div>
);
};

EggCardEggCards

EggCard用于展示某个宠物蛋的图片以及操作按钮,EggCards用于展示该用户所拥有的所有宠物蛋。

EggCard.jsx
import { useContractRead, useContractWrite, usePrepareContractWrite } from "wagmi";
import { useAddRecentTransaction } from "@rainbow-me/rainbowkit";
import { useEffect } from "react";
import { Avatar, Card } from "antd";
import eggAbi from "@/lib/abi/eggAbi";

const { Meta } = Card;

const EggCard = ({ tokenId }) => {

const { data: metadata, isSuccess: isReadSuccess } = useContractRead({
address: process.env.NEXT_PUBLIC_EGG_CONTRACT_ADDRESS,
abi: eggAbi,
functionName: 'tokenURI',
args: [tokenId]
})

const { config } = usePrepareContractWrite({
address: process.env.NEXT_PUBLIC_EGG_CONTRACT_ADDRESS,
abi: eggAbi,
functionName: 'hatchOut',
args: [tokenId]
});
const { data, isLoading, isSuccess, write } = useContractWrite(config);
const addRecentTransaction = useAddRecentTransaction();

useEffect(() => {
if (isSuccess) {
addRecentTransaction({
hash: data?.hash || "",
description: "孵化"
})
}
// console.log(metadata, typeof(metadata))
}, [data, isSuccess, metadata, isReadSuccess])

return (
<Card
className='w-72 hover:shadow-lg'
cover={
<img
alt="example"
src={metadata == `https://${tokenId}` ? "https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png" : metadata}
/>
}
actions={[
<button disabled={!write} onClick={() => write?.()} className={`rounded-xl px-8 py-3 text-neutral-100 font-[500] transition tracking-wide w-[200px] outline-none ${isLoading ? "bg-emerald-500" : isSuccess ? 'bg-amber-400' : 'bg-blue-600 hover:bg-blue-700'}`}>
{isLoading ? "孵化中..." : isSuccess ? "孵化成功!" : "孵化"}
</button>
]}
>
<Meta
avatar={<Avatar src="https://xsgames.co/randomusers/avatar.php?g=pixel" />}
title={`iCat Egg #${tokenId}`}
description="孵化扣除10积分"
/>
</Card>
)
}

export default EggCard;
EggCards.jsx
import { useContractRead, useContractWrite, usePrepareContractWrite } from "wagmi";
import { Avatar, Card } from "antd";
import eggAbi from "@/lib/abi/eggAbi";
import { useEffect } from "react";
import EggCard from "./EggCard";

const { Meta } = Card;

const EggCards = ({ address }) => {

const { data, isError, isSuccess, isLoading } = useContractRead({
address: process.env.NEXT_PUBLIC_EGG_CONTRACT_ADDRESS,
abi: eggAbi,
functionName: 'getOwnedTokenId',
args: [address]
})

const generatedElements = !!data &&
(
data?.[0].length == 0 ?
<div className="pb-20">
<p>还没有iCat哦,快去铸造一个吧!</p>
</div>
:
data[0].map(tokenId => (
<EggCard key={tokenId} tokenId={tokenId} />
))
);

useEffect(() => {
// console.log(data)
}, [data, isSuccess])

return (
<div className='flex flex-row flex-wrap gap-4 pt-8 pb-20'>
{generatedElements}
</div>
)
}

export default EggCards;

CatCardCatCards

CatCard用于展示某个宠物猫的图片以及详情链接,CatCards用于展示该用户所拥有的所有宠物猫。

CatCard.jsx
import { useContractReads } from "wagmi";
import iCatAbi from "@/lib/abi/catAbi";
import { useEffect } from "react";
import Link from "next/link";
import { Avatar, Card } from "antd";

const CatCard = ({ tokenId }) => {

const iCatCA = {
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: iCatAbi
};

const { data, isSuccess, isLoading, isError } = useContractReads({
contracts: [
{
...iCatCA,
functionName: 'detail',
args: [tokenId]
},
{
...iCatCA,
functionName: 'tokenURI',
args: [tokenId]
}
]
});

const stage = {
0: "幼生期",
1: "成长期",
2: "成熟期"
}

useEffect(() => {
// console.log(data); // eslint-disable-line no-console
}, [data, isSuccess])

return (
<Link href={`/asset/${tokenId.toString()}`} key={tokenId}>
<Card
className='w-72 hover:shadow-lg cursor-pointer'
cover={
<img
alt="example"
src="https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png"
/>
}
loading={!isSuccess}
>
<Card.Meta
avatar={<Avatar />}
title={`iCat #${tokenId} ${!!data ? data[0]?.result[0] : 'unknown'}`}
description={stage[data?.[0].result[3]]}
/>
</Card>
</Link>
)
}

export default CatCard;
CatCards.jsx
import { useContractRead } from "wagmi";
import { Avatar, Card } from "antd";
import icatAbi from "@/lib/abi/catAbi";
import { useEffect } from "react";
import CatCard from "./CatCard";

const { Meta } = Card;

const CatCards = ({ address }) => {

const { data, isError, isSuccess, isLoading } = useContractRead({
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: icatAbi,
functionName: 'getOwnedTokenId',
args: [address]
})

const generatedElements = !!data &&
(
data?.[0].length == 0 ?
<div>
<p>还没有iCat哦,快去铸造一个吧!</p>
</div>
:
data[0].map(tokenId => (
<CatCard key={tokenId} tokenId={tokenId} />
))
);

useEffect(() => {
// console.log(data, isSuccess)
}, [data, isSuccess])

return (
<div className='flex flex-row flex-wrap gap-4 pt-8 pb-20'>
{generatedElements}
</div>
)
}

export default CatCards;

FoodCard

FoodCard用于展示用户所拥有的某种食物的数量。在components文件夹下创建FoodCard.jsx,并填入以下代码:

FoodCard.jsx
import { Avatar, Card } from "antd"
import { useAccount, useContractRead, useContractWrite, usePrepareContractWrite } from "wagmi"
import { ContractFunctionExecutionError } from "viem";
import iCatAbi from "@/lib/abi/catAbi.json";
import { useEffect, useState } from "react";
import { useAddRecentTransaction } from "@rainbow-me/rainbowkit";
import { toast, Toaster } from "react-hot-toast";

export const FoodCard = ({ style }) => {

const { address } = useAccount();
const [amount, setAmount] = useState(undefined);

const food = {
'leftover': 0,
'fishchip': 1,
'tin': 2
}

const foodTrans = {
'leftover': '剩饭',
'fishchip': '小鱼干',
'tin': '鱼罐头'
}

const { data, isError } = useContractRead({
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: iCatAbi,
functionName: 'foodBalance',
args: [address, food[style]]
})

const { config, error } = usePrepareContractWrite({
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: iCatAbi,
functionName: 'buyFood',
args: [food[style], amount]
})
const { data: tx, isLoading, isSuccess, write } = useContractWrite(config);
const addRecentTransaction = useAddRecentTransaction();

useEffect(() => {
// console.log(data, error)
if (isSuccess) {
addRecentTransaction({
hash: tx?.hash || "",
description: `购买${foodTrans[style]}`
})
}
if (error instanceof ContractFunctionExecutionError) {
// console.log('message:', error.metaMessages)
if (error.metaMessages[0] == "Error: creditNotEnough()") {
toast.error("积分不足!");
}
}
}, [data, isError, isSuccess, error])

return (
<div className="hover:drop-shadow-lg">
<Card
cover={
<img alt={`${style}`} src="https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png"></img>
}
actions={[
<div className="flex items-center space-x-2 justify-center">
<input
className="bg-gray-100 rounded-full h-[30px] pl-3 w-[130px]"
type="number"
placeholder="请输入数量"
value={amount}
onChange={a => {setAmount(a.target.value)}}
/>
<button disabled={!write} onClick={() => write?.()} className={`rounded-xl text-neutral-100 font-[100] transition tracking-wide w-[90px] h-[30px] outline-none ${isLoading ? "bg-emerald-500" : isSuccess ? 'bg-amber-400' : 'bg-blue-600 hover:bg-blue-700'}`}>
{isLoading ? `购买中...` : isSuccess ? `购买成功!` : `购买${foodTrans[style]}`}
</button>
</div>
]}
>
<Card.Meta
avatar={<Avatar src="https://xsgames.co/randomusers/avatar.php?g=pixel" />}
title={`${foodTrans[style]}`}
description={`余额:${data}`}
/>
</Card>
</div>
)
}

OrnamentCard

OrnamentCard用于展示用户拥有的装饰品数量。在components文件夹下创建OrnamentCard.jsx文件,并填入以下代码:

OrnamentCard.jsx
import { Avatar, Card } from "antd"
import { useAccount, useContractRead, useContractWrite, usePrepareContractWrite } from "wagmi"
import { ContractFunctionExecutionError } from "viem";
import iCatAbi from "@/lib/abi/catAbi.json";
import { useEffect, useState } from "react";
import { useAddRecentTransaction } from "@rainbow-me/rainbowkit";
import { toast, Toaster } from "react-hot-toast";

export const OrnamentCard = ({ style }) => {

const { address } = useAccount();
const [amount, setAmount] = useState(undefined);

const ornament = {
'hat': 0,
'scarf': 1,
'clothes': 2
}

const ornamentTrans = {
'hat': '帽子',
'scarf': '围巾',
'clothes': '衣服'
}

const { data, isError } = useContractRead({
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: iCatAbi,
functionName: 'ornamentBalance',
args: [address, ornament[style]]
})

const { config, error } = usePrepareContractWrite({
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: iCatAbi,
functionName: 'buyOrnament',
args: [ornament[style], amount]
})
const { data: tx, isLoading, isSuccess, write } = useContractWrite(config);
const addRecentTransaction = useAddRecentTransaction();

useEffect(() => {
// console.log(data, error)
if (isSuccess) {
addRecentTransaction({
hash: tx?.hash || "",
description: `购买${ornamentTrans[style]}`
})
}
if (error instanceof ContractFunctionExecutionError) {
// console.log('message:', error.metaMessages)
if (error.metaMessages[0] == "Error: creditNotEnough()") {
toast.error("积分不足!");
}
}
}, [data, isError, isSuccess, error])

return (
<div className="hover:drop-shadow-lg">
<Card
cover={
<img alt={`${style}`} src="https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png"></img>
}
actions={[
<div className="flex items-center space-x-2 justify-center">
<input
className="bg-gray-100 rounded-full h-[30px] pl-3 w-[130px]"
type="number"
placeholder="请输入数量"
value={amount}
onChange={a => {setAmount(a.target.value)}}
/>
<button disabled={!write} onClick={() => write?.()} className={`rounded-xl text-neutral-100 font-[100] transition tracking-wide w-[90px] h-[30px] outline-none ${isLoading ? "bg-emerald-500" : isSuccess ? 'bg-amber-400' : 'bg-blue-600 hover:bg-blue-700'}`}>
{isLoading ? `购买中...` : isSuccess ? `购买成功!` : `购买${ornamentTrans[style]}`}
</button>
</div>
]}
>
<Card.Meta
avatar={<Avatar src="https://xsgames.co/randomusers/avatar.php?g=pixel" />}
title={`${ornamentTrans[style]}`}
description={`余额:${data}`}
/>
</Card>
</div>
)
}

MedicineCard

MedicineCard用于展示用户拥有的药品数量。在components文件夹下面创建MedicineCard.jsx文件,并填入以下代码:

MedicineCard.jsx
import { Avatar, Card } from "antd"
import { useAccount, useContractRead, useContractWrite, usePrepareContractWrite } from "wagmi"
import { ContractFunctionExecutionError } from "viem";
import iCatAbi from "@/lib/abi/catAbi.json";
import { useEffect, useState } from "react";
import { useAddRecentTransaction } from "@rainbow-me/rainbowkit";
import { toast, Toaster } from "react-hot-toast";

export const MedicineCard = () => {

const { address } = useAccount();
const [amount, setAmount] = useState(undefined);

const { data, isError } = useContractRead({
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: iCatAbi,
functionName: 'medicine',
args: [address]
})

const { config, error } = usePrepareContractWrite({
address: process.env.NEXT_PUBLIC_ICAT_CONTRACT_ADDRESS,
abi: iCatAbi,
functionName: 'buyMedicine',
args: [amount]
})
const { data: tx, isLoading, isSuccess, write } = useContractWrite(config);
const addRecentTransaction = useAddRecentTransaction();

useEffect(() => {
// console.log(data, error)
if (isSuccess) {
addRecentTransaction({
hash: tx?.hash || "",
description: `购买药品`
})
}
if (error instanceof ContractFunctionExecutionError) {
// console.log('message:', error.metaMessages)
if (error.metaMessages[0] == "Error: creditNotEnough()") {
toast.error("积分不足!");
}
}
}, [data, isError, isSuccess, error])

return (
<div className="hover:drop-shadow-lg">
<Card
cover={
<img alt={`药品`} src="https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png"></img>
}
actions={[
<div className="flex items-center space-x-2 justify-center">
<input
className="bg-gray-100 rounded-full h-[30px] pl-3 w-[130px]"
type="number"
placeholder="请输入数量"
value={amount}
onChange={a => {setAmount(a.target.value)}}
/>
<button disabled={!write} onClick={() => write?.()} className={`rounded-xl text-neutral-100 font-[100] transition tracking-wide w-[90px] h-[30px] outline-none ${isLoading ? "bg-emerald-500" : isSuccess ? 'bg-amber-400' : 'bg-blue-600 hover:bg-blue-700'}`}>
{isLoading ? `购买中...` : isSuccess ? `购买成功!` : `购买药品`}
</button>
</div>
]}
>
<Card.Meta
avatar={<Avatar src="https://xsgames.co/randomusers/avatar.php?g=pixel" />}
title={`药品`}
description={`余额:${data}`}
/>
</Card>
</div>
)
}

Stuff

Stuff组件用于存放上述EggCardsFoodCardsMedicineCard组件。在components文件夹下面创建Stuff.jsx文件,并填入以下代码:

Stuff.jsx
import { List } from "antd"
import { FoodCard } from "./FoodCard";
import { MedicineCard } from "./MedicineCard";
import { OrnamentCard } from "./OrnamentCard";

export const Stuff = () => {
const listData = [
<div className="flex flex-col space-y-3">
<p className="font-semibold">猫粮</p>
<div className="flex flex-row flex-wrap gap-4 pt-8 pb-20">
<FoodCard style={"leftover"} />
<FoodCard style={"fishchip"} />
<FoodCard style={"tin"} />
</div>
</div>,
<div className="flex flex-col space-y-3">
<p className="font-semibold">饰品</p>
<div className="flex flex-row flex-wrap gap-4 pt-8 pb-20">
<OrnamentCard style={"hat"} />
<OrnamentCard style={"scarf"} />
<OrnamentCard style={"clothes"} />
</div>
</div>,
<div className="flex flex-col space-y-3">
<p className="font-semibold">药品</p>
<div className="flex flex-row flex-wrap gap-4 pt-8 pb-20">
<MedicineCard />
</div>
</div>,
];

return (
<div className="flex flex-col">
<List
dataSource={listData}
bordered
renderItem={(item) => (
<List.Item>{item}</List.Item>
)}
/>
</div>
)
}

Stuff中,我们使用ant-design库中的<List />组件来为其中包含的组件排序。