diff --git a/client/jest.config.js b/client/jest.config.js index be5eadd..dfa03b2 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -5,7 +5,7 @@ export default { }, testEnvironment: 'jsdom', testMatch: ['/**/*.test.(js|jsx|ts|tsx)'], - setupFilesAfterEnv: ['/setup-tests.ts'], + setupFilesAfterEnv: ['/setup-tests.ts', 'jest-localstorage-mock'], transformIgnorePatterns: ['/node_modules/'], moduleNameMapper: { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': diff --git a/client/package.json b/client/package.json index 2e942f4..51bb2be 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "store-2-client", - "version": "0.1.0", + "version": "0.6.0", "description": "배민문구사 클라이언트", "main": "src/index.tsx", "type": "module", @@ -68,6 +68,7 @@ "eslint-plugin-testing-library": "^4.10.1", "html-webpack-plugin": "^5.3.2", "jest": "^27.0.6", + "jest-localstorage-mock": "^2.4.17", "mini-css-extract-plugin": "^2.2.0", "prettier": "^2.3.2", "style-loader": "^3.2.1", diff --git a/client/public/favicon.png b/client/public/favicon.png index b110988..567712a 100644 Binary files a/client/public/favicon.png and b/client/public/favicon.png differ diff --git a/client/src/App.tsx b/client/src/App.tsx index b7b6202..4c1c4c7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,6 +10,8 @@ import { SIGNUP_URL, ORDER_LIST_URL, PAYMENT_URL, + ADDRESS_URL, + CART_URL, } from 'constants/urls'; import { @@ -22,6 +24,8 @@ import { ItemDetailPage, MyOrderListPage, OrderPage, + MyAddressPage, + CartPage, } from 'pages'; import Theme from './styles/theme'; @@ -37,6 +41,9 @@ const App: React.FC = () => { + + + diff --git a/client/src/assets/icons/back.png b/client/src/assets/icons/back.png deleted file mode 100644 index 2c34d83..0000000 Binary files a/client/src/assets/icons/back.png and /dev/null differ diff --git a/client/src/assets/icons/back.svg b/client/src/assets/icons/back.svg new file mode 100644 index 0000000..c4e937d --- /dev/null +++ b/client/src/assets/icons/back.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/assets/images/no_image.png b/client/src/assets/images/no_image.png new file mode 100644 index 0000000..b000d82 Binary files /dev/null and b/client/src/assets/images/no_image.png differ diff --git a/client/src/components/cart/index.tsx b/client/src/components/cart/index.tsx new file mode 100644 index 0000000..acd95ff --- /dev/null +++ b/client/src/components/cart/index.tsx @@ -0,0 +1,151 @@ +import React, { useState, useCallback, Fragment, FC } from 'react'; +import { useSelector } from 'react-redux'; +import styled from 'lib/woowahan-components'; +import { useHistory } from 'lib/router'; + +import { PAYMENT_URL, SIGNIN_URL } from 'constants/urls'; +import { cartGenerator } from 'utils/cart-generator'; + +import { RootState } from 'store'; + +import { TextButton } from 'components'; +import Modal from 'components/common/modal'; +import PriceCalculator from 'components/common/price-calculator'; +import { TableSection, CartItem } from './table-section'; + +const SectionTitle = styled.h4` + width: 100%; + font-size: 18px; + font-weight: ${({ theme }) => theme?.weightBold}; + padding-bottom: 12px; + margin-top: 5px; +`; + +const ContinueLink = styled.div` + cursor: pointer; + width: 80px; + padding-top: 20px; + font-size: 12px; + color: ${({ theme }) => theme?.colorSoftBlack}; + + &:hover { + color: ${({ theme }) => theme?.colorGreyMid}; + } +`; + +const ButtonDiv = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding-bottom: 50px; + @media all and (max-width: 800px) { + gap: 14px; + justify-content: center; + } +`; + +const OrderButtonDiv = styled.div` + display: flex; + gap: 10px; +`; + +const Cart: FC = () => { + const [prices, setPrices] = useState([0]); + const [totalCount, setTotalCount] = useState(0); + const [cartItems, setCartItems] = useState(cartGenerator()); + const [checkAll, setCheckAll] = useState(false); + const [checkedItems, setCheckedItems] = useState(new Set()); + const [modalVisible, setModalVisible] = useState(false); + const history = useHistory(); + + const onClick = useCallback(() => history.goBack(), [history]); + + const moveSignin = () => { + history.push(SIGNIN_URL); + }; + + const { userId } = useSelector(({ auth }: RootState) => ({ + userId: auth.user.userId, + })); + + const deleteSelectCartItem = () => { + const data = localStorage.getItem('select') as string; + const select = data.split(','); + let cartItems = cartGenerator(); + cartItems = cartItems.filter((item, index) => select.indexOf(index.toString()) < 0); + let cartItemsString = ''; + cartItems.forEach(item => { + cartItemsString += `${item.id},${item.thumbnail},${item.title},${item.count},${item.price},`; + }); + cartItemsString = cartItemsString.slice(0, cartItemsString.length - 1); + localStorage.setItem('cart', cartItemsString); + localStorage.removeItem('select'); + setCartItems(cartGenerator()); + setPrices([0]); + setTotalCount(0); + setCheckAll(false); + setCheckedItems(new Set()); + }; + + const orderCartItem = (isAll: boolean) => { + let selectCartItems: CartItem[] = []; + if (isAll) { + selectCartItems = cartItems; + } else { + Array.from(checkedItems).forEach(index => selectCartItems.push(cartItems[index])); + } + + let selectCartItemsString = ''; + selectCartItems.forEach(item => { + selectCartItemsString += `${item.id}-${item.count},`; + }); + selectCartItemsString = selectCartItemsString.slice(0, selectCartItemsString.length - 1); + + if (selectCartItemsString !== '') { + sessionStorage.setItem('order', selectCartItemsString); + history.push(PAYMENT_URL); + } + }; + + const onClickOrder = (isAll: boolean) => () => { + if (userId) { + orderCartItem(isAll); + } else { + setModalVisible(true); + } + }; + + return ( + <> + + + {'<'} 쇼핑 계속하기 + + + + + + + + + 로그인이 필요합니다} + body={
로그인 페이지로 이동하시겠습니까?
} + visible={modalVisible} + setVisible={setModalVisible} + onConfirm={moveSignin} + /> + + ); +}; + +export default Cart; diff --git a/client/src/components/cart/table-section.tsx b/client/src/components/cart/table-section.tsx new file mode 100644 index 0000000..3e15a9f --- /dev/null +++ b/client/src/components/cart/table-section.tsx @@ -0,0 +1,164 @@ +import React, { Fragment, FC } from 'react'; +import styled from 'lib/woowahan-components'; +import { Link } from 'lib/router'; + +import { formatPrice } from 'utils'; +import { ITEM_URL } from 'constants/urls'; +import { CartItem } from 'types/cart'; + +import Table from 'components/common/table'; +import { CheckBox } from 'components'; + +interface TableSectionProps { + cartItems: CartItem[]; + checkedItems: Set; + checkAll: boolean; + setPrices: React.Dispatch>; + setTotalCount: React.Dispatch>; + setCheckAll: React.Dispatch>; + setCheckedItems: React.Dispatch>>; +} + +const TableRowTitle = styled.div` + display: flex; + align-items: center; + font-size: 12px; + font-weight: ${({ theme }) => theme?.weightMid}; + + img { + width: 42px; + height: auto; + margin-right: 8px; + } + + ${({ theme }) => theme?.mobile} { + img { + display: none; + } + } + + .item-link { + color: ${({ theme }) => theme?.colorBlack}; + } +`; + +const TableRowText = styled.div` + text-align: center; +`; + +const CheckBoxDiv = styled.div` + margin-bottom: 20px; +`; + +const ItemTitle = styled.div` + font-size: 14px; + + ${({ theme }) => theme?.mobile} { + font-size: 12px; + } +`; + +const tableHeaders = [ + { column: '상품/옵션 정보', span: 1 }, + { column: '수량', span: 1 }, + { column: '상품금액', span: 1 }, + { column: '배송비', span: 1 }, +]; + +const TableSection: FC = ({ + cartItems, + checkedItems, + checkAll, + setPrices, + setTotalCount, + setCheckAll, + setCheckedItems, +}) => { + const updatePrice = (set: Set) => { + const prices = [] as number[]; + let totalCount = 0; + Array.from(set).forEach(index => { + const item = cartItems[Number(index)]; + prices.push(item.price * item.count); + totalCount += item.count; + }); + if (prices.length === 0) { + prices.push(0); + } + setPrices(prices); + setTotalCount(totalCount); + }; + + const checkedItemHandler = (id: number) => () => { + const checkedSet = new Set(checkedItems); + if (checkedSet.has(id)) { + checkedSet.delete(id); + setCheckedItems(checkedSet); + } else { + checkedSet.add(id); + setCheckedItems(checkedSet); + } + + if (cartItems.length === checkedSet.size) { + setCheckAll(true); + } else { + setCheckAll(false); + } + updatePrice(checkedSet); + localStorage.setItem('select', Array.from(checkedSet).join(',')); + }; + + const checkAllHandler = () => { + const checkedSet = new Set(); + if (checkAll) { + setCheckAll(false); + setCheckedItems(checkedSet); + updatePrice(checkedSet); + localStorage.setItem('select', ''); + } else { + setCheckAll(true); + cartItems.forEach((item, index) => checkedSet.add(index)); + setCheckedItems(checkedSet); + updatePrice(checkedSet); + localStorage.setItem('select', Array.from(checkedSet).join(',')); + } + }; + + return ( + , + span: 1, + }, + ...tableHeaders, + ]} + > + {cartItems.map((item, idx) => { + const { id, title, thumbnail, count, price } = item; + return ( + + + + + +
+ + + {title} + {title} + + +
+
+ {count}개 + {formatPrice(price)}원 + 공짜! +
+ ); + })} +
+ ); +}; + +export { TableSection, CartItem }; diff --git a/client/src/components/common/__test__/layout.test.tsx b/client/src/components/common/__test__/layout.test.tsx deleted file mode 100644 index 59b6c49..0000000 --- a/client/src/components/common/__test__/layout.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import Layout from '../layout'; - -describe('', () => { - it('matches snapshot pc main', () => { - const { container } = render( - - test layout main - , - ); - expect(container).toMatchSnapshot(); - }); - - it('matches snapshot pc', () => { - const { container } = render(test layout); - expect(container).toMatchSnapshot(); - }); - - it('matches snapshot mobile main', () => { - const { container } = render( - - test layout mobile - , - ); - expect(container).toMatchSnapshot(); - }); - - it('matches snapshot mobile', () => { - const { container } = render(test layout mobile); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/client/src/components/common/__test__/logo.test.tsx b/client/src/components/common/__test__/logo.test.tsx deleted file mode 100644 index 69bce2e..0000000 --- a/client/src/components/common/__test__/logo.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import Logo from '../logo'; - -describe('', () => { - it('matches snapshot', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/client/src/components/common/__test__/navbar.test.tsx b/client/src/components/common/__test__/navbar.test.tsx deleted file mode 100644 index a7e07bf..0000000 --- a/client/src/components/common/__test__/navbar.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import Navbar from '../navbar'; - -describe('', () => { - it('matches snapshot', () => { - const { container } = render( null} />); - expect(container).toMatchSnapshot(); - }); - - it('matches snapshot mobile', () => { - const { container } = render( null} />); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/client/src/components/common/button/text-button.tsx b/client/src/components/common/button/text-button.tsx index e14361d..f61602c 100644 --- a/client/src/components/common/button/text-button.tsx +++ b/client/src/components/common/button/text-button.tsx @@ -1,16 +1,18 @@ import React, { FC } from 'react'; import styled from 'lib/woowahan-components'; +import { CircleLoader } from 'components'; + type StyleType = 'black' | 'white'; type ButtonType = 'button' | 'submit' | 'reset'; -type Size = 'big' | 'small'; +type Size = 'big' | 'small' | 'tiny'; interface TextButtonProps { type: ButtonType; title: string; styleType: StyleType; size?: Size; - onClick?: () => void; + onClick?: ((count: number) => void) | (() => void); disabled?: boolean; isLoading?: boolean; } @@ -18,6 +20,7 @@ interface TextButtonProps { const Button = styled.button` padding: ${({ styleType }) => (styleType === 'black' ? '16px 55px' : '16px 40px')}; background: ${({ styleType }) => (styleType === 'black' ? 'black' : 'white')}; + line-height: ${({ size }) => (size === 'tiny' ? '10px' : '20px')}; font-family: ${({ theme }) => theme?.fontEuljiro}; font-size: ${({ size }) => (size === 'big' ? '20px' : '16px')}; border: 1px solid ${({ styleType, theme }) => (styleType === 'black' ? 'black' : theme?.colorTextBeige)}; @@ -48,12 +51,11 @@ const TextButton: FC = ({ ); }; diff --git a/client/src/components/common/check-box.tsx b/client/src/components/common/check-box.tsx index b7cdc6d..2a7ec0f 100644 --- a/client/src/components/common/check-box.tsx +++ b/client/src/components/common/check-box.tsx @@ -5,11 +5,11 @@ interface CheckBoxProps { id: string; text: string; onChange?: (e: ChangeEvent) => void; + check?: boolean; } const Label = styled.label` cursor: pointer; - margin-bottom: 20px; position: relative; display: flex; font-family: ${props => props.theme?.fontHannaAir}; @@ -18,23 +18,23 @@ const Label = styled.label` &:hover .checkmark { background-color: ${props => props.theme?.colorPointBeigeLight}; } -`; -const CheckBoxInput = styled.input` - opacity: 0; - height: 0; - width: 0; + input[type='checkbox'] { + opacity: 0; + height: 0; + width: 0; - &:checked ~ .checkmark { - background-color: ${props => props.theme?.colorLine}; - } + &:checked ~ .checkmark { + background-color: ${props => props.theme?.colorLine}; + } - &:checked ~ .checkmark:after { - display: block; + &:checked ~ .checkmark:after { + display: block; + } } `; -const CheckMark = styled.span` +const CheckMark = styled.div` height: 14px; width: 14px; background-color: ${props => props.theme?.colorOffWhite}; @@ -57,10 +57,10 @@ const CheckMark = styled.span` } `; -const CheckBox: FC = ({ id, text, onChange }) => { +const CheckBox: FC = ({ id, text, onChange, check }) => { return ( diff --git a/client/src/components/common/grid-form.tsx b/client/src/components/common/grid-form.tsx index 3ed3a68..3584af0 100644 --- a/client/src/components/common/grid-form.tsx +++ b/client/src/components/common/grid-form.tsx @@ -8,16 +8,36 @@ interface GridFormProps { const FormContainer = styled.div` width: 100%; - input { + textarea { + resize: none; + height: 300px; + outline: none; + } + input, + textarea { font-size: 14ox; padding: 6px 12px; border: 1px solid ${({ theme }) => theme?.colorLineLight}; - min-width: 220px; + width: 240px; } ${({ theme }) => theme?.mobile} { - input { - min-width: 100%; + input, + textarea { + width: 100%; + } + textarea { + height: 120px; + } + } + ${({ theme }) => theme?.tablet} { + input, + textarea { + width: 100%; + max-width: 220px; + } + textarea { + height: 200px; } } `; @@ -43,7 +63,7 @@ const RowHead = styled.div` ${({ theme }) => theme?.mobile} { width: 110px; - padding: 16px 18px; + padding: 16px 14px; } `; diff --git a/client/src/components/common/menu-header.tsx b/client/src/components/common/menu-header.tsx index fe7b71d..85d1c5f 100644 --- a/client/src/components/common/menu-header.tsx +++ b/client/src/components/common/menu-header.tsx @@ -2,7 +2,7 @@ import React, { FC, useCallback } from 'react'; import styled from 'lib/woowahan-components'; import { useHistory } from 'lib/router'; -import back from 'assets/icons/back.png'; +import back from 'assets/icons/back.svg'; interface MenuHeaderProps { title: string; @@ -28,6 +28,10 @@ const MobileWrapper = styled.div` } `; +const BackButton = styled.img` + width: 30px; +`; + const Wrapper = styled.div` padding-top: 50px; padding-bottom: 30px; @@ -43,7 +47,7 @@ const MenuHeader: FC = ({ title, isMobile }) => { return (
{title}
diff --git a/client/src/components/common/period-selector.tsx b/client/src/components/common/period-selector.tsx index 440e571..af49a25 100644 --- a/client/src/components/common/period-selector.tsx +++ b/client/src/components/common/period-selector.tsx @@ -15,12 +15,13 @@ interface PeriodSelectorProps { } const Wrapper = styled.div` + margin-bottom: 40px; h3 { font-size: 16px; font-weight: 700; padding: 16px; } - padding: 35px 40px; + padding: 20px 25px 10px 25px; border: 2px solid ${props => props.theme?.colorLineLight}; ${props => props.theme?.mobile} { padding: 0; @@ -39,7 +40,7 @@ const Wrapper = styled.div` const Form = styled.form` display: flex; - align-items: baselin; + align-items: baseline; flex-wrap: wrap; > div { margin-right: 10px; @@ -108,8 +109,8 @@ const PeriodSelector: FC = ({ ]; return ( -

조회기간

+

조회기간

{btn.map(([text, fn], idx) => (
+ 장바구니에 상품이 담겼습니다.} + body={

바로 이동하시겠습니까?

} + visible={modalVisible} + setVisible={setModalVisible} + onConfirm={movePayPage} + /> ); }; diff --git a/client/src/components/item-detail/review-list.tsx b/client/src/components/item-detail/review-list.tsx new file mode 100644 index 0000000..c281f02 --- /dev/null +++ b/client/src/components/item-detail/review-list.tsx @@ -0,0 +1,116 @@ +import React, { FC, useState } from 'react'; +import styled from 'lib/woowahan-components'; + +import starOn from 'assets/icons/star_on.png'; +import starOff from 'assets/icons/star_off.png'; + +import { IReview } from 'types/review'; + +import { filterId } from 'utils'; + +interface IReviewListProps { + reviews: IReview[]; + reviewLoading: boolean; +} + +const Wrapper = styled.div` + margin-top: 20px; + margin-bottom: 50px; + display: flex; + flex-direction: column; +`; + +const Review = styled.div` + border-top: 1px solid #dbdbdb; + padding: 10px; + margin: 0 20px; + &:first-child { + border-top: 2px solid #999999; + } + > div:first-child { + display: flex; + } + > div:first-child > div:first-child { + margin-right: 15px; + } + > div:first-child > div:last-child { + margin-left: auto; + } + > div:last-child { + margin-top: 20px; + display: flex; + > img { + width: 200px; + margin-right: 30px; + ${({ theme }) => theme?.mobile} { + width: 100px; + } + ${({ theme }) => theme?.tablet} { + width: 150px; + } + } + } + .star { + width: 18px; + height: 17px; + } +`; + +const Empty = styled.div` + font-size: 80px; + color: ${({ theme }) => theme?.colorLine}; + text-align: center; + font-family: ${({ theme }) => theme?.fontEuljiro10}; + + padding: 30px 0; +`; + +const makeStar = (score: number): boolean[] => { + const star: boolean[] = []; + while (star.length !== 5) { + if (score <= star.length) star.push(true); + else star.push(false); + } + return star; +}; + +const ReviewList: FC = ({ reviews, reviewLoading }) => { + const [state, setState] = useState(null); + return ( + + {!reviewLoading && reviews.length === 0 && } + {reviews.map((review, idx) => { + const { score, title, imgUrl, contents, userId } = review; + return ( + { + if (idx === state) setState(null); + else setState(idx); + }} + > +
+
+ {makeStar(score).map((star, i) => { + if (star) + return startOff; + return startOff; + })} +
+
{title}
+
{filterId(userId)}
+
+ {idx === state && ( +
+ {imgUrl && 후기 이미지} +
{contents}
+
+ )} +
+ ); + })} +
+ ); +}; + +export default ReviewList; diff --git a/client/src/components/item-detail/review-post.tsx b/client/src/components/item-detail/review-post.tsx new file mode 100644 index 0000000..e50cc54 --- /dev/null +++ b/client/src/components/item-detail/review-post.tsx @@ -0,0 +1,167 @@ +import React, { FC } from 'react'; +import styled from 'lib/woowahan-components'; + +import starOn from 'assets/icons/star_on.png'; +import starOff from 'assets/icons/star_off.png'; +import GridForm from 'components/common/grid-form'; +import TextButton from 'components/common/button/text-button'; + +interface IReviewPostProps { + userId: null | string; + postTitle: string; + postContent: string; + setPostTitle: React.Dispatch>; + setPostContent: React.Dispatch>; + setFile: (file: File) => void; + star: number; + setStar: (star: number) => void; + onSubmit: (e: React.FormEvent) => void; + error: null | string; + reviewSubmitLoading: boolean; + fileRef: React.RefObject; + isPaid: boolean; +} + +const Wrapper = styled.div` + border: 3px solid ${({ theme }) => theme?.colorLine}; + background-color: white; + margin-top: 30px; + h3 { + font-size: 20px; + margin-bottom: 20px; + font-weight: ${props => props.theme?.weightBold}; + } + img { + width: 18px; + height: 17px; + } + input[type='button'] { + cursor: pointer; + } + .star { + width: 18px; + height: 17px; + box-sizing: border-box; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + border: 0 !important; + } + .starOn { + background-image: url(${starOn}); + } + .starOff { + background-image: url(${starOff}); + } +`; + +const Padding = styled.div` + padding: 20px; +`; + +const Flex = styled.div` + display: flex; + justify-content: center; + button { + margin-top: 20px; + } +`; + +const InputErrorMessage = styled.div` + font-size: 13px; + color: ${({ theme }) => theme?.colorError}; + text-align: center; + margin-top: 10px; +`; + +const makeStar = (star: number): boolean[] => { + const arr = []; + for (let i = 1; i <= 5; i += 1) { + if (i <= star) arr.push(true); + else arr.push(false); + } + return arr; +}; + +const ReviewPost: FC = ({ + userId, + postTitle, + postContent, + setPostTitle, + setPostContent, + setFile, + star, + setStar, + onSubmit, + error, + reviewSubmitLoading, + fileRef, + isPaid, +}) => { + if (!userId || !isPaid) return null; + return ( + + +

상품후기 작성

+ + +
{userId}
+
+ {makeStar(star).map((star, i) => { + if (star) + return ( + setStar(i + 1)} + /> + ); + return ( + setStar(i + 1)} + /> + ); + })} +
+ setPostTitle(e.target.value)} + name="postTitle" + required + minLength={2} + maxLength={30} + /> +