/ packages / frontend / src / components / merchants / ShopSettings.tsx
ShopSettings.tsx
  1  // SPDX-FileCopyrightText: 2024 Mass Labs
  2  //
  3  // SPDX-License-Identifier: GPL-3.0-or-later
  4  
  5  import { ChangeEvent, useEffect, useState } from "react";
  6  import { toHex } from "viem";
  7  import { useWalletClient } from "wagmi";
  8  import { equal } from "@std/assert";
  9  
 10  import { CodecValue } from "@massmarket/utils/codec";
 11  import { setTokenURI } from "@massmarket/contracts";
 12  import { useShopId } from "@massmarket/react-hooks";
 13  import { getLogger } from "@logtape/logtape";
 14  import {
 15    AcceptedCurrencyMap,
 16    ChainAddress,
 17    Manifest,
 18  } from "@massmarket/schema";
 19  
 20  import { CurrencyChainOption } from "../../types.ts";
 21  import Button from "../common/Button.tsx";
 22  import AvatarUpload from "../common/AvatarUpload.tsx";
 23  import ValidationWarning from "../common/ValidationWarning.tsx";
 24  import ErrorMessage from "../common/ErrorMessage.tsx";
 25  import SuccessToast from "../common/SuccessToast.tsx";
 26  import BackButton from "../common/BackButton.tsx";
 27  import Dropdown from "../common/CurrencyDropdown.tsx";
 28  import { useShopDetails } from "../../hooks/useShopDetails.ts";
 29  import { useRelayClient } from "../../hooks/useRelayClient.ts";
 30  import { useStateManager } from "../../hooks/useStateManager.ts";
 31  import { useAllCurrencyOptions } from "../../hooks/useAllCurrencyOptions.ts";
 32  
 33  const logger = getLogger(["mass-market", "frontend", "StoreSettings"]);
 34  
 35  export default function ShopSettings() {
 36    const { shopDetails, setShopDetails } = useShopDetails();
 37    const { shopId } = useShopId();
 38    const { data: wallet } = useWalletClient();
 39    const { stateManager } = useStateManager();
 40    const { relayClient } = useRelayClient();
 41  
 42    const [storeName, setStoreName] = useState<string>(shopDetails.name || "");
 43    const [avatar, setAvatar] = useState<FormData | null>(null);
 44  
 45    const [acceptedCurrencies, setAcceptedCurrencies] = useState<
 46      AcceptedCurrencyMap
 47    >(new AcceptedCurrencyMap());
 48    const [pricingCurrency, setPricingCurrency] = useState<
 49      ChainAddress | null
 50    >(
 51      null,
 52    );
 53    const [error, setError] = useState<null | string>(null);
 54    const [validationError, setValidationError] = useState<string | null>(null);
 55    const [success, setSuccess] = useState<string | null>(null);
 56    const [manifest, setManifest] = useState<Manifest | null>(null);
 57    const currencyOptions = useAllCurrencyOptions();
 58    useEffect(() => {
 59      if (!stateManager) return;
 60      function onUpdateEvent(res: CodecValue | undefined) {
 61        if (!res) throw new Error("Manifest not found");
 62        const m = Manifest.fromCBOR(res);
 63        setManifest(m);
 64        setAcceptedCurrencies(m.AcceptedCurrencies);
 65        // setPricingCurrency(m.PricingCurrency);
 66      }
 67  
 68      stateManager.get(["Manifest"])
 69        .then((res: CodecValue | undefined) => {
 70          if (!res) throw new Error("Manifest not found");
 71          const m = Manifest.fromCBOR(res);
 72          setManifest(m);
 73          setAcceptedCurrencies(m.AcceptedCurrencies);
 74          setPricingCurrency(m.PricingCurrency);
 75  
 76          const p = m.Payees.get(m.PricingCurrency.ChainID);
 77          if (p) {
 78            const payee = p.keys().next().value;
 79            if (payee instanceof Uint8Array) {
 80              logger.debug`Payee Address: ${toHex(payee)}`;
 81            }
 82          }
 83        });
 84  
 85      stateManager.events.on(onUpdateEvent, ["Manifest"]);
 86  
 87      return () => {
 88        // Cleanup listeners on unmount
 89        stateManager.events.off(
 90          onUpdateEvent,
 91          ["Manifest"],
 92        );
 93      };
 94    }, [stateManager]);
 95  
 96    function copyToClipboard() {
 97      navigator.clipboard.writeText(String(shopId));
 98    }
 99    async function updateShopManifest() {
100      if (!stateManager) {
101        logger.error`stateManager is undefined"`;
102        return;
103      }
104      //If pricing currency needs to update.
105      if (
106        pricingCurrency!.Address !== manifest!.PricingCurrency.Address ||
107        pricingCurrency!.ChainID !== manifest!.PricingCurrency.ChainID
108      ) {
109        await stateManager.set(["Manifest", "PricingCurrency"], pricingCurrency);
110      }
111      if (
112        acceptedCurrencies.asCBORMap() !==
113          manifest!.AcceptedCurrencies.asCBORMap()
114      ) {
115        await stateManager.set(
116          ["Manifest", "AcceptedCurrencies"],
117          acceptedCurrencies,
118        );
119      }
120  
121      try {
122        //If avatar or store name changed, setShopMetadataURI.
123        if (avatar || storeName !== shopDetails.name) {
124          const metadata = {
125            name: storeName.length ? storeName : shopDetails.name,
126            //If new avatar was uploaded, upload the image, otherwise use previous image.
127            image: avatar
128              ? (
129                await relayClient!.uploadBlob(
130                  avatar as FormData,
131                )
132              ).url
133              : shopDetails.profilePictureUrl,
134          };
135          //Upload metadata to IPFS
136          const jsn = JSON.stringify(metadata);
137          const blob = new Blob([jsn], { type: "application/json" });
138          const file = new File([blob], "file.json");
139          const formData = new FormData();
140          formData.append("file", file);
141          const { url } = await relayClient!.uploadBlob(
142            formData,
143          );
144          await setTokenURI(wallet!, wallet!.account, [shopId!, url]);
145          setShopDetails({
146            name: storeName,
147            profilePictureUrl: metadata.image,
148          });
149        }
150        setSuccess("Changes saved.");
151        // Deno doesn't support globalThis.scrollTo
152        const element = document.getElementById("top");
153        element?.scrollIntoView();
154      } catch (error: unknown) {
155        logger.error("Failed: updateShopManifest", { error });
156        setError("Error updating shop manifest.");
157      }
158    }
159  
160    function handleAcceptedCurrencies(
161      e: ChangeEvent<HTMLInputElement>,
162      c: CurrencyChainOption,
163    ) {
164      const copy = AcceptedCurrencyMap.fromCBOR(acceptedCurrencies.asCBORMap());
165      if (e.target.checked) {
166        copy.addAddress(c.chainId, c.address, true);
167      } else {
168        copy.removeAddress(c.chainId, c.address);
169      }
170      setAcceptedCurrencies(copy);
171    }
172  
173    function handlePricingCurrency(option: CurrencyChainOption) {
174      const pc = new ChainAddress(
175        option.chainId,
176        option.address,
177      );
178      setPricingCurrency(pc);
179    }
180  
181    function currencyIsSelected(option: CurrencyChainOption) {
182      const metadata = acceptedCurrencies.getAddressMetadata(
183        option.chainId,
184        option.address,
185      );
186      return Boolean(metadata);
187    }
188  
189    function selectedPricingCurrency() {
190      return currencyOptions.find((c) => {
191        if (
192          c.chainId === pricingCurrency?.ChainID &&
193          equal(c.address, pricingCurrency?.Address)
194        ) {
195          return c;
196        }
197      });
198    }
199  
200    return (
201      <main
202        className="px-4 mt-3 md:flex justify-center"
203        data-testid="shop-settings-page"
204      >
205        <section className="md:w-[560px]">
206          <ErrorMessage
207            errorMessage={error}
208            onClose={() => {
209              setError(null);
210            }}
211          />
212          <ValidationWarning
213            warning={validationError}
214            onClose={() => {
215              setValidationError(null);
216            }}
217          />
218          <SuccessToast
219            message={success}
220            onClose={() => {
221              setSuccess(null);
222            }}
223          />
224          <BackButton />
225          <section className="mt-2">
226            <div className="flex">
227              <h2>Edit shop details</h2>
228            </div>
229            <section className="mt-2 flex flex-col gap-4 bg-white p-5 rounded-lg">
230              <p className="flex items-center font-medium">Shop PFP</p>
231              <AvatarUpload
232                setImgBlob={setAvatar}
233                setErrorMsg={setError}
234                currentImg={shopDetails.profilePictureUrl}
235              />
236              <section className="text-sm flex flex-col gap-4">
237                <div>
238                  <section>
239                    <form
240                      className="flex flex-col"
241                      onSubmit={(e) => e.preventDefault()}
242                    >
243                      <label
244                        className="font-medium text-base"
245                        htmlFor="storeName"
246                      >
247                        Shop Name
248                      </label>
249                      <input
250                        className="border-2 border-solid mt-1 p-2 rounded"
251                        data-testid="storeName"
252                        name="storeName"
253                        value={storeName}
254                        placeholder={shopDetails.name}
255                        onChange={(e) => setStoreName(e.target.value)}
256                      />
257                    </form>
258                  </section>
259                  <section className="mt-4 flex">
260                    <form
261                      className="flex flex-col"
262                      onSubmit={(e) => e.preventDefault()}
263                    >
264                      <label className="font-medium text-base" htmlFor="storeId">
265                        Shop ID
266                      </label>
267                      <div className="flex gap-2">
268                        <input
269                          className="border-2 border-solid mt-1 p-2 rounded"
270                          id="shopId"
271                          name="shopId"
272                          value={String(shopId)}
273                          onChange={() => {}}
274                        />
275                        <button
276                          type="button"
277                          className="mr-4"
278                          style={{ backgroundColor: "transparent", padding: 0 }}
279                          onClick={copyToClipboard}
280                        >
281                          <img
282                            src="/icons/copy-icon.svg"
283                            width={14}
284                            height={14}
285                            alt="copy-icon"
286                            className="w-auto h-auto"
287                          />
288                        </button>
289                      </div>
290                    </form>
291                  </section>
292                  <section className="mt-4">
293                    <label className="font-medium text-base">
294                      Accepted currency
295                    </label>
296                    <div
297                      className="flex flex-col gap-1 mt-1"
298                      data-testid="displayed-accepted-currencies"
299                    >
300                      {currencyOptions.map((c: CurrencyChainOption) => {
301                        return (
302                          <div key={c.value}>
303                            <label className="flex items-center space-x-2">
304                              <input
305                                type="checkbox"
306                                onChange={(e) => handleAcceptedCurrencies(e, c)}
307                                className="form-checkbox h-4 w-4"
308                                value={c.value}
309                                checked={currencyIsSelected(c)}
310                              />
311                              <span>{c.label}</span>
312                            </label>
313                          </div>
314                        );
315                      })}
316                    </div>
317                  </section>
318                  <section className="mt-4">
319                    <div className="flex flex-col">
320                      <Dropdown
321                        label="Pricing Currency"
322                        testId="pricing-currency-dropdown"
323                        options={currencyOptions}
324                        callback={handlePricingCurrency}
325                        selected={selectedPricingCurrency()}
326                      />
327                    </div>
328                  </section>
329                </div>
330              </section>
331            </section>
332            <div className="my-4">
333              <Button onClick={updateShopManifest}>Update</Button>
334            </div>
335          </section>
336        </section>
337      </main>
338    );
339  }