Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

1조 과제 제출 #1

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open

1조 과제 제출 #1

wants to merge 4 commits into from

Conversation

howooking
Copy link

@howooking howooking commented Jun 21, 2023

🤝 패스트캠퍼스 FE5 쇼핑몰 팀프로젝트


Hits

본 프로젝트는 패스트캠퍼스 부트캠프 프론트앤드 5기, 5차 과제입니다.
저희 1조는 주어진 API를 활용하여 축구화 온라인 쇼핑몰을 제작하였습니다.
참고 한 사이트: 크레이지11

개발 기간 : 2023. 5. 31 ~ 2023. 6. 21


배포주소

https://kdt-5-m5-crazy11.vercel.app


개발팀 소개

팀원 정승원 박현준 최용준 황인승 이정우
깃허브 @Tteum00 @HyunJunPark0 @PelicanStd @hwanginseung @howooking
담당 회원정보
상품 상세페이지
구매확정
개인정보 수정
구매내역
구매취소
상품 관리
상품 추가
상품 수정
계좌
거래내역
상품 검색
인증 / 인가
상품 배치
스타일링

시작 가이드

Installation

$ git clone https://github.com/howooking/KDT5-M5
$ cd KDT5-M5
$ npm install
$ npm run dev

백앤드 서버 실행은 불필요합니다.


사용한 기술, 라이브러리

Environment






Config




Development









: 전역 상태관리

: 팝업 안내 메시지

: 이미지 슬라이더

화면 구성

메인페이지 모든제품
카테고리별 상품 상품 검색
연관 상품 추천 상품 상세 페이지
회원 정보 상품 관리
상품 추가 상품 수정
거래 내역 내 정보
계좌 조회 / 해지 계좌 연결
구매 내역 로딩화면
로그인 회원가입

