[새싹] 현대IT&E 240213~240315 최종 프로젝트 - React

최정윤·2024년 2월 14일
0

새싹

목록 보기
67/67
post-custom-banner

240213 기록

프론트엔드 환경 구축하기

  • react
  • typescript
  • redux
  • recoil
  • toolkit
  • Tailwind
  • scss

설치하기

1. 타입스크립트가 적용된 리액트 프로젝트 셋팅하기

create-react-app 홈페이지

npx create-react-app my-app --template typescript

2. tailwind css 설치하고 적용하기

tailwind css 공식 홈페이지

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

▶️ tailwind.config.js 랑 postcss.config.js 파일 생성

tailwind.config.js 수정하기

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

src/index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

App.tsx

import React from 'react';
import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <h1 className="text-3xl font-bold underline">
        Hello world!
      </h1>
      {/* <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header> */}
    </div>
  );
}

export default App;
npm run start

3. Craco 설치

create-react-app은 PostCSS를 지원해주지만, 재정의를 할 수 없기에 TailwindCSS를 이용하기에 다양한 제약이 따릅니다. 따라서 CRACO를 설치하고, craco.config.js파일을 추가해서 쉽고 다양하게 커스터마이징을 할 수 있도록 설정해줍니다.

yarn add @craco/craco
npm install  @craco/craco

craco.config.js

module.exports = {
  style: {
      postcssOptions: {
          plugins: [require('tailwindcss'), require('autoprefixer')],
      },
  },
};

package.json

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  }

▶️ 위 코드를 아래 코드와 같이 수정

 "scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "craco test",
    "eject": "react-scripts eject"
  }

Tailwind css 반응형 메뉴 navbar 만들기

패키지 설치

npm install classnames
npm install react-router-dom
npm install react-router-dom@latest

Header Router 연결하기

SCSS 사용하기

npm install sass

이미지 추가하기


홈화면 구현하기

배너 만들기

패키지 설치

npm install swiper

무한 슬라이드

슬라이드 배너

리액트버전


소개페이지 구현하기


로그인 페이지 구현하기


240215 기록

홈화면 구현하기

슬라이드 배너 구현

react-slick 사용하기

패키지 설치

# react-slick 사용하기
npm install react-slick

# react-slick에서 css 수정하고 싶다면
npm install slick-carousel

npm install --save-dev @types/react-slick

240219 기록

홈화면

리액트에서 스크롤,드래그 둘 다 되는 슬라이더

폰트적용

케러셀

카드 케러셀

로그인화면

폼작성


240220 기록

주문페이지

select 구현

npm install tw-elements

모달구현

토스페이구현

npm install @tosspayments/payment-widget-sdk
npm install nanoid

마이페이지 구현

중첩라우팅


240227 기록

모달창 만들기

npm install @emotion/react
npm install @emotion/styled

[참고링크]


240301 기록

리스토어 신청페이지 구현

사진첨부 구현하기

div 스크롤하기

아이콘 추가하기

npm install react-icons

파일업로드 버튼 꾸미기

주소검색기능

npm install react-daum-postcode

MyRestore.tsx

import "../../../Components_scss/MyRestore.scss"
import { useImage } from "../../common/hooks/useImage";
import { useEffect, useRef, useState } from "react"
import {Form, useActionData} from "react-router-dom";
import ModalBase from '../../../Components/ModalBase';
import CardModal from '../../../Components/CardModal';
import {FormMessage} from "../../../common/FormMessage";
import {getApi} from "../../../api/ApiWrapper";
import {PageOrderResDto, ProductDto} from "../../../api/Api";
  
interface State {
  id: string;
  value: string;
  label: string;
  desc: string;
}

