Skip to content

Commit

Permalink
task: Port home page (product listing)
Browse files Browse the repository at this point in the history
  • Loading branch information
YoNG-Zaii committed Mar 20, 2023
1 parent 618a658 commit c2a3c16
Show file tree
Hide file tree
Showing 15 changed files with 561 additions and 10 deletions.
1 change: 1 addition & 0 deletions apps/web/features/layout/components/WebLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const WebLayout = ({ children }: WebLayoutProps) => {
{ label: "Sponsors", href: "/sponsors" },
{ label: "Contact", href: "/contact" },
{ label: "BLOG", href: "/blog", menuLinkStyle: "button.golden" },
{ label: "MERCH", href: "/merch", menuLinkStyle: "button.golden" }
],
logoProps: {
src: "/scse-logo.png",
Expand Down
9 changes: 9 additions & 0 deletions apps/web/features/merch/constants/queryKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export enum QueryKeys {
PRODUCTS = "PRODUCTS",
PRODUCT = "PRODUCT",
VOUCHER = "VOUCHER",
ORDER = "ORDER",
ORDERS = "ORDERS",
EMAIL = "EMAIL",
CHECKOUT = "CHECKOUT",
}
17 changes: 17 additions & 0 deletions apps/web/features/merch/constants/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
type Routes = {
HOME: string;
PRODUCT: string;
CART: string;
CHECKOUT: string;
ORDER_SUMMARY: string;
};

export const routes: Routes = {
HOME: "/merch",
PRODUCT: "/merch/product",
CART: "/merch/cart",
CHECKOUT: "/merch/checkout",
ORDER_SUMMARY: "/merch/order-summary",
};

export default routes;
7 changes: 7 additions & 0 deletions apps/web/features/merch/functions/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const displayPrice = (amountInCents: number): string => {
const amountStr = amountInCents.toString()
const dollarStr = amountStr.slice(0, -2).padStart(1, '0')
const centStr = amountStr.slice(-2).padStart(2, '0')
const priceStr = dollarStr.concat(".").concat(centStr)
return `$${priceStr}`
}
81 changes: 81 additions & 0 deletions apps/web/features/merch/functions/stock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Product } from "types/lib/merch"

/*
export const getQtyInStock = (product: ProductType, colorway: string, size: string): number => {
// returns remaining stock for specified colorway and size
if (product.stock[colorway] && product.stock[colorway][size]) {
return product.stock[colorway][size]
}
return 0;
}
export const displayStock = (product: ProductType, colorway: string, size: string): string => {
// returns string describing remaining stock
if (product.stock[colorway] && product.stock[colorway][size]) {
const qty = product.stock[colorway][size];
if (qty > 0) {
return `${qty} available`;
}
return "OUT OF STOCK";
}
return "ERROR: invalid color/size selected";
}
*/
export const isOutOfStock = (product: Product): boolean => {
// returns true if product is out of stock in all colorways and sizes
const totalQty = Object.values(product.stock).reduce((acc, stockByColor)=>{
const colorQty = Object.values(stockByColor).reduce((acc2, qty)=>acc2+qty, 0);
return acc+colorQty
}, 0);
return totalQty <= 0;

}

/*
export const isColorwayAvailable = (product: ProductType, colorway: string): boolean => {
// returns true if colorway is available in any size
// returns false if colorway is out of stock in all sizes
const colorwayStock = Object.values(product.stock[colorway]).reduce(
(acc: any, size: any)=>acc+size, 0
);
return (colorwayStock > 0);
}
export const isSizeAvailable = (product: ProductType, size: string): boolean => {
// returns true if size is available in any colorway
// returns false if size is out of stock in all colorways
const sizeStock = Object.values(product.stock).map(d => d[size]||0);
const totalQty = sizeStock.reduce((a, b) => {
return a + b;
}, 0);
return (totalQty > 0);
}
export const getDefaultSize = (product: ProductType): string => {
const index = product.sizes.findIndex((size) => isSizeAvailable(product, size));
if (index !== -1) {
return product.sizes[index];
}
return "";
}
export const getDefaultColorway = (product: ProductType, size: string): string => {
const sizeIndex = product.sizes.indexOf(size);
if (sizeIndex === -1) { // no such size
return "";
}
const colorwayStock = Object.values(product.stock).map(d => d[sizeIndex]);
const availColorwayIndex = colorwayStock.map((qty, idx) => qty > 0 ? idx : -1).filter(idx => idx !== -1);
if (availColorwayIndex.length > 0) {
return product.colorways[availColorwayIndex[0]];
}
return "";
}
export const getDefaults = (product: ProductType): [string, string] => {
const size = getDefaultSize(product);
const colorway = getDefaultColorway(product, size);
return [colorway, size];
}
*/
117 changes: 117 additions & 0 deletions apps/web/features/merch/services/api.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Product } from "types/lib/merch";