고찰, 느낀점

  • 상태관리 툴

    • 팀원 내 입문자를 배려하여 상대적으로 사용이 쉬운 ZUSTAND를 사용

    • context wrapping하는 과정이 필요하지 않음

    • src/store.ts

      import { create } from 'zustand';
      import { authenticate } from '@/api/authApi';
      import { ADMINS } from '@/constants/constants';
      
      interface UserState {
        userInfo: LocalUser | null;
        setUser: (user: LocalUser | null) => void;
        authMe: () => Promise<string | undefined>;
      }
      
      export const userStore = create<UserState>((set) => ({
        userInfo: localStorage.getItem('user')
          ? JSON.parse(localStorage.getItem('user') as string)
          : null,
      
        setUser: (user: LocalUser | null) =>
          set({
            userInfo: user,
          }),
      
        authMe: async () => {
          const userInfo: LocalUser | null = localStorage.getItem('user')
            ? JSON.parse(localStorage.getItem('user') as string)
            : null;
          if (!userInfo) {
            set({
              userInfo: null,
            });
            return '로그인을 해주세요.';
          }
          const res = await authenticate(userInfo.accessToken);
          if (res.statusCode === 200) {
            const user = res.data as AuthenticateResponseValue;
            const isAdmin = ADMINS.includes(user.email);
            set({
              userInfo: {
                user: user,
                accessToken: userInfo.accessToken,
                isAdmin,
              },
            });
            localStorage.setItem(
              'user',
              JSON.stringify({ user, accessToken: userInfo.accessToken, isAdmin })
            );
            return;
          }
          set({
            userInfo: null,
          });
          localStorage.removeItem('user');
          return '로그인 하신지 24시간이 지나셨어요! 다시 로그인해주세요.';
        },
      }));
      
      
      (필요한 곳에서 사용)
      import { userStore } from '@/store';
      const { userInfo, setUser, authMe } = userStore();
  • 관리자 확인

    • 로그인 시 서버로 부터 받는 데이터는 아래와 같으며 해당 정보로는 관리자 여부를 알 수 없다.

      interface ResponseValue {
        user: {
          email: string;
          displayName: string;
          profileImg: string | null;
        };
        accessToken: string;
      }
    • 따라서 클라이언트 단에서 관리자 여부를 확인하고 isAdmin property를 추가하여 전역상태와 로컬저장소에 저장한다.

      interface LocalUser {
        user: {
          email: string;
          displayName: string;
          profileImg: string | null;
        };
        accessToken: string;
        isAdmin: boolean;
      }
    • 이 방법은 보안상 위험하지만 다음과 같은 대응 전략을 취할 수 있다.

      • 비건전한 사용자가 local storage에 접근하여 isAdmin을 true로 바꿀 경우
        👉 관리자만 접근 할 수 있는 route 분기점에 인증 api를 사용하여 사용자의 신원을 확인한다.

      • src/routes/admin/Admin.tsx

        export default function Admin() {
          const { authMe } = userStore();
          useEffect(() => {
            async function auth() {
              const errorMessage = await authMe();
              if (errorMessage) {
                toast.error(errorMessage, { id: 'authMe' });
              }
            }
            auth();
          }, []);
          return (
            <>
              <SubNavbar menus={SUB_MENUS_ADMIN} gray />
              <Outlet />
            </>
          );
        }
      • 비건전한 사용자가 파일에 저장된 관리자 이메일 주소를 보는 경우
        👉 관리자의 메일 주소를 알더라도 비밀번호는 모르기 때문에 괜찮다. 관리자 메일 주소를 환경변수에 저장하는 방법도 있다.

  • 부족한 상품 정보

    • 상품의 스키마는 아래와 같으며 본 프로젝트에서 필요한 'category'와 'brand' 항목이 없다.

      interface Product {
        id: string;
        title: string;
        price: number;
        description: string;
        tags: string[];
        thumbnail: string | null;
        photo: string | null;
        isSoldOut: boolean;
        discountRate: number;
      }
    • tags 항목에서 배열의 첫번째 요소를 category, 두번째 요소를 brand로 지정하였다.

      tags: ['soccer', 'nike'],
  • 라우트 보호

    • 로그인 상태, 관리자 여부에 따라서 접근할 수 있는 페이지를 제한해야 한다.

    • ProdtectedRoute에서 전역 User 상태와 adminRequired props 속성에 따라서 접근을 제한하게 하였다.

    • src/routes/ProtectedRoute.tsx

      import { Navigate } from 'react-router-dom';
      import { userStore } from '@/store';
      
      type ProtectedRouteProps = {
        element: React.ReactNode,
        adminRequired?: boolean,
      };
      
      export default function ProtectedRoute({
        element,
        adminRequired,
      }: ProtectedRouteProps) {
        const { userInfo } = userStore();
      
        if (!userInfo) {
          return <Navigate to="/login" replace />;
        }
        if (adminRequired && !userInfo.isAdmin) {
          return <Navigate to="/" replace />;
        }
        return <>{element}</>;
      }
  • 상태에 따른 UI의 동적 변화

    • 관리자
      • 관리자의 경우 Navbar에 "관리자" 버튼이 보인다.
      • 관리자의 경우 관리자 페이지에 접근 할 수 있다.
      • 관리자의 경우 로그인시 "주인님 오셨습니다" 알림 메세지가 출력된다.
      • 관리자의 경우 상품 상세 페이지에서 상품 수정 아이콘이 보인다.
    • 로그인
      • 로그인하지 않은 경우 개인정보 페이지에 접근할 수 없다.
      • 로그인하지 않은 경우 상품 상세 페이지에서 결제 버튼 대신 "로그인 하러가기" 버튼이 보인다.
      • 로그인을 한 경우 login 페이지와 signup 페이지에 접근할 수 없다.
    • 계좌
      • 계좌를 하나도 등록하지 않은 경우 상품 상세 페이지에서 "원클린 간편 결제" 버튼 대신 "계좌 등록하러 가기" 버튼이 보인다.
      • 계좌 연결 페이지에서 은행 선택시 입력창에 해당 은행의 계좌번호수를 알려주며 그 수를 input 요소의 maxLength로 지정한다.
    • 상품
      • 상품 상세 페이지 하단에 해당 상품과 같은 카테고리에 있는 제품 10개를 랜덤으로 추천한다.
      • 상품이 매진인 경우 "SOLD OUT" 이미지를 상품 이미지 위에 표시한다.
      • 상품이 매진인 경우 "입고 알림" 버튼이 보인다.


  • 첫 협업 프로젝트

    • 첫 팀프로젝트다 보니 진행과정에서 아쉬웠던 부분이 많았음
    • 브랜치 전략
      • 5명이 각자 맡은 기능의 branch를 생성하여 develope 브랜치에 merge하고 최종적으로 main 브랜치에 merge하는 방식으로 진행
      • 이 보다는 git hub에서 pull request를 하고 다같이 리뷰를 한 후 merge하는 방식이 바람직하다.
    • 정기적으로 develope 브렌치를 pull해야 한꺼번에 많은 양의 conflict가 발생하는 것을 방지할 수 있다.
    • commit 단위 & commit message
      • commit의 단위는 기능 단위여야 한다.
      • commit message를 적기 힘들다면 해당 commit은 너무 많은 기능을 담고 있을 가능성이 높다.
      • commit 단위는 파일 단위가 아니여도 된다. 줄 단위로 commit이 가능하다.
      • 5명의 commit message가 제각각이라 다른 사람의 commit을 한번에 이해하기 어려웠다.
      • 협업을 진행하기 전 commit 규칙을 반드시 세우고 시작해야 함


