미새문지

24.09.10 day64 작업 일지 본문

개발 TIL

24.09.10 day64 작업 일지

문미새 2024. 9. 10. 23:57
728x90

루틴 페이지의 필터 드롭다운을 변경했다.

어제 회의로 정렬 필터를 가격 오름차순, 내림차순, 평점 오름차순, 내림차순 총 4개를 적용하기로 했다.

이 후 데이터에 따라 더 추가할 요소가 있으면 추가할 예정이고 이대로만 진행해도 크게 문제는 없을 것 같아 나중으로 미뤄두려고 한다.

import "../../scss/about/filterList.scss";
import sortFilter from "../../img/sort.png";
import goodOff from "../../img/goodOff.png";
import goodOn from "../../img/goodOn.png";
import star from "../../img/star.png";
import { items } from "../../data/Data";
import { useNavigate } from "react-router-dom";
import { useState } from "react";

const FilterList: React.FC = () => {
  const navigate = useNavigate();
  const [sortedItems, setSortedItems] = useState(items);
  const [sortOrder, setSortOrder] = useState("priceAsc");

  const handleAddRoutine = () => {
    navigate("/add");
  };

  const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const selectedOrder = e.target.value;
    setSortOrder(selectedOrder);

    const sortedArray = [...items];

    switch (selectedOrder) {
      case "priceAsc":
        sortedArray.sort((a, b) => a.price - b.price);
        break;
      case "priceDesc":
        sortedArray.sort((a, b) => b.price - a.price);
        break;
      case "ratingAsc":
        sortedArray.sort((a, b) => a.rating - b.rating);
        break;
      case "ratingDesc":
        sortedArray.sort((a, b) => b.rating - a.rating);
        break;
      default:
        break;
    }

    setSortedItems(sortedArray);
  };

  return (
    <>
      <div className="filterListWrapper">
        <div className="filterBtn">
          <div style={{ display: "flex" }}>
            정렬 순:{" "}
            <select value={sortOrder} onChange={handleSortChange}>
              <option value="priceAsc">가격 오름차순</option>
              <option value="priceDesc">가격 내림차순</option>
              <option value="ratingAsc">평점 오름차순</option>
              <option value="ratingDesc">평점 내림차순</option>
            </select>
          </div>
          {/* <img src={sortFilter} alt="" /> */}
        </div>
        {items.map((item, index) => (
          <div className="itemListWrapper" key={index}>
            <div className="itemListTitle">
              <div className="itemListFirstTitle">
                <div>{item.title}</div>
                <div>
                  <img src={goodOff} alt="" />
                </div>
              </div>
              <div className="itemListSecondTitle">
                <div>
                  <img src={star} alt="" />
                </div>
                <div>
                  {item.rating} <span>({item.reviews})</span>
                </div>
              </div>
            </div>
            <div className="itemListContent">
              <div className="contentImg">
                <img src="#" alt="" />
              </div>
              <div className="contentInfo">
                <span>{item.routine}</span>
                <div className="selectFilter">
                  <div>{item.skinType}</div>
                  <div>{item.gender}</div>
                </div>
                <div className="contentTag">
                  <ul>
                    {item.tags.map((tag, tagIndex) => (
                      <li key={tagIndex}>{tag}</li>
                    ))}
                  </ul>
                </div>
                <div className="contentPrice">
                  종합 <span>₩ {item.price.toLocaleString()}</span>
                </div>
              </div>
            </div>
          </div>
        ))}
        <div className="addRoutineBtn" onClick={handleAddRoutine}>
          <span>+</span>
        </div>
      </div>
    </>
  );
};
export default FilterList;

 

기존의 정렬 UI는 정렬방식에 아이콘이 있는 방식이였는데, 이 부분에서 다른 정렬방식을 선택할 때의 UI를 넣기가 애매해서 아이콘을 없애고 선택박스를 넣었다. select 태그에 option태그를 이용해 각각 정렬방식을 넣었고 태그에 onChange를 사용해서 선택한 option의 value값을 받아 해당 value값의 방식으로 정렬되게 구현했다.

이제 api요청으로 루틴 값들을 가져올 때 정렬 방식이 바뀔 때마다 재요청을 보내서 해당 정렬방식으로 리렌더링이 되게 하면 될 것 같다.

 

