NEXT.JS/Cock! 칵테일 프로젝트

[NEXT] 6. Redux-Toolkit으로 개별 상세 데이터 불러오기 / 검색 기능 구현 / 메인페이지 디자인 변경

예글 2024. 3. 11. 18:51

✅ 저번에는 전체 칵테일에 대한 api를 불러왔었다면 이번에는 개별 상세 데이터 불러오기와 검색 기능을 구현하였다!

✅ 그 과정에서 원래 구현하려고 했던 추천 칵테일, TOP10 칵테일 api는 유로라는 것을 발견하였고.. 어찌해야할까 고민하다가 결국 약간 디자인을 변경하기로 결정했다.

 

1️⃣ 변경된 메인페이지 디자인

이 디자인을 참고하였다

 

메인페이지에 넣으려고 했었던 추천 레시피, TOP10 레시피가 빠지니까 너무 휑해보이고 그렇다고 레시피 페이지에 있는 전체 리스트를 또 메인페이지에 보여주는 건 아닌 것 같고,,,

고민하다가 조금 감각적(?)으로 가는 것도 좋을 것 같아서 이런 식으로 변경하기로 결정했다!

 

 

저 디자인에 비하면 조악하지만,, 나름 애니메이션도 들어있다 하하

최종디자인은 아니라 조금씩 고쳐나가면 그래도 좀 괜찮지 않을까.. 라는 희망을 품으며,,

 

2️⃣ 개별 상세 데이터 불러오기

1. thunk 만들기

export const fetchSingleCocktails = createAsyncThunk(
  "cocktails/fetchSingleCocktails",
  async ({ id }: Cocktail) => {
    const res = await fetch(
      `https://www.thecocktaildb.com/api/json/v1/1/lookup.php?i=${id}`
    );
    return await res.json();
  }
);

 

2. 리듀서 추가

const cocktailSlice = createSlice({
  // 리듀서 이름
  name: "cocktails",
  // 초기 상태
  initialState: {
    loading: false,
    cocktails: [],
    error: null,
    cocktail: [],
  } as CocktailsState,
  reducers: {},
  // 추가 리듀서
  extraReducers: (builder: ActionReducerMapBuilder<CocktailsState>) => {
    builder
 	  ...

      // 개별 칵테일
      .addCase(fetchSingleCocktails.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchSingleCocktails.fulfilled, (state, action) => {
        state.loading = false;
        state.cocktail = action.payload.drinks;
      })
      .addCase(fetchSingleCocktails.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      })

     ...
  },
});

 

3. 상세페이지 파일에 붙이기

"use client";

const RecipeDetailPage = () => {
  const [modifiedCocktail, setModifiedCocktail] = useState<{
    name: string;
    img: string;
    info: string;
    category: string;
    glass: string;
    instruction: string;
    ingredients: string[];
  }>({
    name: "",
    img: "",
    info: "",
    category: "",
    glass: "",
    instruction: "",
    ingredients: [],
  });

  const [like, setLike] = useState(false);

  const OnHeartClick = () => {
    setLike(!like);
  };

  const { loading, cocktail } = useSelector((state: any) => ({ ...state.app }));

  const dispatch = useDispatch<AppDispatch>();
  
  const { id } = useParams() as { id: string };

  useEffect(() => {
    dispatch(fetchSingleCocktails({ id }));
  }, [dispatch, id]);

  useEffect(() => {
    if (cocktail.length > 0) {
      const {
        strDrink: name,
        strCategory: category,
        strAlcoholic: info,
        strGlass: glass,
        strDrinkThumb: img,
        strInstructions: instruction,
        strIngredient1,
        strIngredient2,
        strIngredient3,
        strIngredient4,
        strIngredient5,
      } = cocktail[0];
      const ingredients = [
        strIngredient1,
        strIngredient2,
        strIngredient3,
        strIngredient4,
        strIngredient5,
      ];
      const newCocktail = {
        name,
        category,
        info,
        glass,
        img,
        instruction,
        ingredients,
      };
      setModifiedCocktail(newCocktail);
    } else {
      setModifiedCocktail({
        name: "",
        img: "",
        info: "",
        category: "",
        glass: "",
        instruction: "",
        ingredients: [],
      });
    }
  }, [id, cocktail]);

  if (!modifiedCocktail) {
    return <p>칵테일 정보가 없습니다.</p>;
  } else {
    const { name, img, info, category, glass, instruction, ingredients } =
      modifiedCocktail;
    console.log(modifiedCocktail);
    return (
      <>
        {loading ? (
          <Loading />
        ) : (
          <div className={styles.root}>
            <div className={styles.left}>
              <span className={styles.mainImgBox}>
                <img src={img} alt={name} className={styles.mainImg} />
              </span>
              <span className={styles.heartBox}>
                <Image
                  src={like ? heart_fill : heart}
                  alt={like ? "채운 하트" : "빈 하트"}
                  className={styles.heartImg}
                  onClick={OnHeartClick}
                />
              </span>
            </div>

            <div className={styles.right}>
              <div className={styles.nameWrap}>
                <h1>{name}</h1>
                <div className={styles.contentBox}>
                  <p className={styles.subTitle}>카테고리</p>
                  <p className={styles.content}>{category}</p>
                </div>
                <div className={styles.contentBox}>
                  <p className={styles.subTitle}>알코올 여부</p>
                  <p className={styles.content}>{info}</p>
                </div>
                <div className={styles.contentBox}>
                  <p className={styles.subTitle}>칵테일 잔</p>
                  <p className={styles.content}>{glass}</p>
                </div>
              </div>

              <div className={styles.ingredientsWrap}>
                <h2>필요한 재료</h2>
                {ingredients.map((x, i) => {
                  return (
                    <p key={i} className={styles.ingredient}>
                      {x}
                    </p>
                  );
                })}
              </div>
            </div>
          </div>
        )}
      </>
    );
  }
};

