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