将 Next.js 前端应用程序与智能合约集成
在前面的步骤中,您已成功构建智能合约并将其部署到本地主机。 现在,是时候从前端与它互动了。 前端使用 Next.js,集成了用于隐私功能的 Semaphore 和用于身份验证的 Mini Dapp SDK。
设置和安装
克隆了包含 Next.js 前端的项目 repo 后,接下来要做的就是使用下面的命令在根目录下创建一个 .env 文件:
touch .env
注意:运行上述命令前,请确保您已进入根目录。
在刚刚创建的 .env 文件中,添加以下内容:
SURVEY_FACTORY_V1_CONTRACT_ADDRESS=[factory address]NEXT_PUBLIC_SURVEY_FACTORY_V1_CONTRACT_ADDRESS=[factory address]NODE_URL=http://localhost:8545
确保将 SURVEY_FACTORY_V1_CONTRACT_ADDRESS
和 NEXT_PUBLIC_SURVEY_FACTORY_V1_CONTRACT_ADDRESS
替换为本教程前面部署到 localhost 的合同地址。
现在,让我们来看看该程序的核心功能:
调查管理
1. 创建调查
接口定义:
SurveyInfo 接口规范了整个应用程序处理调查数据的方式,确保了数据类型和结构的一致性。
// types/index.tsexport interface SurveyInfo { title: string; // Survey's display title desc: string; // Detailed survey description id: string; // Unique identifier (contract address) remaining: number; // Number of responses still needed reward: string; // Per-response reward amount respondents: number; // Current number of participants daysleft: number; // Time until expiration finished: boolean; // Survey completion status}
实施和使用
A. 数据获取:该层管理智能合约与应用程序之间的通信,将原始区块链数据转换为 SurveyInfo 格式。
// backend/survey.tsxexport const getSurvey = async (address: string): Promise<SurveyInfo> => { const survey = getSurveyV1(address); const info = await survey.surveyInfo(); return decodeSurveyInfo(info, address);};// Decode contract data to SurveyInfoconst decodeSurveyInfo = (info: any[], contractAddr: string): SurveyInfo => { return { title: info[0], desc: info[1], id: contractAddr, remaining: remainedSurvey(info[4], info[5]), reward: ethers.formatEther(info[3]), respondents: info[5], daysleft: daysLeft(info[7]), finished: info[10] };};// Helper functionsconst remainedSurvey = (targetNumber: number, respondents: number) => { return targetNumber - respondents;};const daysLeft = (duration: bigint) => { const now = BigInt(Math.floor(Date.now() / 1000)); return Number(duration - now) / 86400; // Convert to days};
B. 显示组件:此 React 组件处理调查数据的可视化表示,包括状态指示器、进度跟踪和其他交互式元素。
// components/SurveyCard.tsxinterface SurveyCardProps extends SurveyInfo {}export default function SurveyCard({ id, title, desc, reward, remaining, respondents, daysleft, finished,}: SurveyCardProps) { return ( <div className="flex flex-col rounded-lg bg-violet-400"> {/* Status Badge */} {finished ? ( <div className="bg-red-400 rounded-2xl"> <span>Finished</span> </div> ) : ( <div className="bg-lime-400 rounded-2xl"> <span>In Progress</span> </div> )} {/* Time Remaining */} {!finished && ( daysleft < 1 ? ( <div className="bg-amber-600 rounded-2xl"> <span>{`h-${Math.floor(daysleft * 24)}`}</span> </div> ) : ( <div className="bg-teal-500 rounded-2xl"> <span>D-{Math.floor(daysleft)}</span> </div> ) )} {/* Reward Display */} <span className="text-white"> <span className="font-bold">{reward}</span> KAIA </span> {/* Survey Details */} <div className="px-5 pt-2"> <h1 className="text-xl font-bold"> {title.length > 20 ? title.substring(0, 20) + "..." : title} </h1> <p className="text-white mt-1 break-words"> {desc.length > 65 ? desc.substring(0, 65) + "..." : desc} </p> </div> {/* Statistics */} <div className="flex justify-start"> <div className="flex flex-col mr-8"> <span>Remains</span> <span className="font-bold">{remaining}</span> </div> <div className="flex flex-col"> <span>Respondents</span> <span className="font-bold">{respondents}</span> </div> </div> </div> );}
C. 调查列表:处理调查表集合的组织和显示,设有_热门话题_和_即将结束_等部分,以改进用户导航。
// app/[locale]/square/surveys/page.tsxexport default async function SurveysPage() { const data = await getAllSurveyV1s(); // Sort and filter surveys const hotTopics = data .sort((a, b) => b.respondents - a.respondents) .slice(0, 10); const endingSoon = data .filter((survey) => survey.daysleft < 2); return ( <div className="flex flex-col mt-5"> {/* Hot Topics Section */} {hotTopics.length > 0 && ( <div> <h1 className="text-3xl font-bold">Hot Topics</h1> <div className="flex overflow-x-scroll"> {hotTopics.map((survey) => ( <SurveyCard key={survey.id} {...survey} /> ))} </div> </div> )} {/* Ending Soon Section */} {endingSoon.length > 0 && ( <div> <h1 className="text-3xl font-bold">Ending Soon</h1> <div className="flex overflow-x-scroll"> {endingSoon.map((survey) => ( <SurveyCard key={survey.id} {...survey} /> ))} </div> </div> )} </div> );}
2. 回答调查问卷
A. 接口定义:回答接口为调查回答提供标准格式,包括回答数据和与隐私相关的证明信息。
// types/index.tsinterface Answer { respondent: string; // Wallet address of respondent answers: number[]; // Array of selected answer indices merkleTreeDepth: number; // Depth of merkle tree for proof merkleTreeRoot: string; // Root of merkle tree nullifier: string; // Unique identifier to prevent double submission points: number[]; // Proof points for zero-knowledge verification}
B. 提交功能: 该功能管理与智能合约的交互,包括 交易创建和确认。
// browser/survey.tsxexport const submitAnswer = async ( surveyAddress: string, provider: Web3Provider, answer: Answer) => { const signer = await provider.getSigner(0);// Get contract instance const survey = getSurveyV1(surveyAddress, signer); try {// Submit transaction const tx = await survey.submitAnswer(answer, { gasLimit: 5000000 });// Wait for confirmation const receipt = await tx.wait(); return receipt; } catch (e: any) { console.log("error", e); return e.message; }};
C. 显示组件:前端组件管理表单数据收集、验证用户输入、与 Web3 提供商交互、处理提交流程,同时提供适当的用户反馈。
// Component for submitting answersexport default function SubmitAnswerForm({ id, questions,}: { id: string; questions: Question[];}) { const { provider, identity, account } = useWeb3(); const router = useRouter(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { // 1. Validate prerequisites if (!provider || !account) { alert("Please connect wallet first!"); return; } if (!identity) { alert("You need to login with LINE first!"); return; } // 2. Collect form data const formData = new FormData(e.target as HTMLFormElement); const answers = Array.from(formData.values()).map(val => parseInt(val as string) ); // 3. Generate proof const group = new Group(members); const proof = await generateProof( identity, group, new Uint8Array(answers), groupId ); // 4. Prepare answer object const answer = { respondent: account, answers, merkleTreeDepth: proof.merkleTreeDepth, merkleTreeRoot: proof.merkleTreeRoot, nullifier: proof.nullifier, points: proof.points }; // 5. Submit answer const receipt = await submitAnswer(id, provider, answer); // 6. Handle success if (receipt.status) { alert("Successfully submitted!"); router.push(`/square/surveys/${id}`); } } catch (error) { // Handle specific error cases if (error.code === 'INSUFFICIENT_FUNDS') { alert("Insufficient funds for transaction"); } else if (error.code === 'ALREADY_SUBMITTED') { alert("You have already submitted an answer"); } else { alert("Failed to submit: " + error.message); } } }; return ( <form onSubmit={handleSubmit}> {/* Form fields */} </form> );}
认证与社交功能集成
线路 LIFF 验证
提供安全的用户身份验证和访问 LINE 用户配置文件,同时确保应用程序在 LINE 环境中正常运行。
LIFF 初始化
// context/LiffProvider.tsxexport const LiffProvider: React.FC<{ children: React.ReactNode }> = ({ children,}) => { const [liffObject, setLiffObject] = useState<typeof liff | null>(null); const [liffError, setLiffError] = useState(null); useEffect(() => { // Check if running in LINE environment if (liff.isInClient()) { liff .init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID as string }) .then(() => { console.log("LIFF initialization succeeded"); setLiffObject(liff); }) .catch((error: any) => { console.error("LIFF initialization failed:", error); setLiffError(error.toString()); }); } }, []); return ( <LiffContext.Provider value={{ liffObject, liffError }}> {children} </LiffContext.Provider> );};
登录实施
// components/buttons/LineLoginBtn.tsxexport default function LineLoginBtn() { const { liffObject } = useLiff(); const loginRequest = async () => { if (!liffObject) { return; } const login = await liffObject.login(); if (!login) { return; } }; return ( <button onClick={() => { loginRequest(); }} > LINE Login </button> );}
Web3 集成
处理钱包连接、账户管理和区块链交互,同时维护整个应用程序的状态。
Web3 提供商设置
// context/Web3Provider.tsxexport const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children,}) => { const [provider, setProvider] = useState<Web3Provider | null>(null); const [account, setAccount] = useState<string | null>(null); const [isConnected, setIsConnected] = useState<boolean>(false); const { liffObject, dappPortalSDK } = useLiff(); // Initialize from session storage useEffect(() => { const storedAccount = sessionStorage.getItem(WALLET_ACCOUNT_KEY); const storedIsConnected = sessionStorage.getItem(WALLET_IS_CONNECTED_KEY); if (storedAccount) { setAccount(storedAccount); } if (storedIsConnected) { setIsConnected(true); } }, []); // Persist state changes useEffect(() => { if (account) { sessionStorage.setItem(WALLET_ACCOUNT_KEY, account); } else { sessionStorage.removeItem(WALLET_ACCOUNT_KEY); } }, [account]); return ( <Web3Context.Provider value={{ provider, account, isConnected }}> {children} </Web3Context.Provider> );};
钱包连接
const connectWallet = async () => { try { // 1. Get wallet provider from Mini Dapp SDK const provider = dappPortalSDK?.getWalletProvider(); // 2. Create Web3 provider instance const web3Provider = new w3(provider); // 3. Request account access const accounts = await web3Provider.send("kaia_requestAccounts", []); // 4. Get payment provider for transactions const pProvider = dappPortalSDK?.getPaymentProvider(); setPProvider(pProvider as PaymentProvider); // 5. Create identity if necessary if ( provider && liffObject && (provider.getWalletType() === WalletType.Liff || provider.getWalletType() === WalletType.Web) && liffObject.isLoggedIn() ) { const identity = await createIdentity( web3Provider, accounts[0], liffObject ); setIdentity(identity); } // 6. Update state setProvider(web3Provider); setAccount(accounts[0]); setIsConnected(true); } catch (error) { console.error("Wallet connection error:", error); throw error; }};
扩展社交功能
朋友邀请系统
该平台结合了 LINE 的社交功能,使用户能够通过无缝共享体验邀请朋友。 这是通过 LIFF ShareTargetPicker 实现的,它为好友选择提供了本地 LINE 接口。
** 提供者界面**
interface LiffContextType { // Core authentication properties liffObject: any; liffError: any; // Social feature properties inviteFriends: () => void; encodedUID: string | null; loading: boolean;}
实施朋友邀请
const inviteFriends = async () => { // 1. Check authentication if (liff && !liff.isLoggedIn()) { liff.login(); return; } // 2. Generate invitation code let encodedUID; try { const result = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/api/invite/encode`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ idToken: liff.getAccessToken(), }), } ); encodedUID = result.encodedUID; } catch (error) { alert("Error when encoding user ID"); return; } // 3. Share with friends const msg = getFlexMessage(locale, encodedUID); if (liff.isApiAvailable("shareTargetPicker")) { await liff.shareTargetPicker([msg]); }};
转介系统
转介系统通过 URL 参数中的编码 UID 跟踪用户邀请。
// Referral check in LiffProvideruseEffect(() => { liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID as string, }).then(() => { // Parse referral code if present if (window.location.search !== "") { const encodedUID = parseEncodedUID(window.location.search); setEncodedUID(encodedUID); // Handle referral logic } });}, []);
共享消息模板
可定制的 LINE Flex 消息邀请函,支持多种语言。
const getFlexMessage = (locale: string, encodedUID: string): LiffMessage => { const message = inviteMessages[locale] || inviteMessages["en"]; return { type: "flex", altText: message.altText, contents: { type: "bubble", hero: { type: "image", url: "your-image-url", size: "full", aspectRatio: "20:13", aspectMode: "cover", }, body: { type: "box", layout: "vertical", contents: [ { type: "text", text: encodedUID, weight: "bold", size: "xl", }, { type: "text", text: message.contentsText2, wrap: true, }, ], }, footer: { type: "box", layout: "vertical", contents: [ { type: "button", style: "primary", action: { type: "uri", label: message.footerLabel, uri: `https://liff.line.me/your-liff-id?encodedUID=${encodedUID}`, }, }, ], }, }, };};
联合提供商设置
export const LiffProvider: React.FC<{ children: React.ReactNode }> = ({ children,}) => { // Authentication states const [liffObject, setLiffObject] = useState<typeof liff | null>(null); const [liffError, setLiffError] = useState(null); // Social feature states const [encodedUID, setEncodedUID] = useState<string | null>(null); const [loading, setLoading] = useState(true); // Initialize everything useEffect(() => { initializeLIFF(); initializeDappPortal(); checkForReferral(); }, []); return ( <LiffContext.Provider value={{ // Auth values liffObject, liffError, // Social features inviteFriends, encodedUID, loading }}> {children} </LiffContext.Provider> );};
朋友邀请组件
该平台为好友邀请提供了一个专用组件,使其可以在调查界面内轻松访问。
组件实施
// components/Friends.tsx"use client";export default function Friends() { const params = useParams(); const { liffObject, loading, inviteFriends } = useLiff(); const locale = params.locale as keyof typeof inviteMessages; const messages = inviteMessages[locale] || inviteMessages.en; // Environment and loading checks if (loading) return <div></div>; if (liffObject && !liffObject.isInClient()) return <div></div>; return ( <div className="flex flex-row items-center justify-center mb-3"> <label htmlFor="inviteButton" className="text-xl font-bold"> {messages.inviteMessage} </label> <button id="inviteButton" onClick={() => inviteFriends()} className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-2 ml-2 rounded" > {messages.invite} </button> </div> );}
与调查页面整合
// app/[locale]/square/surveys/page.tsxexport default async function SurveysPage({ params,}: { params: Promise<{ locale: string }>;}) { const data = await getAllSurveyV1s(); const { locale } = await params; // Sort surveys for different sections const hotTopics = data .sort((a, b) => b.respondents - a.respondents) .slice(0, 10); const endingSoon = data.filter((survey) => survey.daysleft < 2); return ( <div className="flex flex-col mt-5"> {/* Friend Invitation Component */} <Friends /> {/* Survey Sections */} {hotTopics.length !== 0 && ( // Hot Topics section )} {endingSoon.length !== 0 && ( // Ending Soon section )} {/* All Surveys */} <div className="flex flex-wrap gap-5 justify-center mt-5 mb-10"> {data.map((survey) => ( <SurveyCard key={survey.id} {...survey} /> ))} </div> </div> );}
隐私与安全实施 Semaphore 协议
在分散式调查系统中,保护用户隐私同时确保回复的真实性至关重要。 实施 Semaphore 协议是为了解决几个关键难题:
- 匿名参与:用户需要证明自己有资格在不暴露身份的情况下参与调查。
- 防止重复提交:系统必须防止重复提交,同时保持匿名性。
- 答卷隐私:调查答案应保密,用户个人无法追踪。
- 可验证的真实性:尽管是匿名的,但必须核实答复来自授权参与者。
Semaphore 协议通过使用零知识证明来应对这些挑战,允许用户证明自己在群组中的成员身份,并在不暴露身份的情况下提交回复。 这确保了调查过程中的隐私和数据完整性。
1. 身份创建:使用多种因素生成确定性身份,确保唯一性和安全性,同时维护隐私。
//.. browser/survey.tsxexport const createIdentity = async ( web3: Web3Provider, address: string, liffObject: typeof liff) => {// 1. Get LINE user identity const idToken = liffObject.getDecodedIDToken(); if (!idToken) { throw Error("Failed to get ID token"); } try {// 2. Extract unique LINE user ID const uid = idToken.sub;// 3. Create deterministic message const msg = "hello destat" + uid + address; const hexMsg = ethers.hexlify(ethers.toUtf8Bytes(msg));// 4. Get wallet signature as entropy source // const secret = await web3.send("kaia_signLegacy", [address, hexMsg]); const secret = await web3.send("personal_sign", [hexMsg, address]);// 5. Create Semaphore identity return new Identity(secret); } catch (e) { console.log("error", e); throw Error("Failed to create identity"); }};
2. 群组管理:在不泄露个人身份的情况下,处理群组成员操作和验证。
加入小组实施
//..backend/survey.tsx export const joinGroup = async ( surveyAddress: string, commitment: bigint, signature: ethers.SignatureLike, idToken: string, address: string) => {// 1. Get group contract const survey = getManagerConnectedSurveyV1(surveyAddress); // 2. Verify LINE token const profile = await isValidToken(idToken);// 3. Verify wallet ownership await verifyLineIdentity(profile.userId, address, signature);// 4. Add member to group const tx = await survey.joinGroup(commitment);// 5. Verify successful addition const receipt = await tx.wait(); return receipt;};
组件结构
在本节中,我们将对组件结构进行细分。 组件文件夹应该是这样的
buttons/ ├── dropdown.tsx -> Handles expandable menu navigation ├── LineLoginBtn.tsx -> Manages LINE authentication flow └── WalletBtn.tsx -> Handles Web3 wallet connectionscommon/ ├── index.tsx -> Exports shared components and utilitiesAnswerChart.tsx -> Displays bar chart of survey responsesAnswerPie.tsx -> Shows circular visualization of responsesFooter.tsx -> Shows app footer contentHeader.tsx -> Contains app header and main navigationItemCard.tsx -> Displays purchasable items with pricingLinearChart.tsx -> Shows linear progress indicatorsNav.tsx -> Provides main app navigation structureSubmitAnswerForm -> Handles survey response submissionSurveyCard.tsx -> Displays survey information and status