export class Api {
private API_ORIGIN: string;

constructor() {
if (!process.env.NEXT_PUBLIC_MERCH_API_ORIGIN) {
throw new Error("NEXT_PUBLIC_MERCH_API_ORIGIN environment variable is not set")
}
this.API_ORIGIN = process.env.NEXT_PUBLIC_MERCH_API_ORIGIN || "";
}

// http methods
async get(urlPath: string): Promise<Record<string, Product[]>> {
const response = await fetch(`${this.API_ORIGIN}${urlPath}`);
const convert = response.json() as unknown; // Convert to unknown type
return convert as Record<string, Product[]>
}

/*
// eslint-disable-next-line class-methods-use-this
async post(urlPath: string, data: any): Promise<any> {
const response = await fetch(`${this.API_ORIGIN}${urlPath}`, {
method: "POST", // *GET, POST, PUT, DELETE, etc.
mode: "cors", // no-cors, *cors, same-origin
cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
credentials: "same-origin", // include, *same-origin, omit
headers: {
"Content-Type": "application/json",
// 'Content-Type': 'application/x-www-form-urlencoded',
},
redirect: "follow", // manual, *follow, error
referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
body: JSON.stringify(data), // body data type must match
});
return response.json();
}
*/

// eslint-disable-next-line class-methods-use-this
async getProducts(): Promise<Product[]> {
try {
const res = await this.get("/products");
console.log("product-list", res);
return res?.products ?? [];
} catch (e) {
if(e instanceof Error){
throw new Error(e.message);
}
return []
}
}

/*
// eslint-disable-next-line class-methods-use-this
async getProduct(productId: string) {
try {
const res = await this.get(`/products/${productId}`);
console.log("product res", res);
return res;
} catch (e: any) {
throw new Error(e);
}
}
async getOrder(userId: string, orderId: string) {
try {
const res = await this.get(`/orders/${orderId}`);
console.log("Order Summary response:", res);
return res;
} catch (e: any) {
throw new Error(e);
}
}
async getOrderHistory(userId: string) {
try {
const res = await this.get(`/orders/${userId}`);
console.log("Order Summary response:", res);
return res.json();
} catch (e: any) {
throw new Error(e);
}
}
async postCheckoutCart(
items: CartItemType[],
email: string,
promoCode: string | null
) {
try {
const res = await this.post(`/cart/checkout`, {
items,
promoCode: promoCode ?? "",
email,
});
return res;
} catch (e: any) {
throw new Error(e);
}
}
async postQuotation(items: CartItemType[], promoCode: string | null) {
try {
const res = await this.post(`/cart/quotation`, {
items,
promoCode: promoCode ?? "",
});
return res;
} catch (e: any) {
throw new Error(e);
}
}
*/
}

export const api = new Api();
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"@chakra-ui/system": "^2.3.1",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@tanstack/react-query": "^4.26.1",
"@tanstack/react-query-devtools": "^4.26.1",
"framer-motion": "^7.6.4",
"next": "13.0.0",
"next-transpile-modules": "^10.0.0",
Expand Down
17 changes: 12 additions & 5 deletions apps/web/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { ChakraProvider } from "@chakra-ui/react";
import { theme } from "ui/theme";
import "@fontsource/roboto/400.css";
Expand All @@ -8,13 +10,18 @@ import "@fontsource/roboto-slab/400.css";
import "@fontsource/poppins/400.css";
import { WebLayout } from "@/features/layout";

const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } });

const App = ({ Component, pageProps }: AppProps) => {
return (
<ChakraProvider theme={theme}>
<WebLayout>
<Component {...pageProps} />
</WebLayout>
</ChakraProvider>
<QueryClientProvider client={queryClient}>
<ChakraProvider theme={theme}>
<WebLayout>
<Component {...pageProps} />
</WebLayout>
</ChakraProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};

Expand Down
82 changes: 82 additions & 0 deletions apps/web/pages/merch/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState } from "react";
import { Flex, Divider, Select, Heading, Grid } from "@chakra-ui/react";
import { useQuery } from "@tanstack/react-query";
import Card from "ui/components/merch/Card";
import Page from "ui/components/merch/Page";
import { QueryKeys } from "../../features/merch/constants/queryKeys";
import { api } from "../../features/merch/services/api";
import { Product } from "types/lib/merch";
import ProductListSkeleton from "ui/components/merch/Skeleton";
import { isOutOfStock } from "../../features/merch/functions/stock";

const MerchandiseList = () => {
const [selectedCategory, setSelectedCategory] = useState<string>("");

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<HTMLSelectElement>) => {
setSelectedCategory(event.target.value);
};

return (
<Page>
<Flex justifyContent="space-between" my={5} alignItems="center">
<Heading fontSize={["md", "2xl"]} textColor={["primary.600", "black"]}>
New Drop
</Heading>
<Select
bgColor={["white", "gray.100"]}
w="fit-content"
textAlign="center"
alignSelf="center"
placeholder="All Product Type"
size="sm"
disabled={isLoading}
value={selectedCategory}
onChange={handleCategoryChange}
>
{uniqueCategories?.map((category, idx) => (
<option key={idx.toString()} value={category}>
{category}
</option>
))}
</Select>
</Flex>
<Divider borderColor="blackAlpha.500" mt={[5, 10]} />
{isLoading ? (
<ProductListSkeleton />
) : (
<Grid
templateColumns={{ base: "repeat(2, 1fr)", md: "repeat(3, 1fr)", lg: "repeat(4, 1fr)" }}
columnGap={4}
rowGap={2}
>
{products
?.filter((product: Product) => {
if (!product?.is_available) return false;
if (selectedCategory === "") return true;
return product?.category === selectedCategory;
})
?.map((item: Product, idx: number) => (
<Card
_productId={item.id}
key={idx.toString()}
text={item?.name}
price={item?.price}
imgSrc={item?.images?.[0]}
sizeRange={`${item?.sizes?.[0]} - ${item.sizes?.[item.sizes.length - 1]}`}
isOutOfStock={isOutOfStock(item)}
/>
))}
</Grid>
)}
</Page>
);
};

export default MerchandiseList;
Loading

0 comments on commit c2a3c16

Please sign in to comment.