const stateList: State[] = [
  { id: '1', value: 'S', label: 'S - 가장 낮았던 판매가격의 50%', desc: '흠집이 없으며 새 것과 동일한 상태'},
  { id: '2', value: 'A', label: 'A - 가장 낮았던 판매가격의 40%', desc: '경미한 흠집이 있으나 전반적으로 양호한 상태'},
  { id: '3', value: 'B', label: 'B - 가장 낮았던 판매가격의 30%', desc: '흠집 다소 있으며 사용감이 있는 상태'},
];

const MyRestore = () => {
  const image = useImage()
  const [orderHistoryItem, setOrderHistoryItem] = useState<ProductDto[]>([])
  const error = useActionData() as FormMessage
  const formRef = useRef<HTMLFormElement | null>(null);

  // 리스토어 항목 불러오기
  useEffect(() => {
    async function fetchOrderHistory() {
        try {
            const products: ProductDto[] = []
            const api = await getApi()
            const myOrders = (await api.getOrders({page: 0, pageSize: 5}, {})).data as PageOrderResDto
            if (myOrders.content !== undefined) {
                for (let p of myOrders.content) {
                    if (p.products === undefined) continue
                    for (let product of p.products) {
                        try {
                            // @ts-ignore
                            const res = (await api.getProduct1(product.productId)).data as ProductDto
                            products.push(res)

                        } catch (e) {

                        }
                    }
                }
                setOrderHistoryItem(products)
            }
        } catch (e) {
        }
    }

    fetchOrderHistory().then()
  }, []);

  // 모달 기능
  const [isActive, setIsActive] = useState(false);
  const onClickModalOn = () => {
    setIsActive(true);
  };
  const onClickModalOff = () => {
    setIsActive(false);
  };
  const onClickCardConfirm = () => {
    // 모달을 닫고 캐시 비우기
    onClickModalOff();
    setImgFile(undefined);
    setRestoreImgPath("");
    if (imgRef.current) {
      imgRef.current.value = "";
    }
    alert('리스토어가 신청되었습니다.');
  };

  const handleFormSubmit = (event: React.FormEvent) => {
    event.preventDefault();

    console.log("Restoration Data:");
    console.log("Selected State:", restoreGrade);
    console.log("Image File:", imgFile);
    console.log("Restore Description:", restoreDesc);
    console.log("리스토어 이미지 경로:", restoreImgPath);
  };


  // 상태 선택 기능
  const [restoreGrade, setRestoreGrade] = useState<string>();
  // console.log(`Selected state: ${restoreGrade}`);

  // 사진 첨부 기능
  const [imgFile, setImgFile] = useState<File>();
  const [restoreImgPath, setRestoreImgPath] = useState<string>();
  const imgRef = useRef<HTMLInputElement>(null);
  const MAX_IMAGE_SIZE_BYTES = 1024 * 1024 * 2;
  // console.log(restoreImgPath);

  const previewImage = () => {
    if (imgRef.current && imgRef.current.files) {
      const img = imgRef.current.files[0];
      setImgFile(img);
      
      //이미지 미리보기 기능
      const reader = new FileReader();
      reader.readAsDataURL(img);
      reader.onload = () => {
        setRestoreImgPath(reader.result as string);
      };
    }
  };

  // 상품 설명
  const [restoreDesc, setRestoreDesc] = useState<String>();
  // console.log(restoreDesc);

  return (
    <div className="MyRestore">
      <div className="MyRestoreWrapper mb-3">
        <div className="MyRestoreWrapperTitle">리스토어 신청</div>
        <div className="MyRestoreSearchWrapper">
          <div className="MyRestoreSearch relative mb-4 flex w-full flex-wrap items-stretch">
            <input
              type="search"
              className="MyRestoreSearchInput ㅌrelative m-0 -mr-0.5 block min-w-0 flex-auto rounded-l border border-solid border-neutral-300 bg-transparent bg-clip-padding px-3 py-[0.25rem] text-base font-normal leading-[1.6] text-neutral-700 outline-none transition duration-200 ease-in-out focus:z-[3] focus:border-primary focus:text-neutral-700 focus:shadow-[inset_0_0_0_1px_rgb(59,113,202)] focus:outline-none dark:border-neutral-600 dark:text-neutral-200 dark:placeholder:text-neutral-200 dark:focus:border-primary"
              placeholder="검색어를 입력하세요."
              aria-label="Search"
              aria-describedby="button-addon3" />
            <button
              className="MyRestoreSearchBtn relative z-[2] rounded-r border-2 border-primary px-6 py-2 text-xs font-medium uppercase text-primary transition duration-150 ease-in-out hover:bg-black hover:bg-opacity-5 focus:outline-none focus:ring-0"
              type="button"
              id="button-addon3"
              data-te-ripple-init>
              검색
            </button>
          </div>
        </div>
        <div className="MyRestoreContent">
          <div className="MyRestoreContentItem">
            <img className="MyRestoreContentItemImg" src={image("베스트1.jpeg")} title="pic"></img>
            <div className="MyRestoreContentItemText">
              <div className="MyRestoreContentItemBrand">코이</div>
              <div className="MyRestoreContentItemName">코이 6단 이동식 책상세트</div>
              <div className="MyRestoreContentItemInfo">사이즈: 1200 / 색상: 화이트</div>
            </div>
            <button onClick={onClickModalOn} className="MyRestoreContentItemBtn" title="신청하기">신청하기</button>
            <ModalBase active={isActive} closeEvent={onClickModalOff}>
              <Form className="RestoreForm" method={"post"}>
                <CardModal closeEvent={onClickModalOff} title="리스토어 신청하기" actionMsg="확인" actionEvent={onClickCardConfirm}>
                  <div className="RestoreModal">
                    {/* 상품상태 */}
                    <div className="RestoreModalState">
                      <div className="RestoreModalStateTitle">제품상태</div>
                      <div className="RestoreModalStateContent">
                      <ul className="PreferenceTestRightAnswer space-y-4 mb-4">
                        {stateList.map((state) => (
                          <li key={state.id}>
                            <input
                              type="radio"
                              id={state.id}
                              name="job"
                              value={state.value}
                              className="hidden peer"
                              onChange={() => setRestoreGrade(state.value)}
                              required
                            />
                            <label
                              htmlFor={state.id}
                              className="inline-flex items-center justify-between w-full p-5 text-gray-900 bg-white border border-gray-200 rounded-lg cursor-pointer dark:hover:text-gray-300 dark:border-gray-500 dark:peer-checked:text-blue-500 peer-checked:border-blue-600 peer-checked:text-blue-600 hover:text-gray-900 hover:bg-gray-100 dark:text-white dark:bg-gray-600 dark:hover:bg-gray-500"
                            >
                              <div className="block">
                                <div className="w-full text-lg font-semibold">{state.label}</div>
                                <div className="w-full text-lg font-semibold">{state.desc}</div>
                              </div>
                            </label>
                          </li>
                        ))}
                      </ul>
                      </div>
                    </div>
                    {/* 상품사진 */}
                    <div className="RestoreModalPic">
                      <div className="RestoreModalPicTitle">상품사진</div>
                      <div className="RestoreModalPicContent">
                        <div className="RestoreModalPicContentText">사진을 첨부해주세요.</div>
                        <div className="RestoreModalPicContentButton">
                          <label className="RestoreModalPicUploadPreviewLabel" htmlFor="photo">
                            <img
                              //사용자가 이미지 파일을 업로드하면 해당 이미지를 보여주고, 없으면 기본 이미지를 보여준다.
                              className="RestoreModalPicUploadPreviewLImg"
                              src={restoreImgPath ? restoreImgPath : image("upload.png")}
                              alt="사진 첨부하기"
                            />
                          </label>
                          <label className="RestoreModalPicUploadInputLabel" htmlFor="photo">
                            사진 첨부하기
                            <input
                              className="RestoreModalPicUploadInput"
                              type="file"
                              id="photo"
                              name="photo"
                              accept=".png, .jpeg, .jpg"
                              onChange={previewImage}
                              ref={imgRef}
                            />
                            <input type="hidden" name="restoreImgPath" 
                            onChange={() => setRestoreGrade(restoreImgPath)} />
                          </label>
                        </div>
                      </div>
                    </div>
                    {/* 상품설명 */}
                    <div className="RestoreModalDescription">
                      <div className="RestoreModalDescriptionTitle">상품설명</div>
                      <textarea 
                      className="RestoreModalDescriptionContent"
                      placeholder="상품상태를 간략하게 설명해주세요." 
                      title="상품설명"
                      onChange={(e) => setRestoreDesc(e.target.value)}
                      />
                    </div>
                    {/* 안내사항 */}
                    <div className="RestoreModalInfo">
                      <div className="RestoreModalInfoTitle">위의 조건을 충족하지 못하나요?</div>
                      <div className="RestoreModalInfoContent">조건에 맞지 않는 제품은 리스토어 판매가 어렵습니다. 
                      <br/>H.Livv 리스토어 서비스는 가구에 제2의 삶을 불어 넣을 수 있는 선택 중 하나일 뿐입니다. 가구를 폐기할 때가 되었다면 다른 재활용 방법을 고려해보세요.</div>
                    </div>
                  </div>
                </CardModal>
              </Form>
            </ModalBase>
          </div>
        </div>
      </div>
    </div>
  );
}

