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", + }; +};