디렉토리 구조

KDT5-M5
┣ public
┣ src
┃ ┣ api
┃ ┃ ┣ adminApi.ts
┃ ┃ ┣ authApi.ts
┃ ┃ ┣ bankApi.ts
┃ ┃ ┗ transactionApi.ts
┃ ┣ components
┃ ┃ ┣ product
┃ ┃ ┃ ┣ ProductBar.tsx
┃ ┃ ┃ ┣ ProductCard.tsx
┃ ┃ ┃ ┣ ProductSection.tsx
┃ ┃ ┃ ┗ ProductSortOptions.tsx
┃ ┃ ┣ ui
┃ ┃ ┃ ┣ Breadcrumbs.tsx
┃ ┃ ┃ ┣ Button.tsx
┃ ┃ ┃ ┣ CrazyLoading.tsx
┃ ┃ ┃ ┣ ImageUpload.tsx
┃ ┃ ┃ ┣ Input.tsx
┃ ┃ ┃ ┣ LoadingSpinner.tsx
┃ ┃ ┃ ┣ ProfileImage.tsx
┃ ┃ ┃ ┣ SectionTitle.tsx
┃ ┃ ┃ ┣ Select.tsx
┃ ┃ ┃ ┗ Skeleton.tsx
┃ ┃ ┣ Footer.tsx
┃ ┃ ┣ ImageSlider.tsx
┃ ┃ ┣ Layout.tsx
┃ ┃ ┣ Navbar.tsx
┃ ┃ ┣ Search.tsx
┃ ┃ ┣ SingleUser.tsx
┃ ┃ ┗ SubNavbar.tsx
┃ ┣ constants
┃ ┃ ┣ constants.ts
┃ ┃ ┗ library.ts
┃ ┣ lib
┃ ┃ ┣ ceilPrice.ts
┃ ┃ ┗ time.ts
┃ ┣ routes
┃ ┃ ┣ admin
┃ ┃ ┃ ┣ AddProduct.tsx
┃ ┃ ┃ ┣ Admin.tsx
┃ ┃ ┃ ┣ AdminClients.tsx
┃ ┃ ┃ ┣ AdminProducts.tsx
┃ ┃ ┃ ┣ AllTransactions.tsx
┃ ┃ ┃ ┗ EditProduct.tsx
┃ ┃ ┣ myAccount
┃ ┃ ┃ ┣ bank
┃ ┃ ┃ ┃ ┣ BankAccounts.tsx
┃ ┃ ┃ ┃ ┗ ConnectBankAccount.tsx
┃ ┃ ┃ ┣ ChangeName.tsx
┃ ┃ ┃ ┣ ChangePassword.tsx
┃ ┃ ┃ ┣ Info.tsx
┃ ┃ ┃ ┣ Login.tsx
┃ ┃ ┃ ┣ LogoutNeededRoute.tsx
┃ ┃ ┃ ┣ MyAccount.tsx
┃ ┃ ┃ ┣ OrderDetail.tsx
┃ ┃ ┃ ┣ OrderList.tsx
┃ ┃ ┃ ┗ SignUp.tsx
┃ ┃ ┣ Home.tsx
┃ ┃ ┣ Login.tsx
┃ ┃ ┣ LogoutNeededRoute.tsx
┃ ┃ ┣ NotFound.tsx
┃ ┃ ┣ ProductDetail.tsx
┃ ┃ ┣ Products.tsx
┃ ┃ ┣ ProtectedRoute.tsx
┃ ┃ ┣ SearchProducts.tsx
┃ ┃ ┗ SignUp.tsx
┃ ┣ App.tsx
┃ ┣ index.css
┃ ┣ main.tsx
┃ ┣ store.ts
┃ ┗ vite-env.d.ts
┣ .eslintrc.cjs
┣ .gitignore
┣ .prettierrc
┣ custom.d.ts
┣ index.html
┣ package-lock.json
┣ package.json
┣ postcss.config.js
┣ README.md
┣ tailwind.config.js
┣ tsconfig.json
┣ tsconfig.node.json
┗ vite.config.ts

