TIL #45 React 아웃소싱 프로젝트-3

DO YEON KIM·2024년 6월 20일
1

부트캠프

목록 보기
45/72

하루 하나씩 작성하는 TIL #45


작성된 댓글을 마이페이지에 로드하고, api및 라우터를 설정하였다.


MyPage.jsx

import React, { useState, useEffect, useContext } from 'react';
import SavedShopsList from './SavedShopsList.jsx';
import ReviewsList from './ReviewsList.jsx';
import { getUserInfo } from '../../api/auth';
import { AuthContext } from '../../contexts/AuthContext';

const MyPage = () => {
  const { userInfo } = useContext(AuthContext);
  const [nickname, setNickname] = useState('');
  const [userId, setUserId] = useState('');
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchNickname = async () => {
      try {
        if (userInfo) {
          const userId = userInfo.id;
          const user = await getUserInfo(userId);

          if (user) {
            setNickname(user.nickname);
            setUserId(userId);
          } else {
            console.error('유저 정보를 불러오는데 실패했습니다.');
          }
        }
      } catch (error) {
        console.error('닉네임을 불러오는데에 실패했습니다.', error.message);
      } finally {
        setLoading(false);
      }
    };

    fetchNickname();
  }, [userInfo]);

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <div className="w-full min-h-screen flex flex-col items-center">
      <div className="container bg-[#E5E1EF] p-4 rounded-lg shadow mt-16 mb-20 w-3/4">
        <h1 className="text-3xl font-bold mb-10">💜{nickname || 'Loading...'}님의 페이지💜</h1>
        <div className="my-4">
          <h2 className="text-xl font-semibold mb-4">
            💜{nickname || 'Loading...'}💜님의 찜 목록
          </h2>
          <div className="p-4 bg-gray-100 rounded-lg shadow min-h-40 mb-8">
            <SavedShopsList userId={userId} />
          </div>
        </div>
        <div className="my-4">
          <h2 className="text-xl font-semibold mb-4">
            💜{nickname || 'Loading...'}💜님이 작성한 리뷰
          </h2>
          <div className="p-4 bg-gray-100 rounded-lg shadow min-h-40 mb-4">
            <ReviewsList userId={userId} />
          </div>
        </div>
      </div>
    </div>
  );
};

export default MyPage;

AuthContext.jsx

import React, { createContext, useState, useEffect } from 'react';

export const AuthContext = createContext();