그리고 루틴 추가 페이지에 value 값을 받는 코드와 더불어 리덕스에 연결해서 상태관리를 진행하고 마지막 페이지에 모든 데이터를 api를 통해 서버에 전달하는 것까지 구현했다. 그러나 아직 이해가 잘 안되는 코드가 있어서 좀 더 학습해야 할 것 같다.

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface RoutineItem {
  order: string;
  description: string;
  itemName: string;
}

interface addRoutineState {
  title: string;
  forRoutine: number[];
  grade: number;
  routineItem: RoutineItem[];
  tag: string[];
}

const initialState: addRoutineState = {
  title: "",
  forRoutine: [],
  grade: 1,
  routineItem: [],
  tag: [],
};

const addRoutineSlice = createSlice({
  name: "addRoutine",
  initialState,
  reducers: {
    setTitle: (state, action: PayloadAction<string>) => {
      state.title = action.payload;
    },
    setForRoutine: (state, action: PayloadAction<number[]>) => {
      state.forRoutine = action.payload;
    },
    setGrade: (state, action: PayloadAction<number>) => {
      state.grade = action.payload;
    },
    setRoutineItem: (state, action: PayloadAction<RoutineItem>) => {
      state.routineItem.push(action.payload);
    },
    setTag: (state, action: PayloadAction<string[]>) => {
      state.tag = action.payload;
    },
  },
});

export const { setTitle, setForRoutine, setGrade, setRoutineItem, setTag } =
  addRoutineSlice.actions;
export default addRoutineSlice.reducer;

회원가입과 비슷하게 여러 페이지로 구성되어있기 때문에 리덕스를 통해 상태관리를 진행했다.

필요한 데이터는

  • 1페이지 : 루틴 주제, 누구를 위한 루틴 체크박스, 루틴 단계
  • 2페이지 : 단계 주제, 단계 설명, 제품 이름이 객체로 묶여있는 단계 배열
  • 3페이지 : 루틴 태그

로 구성되어 있다.

 

import styled from "styled-components";
import OtherFilter from "../about/otherFilter";
import PageCount from "../common/pageCount";
import PageGuide from "../common/pageGuide";
import CommonInput from "../common/commonInput";
import { useState } from "react";
import NextBtn from "../signup/nextBtn";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../redux/store";
import {
  setTitle,
  setForRoutine,
  setGrade,
} from "../../redux/slice/addRoutineSlice";

interface NextProps {
  onNext: () => void;
}

const AddRoutine1: React.FC<NextProps> = ({ onNext }) => {
  const dispatch = useDispatch();
  const title = useSelector((state: RootState) => state.addRoutine.title);
  const forRoutine = useSelector(
    (state: RootState) => state.addRoutine.forRoutine
  );
  const grade = useSelector((state: RootState) => state.addRoutine.grade);

  const handleCheckedChange = (newCheckedItems: number[]) => {
    dispatch(setForRoutine(newCheckedItems));
  };

  const isButtonDisabled = () => {
    return !title || forRoutine.length === 0 || grade <= 0;
  };

  return (
    <>
      <AddRoutine1Wrapper>
        <PageCount count="1" />
        <PageGuide text="루틴 이름을 입력해주세요." />
        <CommonInput
          typeValue="text"
          placeholderValue="루틴 이름"
          value={title}
          onChange={(e) => dispatch(setTitle(e.target.value))}
        />
        <PageGuide text="누구를 위한 루틴인가요?" />
        <OtherFilter onCheckedChange={handleCheckedChange} />
        <PageGuide text="몇개의 단계로 이루어져 있나요?" />
        <ItemGrade>제품 개수</ItemGrade>
        <CommonInput
          typeValue="number"
          placeholderValue="예) 3"
          value={grade}
          onChange={(e) => dispatch(setGrade(Number(e.target.value)))}
        />
        <NextBtn onClick={onNext} disabled={isButtonDisabled()} />
      </AddRoutine1Wrapper>
    </>
  );
};
export default AddRoutine1;

const AddRoutine1Wrapper = styled.div`
  width: 70%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
`;
const ItemGrade = styled.span`
  font-size: 14px;
  color: #848484;
  margin-bottom: 10px;
`;

1페이지는 기존에 없던 루틴 주제를 입력할 수 있게 UI를 추가했고 리덕스의 변수를 가져와 input의 value값에 연결하여 입력한 값이 리덕스로 들어가게 구현했다.

