ListingDetail.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 { Link, useSearch } from "@tanstack/react-router"; 7 import { formatUnits } from "viem"; 8 import { getLogger } from "@logtape/logtape"; 9 10 import { Listing, Order, OrderedItem } from "@massmarket/schema"; 11 import type { CodecValue } from "@massmarket/utils/codec"; 12 import { ListingId, OrderState } from "../types.ts"; 13 import Button from "./common/Button.tsx"; 14 import BackButton from "./common/BackButton.tsx"; 15 import { useStateManager } from "../hooks/useStateManager.ts"; 16 import { useBaseToken } from "../hooks/useBaseToken.ts"; 17 import { useKeycard } from "../hooks/useKeycard.ts"; 18 import ErrorMessage from "./common/ErrorMessage.tsx"; 19 import SuccessToast from "./common/SuccessToast.tsx"; 20 import { useCurrentOrder } from "../hooks/useCurrentOrder.ts"; 21 22 const logger = getLogger(["mass-market", "frontend", "listing-detail"]); 23 24 export default function ListingDetail() { 25 const { baseToken } = useBaseToken(); 26 const { stateManager } = useStateManager(); 27 const [keycard] = useKeycard(); 28 const search = useSearch({ strict: false }); 29 const { currentOrder, createOrder, cancelAndRecreateOrder } = 30 useCurrentOrder(); 31 const itemId = search.itemId as ListingId; 32 const [listing, setListing] = useState<Listing>(new Listing()); 33 const [tokenIcon, setIcon] = useState("/icons/usdc-coin.png"); 34 const [quantity, setQuantity] = useState<number>(1); 35 const [errorMsg, setErrorMsg] = useState<null | string>(null); 36 const [successMsg, setMsg] = useState<string | null>(null); 37 const [displayedImg, setDisplayedImg] = useState<string | null>(null); 38 39 useEffect(() => { 40 if (!(itemId && baseToken && stateManager)) { 41 return; 42 } 43 //set item details 44 stateManager 45 ?.get(["Listings", itemId]) 46 .then((res: CodecValue | undefined) => { 47 if (!res) { 48 logger.info`Listing ${itemId} not found`; 49 throw new Error(`Listing not found`); 50 } 51 const item = Listing.fromCBOR(res); 52 setListing(item); 53 if (item.Metadata.Images && item.Metadata.Images.length > 0) { 54 setDisplayedImg(item.Metadata.Images[0]); 55 } 56 if (baseToken?.symbol === "ETH") { 57 setIcon("/icons/eth-coin.svg"); 58 } 59 }); 60 }, [itemId, baseToken, stateManager]); 61 62 if (!listing) { 63 return ( 64 <main data-testid="listing-detail-page"> 65 <p>Item not found.</p> 66 </main> 67 ); 68 } 69 70 function handlePurchaseQty(e: ChangeEvent<HTMLInputElement>) { 71 const newValue = parseInt(e.target.value); 72 setQuantity(newValue); 73 } 74 75 async function changeItems() { 76 if (!stateManager) { 77 return; 78 } 79 try { 80 let orderId = currentOrder?.ID; 81 if (!orderId) { 82 await createOrder(itemId, quantity); 83 if (quantity <= 1) { 84 setMsg("Item added to cart"); 85 } else { 86 setMsg(`${quantity} items added`); 87 } 88 return; 89 } 90 91 // Update existing order 92 // If the order is not an open order, cancel it and create a new one 93 if (currentOrder?.State !== OrderState.Open) { 94 orderId = await cancelAndRecreateOrder(); 95 } 96 97 if (!orderId) { 98 throw new Error("Order ID is undefined"); 99 } 100 101 const o = await stateManager.get(["Orders", orderId]); 102 if (!o) { 103 logger.info`Order ${orderId} not found`; 104 throw new Error(`Order not found`); 105 } 106 const order: Order = Order.fromCBOR(o); 107 let cartItemQuantity = 0; 108 // If item already exists in the items array, filter it out so we can replace it with the new quantity 109 const updatedOrderItems = (order.Items ?? []).filter( 110 (item: OrderedItem) => { 111 if (item.ListingID === itemId) { 112 // this should never happen? 113 if (cartItemQuantity !== 0) { 114 logger.debug( 115 "cart item quantity for the same item should not be changed more than at most once", 116 ); 117 } 118 cartItemQuantity = item.Quantity; 119 } 120 return item.ListingID !== itemId; 121 }, 122 ); 123 // note: cartItemQuantity is 0 if this is the first time we add the item to our cart, and adding with 0 is fine :) 124 updatedOrderItems.push( 125 new OrderedItem(itemId, cartItemQuantity + quantity), 126 ); 127 128 await stateManager.set( 129 ["Orders", orderId, "Items"], 130 // TODO: this is a bit of a hack, since StateManager doesnt handle BaseClass[] 131 updatedOrderItems.map((item: OrderedItem) => item.asCBORMap()), 132 ); 133 if (quantity <= 1) { 134 setMsg("Cart updated"); 135 } else { 136 setMsg(`${quantity} items added`); 137 } 138 setQuantity(1); 139 } catch (error) { 140 logger.error`Error: changeItems ${error}`; 141 setErrorMsg("There was an error updating cart"); 142 } 143 } 144 145 function splitTextByNewlines(input: string) { 146 return input.split("\n"); 147 } 148 149 return ( 150 <main 151 className="bg-gray-100 md:flex justify-center" 152 data-testid="listing-detail-page" 153 > 154 <section className="flex flex-col md:w-[800px] mx-4"> 155 <ErrorMessage 156 errorMessage={errorMsg} 157 onClose={() => { 158 setErrorMsg(null); 159 }} 160 /> 161 <BackButton /> 162 <div className="my-3"> 163 <h1 className="flex items-center" data-testid="title"> 164 {listing.Metadata.Title} 165 </h1> 166 <div 167 className={`mt-2 ${keycard.role === "merchant" ? "" : "hidden"}`} 168 > 169 <Button> 170 <Link 171 to="/edit-listing" 172 search={(prev: Record<string, string>) => ({ 173 shopId: prev.shopId, 174 itemId: listing.ID, 175 })} 176 style={{ 177 color: "white", 178 }} 179 > 180 Edit Product 181 </Link> 182 </Button> 183 </div> 184 </div> 185 <div className="md:flex md:gap-8"> 186 <div className="listing-image-container md:w-3/5"> 187 {displayedImg && ( 188 <img 189 src={displayedImg} 190 alt="product-detail-image" 191 className="rounded-lg w-full max-h-1/2 md:max-h-[380px]" 192 style={{ 193 objectFit: "cover", 194 objectPosition: "center", 195 border: "none", 196 }} 197 /> 198 )} 199 {listing.Metadata.Images && listing.Metadata.Images.length > 1 200 ? ( 201 <div className="flex mt-2 gap-2"> 202 {listing.Metadata.Images.map((image: string, i: number) => { 203 if (image === displayedImg) return; 204 return ( 205 <img 206 key={i} 207 src={image} 208 alt="product-detail-image" 209 width={90} 210 height={81} 211 className="border rounded-lg" 212 style={{ 213 maxHeight: "81px", 214 maxWidth: "90px", 215 objectFit: "cover", 216 objectPosition: "center", 217 border: "none", 218 }} 219 onClick={() => setDisplayedImg(image)} 220 /> 221 ); 222 })} 223 </div> 224 ) 225 : null} 226 </div> 227 <section className="flex gap-4 flex-col bg-white mt-5 md:mt-0 rounded-md md:w-2/5 p-4"> 228 <div> 229 <h3 className=" ">Description</h3> 230 <section data-testid="description"> 231 {splitTextByNewlines(listing.Metadata.Description).map( 232 (line: string, index: number) => ( 233 <p key={`description-${index}`} className="min-h-[1ch]"> 234 {line} 235 </p> 236 ), 237 )} 238 </section> 239 </div> 240 <div className="flex gap-2 items-center mt-auto"> 241 <img 242 src={tokenIcon} 243 alt="coin" 244 width={24} 245 height={24} 246 className="w-6 h-6 max-h-6" 247 /> 248 <h1 data-testid="price"> 249 {formatUnits(listing.Price, baseToken.decimals)} 250 </h1> 251 </div> 252 <div 253 className={keycard.role === "merchant" ? "hidden" : "flex gap-2"} 254 > 255 <div> 256 <p className="text-xs text-primary-gray mb-2">Quantity</p> 257 <input 258 className="mt-1 p-2 rounded-md max-w-[80px]" 259 style={{ backgroundColor: "#F3F3F3" }} 260 id="quantity" 261 name="quantity" 262 value={quantity} 263 data-testid="purchaseQty" 264 type="number" 265 min="1" 266 step="1" 267 onChange={(e) => handlePurchaseQty(e)} 268 /> 269 </div> 270 <div className="flex items-end"> 271 <Button 272 onClick={changeItems} 273 disabled={!quantity} 274 data-testid="addToCart" 275 > 276 <div className="flex items-center gap-2"> 277 <p>Add to cart</p> 278 <img 279 src="/icons/white-arrow.svg" 280 alt="white-arrow" 281 width={7} 282 height={12} 283 style={{ display: quantity ? "" : "none" }} 284 /> 285 </div> 286 </Button> 287 </div> 288 </div> 289 <div className="h-6 mb-4"> 290 <SuccessToast 291 message={successMsg} 292 onClose={() => setMsg(null)} 293 cta={{ copy: "View Cart", href: "/cart" }} 294 /> 295 </div> 296 </section> 297 </div> 298 </section> 299 </main> 300 ); 301 }