diff --git a/src/frontend/App.js b/src/frontend/App.js index 272194d..ad3ec07 100644 --- a/src/frontend/App.js +++ b/src/frontend/App.js @@ -13,6 +13,8 @@ import PostingsRouter from './pages/postings/Postings.router'; import SponsorsRouter from './pages/sponsors/Sponsors.router'; import GeeseRouter from './pages/geese/Geese.router'; import TeamDescriptionsRouter from './pages/team-descriptions/TeamDescriptions.router'; +import MerchStoreRouter from './pages/merch-store/Products.router' + import { addAuthTokenToRequests } from './api/server'; import BlogsRouter from './pages/blogs/Blogs.Router'; @@ -69,6 +71,11 @@ const App = () => { + + {!token && } + + + {/* {!token && } */} diff --git a/src/frontend/api/index.js b/src/frontend/api/index.js index 85e794e..93ac1f2 100644 --- a/src/frontend/api/index.js +++ b/src/frontend/api/index.js @@ -7,6 +7,7 @@ import openingsDescription from './openings-description'; import sponsors from './sponsors'; import geeseInfo from './geese-info'; import blogs from './blogs'; +import merchStore from './merch-store' export default { google: google(server), @@ -16,5 +17,6 @@ export default { formUpload: formUpload(server), openingsDescription: openingsDescription(server), geeseInfo: geeseInfo(server), - blogs: blogs(server) + blogs: blogs(server), + merchStore: merchStore(server) }; diff --git a/src/frontend/api/merch-store.js b/src/frontend/api/merch-store.js new file mode 100644 index 0000000..ce21909 --- /dev/null +++ b/src/frontend/api/merch-store.js @@ -0,0 +1,26 @@ +const getProducts = (server) => () => server.get('/api/products'); +const getProductVariations = (server) => (id) => + server.get(`/api/products/${id}/variations`); +const updateProduct = (server) => (id, updatedProductInfo) => + server.patch(`/api/products/${id}`, updatedProductInfo); +const updateProductVariation = (server) => (variationId, productId, updatedVariationInfo) => + server.patch(`/api/products/${productId}/variations/${variationId}`, updatedVariationInfo); +const addProduct = (server) => (product) => + server.post(`/api/products`, product); +const addProductVariation = (server) => (productVariation) => + server.post(`/api/products/${productVariation.productId}/variations`, productVariation); +const deleteProduct = (server) => (id) => + server.delete(`/api/products/${id}`); + const deleteProductVariation = (server) => (productId, variationId) => + server.delete(`/api/products/${productId}/variations/${variationId}`); + +export default (server) => ({ + getProducts: getProducts(server), + getProductVariations: getProductVariations(server), + updateProduct: updateProduct(server), + updateProductVariation:updateProductVariation(server), + addProduct: addProduct(server), + addProductVariation: addProductVariation(server), + deleteProduct: deleteProduct(server), + deleteProductVariation: deleteProductVariation(server), +}); diff --git a/src/frontend/assets/page-icons/merch.svg b/src/frontend/assets/page-icons/merch.svg new file mode 100644 index 0000000..8019585 --- /dev/null +++ b/src/frontend/assets/page-icons/merch.svg @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/src/frontend/hooks/goose-form.js b/src/frontend/hooks/goose-form.js index 26ed629..728b993 100644 --- a/src/frontend/hooks/goose-form.js +++ b/src/frontend/hooks/goose-form.js @@ -6,7 +6,7 @@ import moment from 'moment'; const useGooseForm = () => { const [gooseName, setGooseName] = useState(''); - const [description, setDescription] = useState(''); + const [description, setDescription] = useState(''); const [images, setImages] = useState([]); const [imageUrls, setImageUrls] = useState([]); const [imagesToDelete, setImagesToDelete] = useState([]); diff --git a/src/frontend/hooks/product-variations-form.js b/src/frontend/hooks/product-variations-form.js new file mode 100644 index 0000000..b39deb5 --- /dev/null +++ b/src/frontend/hooks/product-variations-form.js @@ -0,0 +1,199 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import useProductVariations from './product-variations'; +import useProducts from './products'; +import api from '../api'; +import moment from 'moment'; + +const useProductVariationsForm = () => { + const [productName, setProductName] = useState(''); + const [productId, setProductId] = useState(null); + const [variationName, setVariationName] = useState(''); + const [price, setPrice] = useState(null); + const [stock, setStock] = useState(null); + const [picture, setPicture] = useState(null); + const [pictureUrl, setPictureURL] = useState(null); + const [showModal, setShowModal] = useState(false); + const history = useHistory(); + const params = useParams(); + const { products } = useProducts(); + const { productVariations } = useProductVariations(); + + useEffect(() => { + (async () => { + try { + let variation = {}; + let product = {}; + + if (params.productId) { + product = await products.find( + (product) => product.id == params.productId, + ); + } + + if (params.variationId) { + const productVariationId = parseInt(params.variationId, 10); + variation = productVariations.find( + (variation) => variation.id === productVariationId, + ); + } + setProductName(product.name); + setProductId(product.id); + + setVariationName(variation.variationName); + setPrice(variation.price); + setStock(variation.stock); + setPictureURL(variation.picture); + } catch (err) { + console.error("Error init'ing product variation form info", err); + } + })(); + }, [productVariations, products, params.variationId, params.productId]); + + const imageUpload = (image) => { + setPicture(image); + setPictureURL(URL.createObjectURL(image)); + }; + + const imageDelete = useCallback(() => { + setPicture(null); + setPictureURL(null); + }, [setPicture]); + + const openModal = () => { + setShowModal(true); + }; + + const closeModal = () => { + setShowModal(false); + }; + + const closeForm = useCallback(() => { + history.push(`/products/${productId}/variations`); + }, [history, productId]); + + const saveForm = useCallback(async () => { + try { + let newPictureUrl = pictureUrl; + + if (picture instanceof File) { + const data = new FormData(); + data.append('files', picture, picture.name); + const res = await api.formUpload(data); + + newPictureUrl = res.data.data[0]; + } + + const currentDateTime = new Date(); + const unixTimestamp = currentDateTime.getTime(); + + const productVariationInfo = { + variationName, + productId, + price, + stock, + picture: newPictureUrl, + lastUpdated: unixTimestamp, + }; + + if (variationName && price && stock && pictureUrl) { + if (params.variationId) { + await api.merchStore.updateProductVariation( + params.variationId, + productId, + productVariationInfo, + ); + } else { + await api.merchStore.addProductVariation({ + ...productVariationInfo, + productId, + }); + } + setPicture(picture); + } else { + throw new Error('Please fill all the required fields.'); + } + // onSuccess: + closeForm(); + } catch (e) { + // TODO: Display "could not add/update" error to the user as dialogue. + console.error(e); + } + }, [ + params, + variationName, + price, + stock, + closeForm, + picture, + productId, + pictureUrl, + ]); + + const deleteForm = useCallback(async () => { + try { + if (params.variationId) { + const productVariationId = parseInt(params.variationId, 10); + const variation = productVariations.find( + (variation) => variation.id === productVariationId, + ); + + if (variation) { + const product = products.find( + (product) => product.id === variation.productId, + ); + + if (product) { + const productId = product.id; + await api.merchStore.deleteProductVariation( + productId, + params.variationId, + ); + } + } + } + closeForm(); + } catch (error) { + console.error('Error deleting product variation:', error); + closeForm(); + } + }, [params, productVariations, products, closeForm]); + + const getLastUpdated = useCallback(() => { + const variation = productVariations.find( + (variation) => variation.id === parseInt(params.variationId, 10), + ); + if (variation) { + return moment.utc(variation.updatedAt).local().format('MMMM D, YYYY'); + } + return ''; + }, [productVariations, params.variationId]); + + return { + productName, + setProductName, + productId, + setProductId, + variationName, + setVariationName, + price, + setPrice, + picture, + setPicture, + pictureUrl, + setPictureURL, + stock, + setStock, + imageUpload, + imageDelete, + closeForm, + saveForm, + deleteForm, + getLastUpdated, + showModal, + openModal, + closeModal, + }; +}; + +export default useProductVariationsForm; diff --git a/src/frontend/hooks/product-variations.js b/src/frontend/hooks/product-variations.js new file mode 100644 index 0000000..3ab86a6 --- /dev/null +++ b/src/frontend/hooks/product-variations.js @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import api from '../api'; +import { useDispatch, useSelector } from 'react-redux'; +import * as productVariationActions from '../state/product-variations/actions'; +import * as productVariationSelectors from '../state/product-variations/selectors'; + +const useProductVariations = () => { + const params = useParams(); + + const dispatch = useDispatch(); + const productVariations = useSelector( + productVariationSelectors.allVariations, + ); + useEffect(() => { + (async () => { + try { + let newProductVariations = []; + if (params.productId) { + const productVariationsResponse = + await api.merchStore.getProductVariations(params.productId); + // get all products + const productsResponse = await api.merchStore.getProducts(); + const products = productsResponse.data; + + const productName = products.find( + (product) => product.id == params.productId, + ).name; + + for (const productVariation of productVariationsResponse.data) { + const variation = { ...productVariation, productName }; + newProductVariations = [...newProductVariations, variation]; + } + } + + dispatch( + productVariationActions.updateProductVariationsInfo( + newProductVariations, + ), + ); + } catch (err) { + console.error( + 'error fetching products in product-variations.js: ', + err, + ); + } + })(); + }, [dispatch, params.productId, params.variationId]); + + return { + productVariations, + }; +}; + +export default useProductVariations; diff --git a/src/frontend/hooks/products-form.js b/src/frontend/hooks/products-form.js new file mode 100644 index 0000000..9c4c0bf --- /dev/null +++ b/src/frontend/hooks/products-form.js @@ -0,0 +1,111 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import useProducts from './products'; +import api from '../api'; + +const useProductsForm = () => { + const [productName, setProductName] = useState(''); + const [description, setDescription] = useState(''); + const [category, setCategory] = useState(''); + const [showModal, setShowModal] = useState(false); + const history = useHistory(); + const params = useParams(); + const { products } = useProducts(); + + useEffect(() => { + if (products.length > 0) { + (async () => { + try { + // init relevant product data when editing + if (params.productId) { + const productId = parseInt(params.productId, 10); + const product = products.find( + (product) => product.id === productId, + ); + if (product) { + setProductName(product.name); + setDescription(product.description); + setCategory(product.category); + } + } + } catch (err) { + console.log(err); + } + })(); + } + }, [setProductName, setDescription, setCategory]); + + const openModal = () => { + setShowModal(true); + }; + + const closeModal = () => { + setShowModal(false); + }; + + const closeForm = useCallback(() => { + history.push('/products'); + }, [history]); + + const saveForm = useCallback(async () => { + try { + const data = new FormData(); + const res = await api.formUpload(data); + + const productInfo = { + name: productName, + description, + category, + }; + + // ensure all fields are filled + if (productName && description && category) { + // update product if it exists + if (params.productId) { + await api.merchStore.updateProduct(params.productId, productInfo); + } + + // add product if it doesnt exist + else { + await api.merchStore.addProduct(productInfo); + } + } else { + throw new Error('Please fill all the required fields.'); + } + // onSuccess: + closeForm(); + } catch (e) { + console.error(e); + } + }, [params, productName, description, category, closeForm]); + + const deleteForm = useCallback(async () => { + // delete product + if (params.productId) { + await api.merchStore.deleteProduct(params.productId); + } + closeForm(); + }, [params.productId, closeForm]); + + const openVariations = useCallback(() => { + history.push(`/products/${params.productId}/variations`); + }, [history, params.productId]); + + return { + productName, + setProductName, + description, + setDescription, + category, + setCategory, + closeForm, + saveForm, + deleteForm, + showModal, + openVariations, + openModal, + closeModal, + }; +}; + +export default useProductsForm; diff --git a/src/frontend/hooks/products.js b/src/frontend/hooks/products.js new file mode 100644 index 0000000..cb04863 --- /dev/null +++ b/src/frontend/hooks/products.js @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import api from '../api'; +import { useDispatch, useSelector } from 'react-redux'; +import * as productActions from '../state/products/actions'; +import * as productSelectors from '../state/products/selectors'; + +const useProducts = () => { + const dispatch = useDispatch(); + const products = useSelector(productSelectors.allProducts); + useEffect(() => { + (async () => { + try { + const productsResponse = await api.merchStore.getProducts(); + + const newProducts = productsResponse.data; + + dispatch(productActions.updateProductInfo(newProducts)); + } catch (err) { + console.error(err); + } + })(); + }, [dispatch]); + + return { + products, + }; +}; + +export default useProducts; diff --git a/src/frontend/pages/landing/LandingPage.js b/src/frontend/pages/landing/LandingPage.js index 16e77c4..035f98f 100644 --- a/src/frontend/pages/landing/LandingPage.js +++ b/src/frontend/pages/landing/LandingPage.js @@ -6,6 +6,7 @@ import RecruitmentPageSVG from '../../assets/page-icons/recruitment.svg'; import SponsorsPageSVG from '../../assets/page-icons/sponsors.svg'; import TeamDescriptionsPageSVG from '../../assets/page-icons/team-descriptions.svg'; import BlogPostsPageSVG from '../../assets/page-icons/blog-posts.svg'; +import MerchPageSVG from '../../assets/page-icons/merch.svg' import UnstyledSection from './components/Section'; import Grid from '@mui/material/Grid'; @@ -62,6 +63,12 @@ const sections = [ previewLink: 'https://teamwaterloop.ca/blogs', icon: BlogPostsPageSVG, }, + { + name: 'Merch Store', + editLink: '/products', + previewLink: 'https://teamwaterloop.ca/products', + icon: MerchPageSVG, + }, ]; const LandingPage = () => { diff --git a/src/frontend/pages/merch-store/Products.router.js b/src/frontend/pages/merch-store/Products.router.js new file mode 100644 index 0000000..95c895c --- /dev/null +++ b/src/frontend/pages/merch-store/Products.router.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; +import ProductsPage from './ProductsPage'; +import EditProduct from './edit-a-product/EditProduct'; +import EditProductVariation from './variations/edit-a-variation/EditVariation'; +import VariationsPage from './variations/VariationsPage'; + + +const ProductsRouter = () => { + const { path } = useRouteMatch(); + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; +export default ProductsRouter; diff --git a/src/frontend/pages/merch-store/ProductsPage.js b/src/frontend/pages/merch-store/ProductsPage.js new file mode 100644 index 0000000..a942e3c --- /dev/null +++ b/src/frontend/pages/merch-store/ProductsPage.js @@ -0,0 +1,92 @@ +import React, { useCallback } from 'react'; +import styled from 'styled-components'; +import UnstyledButton from '../../components/Button'; +import PreviewTable from '../../components/PreviewTable'; +import TableCell from '@mui/material/TableCell'; +import useProducts from '../../hooks/products'; +import { useHistory } from 'react-router-dom'; + +const Button = styled(UnstyledButton)``; + +const Container = styled.div` + margin: ${(props) => props.theme.pageMargin}; +`; + +const ProductsHeader = styled.p` + font: ${({ theme }) => theme.fonts.medium24}; +`; + +const TableLabelHeader = styled.span` + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 30px; + + @media only screen and (max-width: ${({ theme }) => theme.breakpoints.md}px) { + justify-content: center; + flex-direction: column; + margin-bottom: 20px; + } +`; + +const TableHeader = styled.p` + font: ${({ theme }) => theme.fonts.bold36}; +`; + +const headers = [ + { + id: 'name', + value: 'Product', + }, + { + id: 'category', + value: 'Category', + }, +]; + +const RowComponent = ({ name, category }) => ( + <> + {name} + {category} + +); + +const ProductsPage = () => { + const { products } = useProducts(); + const history = useHistory(); + + const rowProduct = products?.map((product) => ({ + id: product.id, + name: product.name, + category: product.category, + })); + + const addProduct = useCallback(() => { + history.push('/products/add'); + }, [history]); + + const onEditProduct = useCallback( + (id) => { + history.push(`/products/${id}/edit`); + }, + [history], + ); + + return ( + + Merch Store Products + + All Products + + + + + + + + + + + + + + + + + + + + + + + + {productName ? `${productName} Variations` : ''} + + + + + Product: {productName} + + + + + + + + + + + + + + + + + + + + + {displayImages} + + + + + + + +