From b99bd429d0f6514f189c139d431402d96aeb9073 Mon Sep 17 00:00:00 2001 From: LimIvan336 <71662324+LimIvan336@users.noreply.github.com> Date: Wed, 17 Jan 2024 21:26:56 +0800 Subject: [PATCH 01/12] Revert "remove merch routes for launch (#130)" This reverts commit 6bb3c6ddb785141d643d7d587b20cd28c29cd286. --- apps/web/pages/merch/cart/index.tsx | 379 +++++++++++++++++++ apps/web/pages/merch/checkout/index.tsx | 190 ++++++++++ apps/web/pages/merch/index.tsx | 92 +++++ apps/web/pages/merch/orders/[slug].tsx | 199 ++++++++++ apps/web/pages/merch/product/[slug].tsx | 474 ++++++++++++++++++++++++ 5 files changed, 1334 insertions(+) create mode 100644 apps/web/pages/merch/cart/index.tsx create mode 100644 apps/web/pages/merch/checkout/index.tsx create mode 100644 apps/web/pages/merch/index.tsx create mode 100644 apps/web/pages/merch/orders/[slug].tsx create mode 100644 apps/web/pages/merch/product/[slug].tsx diff --git a/apps/web/pages/merch/cart/index.tsx b/apps/web/pages/merch/cart/index.tsx new file mode 100644 index 00000000..45e2fd33 --- /dev/null +++ b/apps/web/pages/merch/cart/index.tsx @@ -0,0 +1,379 @@ +/* eslint-disable */ +/* eslint-disable @typescript-eslint/no-misused-promises */ + + +import React, { useRef, useState, FC, useEffect } from "react"; +import Link from "next/link"; +import { + Button, + Flex, + Heading, + useBreakpointValue, + Divider, + useDisclosure, + Grid, + GridItem, + Text, + Input, + Spinner, +} from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import Joi from "joi"; +import { + CartAction, + CartActionType, + useCartStore, +} from "features/merch/context/cart"; +import { + CartCard, + CartEmptyView, + CartHeader, + CartItemCard, + CartRemoveModal, + LoadingScreen, + Page, +} from "ui/components/merch"; +import { api } from "features/merch/services/api"; +import { routes, QueryKeys } from "features/merch/constants"; +import { displayPrice } from "features/merch/functions"; +import { calculatePricing } from "merch-helpers"; +import { useRouter } from "next/router"; + +type ValidationType = { + error: boolean; + isLoading: boolean; +}; + +const Cart: FC = () => { + // Context hook. + const cartContext = useCartStore(); + const { state: cartState, dispatch: cartDispatch } = cartContext; + + const router = useRouter(); + const [reroute, setReroute] = useState(false); + + // Email input for billing. + const [validation, setValidation] = useState({ + isLoading: false, + error: false, + }); + + // Calculation of pricing + const [isCartLoading, setIsCartLoading] = useState(true); + const { data: products, isLoading: isProductsQueryLoading } = useQuery( + [QueryKeys.PRODUCTS], + () => api.getProducts(), + { + onSuccess: () => { + setIsCartLoading(false); + }, + } + ); + + // Voucher section + // const [voucherInput, setVoucherInput] = useState(""); + // const [voucherError, setVoucherError] = useState(false); + + const pricedCart = products + ? calculatePricing(products, cartState.cart, undefined) + : null; + + const emailValidator = Joi.string() + .email({ tlds: { allow: false } }) + .required() + .label("Email"); + + // Removal Modal cartStates + const { isOpen, onOpen, onClose } = useDisclosure(); + const toBeRemoved = useRef({ productId: "", size: "", color: "" }); + + // Check if break point hit. + const isMobile: boolean = + useBreakpointValue({ base: true, md: false }) || false; + + // Apply voucher - TODO + // const { mutate: applyVoucher, isLoading: voucherLoading } = useMutation( + // () => api.postQuotation(cartState.cart, voucherInput), + // { + // onMutate: () => { + // setPriceLoading(true); + // }, + // onSuccess: (data: PricedCart) => { + // setPriceInfo(data.total); + // if (data.price.discount > 0) { + // // Voucher is valid + // cartDispatch({ type: CartActionType.VALID_VOUCHER, payload: voucherInput }); + // setVoucherError(false); + // setVoucherInput(""); + // } else { + // setVoucherError(true); + // } + // }, + // onSettled: () => { + // setPriceLoading(false); + // }, + // } + // ); + + // const handleRemoveVoucher = () => { + // setVoucherInput(""); + // cartDispatch({ type: CartActionType.REMOVE_VOUCHER, payload: null }); + // applyVoucher(); + // }; + + // Update Cart Item by Size & Id (To be changed next time: BE) + const removeItem = (productId: string, size: string, color: string) => { + cartDispatch({ + type: CartActionType.REMOVE_ITEM, + payload: { id: productId, size: size, color: color }, + }); + onClose(); + }; + + // Set modal's ref value to size & productId pair. + const handleRemoveItem = (productId: string, size: string, color: string) => { + onOpen(); + toBeRemoved.current.size = size; + toBeRemoved.current.color = color; + toBeRemoved.current.productId = productId; + }; + + // Update Cart Item by Size & Id (To be changed next time: BE) + const onQuantityChange = ( + productId: string, + size: string, + color: string, + qty: number + ) => { + const action: CartAction = { + type: CartActionType.UPDATE_QUANTITY, + payload: { id: productId, size: size, color: color, quantity: qty }, + }; + cartDispatch(action); + }; + + const handleToCheckout = async () => { + setValidation({ isLoading: true, error: false }); + try { + await emailValidator.validateAsync(cartState.billingEmail); + cartDispatch({ + type: CartActionType.UPDATE_BILLING_EMAIL, + payload: cartState.billingEmail, + }); + setReroute(true); + } catch (error: any) { + setValidation({ isLoading: false, error: true }); + } + }; + + const CartHeading = ( + + Your Cart + + ); + + const PriceInfoSection = ( + + {!pricedCart ? ( + + + Calculating your cart price + + ) : ( + <> + + + Item(s) subtotal + {displayPrice(pricedCart.subtotal)} + + + Voucher Discount + {displayPrice(pricedCart.discount)} + + + + Total + {displayPrice(pricedCart.total)} + + + + + { + cartDispatch({ + type: CartActionType.UPDATE_NAME, + payload: event.target.value, + }); + }} + variant="outline" + /> + + { + cartDispatch({ + type: CartActionType.UPDATE_BILLING_EMAIL, + payload: event.target.value, + }); + }} + variant="outline" + /> + + {validation.error && "*Invalid email format"} + + + + + + + + + + )} + + ); + /* TODO + const VoucherSection = ( + + + + ) => { + const target = e.target as HTMLInputElement; + setVoucherInput(target.value); + }} + /> + + + + {!cartState.voucher ? ( + Apply your voucher code! + ) : ( + + {voucherError && Invalid voucher} + {cartState.voucher && priceInfo.discount > 0 && ( + + Applied Voucher + + + )} + + )} + + + + ); +*/ + const renderCartView = () => ( + + + {!isMobile && } + {cartState.cart.items.map((item, index) => ( + <> + product.id === item.id)} + isLoading={isProductsQueryLoading} + isMobile={isMobile} + onRemove={handleRemoveItem} + onQuantityChange={onQuantityChange} + /> + {index !== cartState.cart.items.length - 1 && } + + ))} + + + {/* {VoucherSection} TODO*/} + {PriceInfoSection} + + + An email will be sent to you closer to the collection date. Our + collection venue is at 50 Nanyang Ave, #32 Block N4 #02a, Singapore + 639798. + + + + + removeItem( + toBeRemoved.current.productId, + toBeRemoved.current.size, + toBeRemoved.current.color + ) + } + /> + + ); + + const renderCartContent = () => { + if (isCartLoading) { + return ; + } + if (cartState.cart.items.length === 0) { + return ; + } + return renderCartView(); + }; + + useEffect(() => { + if (reroute) { + void router.push(routes.CHECKOUT); + } + }, [reroute]); + + return ( + + {CartHeading} + {renderCartContent()} + + ); +}; + +export default Cart; diff --git a/apps/web/pages/merch/checkout/index.tsx b/apps/web/pages/merch/checkout/index.tsx new file mode 100644 index 00000000..07f44240 --- /dev/null +++ b/apps/web/pages/merch/checkout/index.tsx @@ -0,0 +1,190 @@ +/* eslint-disable */ + +import { CartEmptyView, Page } from "ui/components/merch"; +import { useCartStore } from "@/features/merch/context/cart"; +import { useEffect, useState } from "react"; +import { useCheckoutStore } from "@/features/merch/context/checkout"; +import { + Box, + Divider, + Flex, + Grid, + GridItem, + Heading, + Text, + Image, + Badge, +} from "@chakra-ui/react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { QueryKeys, routes } from "@/features/merch/constants"; +import { api } from "@/features/merch/services/api"; +import CheckoutSkeleton from "@/features/merch/components/checkout/Skeleton"; +import Link from "next/link"; +import { displayPrice } from "@/features/merch/functions"; +import { useRouter } from "next/router"; +import StripeForm from "@/features/merch/components/checkout/StripeForm"; +import { CheckoutResponse } from "types"; + +const CheckoutPage = () => { + const [isLoading, setIsLoading] = useState(true); + const { state: cartState } = useCartStore(); + const { setState: setCheckoutState } = useCheckoutStore(); + + // Fetch and check if cart item is valid. + const { mutate: initCheckout } = useMutation( + () => + api.postCheckoutCart( + cartState.cart, + cartState.billingEmail, + cartState.voucher + ), + { + retry: false, + onMutate: () => { + setIsLoading(true); + }, + onSuccess: (data: CheckoutResponse) => { + setCheckoutState(data); + }, + onSettled: () => { + setIsLoading(false); + }, + } + ); + + const router = useRouter(); + + useEffect(() => { + if (!cartState.billingEmail) { + void router.push(routes.CART); + return; + } + initCheckout(); + }, []); + + return ( + + + Checkout + + {isLoading ? ( + + ) : cartState.cart.items.length === 0 ? ( + + ) : ( + + )} + + ); +}; + +const CheckoutView = () => { + const { state: checkoutState } = useCheckoutStore(); + return ( + + + {OrderSummary()} + + + {checkoutState?.payment?.clientSecret && ( + + )} + + + ); +}; + +const OrderSummary = () => { + const { state: cartState } = useCartStore(); + const { state: checkoutState } = useCheckoutStore(); + + const noOfItems = cartState.cart.items.length; + + const { data: products } = useQuery( + [QueryKeys.PRODUCTS], + () => api.getProducts(), + {} + ); + + return ( + + + Order Summary + + {`${noOfItems} item(s) Edit`} + + + {`Name: ${cartState.name}`} + {`Billing email: ${cartState.billingEmail}`} + {cartState.cart.items?.map((item) => { + const product = products?.find(({ id }) => id === item.id); + const subtotal = (product?.price ?? -1) * item.quantity; + return ( + + {product?.name} + + + + {product?.name} + + {displayPrice(subtotal)} + + + {`Color: ${item.color}`} + + + + {`Qty x${item.quantity}`} + + {item.size} + + + {displayPrice(product?.price ?? 0)} each + + + + ); + })} + + + + + {/* Subtotal: */} + {/* Discount: */} + Grand total: + + + {/* {displayPrice(checkoutState?.price?.subtotal ?? 0)} */} + {/* {displayPrice(checkoutState?.price?.discount ?? 0)} */} + + {displayPrice(checkoutState?.price?.grandTotal ?? 0)} + + + + + ); +}; + +export default CheckoutPage; diff --git a/apps/web/pages/merch/index.tsx b/apps/web/pages/merch/index.tsx new file mode 100644 index 00000000..c6cdfcb6 --- /dev/null +++ b/apps/web/pages/merch/index.tsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; +import { Flex, Divider, Select, Heading, Grid } from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import { Card, MerchListSkeleton, Page } from "ui/components/merch"; +import { QueryKeys } from "features/merch/constants"; +import { api } from "features/merch/services/api"; +import { Product } from "types"; +import { isOutOfStock } from "features/merch/functions"; + +const MerchandiseList = () => { + const [selectedCategory, setSelectedCategory] = useState(""); + + const { data: products, isLoading } = useQuery( + [QueryKeys.PRODUCTS], + () => api.getProducts(), + {} + ); + + const categories = products?.map((product: Product) => product?.category); + const uniqueCategories = categories + ?.filter((c, idx) => categories.indexOf(c) === idx) + .filter(Boolean); + + const handleCategoryChange = ( + event: React.ChangeEvent + ) => { + setSelectedCategory(event.target.value); + }; + + return ( + + + + New Drop + + + + + {isLoading ? ( + + ) : ( + + {products + ?.filter((product: Product) => { + if (!product?.is_available) return false; + if (selectedCategory === "") return true; + return product?.category === selectedCategory; + }) + ?.map((item: Product, idx: number) => ( + + ))} + + )} + + ); +}; + +export default MerchandiseList; diff --git a/apps/web/pages/merch/orders/[slug].tsx b/apps/web/pages/merch/orders/[slug].tsx new file mode 100644 index 00000000..5f9e8a52 --- /dev/null +++ b/apps/web/pages/merch/orders/[slug].tsx @@ -0,0 +1,199 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { Image, Badge, Button, Divider, Flex, Heading, Text, useBreakpointValue } from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import { Page } from "ui/components/merch"; +import { Order, OrderStatus } from "types"; +import { api } from "features/merch/services/api"; +import { routes } from "features/merch/constants/routes"; +import { QueryKeys } from "features/merch/constants/queryKeys"; +import { displayPrice } from "features/merch/functions/currency"; +import Link from "next/link" +import LoadingScreen from "ui/components/merch/skeleton/LoadingScreen"; +import { getOrderStatusColor, renderOrderStatus } from "merch-helpers"; +import OrderItem from "ui/components/merch/OrderItem"; +const OrderSummary: React.FC = () => { +// Check if break point hit. KIV + const isMobile: boolean = useBreakpointValue({ base: true, md: false }) || false; + const router = useRouter(); + const orderSlug = router.query.slug as string | undefined; + + const [showThankYou, setShowThankYou] = useState(false); + const [orderState, setOrderState] = useState(null); + // TODO: Fetch subtotal and total from server. + const [total, setTotal] = useState(0); + // Fetch and check if cart item is valid. Number(item.price) set to convert string to num + const { isLoading } = useQuery( + [QueryKeys.ORDER, orderSlug], + () => api.getOrder(orderSlug ?? ""), + { + enabled: !!orderSlug, + onSuccess: (data: Order) => { + setOrderState(data); + setTotal( + data.items.reduce((acc, item) => { + return item.price * item.quantity + acc; + }, 0) + ); + setShowThankYou(true); + }, + } + ); + + const renderThankYouMessage = () => ( + <> + THANK YOU + Thank you for your purchase. We have received your order. + + + + + + ); + const renderOrderSummary = () => ( + <> + + {showThankYou && renderThankYouMessage()} + + + +
+ + + + {renderOrderStatus(orderState?.status ?? OrderStatus.PENDING_PAYMENT)} + + Order Number + + {orderState?.id.split("-")[0]} + + + {orderState?.id} + + + Order date:{" "} + {orderState?.transaction_time + ? new Date(`${orderState.transaction_time}`).toLocaleString( + "en-sg" + ) + : ""} + + {/*Last update: {orderState?.lastUpdate}*/} + + +
+
+ + + + Order Number + + {renderOrderStatus(orderState?.status ?? OrderStatus.PENDING_PAYMENT)} + + + + {orderState?.id.split("-")[0]} + + + {orderState?.id} + + + + + Order date:{" "} + {orderState?.transaction_time + ? new Date(`${orderState.transaction_time}`).toLocaleString( + "en-sg" + ) + : ""} + + {/*Last update: {orderState?.lastUpdate}*/} + + +
+ + {/*{orderState?.items.map((item) => (*/} + {/* */} + {/*))}*/} + + {orderState? : Order Not Found} + + + + + Item Subtotal: + Voucher Discount: + Total: + + + {displayPrice(total)} + + {/*{displayPrice( TODO*/} + {/* (orderState?.billing?.subtotal ?? 0) -*/} + {/* (orderState?.billing?.total ?? 0)*/} + {/*)}*/} + 0 + + {displayPrice(total)} + + +
+ + + {/* TODO: QR Code generator based on Param. */} + QRCode + + Please screenshot this QR code and show it at SCSE Lounge to collect your order. + Alternatively, show the email receipt you have received. + + + For any assistance, please contact our email address: + merch@ntuscse.com + + + + ); + const renderSummaryPage = () => { + if (isLoading) return ; + //rmb to change this v + if (orderState === undefined || orderState === null){return ;} + return renderOrderSummary(); + }; + return {renderSummaryPage()}; +} +export default OrderSummary diff --git a/apps/web/pages/merch/product/[slug].tsx b/apps/web/pages/merch/product/[slug].tsx new file mode 100644 index 00000000..4085d159 --- /dev/null +++ b/apps/web/pages/merch/product/[slug].tsx @@ -0,0 +1,474 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ + +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { + Badge, + Button, + Center, + Divider, + Flex, + Grid, + GridItem, + Heading, + Input, + Text, + useDisclosure, +} from "@chakra-ui/react"; +import { + EmptyProductView, + MerchCarousel, + MerchDetailSkeleton, + Page, + SizeChartDialog, + SizeOption, +} from "ui/components/merch"; +import { Product } from "types"; +import { + CartAction, + CartActionType, + useCartStore, +} from "features/merch/context/cart"; +import { QueryKeys, routes } from "features/merch/constants"; +import { + displayPrice, + displayQtyInCart, + displayStock, getDefaultColor, getDefaultSize, + getQtyInCart, + getQtyInStock, + isColorAvailable, + isOutOfStock, + isSizeAvailable, +} from "features/merch/functions"; +import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/features/merch/services/api"; + +interface GroupTitleProps { + children: React.ReactNode; +} + +const GroupTitle = ({ children }: GroupTitleProps) => ( + + {children} + +); + +const MerchDetail = (_props: InferGetStaticPropsType) => { + // Context hook. + const { state: cartState, dispatch: cartDispatch } = useCartStore(); + const router = useRouter(); + const id = (router.query.slug ?? "") as string; + + const [quantity, setQuantity] = useState(1); + const [isDisabled, setIsDisabled] = useState(false); + const [selectedSize, setSelectedSize] = useState(null); + const [selectedColor, setSelectedColor] = useState(null); + const [maxQuantity, setMaxQuantity] = useState(1); + + const { isOpen, onOpen, onClose } = useDisclosure(); + + const { data: product, isLoading } = useQuery( + [QueryKeys.PRODUCT, id], + () => api.getProduct(id), + { + onSuccess: (data: Product) => { + setIsDisabled(!(data?.is_available === true)); + setSelectedSize(getDefaultSize(data)); + setSelectedColor(getDefaultColor(data)); + }, + } + ); + + //* In/decrement quantity + const handleQtyChangeCounter = (isAdd = true) => { + const value = isAdd ? 1 : -1; + if (!isAdd && quantity === 1) return; + if (isAdd && quantity >= maxQuantity) return; + setQuantity(quantity + value); + }; + + //* Manual input quantity. + const handleQtyChangeInput = (e: React.FormEvent): void => { + const target = e.target as HTMLInputElement; + if (Number.isNaN(parseInt(target.value, 10))) { + setQuantity(1); + return; + } + const value = parseInt(target.value, 10); + if (value <= 0) { + setQuantity(1); + } else if (value > maxQuantity) { + setQuantity(maxQuantity); + } else { + setQuantity(value); + } + }; + + const updateMaxQuantity = (color: string, size: string) => { + if (product) { + const stockQty = getQtyInStock(product, color, size); + const cartQty = getQtyInCart( + cartState.cart.items, + product.id, + color, + size + ); + const max = stockQty > cartQty ? stockQty - cartQty : 0; + setMaxQuantity(max); + } + }; + + const handleAddToCart = () => { + if (!selectedColor || !selectedSize) { + return; + } + setIsDisabled(true); + const payload: CartAction = { + type: CartActionType.ADD_ITEM, + payload: { + id, + quantity, + color: selectedColor, + size: selectedSize, + }, + }; + cartDispatch(payload); + setMaxQuantity(maxQuantity - quantity); + setQuantity(1); + setIsDisabled(false); + }; + + const handleBuyNow = async () => { + handleAddToCart(); + await router.push(routes.CART); + }; + + const ProductNameSection = ( + + + {product?.name} + {!product?.is_available && ( + + unavailable + + )} + {product && isOutOfStock(product) && ( + + out of stock + + )} + + + {displayPrice(product?.price ?? 0)} + + + ); + + const renderSizeSection = ( + + + Sizes + {product?.size_chart && ( + + )} + + + {product?.sizes?.map((size, idx) => { + return ( + { + setQuantity(1); + if (size !== selectedSize) { + setSelectedSize(size); + if (selectedColor) { + updateMaxQuantity(selectedColor, size); + } + } else { + setSelectedSize(null); + } + }} + disabled={ + isDisabled || + (product + ? !isSizeAvailable(product, size) // size is not available for all colors + : false) || + (product && selectedColor + ? getQtyInStock(product, selectedColor, size) === 0 // size is not available for selected color + : false) + } + > + + {size} + + + ); + })} + + + ); + + const renderColorSection = ( + + + Colors + + + {product?.colors?.map((color, idx) => { + return ( + { + setQuantity(1); + if (color !== selectedColor) { + setSelectedColor(color); + if (selectedSize) { + updateMaxQuantity(color, selectedSize); + } + } else { + setSelectedColor(null); + } + }} + width="auto" + px={4} + disabled={ + isDisabled || + (product + ? !isColorAvailable(product, color) // color is not available for all sizes + : false) || + (product && selectedSize + ? getQtyInStock(product, color, selectedSize) === 0 // color is not available for selected size + : false) + } + > + + {color} + + + ); + })} + + + ); + + const renderQuantitySection = ( + + Quantity + + handleQtyChangeCounter(false)} + > + - + + + = maxQuantity + } + active={false.toString()} + onClick={() => handleQtyChangeCounter(true)} + > + + + +
+ + {product && selectedColor && selectedSize && product.is_available + ? displayStock(product, selectedColor, selectedSize) + : ""} + +
+
+ + + {product && selectedColor && selectedSize + ? displayQtyInCart( + cartState.cart.items, + product.id, + selectedColor, + selectedSize + ) + : ""} + + + {product && selectedColor && selectedSize && maxQuantity === 0 + ? "You have reached the maximum purchase quantity." + : ""} + + +
+ ); + + const purchaseButtons = ( + + + + + ); + + const renderMerchDetails = () => { + return ( + + + + + + {ProductNameSection} + + {renderSizeSection} + {renderColorSection} + {renderQuantitySection} + + {purchaseButtons} + + {/* {renderDescription} */} + + + + ); + }; + + const renderMerchPage = () => { + if (isLoading) return ; + if (product === undefined || product === null) return ; + return renderMerchDetails(); + }; + + return {renderMerchPage()}; +}; + +export default MerchDetail; + +export const getStaticProps: GetStaticProps<{ + slug: string; + product: Product | undefined; +}> = async ({ params }) => { + console.log("generating static props for /merch/product/[slug]"); + console.log("params", JSON.stringify(params)); + + // TODO: replace this with trpc/react-query call + if (!process.env.NEXT_PUBLIC_MERCH_API_ORIGIN) { + throw new Error("NEXT_PUBLIC_MERCH_API_ORIGIN is not defined"); + } + const res = await fetch( + `${ + process.env.NEXT_PUBLIC_MERCH_API_ORIGIN + }/trpc/getProduct?batch=1&input=${encodeURIComponent( + JSON.stringify({ "0": { id: params?.slug } }) + )}` + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const product = (await res.json())[0].result.data as Product; + + return { + props: { + slug: params?.slug as string, + product: product, + }, + }; +}; + +// eslint-disable-next-line @typescript-eslint/require-await +export const getStaticPaths: GetStaticPaths = async () => { + console.log("generating static paths for /merch/product/[slug]"); + + // TODO: replace this with trpc/react-query call + if (!process.env.NEXT_PUBLIC_MERCH_API_ORIGIN) { + throw new Error("NEXT_PUBLIC_MERCH_API_ORIGIN is not defined"); + } + const res = await fetch( + `${process.env.NEXT_PUBLIC_MERCH_API_ORIGIN}/trpc/getProducts` + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const products = (await res.json()).result.data.products as Product[]; + + return { + paths: products.map((product) => ({ + params: { + slug: product.id, + }, + })), + // https://nextjs.org/docs/pages/api-reference/functions/get-static-paths#fallback-blocking + fallback: "blocking", + }; +}; From e31d02bf3283285671aa23148daff767262363f2 Mon Sep 17 00:00:00 2001 From: LimIvan336 <71662324+LimIvan336@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:10:37 +0800 Subject: [PATCH 02/12] Disable merch store feature --- apps/web/features/merch/services/api.ts | 15 +++ apps/web/pages/merch/index.tsx | 143 +++++++++++++++--------- packages/types/src/lib/merch.ts | 5 + 3 files changed, 108 insertions(+), 55 deletions(-) diff --git a/apps/web/features/merch/services/api.ts b/apps/web/features/merch/services/api.ts index 1b0fd34e..f9478ebe 100644 --- a/apps/web/features/merch/services/api.ts +++ b/apps/web/features/merch/services/api.ts @@ -3,6 +3,7 @@ import { Cart, CheckoutRequest, CheckoutResponse, + MerchSaleStatus, PricedCart, Product, ProductsResponse, @@ -85,6 +86,20 @@ export class Api { email, }); } + + async getMerchSaleStatus(): Promise { + // fetch merch status from backend, either true: enabled, false: disabled + // Simulating fetching data from backend and admin panel + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve({ + disabled: true, + displayText: + "We are currently preparing for the next merch sale. Please look forward to our email!", + }); + }, 1000); + }); + } } export const api = new Api(); diff --git a/apps/web/pages/merch/index.tsx b/apps/web/pages/merch/index.tsx index c6cdfcb6..62ee9fba 100644 --- a/apps/web/pages/merch/index.tsx +++ b/apps/web/pages/merch/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Flex, Divider, Select, Heading, Grid } from "@chakra-ui/react"; import { useQuery } from "@tanstack/react-query"; import { Card, MerchListSkeleton, Page } from "ui/components/merch"; @@ -9,6 +9,18 @@ import { isOutOfStock } from "features/merch/functions"; const MerchandiseList = () => { const [selectedCategory, setSelectedCategory] = useState(""); + const [isMerchDisabled, setIsMerchDisabled] = useState(false); + const [disabledText, setDisabledText] = useState(""); + + useEffect(() => { + const fetchMerchSaleStatus = async () => { + const { disabled, displayText } = await api.getMerchSaleStatus(); + console.log(disabledText); + setDisabledText(displayText ?? ""); + setIsMerchDisabled(disabled); + }; + fetchMerchSaleStatus(); + }, []); const { data: products, isLoading } = useQuery( [QueryKeys.PRODUCTS], @@ -27,63 +39,84 @@ const MerchandiseList = () => { setSelectedCategory(event.target.value); }; + if (isMerchDisabled === null) { + return ( + <> + + + ); + } + return ( - - - New Drop - - - - - {isLoading ? ( - + {isMerchDisabled ? ( + + + {disabledText} + + ) : ( - - {products - ?.filter((product: Product) => { - if (!product?.is_available) return false; - if (selectedCategory === "") return true; - return product?.category === selectedCategory; - }) - ?.map((item: Product, idx: number) => ( - - ))} - + <> + + + New Drop + + + + + {isLoading ? ( + + ) : ( + + {products + ?.filter((product: Product) => { + if (!product?.is_available) return false; + if (selectedCategory === "") return true; + return product?.category === selectedCategory; + }) + ?.map((item: Product, idx: number) => ( + + ))} + + )} + )} ); diff --git a/packages/types/src/lib/merch.ts b/packages/types/src/lib/merch.ts index a10f00b9..ac5ddfc8 100644 --- a/packages/types/src/lib/merch.ts +++ b/packages/types/src/lib/merch.ts @@ -168,3 +168,8 @@ export type APIError = { export type OrderHoldEntry = { // todo: ??? }; + +export type MerchSaleStatus = { + disabled: boolean; + displayText?: string; +}; From c843e9fae5e4c38edf0e7704a0d45363b46747e2 Mon Sep 17 00:00:00 2001 From: iyzyman <101888183+Iyzyman@users.noreply.github.com> Date: Mon, 29 Jan 2024 21:45:32 +0800 Subject: [PATCH 03/12] Fix/merch products (#139) * Added merch products page * fix linting issues * Added merch products page * fix linting issues * Add delete and edit buttons for product page * Using type definition from merch.ts * Fix linting errors * Fix linting errors * import Product from types --------- Co-authored-by: LimIvan336 <71662324+LimIvan336@users.noreply.github.com> Co-authored-by: Chung Zhi Xuan --- .../cms/src/admin/utils/RenderCellFactory.tsx | 118 +++++++++++++++--- apps/cms/src/admin/views/MerchProducts.tsx | 102 ++++++++++++++- apps/cms/src/apis/products.api.ts | 64 ++++++++++ 3 files changed, 260 insertions(+), 24 deletions(-) create mode 100644 apps/cms/src/apis/products.api.ts diff --git a/apps/cms/src/admin/utils/RenderCellFactory.tsx b/apps/cms/src/admin/utils/RenderCellFactory.tsx index 97ec1ef0..1216fcc9 100644 --- a/apps/cms/src/admin/utils/RenderCellFactory.tsx +++ b/apps/cms/src/admin/utils/RenderCellFactory.tsx @@ -2,42 +2,122 @@ import React from "react"; import payload from "payload"; export class RenderCellFactory { - static get(element: unknown, key: string) { - console.log(key) if (element[key] == undefined) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - payload.logger.error(`Attribute ${key} cannot be found in element ${element.toString()}`); + payload.logger.error( + `Attribute ${key} cannot be found in element ${element.toString()}` + ); return null; } const isImageUrl = new RegExp("http(s?):\\/\\/.*.(jpg|png|jpeg)$"); + + if (Array.isArray(element[key])) { + if ( + (element[key] as string[]).every((item: string) => + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + isImageUrl.test((item as string).toString()) + ) + ) { + // If the element is an array, render images accordingly + const ImagesComponent: React.FC<{ children?: React.ReactNode[] }> = ({ + children, + }) => ( + + {children.map((imageUrl: string, index: number) => ( + {`image + ))} + + ); + const ImagesComponentCell = (row, data) => ( + {data} + ); + return ImagesComponentCell; + } else { + // If the element is an array of strings, render them + const StringsComponent: React.FC<{ children?: React.ReactNode[] }> = ({ + children, + }) => ( + + {children.map((text: string, index: number) => ( + + {index > 0 && ", "} {text} + + ))} + + ); + const StringsComponentCell = (row, data) => ( + {data} + ); + return StringsComponentCell; + } + } + if (isImageUrl.test((element[key] as string).toString())) { - const ImageComponent: React.FC<{children?: React.ReactNode}> = ({ children }) => ( + const ImageComponent: React.FC<{ children?: React.ReactNode }> = ({ + children, + }) => ( - image of object + image of object ); - const ImageComponentCell = (row, data) => {data}; + const ImageComponentCell = (row, data) => ( + {data} + ); return ImageComponentCell; } + if (key === "stock") { + const ObjectComponent: React.FC<{ data: string }> = ({ data }) => ( +
+ {Object.entries(data).map(([subKey, value], index) => ( +
+ {subKey}:{" "} + + {typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)} + +
+ ))} +
+ ); + const ObjectComponentCell = (row, data: string) => ( + + ); + return ObjectComponentCell; + + } + if (typeof element[key] == "object") { + const DateComponent: React.FC<{ children?: React.ReactNode }> = ({ + children, + }) => {(children as unknown as Date).toDateString()}; + const DateComponentCell = (row, data) => ( + {data} + ); + return DateComponentCell; + } - if (typeof element[key] == 'object') { - const DateComponent: React.FC<{children?: React.ReactNode}> = ({ children }) => ( - - {(children as unknown as Date).toDateString()} - + if (typeof element[key] === "boolean") { + // If the element is a boolean, render "Yes" or "No" + const BooleanComponent: React.FC<{ children?: React.ReactNode }> = ({ + children, + }) => {children ? "Yes" : "No"}; + const BooleanComponentCell = (row, data) => ( + {data} ); - const DateComponentCell = (row, data) => {data}; - return DateComponentCell + return BooleanComponentCell; } - const TextComponent: React.FC<{children?: React.ReactNode}> = ({ children }) => ( - - {children} - + const TextComponent: React.FC<{ children?: React.ReactNode }> = ({ + children, + }) => {children}; + const TextComponentCell = (row, data) => ( + {data} ); - const TextComponentCell = (row, data) => {data}; - return TextComponentCell + return TextComponentCell; } } diff --git a/apps/cms/src/admin/views/MerchProducts.tsx b/apps/cms/src/admin/views/MerchProducts.tsx index 07faa685..fbfce61e 100644 --- a/apps/cms/src/admin/views/MerchProducts.tsx +++ b/apps/cms/src/admin/views/MerchProducts.tsx @@ -1,9 +1,103 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Button } from "payload/components/elements"; import { AdminView } from "payload/config"; import ViewTemplate from "./ViewTemplate"; +import { Column } from "payload/dist/admin/components/elements/Table/types"; +import { RenderCellFactory } from "../utils/RenderCellFactory"; +import SortedColumn from "../utils/SortedColumn"; +import { Table } from "payload/dist/admin/components/elements/Table"; +import { Product } from "types"; +import ProductsApi from "../../apis/products.api"; const MerchProducts: AdminView = ({ user, canAccessAdmin }) => { + // Get data from API + const [data, setData] = useState(null); + useEffect(() => { + ProductsApi.getProducts() + .then((res: Product[]) => setData(res)) + .catch((error) => console.log(error)); + }, []); + + // Output human-readable table headers based on the attribute names from the API + function prettifyKey(str: string): string { + let res = ""; + for (const i of str.split("_")) { + res += i.charAt(0).toUpperCase() + i.slice(1) + " "; + } + return res; + } + + // Do not load table until we receive the data + if (data == null) { + return
Loading...
; + } + + const tableCols = new Array(); + if (data && data.length > 0) { + const sampleProduct = data[0]; + const keys = Object.keys(sampleProduct); + for (const key of keys) { + const renderCell: React.FC<{ children?: React.ReactNode }> = RenderCellFactory.get(sampleProduct, key); + const col: Column = { + accessor: key, + components: { + Heading: ( + + ), + renderCell: renderCell, + }, + label: "", + name: "", + active: true, + }; + tableCols.push(col); + } + } + + const editColumn: Column = { + accessor: "edit", + components: { + Heading:
Edit
, + renderCell: ({ children }) => ( + + ), + }, + label: "Edit", + name: "edit", + active: true, + }; + + tableCols.push(editColumn); + + const deleteColumn: Column = { + accessor: "delete", + components: { + Heading:
Delete
, + renderCell: ({ children }) => ( + + ), + }, + label: "Delete", + name: "delete", + active: true, + }; + + tableCols.push(deleteColumn); + + const handleEdit = (orderId: string) => { + console.log(`Dummy. Order ID: ${orderId}`); + }; + + const handleDelete = (orderId: string) => { + console.log(`Dummy. Order ID: ${orderId}`); + }; + + console.log(tableCols); + return ( { keywords="" title="Merchandise Products" > -

- Here is a custom route that was added in the Payload config. It uses the - Default Template, so the sidebar is rendered. -

+ + ); }; diff --git a/apps/cms/src/apis/products.api.ts b/apps/cms/src/apis/products.api.ts new file mode 100644 index 00000000..62e10ef7 --- /dev/null +++ b/apps/cms/src/apis/products.api.ts @@ -0,0 +1,64 @@ +import { Product } from "types"; +// todo turn into real api +class ProductsApi { + // eslint-disable-next-line @typescript-eslint/require-await + async getProducts(): Promise { + const res: Product[] = [ + { + id: "1", + name: "product1", + price: 1000, + images: [ + "https://i.kym-cdn.com/entries/icons/original/000/033/421/cover2.jpg", + "https://i.pinimg.com/474x/c0/f9/f1/c0f9f10a0061a8dd1080d7d9e560579c.jpg", + ], + sizes: ["s", "m", "l", "xl"], + category: "shirt", + is_available: true, + colors: ["black,white,blue"], + stock: { + black: { S: 10, M: 15, L: 20, XL: 5 }, + white: { S: 12, M: 17, L: 22, XL: 7 }, + blue: { S: 8, M: 13, L: 18, XL: 3 } + }, + }, + { + id: "2", + name: "product2", + price: 2000, + images: [ + "https://i.kym-cdn.com/photos/images/newsfeed/002/164/493/b8b.jpg", + "https://i.kym-cdn.com/entries/icons/original/000/033/421/cover2.jpg", + ], + sizes: ["s", "m"], + category: "sweater", + is_available: true, + colors: ["blue"], + stock: { + blue: { S: 8, M: 13, L: 18, XL: 3 } + }, + }, + { + id: "3", + name: "product3", + price: 3000, + images: [ + "https://i.kym-cdn.com/entries/icons/original/000/033/421/cover2.jpg", + "https://i.kym-cdn.com/photos/images/newsfeed/002/164/493/b8b.jpg", + "https://i.pinimg.com/474x/c0/f9/f1/c0f9f10a0061a8dd1080d7d9e560579c.jpg", + ], + sizes: ["xs", "s", "m", "l"], + category: "hat", + is_available: false, + colors: ["white"], + stock: { + white: { S: 12, M: 17, L: 22, XL: 7 } + }, + }, + ]; + + return res; + } +} + +export default new ProductsApi(); From 1f71328d816207074910f115ae0a83c6b07182e8 Mon Sep 17 00:00:00 2001 From: LimIvan336 <71662324+LimIvan336@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:45:45 +0800 Subject: [PATCH 04/12] change resolve var name --- apps/web/features/merch/services/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/features/merch/services/api.ts b/apps/web/features/merch/services/api.ts index f9478ebe..167b9ae5 100644 --- a/apps/web/features/merch/services/api.ts +++ b/apps/web/features/merch/services/api.ts @@ -90,9 +90,9 @@ export class Api { async getMerchSaleStatus(): Promise { // fetch merch status from backend, either true: enabled, false: disabled // Simulating fetching data from backend and admin panel - return new Promise((resolve, reject) => { + return new Promise((res, rej) => { setTimeout(() => { - resolve({ + res({ disabled: true, displayText: "We are currently preparing for the next merch sale. Please look forward to our email!", From f36a15184c3d120893df6483b8f5b7b1c192eafb Mon Sep 17 00:00:00 2001 From: LimIvan336 <71662324+LimIvan336@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:59:16 +0800 Subject: [PATCH 05/12] Added toggle button at admin panel --- apps/cms/src/admin/views/MerchOverview.tsx | 46 +++++++++++++++++++++- apps/cms/src/apis/store.api.ts | 31 +++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apps/cms/src/apis/store.api.ts diff --git a/apps/cms/src/admin/views/MerchOverview.tsx b/apps/cms/src/admin/views/MerchOverview.tsx index 1a5b3598..e1f6d36c 100644 --- a/apps/cms/src/admin/views/MerchOverview.tsx +++ b/apps/cms/src/admin/views/MerchOverview.tsx @@ -1,9 +1,35 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Button } from "payload/components/elements"; import { AdminView } from "payload/config"; import ViewTemplate from "./ViewTemplate"; +import StoreApi from "../../apis/store.api"; const MerchOverview: AdminView = ({ user, canAccessAdmin }) => { + const [displayText, setDisplayText] = useState( + "We are currently preparing for the next merch sale. Please look forward to our email!" + ); + const [isStoreDisabled, setIsStoreDisabled] = useState(true); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchStoreStatus = async () => { + const { disabled } = await StoreApi.getStoreStatus(); + setIsStoreDisabled(disabled); + setLoading(false); + }; + fetchStoreStatus(); + }, []); + + const disableStore = async () => { + // TODO: Calls api to disable merch store + setLoading(true); + await StoreApi.setStoreStatus({ + displayText, + disabled: !isStoreDisabled, + }); + setIsStoreDisabled(!isStoreDisabled); + setLoading(false); + }; return ( { +

{`Current state of merch store: ${ + loading ? "..." : isStoreDisabled ? "Disabled" : "Live" + }`}

+ {/*