Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SCSE-247] Port home page (product listing) #77

Merged
merged 1 commit into from
Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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