누구를 위한 루틴값인 forRoutine은 입력이 체크박스 형식이여서 배열로 받을 수 있게 작성했다.

import styled from "styled-components";
import React, { useState } from "react";
import { options } from "../../data/Data";

interface OtherFilterProps {
  onCheckedChange: (checkedItems: number[]) => void;
}

const OtherFilter: React.FC<OtherFilterProps> = ({ onCheckedChange }) => {
  const [checkedItems, setCheckedItems] = useState<number[]>([]);

  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = parseInt(e.target.value, 10);

    if (checkedItems.includes(value)) {
      const updatedItems = checkedItems
        .filter((item) => item !== value)
        .sort((a, b) => a - b);
      setCheckedItems(updatedItems);
      onCheckedChange(updatedItems);
    } else {
      const updatedItems = [...checkedItems, value].sort((a, b) => a - b);
      setCheckedItems(updatedItems);
      onCheckedChange(updatedItems);
    }
  };

  return (
    <>
      <FilterWrapper>
        {options.map((option, index) => (
          <Label key={index} isChecked={checkedItems.includes(index + 1)}>
            <input
              type="checkbox"
              value={index + 1}
              checked={checkedItems.includes(index + 1)}
              onChange={handleCheckboxChange}
            />
            {option}
          </Label>
        ))}
      </FilterWrapper>
    </>
  );
};
export default OtherFilter;

const FilterWrapper = styled.div`
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  row-gap: 10px;
  padding: 10px;
`;

const Label = styled.label<{ isChecked: boolean }>`
  width: 60px;
  padding: 2px 0;
  cursor: pointer;
  margin: 0 auto;
  font-size: 12px;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 8px;
  background-color: ${(props) => (props.isChecked ? "#FFA4E4" : "white")};
  border: 1px solid #c9c9c9;
  border-radius: 15px;

  &:hover {
    background-color: #ffa4e4;
  }

  input[type="checkbox"] {
    display: none;
  }
`;

forRoutine값은 OtherFilter라는 컴포넌트로 나누어져 있고, 따로 체크박스 명을 Data.js라는 곳에 배열로 작성해놓고 import로 가져와 적용했다. 그리고 특정 체크박스를 클릭할 시 배열에 이미 있으면(체크가 되어있을 시) 해당 배열에서 제거하고 없으면 추가한다. 해당 값을 상위 컴포넌트에서 보낸 props에 배열값을 전달해준다.

그리고 회원가입과 같이 입력값이 전부 입력되야 버튼이 활성화 될 수 있게 disabled를 넣었다.

 

import styled from "styled-components";
import PageCount from "../common/pageCount";
import CommonInput from "../common/commonInput";
import { useEffect, useState } from "react";
import NextBtn from "../signup/nextBtn";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../redux/store";
import { setRoutineItem } from "../../redux/slice/addRoutineSlice";
import axios from "axios";

interface NextProps {
  onNext: () => void;
}

