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 }