export const AuthProvider = ({ children }) => {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [userInfo, setUserInfo] = useState(null);

  useEffect(() => {
    const storedUserInfo = localStorage.getItem('userInfo');
    if (storedUserInfo) {
      setIsAuthenticated(true);
      setUserInfo(JSON.parse(storedUserInfo));
    }
  }, []);

  const login = (userInfo) => {
    localStorage.setItem('userInfo', JSON.stringify(userInfo));
    setIsAuthenticated(true);
    setUserInfo(userInfo);
  };

  const logout = () => {
    localStorage.removeItem('userInfo');
    setIsAuthenticated(false);
    setUserInfo(null);
  };

  return (
    <AuthContext.Provider value={{ isAuthenticated, userInfo, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

Private.jsx

import React, { useContext, useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../contexts/AuthContext';
import Swal from 'sweetalert2';

const PrivateRoute = ({ children }) => {
  const { isAuthenticated } = useContext(AuthContext);
  const [showAlert, setShowAlert] = useState(false);

  useEffect(() => {
    if (!isAuthenticated) {
      Swal.fire('로그인되지 않은 사용자입니다!', '로그인부터 해주세요', 'warning').then(() => {
        setShowAlert(true);
      });
    }
  }, [isAuthenticated]);

  if (showAlert) {
    return <Navigate to="/login" />;
  }

  return isAuthenticated ? children : null;
};

export default PrivateRoute;

DetailPage.jsx

import React, { useEffect, useState, useContext } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PiHeart, PiHeartFill } from 'react-icons/pi';
import { useLocation, useParams } from 'react-router-dom';
import Swal from 'sweetalert2';
import { addLike, deleteLike, isLikedShop } from '../../api/like';
import { addReview, deleteReview, getShopReviewsByShopId, modifyReview } from '../../api/review';
import mainIcon from '../../assets/mainIcon.png';
import { AuthContext } from '../../contexts/AuthContext';

const DetailPage = () => {
  const [isLiked, setIsLiked] = useState(false);
  const [userId, setUserId] = useState('');
  const [newReview, setNewReview] = useState('');
  const { shopId } = useParams();
  const location = useLocation();
  const queryClient = useQueryClient();
  const { isAuthenticated, userInfo } = useContext(AuthContext);

  const { place_name: shop_name, road_address_name } = location.state;

  useEffect(() => {
    if (userInfo) {
      setUserId(userInfo.id);
    }
  }, [userInfo]);

  useEffect(() => {
    (async () => {
      if (userId && shopId) {
        const likedStatus = await isLikedShop({ userId, shopId });
        setIsLiked(likedStatus);
      }
    })();
  }, [userId, shopId]);

  const addLikeMutation = useMutation({
    mutationFn: (data) => addLike(data),
    onSuccess: () => setIsLiked(true)
  });

  const deleteLikeMutation = useMutation({
    mutationFn: (data) => deleteLike(data),
    onSuccess: () => setIsLiked(false)
  });

  const { data: reviews } = useQuery({
    queryKey: ['reviews', shopId],
    queryFn: () => getShopReviewsByShopId(shopId),
    enabled: !!shopId
  });

  const addReviewMutation = useMutation({
    mutationFn: (data) => addReview(data),
    onSuccess: () => {
      queryClient.invalidateQueries(['reviews', shopId]);
      setNewReview('');
    }
  });

  const updateReviewMutation = useMutation({
    mutationFn: (data) => modifyReview(data),
    onSuccess: () => queryClient.invalidateQueries(['reviews', shopId])
  });

  const deleteReviewMutation = useMutation({
    mutationFn: (data) => deleteReview(data),
    onSuccess: () => queryClient.invalidateQueries(['reviews', shopId])
  });

  const handleLike = async () => {
    if (!isAuthenticated) {
      Swal.fire('Error', '해당 기능은 로그인 후 이용 가능합니다.', 'error');
      return;
    }
    if (isLiked) await deleteLikeMutation.mutateAsync({ userId, shopId });
    else await addLikeMutation.mutateAsync({ userId, shopId, shop_name });
  };

  const handleAddReview = async () => {
    if (!newReview.trim()) {
      Swal.fire('Error', '입력 후 등록해주세요.', 'error');
      return;
    }
    await addReviewMutation.mutateAsync({ userId, shopId, content: newReview });
  };

  const handleUpdateReview = async (reviewId, newContent) => {
    await updateReviewMutation.mutateAsync({ reviewId, content: newContent });
  };

  const handleDeleteReview = async (reviewId) => {
    await deleteReviewMutation.mutateAsync(reviewId);
  };

  return (
    <div className="flex min-h-screen justify-center">
      <main className="w-full max-w-[1320px] p-4">
        <div className="mt-[20px] flex flex-col gap-6 rounded-lg bg-hover p-[48px_36px_30px_36px] shadow-lg md:flex-row">
          <img src={mainIcon} alt="Store" className="h-64 w-full rounded-lg bg-white object-cover shadow-md md:w-1/2" />
          <div className="relative flex w-full flex-col rounded-lg bg-white p-6 shadow-md md:w-1/2">
            <h1 className="text-3xl font-bold">{shop_name}</h1>
            <p className="mt-2 text-gray-700">{road_address_name}</p>
            <button onClick={handleLike} className="absolute bottom-4 right-4 text-3xl text-point">
              {isLiked ? <PiHeartFill /> : <PiHeart />}
            </button>
          </div>
        </div>

        <div className="mt-6 rounded-lg bg-hover p-[48px_36px_30px_36px] shadow-md">
          <section className="rounded-lg bg-white p-4 shadow-md">
            <div className="flex items-center space-x-[22px]">
              <textarea
                placeholder="리뷰를 남겨주세요"
                className="h-[70px] w-full resize-none rounded-lg border p-2 focus:outline-active"
                value={newReview}
                onChange={(e) => setNewReview(e.target.value)}
              />
              <button onClick={handleAddReview} className="h-[35px] w-[76px] rounded-lg bg-point text-white">
                등록
              </button>
            </div>
          </section>
          <section className="mt-6 space-y-[30px]">
            {reviews?.map((review) => (
              <div key={review.id} className="rounded-lg bg-white p-4 shadow-md">
                <div className="flex items-center justify-between">
                  <div>
                    <p className="font-bold">{review.user_name}</p>
                    <p>{review.content}</p>
                  </div>
                  {userId === review.user_id && (
                    <div className="flex space-x-[22px]">
                      <button
                        onClick={() => handleUpdateReview(review.id, prompt('리뷰를 수정하세요:', review.content))}
                        className="h-[35px] w-[76px] rounded-lg bg-point text-white"
                      >
                        수정
                      </button>
                      <button
                        onClick={() => handleDeleteReview(review.id)}
                        className="h-[35px] w-[76px] rounded-lg bg-main text-white"
                      >
                        삭제
                      </button>
                    </div>
                  )}
                </div>
              </div>
            ))}
          </section>
        </div>
      </main>
    </div>
  );
};

export default DetailPage;

like.js

import supabase from '../supabase/supabaseClient';
import Swal from 'sweetalert2';

export const isLikedShop = async ({ userId, shopId }) => {
  try {
    const { data, error } = await supabase
      .from('likes')
      .select('*')
      .eq('user_id', userId)
      .eq('kakao_shop_id', shopId);
    if (error) throw error;
    return data.length ? true : false;
  } catch {
    Swal.fire('Error', '좋아요를 가져오는 중 오류가 발생하였습니다', 'error');
  }
};

export const addLike = async ({ userId, shopId, shop_name }) => {
  try {
    const { error } = await supabase.from('likes').insert({
      user_id: userId,
      kakao_shop_id: shopId,
      shop_name
    });

    if (error) throw error;
    Swal.fire('Success', '좋아요를 눌렀습니다!', 'success');
  } catch (err) {
    Swal.fire('Error', '좋아요 과정 중 오류가 발생하였습니다', 'error');
  }
};

export const deleteLike = async ({ userId, shopId }) => {
  try {
    const { error } = await supabase.from('likes').delete().eq('user_id', userId).eq('kakao_shop_id', shopId);

    if (error) throw error;
    Swal.fire('Success', '좋아요를 취소하였습니다', 'success');
  } catch (err) {
    Swal.fire('Error', '좋아요를 취소중 오류가 발생하였습니다', 'error');
  }
};

export const getUserLikedShops = async (userId) => {
  try {
    const { data, error } = await supabase.from('likes').select('shop_name').eq('user_id', userId);
    if (error) throw error;
    return data;
  } catch (err) {
    Swal.fire('Error', '찜한 상점을 불러오는 중 오류가 발생하였습니다', 'error');
  }
};

review.jsx

import supabase from '../supabase/supabaseClient';
import Swal from 'sweetalert2';

// 리뷰 추가 함수
export const addReview = async ({ userId, shopId, content }) => {
  try {
    const { error } = await supabase.from('reviews').insert({
      user_id: userId,
      shop_id: shopId,
      content
    });

    if (error) throw error;
    Swal.fire('Success', '리뷰가 등록되었습니다', 'success');
  } catch (err) {
    Swal.fire('Error', '리뷰 등록 과정 중 오류가 발생했습니다', 'error');
  }
};

// 리뷰 삭제 함수
export const deleteReview = async (reviewId) => {
  try {
    const result = await Swal.fire({
      title: '정말 삭제하시겠습니까?',
      icon: 'warning',
      showCancelButton: true,
      confirmButtonText: '삭제',
      cancelButtonText: '취소'
    });

    if (result.isConfirmed) {
      const { error } = await supabase.from('reviews').delete().eq('id', reviewId);
      if (error) throw error;
      Swal.fire('Success', '리뷰를 삭제하였습니다', 'success');
    }
  } catch (err) {
    Swal.fire('Error', '리뷰 삭제 중 오류가 발생하였습니다', 'error');
  }
};

// 리뷰 수정 함수
export const modifyReview = async ({ reviewId, content }) => {
  try {
    const { error } = await supabase
      .from('reviews')
      .update({
        content
      })
      .eq('id', reviewId);

    if (error) throw error;
    Swal.fire('Success', '리뷰를 수정하였습니다', 'success');
  } catch (err) {
    Swal.fire('Error', '리뷰 수정 중 오류가 발생하였습니다', 'error');
  }
};

// 특정 가게의 리뷰 가져오기 함수
export const getShopReviewsByShopId = async (shopId) => {
  try {
    const { data, error } = await supabase.from('reviews').select('*').eq('shop_id', shopId);
    if (error) throw error;
    return data;
  } catch (err) {
    Swal.fire('Error', '리뷰를 가져오는 과정 중 오류가 발생했습니다', 'error');
  }
};

// 특정 유저의 리뷰 가져오기 함수
export const getUserReviewsByUserId = async (userId) => {
  try {
    const { data, error } = await supabase.from('reviews').select('*').eq('user_id', userId);
    if (error) throw error;
    return data;
  } catch (err) {
    Swal.fire('Error', '리뷰를 가져오는 과정 중 오류가 발생했습니다', 'error');
  }
};

CommentComponents.jsx

import React, { useContext } from 'react';
import { AuthContext } from '../contexts/AuthContext';

const Comment = ({ comment, onEdit, onDelete }) => {
  const { isAuthenticated, userInfo } = useContext(AuthContext);

  const isAuthor = userInfo && userInfo.id === comment.userId;

  return (
    <div className="comment">
      <p>{comment.text}</p>
      {isAuthenticated && isAuthor && (
        <div>
          <button onClick={() => onEdit(comment.id)}>Edit</button>
          <button onClick={() => onDelete(comment.id)}>Delete</button>
        </div>
      )}
    </div>
  );
};

export default Comment;

PrivateRoute.jsx

import React, { useContext, useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../contexts/AuthContext';
import Swal from 'sweetalert2';

const PrivateRoute = ({ children }) => {
  const { isAuthenticated } = useContext(AuthContext);
  const [showAlert, setShowAlert] = useState(false);

  useEffect(() => {
    if (!isAuthenticated) {
      Swal.fire('로그인되지 않은 사용자입니다!', '로그인부터 해주세요', 'warning').then(() => {
        setShowAlert(true);
      });
    }
  }, [isAuthenticated]);

  if (showAlert) {
    return <Navigate to="/login" />;
  }

  return isAuthenticated ? children : null;
};

export default PrivateRoute;

ReviewList.jsx

import React, { useState, useEffect } from 'react';
import { getUserReviewsByUserId } from '../../api/review';

const ReviewsList = ({ userId }) => {
  const [reviews, setReviews] = useState([]);
  const [loading, setLoading] = useState(true);

  const fetchReviews = async () => {
    try {
      const data = await getUserReviewsByUserId(userId);
      setReviews(data);
    } catch (error) {
      console.error('리뷰를 불러오는 데 실패했습니다:', error.message);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    if (userId) {
      fetchReviews();
    }
  }, [userId]);

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <ul className="list-none">
      {reviews.map((review) => (
        <li key={review.id} className="p-2 bg-white rounded shadow mb-2">
          <p>{review.content}</p>
          <small>{new Date(review.created_at).toLocaleDateString()}</small>
        </li>
      ))}
    </ul>
  );
};

export default ReviewsList;

SavedShopList.jsx

import React, { useState, useEffect } from 'react';
import { getUserLikedShops } from '../../api/like'; // API 함수 import

const SavedShopsList = ({ userId }) => {
  const [shops, setShops] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchSavedShops = async () => {
      try {
        // 기존의 supabase 클라이언트 직접 호출을 API 함수 호출로 변경
        const data = await getUserLikedShops(userId); // API 함수 호출
        setShops(data.map(item => item.shop_name)); // 데이터 처리
      } catch (error) {
        console.error('찜한 상점을 불러오는 데 실패했습니다:', error.message);
      } finally {
        setLoading(false);
      }
    };

    if (userId) {
      fetchSavedShops();
    }
  }, [userId]);

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <ul className="list-none">
      {shops.map((shopName, index) => (
        <li key={index} className="p-2 bg-white rounded shadow mb-2"> 
          {shopName}
        </li>
      ))}
    </ul>
  );
};

export default SavedShopsList;

QueryProvider.jsx

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';

const queryClient = new QueryClient();

function QueryProvider({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools />
    </QueryClientProvider>
  );
}

export default QueryProvider;

validateForm.jsx

import Swal from 'sweetalert2';

export const validateForm = ({ email, password, nickname = null }) => {
  if (nickname !== null && nickname.trim() === '') {
    Swal.fire({
      title: '오류',
      text: '닉네임을 입력하세요.',
      icon: 'error',
      confirmButtonText: '확인'
    });
    return false;
  }
  if (!email || !password) {
    Swal.fire({
      title: '오류',
      text: '이메일과 비밀번호를 입력하세요.',
      icon: 'error',
      confirmButtonText: '확인'
    });
    return false;
  }
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    Swal.fire({
      title: '오류',
      text: '올바른 이메일 형식이 아닙니다.',
      icon: 'error',
      confirmButtonText: '확인'
    });
    return false;
  }
  if (password.length < 6) {
    Swal.fire({
      title: '오류',
      text: '비밀번호는 6자리 이상이어야 합니다.',
      icon: 'error',
      confirmButtonText: '확인'
    });
    return false;
  }

  return true;
};

완성 화면


로그인된 정보가 없는 상태에서 마이페이지 접근 시 로그인 페이지로 네비게이트

profile
프론트엔드 개발자를 향해서

0개의 댓글