diff --git a/umc-master/index.html b/umc-master/index.html
index 046e329..98aeab9 100644
--- a/umc-master/index.html
+++ b/umc-master/index.html
@@ -11,6 +11,7 @@
/>
Vite + React + TS
+
diff --git a/umc-master/package.json b/umc-master/package.json
index 5c0fb6a..c9d54a0 100644
--- a/umc-master/package.json
+++ b/umc-master/package.json
@@ -12,8 +12,8 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
- "@tanstack/react-query": "^5.65.0",
- "@tanstack/react-query-devtools": "^5.65.0",
+ "@tanstack/react-query": "^5.66.3",
+ "@tanstack/react-query-devtools": "^5.66.3",
"@types/node": "^22.10.5",
"@types/styled-components": "^5.1.34",
"axios": "^1.7.9",
diff --git a/umc-master/src/App.tsx b/umc-master/src/App.tsx
index 5874d65..571df18 100644
--- a/umc-master/src/App.tsx
+++ b/umc-master/src/App.tsx
@@ -2,13 +2,18 @@ import { ThemeProvider } from 'styled-components';
import GlobalStyle from '@styles/globalStyle.ts';
import theme from '@styles/theme.ts';
import Router from './router/routes.tsx';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+const queryClient = new QueryClient();
function App() {
return (
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/umc-master/src/apis/authApi.ts b/umc-master/src/apis/authApi.ts
new file mode 100644
index 0000000..55d9c5c
--- /dev/null
+++ b/umc-master/src/apis/authApi.ts
@@ -0,0 +1,38 @@
+// import axiosInstance from '@apis/axios-instance';
+
+// interface UserSignup {
+// email: string;
+// password: string;
+// nickname: string;
+// hashtags: string[];
+// }
+
+// export const postSignup = async ({ email, password, nickname, hashtags } : UserSignup) => {
+// const { data } = await axiosInstance.post(`/signup`, {
+// email,
+// password,
+// nickname,
+// hashtags,
+// });
+// return data;
+// };
+
+import axios from 'axios';
+
+export const postSignup = async () => {
+ try {
+ const response = await axios.post('https://api.hmaster.shop/api/v1/signup', {
+ email: 'ekos555@naver.com',
+ password: 'asfa1234!@',
+ nickname: 'rael',
+ hashtags: ['봄', '패션', '청소', '요리', '재활용', '주택']
+ }, {
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+ console.log('회원가입 성공:', response.data);
+ } catch (error) {
+ console.error('회원가입 오류:', error);
+ }
+};
diff --git a/umc-master/src/apis/axios-instance.ts b/umc-master/src/apis/axios-instance.ts
index bd20a2f..6afb7ac 100644
--- a/umc-master/src/apis/axios-instance.ts
+++ b/umc-master/src/apis/axios-instance.ts
@@ -70,7 +70,9 @@ axiosInstance.interceptors.response.use(
useTokenStore.getState().clearTokens();
// 로그인 페이지로 리다이렉트
- window.location.href = RoutePaths.LOGIN;
+ if (window.location.pathname !== RoutePaths.LOGIN) {
+ window.location.href = RoutePaths.LOGIN;
+ }
return Promise.reject(refreshError);
}
diff --git a/umc-master/src/apis/commentApi.ts b/umc-master/src/apis/commentApi.ts
new file mode 100644
index 0000000..4de38d8
--- /dev/null
+++ b/umc-master/src/apis/commentApi.ts
@@ -0,0 +1,46 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import axiosInstance from './axios-instance';
+
+export const getComments = async (tipId: number) => {
+ try {
+ const { data } = await axiosInstance.get('/comments');
+ return data.result.filter((comment: any) => comment.tips_id === tipId);
+ } catch (error: any) {
+ console.error('댓글 불러오기 실패:', error);
+ throw new Error('댓글을 불러오는 데 실패했습니다.');
+ }
+};
+
+export const addComment = async (tipId: string, comment: string) => {
+ try {
+ const { data } = await axiosInstance.post(`/tips/${tipId}/comments`, {
+ comment,
+ });
+ return data;
+ } catch (error: any) {
+ console.error('댓글 추가 실패:', error);
+ throw new Error('댓글 추가에 실패했습니다.');
+ }
+};
+
+export const editComment = async (tipId: string, commentId: string, newComment: string) => {
+ try {
+ const { data } = await axiosInstance.put(`/tips/${tipId}/comments/${commentId}`, {
+ comment: newComment,
+ });
+ return data;
+ } catch (error: any) {
+ console.error('댓글 수정 실패:', error);
+ throw new Error('댓글 수정에 실패했습니다.');
+ }
+};
+
+export const deleteComment = async (tipId: string, commentId: string) => {
+ try {
+ const { data } = await axiosInstance.delete(`/tips/${tipId}/comments/${commentId}`);
+ return data;
+ } catch (error: any) {
+ console.error('댓글 삭제 실패:', error);
+ throw new Error('댓글 삭제에 실패했습니다.');
+ }
+};
diff --git a/umc-master/src/apis/queries/useCommentMutations.ts b/umc-master/src/apis/queries/useCommentMutations.ts
new file mode 100644
index 0000000..e123d98
--- /dev/null
+++ b/umc-master/src/apis/queries/useCommentMutations.ts
@@ -0,0 +1,59 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getComments, addComment, editComment, deleteComment } from '@apis/commentApi';
+import { useUserStore } from '@store/userStore';
+export interface Comment {
+ comment_id: number;
+ user: {
+ user_id: number;
+ nickname: string;
+ profileImageUrl?: string | null;
+ };
+ comment: string;
+ created_at: string;
+}
+
+export const useAddComment = (tipId: number) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (comment: string) => addComment(tipId.toString(), comment),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['comments', tipId] });
+ },
+ });
+};
+
+export const useDeleteComment = (tipId: number) => {
+ const queryClient = useQueryClient();
+ const { user } = useUserStore();
+ return useMutation({
+ mutationFn: async (commentId: number) => {
+ const comments: Comment[] = await getComments(tipId);
+ const comment = comments.find((c) => c.comment_id === commentId);
+ if (comment?.user.user_id !== user?.user_id) {
+ throw new Error('본인의 댓글만 삭제할 수 있습니다.');
+ }
+ return deleteComment(tipId.toString(), commentId.toString());
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['comments', tipId] });
+ },
+ });
+};
+
+export const useUpdateComment = (tipId: number) => {
+ const queryClient = useQueryClient();
+ const { user } = useUserStore();
+ return useMutation({
+ mutationFn: async ({ commentId, newComment }: { commentId: number; newComment: string }) => {
+ const comments: Comment[] = await getComments(tipId);
+ const comment = comments.find((c) => c.comment_id === commentId);
+ if (comment?.user.user_id !== user?.user_id) {
+ throw new Error('본인의 댓글만 수정할 수 있습니다.');
+ }
+ return editComment(tipId.toString(), commentId.toString(), newComment);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['comments', tipId] });
+ },
+ });
+};
diff --git a/umc-master/src/apis/queries/useCommentQueries.ts b/umc-master/src/apis/queries/useCommentQueries.ts
new file mode 100644
index 0000000..120b530
--- /dev/null
+++ b/umc-master/src/apis/queries/useCommentQueries.ts
@@ -0,0 +1,9 @@
+import { useQuery } from '@tanstack/react-query';
+import { getComments } from '@apis/commentApi';
+
+export const useComments = (tipId: number) => {
+ return useQuery({
+ queryKey: ['comments', tipId],
+ queryFn: () => getComments(tipId),
+ });
+};
diff --git a/umc-master/src/apis/queries/useSaveTipQueries.ts b/umc-master/src/apis/queries/useSaveTipQueries.ts
new file mode 100644
index 0000000..da538a1
--- /dev/null
+++ b/umc-master/src/apis/queries/useSaveTipQueries.ts
@@ -0,0 +1,13 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { getSavedTips } from "@apis/tipApi";
+
+export const useSaveTipList = () => {
+ return useInfiniteQuery({
+ queryKey: ["savedTips"],
+ queryFn: () => getSavedTips(),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ return lastPage.hasMore ? allPages.length + 1 : undefined;
+ },
+ });
+};
diff --git a/umc-master/src/apis/queries/useTipDetailMutations.ts b/umc-master/src/apis/queries/useTipDetailMutations.ts
new file mode 100644
index 0000000..d7d4230
--- /dev/null
+++ b/umc-master/src/apis/queries/useTipDetailMutations.ts
@@ -0,0 +1,32 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toggleLike, toggleBookmark } from '@apis/tipApi';
+
+export const useToggleLike = (tipId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => toggleLike(tipId),
+ onSuccess: (data) => {
+ console.log(data.message);
+ queryClient.invalidateQueries({ queryKey: ['tipDetail', tipId] });
+ },
+ onError: (error) => {
+ console.error('좋아요 토글 오류:', error);
+ },
+ });
+};
+
+export const useToggleBookmark = (tipId: number) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => toggleBookmark(tipId),
+ onSuccess: (data) => {
+ console.log(data.message);
+ queryClient.invalidateQueries({ queryKey: ['tipDetail', tipId] });
+ },
+ onError: (error) => {
+ console.error('북마크 토글 오류:', error);
+ },
+ });
+};
diff --git a/umc-master/src/apis/queries/useTipDetailQuery.ts b/umc-master/src/apis/queries/useTipDetailQuery.ts
new file mode 100644
index 0000000..66f644f
--- /dev/null
+++ b/umc-master/src/apis/queries/useTipDetailQuery.ts
@@ -0,0 +1,10 @@
+import { getTipDetail } from '@apis/tipApi';
+import { useQuery } from '@tanstack/react-query';
+
+export const useTipDetail = (tipId: number) => {
+ return useQuery({
+ queryKey: ['tipDetail', tipId],
+ queryFn: () => getTipDetail(tipId),
+ enabled: !!tipId, // tipId가 있을 때만 실행
+ });
+};
diff --git a/umc-master/src/apis/tipApi.ts b/umc-master/src/apis/tipApi.ts
index bef2112..41bef19 100644
--- a/umc-master/src/apis/tipApi.ts
+++ b/umc-master/src/apis/tipApi.ts
@@ -14,6 +14,10 @@ export interface NewPost {
imageUrls: File[];
}
+export interface GetSavedParams {
+ page: number;
+}
+
export const getTips = async ({ pageParam, sorted }: GetTipsParams) => {
const { data } = await axiosInstance.get(`/tips/sorted?page=${pageParam}&limit=5&sort=${sorted}`);
return data;
@@ -50,4 +54,28 @@ export const createPost = async (newPost: NewPost): Promise => {
}
};
-// 다른 Tips 관련 API들...
+export const getSavedTips = async () => {
+ try {
+ const { data } = await axiosInstance.get(`/users/saved-tips`);
+ console.log('저장된 꿀팁 API 응답:', data.result);
+ return data.result;
+ } catch (error: any) {
+ console.error('저장된 꿀팁 API 에러 발생:', error.response?.status, error.response?.data);
+ throw new Error(`저장된 꿀팁 API 요청 실패: ${error.response?.status}`);
+ }
+};
+
+export const getTipDetail = async (tipId: number) => {
+ const { data } = await axiosInstance.get(`/tips/${tipId}`);
+ return data.result;
+};
+
+export const toggleLike = async (tipId: number) => {
+ const response = await axiosInstance.post(`/tips/${tipId}/like`);
+ return response.data;
+};
+
+export const toggleBookmark = async (tipId: number) => {
+ const response = await axiosInstance.post(`/tips/${tipId}/bookmark`);
+ return response.data;
+};
diff --git a/umc-master/src/assets/gray-character.png b/umc-master/src/assets/gray-character.png
new file mode 100644
index 0000000..0a67ba9
Binary files /dev/null and b/umc-master/src/assets/gray-character.png differ
diff --git a/umc-master/src/components/Card/Card.tsx b/umc-master/src/components/Card/Card.tsx
index 5bc0e33..f99add1 100644
--- a/umc-master/src/components/Card/Card.tsx
+++ b/umc-master/src/components/Card/Card.tsx
@@ -40,8 +40,7 @@ const CardImageWrapper = styled.div`
`;
const CardImage = styled.img`
- min-width: 240px;
- width: 100%;
+ width: 240px;
height: 200px;
object-fit: cover;
`;
diff --git a/umc-master/src/components/NavigationBar/NavigationBar.tsx b/umc-master/src/components/NavigationBar/NavigationBar.tsx
index ea636f9..736c344 100644
--- a/umc-master/src/components/NavigationBar/NavigationBar.tsx
+++ b/umc-master/src/components/NavigationBar/NavigationBar.tsx
@@ -1,5 +1,5 @@
/* eslint-disable react/prop-types */
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import LogoImage from '@assets/logo.png';
@@ -8,6 +8,9 @@ import Typography from '@components/common/typography';
import AlarmIcon from '@assets/icons/alarm.svg?react';
import AlarmModal from '@components/Modal/alarm';
import ProfileModal from '@components/Modal/profile';
+import { useUserStore } from '@store/userStore';
+import { getUsers } from '@apis/profileApi';
+import gray_character from '@assets/gray-character.png';
interface NavigationBarProps {
login: boolean;
@@ -16,6 +19,12 @@ interface NavigationBarProps {
const NavigationBar: React.FC = ({ login }) => {
const [isAlarmModalOpen, setIsAlarmModalOpen] = useState(false);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
+ const { user, fetchUser } = useUserStore();
+
+ useEffect(() => {
+ fetchUser(); // 컴포넌트 마운트 시 사용자 정보 가져오기
+ }, []);
+ getUsers();
const toggleAlarmModal = () => setIsAlarmModalOpen((prev) => !prev);
const toggleProfileModal = () => setIsProfileModalOpen((prev) => !prev);
@@ -45,7 +54,11 @@ const NavigationBar: React.FC = ({ login }) => {
{login ? (
-
+
) : (
@@ -113,7 +126,7 @@ const UserSection = styled.div`
cursor: pointer;
`;
-const ProfileImg = styled.div`
+const ProfileImg = styled.img`
width: 40px;
height: 40px;
background: #e0e0e0; /** TODO: 프로필 이미지 추가 */
diff --git a/umc-master/src/pages/auth/Login_components/InputForm.tsx b/umc-master/src/pages/auth/Login_components/InputForm.tsx
index 0d6d7f1..2204ddb 100644
--- a/umc-master/src/pages/auth/Login_components/InputForm.tsx
+++ b/umc-master/src/pages/auth/Login_components/InputForm.tsx
@@ -195,6 +195,7 @@ const StyledCheckbox = styled.input`
const StyledTypography = styled(Typography)`
color: ${({ theme }) => theme.colors.text.gray};
+ cursor: pointer;
`;
const Options = styled.div`
diff --git a/umc-master/src/pages/auth/SignUpPage.tsx b/umc-master/src/pages/auth/SignUpPage.tsx
index e650b2e..48f542f 100644
--- a/umc-master/src/pages/auth/SignUpPage.tsx
+++ b/umc-master/src/pages/auth/SignUpPage.tsx
@@ -9,44 +9,72 @@ import PrivacyForm from "./Signup_components/PrivacyForm";
import InterestForm from "./Signup_components/InterestForm";
import Button from "@components/Button/Button";
import { useNavigate } from "react-router-dom";
-
+import { postSignup } from "@apis/authApi";
const SignUpPage: React.FC = () => {
- const theme = useTheme();
- const navigate = useNavigate();
- const [sectionCount, setSectionCount] = useState(0);
- const [isNextButtonEnabled, setIsNextButtonEnabled] = useState(false);
+const theme = useTheme();
+const navigate = useNavigate();
+const [sectionCount, setSectionCount] = useState(0);
+const [isNextButtonEnabled, setIsNextButtonEnabled] = useState(false);
+const [email, setEmail] = useState("");
+const [password, setPassword] = useState("");
+const [nickname, setNickname] = useState("");
+const [hashtags, setHashtag] = useState([]);
+
+useEffect(() => {
+ setIsNextButtonEnabled(false); // 섹션이 변경될 때마다 버튼 비활성화
+}, [sectionCount]);
+
+const handleCheckRequired = (areRequiredChecked: boolean) => {
+ setIsNextButtonEnabled(areRequiredChecked);
+}
+
+const handleEmailChange = (email: string) => {
+ setEmail(email);
+};
+
+const handlePasswordChange = (password: string) => {
+ setPassword(password);
+};
+
+const handleNicknameChange = (nickname: string) => {
+ setNickname(nickname);
+};
- useEffect(() => {
- setIsNextButtonEnabled(false); // 섹션이 변경될 때마다 버튼 비활성화
- }, [sectionCount]);
-
- const handleCheckRequired = (areRequiredChecked: boolean) => {
- setIsNextButtonEnabled(areRequiredChecked);
+const handleHashtagChange = (hashtags: string[]) => {
+ setHashtag(hashtags);
+};
+
+const handleSignUpComplete = async () => {
+ try {
+ const userSignupData = { email, password, nickname, hashtags };
+ console.log("회원가입 확인:", userSignupData)
+ await postSignup();
+ navigate("/main");
+ } catch (error) {
+ console.error("회원가입 오류:", error);
+ alert("회원가입에 실패했습니다. 다시 시도해주세요.");
}
+};
const renderSection = () => {
switch (sectionCount) {
case 0:
return ;
case 1:
- return ;
+ return ;
case 2:
- return ;
+ return ;
case 3:
- return ;
+ return ;
case 4:
- return ;
+ return ;
default:
- return ;
+ return ;
}
};
- const handleSignUpComplete = () => {
- navigate("/main");
- };
-
return (
diff --git a/umc-master/src/pages/auth/Signup_components/EmailForm.tsx b/umc-master/src/pages/auth/Signup_components/EmailForm.tsx
index 113d0b2..647b5c9 100644
--- a/umc-master/src/pages/auth/Signup_components/EmailForm.tsx
+++ b/umc-master/src/pages/auth/Signup_components/EmailForm.tsx
@@ -1,4 +1,6 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react/prop-types */
+import axiosInstance from "@apis/axios-instance";
import Button from "@components/Button/Button";
import Typography from "@components/common/typography";
import Input from "@components/Input/Input";
@@ -6,6 +8,7 @@ import { useEffect, useState } from "react";
import styled, { useTheme } from "styled-components";
const emails = [
+ { value: "", label: "선택" },
{ value: "naver.com", label: "naver.com" },
{ value: "daum.net", label: "daum.net" },
{ value: "gmail.com", label: "gmail.com" },
@@ -14,21 +17,114 @@ const emails = [
{ value: "outlook.com", label: "outlook.com" },
];
-const EmailForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = ({ onCheckRequired }) => {
+const EmailForm: React.FC<{
+ onCheckRequired: (isValid: boolean) => void,
+ onEmailChange: (email: string) => void
+}> = ({ onCheckRequired, onEmailChange }) => {
const theme = useTheme();
- const [email, setEmail] = useState('');
const [authCode, setAuthCode] = useState('');
+ const [localPart, setLocalPart] = useState("");
+ const [domain, setDomain] = useState("");
+ const [emailSent, setEmailSent] = useState(false);
+ const [verified, setVerified] = useState(false);
+ const [timer, setTimer] = useState(180);
+ const [retryEnabled, setRetryEnabled] = useState(false);
+
+ const fullEmail = localPart && domain ? `${localPart}@${domain}` : "";
// 이메일과 인증번호 입력이 모두 채워졌는지 확인하는 useEffect
useEffect(() => {
- if (email && authCode) {
+ if (verified) {
onCheckRequired(true); // 유효성 체크
} else {
onCheckRequired(false); // 유효성 체크
}
- }, [email, authCode, onCheckRequired]);
+ }, [verified, onCheckRequired]);
+
+ useEffect(() => {
+ let timerInterval: NodeJS.Timeout | undefined;
+ if (emailSent && timer > 0) {
+ timerInterval = setInterval(() => {
+ setTimer((prev) => prev - 1);
+ }, 1000);
+ } else if (timer === 0) {
+ setRetryEnabled(true);
+ }
+ return () => {
+ if (timerInterval) {
+ clearInterval(timerInterval);
+ }
+ };
+ }, [emailSent, timer]);
+
+ const handleEmailRequest = async () => {
+ if (!localPart || !domain) {
+ alert("이메일을 입력해주세요.");
+ return;
+ }
+ try {
+ await axiosInstance.post("/auth/send-verification-email", { email: fullEmail });
+ setEmailSent(true);
+ alert("인증 코드가 발송되었습니다.");
+ } catch (error) {
+ alert("이메일 인증 요청에 실패했습니다.");
+ }
+ };
+
+ const handleVerifyCode = async () => {
+ if (!authCode) {
+ alert("인증번호를 입력해주세요.");
+ return;
+ }
+ try {
+ const response = await axiosInstance.post("/auth/verify-email-code", {
+ email: fullEmail,
+ code: authCode,
+ });
+ console.log("서버 응답:", response.data);
+ if (response.data) {
+ alert("이메일 인증이 완료되었습니다.");
+ setVerified(true);
+ } else {
+ alert("인증번호가 올바르지 않습니다.");
+ }
+ } catch (error) {
+ alert("인증번호 확인에 실패했습니다.");
+ }
+ };
+
+ const handleDomainChange = (e: React.ChangeEvent) => {
+ const updatedDomain = e.target.value;
+ setDomain(updatedDomain);
+
+ const updatedFullEmail = `${localPart}@${updatedDomain}`;
+ onEmailChange(updatedFullEmail);
+
+ console.log("도메인 변경:", updatedFullEmail);
+ };
+
+ const handleEmailChange = (e: React.ChangeEvent) => {
+ const email = e.target.value;
+ setLocalPart(email);
+
+ const updatedFullEmail = `${email}@${domain}`; // 최신 도메인 사용
+ onEmailChange(updatedFullEmail);
+
+ console.log("이메일 입력:", updatedFullEmail);
+ };
+
+
+ const handleRetry = () => {
+ setLocalPart("");
+ setDomain("");
+ setAuthCode("");
+ setEmailSent(false);
+ setVerified(false);
+ setTimer(180);
+ setRetryEnabled(false);
+ };
return (
@@ -38,24 +134,30 @@ const EmailForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = ({
>이메일 입력 (필수) *
setEmail(e.target.value)}
+ value={localPart}
+ onChange={handleEmailChange}
+ disabled={emailSent}
/>
@
-
+
{emails.map((email) => (
))}
-
+
void }> = ({
value={authCode}
onChange={(e) => setAuthCode(e.target.value)}
/>
-
+
+ {emailSent && (
+
+ 남은 시간: {Math.floor(timer / 60)}분 {timer % 60}초
+
+
+ )}
);
};
@@ -119,4 +232,15 @@ const EmailSelect = styled.select`
background-color: ${({ theme }) => theme.colors.text.white};
color: ${({ theme }) => theme.colors.text.black};
}
+`
+
+const Timer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 10px;
+ width: 100%; /* 부모 컨테이너에서 중앙 정렬 */
+ align-self: center;
`
\ No newline at end of file
diff --git a/umc-master/src/pages/auth/Signup_components/InterestForm.tsx b/umc-master/src/pages/auth/Signup_components/InterestForm.tsx
index 24c23da..3d69070 100644
--- a/umc-master/src/pages/auth/Signup_components/InterestForm.tsx
+++ b/umc-master/src/pages/auth/Signup_components/InterestForm.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable react/prop-types */
import Typography from "@components/common/typography";
import CategoryInputSection from "@pages/main/components/CategoriesInputSection";
import { useState } from "react";
@@ -16,7 +17,7 @@ const dummyCategories = [
{ section: '주거', tags: ['주택', '원룸', '빌라', '아파트', '기숙사'] },
];
-const InterestForm: React.FC = () => {
+const InterestForm: React.FC<{ onHashtagChange: (hashtags: string[]) => void }> = ({ onHashtagChange }) => {
const [selectedTags, setSelectedTags] = useState([]);
@@ -24,6 +25,8 @@ const InterestForm: React.FC = () => {
const isSelected = selectedTags.includes(tag);
const updatedTags = isSelected ? selectedTags.filter((t) => t !== tag) : [...selectedTags, tag];
setSelectedTags(updatedTags);
+ onHashtagChange(updatedTags); // 상위 컴포넌트로 업데이트
+ console.log("해시태그: ", updatedTags);
};
const theme = useTheme();
diff --git a/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx b/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx
index 3fd0414..0de5716 100644
--- a/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx
+++ b/umc-master/src/pages/auth/Signup_components/PasswordForm.tsx
@@ -5,7 +5,11 @@ import { useEffect, useState } from "react";
import { styled, useTheme } from "styled-components";
-const PasswordForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = ({ onCheckRequired }) => {
+const PasswordForm: React.FC<{
+ onCheckRequired: (isValid: boolean) => void;
+ onPasswordChange: (password: string) => void;
+}> = ({ onCheckRequired, onPasswordChange }) => {
+
const theme = useTheme();
@@ -19,6 +23,13 @@ const PasswordForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> =
onCheckRequired(false);
}
}, [password, confirmPassword]);
+
+ const handlePasswordChange = (e: React.ChangeEvent) => {
+ const newPassword = e.target.value;
+ setPassword(newPassword);
+ onPasswordChange(newPassword); // 상위 컴포넌트에 전달
+ console.log("비밀번호: ", newPassword);
+ };
return (
@@ -31,7 +42,7 @@ const PasswordForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> =
type={'password'}
placeholder={'비밀번호 입력 (숫자, 영문자, 문자 포함 8~15자 이내)'}
value={password}
- onChange={(e) => setPassword(e.target.value)}
+ onChange={handlePasswordChange}
/>
void }> = ({ onCheckRequired }) => {
+const PrivacyForm: React.FC<{
+ onCheckRequired: (isValid: boolean) => void;
+ onNicknameChange: (nickname: string) => void;
+}> = ({ onCheckRequired, onNicknameChange }) => {
const [selectedCity, setSelectedCity] = useState("default");
const [districts, setDistricts] = useState([]);
@@ -22,7 +25,8 @@ const PrivacyForm: React.FC<{ onCheckRequired: (isValid: boolean) => void }> = (
const handleNicknameChange = (e: React.ChangeEvent) => {
const newNickname = e.target.value;
setNickname(newNickname);
-
+ onNicknameChange(newNickname); // 상위 컴포넌트로 업데이트
+ console.log("닉네임: ", newNickname);
// 닉네임이 0글자 이상일 때만 "다음" 버튼을 활성화
onCheckRequired(newNickname.length > 0);
};
diff --git a/umc-master/src/pages/mypage/MyPage.tsx b/umc-master/src/pages/mypage/MyPage.tsx
index 6a9757c..4deb5e6 100644
--- a/umc-master/src/pages/mypage/MyPage.tsx
+++ b/umc-master/src/pages/mypage/MyPage.tsx
@@ -2,7 +2,7 @@ import styled, { useTheme } from "styled-components";
import ProfileSection from "./components/ProfileSection";
import RecentTips from "./components/RecentTips";
import BestInterest from "./components/BestInterest";
-import { dummyData, dummyInterests } from "./dummyData/dummyData";
+import { dummyInterests } from "./dummyData/dummyData";
import Typography from "@components/common/typography";
const MyPage: React.FC = () => {
@@ -17,7 +17,7 @@ const MyPage: React.FC = () => {
>마이페이지
-
+
@@ -40,10 +40,19 @@ const MyPageForm = styled.div`
display: flex;
flex-direction: column;
align-items: center;
- width: 1280px;
+ width: 1440px;
gap: 48px;
- padding-top: 80px;
- padding-bottom: 100px;
+ padding: 80px 0px 100px;
+
+ @media (max-width: 1024px) {
+ gap: 32px;
+ padding: 60px 16px 80px;
+ }
+
+ @media (max-width: 768px) {
+ gap: 24px;
+ padding: 40px 12px 60px;
+ }
`
const ProfileCard = styled.div`
@@ -51,4 +60,15 @@ const ProfileCard = styled.div`
flex-direction: row;
gap: 30px;
flex-shrink: 0;
+ width: 100%;
+
+ @media (max-width: 1024px) {
+ gap: 20px;
+ }
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ }
`
\ No newline at end of file
diff --git a/umc-master/src/pages/mypage/components/ProfileSection.tsx b/umc-master/src/pages/mypage/components/ProfileSection.tsx
index 83c676b..7063d58 100644
--- a/umc-master/src/pages/mypage/components/ProfileSection.tsx
+++ b/umc-master/src/pages/mypage/components/ProfileSection.tsx
@@ -1,20 +1,53 @@
import Typography from '@components/common/typography';
import styled, { useTheme } from 'styled-components';
import CameraImg from '@assets/icons/cameraImg.svg'
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import ProfileEditModal from '../modal/ProfileEditModal';
+import { useUserStore } from '@store/userStore';
+import { getUsers } from '@apis/profileApi';
+import gray_character from '@assets/gray-character.png';
const ProfileSection: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
+ const { user, fetchUser, setProfileImageUrl } = useUserStore();
+ const [profileImageUrl, setProfileImageUrlLocal] = useState(user?.profile_image_url || gray_character);
+
+ useEffect(() => {
+ fetchUser(); // 컴포넌트 마운트 시 사용자 정보 가져오기
+ }, []);
+ getUsers();
const theme = useTheme();
+
+ const handleImageChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const imageUrl = reader.result as string;
+ setProfileImageUrlLocal(imageUrl); // 로컬 상태 업데이트
+ setProfileImageUrl(imageUrl); // 전역 상태 업데이트
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
return (
-
-
+
+
document.getElementById('fileInput')?.click()}
+ />
+
@@ -22,7 +55,7 @@ const ProfileSection: React.FC = () => {
애니
+ >{user?.nickname}
= ({ items }) => {
-
+const RecentTips: React.FC = () => {
const theme = useTheme();
- const navigate = useNavigate(); // 추가
+ const navigate = useNavigate();
+
+ // zustand 상태에서 최근 팁 가져오기
+ const { recentTips } = recentStore();
const handleCardClick = (id: string) => {
navigate(`/save-tip/${id}`); // 상세 페이지로 이동
};
+ useEffect(() => {
+ // 처음 렌더링 시에 최근 본 팁이 로컬스토리지에 있으면 복원됩니다.
+ }, []);
+
return (
- 최근에 본 꿀팁
- {items.length === 0 ? (
+
+ 최근에 본 꿀팁
+
+ {recentTips.length === 0 ? (
최근 본 꿀팁이 없습니다.
) : (
- {items.map((item) => (
- handleCardClick(item.id)}
+ {recentTips.map((item) => (
+ handleCardClick(String(item.tipId))}
/>
))}
-
+
)}
);
@@ -58,16 +51,17 @@ export default RecentTips;
const RecentGoodTip = styled.div`
display: flex;
+ width: 780px;
height: 295px;
flex-direction: column;
align-items: flex-start;
gap: 20px;
flex-shrink: 0;
-`
+`;
const TipCardList = styled.div`
display: flex;
align-items: center;
gap: 30px;
align-self: stretch;
-`
\ No newline at end of file
+`;
diff --git a/umc-master/src/pages/saveTip/DetailPage_componenets/CommentView.tsx b/umc-master/src/pages/saveTip/DetailPage_componenets/CommentView.tsx
deleted file mode 100644
index 6c99704..0000000
--- a/umc-master/src/pages/saveTip/DetailPage_componenets/CommentView.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-/* eslint-disable react/prop-types */
-import Typography from "@components/common/typography";
-import { useCallback, useEffect, useRef, useState } from "react";
-// import { useParams } from "react-router-dom";
-import styled, { useTheme } from "styled-components";
-import { generateComments } from "../dummydata/dummydata";
-import SkeletonComment from "@components/Skeleton/SkeletonComment";
-
-
-const commentCount = 1000; // 실제 데이터에서 가져올 값
-const formattedNumber = new Intl.NumberFormat().format(commentCount);
-
-const MAX_LENGTH = 100;
-const COMMENTS_PER_LOAD = 3;
-
-const CommentText: React.FC<{ text: string }> = ({ text }) => {
- const [isExpanded, setIsExpanded] = useState(false);
- const shouldShowMore = text.length > MAX_LENGTH;
- const theme = useTheme();
-
- return (
-
-
- {isExpanded ? text : `${text.slice(0, MAX_LENGTH)}`}
-
- {shouldShowMore && (
-
- )}
-
- );
- };
-
-const CommentView: React.FC = () => {
-
- const theme = useTheme();
- // const { tipId } = useParams<{ tipId: string }>();
-
- const comment = generateComments(1300);
-
- const [comments, setComments] = useState<{ author: string; date: string; time: string; comment: string }[]>(comment.slice(0, COMMENTS_PER_LOAD * 2));
- const [inputValue, setInputValue] = useState("");
- const [isLoading, setIsLoading] = useState(false); // 로딩 상태 관리
- const [hasMore, setHasMore] = useState(comment.length > COMMENTS_PER_LOAD);
- const observerRef = useRef(null);
- const lastElementRef = useRef(null);
-
- const handleAddComment = () => {
- if (inputValue.trim().length === 0) return;
- setComments((prevComments) => [
- {
- author: "내이름", // 예시로 새 댓글 작성자 지정
- date: "2025.02.02", // 예시로 새 댓글 작성 날짜 지정
- time: "2:43", // 예시로 새 댓글 작성 시간 지정
- comment: inputValue, // 새 댓글 내용
- },
- ...prevComments,
- ]);
- setInputValue("");
- };
-
- const loadMoreData = useCallback(() => {
- if (isLoading || !hasMore) return;
-
- setIsLoading(true);
- setTimeout(() => {
- const nextData = comment.slice(comments.length, comments.length + COMMENTS_PER_LOAD);
- setComments((prevData) => {
- const updatedComments = [...prevData, ...nextData];
- setHasMore(updatedComments.length < comment.length); // 여기서 updatedComments.length 사용!
- return updatedComments;
- });
- setIsLoading(false);
- }, 1000);
- }, [isLoading, hasMore, comments.length]);
-
- useEffect(() => {
- if (isLoading || !hasMore) return;
-
- if (observerRef.current) observerRef.current.disconnect();
-
- observerRef.current = new IntersectionObserver((entries) => {
- if (entries[0].isIntersecting) {
- loadMoreData();
- }
- }, { threshold: 1.0 });
-
- if (lastElementRef.current) observerRef.current.observe(lastElementRef.current);
-
- return () => observerRef.current?.disconnect();
- }, [isLoading, hasMore, loadMoreData]);
-
- return (
-
-
-
- 댓글
- ({formattedNumber})
-
- setInputValue(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
- />
-
- {comments.length === 0 && !isLoading ? (
- 아직 댓글이 없습니다.
- ) : (
-
- {comments.map((cmt, index) => (
-
-
-
-
-
- {cmt.author}
-
-
-
- {cmt.date}
-
-
- {cmt.time}
-
-
-
-
-
-
- ))}
-
- {/* 마지막 요소 감지용 div */}
- {hasMore && !isLoading && }
-
- {/* 스켈레톤 UI */}
- {isLoading && (
-
- {Array.from({ length: COMMENTS_PER_LOAD }).map((_, index) => (
-
- ))}
-
- )}
-
-
- )}
-
- );
-};
-
-export default CommentView;
-
-const Comment = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 60px;
- align-self: stretch;
-`
-
-const CommentAdd = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 30px;
- align-self: stretch;
-`
-
-const Title = styled.div`
- display: flex;
- align-items: center;
- align-self: stretch;
- gap: 10px;
-`
-
-const StyledInput = styled.input`
- display: flex;
- height: 72px;
- padding: 23px 32px;
- align-items: center;
- align-self: stretch;
- border-radius: 20px;
- border: 2px solid ${({ theme }) => theme.colors.primary[400]};
- background: ${({ theme }) => theme.colors.text.white};
-
- color: ${({ theme }) => theme.colors.text.gray};
-
- font-family: ${({ theme }) => theme.fontFamily.regular};
- font-size: ${({ theme }) => theme.typography.body.small.size};
- font-weight: ${({ theme }) => theme.typography.body.small.weight};
- line-height: ${({ theme }) => theme.typography.body.small.lineHeight};
- letter-spacing: -0.48px;
-
- &:focus {
- outline: none;
- border-color: ${({ theme }) => theme.colors.primary[500]};
- }
-`
-
-const CommentList = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 40px;
- align-self: stretch;
-`
-
-const CommentCard = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 32px;
- align-self: stretch;
-`
-
-const Author = styled.div`
- display: flex;
- align-items: center;
- gap: 24px;
-`
-
-const ProfileImg = styled.div`
- width: 60px;
- height: 60px;
- border-radius: 30px;
- background: #D9D9D9;
-`
-
-const AuthorInfo = styled.div`
- display: flex;
- width: 125px;
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
-`
-
-const CommentDate = styled.div`
- display: flex;
- align-items: center;
- gap: 8px;
- align-self: stretch;
-`
-
-const SkeletonWrapper = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 56px;
-`
\ No newline at end of file
diff --git a/umc-master/src/pages/saveTip/DetailPage_componenets/PostDetail.tsx b/umc-master/src/pages/saveTip/DetailPage_componenets/PostDetail.tsx
deleted file mode 100644
index e522288..0000000
--- a/umc-master/src/pages/saveTip/DetailPage_componenets/PostDetail.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import Typography from "@components/common/typography";
-import Tag from "@components/Tag/Tag";
-import { useEffect } from "react";
-import { useParams } from "react-router-dom";
-import styled, { useTheme } from "styled-components";
-import { saveTipDetailPageDataList } from "../dummydata/dummydata";
-
-
-const PostDetail: React.FC = () => {
-
- const { tipId } = useParams<{ tipId: string }>();
-
- useEffect(() => {
- window.scrollTo(0, 0);
- }, []);
-
- // TODO: 추후 API 연동 시, 실제 데이터 불러오도록 수정
- const detail = saveTipDetailPageDataList.find((item) => item.id === tipId);
- console.log(tipId);
-
-
- if (!detail) {
- return 해당 포스트를 찾을 수 없습니다.;
- }
-
- const theme = useTheme();
- return (
-
-
- {detail.title}
-
-
-
-
-
- {detail.author}
-
- BEST 꿀팁 선정 횟수
- {detail.bestnum}회
-
-
-
-
- {detail.tags.map((tag, index) => (
-
- ))}
-
-
-
- {detail.date}
- {detail.time}
-
-
- {detail.description}
-
- );
-};
-
-export default PostDetail;
-
-const PostView = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 32px;
- align-self: stretch;
-`
-
-const Img = styled.div`
- width: 1280px;
- height: 360px;
- border-radius: 20px;
- background: #D9D9D9;
-`
-
-const PostInfo = styled.div`
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- align-self: stretch;
-`
-
-const InfoDetail = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 32px;
-`
-
-const Author = styled.div`
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 19px;
-`
-
-const ProfileImg = styled.div`
- width: 80px;
- height: 80px;
- border-radius: 50px;
- background: #D9D9D9;
-`
-
-const AuthorInfo = styled.div`
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 2px;
-`
-
-const Bestnum = styled.div`
- display: flex;
- align-items: center;
- gap: 4px;
-`
-
-const Tags = styled.div`
- display: flex;
- align-items: center;
- gap: 12px;
-`
-
-const PostDate = styled.div`
- display: flex;
- align-items: center;
- gap: 8px;
-`
\ No newline at end of file
diff --git a/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx b/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx
index df88406..bd694ca 100644
--- a/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx
+++ b/umc-master/src/pages/saveTip/SaveTipDetailPage.tsx
@@ -1,83 +1,33 @@
-import styled from "styled-components";
-import PostDetail from "./DetailPage_componenets/PostDetail";
-import CommentView from "./DetailPage_componenets/CommentView";
-import Likes from "@assets/savetipdetail/Likes.svg";
-import Liked from "@assets/savetipdetail/liked.svg";
-import Saves from "@assets/savetipdetail/saves.svg";
-import Saved from "@assets/savetipdetail/saved.svg";
-import Link from "@assets/savetipdetail/link.svg";
-import Typography from "@components/common/typography";
-import { dummyData } from "./dummydata/dummydata";
-import { useParams } from "react-router-dom";
-import theme from "@styles/theme";
-import { useState } from "react";
+import styled from 'styled-components';
+import { useParams } from 'react-router-dom';
+import PostDetail from './components/PostDetail';
+import CommentView from './components/CommentView';
+import FloatingToggleBtn from './components/FloatingToggleBtn';
+import { useTipDetail } from '@apis/queries/useTipDetailQuery';
const SaveTipDetailPage: React.FC = () => {
-
const { tipId } = useParams<{ tipId: string }>();
+ const { data: detail, isLoading, error } = useTipDetail(Number(tipId));
+ console.log('꿀팁 상세', detail);
- const detail = dummyData.find((item) => item.id === tipId);
-
- if (!detail) {
- return 데이터를 찾을 수 없습니다.
;
- }
-
- const [likes, setLikes] = useState(detail.likes);
- const [liked, setLiked] = useState(false);
-
- const handleLikeClick = () => {
- if (!liked) {
- setLikes(likes + 1); // 좋아요 수 증가
- setLiked(true); // 좋아요 상태로 변경
- } else {
- setLikes(likes - 1); // 좋아요 취소 시 수 감소
- setLiked(false); // 좋아요 취소 상태로 변경
- }
- };
-
- const [saves, setSaves] = useState(detail.bookmarks);
- const [saved, setSaved] = useState(false);
+ if (isLoading) return;
+ if (error) return 데이터를 불러오는 중 오류가 발생했습니다.
;
+ if (!detail) return 데이터를 찾을 수 없습니다.
;
- const handleSaveClick = () => {
- if (!saved) {
- setSaves(saves + 1); // 좋아요 수 증가
- setSaved(true); // 좋아요 상태로 변경
- } else {
- setSaves(saves - 1); // 좋아요 취소 시 수 감소
- setSaved(false); // 좋아요 취소 상태로 변경
- }
- };
-
return (
-
-
-
-
-
-
-
-
- {likes}
-
-
-
- {saves}
-
-
-
- 공유하기
-
-
+
+
+
+
+
+
);
};
@@ -92,45 +42,20 @@ const Container = styled.div`
align-items: center;
padding-top: 80px;
padding-bottom: 100px;
- background: #FFF;
-`
+ background: #fff;
+`;
-const SaveTipDatail = styled.div`
+const Content = styled.div`
display: flex;
- width: 1280px;
+ width: 80vw;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
gap: 60px;
-`
+`;
const Line = styled.div`
- width: 1280px;
+ width: 80vw;
height: 1px;
border: 1px solid ${({ theme }) => theme.colors.primary[800]};
-`
-
-const InteractionButtons = styled.div`
- position: fixed;
- right: 168px;
- top: 610px;
- width: 72px;
- height: 366px;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 36px;
-`
-
-const Interaction = styled.div`
- display: flex;
- width: 72px;
- flex-direction: column;
- align-items: center;
- gap: 7px;
-`
-
-const Img = styled.img`
- object-fit: cover;
- cursor: pointer;
-`
\ No newline at end of file
+`;
diff --git a/umc-master/src/pages/saveTip/SaveTipPage.tsx b/umc-master/src/pages/saveTip/SaveTipPage.tsx
index bf95543..2641d3b 100644
--- a/umc-master/src/pages/saveTip/SaveTipPage.tsx
+++ b/umc-master/src/pages/saveTip/SaveTipPage.tsx
@@ -1,37 +1,32 @@
-import Card from "@components/Card/Card";
-import Typography from "@components/common/typography";
-import styled, { useTheme } from "styled-components";
-import { dummyData as initialData } from "./dummydata/dummydata";
-import { useCallback, useEffect, useRef, useState } from "react";
-import SkeletonCard from "@components/Skeleton/SkeletonCard";
-import { useNavigate } from "react-router-dom";
+import Card from '@components/Card/Card';
+import Typography from '@components/common/typography';
+import styled, { useTheme } from 'styled-components';
+import { useCallback, useEffect, useRef } from 'react';
+import SkeletonCard from '@components/Skeleton/SkeletonCard';
+import { useNavigate } from 'react-router-dom';
+import { recentStore } from '@store/recentStore';
+import { useSaveTipList } from '@apis/queries/useSaveTipQueries';
const PAGE_SIZE = 5;
+const placeholderImg = 'https://via.placeholder.com/150';
const SaveTipPage: React.FC = () => {
-
const theme = useTheme();
-
- const [data, setData] = useState(initialData.slice(0, PAGE_SIZE * 6));
- const [isLoading, setIsLoading] = useState(false);
- const [hasMore, setHasMore] = useState(initialData.length > PAGE_SIZE);
+ const { addRecentTip } = recentStore();
+ const navigate = useNavigate();
const observerRef = useRef(null);
const lastElementRef = useRef(null);
- const loadMoreData = useCallback(() => {
- if (isLoading || !hasMore) return;
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSaveTipList();
- setIsLoading(true);
- setTimeout(() => {
- const nextData = initialData.slice(data.length, data.length + PAGE_SIZE);
- setData((prevData) => [...prevData, ...nextData]);
- setHasMore(data.length + PAGE_SIZE < initialData.length);
- setIsLoading(false);
- }, 1000);
- }, [isLoading, hasMore, data.length]);
+ const loadMoreData = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
useEffect(() => {
- if (isLoading || !hasMore) return;
+ if (!hasNextPage) return;
if (observerRef.current) observerRef.current.disconnect();
@@ -44,46 +39,61 @@ const SaveTipPage: React.FC = () => {
if (lastElementRef.current) observerRef.current.observe(lastElementRef.current);
return () => observerRef.current?.disconnect();
- }, [isLoading, hasMore, loadMoreData]);
+ }, [hasNextPage, loadMoreData]);
- const navigate = useNavigate(); // 추가
+ const handleCardClick = (tipId: number) => {
+ console.log('🖱️ 클릭한 tipId:', tipId);
+ const clickedTip = data?.pages.flatMap((page) => page).find((item) => item.tipId === tipId);
+ console.log('🔍 찾은 팁 데이터:', clickedTip);
- const handleCardClick = (id: string) => {
- navigate(`/save-tip/${id}`); // 상세 페이지로 이동
+ if (clickedTip) {
+ addRecentTip(clickedTip);
+ }
+ navigate(`/save-tip/${tipId}`);
};
+ if (isLoading) {
+ return;
+ }
+
return (
- 저장한 꿀팁
- {data.length === 0 && !isLoading ? (
+
+ 저장한 꿀팁
+
+ {data?.pages.length === 0 && !isFetchingNextPage ? (
최근 본 꿀팁이 없습니다.
) : (
- {data.map((item) => (
- handleCardClick(item.id)}
- />
- ))}
-
+ {data?.pages
+ .flatMap((page) => page)
+ .filter(Boolean)
+ .map((item) => {
+ console.log('🔍 개별 아이템 확인:', item);
+ return (
+ 0
+ ? item.imageUrls[0]?.media_url
+ : placeholderImg
+ }
+ text={item.title}
+ likes={item.likes ?? 0}
+ bookmarks={item.bookmarks ?? 0}
+ date={item.createdAt.slice(0, 10)}
+ onClick={() => handleCardClick(item.tipId)}
+ />
+ );
+ })}
+
{/* 마지막 요소 감지용 div */}
- {hasMore && !isLoading && }
+ {hasNextPage && !isFetchingNextPage && }
{/* 스켈레톤 UI */}
- {isLoading &&
- Array.from({ length: PAGE_SIZE }).map((_, index) => (
-
- ))
- }
+ {isFetchingNextPage &&
+ Array.from({ length: PAGE_SIZE }).map((_, index) => )}
)}
@@ -101,8 +111,8 @@ const Container = styled.div`
align-items: center;
padding-top: 80px;
padding-bottom: 100px;
- background: #FFF;
-`
+ background: #fff;
+`;
const SavedTips = styled.div`
display: flex;
@@ -110,7 +120,7 @@ const SavedTips = styled.div`
flex-direction: column;
align-items: center;
gap: 40px;
-`
+`;
const TipCardList = styled.div`
display: flex;
@@ -119,4 +129,4 @@ const TipCardList = styled.div`
align-self: stretch;
flex-wrap: wrap;
cursor: pointer;
-`
\ No newline at end of file
+`;
diff --git a/umc-master/src/pages/saveTip/components/CommentView.tsx b/umc-master/src/pages/saveTip/components/CommentView.tsx
new file mode 100644
index 0000000..20521d4
--- /dev/null
+++ b/umc-master/src/pages/saveTip/components/CommentView.tsx
@@ -0,0 +1,204 @@
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { useParams } from 'react-router-dom';
+import { useTheme } from 'styled-components';
+import { useComments } from '@apis/queries/useCommentQueries';
+import { Comment, useAddComment, useDeleteComment, useUpdateComment } from '@apis/queries/useCommentMutations';
+import { useUserStore } from '@store/userStore';
+import Typography from '@components/common/typography';
+import SkeletonComment from '@components/Skeleton/SkeletonComment';
+import styled from 'styled-components';
+import ProfileDefault from '@assets/gray-character.png';
+
+const COMMENTS_PER_LOAD = 3;
+
+const CommentView: React.FC = () => {
+ const theme = useTheme();
+ const { tipId } = useParams<{ tipId: string }>();
+ const { user } = useUserStore();
+
+ const { data: comments, isLoading, error } = useComments(Number(tipId));
+ const addCommentMutation = useAddComment(Number(tipId));
+ const deleteCommentMutation = useDeleteComment(Number(tipId));
+ const updateCommentMutation = useUpdateComment(Number(tipId));
+
+ const [inputValue, setInputValue] = useState('');
+ const [visibleComments, setVisibleComments] = useState(COMMENTS_PER_LOAD);
+ const [editMode, setEditMode] = useState<{ [key: number]: boolean }>({});
+ const [editedComment, setEditedComment] = useState<{ [key: number]: string }>({});
+
+ const observerRef = useRef(null);
+ const lastElementRef = useRef(null);
+
+ const handleAddComment = () => {
+ if (inputValue.trim().length === 0) return;
+ addCommentMutation.mutate(inputValue, {
+ onSuccess: () => setInputValue(''),
+ });
+ };
+
+ const handleDeleteComment = (commentId: number) => {
+ deleteCommentMutation.mutate(commentId);
+ };
+
+ const handleEditToggle = (commentId: number, commentText: string) => {
+ setEditMode((prev) => ({ ...prev, [commentId]: true }));
+ setEditedComment((prev) => ({ ...prev, [commentId]: commentText }));
+ };
+
+ const handleEditSubmit = (commentId: number) => {
+ updateCommentMutation.mutate({ commentId, newComment: editedComment[commentId] });
+ setEditMode((prev) => ({ ...prev, [commentId]: false }));
+ };
+
+ const loadMoreData = useCallback(() => {
+ setVisibleComments((prev) => prev + COMMENTS_PER_LOAD);
+ }, []);
+
+ useEffect(() => {
+ if (!comments || comments.length <= visibleComments) return;
+ if (observerRef.current) observerRef.current.disconnect();
+
+ observerRef.current = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting) {
+ loadMoreData();
+ }
+ },
+ { threshold: 1.0 }
+ );
+
+ if (lastElementRef.current) observerRef.current.observe(lastElementRef.current);
+
+ return () => observerRef.current?.disconnect();
+ }, [comments, visibleComments, loadMoreData]);
+
+ if (error) return 댓글을 불러오는 데 실패했습니다.;
+
+ return (
+
+
+ 댓글 ({comments?.length || 0})
+ setInputValue(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAddComment()}
+ />
+
+
+ {isLoading
+ ? Array.from({ length: COMMENTS_PER_LOAD }).map((_, index) => )
+ : comments.slice(0, visibleComments).map((cmt: Comment) => (
+
+
+
+
+ {cmt.user.nickname}
+
+ {new Date(cmt.created_at).toLocaleDateString()}{' '}
+ {new Date(cmt.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+ {user?.user_id === cmt.user.user_id && (
+
+ handleEditToggle(cmt.comment_id, cmt.comment)}>수정
+ handleDeleteComment(cmt.comment_id)}>삭제
+
+ )}
+
+ {editMode[cmt.comment_id] ? (
+ setEditedComment({ ...editedComment, [cmt.comment_id]: e.target.value })}
+ onKeyDown={(e) => e.key === 'Enter' && handleEditSubmit(cmt.comment_id)}
+ />
+ ) : (
+
+ {cmt.comment}
+
+ )}
+
+ ))}
+ {comments?.length > visibleComments && }
+
+
+ );
+};
+
+export default CommentView;
+
+const CommentContainer = styled.div`
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ gap: 60px;
+`;
+
+const CommentAdd = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+`;
+
+const StyledInput = styled.input`
+ padding: 26px 32px;
+ border: 1px solid ${({ theme }) => theme.colors.primary[400]};
+ border-radius: 20px;
+ font-size: 14px;
+`;
+
+const CommentList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+`;
+
+const CommentCard = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding-bottom: 10px;
+`;
+
+const CommentHeader = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 24px;
+`;
+
+const ProfileImg = styled.img`
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+`;
+
+const CommentInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const EditDelete = styled.div`
+ display: flex;
+ gap: 5px;
+ margin-left: auto;
+`;
+
+const EditText = styled.span`
+ cursor: pointer;
+ font-size: 12px;
+`;
+
+const DeleteText = styled.span`
+ cursor: pointer;
+ font-size: 12px;
+`;
+
+const StyledEditInput = styled.input`
+ margin-top: 16px;
+ padding: 10px;
+ width: 100%;
+ border: 1px solid ${({ theme }) => theme.colors.primary[400]};
+ border-radius: 5px;
+ background: #f7f7f7;
+`;
diff --git a/umc-master/src/pages/saveTip/components/FloatingToggleBtn.tsx b/umc-master/src/pages/saveTip/components/FloatingToggleBtn.tsx
new file mode 100644
index 0000000..e554dc0
--- /dev/null
+++ b/umc-master/src/pages/saveTip/components/FloatingToggleBtn.tsx
@@ -0,0 +1,141 @@
+/* eslint-disable react/prop-types */
+import { useEffect, useState } from 'react';
+import styled from 'styled-components';
+import theme from '@styles/theme';
+import Typography from '@components/common/typography';
+import Likes from '@assets/savetipdetail/Likes.svg';
+import Liked from '@assets/savetipdetail/liked.svg';
+import Saves from '@assets/savetipdetail/saves.svg';
+import Saved from '@assets/savetipdetail/saved.svg';
+import Link from '@assets/savetipdetail/link.svg';
+import { useToggleLike, useToggleBookmark } from '@apis/queries/useTipDetailMutations';
+
+interface FloatingToggleBtnProps {
+ tipId: number;
+ initialLikes: number;
+ initialSaves: number;
+ userLiked: boolean;
+ userSaved: boolean;
+}
+
+const FloatingToggleBtn: React.FC = ({
+ tipId,
+ initialLikes,
+ initialSaves,
+ userLiked,
+ userSaved,
+}) => {
+ const [likes, setLikes] = useState(initialLikes);
+ const [liked, setLiked] = useState(userLiked);
+ const [saves, setSaves] = useState(initialSaves);
+ const [saved, setSaved] = useState(userSaved);
+
+ const { mutate: toggleLike } = useToggleLike(tipId);
+ const { mutate: toggleBookmark } = useToggleBookmark(tipId);
+
+ const handleLikeClick = () => {
+ toggleLike();
+ setLikes((prevLikes) => prevLikes + (liked ? -1 : 1));
+ setLiked(!liked);
+ };
+
+ const handleSaveClick = () => {
+ toggleBookmark();
+ setSaves((prevSaves) => prevSaves + (saved ? -1 : 1));
+ setSaved(!saved);
+ };
+
+ const realUrl = 'https://umc-master-frontend.vercel.app'; // 실제 URL을 여기에 설정하세요
+ // const realUrl = window.location.href; // 현재 보고 있는 페이지의 URL
+ const loadKakaoSDK = () => {
+ const script = document.createElement('script');
+ script.src = 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js';
+ script.integrity = import.meta.env.VITE_INTEGRITY_VALUE; // 환경 변수 사용
+ script.crossOrigin = 'anonymous';
+ script.onload = () => {
+ console.log('Kakao SDK 로드 완료');
+ };
+ document.head.appendChild(script);
+ };
+
+ loadKakaoSDK();
+
+ useEffect(() => {
+ if (!window.Kakao) return;
+ if (!window.Kakao.isInitialized()) {
+ window.Kakao.init(`${import.meta.env.VITE_JAVASCRIPT_KEY}`); // 여기에 카카오 앱 키를 넣어주세요
+ }
+ }, []);
+ const shareKakao = () => {
+ if (!window.Kakao) {
+ console.error('Kakao SDK가 로드되지 않았습니다.');
+ return;
+ }
+ window.Kakao.Share.sendDefault({
+ objectType: 'feed',
+ content: {
+ title: '오늘의 꿀팁',
+ description: '오늘의 꿀팁을 보러 갈까요?',
+ imageUrl: 'https://mud-kage.kakao.com/dn/NTmhS/btqfEUdFAUf/FjKzkZsnoeE4o19klTOVI1/openlink_640x640s.jpg',
+ link: {
+ mobileWebUrl: realUrl,
+ },
+ },
+ buttons: [
+ {
+ title: '나도 꿀팁 보러가기',
+ link: {
+ mobileWebUrl: realUrl,
+ },
+ },
+ ],
+ });
+ };
+
+ return (
+
+
+
+
+ {likes}
+
+
+
+
+
+ {saves}
+
+
+
+
+
+ 공유하기
+
+
+
+ );
+};
+
+export default FloatingToggleBtn;
+
+const BtnContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: fixed;
+ right: 2%;
+ bottom: 5%;
+ gap: 26px;
+`;
+
+const InteractionBtn = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 7px;
+`;
+
+const BtnImg = styled.img`
+ object-fit: cover;
+ cursor: pointer;
+`;
diff --git a/umc-master/src/pages/saveTip/components/PostDetail.tsx b/umc-master/src/pages/saveTip/components/PostDetail.tsx
new file mode 100644
index 0000000..c9e1460
--- /dev/null
+++ b/umc-master/src/pages/saveTip/components/PostDetail.tsx
@@ -0,0 +1,166 @@
+/* eslint-disable react/prop-types */
+import { useEffect } from 'react';
+import styled from 'styled-components';
+import theme from '@styles/theme';
+import Typography from '@components/common/typography';
+import Tag from '@components/Tag/Tag';
+import ProfileDefault from '@assets/gray-character.png';
+interface Media {
+ mediaUrl: string;
+ mediaType: string;
+}
+
+interface User {
+ userId: number;
+ nickname: string;
+ profileImageUrl: string | null;
+}
+
+export interface TipItem {
+ tipId: number;
+ title: string;
+ content: string;
+ createdAt: string;
+ media: Media[];
+ hashtags: [];
+ user: User;
+ likesCount: number;
+ savesCount: number;
+ isLiked: boolean;
+ isBookmarked: boolean;
+}
+
+interface PostDetailProps {
+ detail: TipItem;
+}
+
+const PostDetail: React.FC = ({ detail }) => {
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, []);
+
+ if (!detail) {
+ return 해당 포스트를 찾을 수 없습니다.;
+ }
+
+ return (
+
+ {detail.media.length > 0 &&
}
+
+ {detail.title}
+
+
+
+
+
+
+
+ {detail.user.nickname}
+
+
+
+ BEST 꿀팁 선정 횟수
+
+
+ 0 회
+
+
+
+
+
+ {detail.hashtags.map((tag, index) => (
+
+ ))}
+
+
+
+
+ {detail.createdAt.slice(0, 10)}
+
+
+
+
+
+ {detail.content}
+
+
+
+ );
+};
+
+export default PostDetail;
+
+const PostView = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 32px;
+ align-self: stretch;
+`;
+
+const Img = styled.img`
+ width: 80vw;
+ height: 360px;
+ border-radius: 20px;
+ object-fit: cover;
+ background: #d9d9d9;
+`;
+
+const PostInfo = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ align-self: stretch;
+`;
+
+const InfoDetail = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 32px;
+`;
+
+const Author = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 19px;
+`;
+
+const ProfileImg = styled.img`
+ width: 80px;
+ height: 80px;
+ border-radius: 50px;
+ object-fit: cover;
+ background: #d9d9d9;
+`;
+
+const AuthorInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+`;
+
+const Bestnum = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+`;
+
+const Tags = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`;
+
+const PostDate = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+const ContentWrapper = styled.div`
+ white-space: pre-wrap;
+ word-wrap: break-word;
+`;
diff --git a/umc-master/src/store/recentStore.ts b/umc-master/src/store/recentStore.ts
new file mode 100644
index 0000000..1430737
--- /dev/null
+++ b/umc-master/src/store/recentStore.ts
@@ -0,0 +1,70 @@
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+
+interface Hashtag {
+ hashtagId: number;
+ name: string;
+}
+
+interface Image {
+ media_url: string;
+ media_type: string;
+}
+interface Author {
+ userId: number;
+ nickname: string;
+ profileImageUrl: string | null;
+}
+
+interface Tip {
+ tipId: number;
+ title: string;
+ content: string;
+ createdAt: string;
+ updatedAt: string;
+ hashtags: Hashtag[];
+ imageUrls: Image[];
+ likesCount: number;
+ savesCount: number;
+ author: Author;
+}
+
+interface UserState {
+ recentTips: Tip[];
+ addRecentTip: (tip: Tip) => void;
+ clearRecentTips: () => void;
+}
+
+export const recentStore = create()(
+ persist(
+ (set) => ({
+ recentTips: [],
+ addRecentTip: (tip) => {
+ set((state) => {
+ const newTips = [...state.recentTips];
+ const existingTipIndex = newTips.findIndex((t) => t.tipId === tip.tipId);
+
+ if (existingTipIndex !== -1) {
+ // 이미 있는 팁은 최신으로 업데이트
+ newTips[existingTipIndex] = tip;
+ } else {
+ // 새로운 팁은 리스트의 맨 앞에 추가
+ newTips.unshift(tip);
+ }
+
+ // 최대 3개만 저장
+ if (newTips.length > 3) {
+ newTips.pop();
+ }
+
+ return { recentTips: newTips };
+ });
+ },
+ clearRecentTips: () => set({ recentTips: [] }),
+ }),
+ {
+ name: 'recent-tips-storage', // 로컬스토리지 키 이름
+ storage: createJSONStorage(() => localStorage),
+ }
+ )
+);
diff --git a/umc-master/src/store/userStore.ts b/umc-master/src/store/userStore.ts
index 07f3980..069e578 100644
--- a/umc-master/src/store/userStore.ts
+++ b/umc-master/src/store/userStore.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { getUsers } from '@apis/profileApi';
import axiosInstance from '@apis/axios-instance';
+
interface User {
user_id: number;
email: string;
@@ -27,6 +28,8 @@ interface ProfileUpdateData {
interface UserState {
user: User | null;
+ profileImageUrl: string;
+ setProfileImageUrl: (url: string) => void;
fetchUser: () => Promise;
updateProfile: (profileData: ProfileUpdateData) => Promise;
clearUser: () => void;
@@ -64,4 +67,7 @@ export const useUserStore = create((set) => ({
},
clearUser: () => set({ user: null }),
+
+ profileImageUrl: "",
+ setProfileImageUrl: (url) => set({ profileImageUrl: url }),
}));
diff --git a/umc-master/yarn.lock b/umc-master/yarn.lock
index a4868e1..cd60ad8 100644
--- a/umc-master/yarn.lock
+++ b/umc-master/yarn.lock
@@ -705,29 +705,29 @@
dependencies:
"@swc/counter" "^0.1.3"
-"@tanstack/query-core@5.65.0":
- version "5.65.0"
- resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.65.0.tgz#6b7c7087a36867361535b613ff39b633808052fd"
- integrity sha512-Bnnq/1axf00r2grRT6gUyIkZRKzhHs+p4DijrCQ3wMlA3D3TTT71gtaSLtqnzGddj73/7X5JDGyjiSLdjvQN4w==
+"@tanstack/query-core@5.66.3":
+ version "5.66.3"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.66.3.tgz#3ab0daa49477cfae38c45c02b8dc0bc39ec9cc5d"
+ integrity sha512-+2iDxH7UFdtwcry766aJszGmbByQDIzTltJ3oQAZF9bhCxHCIN3yDwHa6qDCZxcpMGvUphCRx/RYJvLbM8mucQ==
"@tanstack/query-devtools@5.65.0":
version "5.65.0"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz#37da5e911543b4f6d98b9a04369eab0de6044ba1"
integrity sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==
-"@tanstack/react-query-devtools@^5.65.0":
- version "5.65.0"
- resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.65.0.tgz#951e8ddbe08b13ba0452c52f3a49af8764b4c2dd"
- integrity sha512-xKoeWpHs6DcPqYmydIl+juWmZ2j4e4DaQEA/ju9PylhLI/X5eV5JG4IsI0ZrjtGwAEb4+lJv3SFwU/m6cmrljA==
+"@tanstack/react-query-devtools@^5.66.3":
+ version "5.66.3"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.66.3.tgz#86ededd9567b92ef71dd060b66580960da88fdb2"
+ integrity sha512-ycICgTVQ2V6EEAXShOei8Ekxf+6IT6EQmwUgzEnJInZRTJZIcokOGB2Shp60Ky7sTAe1oeZD3tuky7gZg0gvyw==
dependencies:
"@tanstack/query-devtools" "5.65.0"
-"@tanstack/react-query@^5.65.0":
- version "5.65.0"
- resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.65.0.tgz#741b124ff78dd7c27cdb80cd3bd13f3972f819c1"
- integrity sha512-qXdHj3SCT2xkFxgrBIe6y9Lkowlwm+tGcV++PBLFtyvEJR5Q+biTnzm5p0tdVwqA603xlju9mtV2Kd/2brobgA==
+"@tanstack/react-query@^5.66.3":
+ version "5.66.3"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.66.3.tgz#287d37e2079291302b86ce2c5898f6a1a4aaeea9"
+ integrity sha512-sWMvxZ5VugPDgD1CzP7f0s9yFvjcXP3FXO5IVV2ndXlYqUCwykU8U69Kk05Qn5UvGRqB/gtj4J7vcTC6vtLHtQ==
dependencies:
- "@tanstack/query-core" "5.65.0"
+ "@tanstack/query-core" "5.66.3"
"@types/cookie@^0.6.0":
version "0.6.0"