/ packages / frontend / src / components / merchants / MerchantConnect.tsx
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  }