const AddRoutine2: React.FC<NextProps> = ({ onNext }) => {
  const dispatch = useDispatch();
  const grade = useSelector((state: RootState) => state.addRoutine.grade);
  const routineItems = useSelector(
    (state: RootState) => state.addRoutine.routineItem
  );
  const [allRoutineItems, setAllRoutineItems] = useState(
    new Array(grade).fill({ order: "", description: "", itemName: "" })
  );
  const [searchResults, setSearchResults] = useState<string[]>([]);
  const [totalPrice, setTotalPrice] = useState<number>(0);
  const backPort = process.env.REACT_APP_BACKEND_PORT;

  useEffect(() => {
    setAllRoutineItems(
      new Array(grade).fill({ order: "", description: "", itemName: "" })
    );
  }, [grade]);

  // useEffect(() => {
  //   const sum = searchResults.reduce(
  //     (acc, product) => acc + parseFloat(product.price || "0"),
  //     0
  //   );
  //   setTotalPrice(sum);
  // }, [searchResults]);

  const handleRoutineItemChange = (
    index: number,
    key: keyof (typeof routineItems)[0],
    value: string
  ) => {
    const updatedItems = [...allRoutineItems];
    updatedItems[index] = { ...updatedItems[index], [key]: value };
    setAllRoutineItems(updatedItems);
    dispatch(setRoutineItem(updatedItems[index]));

    if (key === "itemName" && value.length > 2) {
      searchItemData(value);
    }
  };

  const searchItemData = async (query: string) => {
    try {
      const response = await axios.get(`${backPort}/api/item/${query}`);
      console.log(response.data);

      setSearchResults(response.data.item || []);
    } catch (error) {
      console.error("제품 검색 중 오류 발생", error);
    }
  };

  const handleProductSelect = (
    index: number,
    productName: string,
    productPrice: string
  ) => {
    const updatedItems = [...allRoutineItems];
    updatedItems[index] = { ...updatedItems[index], itemName: productName };
    setAllRoutineItems(updatedItems);
    dispatch(setRoutineItem(updatedItems[index]));
    setSearchResults([]);
  };

  const isButtonDisabled = allRoutineItems.every(
    (item) =>
      item.order.trim() !== "" &&
      item.description.trim() !== "" &&
      item.itemName.trim() !== ""
  );

  return (
    <>
      <AddRoutine2Wrapper>
        <PageCount count="2" />
        {allRoutineItems.map((item, index) => (
          <RoutineGradeWrapper key={index}>
            <RoutineGradeTitle>
              <span>{index + 1}단계: </span>
              <CommonInput
                typeValue="text"
                placeholderValue="예) 세안"
                value={item.order}
                onChange={(e) =>
                  handleRoutineItemChange(index, "order", e.target.value)
                }
              />
            </RoutineGradeTitle>
            <CommonInput
              typeValue="text"
              placeholderValue="설명"
              value={item.description}
              onChange={(e) =>
                handleRoutineItemChange(index, "description", e.target.value)
              }
            />
            <ItemSearchWrapper>
              <CommonInput
                typeValue="text"
                placeholderValue="제품명"
                value={item.itemName}
                onChange={(e) =>
                  handleRoutineItemChange(index, "itemName", e.target.value)
                }
              />
              {searchResults.length > 0 && (
                <SearchResults>
                  {searchResults.map((product, productIndex) => (
                    <ProductItem
                      key={productIndex}
                      // onClick={
                      //   () => handleProductSelect(index, product.name, product.price);
                      // }
                    >
                      {/* <span>{product.name}</span>
                      <span>₩ {product.price}</span> */}
                    </ProductItem>
                  ))}
                </SearchResults>
              )}
            </ItemSearchWrapper>
          </RoutineGradeWrapper>
        ))}
      </AddRoutine2Wrapper>
      <RoutinePriceWrapper>
        <div>
          <span>
            종합 <span>₩ {totalPrice.toLocaleString()}</span>
          </span>
          <NextBtn onClick={onNext} disabled={!isButtonDisabled} />
        </div>
      </RoutinePriceWrapper>
    </>
  );
};
export default AddRoutine2;

const AddRoutine2Wrapper = styled.div`
  width: 70%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  padding-bottom: 150px;
`;

const RoutineGradeWrapper = styled.div`
  width: 100%;
  margin: 0 auto;
`;

const RoutineGradeTitle = styled.div`
  display: flex;
  align-items: center;
  margin-bottom: 20px;

  span {
    width: 30%;
  }

  input {
    margin: 0;
  }
`;

const RoutinePriceWrapper = styled.div`
  width: 30%;
  min-width: 430px;
  position: fixed;
  bottom: 0;
  background-color: white;
  z-index: 1000;

  div {
    width: 70%;
    margin: 30px auto;
    display: flex;
    flex-direction: column;

    span {
      font-size: 14px;

      span {
        font-size: 20px;
        font-weight: bold;
      }
    }
  }
`;

const ItemSearchWrapper = styled.div`
  width: 100%;
  position: relative;
`;

const SearchResults = styled.div``;
const ProductItem = styled.div``;

2페이지는 1페이지에서 입력한 단계 개수에 따라 루틴 입력 칸이 생성되도록 했다. 그리고 해당 루틴 칸마다 루틴의 주제, 설명, 루틴에 사용하는 제품명, 총 3개의 입력값을 객체로 받을 수 있게 리덕스에서 미리 묶어놨다.

모션비트를 만들 때도 좀 헷갈렸는데 여기서도 아직 해당 객체의 인덱스에 데이터를 담는게 익숙하지 않아 지피티에 많이 의존했다.

루틴 단계의 값에 따라 루틴박스가 생성되었을 때 해당 박스마다 index를 적용해서 입력값의 연결을 키값으로 하여 데이터가 들어가게 된다.

