-
-
-
리뷰
-
{product.reviewCount}
-
-
-
찜
-
{product.favoriteCount}
-
-
+
+ {/* */}
+
+
+
+
+
+ {product.name}
+
-
+
+
+
+
리뷰
+
{product.reviewCount}
+
+
찜
+
{product.favoriteCount}
+
+
+
+
-
- ))}
-
+
+
+ {/* */}
+
);
};
-export default Product;
+export default Product;
\ No newline at end of file
diff --git a/src/components/home/ProductList.tsx b/src/components/home/ProductList.tsx
new file mode 100644
index 00000000..2b19a006
--- /dev/null
+++ b/src/components/home/ProductList.tsx
@@ -0,0 +1,29 @@
+import { useProductList } from '@/hooks/useProductList';
+import Product from '@/components/home/Product';
+import { Product as ProductType } from '@/types/product';
+import ProductSkeleton from '@/components/home/ProductSkeleton';
+
+interface Props {
+ order: 'reviewCount' | 'rating' | string;
+ onProductClick: (product: ProductType) => void;
+}
+
+const ProductList = ({ order, onProductClick }: Props) => {
+ const { data, isLoading } = useProductList({ order });
+ const products = data?.list ?? [];
+ const skeletonCount = products.length || 6;
+
+ return (
+
+ {isLoading
+ ? // 로딩 중엔 skeletonCount 개수만큼 스켈레톤 직접 렌더
+ Array.from({ length: skeletonCount }).map((_, idx) => )
+ : // 로딩 완료 후 실제 제품 리스트 렌더
+ products.map((product) => (
+ onProductClick(product)} />
+ ))}
+
+ );
+};
+
+export default ProductList;
diff --git a/src/components/home/ProductSkeleton.tsx b/src/components/home/ProductSkeleton.tsx
new file mode 100644
index 00000000..f073518e
--- /dev/null
+++ b/src/components/home/ProductSkeleton.tsx
@@ -0,0 +1,30 @@
+const ProductSkeleton = () => {
+ return (
+
+
+
+
+ );
+};
+
+export default ProductSkeleton;
diff --git a/src/components/home/ReviewerRanking.tsx b/src/components/home/ReviewerRanking.tsx
index 8449a383..a8fdff46 100644
--- a/src/components/home/ReviewerRanking.tsx
+++ b/src/components/home/ReviewerRanking.tsx
@@ -1,5 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { getUserRanking } from '@/api/user';
+import Link from 'next/link';
+import ReviewerRankingSkeleton from '@/components/home/ReviewerRankingSkeleton';
+import Image from 'next/image';
const ReviewerRanking = () => {
const {
@@ -11,41 +14,64 @@ const ReviewerRanking = () => {
queryFn: getUserRanking,
});
- if (isLoading) return
로딩 중...
;
+ if (isLoading) return
;
if (error) return
랭킹 조회 중 오류가 발생했습니다: {error.message}
;
if (!userRanking) return
아직 등록된 리뷰어가 없습니다.
;
+ function formatNumber(count: number): string {
+ if (count >= 1000) {
+ return (count / 1000).toFixed(count >= 10000 ? 0 : 1) + 'K';
+ }
+ return count.toString();
+ }
+
+ const Count = 1200;
+
return (
{userRanking?.map((user, index) => (
-
-
-
-
- {index === 0 && (
-
- 1등
-
- )}
- {index === 1 && (
-
- 2등
-
- )}
- {index === 2 && (
-
- 3등
+
+
+ {user?.image ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {index + 1}등
+
+
+ {user.nickname}
- )}
-
- {user.nickname}
-
-
-
-
팔로워 {user.followersCount}
-
리뷰 {user.reviewCount}
+
+
+
팔로워 {formatNumber(user.followersCount)}
+
리뷰 {formatNumber(user.reviewCount)}
+
-
+
))}
diff --git a/src/components/home/ReviewerRankingSkeleton.tsx b/src/components/home/ReviewerRankingSkeleton.tsx
new file mode 100644
index 00000000..31235df2
--- /dev/null
+++ b/src/components/home/ReviewerRankingSkeleton.tsx
@@ -0,0 +1,23 @@
+const ReviewerRankingSkeleton = () => {
+ return (
+
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]?.map((index) => (
+
+
+
+
+ ))}
+
+ );
+};
+
+export default ReviewerRankingSkeleton;
diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx
index c00d0084..05bd64a5 100644
--- a/src/components/input/input.tsx
+++ b/src/components/input/input.tsx
@@ -1,24 +1,88 @@
import React from 'react';
+import clsx from 'clsx';
+import close from '../../../public/icon/common/close.png';
+import { Product } from '@/types/product';
+import Image from 'next/image';
interface InputProps extends React.InputHTMLAttributes
{
- label: string;
+ label?: string;
error?: string;
+ className?: string;
+ ProductA?: Product;
+ ProductB?: Product;
+ deleteProductA?: (product: Product) => void;
+ deleteProductB?: (product: Product) => void;
}
-const Input = React.forwardRef(({ label, error, ...props }, ref) => {
- return (
-
-
{label}
-
- {error &&
{error}
}
-
- );
-});
+const Input = React.forwardRef(
+ (
+ { label, error, className, ProductA, ProductB, deleteProductA, deleteProductB, ...props },
+ ref,
+ ) => {
+ return (
+
+
{label}
+
+
+
-Input.displayName = 'Input'; // forwardRef
+ {ProductA && (
+
+ {ProductA.name}
+ deleteProductA?.(ProductA)}
+ >
+
+
+
+ )}
+
+ {ProductB && (
+
+ {ProductB.name}
+ deleteProductB?.(ProductB)}
+ >
+
+
+
+ )}
+
+
+ {error &&
{error}
}
+
+ );
+ },
+);
+
+Input.displayName = 'Input';
export default Input;
diff --git a/src/components/input/loginInput.tsx b/src/components/input/loginInput.tsx
index 7bc331b0..f5c13794 100644
--- a/src/components/input/loginInput.tsx
+++ b/src/components/input/loginInput.tsx
@@ -1,24 +1,47 @@
import { UseFormRegister, FieldErrors } from 'react-hook-form';
+import { useState } from 'react';
import Input from './input';
+import { Eye, EyeOff } from 'lucide-react';
+interface SignInForm {
+ email: string;
+ password: string;
+}
+interface SignUpForm {
+ email: string;
+ nickname: string;
+ password: string;
+ passwordConfirmation: string;
+}
+interface NickNameForm {
+ nickname: string;
+}
interface Props {
register: UseFormRegister;
errors: FieldErrors;
type: 'login' | 'signup';
watch?: (field: string) => string | undefined;
+ className?: string;
+}
+interface NickNameInputProps {
+ register: UseFormRegister;
+ errors: FieldErrors;
+ className?: string;
+ disabled?: boolean;
}
-export const EmailInput = ({ register, errors, type }: Props) => {
+export const EmailInput = ({ register, errors, className }: Props) => {
return (
<>
{
);
};
-export const NameInput = ({ register, errors, type }: Props) => {
+export const NickNameInput = ({ register, errors, className }: NickNameInputProps) => {
return (
<>
{
message: '닉네임은 최대 10자까지만 가능합니다.',
},
})}
- error={typeof errors.name?.message === 'string' ? errors.name?.message : undefined}
+ error={typeof errors.nickname?.message === 'string' ? errors.nickname.message : undefined}
+ className={className}
/>
>
);
};
-export const PasswordInput = ({ register, errors, type }: Props) => {
+export const PasswordInput = ({ register, errors, type, className }: Props) => {
+ const [showPassword, setShowPassword] = useState(false);
return (
- <>
+
{
: undefined,
})}
error={typeof errors.password?.message === 'string' ? errors.password?.message : undefined}
+ className={className}
/>
- >
+ setShowPassword((prev) => !prev)}
+ className="absolute right-[20px] top-[50px] text-gray-400"
+ >
+ {showPassword ? : }
+
+
);
};
-export const ConfirmPasswordInput = ({ register, errors, watch }: Props) => (
- value === watch?.('password') || '비밀번호가 일치하지 않습니다.',
- })}
- error={
- typeof errors.confirmPassword?.message === 'string'
- ? errors.confirmPassword?.message
- : undefined
- }
- />
-);
+export const ConfirmPasswordInput = ({ register, errors, watch, className }: Props) => {
+ const [showPassword, setShowPassword] = useState(false);
+ return (
+
+ value === watch?.('password') || '비밀번호가 일치하지 않습니다.',
+ })}
+ error={
+ typeof errors.passwordConfirmation?.message === 'string'
+ ? errors.passwordConfirmation?.message
+ : undefined
+ }
+ className={className}
+ />
+ setShowPassword((prev) => !prev)}
+ className="absolute right-3 top-[45px] -translate-y-1/2 text-gray-400"
+ >
+ {showPassword ? : }
+
+
+ );
+};
diff --git a/src/context/ModalContext.tsx b/src/context/ModalContext.tsx
new file mode 100644
index 00000000..21e858e1
--- /dev/null
+++ b/src/context/ModalContext.tsx
@@ -0,0 +1,43 @@
+import { createContext, useContext, useState, ReactNode } from 'react';
+
+interface ModalContextType {
+ openModal: (content: ReactNode, payloadData?: unknown) => void;
+ closeModal: () => void;
+ payload?: unknown;
+ content: ReactNode | null;
+ isOpen: boolean;
+}
+
+const ModalContext = createContext(undefined);
+
+export const ModalProvider = ({ children }: { children: ReactNode }) => {
+ const [content, setContent] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [payload, setPayload] = useState(undefined);
+
+ const openModal = (content: ReactNode, payloadData?: unknown) => {
+ setContent(content);
+ setIsOpen(true);
+ setPayload(payloadData);
+ };
+
+ const closeModal = () => {
+ setContent(null);
+ setIsOpen(false);
+ setPayload(undefined);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useModal = () => {
+ const context = useContext(ModalContext);
+ if (!context) throw new Error('useModal must be used within ModalProvider');
+ return context;
+};
\ No newline at end of file
diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts
new file mode 100644
index 00000000..d572eeb3
--- /dev/null
+++ b/src/hooks/useDebounce.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react';
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
\ No newline at end of file
diff --git a/src/hooks/useInfiniteList.ts b/src/hooks/useInfiniteList.ts
new file mode 100644
index 00000000..0d2e3ac8
--- /dev/null
+++ b/src/hooks/useInfiniteList.ts
@@ -0,0 +1,22 @@
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { getProductList } from '@/api/products';
+
+export function useInfiniteProductList({
+ order = null,
+ keyword = '',
+ category = null,
+ limit = 20,
+}: {
+ order?: string | null;
+ keyword?: string;
+ category?: number | null;
+ limit?: number;
+}) {
+ return useInfiniteQuery({
+ queryKey: ['infiniteProducts', { keyword, category, order, limit }],
+ queryFn: ({ pageParam = 0 }) => getProductList(keyword, category, order, Number(pageParam)),
+ getNextPageParam: (last) => last.nextCursor ?? undefined,
+ staleTime: 1000 * 60 * 2,
+ initialPageParam: 0,
+ });
+}
diff --git a/src/hooks/useKakaoLogin.ts b/src/hooks/useKakaoLogin.ts
new file mode 100644
index 00000000..229949d9
--- /dev/null
+++ b/src/hooks/useKakaoLogin.ts
@@ -0,0 +1,16 @@
+import { useQuery } from '@tanstack/react-query';
+import { kakaoLogin } from '@/api/auth';
+import { KakaoLoginResponse } from '@/types/auth';
+
+export const useKakaoLogin = (code: string) => {
+ return useQuery({
+ queryKey: ['kakao-login', code],
+ queryFn: async () => {
+ if (!code) throw new Error('code가 없습니다');
+ return await kakaoLogin(code);
+ },
+ enabled: !!code,
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+};
diff --git a/src/hooks/useProductList.ts b/src/hooks/useProductList.ts
new file mode 100644
index 00000000..a15a5db3
--- /dev/null
+++ b/src/hooks/useProductList.ts
@@ -0,0 +1,23 @@
+import { useQuery } from '@tanstack/react-query';
+import { getProductList } from '@/api/products';
+
+export function useProductList({
+ order,
+ keyword = '',
+ category = null,
+ limit = 6,
+}: {
+ order: string;
+ keyword?: string;
+ category?: number | null;
+ limit?: number;
+}) {
+ return useQuery({
+ queryKey: ['products', { keyword, category, order, limit }],
+ queryFn: () => getProductList(keyword, category, order, 0),
+ select: (res) => ({
+ ...res,
+ list: res.list.slice(0, limit),
+ }),
+ });
+}
diff --git a/src/lib/.gitkeep b/src/lib/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/lib/getKakaoAccessToken.ts b/src/lib/getKakaoAccessToken.ts
new file mode 100644
index 00000000..6b8f9e3d
--- /dev/null
+++ b/src/lib/getKakaoAccessToken.ts
@@ -0,0 +1,24 @@
+export const getKakaoAccessToken = async (code: string) => {
+ const params = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY!,
+ redirect_uri: process.env.NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI!,
+ code,
+ });
+
+ const response = await fetch('https://kauth.kakao.com/oauth/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: params.toString(),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ console.error('카카오 access token 발급 실패 응답:', error);
+ throw new Error('카카오 access token 발급 실패');
+ }
+
+ return await response.json(); // access_token, refresh_token 등 포함
+};
diff --git a/src/lib/kakaoAuth.ts b/src/lib/kakaoAuth.ts
new file mode 100644
index 00000000..c5f4cbfb
--- /dev/null
+++ b/src/lib/kakaoAuth.ts
@@ -0,0 +1,11 @@
+export const getKakaoAuthUrl = () => {
+ const clientId = process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY;
+ const redirectUrl = process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI;
+ console.log('✅ clientId:', clientId); // 디버깅용
+ console.log('✅ redirectUrl:', redirectUrl); // 디버깅용
+ if (!clientId || !redirectUrl) {
+ throw new Error('카카오 로그인 환경변수가 설정되지 않았습니다.');
+ }
+
+ return `https://kauth.kakao.com/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUrl}&response_type=code`;
+};
diff --git a/src/lib/kakaoSignupAuth.ts b/src/lib/kakaoSignupAuth.ts
new file mode 100644
index 00000000..1ae5f8c0
--- /dev/null
+++ b/src/lib/kakaoSignupAuth.ts
@@ -0,0 +1,11 @@
+export const getKakaoSignupAuthUrl = () => {
+ const clientId = process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY;
+ const redirectUrl = process.env.NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI;
+ console.log('✅ clientId:', clientId);
+ console.log('✅ redirectUrl:', redirectUrl);
+ if (!clientId || !redirectUrl) {
+ throw new Error('카카오 로그인 환경변수가 설정되지 않았습니다.');
+ }
+
+ return `https://kauth.kakao.com/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUrl}&response_type=code`;
+};
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index b7203fd7..57e6d566 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,122 +1,43 @@
import { NextPage } from 'next';
import React from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import reversedMouse from '@/assets/logo/reversedMouse.svg';
const Error404: NextPage = () => {
return (
-
- {/* 404 텍스트 + 아이콘 */}
-
);
};
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 7639c4b2..50c50537 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -1,23 +1,65 @@
+import { useEffect } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { ModalProvider } from '@/context/ModalContext';
+import ModalRoot from '@/components/ModalRoot';
+import FloatingAddButton from '@/components/AddButton';
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
+import NavBar from '@/components/NavBar';
+import useAuthStore from '@/stores/authStores';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- staleTime: 1000 * 60 * 5, // 5분간은 fresh
- gcTime: 1000 * 60 * 10, // 10분간 캐시에 보관
- refetchOnMount: false, // 마운트 시 재요청 방지
- refetchOnWindowFocus: false, // 포커스 복귀 시 재요청 방지
+ staleTime: 1000 * 60 * 5,
+ gcTime: 1000 * 60 * 10,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
},
},
});
export default function App({ Component, pageProps }: AppProps) {
+ const setIsLoggedIn = useAuthStore((state) => state.setIsLoggedIn);
+ const isLoggedIn = useAuthStore((state) => state.isLoggedIn);
+
+ useEffect(() => {
+ const stored = localStorage.getItem('isLoggedIn');
+ if (stored === 'true') {
+ setIsLoggedIn(true);
+ }
+
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === 'isLoggedIn') {
+ setIsLoggedIn(event.newValue === 'true');
+ }
+ };
+
+ window.addEventListener('storage', handleStorage);
+ return () => {
+ window.removeEventListener('storage', handleStorage);
+ };
+ }, [setIsLoggedIn]);
+
+ const is404Page =
+ Component.name === 'Error404' ||
+ Component.displayName === 'Error404' ||
+ pageProps?.statusCode === 404;
+
return (
-
+
+ {!is404Page && }
+
+
+
+
+
+ {!is404Page && isLoggedIn && }
+
+
+
);
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
index edb85c44..c107e3b3 100644
--- a/src/pages/_document.tsx
+++ b/src/pages/_document.tsx
@@ -6,6 +6,7 @@ export default function Document() {
+
diff --git a/src/pages/compare.tsx b/src/pages/compare.tsx
new file mode 100644
index 00000000..e2873588
--- /dev/null
+++ b/src/pages/compare.tsx
@@ -0,0 +1,264 @@
+import Button from '@/components/button/Button';
+import Input from '@/components/input/input';
+import Image from 'next/image';
+import loadingSmall from '../../public/icon/loading/lodingS.png';
+import CompareTable from '@/components/compare/CompareTable';
+import { useDebounce } from '@/hooks/useDebounce';
+import { useEffect, useRef, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { getProductList } from '@/api/products';
+import { Product } from '@/types/product';
+
+type totlaType = { A: number; B: number };
+
+const Compare = () => {
+ const [productAName, setProductAName] = useState('');
+ const [productA, setProductA] = useState();
+ const [productBName, setProductBName] = useState('');
+ const [productB, setProductB] = useState();
+ const [isProductADropdownOpen, setIsProductADropdownOpen] = useState(false);
+ const [isProductBDropdownOpen, setIsProductBDropdownOpen] = useState(false);
+ const [hasCompare, setHasCompare] = useState(false);
+ const [totals, setTotals] = useState({ A: 0, B: 0 });
+ const debouncedProductASearch = useDebounce(productAName, 300);
+ const debouncedProductBSearch = useDebounce(productBName, 300);
+
+ const productARef = useRef(null);
+ const productADropdownRef = useRef(null);
+ const productBRef = useRef(null);
+ const productBDropdownRef = useRef(null);
+
+ const { data: productAData, isLoading: isProductALoading } = useQuery({
+ queryKey: ['search', debouncedProductASearch],
+ queryFn: () => getProductList(debouncedProductASearch),
+ enabled: !!debouncedProductASearch,
+ staleTime: 0,
+ gcTime: 0,
+ });
+
+ const { data: productBData, isLoading: isProductBLoading } = useQuery({
+ queryKey: ['search', debouncedProductBSearch],
+ queryFn: () => getProductList(debouncedProductBSearch),
+ enabled: !!debouncedProductBSearch,
+ staleTime: 0,
+ gcTime: 0,
+ });
+
+ useEffect(() => {
+ const productA = JSON.parse(localStorage.getItem('productA')!);
+ const productB = JSON.parse(localStorage.getItem('productB')!);
+ if (productA !== null) {
+ setProductA(productA);
+ }
+ if (productB !== null) {
+ setProductB(productB);
+ }
+ }, []);
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ productBDropdownRef.current &&
+ !productBDropdownRef.current.contains(e.target as Node) &&
+ productBRef.current &&
+ !productBRef.current.contains(e.target as Node)
+ ) {
+ setIsProductBDropdownOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ productADropdownRef.current &&
+ !productADropdownRef.current.contains(e.target as Node) &&
+ productARef.current &&
+ !productARef.current.contains(e.target as Node)
+ ) {
+ setIsProductADropdownOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleProductA = (item: Product) => {
+ setProductA(item);
+ localStorage.setItem('productA', JSON.stringify(item));
+ setProductAName('');
+ setHasCompare(false);
+ setIsProductADropdownOpen(false);
+ };
+ const handleProductB = (item: Product) => {
+ setProductB(item);
+ localStorage.setItem('productB', JSON.stringify(item));
+ setProductBName('');
+ setHasCompare(false);
+ setIsProductBDropdownOpen(false);
+ };
+
+ const onTotalsChange = (totals: { A: number; B: number }) => {
+ setTotals(totals);
+ };
+
+ const colorClass =
+ totals.A === totals.B ? 'text-gray-50' : totals.A > totals.B ? 'text-green' : 'text-pink';
+
+ const handleDeleteProductA = () => {
+ setProductA(null);
+ localStorage.removeItem('productA');
+ setHasCompare(false);
+ };
+
+ const handleDeleteProductB = () => {
+ setProductB(null);
+ localStorage.removeItem('productB');
+ setHasCompare(false);
+ };
+
+ return (
+
+
+
+
+
+
{
+ setProductAName(e.target.value);
+ setIsProductADropdownOpen(true);
+ }}
+ onFocus={() => {
+ if (productAName) setIsProductADropdownOpen(true);
+ }}
+ ProductA={productA!}
+ deleteProductA={handleDeleteProductA}
+ placeholder={!productA ? `상품명 (상품 등록 여부를 확인해 주세요)` : ''}
+ />
+ {isProductADropdownOpen && productAData?.list && (
+
+ {isProductALoading ? (
+
검색 중...
+ ) : (
+
+ {productAData?.list?.map((item) => (
+ handleProductA(item)}
+ >
+ {item.name}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+
+
{
+ setProductBName(e.target.value);
+ setIsProductBDropdownOpen(true);
+ }}
+ onFocus={() => {
+ if (productBName) setIsProductBDropdownOpen(true);
+ }}
+ ProductB={productB!}
+ deleteProductB={handleDeleteProductB}
+ placeholder={!productB ? `상품명 (상품 등록 여부를 확인해 주세요)` : ''}
+ />
+ {isProductBDropdownOpen && productBData?.list && (
+
+ {isProductBLoading ? (
+
검색 중...
+ ) : (
+
+ {productBData?.list?.map((item) => (
+ handleProductB(item)}
+ >
+ {item.name}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
setHasCompare(true)}
+ disabled={!(productA && productB)}
+ >
+ 비교하기
+
+
+
+
+ {hasCompare ? (
+ <>
+
+ {totals.A !== totals.B ? (
+
+
+ {totals.A > totals.B ? productA?.name : productB?.name}
+ {' '}
+ 상품이
+
+
+
+ 승리하였습니다.
+
+ ) : (
+
+ 무승부입니다!
+
+ )}
+ {totals.A !== totals.B && (
+
+ 3가지 항목 중 {totals.A > totals.B ? totals.A : totals.B}가지 항목에서
+ 우세합니다.
+
+ )}
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default Compare;
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index f54b3ef6..f082f37c 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -1,52 +1,173 @@
import Category from '@/components/home/Category';
-import Product from '@/components/home/Product';
+import { InfiniteProductList } from '@/components/home/InfiniteProductList';
+import ProductList from '@/components/home/ProductList';
import ReviewerRanking from '@/components/home/ReviewerRanking';
-import { useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { getProductList } from '@/api/products';
+import { JSX, useState } from 'react';
+import categoryIcon from '../../public/icon/common/category.png';
+import Image from 'next/image';
+import CreateProduct from '@/components/ProductForm';
+import { Product } from '@/types/product';
+import { useQueryClient } from '@tanstack/react-query';
+import { getProductById } from '@/api/products';
+import { useModal } from '@/context/ModalContext';
+import { useRouter } from 'next/router';
+import { DropDown, DropDownOption } from '@/components/DropDown';
-const Index = () => {
+const Home = () => {
const [selectedCategory, setSelectedCategory] = useState(null);
+ const [selectedCategoryName, setSelectedCategoryName] = useState(null);
- const keyword = ''; // 빈 문자열
- const category = selectedCategory ?? null; // 선택된 카테고리(숫자) 혹은 ''
- const order: 'recent' = 'recent'; // 고정값 'recent'
- const cursor = null;
+ const router = useRouter();
+ const keyword = (router.query.keyword as string) || '';
- const {
- data: productList,
- isLoading,
- error,
- } = useQuery({
- queryKey: ['productList', keyword, category, order, cursor],
- queryFn: () => getProductList(keyword, category, order, cursor),
- });
+ const [selectedOrder, setSelectedOrder] = useState('recent');
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const { openModal } = useModal();
+ const queryClient = useQueryClient();
+
+ const handleProductClick = async (product: Product) => {
+ const productDetail = await queryClient.fetchQuery({
+ queryKey: ['product', product.id],
+ queryFn: () => getProductById(product.id),
+ });
+
+ openModal( , productDetail);
+ };
+
+ const sort = [
+ { key: '최신순', value: 'recent' },
+ { key: '별점순', value: 'rating' },
+ { key: '리뷰순', value: 'reviewCount' },
+ ];
return (
-
-
-
-
-
-
-
리뷰어 랭킹
-
+
+
+ {isMenuOpen && (
+
setIsMenuOpen(false)}
+ />
+ )}
+
+ setIsMenuOpen(false)}
+ />
+
+
+
+
-
-
+
+ {!keyword && !selectedCategory ? (
+
+
+
+ 지금 핫한 상품
+
+ TOP 6
+
+
+
setIsMenuOpen(true)}
+ >
+
+ {selectedCategoryName ? selectedCategoryName : '카테고리'}
+
+
+
-
+
+
+ ) : (
+
+
+
+
+ {selectedCategory && keyword && (
+
+ {selectedCategoryName} 카테고리의 '{keyword}'로 검색한 상품
+
+ )}
+ {selectedCategory && !keyword && (
+ {selectedCategoryName}의 모든 상품
+ )}
+ {!selectedCategory && keyword && '{keyword}'로 검색한 상품 }
+
+
setIsMenuOpen(true)}
+ >
+
+ {selectedCategoryName ? selectedCategoryName : '카테고리'}
+
+
+
setSelectedOrder(value)}
+ >
+ {sort?.map((data) => (
+ setSelectedOrder(data.value)}
+ >
+ {data.key}
+
+ ))}
+
+
+
+
+ )}
@@ -54,4 +175,4 @@ const Index = () => {
);
};
-export default Index;
+export default Home;
diff --git a/src/pages/kakaologin.tsx b/src/pages/kakaologin.tsx
new file mode 100644
index 00000000..fb1e7b64
--- /dev/null
+++ b/src/pages/kakaologin.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { useRouter } from 'next/router';
+import { useKakaoLogin } from '@/hooks/useKakaoLogin';
+import { KakaosignupButton } from '@/components/button/KakaoSignupButton';
+import useAuthStore from '@/stores/authStores';
+
+const setIsLoggedIn = useAuthStore.getState().setIsLoggedIn;
+
+const KakaoRedirectPage = () => {
+ const router = useRouter();
+ const { code } = router.query;
+
+ const { data, isError, error, isLoading } = useKakaoLogin(code as string);
+
+ const err = error as any;
+
+ if (isLoading) return
로그인 처리 중입니다...
;
+
+ if (
+ isError &&
+ err?.response?.status === 403 &&
+ err?.response?.data?.message === '등록되지 않은 사용자입니다.'
+ ) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (data) {
+ const { accessToken } = data;
+ localStorage.setItem('accessToken', accessToken);
+ setIsLoggedIn(true);
+ router.push('/');
+ }
+
+ return null;
+};
+
+export default KakaoRedirectPage;
diff --git a/src/pages/login.tsx b/src/pages/login.tsx
index 77134a24..0cc15048 100644
--- a/src/pages/login.tsx
+++ b/src/pages/login.tsx
@@ -1,36 +1,66 @@
import { useForm } from 'react-hook-form';
import { EmailInput, PasswordInput } from '@/components/input/loginInput';
+import { KakaoButton } from '@/components/button/KakaoButton';
+import { GoogleLoginButton } from '@/components/button/Google';
+import { Login } from '@/api/auth';
+import { SignInRequest } from '@/types/auth';
+import { useRouter } from 'next/router';
+import { useMutation } from '@tanstack/react-query';
+import useAuthStore from '@/stores/authStores';
+import Button from '@/components/button/Button';
-const SignupPage = () => {
+
+const setIsLoggedIn = useAuthStore.getState().setIsLoggedIn;
+
+const LoginPage = () => {
const {
register,
handleSubmit,
- formState: { errors },
- } = useForm({
+ formState: { errors, isSubmitting },
+ } = useForm
({
mode: 'onBlur',
});
+ const Router = useRouter();
+
+ const { mutate } = useMutation({
+ mutationFn: (formData: SignInRequest) => Login('login', formData),
+ onSuccess: (data: { accessToken: string }) => {
+ localStorage.setItem('accessToken', data.accessToken);
+ setIsLoggedIn(true);
+
+ Router.push('/');
+ },
+ onError: () => {
+ alert('로그인에 실패했습니다. 이메일 또는 비밀번호를 확인해주세요.');
+ },
+ });
- const onSubmit = (data: any) => {
- console.log('회원가입 정보:', data);
+ const onSubmit = (formData: SignInRequest) => {
+ if (isSubmitting) return;
+ mutate(formData);
};
return (
-
-
-
SNS로 바로 시작하기
-
+
);
};
-export default SignupPage;
+export default LoginPage;
diff --git a/src/pages/mypage.tsx b/src/pages/mypage.tsx
new file mode 100644
index 00000000..f64584fb
--- /dev/null
+++ b/src/pages/mypage.tsx
@@ -0,0 +1,216 @@
+import {
+ getMyProfile,
+ getUserCreatedProducts,
+ getUserFavoriteProducts,
+ getUserReviewedProducts,
+} from '@/api/user';
+import Activity from '@/components/Activity';
+import { DropDown, DropDownOption } from '@/components/DropDown';
+import Product from '@/components/Product';
+import Profile from '@/components/Profile';
+import { GetMeResponse } from '@/types/user';
+import { useQuery } from '@tanstack/react-query';
+import { useState } from 'react';
+
+function MyPage() {
+ const [showProductState, setShowProductState] = useState
('1');
+ const { data: profileData } = useQuery({
+ queryKey: ['userProfile'],
+ queryFn: getMyProfile,
+ });
+
+ const { data: reviewedProducts } = useQuery({
+ queryKey: ['reviewedProduct'],
+ queryFn: () => getUserReviewedProducts(String(profileData?.id)),
+ select: (data) => data.list,
+ enabled: Boolean(profileData),
+ });
+
+ const { data: createdProducts } = useQuery({
+ queryKey: ['createdProducts'],
+ queryFn: () => getUserCreatedProducts(String(profileData?.id)),
+ select: (data) => data.list,
+ enabled: Boolean(profileData),
+ });
+
+ const { data: favoriteProducts } = useQuery({
+ queryKey: ['favoriteProducts'],
+ queryFn: () => getUserFavoriteProducts(String(profileData?.id)),
+ select: (data) => data.list,
+ enabled: Boolean(profileData),
+ });
+
+ function handleDropDown(value: string | number | null) {
+ setShowProductState(String(value));
+ }
+
+ function setSpanTextColor(spanNum: string) {
+ return spanNum === showProductState ? 'text-gray-50' : 'text-gray-200';
+ }
+
+ const ReviewedProducts = () => {
+ switch (showProductState) {
+ case '1':
+ return (
+ <>
+ {reviewedProducts ? (
+ reviewedProducts.length === 0 ? (
+
+ 리뷰 남긴 상품이 없습니다.
+
+ ) : (
+ reviewedProducts.map((product) => (
+
+ ))
+ )
+ ) : null}
+ >
+ );
+ case '2':
+ return (
+ <>
+ {createdProducts ? (
+ createdProducts.length === 0 ? (
+
+ 등록한 상품이 없습니다.
+
+ ) : (
+ createdProducts.map((product) => (
+
+ ))
+ )
+ ) : null}
+ >
+ );
+ case '3':
+ return (
+ <>
+ {favoriteProducts ? (
+ favoriteProducts.length === 0 ? (
+
+ 찜한 상품이 없습니다.
+
+ ) : (
+ favoriteProducts.map((product) => (
+
+ ))
+ )
+ ) : null}
+ >
+ );
+ }
+ };
+
+ const ShowActivitys = ({ profileData }: { profileData: GetMeResponse }) => {
+ return (
+
+
+ 남긴
+ 별점 평균
+ >
+ }
+ icon="/icon/common/star.png"
+ dataNumber={profileData.averageRating}
+ />
+
+
+ 관심
+ 카테고리
+ >
+ }
+ category="/chip/category/electronicS.png"
+ />
+
+ );
+ };
+
+ return (
+
+
+ {profileData ?
: null}
+
+
+
+ 활동 내역
+ {profileData ? : null}
+
+
+
+ 리뷰 남긴 상품
+ 등록한 상품
+ 찜한 상품
+
+
+ setShowProductState('1')}
+ >
+ 리뷰 남긴 상품
+
+ setShowProductState('2')}
+ >
+ 등록한 상품
+
+ setShowProductState('3')}
+ >
+ 찜한 상품
+
+
+
+
+
+
+
+
+ );
+}
+
+export default MyPage;
diff --git a/src/pages/oauth/signup/kakao.tsx b/src/pages/oauth/signup/kakao.tsx
new file mode 100644
index 00000000..82f81dd0
--- /dev/null
+++ b/src/pages/oauth/signup/kakao.tsx
@@ -0,0 +1,83 @@
+import { useForm } from 'react-hook-form';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+import { NickNameInput } from '@/components/input/loginInput';
+import Button from '@/components/button/Button';
+import axiosInstance from '@/api/axiosInstance';
+
+interface FormValues {
+ nickname: string;
+}
+
+const KakaoSignupPage = () => {
+ const router = useRouter();
+ const { code } = router.query;
+ const [isLoading, setIsLoading] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ setError,
+ } = useForm();
+
+ const redirectUri = process.env.NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI;
+
+ const onSubmit = async ({ nickname }: FormValues) => {
+ if (!code || typeof code !== 'string') {
+ setError('nickname', {
+ message: '인가 코드가 없습니다. 다시 로그인해주세요.',
+ });
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+
+ await axiosInstance.post(
+ `/auth/signUp/kakao`,
+ {
+ nickname,
+ redirectUri,
+ token: code,
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ router.push('/');
+ } catch (error: any) {
+ const message = error?.response?.data?.message;
+ console.error('회원가입 실패:', message);
+
+ setError('nickname', {
+ message: message?.includes('닉네임')
+ ? message
+ : '회원가입에 실패했습니다. 다시 시도해주세요.',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default KakaoSignupPage;
diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx
index 20b3281c..03425344 100644
--- a/src/pages/signup.tsx
+++ b/src/pages/signup.tsx
@@ -1,38 +1,66 @@
import { useForm } from 'react-hook-form';
+import React from 'react';
+import { SignUpRequest } from '@/types/auth';
import {
EmailInput,
- NameInput,
+ NickNameInput,
PasswordInput,
ConfirmPasswordInput,
} from '@/components/input/loginInput';
+import axiosInstance from '@/api/axiosInstance';
+import router from 'next/router';
+import Button from '@/components/button/Button';
const SignupPage = () => {
const {
register,
handleSubmit,
- formState: { errors },
+ formState: { errors, isSubmitting },
watch,
- } = useForm({
- mode: 'onBlur',
- });
+ setError,
+ } = useForm({ mode: 'onTouched' });
- const onSubmit = (data: any) => {
- console.log('회원가입 정보:', data);
+ const onSubmit = async (data: SignUpRequest) => {
+ try {
+ const response = await axiosInstance.post(`/auth/signup`, data);
+ router.push('/');
+ console.log('회원가입 성공:', response.data);
+ } catch (error: any) {
+ console.error('회원가입 실패:', error.response.data);
+
+ const errorMessage = error.response?.data?.message;
+
+ if (error.response?.status === 400) {
+ if (errorMessage?.includes('이메일')) {
+ setError('email', { message: errorMessage });
+ } else if (errorMessage?.includes('닉네임')) {
+ setError('nickname', { message: errorMessage });
+ } else {
+ setError('root', { message: errorMessage || '입력값을 다시 확인해주세요.' });
+ }
+ } else {
+ setError('root', { message: '회원가입에 실패했습니다. 다시 시도해주세요.' });
+ }
+ }
};
return (
-
+
+
+
);
};
diff --git a/src/pages/user/[userId].tsx b/src/pages/user/[userId].tsx
new file mode 100644
index 00000000..fa7ba166
--- /dev/null
+++ b/src/pages/user/[userId].tsx
@@ -0,0 +1,218 @@
+import {
+ getUserCreatedProducts,
+ getUserFavoriteProducts,
+ getUserProfile,
+ getUserReviewedProducts,
+} from '@/api/user';
+import Activity from '@/components/Activity';
+import { DropDown, DropDownOption } from '@/components/DropDown';
+import Product from '@/components/Product';
+import Profile from '@/components/Profile';
+import { GetMeResponse } from '@/types/user';
+import { useQuery } from '@tanstack/react-query';
+import { useRouter } from 'next/router';
+import { useState } from 'react';
+
+function UserPage() {
+ const router = useRouter();
+ const { userId } = router.query as { userId: string };
+ const [showProductState, setShowProductState] = useState('1');
+ const { data: profileData } = useQuery({
+ queryKey: ['userProfile', userId],
+ queryFn: () => getUserProfile(userId),
+ enabled: Boolean(userId),
+ });
+
+ const { data: reviewedProducts } = useQuery({
+ queryKey: ['reviewedProduct', userId],
+ queryFn: () => getUserReviewedProducts(userId),
+ select: (data) => data.list,
+ enabled: Boolean(userId),
+ });
+
+ const { data: createdProducts } = useQuery({
+ queryKey: ['createdProducts', userId],
+ queryFn: () => getUserCreatedProducts(userId),
+ select: (data) => data.list,
+ enabled: Boolean(userId),
+ });
+
+ const { data: favoriteProducts } = useQuery({
+ queryKey: ['favoriteProducts', userId],
+ queryFn: () => getUserFavoriteProducts(userId),
+ select: (data) => data.list,
+ enabled: Boolean(userId),
+ });
+
+ function handleDropDown(value: string | null) {
+ setShowProductState(value);
+ }
+
+ function setSpanTextColor(spanNum: string) {
+ return spanNum === showProductState ? 'text-gray-50' : 'text-gray-200';
+ }
+
+ const ReviewedProducts = () => {
+ switch (showProductState) {
+ case '1':
+ return (
+ <>
+ {reviewedProducts ? (
+ reviewedProducts.length === 0 ? (
+
+ 리뷰 남긴 상품이 없습니다.
+
+ ) : (
+ reviewedProducts.map((product) => (
+
+ ))
+ )
+ ) : null}
+ >
+ );
+ case '2':
+ return (
+ <>
+ {createdProducts ? (
+ createdProducts.length === 0 ? (
+
+ 등록한 상품이 없습니다.
+
+ ) : (
+ createdProducts.map((product) => (
+
+ ))
+ )
+ ) : null}
+ >
+ );
+ case '3':
+ return (
+ <>
+ {favoriteProducts ? (
+ favoriteProducts.length === 0 ? (
+
+ 찜한 상품이 없습니다.
+
+ ) : (
+ favoriteProducts.map((product) => (
+
+ ))
+ )
+ ) : null}
+ >
+ );
+ }
+ };
+
+ const ShowActivitys = ({ profileData }: { profileData: GetMeResponse }) => {
+ return (
+
+
+ 남긴
+ 별점 평균
+ >
+ }
+ icon="/icon/common/star.png"
+ dataNumber={profileData.averageRating}
+ />
+
+
+ 관심
+ 카테고리
+ >
+ }
+ category="/chip/category/electronicS.png"
+ />
+
+ );
+ };
+
+ return (
+
+
+
+
+ 활동 내역
+ {profileData ? : null}
+
+
+
+ 리뷰 남긴 상품
+ 등록한 상품
+ 찜한 상품
+
+
+ setShowProductState('1')}
+ >
+ 리뷰 남긴 상품
+
+ setShowProductState('2')}
+ >
+ 등록한 상품
+
+ setShowProductState('3')}
+ >
+ 찜한 상품
+
+
+
+
+
+
+
+
+ );
+}
+
+export default UserPage;
diff --git a/src/stores/authStores.tsx b/src/stores/authStores.tsx
new file mode 100644
index 00000000..b7962e9d
--- /dev/null
+++ b/src/stores/authStores.tsx
@@ -0,0 +1,24 @@
+import { create } from 'zustand';
+
+interface AuthState {
+ isLoggedIn: boolean;
+ setIsLoggedIn: (value: boolean) => void;
+}
+
+const useAuthStore = create((set) => ({
+ isLoggedIn: false,
+
+ setIsLoggedIn: (value: boolean) => {
+ set({ isLoggedIn: value });
+
+ if (typeof window !== 'undefined') {
+ if (value) {
+ localStorage.setItem('isLoggedIn', 'true');
+ } else {
+ localStorage.removeItem('isLoggedIn');
+ }
+ }
+ },
+}));
+
+export default useAuthStore;
diff --git a/src/styles/globals.css b/src/styles/globals.css
index af4fa9b1..52c36ba7 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -22,3 +22,20 @@ button {
font-weight: 400;
font-style: normal;
}
+
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: #6e6e82;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-track {
+ background-color: #1c1c22;
+}
+
+::-webkit-scrollbar-button {
+ display: none;
+}
diff --git a/src/types/.gitkeep b/src/types/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/types/auth.ts b/src/types/auth.ts
index 788cebe2..42b7975a 100644
--- a/src/types/auth.ts
+++ b/src/types/auth.ts
@@ -1,9 +1,14 @@
// /{teamId}/auth/signUp (POST)
+export interface SignUpFormData {
+ email: string;
+ password: string;
+ passwordConfirmation: string;
+ nickname: string;
+}
export interface SignUpRequest {
email: string;
password: string;
nickname: string;
- passwordConfirmation: string;
}
// /{teamId}/auth/signIn (POST)
@@ -18,12 +23,31 @@ export interface SignUpWithProviderRequest {
redirectUri: string;
token: string;
}
-
+//kakao
+export interface KakaoSignUpRequest {
+ accessToken(arg0: string, accessToken: any): unknown;
+ nickname: string;
+ token: string;
+ redirectUri: string;
+}
+export interface KakaoLoginResponse {
+ accessToken: string;
+ user: {
+ id: number;
+ nickname: string;
+ image: string | null;
+ createdAt: string;
+ };
+}
// /{teamId}/auth/signIn/{provider} (POST)
export interface SignInWithProviderRequest {
redirectUri: string;
token: string;
}
+export interface AuthResponse {
+ nickname: string;
+ accessToken: string;
+}
export interface AuthUser {
id: number;
diff --git a/src/types/product.ts b/src/types/product.ts
index 26b9b8c5..25206c06 100644
--- a/src/types/product.ts
+++ b/src/types/product.ts
@@ -23,7 +23,7 @@ export interface CreateProductRequest {
name: string;
description: string;
image: string;
- categoryId: number;
+ categoryId: string | number;
}
export interface CategoryMetric {
@@ -83,4 +83,4 @@ export interface ReviewListItem {
export interface GetReviewListResponse {
nextCursor: number;
list: ReviewListItem[];
-}
+}
\ No newline at end of file
diff --git a/src/types/user.ts b/src/types/user.ts
index 28fa9649..c14de90c 100644
--- a/src/types/user.ts
+++ b/src/types/user.ts
@@ -105,7 +105,7 @@ export interface FollowUser {
// /{teamId}/users/{userId}/followers (GET)
export interface FollowUserItem {
id: number;
- followee: FollowUser;
+ follower: FollowUser;
}
// /{teamId}/users/{userId}/followees (GET)
// /{teamId}/users/{userId}/followers (GET)
diff --git a/tailwind.config.js b/tailwind.config.js
index 7da10240..69441aa5 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -8,6 +8,9 @@ module.exports = {
],
theme: {
extend: {
+ fontFamily: {
+ sans: ['"Pretendard"', 'ui-sans-serif', 'system-ui'],
+ },
colors: {
black: {
300: '#353542',