OrderDetails.tsx
1 import { useEffect, useState } from "react"; 2 import { createPublicClient, formatUnits, http, toHex } from "viem"; 3 import { type Chain } from "wagmi/chains"; 4 import { useSearch } from "@tanstack/react-router"; 5 import { useChains } from "wagmi"; 6 import { getLogger } from "@logtape/logtape"; 7 import { 8 AddressDetails, 9 Listing, 10 Order, 11 OrderedItem, 12 } from "@massmarket/schema"; 13 import { CodecValue } from "@massmarket/utils/codec"; 14 15 import BackButton from "../common/BackButton.tsx"; 16 import { ListingId, OrderState } from "../../types.ts"; 17 import { useStateManager } from "../../hooks/useStateManager.ts"; 18 import { useBaseToken } from "../../hooks/useBaseToken.ts"; 19 import { formatDate, getTokenInformation } from "../../utils/mod.ts"; 20 21 const logger = getLogger(["mass-market", "frontend", "order-details"]); 22 23 export default function OrderDetails() { 24 const { stateManager } = useStateManager(); 25 const chains = useChains(); 26 const search = useSearch({ strict: false }); 27 const { baseToken } = useBaseToken(); 28 const orderId = search.orderId; 29 const [cartItemsMap, setCartMap] = useState<Map<ListingId, Listing>>( 30 new Map(), 31 ); 32 const [selectedQty, setSelectedQty] = useState<Map<ListingId, number>>( 33 new Map(), 34 ); 35 const [txHash, setTxHash] = useState<string | null>(null); 36 const [blockHash, setBlockHash] = useState<string | null>(null); 37 const [etherScanLink, setLink] = useState<string | null>(null); 38 const [order, setOrder] = useState<Order>(new Order()); 39 const [token, setToken] = useState< 40 { symbol: string; decimals: number } 41 >({ 42 symbol: "", 43 decimals: 0, 44 }); 45 const [orderDate, setOrderDate] = useState<string | null>(null); 46 47 useEffect(() => { 48 if (!orderId || !stateManager) return; 49 stateManager.get(["Orders", orderId]).then( 50 (res: CodecValue | undefined) => { 51 if (!res) throw new Error("Order not found"); 52 const o = Order.fromCBOR(res); 53 getAllCartItemDetails(o).then((cartItems) => { 54 setCartMap(cartItems); 55 setOrder(o); 56 57 if (o.PaymentDetails) { 58 const d = formatDate(o.PaymentDetails!.TTL); 59 setOrderDate(d); 60 } 61 }); 62 }, 63 ); 64 }, [orderId, stateManager]); 65 66 useEffect(() => { 67 if (order?.State === OrderState.Paid) { 68 const id = order.ChosenCurrency!.ChainID; 69 order.TxDetails!.TxHash && setTxHash(toHex(order.TxDetails!.TxHash!)); 70 order.TxDetails!.BlockHash && 71 setBlockHash(toHex(order.TxDetails!.BlockHash!)); 72 73 const chain = chains.find((chain: Chain) => chain.id === id) || null; 74 75 if (chain) { 76 setLink(chain.blockExplorers?.default?.url || null); 77 } 78 } 79 // Show price in pricing currency as default. 80 setToken(baseToken); 81 // TODO: might need to useToken(...)s. one for items, one for order summary 82 // this not necessarily the same currency as pricing currency was at the time of order... 83 if (order?.ChosenCurrency) { 84 const chain = chains.find((chain: Chain) => 85 chain.id === Number(order.ChosenCurrency!.ChainID) 86 ); 87 if (!chain) { 88 throw new Error(`Chain (${order.ChosenCurrency!.ChainID}) not found`); 89 } 90 const tokenPublicClient = createPublicClient({ 91 chain, 92 transport: http(), 93 }); 94 getTokenInformation( 95 tokenPublicClient, 96 toHex(order.ChosenCurrency!.Address), 97 ).then(([symbol, decimals]) => { 98 setToken({ symbol, decimals }); 99 }); 100 } 101 }, [order]); 102 103 function copyTxHash() { 104 navigator.clipboard.writeText(txHash!); 105 } 106 107 function copyBlockHash() { 108 navigator.clipboard.writeText(blockHash!); 109 } 110 111 async function getAllCartItemDetails(order: Order) { 112 if (!stateManager) { 113 logger.warn("stateManager is undefined"); 114 return new Map(); 115 } 116 const ci = order.Items; 117 const allCartItems = new Map<ListingId, Listing>(); 118 await Promise.all( 119 ci.map(async (orderItem: OrderedItem) => { 120 const updatedQtyMap = new Map(selectedQty); 121 updatedQtyMap.set(orderItem.ListingID, orderItem.Quantity); 122 setSelectedQty(updatedQtyMap); 123 // If the selected quantity is 0, don't add the item to cart items map 124 if (orderItem.Quantity === 0) return; 125 // Get price and metadata for all the selected items in the order. 126 const listing = await stateManager.get([ 127 "Listings", 128 orderItem.ListingID, 129 ]); 130 if (!listing) throw new Error("Listing not found"); 131 const l = Listing.fromCBOR(listing); 132 allCartItems.set(orderItem.ListingID, l); 133 }), 134 ); 135 return allCartItems; 136 } 137 138 function renderItems() { 139 if (!order || !cartItemsMap.size) return <p>No items in cart</p>; 140 const values: Listing[] = Array.from(cartItemsMap.values()); 141 return values.map((listing: Listing) => { 142 return ( 143 <div 144 key={listing.ID} 145 className="flex gap-4 md:grid md:grid-cols-3 items-center" 146 data-testid="order-item" 147 > 148 <div className="flex gap-1 items-center"> 149 <img 150 src={listing.Metadata.Images?.[0] || "/assets/no-image.png"} 151 width={48} 152 height={48} 153 alt="product-thumb" 154 className="w-12 h-12 object-cover object-center rounded-lg" 155 /> 156 <h3 data-testid="item-title" className="line-clamp-2"> 157 {listing.Metadata.Title} 158 </h3> 159 </div> 160 161 <p data-testid="item-price"> 162 {formatUnits(listing.Price, token!.decimals)} {token!.symbol} 163 </p> 164 <p data-testid="item-quantity"> 165 Quantity: {selectedQty.get(listing.ID)} 166 </p> 167 </div> 168 ); 169 }); 170 } 171 172 function renderAddressDetails(addr: AddressDetails, isShipping: boolean) { 173 return ( 174 <section 175 className="mt-2 flex flex-col gap-4 bg-white p-6 rounded-lg" 176 data-testid={isShipping ? "shipping-details" : "billing-details"} 177 > 178 <h2>{isShipping ? "Shipping Details" : "Billing Details"}</h2> 179 <div className="grid grid-cols-2"> 180 <h3>Name</h3> 181 <p>{addr.Name}</p> 182 </div> 183 <div className="grid grid-cols-2"> 184 <h3>Address</h3> 185 <div> 186 <p>{addr.Address1}</p> 187 {addr.Address2 && <p>{addr.Address2}</p>} 188 <p>{addr.City}</p> 189 <p>{addr.Country}</p> 190 <p>{addr.PostalCode}</p> 191 </div> 192 </div> 193 <div className="grid grid-cols-2"> 194 <h3>Email</h3> 195 <p>{addr.EmailAddress}</p> 196 </div> 197 {addr.PhoneNumber && ( 198 <div className="grid grid-cols-2"> 199 <h3>Phone</h3> 200 <p>{addr.PhoneNumber}</p> 201 </div> 202 )} 203 </section> 204 ); 205 } 206 207 if (!order) return <p data-testid="order-details-page">No order found</p>; 208 209 return ( 210 <main 211 className="px-4 md:flex justify-center" 212 data-testid="order-details-page" 213 > 214 <section className="md:w-[560px]"> 215 <BackButton /> 216 <div className="my-5"> 217 <h1>Order overview</h1> 218 </div> 219 <section className="flex justify-between grid grid-cols-2 gap-1"> 220 <div className="bg-white p-2 rounded-lg flex"> 221 <p className="mr-2">Order ID:</p> 222 <p className="font-bold">{order.ID}</p> 223 </div> 224 <div className="bg-white p-2 rounded-lg flex"> 225 <p className="mr-2"> 226 Total: 227 </p> 228 <p className="font-bold"> 229 {order.PaymentDetails 230 ? `${ 231 formatUnits( 232 BigInt(order.PaymentDetails!.Total), 233 token!.decimals, 234 ) 235 } ${token!.symbol}` 236 : "N/A"} 237 </p> 238 </div> 239 <div className="bg-white p-2 rounded-lg flex"> 240 <p className="mr-2">Order Date:</p> 241 <p className="font-bold">{orderDate}</p> 242 </div> 243 </section> 244 245 <section className="mt-2 flex flex-col gap-4 bg-white p-6 rounded-lg"> 246 <h2>Order items</h2> 247 {renderItems()} 248 </section> 249 {order.ShippingAddress 250 ? renderAddressDetails(order.ShippingAddress, true) 251 : null} 252 {order.InvoiceAddress 253 ? renderAddressDetails(order.InvoiceAddress, false) 254 : null} 255 256 <section 257 className={`mt-2 flex flex-col gap-4 bg-white p-6 rounded-lg ${ 258 txHash || blockHash ? "" : "hidden" 259 }`} 260 > 261 <div className={txHash ? "" : "hidden"}> 262 <h2>Tx Hash</h2> 263 <div className="flex gap-2"> 264 <div 265 className={`bg-background-gray p-2 rounded-md overflow-x-auto w-40 266 }`} 267 > 268 <p>{txHash}</p> 269 </div> 270 <button 271 onClick={copyTxHash} 272 style={{ backgroundColor: "transparent", padding: 0 }} 273 type="button" 274 > 275 <img 276 src="/icons/copy-icon.svg" 277 width={20} 278 height={20} 279 alt="copy-icon" 280 className="w-auto h-auto ml-auto" 281 /> 282 </button> 283 </div> 284 </div> 285 <div className={blockHash ? "" : "hidden"}> 286 <h2>Block Hash</h2> 287 <div className="flex gap-2"> 288 <div 289 className={`bg-background-gray p-2 rounded-md overflow-x-auto w-40 ${ 290 blockHash ? "" : "hidden" 291 }`} 292 > 293 <p>{blockHash}</p> 294 </div> 295 <button 296 onClick={copyBlockHash} 297 style={{ backgroundColor: "transparent", padding: 0 }} 298 type="button" 299 > 300 <img 301 src="/icons/copy-icon.svg" 302 width={20} 303 height={20} 304 alt="copy-icon" 305 className="w-auto h-auto ml-auto" 306 /> 307 </button> 308 </div> 309 </div> 310 311 <a 312 href={`${etherScanLink}/tx/${txHash}`} 313 className={etherScanLink && txHash ? "" : "hidden"} 314 > 315 View TX 316 </a> 317 <a 318 href={`${etherScanLink}/block/${blockHash}`} 319 className={etherScanLink && blockHash ? "" : "hidden"} 320 > 321 View block 322 </a> 323 </section> 324 </section> 325 </main> 326 ); 327 }