그리고 제품명을 입력하면 DB에서 해당 제품을 찾아 데이터를 화면에 출력해주는데 아직 제품의 데이터가 어떻게 전달될지 정해지지 않았지만 데이터를 받는 부분만 미리 구현해놨다. 이러면 입력값이 작성될 때마다 값을 업데이트하면서 해당 값에 맞는 제품들을 보여주게 된다.

그리고 해당 제품의 데이터에 있는 가격을 받아 합쳐 총액을 입력한다.

 

import styled from "styled-components";
import PageCount from "../common/pageCount";
import PageGuide from "../common/pageGuide";
import CommonTextarea from "../common/commonTextarea";
import { useState } from "react";
import CompleteBtn from "../common/completeBtn";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../redux/store";
import {
  setTitle,
  setForRoutine,
  setGrade,
  setRoutineItem,
  setTag,
} from "../../redux/slice/addRoutineSlice";
import axios from "axios";

const AddRoutine3: React.FC = () => {
  const dispatch = useDispatch();
  const title = useSelector((state: RootState) => state.addRoutine.title);
  const forRoutine = useSelector(
    (state: RootState) => state.addRoutine.forRoutine
  );
  const grade = useSelector((state: RootState) => state.addRoutine.grade);
  const routineItem = useSelector(
    (state: RootState) => state.addRoutine.routineItem
  );
  const tag = useSelector((state: RootState) => state.addRoutine.tag);

  const tagChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const value = e.target.value;
    const tagArr = value.split(/[\s,]+/).filter((tag) => tag.trim() !== "");
    dispatch(setTag(tagArr));
  };
  const [isSubmit, setIsSubmit] = useState(false);
  const backPort = process.env.REACT_APP_BACKEND_PORT;

  const handleSubmit = async () => {
    setIsSubmit(true);
    try {
      const response = await axios.post(`${backPort}/api/routine`, {
        main: {
          title: title,
          forRoutine: forRoutine,
          grade: grade,
        },
        routineItem: routineItem,
        tag: tag,
      });
      console.log("응답:", response.data);
    } catch (error) {
      console.error("제출 중 오류 발생", error);
    } finally {
      setIsSubmit(false);
    }
  };

  const isButtonDisabled = () => {
    return !tag || tag.length === 0;
  };

  return (
    <>
      <AddRoutine3Wrapper>
        <PageCount count="3" />
        <PageGuide text="태그를 등록해주세요" />
        <CommonTextarea value={tag.join(", ")} onChange={tagChange} />
        <span>스페이스 또는 쉼표(,)로 구분해주세요</span>
        <CompleteBtn
          text="등록"
          onClick={handleSubmit}
          disabled={isButtonDisabled() || isSubmit}
        />
      </AddRoutine3Wrapper>
    </>
  );
};
export default AddRoutine3;

const AddRoutine3Wrapper = styled.div`
  width: 70%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  padding-bottom: 150px;

  span {
    font-size: 12px;
    color: #454545;
  }
`;

3페이지에서는 태그 값만 있어 오래걸리지 않았다. 여기선 태그 부분에 데이터를 입력하면 쉼표나 공백으로 구분하여 배열에 담기게 구현했다.

그리고 마지막 페이지기 때문에 리덕스에 있는 모든 데이터를 받아와 해당 데이터를 서버가 요청한대로 객체에 담아 전송하게 된다.

여기서 버튼의 활성화 상태는 태그 부분이 필수인지 아닌지 정하지 않아 일단 필수인 걸로 코드를 짰다 .그리고 api를 요청할 시 여러 번 요청하지 못하게 응답이 오기전까지 버튼을 비활성화되게 해놨다.

 

처음부터 보내야할 데이터나 받을 데이터들이 확실하게 정해졌으면 하나씩 확실하게 구현할 수 있을 텐데 그 부분이 부족하여 매번 페이지를 다시 수정해야 하는게 좀 빡센 것 같다. 기획이 매우 중요함을 느끼는 날이다...

728x90

'개발 TIL' 카테고리의 다른 글

24.09.13 day66 작업 일지  (0) 2024.09.13
24.09.11 day65 작업 일지  (2) 2024.09.11
24.09.09 day63 작업 일지  (0) 2024.09.09
24.09.06 day62 작업 일지  (2) 2024.09.06
24.09.05 day61 작업 일지  (0) 2024.09.05