export default RecipeDetailPage;
  • useParams를 사용하여 URL 파라미터 가져오기
  • 컴포넌트가 마운트될 때, id가 변경될 때 해당 칵테일 레시피 가져오는 액션 Dispatch
  • Redux의 store에서 해당 액션 처리되어 레시피 가져오짐
  • 가져온 레시피 가공하여 modifiedCocktail에 저장

이 부분에서 타입스크립트 때문에 좀 고생했다.. 역시 타입스크립트는 어려워.. 각잡고 공부를 더 해야겠다.. 이러다 any스크립트 되겠어요...

 

4. 결과 화면

 

데이터는 잘 가져와지고 잘 뿌려지는데... 뭔가... 뭔가.. 디자인적으로 아쉬운 느낌 😭 

회사에서는 디자이너님이 예쁘고 잘 만들어주셔서 그거만 보고 하면 됐었는데 혼자서 디자인 하려니까 너무 막막하다...

footer를 만들어서 조금 채워야 하나 ㅠㅠ

우선 상세페이지는 끝!

 

2️⃣ 검색 기능 구현

1. thunk 만들기

export const fetchSearchCocktails = createAsyncThunk(
  "cocktails/fetchSearchCocktails",
  async ({ searchText }: Cocktail) => {
    const res = await fetch(
      `https://www.thecocktaildb.com/api/json/v1/1/search.php?f=${searchText}`
    );
    return await res.json();
  }
);

 

2. 리듀서 추가

const cocktailSlice = createSlice({
  // 리듀서 이름
  name: "cocktails",
  // 초기 상태
  initialState: {
    loading: false,
    cocktails: [],
    error: null,
    cocktail: [],
  } as CocktailsState,
  reducers: {},
  // 추가 리듀서
  extraReducers: (builder: ActionReducerMapBuilder<CocktailsState>) => {
    builder
      // 칵테일 검색
      .addCase(fetchSearchCocktails.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchSearchCocktails.fulfilled, (state, action) => {
        state.loading = false;
        state.cocktails = action.payload.drinks;
      })
      .addCase(fetchSearchCocktails.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      });
  },
});

 

3. searchBox 컴포넌트 만들기

import { SyntheticEvent, useRef } from "react";
import * as styles from "./index.css";
import { useDispatch } from "react-redux";
import { fetchSearchCocktails } from "@/app/Redux/features/cocktailSlice";
import { AppDispatch } from "@/app/Redux/store";

const SearchBox = () => {
  const searchTerm = useRef<HTMLInputElement>(null);
  const dispatch = useDispatch<AppDispatch>();

  const handleSubmit = (e: SyntheticEvent) => {
    e.preventDefault();
    if (searchTerm.current) {
      const searchText = searchTerm.current.value;
      dispatch(fetchSearchCocktails({ searchText }));
    }
  };

  return (
    <form className={styles.root} onSubmit={handleSubmit}>
      <input
        ref={searchTerm}
        placeholder="칵테일 이름 검색"
        className={styles.searchInput}
      />
      <button type="submit" className={styles.searchBtn}>
        검색
      </button>
    </form>
  );
};

export default SearchBox;
  • e.preventDefault() 안 넣어서 검색은 잘 되는데 input에 텍스트가 안 보이는 현상이 있었다..
  • e.prevenDefault() 넣으니까 해결!!

타입스크립트 때문에 조금 멈칫 멈칫 했으나 예전에 타입스크립트로 쇼핑몰 만드는 강의를 들었어서 나름 순조롭게 지나갔다 휴,,

 

4. 결과 화면

  • 처음엔 다 전체 칵테일 레시피가 다 보인다 총 25개!

  • d를 검색하니 D가 들어간 칵테일 레시피만 뜨고 총 개수도 18개인 것을 확인!

레시피에 관한 건 끝!!

다음엔 게시판,,

crud 잘 할 수 있겠지,,