) {
12 | const { fieldState, field } = useController({ ...props });
13 |
14 | const onDrop = useCallback((acceptedFiles: File[]) => {
15 | if (acceptedFiles.length > 0) {
16 | const fileWithPreview = Object.assign(acceptedFiles[0], {
17 | preview: URL.createObjectURL(acceptedFiles[0])
18 | });
19 |
20 | field.onChange(fileWithPreview);
21 | }
22 | }, [field]);
23 |
24 | const { getRootProps, getInputProps, isDragActive } = useDropzone({onDrop});
25 |
26 | const dzStyles = {
27 | display: 'flex',
28 | border: 'dashed 2px #767676',
29 | borderColor: '#767676',
30 | borderRadius: '5px',
31 | paddingTop: '30px',
32 | alignItems: 'center',
33 | height: 200,
34 | width: 500
35 | }
36 |
37 | const dzActive = {
38 | borderColor: 'green'
39 | }
40 |
41 | return (
42 |
43 |
47 |
48 |
49 | Drop image here
50 | {fieldState.error?.message}
51 |
52 |
53 | )
54 | }
--------------------------------------------------------------------------------
/client/src/app/shared/components/AppPagination.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Pagination, Typography } from "@mui/material";
2 | import { Pagination as PaginationType } from "../../models/pagination";
3 |
4 | type Props = {
5 | metadata: PaginationType
6 | onPageChange: (page: number) => void
7 | }
8 |
9 | export default function AppPagination({ metadata, onPageChange }: Props) {
10 | const {currentPage, totalPages, pageSize, totalCount} = metadata;
11 |
12 | const startItem = (currentPage - 1) * pageSize + 1;
13 | const endItem = Math.min(currentPage * pageSize, totalCount)
14 |
15 | return (
16 |
17 |
18 | Displaying {startItem}-{endItem} of {totalCount} items
19 |
20 | onPageChange(page)}
26 | />
27 |
28 | )
29 | }
--------------------------------------------------------------------------------
/client/src/app/shared/components/AppSelectInput.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, FormHelperText, InputLabel, MenuItem, Select } from "@mui/material";
2 | import { SelectInputProps } from "@mui/material/Select/SelectInput";
3 | import { FieldValues, useController, UseControllerProps } from "react-hook-form"
4 |
5 | type Props = {
6 | label: string
7 | name: keyof T
8 | items: string[]
9 | } & UseControllerProps & Partial
10 |
11 | export default function AppSelectInput(props: Props) {
12 | const {fieldState, field} = useController({...props});
13 |
14 | return (
15 |
16 | {props.label}
17 |
26 | {fieldState.error?.message}
27 |
28 | )
29 | }
--------------------------------------------------------------------------------
/client/src/app/shared/components/AppTextInput.tsx:
--------------------------------------------------------------------------------
1 | import { TextField, TextFieldProps } from "@mui/material";
2 | import { FieldValues, useController, UseControllerProps } from "react-hook-form"
3 |
4 | type Props = {
5 | label: string
6 | name: keyof T
7 | } & UseControllerProps & TextFieldProps
8 |
9 | export default function AppTextInput(props: Props) {
10 | const {fieldState, field} = useController({...props});
11 |
12 | return (
13 |
25 | )
26 | }
--------------------------------------------------------------------------------
/client/src/app/shared/components/CheckboxButtons.tsx:
--------------------------------------------------------------------------------
1 | import { FormGroup, FormControlLabel, Checkbox } from "@mui/material";
2 | import { useEffect, useState } from "react";
3 |
4 | type Props = {
5 | items: string[];
6 | checked: string[];
7 | onChange: (items: string[]) => void;
8 | }
9 |
10 | export default function CheckboxButtons({items, checked, onChange}: Props) {
11 | const [checkedItems, setCheckedItems] = useState(checked);
12 |
13 | useEffect(() => {
14 | setCheckedItems(checked);
15 | }, [checked]);
16 |
17 | const handleToggle = (value: string) => {
18 | const updatedChecked = checkedItems?.includes(value)
19 | ? checkedItems.filter(item => item !== value)
20 | : [...checkedItems, value];
21 |
22 | setCheckedItems(updatedChecked);
23 | onChange(updatedChecked);
24 | }
25 |
26 | return (
27 |
28 | {items.map(item => (
29 | handleToggle(item)}
34 | color='secondary'
35 | sx={{ py: 0.7, fontSize: 40 }}
36 | />}
37 | label={item}
38 | />
39 | ))}
40 |
41 | )
42 | }
--------------------------------------------------------------------------------
/client/src/app/shared/components/OrderSummary.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography, Divider, Button, TextField, Paper } from "@mui/material";
2 | import { currencyFormat } from "../../../lib/util";
3 | import { Link, useLocation } from "react-router-dom";
4 | import { useBasket } from "../../../lib/hooks/useBasket";
5 | import { FieldValues, useForm } from "react-hook-form";
6 | import { LoadingButton } from "@mui/lab";
7 | import { useAddCouponMutation, useRemoveCouponMutation } from "../../../features/basket/basketApi";
8 | import { Delete } from "@mui/icons-material";
9 |
10 | export default function OrderSummary() {
11 | const {subtotal, deliveryFee, discount, basket, total} = useBasket();
12 | const location = useLocation();
13 | const {register, handleSubmit, formState: {isSubmitting}} = useForm();
14 | const [addCoupon] = useAddCouponMutation();
15 | const [removeCoupon, {isLoading}] = useRemoveCouponMutation();
16 |
17 | const onSubmit = async (data: FieldValues) => {
18 | await addCoupon(data.code);
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 |
26 | Order summary
27 |
28 |
29 | Orders over $100 qualify for free delivery!
30 |
31 |
32 |
33 | Subtotal
34 |
35 | {currencyFormat(subtotal)}
36 |
37 |
38 |
39 | Discount
40 |
41 | -{currencyFormat(discount)}
42 |
43 |
44 |
45 | Delivery fee
46 |
47 | {currencyFormat(deliveryFee)}
48 |
49 |
50 |
51 |
52 | Total
53 |
54 | {currencyFormat(total)}
55 |
56 |
57 |
58 |
59 |
60 | {!location.pathname.includes('checkout') &&
61 | }
71 |
78 |
79 |
80 |
81 | {/* Coupon Code Section */}
82 | {location.pathname.includes('checkout') &&
83 |
84 |
85 |
119 | }
120 |
121 | )
122 | }
--------------------------------------------------------------------------------
/client/src/app/shared/components/RadioButtonGroup.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, FormControlLabel, Radio, RadioGroup } from "@mui/material";
2 | import { ChangeEvent } from "react";
3 |
4 | type Props = {
5 | options: { value: string, label: string }[]
6 | onChange: (event: ChangeEvent) => void
7 | selectedValue: string
8 | }
9 |
10 | export default function RadioButtonGroup({ options, onChange, selectedValue }: Props) {
11 | return (
12 |
13 |
18 | {options.map(({ value, label }) => (
19 | }
22 | label={label}
23 | value={value}
24 | />
25 | ))}
26 |
27 |
28 |
29 | )
30 | }
--------------------------------------------------------------------------------
/client/src/app/store/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore, legacy_createStore } from "@reduxjs/toolkit";
2 | import counterReducer, { counterSlice } from "../../features/contact/counterReducer";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import { catalogApi } from "../../features/catalog/catalogApi";
5 | import { uiSlice } from "../layout/uiSlice";
6 | import { errorApi } from "../../features/about/errorApi";
7 | import { basketApi } from "../../features/basket/basketApi";
8 | import { catalogSlice } from "../../features/catalog/catalogSlice";
9 | import { accountApi } from "../../features/account/accountApi";
10 | import { checkoutApi } from "../../features/checkout/checkoutApi";
11 | import { orderApi } from "../../features/orders/orderApi";
12 | import { adminApi } from "../../features/admin/adminApi";
13 |
14 | export function configureTheStore() {
15 | return legacy_createStore(counterReducer)
16 | }
17 |
18 | export const store = configureStore({
19 | reducer: {
20 | [catalogApi.reducerPath]: catalogApi.reducer,
21 | [errorApi.reducerPath]: errorApi.reducer,
22 | [basketApi.reducerPath]: basketApi.reducer,
23 | [accountApi.reducerPath]: accountApi.reducer,
24 | [checkoutApi.reducerPath]: checkoutApi.reducer,
25 | [orderApi.reducerPath]: orderApi.reducer,
26 | [adminApi.reducerPath]: adminApi.reducer,
27 | counter: counterSlice.reducer,
28 | ui: uiSlice.reducer,
29 | catalog: catalogSlice.reducer
30 | },
31 | middleware: (getDefaultMiddleware) =>
32 | getDefaultMiddleware().concat(
33 | catalogApi.middleware,
34 | errorApi.middleware,
35 | basketApi.middleware,
36 | accountApi.middleware,
37 | checkoutApi.middleware,
38 | orderApi.middleware,
39 | adminApi.middleware
40 | )
41 | });
42 |
43 | export type RootState = ReturnType
44 | export type AppDispatch = typeof store.dispatch
45 |
46 | export const useAppDispatch = useDispatch.withTypes()
47 | export const useAppSelector = useSelector.withTypes()
--------------------------------------------------------------------------------
/client/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/features/about/AboutPage.tsx:
--------------------------------------------------------------------------------
1 | import { Alert, AlertTitle, Button, ButtonGroup, Container, List, ListItem, Typography } from "@mui/material";
2 | import { useLazyGet400ErrorQuery, useLazyGet401ErrorQuery, useLazyGet404ErrorQuery, useLazyGet500ErrorQuery, useLazyGetValidationErrorQuery } from "./errorApi";
3 | import { useState } from "react";
4 |
5 | export default function AboutPage() {
6 | const [validationErrors, setValidationErrors] = useState([]);
7 |
8 | const [trigger400Error] = useLazyGet400ErrorQuery();
9 | const [trigger401Error] = useLazyGet401ErrorQuery();
10 | const [trigger404Error] = useLazyGet404ErrorQuery();
11 | const [trigger500Error] = useLazyGet500ErrorQuery();
12 | const [triggerValidationError] = useLazyGetValidationErrorQuery();
13 |
14 | const getValidatonError = async () => {
15 | try {
16 | await triggerValidationError().unwrap();
17 | } catch (error: unknown) {
18 | if (error && typeof error === 'object' && 'message' in error
19 | && typeof (error as {message: unknown}).message === 'string') {
20 | const errorArray = (error as {message: string}).message.split(', ');
21 | setValidationErrors(errorArray);
22 | }
23 |
24 | }
25 | }
26 |
27 | return (
28 |
29 | Errors for testing
30 |
31 |
35 |
39 |
43 |
47 |
50 |
51 | {validationErrors.length > 0 && (
52 |
53 | Validation errors
54 |
55 | {validationErrors.map(err => (
56 | {err}
57 | ))}
58 |
59 |
60 | )}
61 |
62 | )
63 | }
--------------------------------------------------------------------------------
/client/src/features/about/errorApi.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "@reduxjs/toolkit/query/react";
2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi";
3 |
4 | export const errorApi = createApi({
5 | reducerPath: 'errorApi',
6 | baseQuery: baseQueryWithErrorHandling,
7 | endpoints: (builder) => ({
8 | get400Error: builder.query({
9 | query: () => ({url: 'buggy/bad-request'})
10 | }),
11 | get401Error: builder.query({
12 | query: () => ({url: 'buggy/unauthorized'})
13 | }),
14 | get404Error: builder.query({
15 | query: () => ({url: 'buggy/not-found'})
16 | }),
17 | get500Error: builder.query({
18 | query: () => ({url: 'buggy/server-error'})
19 | }),
20 | getValidationError: builder.query({
21 | query: () => ({url: 'buggy/validation-error'})
22 | }),
23 | })
24 | });
25 |
26 | export const {
27 | useLazyGet400ErrorQuery,
28 | useLazyGet401ErrorQuery,
29 | useLazyGet500ErrorQuery,
30 | useLazyGet404ErrorQuery,
31 | useLazyGetValidationErrorQuery
32 | } = errorApi;
--------------------------------------------------------------------------------
/client/src/features/account/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | import { LockOutlined } from "@mui/icons-material";
2 | import { Box, Button, Container, Paper, TextField, Typography } from "@mui/material";
3 | import { useForm } from "react-hook-form";
4 | import { Link, useLocation, useNavigate } from "react-router-dom";
5 | import { loginSchema, LoginSchema } from "../../lib/schemas/loginSchema";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useLazyUserInfoQuery, useLoginMutation } from "./accountApi";
8 |
9 | export default function LoginForm() {
10 | const [login, {isLoading}] = useLoginMutation();
11 | const [fetchUserInfo] = useLazyUserInfoQuery();
12 | const location = useLocation();
13 | const {register, handleSubmit, formState: {errors}} = useForm({
14 | mode: 'onTouched',
15 | resolver: zodResolver(loginSchema)
16 | });
17 | const navigate = useNavigate();
18 |
19 | const onSubmit = async (data: LoginSchema) => {
20 | await login(data);
21 | await fetchUserInfo();
22 | navigate(location.state?.from || '/catalog');
23 | }
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | Sign in
31 |
32 |
41 |
49 |
57 |
60 |
61 | Don't have an account?
62 |
63 | Sign up
64 |
65 |
66 |
67 |
68 |
69 | )
70 | }
--------------------------------------------------------------------------------
/client/src/features/account/RegisterForm.tsx:
--------------------------------------------------------------------------------
1 | import { useForm } from "react-hook-form";
2 | import { useRegisterMutation } from "./accountApi"
3 | import { registerSchema, RegisterSchema } from "../../lib/schemas/registerSchema";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import { LockOutlined } from "@mui/icons-material";
6 | import { Container, Paper, Box, Typography, TextField, Button } from "@mui/material";
7 | import { Link } from "react-router-dom";
8 |
9 | export default function RegisterForm() {
10 | const [registerUser] = useRegisterMutation();
11 | const {register, handleSubmit, setError, formState: {errors, isValid, isLoading}} = useForm({
12 | mode: 'onTouched',
13 | resolver: zodResolver(registerSchema)
14 | })
15 |
16 | const onSubmit = async (data: RegisterSchema) => {
17 | try {
18 | await registerUser(data).unwrap();
19 | } catch (error) {
20 | const apiError = error as {message: string};
21 | if (apiError.message && typeof apiError.message === 'string') {
22 | const errorArray = apiError.message.split(',');
23 |
24 | errorArray.forEach(e => {
25 | if (e.includes('Password')) {
26 | setError('password', {message: e})
27 | } else if (e.includes('Email')) {
28 | setError('email', {message: e})
29 | }
30 | })
31 | }
32 | }
33 |
34 | }
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | Register
42 |
43 |
52 |
60 |
68 |
71 |
72 | Already have an account?
73 |
74 | Sign in here
75 |
76 |
77 |
78 |
79 |
80 | )
81 | }
--------------------------------------------------------------------------------
/client/src/features/account/accountApi.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "@reduxjs/toolkit/query/react";
2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi";
3 | import { Address, User } from "../../app/models/user";
4 | import { LoginSchema } from "../../lib/schemas/loginSchema";
5 | import { router } from "../../app/routes/Routes";
6 | import { toast } from "react-toastify";
7 |
8 | export const accountApi = createApi({
9 | reducerPath: 'accountApi',
10 | baseQuery: baseQueryWithErrorHandling,
11 | tagTypes: ['UserInfo'],
12 | endpoints: (builder) => ({
13 | login: builder.mutation({
14 | query: (creds) => {
15 | return {
16 | url: 'login?useCookies=true',
17 | method: 'POST',
18 | body: creds
19 | }
20 | },
21 | async onQueryStarted(_, {dispatch, queryFulfilled}) {
22 | try {
23 | await queryFulfilled;
24 | dispatch(accountApi.util.invalidateTags(['UserInfo']))
25 | } catch (error) {
26 | console.log(error);
27 | }
28 | }
29 | }),
30 | register: builder.mutation({
31 | query: (creds) => {
32 | return {
33 | url: 'account/register',
34 | method: 'POST',
35 | body: creds
36 | }
37 | },
38 | async onQueryStarted(_, {queryFulfilled}) {
39 | try {
40 | await queryFulfilled;
41 | toast.success('Registration successful - you can now sign in!');
42 | router.navigate('/login');
43 | } catch (error) {
44 | console.log(error);
45 | throw error;
46 | }
47 | }
48 | }),
49 | userInfo: builder.query({
50 | query: () => 'account/user-info',
51 | providesTags: ['UserInfo']
52 | }),
53 | logout: builder.mutation({
54 | query: () => ({
55 | url: 'account/logout',
56 | method: 'POST'
57 | }),
58 | async onQueryStarted(_, {dispatch, queryFulfilled}) {
59 | await queryFulfilled;
60 | dispatch(accountApi.util.invalidateTags(['UserInfo']));
61 | router.navigate('/');
62 | }
63 | }),
64 | fetchAddress: builder.query({
65 | query: () => ({
66 | url: 'account/address'
67 | })
68 | }),
69 | updateUserAddress: builder.mutation({
70 | query: (address) => ({
71 | url: 'account/address',
72 | method: 'POST',
73 | body: address
74 | }),
75 | onQueryStarted: async (address, {dispatch, queryFulfilled}) => {
76 | const patchResult = dispatch(
77 | accountApi.util.updateQueryData('fetchAddress', undefined, (draft) => {
78 | Object.assign(draft, {...address})
79 | })
80 | );
81 |
82 | try {
83 | await queryFulfilled;
84 | } catch (error) {
85 | patchResult.undo();
86 | console.log(error);
87 | }
88 | }
89 | })
90 | })
91 | });
92 |
93 | export const {useLoginMutation, useRegisterMutation, useLogoutMutation,
94 | useUserInfoQuery, useLazyUserInfoQuery, useFetchAddressQuery,
95 | useUpdateUserAddressMutation} = accountApi;
--------------------------------------------------------------------------------
/client/src/features/admin/InventoryPage.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material";
2 | import { useAppDispatch, useAppSelector } from "../../app/store/store"
3 | import { useFetchProductsQuery } from "../catalog/catalogApi";
4 | import { currencyFormat } from "../../lib/util";
5 | import { Delete, Edit } from "@mui/icons-material";
6 | import AppPagination from "../../app/shared/components/AppPagination";
7 | import { setPageNumber } from "../catalog/catalogSlice";
8 | import { useState } from "react";
9 | import ProductForm from "./ProductForm";
10 | import { Product } from "../../app/models/product";
11 | import { useDeleteProductMutation } from "./adminApi";
12 |
13 | export default function InventoryPage() {
14 | const productParams = useAppSelector(state => state.catalog);
15 | const {data, refetch} = useFetchProductsQuery(productParams);
16 | const dispatch = useAppDispatch();
17 | const [editMode, setEditMode] = useState(false);
18 | const [selectedProduct, setSelectedProduct] = useState(null);
19 | const [deleteProduct] = useDeleteProductMutation();
20 |
21 | const handleSelectProduct = (product: Product) => {
22 | setSelectedProduct(product);
23 | setEditMode(true);
24 | }
25 |
26 | const handleDeleteProduct = async (id: number) => {
27 | try {
28 | await deleteProduct(id);
29 | refetch();
30 | } catch (error) {
31 | console.log(error);
32 | }
33 | }
34 |
35 | if (editMode) return
41 |
42 | return (
43 | <>
44 |
45 | Inventory
46 |
47 |
48 |
49 |
50 |
51 |
52 | #
53 | Product
54 | Price
55 | Type
56 | Brand
57 | Quantity
58 |
59 |
60 |
61 |
62 | {data && data.items.map(product => (
63 |
69 |
70 | {product.id}
71 |
72 |
73 |
74 |
79 | {product.name}
80 |
81 |
82 | {currencyFormat(product.price)}
83 | {product.type}
84 | {product.brand}
85 | {product.quantityInStock}
86 |
87 |
90 |
91 | ))}
92 |
93 |
94 |
95 | {data?.pagination && data.items.length > 0 && (
96 | dispatch(setPageNumber(page))}
99 | />
100 | )}
101 |
102 |
103 | >
104 | )
105 | }
--------------------------------------------------------------------------------
/client/src/features/admin/adminApi.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "@reduxjs/toolkit/query/react";
2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi";
3 | import { Product } from "../../app/models/product";
4 |
5 | export const adminApi = createApi({
6 | reducerPath: 'adminApi',
7 | baseQuery: baseQueryWithErrorHandling,
8 | endpoints: (builder) => ({
9 | createProduct: builder.mutation({
10 | query: (data: FormData) => {
11 | return {
12 | url: 'products',
13 | method: 'POST',
14 | body: data
15 | }
16 | }
17 | }),
18 | updateProduct: builder.mutation({
19 | query: ({id, data}) => {
20 | data.append('id', id.toString())
21 |
22 | return {
23 | url: 'products',
24 | method: 'PUT',
25 | body: data
26 | }
27 | }
28 | }),
29 | deleteProduct: builder.mutation({
30 | query: (id: number) => {
31 | return {
32 | url: `products/${id}`,
33 | method: 'DELETE'
34 | }
35 | }
36 | })
37 | })
38 | });
39 |
40 | export const {useCreateProductMutation, useUpdateProductMutation, useDeleteProductMutation} = adminApi;
--------------------------------------------------------------------------------
/client/src/features/basket/BasketItem.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Grid2, IconButton, Paper, Typography } from "@mui/material"
2 | import { Item } from "../../app/models/basket"
3 | import { Add, Close, Remove } from "@mui/icons-material"
4 | import { useAddBasketItemMutation, useRemoveBasketItemMutation } from "./basketApi"
5 | import { currencyFormat } from "../../lib/util"
6 |
7 | type Props = {
8 | item: Item
9 | }
10 |
11 | export default function BasketItem({ item }: Props) {
12 | const [removeBasketItem] = useRemoveBasketItemMutation();
13 | const [addBasketItem] = useAddBasketItemMutation();
14 |
15 | return (
16 |
24 |
25 |
38 |
39 |
40 | {item.name}
41 |
42 |
43 |
44 | {currencyFormat(item.price)} x {item.quantity}
45 |
46 |
47 | {currencyFormat(item.price * item.quantity)}
48 |
49 |
50 |
51 |
52 | removeBasketItem({productId: item.productId, quantity: 1})}
54 | color="error"
55 | size="small"
56 | sx={{border: 1, borderRadius: 1, minWidth: 0}}
57 | >
58 |
59 |
60 | {item.quantity}
61 | addBasketItem({product: item, quantity: 1})}
63 | color="success"
64 | size="small"
65 | sx={{border: 1, borderRadius: 1, minWidth: 0}}
66 | >
67 |
68 |
69 |
70 |
71 |
72 | removeBasketItem({productId: item.productId, quantity: item.quantity})}
74 | color='error'
75 | size="small"
76 | sx={{
77 | border: 1,
78 | borderRadius: 1,
79 | minWidth: 0,
80 | alignSelf: 'start',
81 | mr: 1,
82 | mt: 1
83 | }}
84 | >
85 |
86 |
87 |
88 | )
89 | }
--------------------------------------------------------------------------------
/client/src/features/basket/BasketPage.tsx:
--------------------------------------------------------------------------------
1 | import { Grid2, Typography } from "@mui/material";
2 | import { useFetchBasketQuery } from "./basketApi"
3 | import BasketItem from "./BasketItem";
4 | import OrderSummary from "../../app/shared/components/OrderSummary";
5 |
6 | export default function BasketPage() {
7 | const {data, isLoading} = useFetchBasketQuery();
8 |
9 | if (isLoading) return Loading basket...
10 |
11 | if (!data || data.items.length === 0) return Your basket is empty
12 |
13 | return (
14 |
15 |
16 | {data.items.map(item => (
17 |
18 | ))}
19 |
20 |
21 |
22 |
23 |
24 | )
25 | }
--------------------------------------------------------------------------------
/client/src/features/basket/basketApi.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "@reduxjs/toolkit/query/react";
2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi";
3 | import { Basket, Item } from "../../app/models/basket";
4 | import { Product } from "../../app/models/product";
5 | import Cookies from 'js-cookie';
6 |
7 | function isBasketItem(product: Product | Item): product is Item {
8 | return (product as Item).quantity !== undefined;
9 | }
10 |
11 | export const basketApi = createApi({
12 | reducerPath: 'basketApi',
13 | baseQuery: baseQueryWithErrorHandling,
14 | tagTypes: ['Basket'],
15 | endpoints: (builder) => ({
16 | fetchBasket: builder.query({
17 | query: () => 'basket',
18 | providesTags: ['Basket']
19 | }),
20 | addBasketItem: builder.mutation({
21 | query: ({ product, quantity }) => {
22 | const productId = isBasketItem(product) ? product.productId : product.id;
23 | return {
24 | url: `basket?productId=${productId}&quantity=${quantity}`,
25 | method: 'POST'
26 | }
27 | },
28 | onQueryStarted: async ({ product, quantity }, { dispatch, queryFulfilled }) => {
29 | let isNewBasket = false;
30 | const patchResult = dispatch(
31 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => {
32 | const productId = isBasketItem(product) ? product.productId : product.id;
33 |
34 | if (!draft?.basketId) isNewBasket = true;
35 |
36 | if (!isNewBasket) {
37 | const existingItem = draft.items.find(item => item.productId === productId);
38 | if (existingItem) existingItem.quantity += quantity;
39 | else draft.items.push(isBasketItem(product)
40 | ? product : {...product, productId: product.id, quantity});
41 | }
42 | })
43 | )
44 |
45 | try {
46 | await queryFulfilled;
47 |
48 | if (isNewBasket) dispatch(basketApi.util.invalidateTags(['Basket']))
49 | } catch (error) {
50 | console.log(error);
51 | patchResult.undo();
52 | }
53 | }
54 | }),
55 | removeBasketItem: builder.mutation({
56 | query: ({ productId, quantity }) => ({
57 | url: `basket?productId=${productId}&quantity=${quantity}`,
58 | method: 'DELETE'
59 | }),
60 | onQueryStarted: async ({ productId, quantity }, { dispatch, queryFulfilled }) => {
61 | const patchResult = dispatch(
62 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => {
63 | const itemIndex = draft.items.findIndex(item => item.productId === productId);
64 | if (itemIndex >= 0) {
65 | draft.items[itemIndex].quantity -= quantity;
66 | if (draft.items[itemIndex].quantity <= 0) {
67 | draft.items.splice(itemIndex, 1);
68 | }
69 | }
70 | })
71 | )
72 |
73 | try {
74 | await queryFulfilled;
75 | } catch (error) {
76 | console.log(error);
77 | patchResult.undo();
78 | }
79 | }
80 | }),
81 | clearBasket: builder.mutation({
82 | queryFn: () => ({data: undefined}),
83 | onQueryStarted: async (_, {dispatch}) => {
84 | dispatch(
85 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => {
86 | draft.items = [];
87 | draft.basketId = '';
88 | })
89 | );
90 | Cookies.remove('basketId');
91 | }
92 | }),
93 | addCoupon: builder.mutation({
94 | query: (code: string) => ({
95 | url: `basket/${code}`,
96 | method: 'POST'
97 | }),
98 | onQueryStarted: async (_, {dispatch, queryFulfilled}) => {
99 | const {data: updatedBasket} = await queryFulfilled;
100 |
101 | dispatch(basketApi.util.updateQueryData('fetchBasket', undefined, (draft)=> {
102 | Object.assign(draft, updatedBasket)
103 | }))
104 | }
105 | }),
106 | removeCoupon: builder.mutation({
107 | query: () => ({
108 | url: 'basket/remove-coupon',
109 | method: 'DELETE'
110 | }),
111 | onQueryStarted: async (_, {dispatch, queryFulfilled}) => {
112 | await queryFulfilled;
113 |
114 | dispatch(basketApi.util.updateQueryData('fetchBasket', undefined, (draft)=> {
115 | draft.coupon = null
116 | }))
117 | }
118 | })
119 | })
120 | });
121 |
122 | export const { useFetchBasketQuery, useAddBasketItemMutation,
123 | useAddCouponMutation, useRemoveCouponMutation,
124 | useRemoveBasketItemMutation, useClearBasketMutation } = basketApi;
--------------------------------------------------------------------------------
/client/src/features/catalog/Catalog.tsx:
--------------------------------------------------------------------------------
1 | import { Grid2, Typography } from "@mui/material";
2 | import ProductList from "./ProductList";
3 | import { useFetchFiltersQuery, useFetchProductsQuery } from "./catalogApi";
4 | import Filters from "./Filters";
5 | import { useAppDispatch, useAppSelector } from "../../app/store/store";
6 | import AppPagination from "../../app/shared/components/AppPagination";
7 | import { setPageNumber } from "./catalogSlice";
8 |
9 | export default function Catalog() {
10 | const productParams = useAppSelector(state => state.catalog);
11 | const {data, isLoading} = useFetchProductsQuery(productParams);
12 | const {data: filtersData, isLoading: filtersLoading} = useFetchFiltersQuery();
13 | const dispatch = useAppDispatch();
14 |
15 | if (isLoading || !data || filtersLoading || !filtersData) return Loading...
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | {data.items && data.items.length > 0 ? (
24 | <>
25 |
26 | {
29 | dispatch(setPageNumber(page));
30 | window.scrollTo({top: 0, behavior: 'smooth'})
31 | }}
32 | />
33 | >
34 | ) : (
35 | There are no results for this filter
36 | )}
37 |
38 |
39 | )
40 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/Filters.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Paper } from "@mui/material";
2 | import Search from "./Search";
3 | import RadioButtonGroup from "../../app/shared/components/RadioButtonGroup";
4 | import { useAppDispatch, useAppSelector } from "../../app/store/store";
5 | import { resetParams, setBrands, setOrderBy, setTypes } from "./catalogSlice";
6 | import CheckboxButtons from "../../app/shared/components/CheckboxButtons";
7 |
8 | const sortOptions = [
9 | { value: 'name', label: 'Alphabetical' },
10 | { value: 'priceDesc', label: 'Price: High to low' },
11 | { value: 'price', label: 'Price: Low to high' },
12 | ]
13 |
14 | type Props = {
15 | filtersData: {brands: string[]; types: string[];}
16 | }
17 |
18 | export default function Filters({filtersData: data}: Props) {
19 |
20 | const { orderBy, types, brands } = useAppSelector(state => state.catalog);
21 | const dispatch = useAppDispatch();
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | dispatch(setOrderBy(e.target.value))}
33 | />
34 |
35 |
36 | dispatch(setBrands(items))}
40 | />
41 |
42 |
43 | dispatch(setTypes(items))}
47 | />
48 |
49 | dispatch(resetParams())}>Reset filters
50 |
51 | )
52 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Card, CardActions, CardContent, CardMedia, Typography } from "@mui/material"
2 | import { Product } from "../../app/models/product"
3 | import { Link } from "react-router-dom"
4 | import { useAddBasketItemMutation } from "../basket/basketApi"
5 | import { currencyFormat } from "../../lib/util"
6 |
7 | type Props = {
8 | product: Product
9 | }
10 |
11 | export default function ProductCard({ product }: Props) {
12 | const [addBasketItem, {isLoading}] = useAddBasketItemMutation();
13 | return (
14 |
24 |
29 |
30 |
34 | {product.name}
35 |
36 |
40 | {currencyFormat(product.price)}
41 |
42 |
43 |
46 | addBasketItem({product, quantity: 1})}
49 | >Add to cart
50 | View
51 |
52 |
53 | )
54 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductDetails.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom"
2 | import { Button, Divider, Grid2, Table, TableBody, TableCell, TableContainer, TableRow, TextField, Typography } from "@mui/material";
3 | import { useFetchProductDetailsQuery } from "./catalogApi";
4 | import { useAddBasketItemMutation, useFetchBasketQuery, useRemoveBasketItemMutation } from "../basket/basketApi";
5 | import { ChangeEvent, useEffect, useState } from "react";
6 |
7 | export default function ProductDetails() {
8 | const { id } = useParams();
9 | const [removeBasketItem] = useRemoveBasketItemMutation();
10 | const [addBasketItem] = useAddBasketItemMutation();
11 | const {data: basket} = useFetchBasketQuery();
12 | const item = basket?.items.find(x => x.productId === +id!);
13 | const [quantity, setQuantity] = useState(0);
14 |
15 | useEffect(() => {
16 | if (item) setQuantity(item.quantity);
17 | }, [item]);
18 |
19 | const {data: product, isLoading} = useFetchProductDetailsQuery(id ? +id : 0)
20 |
21 | if (!product || isLoading) return Loading...
22 |
23 | const handleUpdateBasket = () => {
24 | const updatedQuantity = item ? Math.abs(quantity - item.quantity) : quantity;
25 | if (!item || quantity > item.quantity) {
26 | addBasketItem({product, quantity: updatedQuantity})
27 | } else {
28 | removeBasketItem({productId: product.id, quantity: updatedQuantity})
29 | }
30 | }
31 |
32 | const handleInputChange = (event: ChangeEvent) => {
33 | const value = +event.currentTarget.value;
34 |
35 | if (value >= 0) setQuantity(value)
36 | }
37 |
38 | const productDetails = [
39 | { label: 'Name', value: product.name },
40 | { label: 'Description', value: product.description },
41 | { label: 'Type', value: product.type },
42 | { label: 'Brand', value: product.brand },
43 | { label: 'Quantity in stock', value: product.quantityInStock },
44 | ]
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 | {product.name}
53 |
54 | ${(product.price / 100).toFixed(2)}
55 |
56 |
59 |
60 | {productDetails.map((detail, index) => (
61 |
62 | {detail.label}
63 | {detail.value}
64 |
65 | ))}
66 |
67 |
68 |
69 |
70 |
71 |
72 |
80 |
81 |
82 |
91 | {item ? 'Update quantity' : 'Add to basket'}
92 |
93 |
94 |
95 |
96 |
97 | )
98 | }
99 |
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductList.tsx:
--------------------------------------------------------------------------------
1 | import { Grid2 } from "@mui/material"
2 | import { Product } from "../../app/models/product"
3 | import ProductCard from "./ProductCard"
4 |
5 | type Props = {
6 | products: Product[]
7 | }
8 |
9 | export default function ProductList({ products }: Props) {
10 | return (
11 |
12 | {products.map(product => (
13 |
14 |
15 |
16 |
17 | ))}
18 |
19 | )
20 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/Search.tsx:
--------------------------------------------------------------------------------
1 | import { debounce, TextField } from "@mui/material";
2 | import { useAppDispatch, useAppSelector } from "../../app/store/store";
3 | import { setSearchTerm } from "./catalogSlice";
4 | import { useEffect, useState } from "react";
5 |
6 | export default function Search() {
7 | const {searchTerm} = useAppSelector(state => state.catalog);
8 | const dispatch = useAppDispatch();
9 | const [term, setTerm] = useState(searchTerm);
10 |
11 | useEffect(() => {
12 | setTerm(searchTerm)
13 | }, [searchTerm]);
14 |
15 | const debouncedSearch = debounce(event => {
16 | dispatch(setSearchTerm(event.target.value))
17 | }, 500)
18 |
19 | return (
20 | {
27 | setTerm(e.target.value);
28 | debouncedSearch(e);
29 | }}
30 | />
31 | )
32 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/catalogApi.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "@reduxjs/toolkit/query/react";
2 | import { Product } from "../../app/models/product";
3 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi";
4 | import { ProductParams } from "../../app/models/productParams";
5 | import { filterEmptyValues } from "../../lib/util";
6 | import { Pagination } from "../../app/models/pagination";
7 |
8 | export const catalogApi = createApi({
9 | reducerPath: 'catalogApi',
10 | baseQuery: baseQueryWithErrorHandling,
11 | endpoints: (builder) => ({
12 | fetchProducts: builder.query<{items: Product[], pagination: Pagination}, ProductParams>({
13 | query: (productParams) => {
14 | return {
15 | url: 'products',
16 | params: filterEmptyValues(productParams)
17 | }
18 | },
19 | transformResponse: (items: Product[], meta) => {
20 | const paginationHeader = meta?.response?.headers.get('Pagination');
21 | const pagination = paginationHeader ? JSON.parse(paginationHeader) : null;
22 | return {items, pagination}
23 | }
24 | }),
25 | fetchProductDetails: builder.query({
26 | query: (productId) => `products/${productId}`
27 | }),
28 | fetchFilters: builder.query<{ brands: string[], types: string[] }, void>({
29 | query: () => 'products/filters'
30 | })
31 | })
32 | });
33 |
34 | export const { useFetchProductDetailsQuery, useFetchProductsQuery, useFetchFiltersQuery }
35 | = catalogApi;
--------------------------------------------------------------------------------
/client/src/features/catalog/catalogSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { ProductParams } from "../../app/models/productParams";
3 |
4 | const initialState: ProductParams = {
5 | pageNumber: 1,
6 | pageSize: 8,
7 | types: [],
8 | brands: [],
9 | searchTerm: '',
10 | orderBy: 'name'
11 | }
12 |
13 | export const catalogSlice = createSlice({
14 | name: 'catalogSlice',
15 | initialState,
16 | reducers: {
17 | setPageNumber(state, action) {
18 | state.pageNumber = action.payload
19 | },
20 | setPageSize(state, action) {
21 | state.pageSize = action.payload
22 | },
23 | setOrderBy(state, action) {
24 | state.orderBy = action.payload
25 | state.pageNumber = 1;
26 | },
27 | setTypes(state, action) {
28 | state.types = action.payload
29 | state.pageNumber = 1;
30 | },
31 | setBrands(state, action) {
32 | state.brands = action.payload
33 | state.pageNumber = 1;
34 | },
35 | setSearchTerm(state, action) {
36 | state.searchTerm = action.payload
37 | state.pageNumber = 1;
38 | },
39 | resetParams() {
40 | return initialState;
41 | }
42 | }
43 | });
44 |
45 | export const {setBrands, setOrderBy, setPageNumber, setPageSize,
46 | setSearchTerm, setTypes, resetParams}
47 | = catalogSlice.actions;
--------------------------------------------------------------------------------
/client/src/features/checkout/CheckoutPage.tsx:
--------------------------------------------------------------------------------
1 | import { Grid2, Typography } from "@mui/material";
2 | import OrderSummary from "../../app/shared/components/OrderSummary";
3 | import CheckoutStepper from "./CheckoutStepper";
4 | import { loadStripe, StripeElementsOptions } from "@stripe/stripe-js";
5 | import { Elements } from "@stripe/react-stripe-js";
6 | import { useFetchBasketQuery } from "../basket/basketApi";
7 | import { useEffect, useMemo, useRef } from "react";
8 | import { useCreatePaymentIntentMutation } from "./checkoutApi";
9 | import { useAppSelector } from "../../app/store/store";
10 |
11 | const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PK);
12 |
13 | export default function CheckoutPage() {
14 | const { data: basket } = useFetchBasketQuery();
15 | const [createPaymentIntent, {isLoading}] = useCreatePaymentIntentMutation();
16 | const created = useRef(false);
17 | const {darkMode} = useAppSelector(state => state.ui);
18 |
19 | useEffect(() => {
20 | if (!created.current) createPaymentIntent();
21 | created.current = true;
22 | }, [createPaymentIntent])
23 |
24 | const options: StripeElementsOptions | undefined = useMemo(() => {
25 | if (!basket?.clientSecret) return undefined;
26 | return {
27 | clientSecret: basket.clientSecret,
28 | appearance: {
29 | labels: 'floating',
30 | theme: darkMode ? 'night' : 'stripe'
31 | }
32 | }
33 | }, [basket?.clientSecret, darkMode])
34 |
35 | return (
36 |
37 |
38 | {!stripePromise || !options || isLoading ? (
39 | Loading checkout...
40 | ) : (
41 |
42 |
43 |
44 | )}
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
--------------------------------------------------------------------------------
/client/src/features/checkout/CheckoutSuccess.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Container, Divider, Paper, Typography } from "@mui/material";
2 | import { Link, useLocation } from "react-router-dom";
3 | import { Order } from "../../app/models/order";
4 | import { currencyFormat, formatAddressString, formatPaymentString } from "../../lib/util";
5 |
6 | export default function CheckoutSuccess() {
7 | const { state } = useLocation();
8 | const order = state.data as Order;
9 |
10 | if (!order) return Problem accessing the order
11 |
12 |
13 |
14 | return (
15 |
16 | <>
17 |
18 | Thanks for your fake order!
19 |
20 |
21 | Your order #{order.id} will never be processed as this is a fake shop.
22 |
23 |
24 |
25 |
26 |
27 | Order date
28 |
29 |
30 | {order.orderDate}
31 |
32 |
33 |
34 |
35 |
36 | Payment method
37 |
38 |
39 | {formatPaymentString(order.paymentSummary)}
40 |
41 |
42 |
43 |
44 |
45 | Shipping address
46 |
47 |
48 | {formatAddressString(order.shippingAddress)}
49 |
50 |
51 |
52 |
53 |
54 | Amount
55 |
56 |
57 | {currencyFormat(order.total)}
58 |
59 |
60 |
61 |
62 |
63 |
64 | View your order
65 |
66 |
67 | Continue shopping
68 |
69 |
70 | >
71 |
72 | )
73 | }
--------------------------------------------------------------------------------
/client/src/features/checkout/Review.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Divider, Table, TableBody, TableCell, TableContainer, TableRow, Typography } from "@mui/material";
2 | import { currencyFormat } from "../../lib/util";
3 | import { ConfirmationToken } from "@stripe/stripe-js";
4 | import { useBasket } from "../../lib/hooks/useBasket";
5 |
6 | type Props = {
7 | confirmationToken: ConfirmationToken | null;
8 | }
9 |
10 | export default function Review({confirmationToken}: Props) {
11 | const {basket} = useBasket();
12 |
13 | const addressString = () => {
14 | if (!confirmationToken?.shipping) return '';
15 | const {name, address} = confirmationToken.shipping;
16 | return `${name}, ${address?.line1}, ${address?.city}, ${address?.state},
17 | ${address?.postal_code}, ${address?.country}`
18 | }
19 |
20 | const paymentString = () => {
21 | if (!confirmationToken?.payment_method_preview.card) return '';
22 | const {card} = confirmationToken.payment_method_preview;
23 |
24 | return `${card.brand.toUpperCase()}, **** **** **** ${card.last4},
25 | Exp: ${card.exp_month}/${card.exp_year}`
26 | }
27 |
28 | return (
29 |
30 |
31 |
32 | Billing and delivery information
33 |
34 |
35 |
36 | Shipping address
37 |
38 |
39 | {addressString()}
40 |
41 |
42 |
43 | Payment details
44 |
45 |
46 | {paymentString()}
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | {basket?.items.map((item) => (
57 |
59 |
60 |
61 |
65 |
66 | {item.name}
67 |
68 |
69 |
70 |
71 | x {item.quantity}
72 |
73 |
74 | {currencyFormat(item.price)}
75 |
76 |
77 | ))}
78 |
79 |
80 |
81 |
82 |
83 | )
84 | }
--------------------------------------------------------------------------------
/client/src/features/checkout/checkoutApi.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "@reduxjs/toolkit/query/react";
2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi";
3 | import { Basket } from "../../app/models/basket";
4 | import { basketApi } from "../basket/basketApi";
5 |
6 | export const checkoutApi = createApi({
7 | reducerPath: 'checkoutApi',
8 | baseQuery: baseQueryWithErrorHandling,
9 | endpoints: (builder) => ({
10 | createPaymentIntent: builder.mutation({
11 | query: () => {
12 | return {
13 | url: 'payments',
14 | method: 'POST'
15 | }
16 | },
17 | onQueryStarted: async (_, { dispatch, queryFulfilled }) => {
18 | try {
19 | const { data } = await queryFulfilled;
20 | dispatch(
21 | basketApi.util.updateQueryData('fetchBasket', undefined, (draft) => {
22 | draft.clientSecret = data.clientSecret
23 | })
24 | )
25 | } catch (error) {
26 | console.log('Payment intent creation failed: ', error)
27 | }
28 | }
29 | })
30 | })
31 | });
32 |
33 | export const {useCreatePaymentIntentMutation} = checkoutApi;
--------------------------------------------------------------------------------
/client/src/features/contact/ContactPage.tsx:
--------------------------------------------------------------------------------
1 | import { decrement, increment } from "./counterReducer"
2 | import { Button, ButtonGroup, Typography } from "@mui/material";
3 | import { useAppDispatch, useAppSelector } from "../../app/store/store";
4 |
5 | export default function ContactPage() {
6 | const {data} = useAppSelector(state => state.counter);
7 | const dispatch = useAppDispatch();
8 |
9 | return (
10 | <>
11 |
12 | Contact page
13 |
14 |
15 | The data is: {data}
16 |
17 |
18 | dispatch(decrement(1))} color="error">Decrement
19 | dispatch(increment(1))} color="secondary">Increment
20 | dispatch(increment(5))} color="primary">Increment by 5
21 |
22 | >
23 | )
24 | }
--------------------------------------------------------------------------------
/client/src/features/contact/counterReducer.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit"
2 |
3 | export type CounterState = {
4 | data: number
5 | }
6 |
7 | const initialState: CounterState = {
8 | data: 42
9 | }
10 |
11 | export const counterSlice = createSlice({
12 | name: 'counter',
13 | initialState,
14 | reducers: {
15 | increment: (state, action) => {
16 | state.data += action.payload
17 | },
18 | decrement: (state, action) => {
19 | state.data -= action.payload
20 | }
21 | }
22 | })
23 |
24 | export const {increment, decrement} = counterSlice.actions;
25 |
26 |
27 | export function incrementLegacy(amount = 1) {
28 | return {
29 | type: 'increment',
30 | payload: amount
31 | }
32 | }
33 |
34 | export function decrementLegacy(amount = 1) {
35 | return {
36 | type: 'decrement',
37 | payload: amount
38 | }
39 | }
40 |
41 | export default function counterReducer(state = initialState,
42 | action: {type: string, payload: number}) {
43 | switch (action.type) {
44 | case 'increment':
45 | return {
46 | ...state,
47 | data: state.data + action.payload
48 | }
49 | case 'decrement':
50 | return {
51 | ...state,
52 | data: state.data - action.payload
53 | }
54 | default:
55 | return state;
56 | }
57 | }
--------------------------------------------------------------------------------
/client/src/features/home/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Typography } from "@mui/material";
2 | import { Link } from "react-router-dom";
3 |
4 | export default function HomePage() {
5 | return (
6 |
7 |
12 |
25 |
33 |
40 | Welcome to Restore!
41 |
42 |
58 | Go to shop
59 |
60 |
61 |
62 |
63 | )
64 | }
--------------------------------------------------------------------------------
/client/src/features/orders/OrdersPage.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Paper, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
2 | import { useFetchOrdersQuery } from "./orderApi"
3 | import { useNavigate } from "react-router-dom";
4 | import { format } from "date-fns";
5 | import { currencyFormat } from "../../lib/util";
6 |
7 | export default function OrdersPage() {
8 | const {data: orders, isLoading} = useFetchOrdersQuery();
9 | const navigate = useNavigate();
10 |
11 | if (isLoading) return Loading orders...
12 |
13 | if (!orders) return No orders available
14 |
15 | return (
16 |
17 |
18 | My orders
19 |
20 |
21 |
22 |
23 |
24 | Order
25 | Date
26 | Total
27 | Status
28 |
29 |
30 |
31 | {orders.map(order => (
32 | navigate(`/orders/${order.id}`)}
36 | style={{cursor: 'pointer'}}
37 | >
38 | # {order.id}
39 | {format(order.orderDate, 'dd MMM yyyy')}
40 | {currencyFormat(order.total)}
41 | {order.orderStatus}
42 |
43 | ))}
44 |
45 |
46 |
47 |
48 | )
49 | }
--------------------------------------------------------------------------------
/client/src/features/orders/orderApi.ts:
--------------------------------------------------------------------------------
1 | import { createApi } from "@reduxjs/toolkit/query/react";
2 | import { baseQueryWithErrorHandling } from "../../app/api/baseApi";
3 | import { CreateOrder, Order } from "../../app/models/order";
4 |
5 | export const orderApi = createApi({
6 | reducerPath: 'orderApi',
7 | baseQuery: baseQueryWithErrorHandling,
8 | tagTypes: ['Orders'],
9 | endpoints: (builder) => ({
10 | fetchOrders: builder.query({
11 | query: () => 'orders',
12 | providesTags: ['Orders']
13 | }),
14 | fetchOrderDetailed: builder.query({
15 | query: (id) => ({
16 | url: `orders/${id}`
17 | })
18 | }),
19 | createOrder: builder.mutation({
20 | query: (order) => ({
21 | url: 'orders',
22 | method: 'POST',
23 | body: order
24 | }),
25 | onQueryStarted: async (_, {dispatch, queryFulfilled}) => {
26 | await queryFulfilled;
27 | dispatch(orderApi.util.invalidateTags(['Orders']))
28 | }
29 | })
30 | })
31 | })
32 |
33 | export const {useFetchOrdersQuery, useFetchOrderDetailedQuery, useCreateOrderMutation}
34 | = orderApi;
--------------------------------------------------------------------------------
/client/src/lib/hooks/useBasket.ts:
--------------------------------------------------------------------------------
1 | import { Item } from "../../app/models/basket";
2 | import { useClearBasketMutation, useFetchBasketQuery } from "../../features/basket/basketApi";
3 |
4 | export const useBasket = () => {
5 | const {data: basket} = useFetchBasketQuery();
6 | const [clearBasket] = useClearBasketMutation();
7 |
8 | const subtotal = basket?.items.reduce((sum: number, item: Item) => sum + item.quantity * item.price, 0) ?? 0;
9 | const deliveryFee = subtotal > 10000 ? 0 : 500;
10 |
11 |
12 | let discount = 0;
13 |
14 | if (basket?.coupon) {
15 | if (basket.coupon.amountOff) {
16 | discount = basket.coupon.amountOff
17 | } else if (basket.coupon.percentOff) {
18 | discount = Math.round((subtotal * (basket.coupon.percentOff / 100)) * 100) / 100;
19 | }
20 | }
21 |
22 | const total = Math.round((subtotal - discount + deliveryFee) * 100) / 100;
23 |
24 | return {basket, subtotal, deliveryFee, discount, total, clearBasket}
25 | }
--------------------------------------------------------------------------------
/client/src/lib/schemas/createProductSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | const fileSchema = z.instanceof(File).refine(file => file.size > 0, {
4 | message: 'A file must be uploaded'
5 | }).transform(file => ({
6 | ...file,
7 | preview: URL.createObjectURL(file)
8 | }))
9 |
10 | export const createProductSchema = z.object({
11 | name: z.string({required_error: 'Name of product is required'}),
12 | description: z.string({required_error: 'Description is required'}).min(10, {
13 | message: 'Description must be at least 10 characters'
14 | }),
15 | price: z.coerce.number({required_error: 'Price is required'})
16 | .min(100, 'Price must be at least $1.00'),
17 | type: z.string({required_error: 'Type is required'}),
18 | brand: z.string({required_error: 'Brand is required'}),
19 | quantityInStock: z.coerce.number({required_error: 'Quantity is required'})
20 | .min(1, 'Quantity must be at least 1'),
21 | pictureUrl: z.string().optional(),
22 | file: fileSchema.optional()
23 | }).refine((data) => data.pictureUrl || data.file, {
24 | message: 'Please provide an image',
25 | path: ['file']
26 | })
27 |
28 | export type CreateProductSchema = z.infer;
--------------------------------------------------------------------------------
/client/src/lib/schemas/loginSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const loginSchema = z.object({
4 | email: z.string().email(),
5 | password: z.string().min(6, {
6 | message: 'Password must be at least 6 characters'
7 | })
8 | });
9 |
10 | export type LoginSchema = z.infer;
--------------------------------------------------------------------------------
/client/src/lib/schemas/registerSchema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | const passwordValidation = new RegExp(
4 | /(?=^.{6,10}$)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()_+}{":;'?/>.<,])(?!.*\s).*$/
5 | )
6 |
7 | export const registerSchema = z.object({
8 | email: z.string().email(),
9 | password: z.string().regex(passwordValidation, {
10 | message: 'Password must contain 1 lowercase character, 1 uppercase character, 1 number, 1 special and be 6-10 characters'
11 | })
12 | });
13 |
14 | export type RegisterSchema = z.infer;
--------------------------------------------------------------------------------
/client/src/lib/util.ts:
--------------------------------------------------------------------------------
1 | import { FieldValues, Path, UseFormSetError } from "react-hook-form"
2 | import { PaymentSummary, ShippingAddress } from "../app/models/order"
3 |
4 | export function currencyFormat(amount: number) {
5 | return '$' + (amount / 100).toFixed(2)
6 | }
7 |
8 | export function filterEmptyValues(values: object) {
9 | return Object.fromEntries(
10 | Object.entries(values).filter(
11 | ([, value]) => value !== '' && value !== null
12 | && value !== undefined && value.length !== 0
13 | )
14 | )
15 | }
16 |
17 | export const formatAddressString = (address: ShippingAddress) => {
18 | return `${address?.name}, ${address?.line1}, ${address?.city}, ${address?.state},
19 | ${address?.postal_code}, ${address?.country}`
20 | }
21 |
22 | export const formatPaymentString = (card: PaymentSummary) => {
23 | return `${card?.brand?.toUpperCase()}, **** **** **** ${card?.last4},
24 | Exp: ${card?.exp_month}/${card?.exp_year}`
25 | }
26 |
27 | export function handleApiError(
28 | error: unknown,
29 | setError: UseFormSetError,
30 | fieldNames: Path[]
31 | ) {
32 | const apiError = (error as {message: string}) || {};
33 |
34 | if (apiError.message && typeof apiError.message === 'string') {
35 | const errorArray = apiError.message.split(',');
36 |
37 | errorArray.forEach(e => {
38 | const matchedField = fieldNames.find(fieldName =>
39 | e.toLowerCase().includes(fieldName.toString().toLowerCase()));
40 |
41 | if (matchedField) setError(matchedField, {message: e.trim()});
42 | })
43 | }
44 | }
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './app/layout/styles.css'
4 | import '@fontsource/roboto/300.css';
5 | import '@fontsource/roboto/400.css';
6 | import '@fontsource/roboto/500.css';
7 | import '@fontsource/roboto/700.css';
8 | import { RouterProvider } from 'react-router-dom';
9 | import { router } from './app/routes/Routes';
10 | import { Provider } from 'react-redux';
11 | import { store } from './app/store/store';
12 | import { ToastContainer } from 'react-toastify';
13 | import 'react-toastify/dist/ReactToastify.css';
14 |
15 | createRoot(document.getElementById('root')!).render(
16 |
17 |
18 |
19 |
20 |
21 |
22 | ,
23 | )
24 |
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "Bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "Bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 | import mkcert from 'vite-plugin-mkcert'
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | build: {
8 | outDir: '../API/wwwroot',
9 | chunkSizeWarningLimit: 1024,
10 | emptyOutDir: true
11 | },
12 | server: {
13 | port: 3000
14 | },
15 | plugins: [react(), mkcert()],
16 | })
17 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | sql:
3 | image: mcr.microsoft.com/mssql/server:2022-latest
4 | environment:
5 | ACCEPT_EULA: "Y"
6 | MSSQL_SA_PASSWORD: "Password@1"
7 | ports:
8 | - "1433:1433"
9 | volumes:
10 | - sql-data:/var/opt/mssql
11 | platform: "linux/amd64"
12 | volumes:
13 | sql-data:
14 |
--------------------------------------------------------------------------------