export default MyRestore;

MyRestoreRouter.ts

import {FormMessage} from "../../../common/FormMessage";
import {Api} from "../../../api/ApiWrapper";
import {getAuthToken} from "../../../api/auth/Token";

// @ts-ignore
export async function myRestoreAction({request, params}) {
  const formData = await request.formData()
  const formDataObj = Object.fromEntries(formData.entries());
  
  // 프론트에서 입력 받아올 값
  const {
    requestGrade,
    restoreDesc,
    restoreImageUrls
  } = formDataObj;

  // 입력값 유효성 확인
  const validationResult = validateRestoreInput(requestGrade, restoreDesc, restoreImageUrls)

  if (validationResult !== null){
    return validationResult
  }

  // 프론트에서 입력하지 않은 변수들을 임의의 값으로 채워넣기
  const filledProductId = 0;
  const filledPickUpDate = "2024-03-09T10:34:33.145Z";
  const filledWhenRejected = true;

  try {
    const api = Api
    const headers = {
      Authorization: `Bearer ${getAuthToken()}`,
      // 다른 필요한 헤더도 추가할 수 있음
    };
    const result = await api.restoreRegister({
      productId: filledProductId,
      pickUpDate: filledPickUpDate,
      requestGrade: requestGrade ? requestGrade.toString() : '',
      restoreDesc: restoreDesc ? restoreDesc.toString() : '',
      whenRejected: filledWhenRejected,
      restoreImageUrls: restoreImageUrls ? restoreImageUrls.split(',') : []
    }, { headers })
    // 콘솔에 성공적인 응답을 출력
    console.log('Successful response:', result);
    return FormMessage.createFormMessage("리스토어 신청 성공", 200)
  } catch(e) {
    // 실패한 경우 콘솔에 에러 메시지 출력
    console.error('Error:', (e as Error).message);
    return FormMessage.createFormMessage(`${(e as Error).message}`, 500)
  }
}

const validateRestoreInput = (requestGrade: string, restoreDesc: string, restoreImageUrls:string) => {
  if (requestGrade === null) return FormMessage.createFormMessage("등급을 선택해주세요", 400)
  if (restoreDesc === null) return FormMessage.createFormMessage("상풍설명을 입력해주세요", 400)
  if (restoreImageUrls === null) return FormMessage.createFormMessage("상품사진을 등록해주세요", 400)
  return null
}
profile
개발 기록장
post-custom-banner

0개의 댓글