MerchantConnect.tsx
1 // SPDX-FileCopyrightText: 2024 Mass Labs 2 // 3 // SPDX-License-Identifier: GPL-3.0-or-later 4 5 import { useEffect, useState } from "react"; 6 import { useAccount, usePublicClient, useWalletClient } from "wagmi"; 7 import { ConnectButton } from "@rainbow-me/rainbowkit"; 8 import { useNavigate } from "@tanstack/react-router"; 9 import { hexToBigInt, isHex, toHex } from "viem"; 10 import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; 11 import { getLogger } from "@logtape/logtape"; 12 13 import { abi } from "@massmarket/contracts"; 14 import { getWindowLocation } from "@massmarket/utils"; 15 import { useShopId } from "@massmarket/react-hooks"; 16 17 import ConnectConfirmation from "./ConnectConfirmation.tsx"; 18 import ErrorMessage from "../common/ErrorMessage.tsx"; 19 import Button from "../common/Button.tsx"; 20 import { useKeycard } from "../../hooks/useKeycard.ts"; 21 import { useChain } from "../../hooks/useChain.ts"; 22 import { KeycardRole, SearchShopStep } from "../../types.ts"; 23 import { useRelayClient } from "../../hooks/useRelayClient.ts"; 24 import { useStateManager } from "../../hooks/useStateManager.ts"; 25 26 const logger = getLogger(["mass-market", "frontend", "connect-merchant"]); 27 28 export default function MerchantConnect() { 29 const { status } = useAccount(); 30 const { chain } = useChain(); 31 const shopPublicClient = usePublicClient({ chainId: chain.id }); 32 const { data: wallet } = useWalletClient(); 33 const { shopId } = useShopId(); 34 const [keycard, setKeycard] = useKeycard(); 35 const { relayClient } = useRelayClient(); 36 const { stateManager } = useStateManager(); 37 const navigate = useNavigate({ from: "/merchant-connect" }); 38 39 const [searchShopId, setSearchShopId] = useState<string>( 40 shopId ? toHex(shopId, { size: 32 }) : "", 41 ); 42 const [step, setStep] = useState<SearchShopStep>( 43 SearchShopStep.Search, 44 ); 45 const [errorMsg, setErrorMsg] = useState<string | null>(null); 46 const [shopData, setShopData] = useState< 47 { 48 name: string; 49 image: string; 50 } | null 51 >(null); 52 53 useEffect(() => { 54 // If keycard is already enrolled as a customer, reset keycard 55 if (shopId && keycard.role === KeycardRole.RETURNING_GUEST) { 56 const privateKey = generatePrivateKey(); 57 const account = privateKeyToAccount(privateKey); 58 setKeycard({ 59 privateKey, 60 role: KeycardRole.NEW_GUEST, 61 address: account.address, 62 }); 63 } 64 }, [keycard.role === KeycardRole.RETURNING_GUEST, shopId]); 65 66 function handleClearShopIdInput() { 67 setSearchShopId(""); 68 setStep(SearchShopStep.Search); 69 } 70 71 async function handleSearchForShop() { 72 setErrorMsg(null); 73 if (searchShopId.length > 66) { 74 setErrorMsg("Invalid shop ID (input too long)"); 75 return; 76 } 77 if (!isHex(searchShopId)) { 78 setErrorMsg("Invalid shop ID (input not hex)"); 79 return; 80 } 81 const shopID = hexToBigInt(searchShopId as `0x${string}`, { size: 32 }); 82 try { 83 const uri = (await shopPublicClient!.readContract({ 84 address: abi.shopRegAddress, 85 abi: abi.shopRegAbi, 86 functionName: "tokenURI", 87 args: [shopID], 88 })) as string; 89 if (uri) { 90 const res = await fetch(uri); 91 const data = await res.json(); 92 logger.debug("Shop found"); 93 setShopData(data); 94 navigate({ 95 search: { shopId: searchShopId }, 96 }); 97 setStep(SearchShopStep.Connect); 98 } else { 99 setErrorMsg("Shop not found"); 100 } 101 } catch (error: unknown) { 102 logger.error("Error finding shop", { error }); 103 setErrorMsg("Error finding shop"); 104 } 105 } 106 107 async function enroll() { 108 try { 109 if (!relayClient) { 110 throw new Error("Relay client not found"); 111 } 112 if (!stateManager) { 113 logger.warn("stateManager is undefined"); 114 return; 115 } 116 const res = await relayClient.enrollKeycard( 117 wallet!, 118 wallet!.account, 119 false, 120 getWindowLocation(), 121 ); 122 if (!res.ok) { 123 throw new Error("Failed to enroll keycard"); 124 } 125 // Reassign keycard role as merchant after enroll. 126 setKeycard({ 127 privateKey: keycard.privateKey, 128 role: KeycardRole.MERCHANT, 129 address: keycard.address, 130 }); 131 logger.debug`Keycard enrolled: ${keycard.privateKey}`; 132 await relayClient.connect(); 133 await relayClient.authenticate(); 134 stateManager!.addConnection(relayClient); 135 setStep(SearchShopStep.Confirm); 136 } catch (error: unknown) { 137 logger.error("Error enrolling keycard", { error }); 138 setErrorMsg(`Something went wrong. ${error}`); 139 } 140 } 141 142 function getButton() { 143 if (step === SearchShopStep.Search) { 144 return ( 145 <Button 146 onClick={handleSearchForShop} 147 disabled={searchShopId.length === 0} 148 > 149 Search for shop 150 </Button> 151 ); 152 } else if (shopData && step === SearchShopStep.Connect) { 153 return ( 154 <div className="flex flex-col gap-4"> 155 <div className="flex gap-3"> 156 <div className="overflow-hidden rounded-full w-12 h-12"> 157 <img 158 src={shopData.image || 159 "/icons/mass-labs-logo.svg"} 160 width={50} 161 height={50} 162 alt="mass-labs-logo" 163 className="w-12 h-12" 164 /> 165 </div> 166 <p className="flex items-center" data-testid="shop-name"> 167 {shopData.name} 168 </p> 169 </div> 170 <ConnectButton chainStatus="name" /> 171 <div> 172 <Button disabled={status !== "connected"} onClick={enroll}> 173 Connect to shop 174 </Button> 175 </div> 176 </div> 177 ); 178 } 179 } 180 function renderContent() { 181 if (step === SearchShopStep.Confirm) { 182 return <ConnectConfirmation />; 183 } else { 184 return ( 185 <section className="md:w-[560px]"> 186 <ErrorMessage 187 errorMessage={errorMsg} 188 onClose={() => { 189 setErrorMsg(null); 190 }} 191 /> 192 <section className="mt-2 flex flex-col gap-4 bg-white p-6 rounded-lg"> 193 <h1>Connect to your shop</h1> 194 <form 195 className="flex flex-col" 196 onSubmit={(e) => e.preventDefault()} 197 > 198 <label className="font-medium" htmlFor="searchShopId"> 199 Shop ID 200 </label> 201 <div className="flex gap-2"> 202 <input 203 className="mt-1 p-2 rounded-md grow" 204 style={{ backgroundColor: "#F3F3F3" }} 205 data-testid="search-shopId" 206 name="searchShopId" 207 value={searchShopId} 208 onChange={(e) => setSearchShopId(e.target.value)} 209 /> 210 <button 211 onClick={handleClearShopIdInput} 212 style={{ backgroundColor: "transparent", padding: 0 }} 213 type="button" 214 > 215 <img 216 src={`/icons/close-icon.svg`} 217 width={15} 218 height={15} 219 alt="close-icon" 220 className="w-4 h-4" 221 /> 222 </button> 223 </div> 224 </form> 225 <div> 226 {getButton()} 227 </div> 228 </section> 229 </section> 230 ); 231 } 232 } 233 return ( 234 <main 235 className="p-4 mt-5 md:flex justify-center" 236 data-testid="merchant-connect-page" 237 > 238 {renderContent()} 239 </main> 240 ); 241 }