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