@howooking howooking changed the title 과제 제출 1조 과제 제출 Jun 21, 2023
@howooking howooking self-assigned this Jun 26, 2023
foodeco added a commit that referenced this pull request Jul 2, 2023
Copy link
Member

@GyoHeon GyoHeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

총평

정말 멋진 프로젝트입니다.
수강생들의 결과물이라니 믿기지 않을 정도입니다.

useMemo의 사용, 일관된 api return 등 프로젝트의 여러 부분에서 완성도가 높은 것이 티가 납니다.

아쉬운 점은 딱히 없고, 가독성이나 유지 보수 측면에서 더 개선될 사항 정도가 있겠습니다.
fetch를 모두 useEffect에서 사용하고 로직을 대부분 컴포넌트 안에서 사용하다보니 컴포넌트 자체가 복잡해지는 경향이 있습니다.
컴포넌트에서 분리할 수 있는 로직들은 재사용성과 가독성을 위해서 유틸 함수로 만들어 분리해보세요.(API 처럼)

수고하셨습니다.

Comment on lines +60 to +66
{userInfo.isAdmin ? (
// 관리자인 경우
<Link to="/admin/clients">관리자</Link>
) : (
// 관리자가 아닌경우
<></>
)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, false 중 하나의 경우에만 렌더가 필요할 경우 삼항연산자의 사용보단 &&의 사용이 코드를 조금 더 깔끔하게 만들어 줍니다.

@@ -0,0 +1,44 @@
import { useState } from 'react';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

간단한 form은 비제어 컴포넌트로도 구성할 수 있습니다.
비제어 컴포넌트가 좋다는 의미는 아니고, 유연하게 비제어 컴포넌트의 사용도 생각해보세요.

참고: https://velog.io/@yukyung/React-%EC%A0%9C%EC%96%B4-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%99%80-%EB%B9%84%EC%A0%9C%EC%96%B4-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0

Comment on lines +24 to +32
{spentMoney >= 300000 ? (
spentMoney >= 500000 ? (
<span className="font-bold text-accent">💰VVIP💰</span>
) : (
<span className="font-bold text-accent">💰VIP</span>
)
) : (
'일반회원'
)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금의 경우 많이 복잡하진 않은데, 2가지의 방법으로 리팩터링을 해볼 수 있습니다.

  1. vvip와 vip를 감싸는 tag가 동일하므로 span까지 반복하지 말고 그 안의 텍스트만 분리하는 식으로 코드의 양 자체를 줄일 수 있습니다.
  2. 3항 연산자가 중첩으로 사용될 경우 차라리 위에서 변수로 선언하고 return문을 간단하게 유지하는 것도 좋은 방법입니다.


// 로컬 저장소에 카테고리별 상품 갯수를 가져옴 / 없는 경우 10개
const skeletonLength = new Array(
JSON.parse(localStorage.getItem(category ? category : 'all') ?? '10')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

삼항연산자가 자기 자신을 포함하면 or로 처리할 수 있습니다.

Suggested change
JSON.parse(localStorage.getItem(category ? category : 'all') ?? '10')
JSON.parse(localStorage.getItem(category || 'all') ?? '10')

if (res.statusCode === 200) {
// 카테고리에 따라서 products을 setting
const categoryFilteredProducts = (res.data as Product[]).filter(
(product) => (category ? product.tags[0] === category : product)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category가 false인 경우 굳이 filter를 돌리지 않고 res.data를 바로 사용해도 좋아보입니다.

Comment on lines +38 to +46
const files = (event.target as HTMLInputElement).files as FileList;
const reader = new FileReader();
reader.readAsDataURL(files[0]);
reader.onloadend = () => {
setProductInputData((prevData) => ({
...prevData,
[name]: reader.result as string,
}));
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 로직은 따로 함수로 만들어 재사용가능하게 만드는 것이 좋아보입니다.

Comment on lines +90 to +92
!Number(productInputData.discountRate) ||
Number(productInputData.discountRate) <= 0 ||
Number(productInputData.discountRate) >= 100
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조건이 and를 사용하는 것이 더 깔끔해 보입니다.

Suggested change
!Number(productInputData.discountRate) ||
Number(productInputData.discountRate) <= 0 ||
Number(productInputData.discountRate) >= 100
!(Number(productInputData.discountRate) >= 0 &&
Number(productInputData.discountRate) < 100)

toast.error(res.message, { id: 'getBankList' });
}
fetchData();
}, [userInfo?.accessToken]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accessToken이 바뀌지 않아도 bankList가 바뀔 가능성이 있지 않을까요?

Comment on lines +54 to +84
<table className="table-zebra table table-fixed text-center">
<thead className="text-sm text-black">
<tr>
<th>상품 이미지</th>
<th>고객</th>
<th>상품 이름</th>
<th>가격(원)</th>
<th>거래 시간</th>
<th>거래 취소</th>
<th>거래 완료</th>
</tr>
</thead>
<tbody>
{filteredTransactions.map((transaction, index) => (
<tr key={index}>
<td>
<img
src={transaction.product.thumbnail || '/defaultThumb.jpg'}
alt="thumbnail"
/>
</td>
<td>{transaction.user.displayName}</td>
<td>{transaction.product.title}</td>
<td>{transaction.product.price.toLocaleString('ko-KR')}</td>
<td>{convertToHumanReadable(transaction.timePaid)}</td>
<td>{transaction.isCanceled ? '취소함' : '취소하지 않음'}</td>
<td>{transaction.done ? '🔘' : '❌'}</td>
</tr>
))}
</tbody>
</table>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굉장히 동일한 DOM의 반복은, 스타일만 가진 컴포넌트로 재사용성을 높일 수 있습니다.

Comment on lines +121 to +127
if (
Number(detailProduct.discountRate) < 0 ||
Number(detailProduct.discountRate) >= 100
) {
toast.error('할인율은 0 ~ 99를 입력해주세요.', { id: 'updateProduct' });
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

할인율이 NaN인 경우를 체크하지 못하고 있습니다.
명백한 범위가 있는 경우 and와 not으로 체크를 더 확실히 하는 것이 좋습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants