diff --git a/next.config.mjs b/next.config.mjs index 3c97eabb..e0c53033 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,12 @@ -/** @type {import('next').NextConfig} */ +import CompressionPlugin from 'compression-webpack-plugin'; +import withBundleAnalyzer from '@next/bundle-analyzer'; +import withPlaiceholder from '@plaiceholder/next'; + +const bundleAnalyzer = withBundleAnalyzer({ + enabled: process.env.ANALYZE === 'true', + openAnalyzer: true, +}); + const nextConfig = { reactStrictMode: true, webpack(config) { @@ -6,11 +14,28 @@ const nextConfig = { test: /\.svg$/, use: ['@svgr/webpack'], }); + + if (!config.mode.includes('development')) { + config.plugins.push(new CompressionPlugin()); + } + return config; }, images: { - domains: ['manchui-bucket.s3.ap-northeast-2.amazonaws.com', 'ryungbucket.s3.ap-northeast-2.amazonaws.com'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'manchui-bucket.s3.ap-northeast-2.amazonaws.com', + pathname: '**', + }, + { + protocol: 'https', + hostname: 'ryungbucket.s3.ap-northeast-2.amazonaws.com', + pathname: '**', + }, + ], + formats: ['image/avif', 'image/webp'], }, }; -export default nextConfig; +export default bundleAnalyzer(withPlaiceholder(nextConfig)); diff --git a/package.json b/package.json index 2f5c4e94..8bcf93a1 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,12 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "chromatic": "npx chromatic --project-token=chpt_7c63d99a915fab5", - "dev:db": "json-server src/data/main.json --port=8888" + "dev:db": "json-server src/data/main.json --port=8888", + "analyze": "ANALYZE=true next build" }, "dependencies": { + "@plaiceholder/next": "^3.0.0", + "@stomp/stompjs": "^7.0.0", "@svgr/webpack": "^8.1.0", "@tanstack/react-query": "^5.59.16", "@tanstack/react-query-devtools": "^5.59.16", @@ -25,17 +28,20 @@ "event-source-polyfill": "^1.0.31", "framer-motion": "^11.11.11", "json-server": "^1.0.0-beta.3", - "lottie-react": "^2.4.0", + "lottie-light-react": "^2.4.0", "next": "14.2.15", + "plaiceholder": "^3.0.0", "pretendard": "^1.3.9", "react": "^18", "react-dom": "^18", "react-toastify": "^10.0.6", + "sharp": "^0.33.5", "tailwind-merge": "^2.5.4", "zustand": "^5.0.1" }, "devDependencies": { "@chromatic-com/storybook": "1.9.0", + "@next/bundle-analyzer": "^15.1.5", "@storybook/addon-essentials": "8.3.5", "@storybook/addon-interactions": "8.3.5", "@storybook/addon-links": "8.3.5", @@ -54,6 +60,7 @@ "@typescript-eslint/parser": "^8.9.0", "autoprefixer": "^10.4.20", "chromatic": "^11.12.6", + "compression-webpack-plugin": "^11.1.0", "eslint": "^8", "eslint-config-airbnb": "^19.0.4", "eslint-config-next": "14.2.15", @@ -75,6 +82,7 @@ "prettier": "3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", "storybook": "8.3.5", + "svgo": "^3.3.2", "tailwindcss": "^3.4.1", "typescript": "5.4.4" }, diff --git a/public/icons/Search.tsx b/public/icons/Search.tsx deleted file mode 100644 index 6b497379..00000000 --- a/public/icons/Search.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { Props } from '@/components/shared/Svg'; -import { Svg } from '@/components/shared/Svg'; - -export default function Search({ - color = '#FFFFFF', - className, - ...props -}: Props) { - - return ( - - - - ); -} diff --git a/public/icons/main/search.svg b/public/icons/main/search.svg new file mode 100644 index 00000000..fd008634 --- /dev/null +++ b/public/icons/main/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/save-filled.svg b/public/icons/save-filled.svg new file mode 100644 index 00000000..17cd3dde --- /dev/null +++ b/public/icons/save-filled.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/public/icons/save.svg b/public/icons/save.svg new file mode 100644 index 00000000..978ce666 --- /dev/null +++ b/public/icons/save.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/public/icons/search.svg b/public/icons/search.svg deleted file mode 100644 index b870864a..00000000 --- a/public/icons/search.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/images/develop.webp b/public/images/develop.webp deleted file mode 100644 index de3de77b..00000000 Binary files a/public/images/develop.webp and /dev/null differ diff --git a/public/images/food.webp b/public/images/food.webp deleted file mode 100644 index 2ac684f5..00000000 Binary files a/public/images/food.webp and /dev/null differ diff --git a/public/images/landing/landing-create.png b/public/images/landing/landing-create.png deleted file mode 100644 index 96c307af..00000000 Binary files a/public/images/landing/landing-create.png and /dev/null differ diff --git a/public/images/landing/landing-create.webp b/public/images/landing/landing-create.webp new file mode 100644 index 00000000..3efeb1da Binary files /dev/null and b/public/images/landing/landing-create.webp differ diff --git a/public/images/landing/landing-create2.png b/public/images/landing/landing-create2.png deleted file mode 100644 index 6da9d803..00000000 Binary files a/public/images/landing/landing-create2.png and /dev/null differ diff --git a/public/images/landing/landing-create2.webp b/public/images/landing/landing-create2.webp new file mode 100644 index 00000000..cc10c08a Binary files /dev/null and b/public/images/landing/landing-create2.webp differ diff --git a/public/images/landing/landing-moim.png b/public/images/landing/landing-moim.png deleted file mode 100644 index f4c1f03c..00000000 Binary files a/public/images/landing/landing-moim.png and /dev/null differ diff --git a/public/images/landing/landing-moim.webp b/public/images/landing/landing-moim.webp new file mode 100644 index 00000000..6a016556 Binary files /dev/null and b/public/images/landing/landing-moim.webp differ diff --git a/public/images/landing/landing-signup.png b/public/images/landing/landing-signup.png deleted file mode 100644 index 66c14fb3..00000000 Binary files a/public/images/landing/landing-signup.png and /dev/null differ diff --git a/public/images/landing/landing-signup.webp b/public/images/landing/landing-signup.webp new file mode 100644 index 00000000..53deab87 Binary files /dev/null and b/public/images/landing/landing-signup.webp differ diff --git a/public/images/landing/poster.webp b/public/images/landing/poster.webp new file mode 100644 index 00000000..78239750 Binary files /dev/null and b/public/images/landing/poster.webp differ diff --git a/public/images/landing/review1.png b/public/images/landing/review1.png deleted file mode 100644 index 22961622..00000000 Binary files a/public/images/landing/review1.png and /dev/null differ diff --git a/public/images/landing/review1.webp b/public/images/landing/review1.webp new file mode 100644 index 00000000..c85e9d0b Binary files /dev/null and b/public/images/landing/review1.webp differ diff --git a/public/images/landing/review2.png b/public/images/landing/review2.png deleted file mode 100644 index 588557f2..00000000 Binary files a/public/images/landing/review2.png and /dev/null differ diff --git a/public/images/landing/review2.webp b/public/images/landing/review2.webp new file mode 100644 index 00000000..eb131870 Binary files /dev/null and b/public/images/landing/review2.webp differ diff --git a/public/images/landing/review3.png b/public/images/landing/review3.png deleted file mode 100644 index 7fd2f1ff..00000000 Binary files a/public/images/landing/review3.png and /dev/null differ diff --git a/public/images/landing/review3.webp b/public/images/landing/review3.webp new file mode 100644 index 00000000..55425a86 Binary files /dev/null and b/public/images/landing/review3.webp differ diff --git a/public/images/main/develop.webp b/public/images/main/develop.webp new file mode 100644 index 00000000..5e8aa694 Binary files /dev/null and b/public/images/main/develop.webp differ diff --git a/public/images/main/food.webp b/public/images/main/food.webp new file mode 100644 index 00000000..6ba569e5 Binary files /dev/null and b/public/images/main/food.webp differ diff --git a/public/images/main/study.webp b/public/images/main/study.webp new file mode 100644 index 00000000..4df9a5fa Binary files /dev/null and b/public/images/main/study.webp differ diff --git a/public/images/popular.webp b/public/images/popular.webp deleted file mode 100644 index e6fa356c..00000000 Binary files a/public/images/popular.webp and /dev/null differ diff --git a/public/images/position/design.min.svg b/public/images/position/design.min.svg new file mode 100644 index 00000000..356f1126 --- /dev/null +++ b/public/images/position/design.min.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/position/design.svg b/public/images/position/design.svg deleted file mode 100644 index acbee8b6..00000000 --- a/public/images/position/design.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/position/server.min.svg b/public/images/position/server.min.svg new file mode 100644 index 00000000..103005c2 --- /dev/null +++ b/public/images/position/server.min.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/position/server.svg b/public/images/position/server.svg deleted file mode 100644 index 23a47191..00000000 --- a/public/images/position/server.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/public/images/position/web.min.svg b/public/images/position/web.min.svg new file mode 100644 index 00000000..90725d1f --- /dev/null +++ b/public/images/position/web.min.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/position/web.svg b/public/images/position/web.svg deleted file mode 100644 index 0231ae24..00000000 --- a/public/images/position/web.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/public/images/study.webp b/public/images/study.webp deleted file mode 100644 index e38bf5bb..00000000 Binary files a/public/images/study.webp and /dev/null differ diff --git a/public/images/top.webp b/public/images/top.webp deleted file mode 100644 index 9bdc030e..00000000 Binary files a/public/images/top.webp and /dev/null differ diff --git a/src/apis/getGatheringData.ts b/src/apis/getGatheringData.ts index 96b7da06..6c4cae63 100644 --- a/src/apis/getGatheringData.ts +++ b/src/apis/getGatheringData.ts @@ -6,7 +6,7 @@ export async function getGatheringData(request: GetGatheringRequest): Promise
-
+
프로필
@@ -136,7 +136,7 @@ export default function Drawer({ isLoggedIn, userData, setIsOpen, isOpen }: Draw closeDrawer(); }} > - 메뉴 + 메뉴 모임 찾기
-
+ {isClosed ? ( +
+
+
+

+ [{category} | {location}] {groupName} +

+ + +
+ + ); +} diff --git a/src/components/main/CardSection/index.tsx b/src/components/main/CardSection/index.tsx index a17b2a31..c63316e2 100644 --- a/src/components/main/CardSection/index.tsx +++ b/src/components/main/CardSection/index.tsx @@ -1,55 +1,65 @@ -/* eslint-disable tailwindcss/no-custom-classname */ -import { memo } from 'react'; -import { Gugi } from 'next/font/google'; -import Link from 'next/link'; -import ArrowBtn from 'public/icons/ArrowBtn'; -import CardContent from '@/components/main/CardSection/CardContent'; -import CardImage from '@/components/main/CardSection/CardImage'; -import type { GetGatheringResponse } from '@manchui-api'; - -interface CardSectionProps { - gathering: GetGatheringResponse['data']['gatheringList'][number]; -} +import { useEffect, useRef } from 'react'; +import dynamic from 'next/dynamic'; +import { MessageWithLink } from '@/components/main/MainCardSection/CardSection'; +import ErrorBoundary from '@/components/shared/ErrorBoundary'; +import Skeleton from '@/components/shared/Skeleton'; +import useGetGatheringData from '@/hooks/useGetGatheringData'; +import useIntersectionObserver from '@/hooks/useIntersectionObserver'; +import useFilterStore from '@/store/useFilterStore'; + +const NoData = dynamic(() => import('@/components/shared/NoData'), { ssr: false }); +const CardItem = dynamic(() => import('./CardItem'), { ssr: false }); + +function CardSectionContent() { + const { keyword, location, category, closeDate, dateStart, dateEnd } = useFilterStore(); + + const sentinelRef = useRef(null); + const isIntersecting = useIntersectionObserver(sentinelRef); + + const { mainData, isError, hasNextPage, fetchNextPage } = useGetGatheringData({ + query: keyword, + location, + category, + sort: closeDate, + startDate: dateStart, + endDate: dateEnd, + }); -const gugi = Gugi({ weight: '400', subsets: ['latin'] }); + useEffect(() => { + if (isIntersecting && hasNextPage) void fetchNextPage(); + }, [isIntersecting, hasNextPage, fetchNextPage]); -function CardSection({ gathering }: CardSectionProps) { return ( - - - - +
+
    {mainData?.map((data) => )}
+ {mainData?.length === 0 && } + {!isError &&
} +
); } -export default memo(CardSection); - export function CardSkeleton() { return ( -
-
+
+
    + {Array.from({ length: 8 }).map((_, idx) => ( + + ))} +
); } -export function MessageWithLink({ message, buttonText, link, onClick }: { buttonText: string; link?: string; message?: string; onClick?: () => void }) { +export default function CardSection() { return ( -
- {message} - {link ? ( - - {buttonText} - - - ) : ( - - )} -
+ + window.location.reload()} /> +
+ } + > + + ); } diff --git a/src/components/main/Carousel/FAQSlide/index.tsx b/src/components/main/Carousel/FAQSlide.tsx similarity index 72% rename from src/components/main/Carousel/FAQSlide/index.tsx rename to src/components/main/Carousel/FAQSlide.tsx index ee5bac4d..8aac6e0c 100644 --- a/src/components/main/Carousel/FAQSlide/index.tsx +++ b/src/components/main/Carousel/FAQSlide.tsx @@ -16,7 +16,7 @@ const bodyVariants: Variants = { }; export default function QnaSlide() { - const [activeIndex, setActiveIndex] = useState(0); // 초기 상태: 모든 드롭다운 닫힘 + const [activeIndex, setActiveIndex] = useState(0); const router = useInternalRouter(); @@ -25,19 +25,19 @@ export default function QnaSlide() { }; return ( -
-
- 자주 묻는 질문 -
+
+
+ 자주 묻는 질문 +
    {SINGLE_FAQ.map((qna, i) => ( -
  • onClickActiveQna(i)} className="cursor-pointer text-13-16-response font-semibold"> +
  • onClickActiveQna(i)} className="cursor-pointer text-sm font-semibold">

    {qna.question}

    @@ -49,15 +49,15 @@ export default function QnaSlide() { animate={activeIndex === i ? 'open' : 'closed'} variants={bodyVariants} transition={{ duration: 0.3, ease: 'easeOut' }} - className="overflow-hidden" + className="overflow-hidden text-left" > -

    {qna.answer}

    +

    {qna.answer}

  • ))}
-
diff --git a/src/components/main/Carousel/IntroduceSlide.tsx b/src/components/main/Carousel/IntroduceSlide.tsx new file mode 100644 index 00000000..aa91c311 --- /dev/null +++ b/src/components/main/Carousel/IntroduceSlide.tsx @@ -0,0 +1,23 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +const cards = [ + { bg: '#3FD9F9', id: 'server', img: '/images/position/server.min.svg' }, + { bg: '#fd4872', id: 'web', img: '/images/position/web.min.svg' }, + { bg: '#cdf86f', id: 'design', img: '/images/position/design.min.svg' }, +] as const; + +export default function IntroduceSlide() { + return ( + +

▫ 만취 프로젝트 개발자 ▫

+
+ {cards.map(({ id, img, bg }) => ( +
+ {id} +
+ ))} +
+ + ); +} diff --git a/src/components/main/Carousel/IntroduceSlide/index.tsx b/src/components/main/Carousel/IntroduceSlide/index.tsx deleted file mode 100644 index 8bea8980..00000000 --- a/src/components/main/Carousel/IntroduceSlide/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { Variants } from 'framer-motion'; -import * as m from 'framer-motion/m'; -import Image from 'next/image'; -import { BACKEND_CARDS, DESIGNER_CARDS, FRONTEND_CARDS } from '@/constants/cards'; -import { POSITION_BASE } from '@/constants/image'; -import useInternalRouter from '@/hooks/useInternalRouter'; - -export default function IntroduceSlide() { - const router = useInternalRouter(); - - const cards = [ - { ...BACKEND_CARDS[0], bg: '#3FD9F9', title: 'SERVER' }, - { ...FRONTEND_CARDS[0], bg: '#fd4872', title: 'WEB' }, - { ...DESIGNER_CARDS[0], bg: '#cdf86f', title: 'DESIGN' }, - ]; - - const cardVariants: Variants = { - hidden: { opacity: 0, y: 50 }, - visible: (i: number) => ({ - opacity: 1, - y: 0, - transition: { - type: 'spring', - stiffness: 100, - damping: 30, - delay: i * 0.4, - }, - }), - }; - - return ( -
-
-

▫ 만취 프로젝트 개발자 ▫

- -
-
- {cards.map((card, index) => ( - router.push('/introduce')} - className="relative flex w-[300px] cursor-pointer flex-col items-center justify-center rounded-xl p-4 shadow-lg" - style={{ backgroundColor: card.bg }} - > - {`${card.title} -

{card.title}

-
- ))} -
-
- ); -} diff --git a/src/components/main/Carousel/NoticeBoardSlide.tsx b/src/components/main/Carousel/NoticeBoardSlide.tsx new file mode 100644 index 00000000..c6d4b617 --- /dev/null +++ b/src/components/main/Carousel/NoticeBoardSlide.tsx @@ -0,0 +1,29 @@ +import useInternalRouter from '@/hooks/useInternalRouter'; + +export default function NoticeBoardSlide() { + const router = useInternalRouter(); + + return ( +
+

🫧 공지사항 🫧

+

+ New! 만취에서 새로운 카테고리 추가!
+ '여행'을 즐겨보세요. +

+
    +
  • + 🌍 테마 여행 모임으로 특별한 추억 만들기 +
  • +
  • + 📸 사진부터 캠핑까지 다양한 여행 스타일 모임 +
  • +
  • + 🤝 혼자가 아닌 함께 떠나는 소그룹 여행! +
  • +
+ +
+ ); +} diff --git a/src/components/main/Carousel/NoticeBoardSlide/index.tsx b/src/components/main/Carousel/NoticeBoardSlide/index.tsx deleted file mode 100644 index c481cffb..00000000 --- a/src/components/main/Carousel/NoticeBoardSlide/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable tailwindcss/no-custom-classname */ -import { Gugi } from 'next/font/google'; -import useInternalRouter from '@/hooks/useInternalRouter'; - -const gugi = Gugi({ weight: '400', subsets: ['latin'] }); - -export default function NoticeBoardSlide() { - const router = useInternalRouter(); - - return ( -
-

🫧 공지사항 🫧

-

- New! 만취에서 새로운 카테고리 추가!
- '여행'을 즐겨보세요. -

-
    -
  • - 🌍 테마 여행 모임으로 특별한 추억 만들기 -
  • -
  • - 📸 사진부터 캠핑까지 다양한 여행 스타일 모임 -
  • -
  • - 🤝 혼자가 아닌 함께 떠나는 소그룹 여행! -
  • -
- -
- ); -} diff --git a/src/components/main/Carousel/PopularCategorySlide.tsx b/src/components/main/Carousel/PopularCategorySlide.tsx new file mode 100644 index 00000000..3f07b610 --- /dev/null +++ b/src/components/main/Carousel/PopularCategorySlide.tsx @@ -0,0 +1,58 @@ +import { useCallback } from 'react'; +import Image from 'next/image'; +import { useSetCategory } from '@/store/useFilterStore'; + +interface PopularCategorySlideProps { + base64: { + develop: string; + food: string; + study: string; + }; +} + +const categories = [ + { rank: 1, category: '개발', img: 'develop', imageSrc: '/images/main/develop.webp' }, + { rank: 2, category: '공부', img: 'study', imageSrc: '/images/main/study.webp' }, + { rank: 3, category: '맛집', img: 'food', imageSrc: '/images/main/food.webp' }, +]; + +export default function PopularCategorySlide({ base64 }: PopularCategorySlideProps) { + const setCategory = useSetCategory(); + + const handleCategoryClick = useCallback( + (category: string) => { + setCategory(category); + }, + [setCategory], + ); + + return ( +
+

🔥 인기 카테고리 🔥

+

실시간으로 모임수가 증가하고 있어요!

+
+ {categories.map(({ rank, category, img, imageSrc }) => ( +
handleCategoryClick(category)} + className="relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md duration-200 hover:scale-105" + > +
+ {rank}위 +
+ {category} +

{category} 카테고리

+
+ ))} +
+
+ ); +} diff --git a/src/components/main/Carousel/PopularCategorySlide/index.tsx b/src/components/main/Carousel/PopularCategorySlide/index.tsx deleted file mode 100644 index d3e4ab27..00000000 --- a/src/components/main/Carousel/PopularCategorySlide/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-disable tailwindcss/no-custom-classname */ -import { useCallback } from 'react'; -import Image from 'next/image'; -import { useSetCategory } from '@/store/useFilterStore'; - -interface PopularCategorySlideProps { - handleScrollToFilter: () => void; -} - -const categories = [ - { rank: 1, category: '개발', imageSrc: '/images/develop.webp' }, - { rank: 2, category: '공부', imageSrc: '/images/study.webp' }, - { rank: 3, category: '맛집', imageSrc: '/images/food.webp' }, -]; - -export default function PopularCategorySlide({ handleScrollToFilter }: PopularCategorySlideProps) { - const setCategory = useSetCategory(); - - const handleCategoryClick = useCallback( - (category: string) => { - setCategory(category); - handleScrollToFilter(); - }, - [handleScrollToFilter, setCategory], - ); - - return ( -
-
- 인기 카테고리 -
- -
-
-

🔥 인기 카테고리 🔥

-

실시간으로 모임수가 증가하고 있어요!

-
- {categories.map(({ rank, category, imageSrc }) => ( -
handleCategoryClick(category)} - className="relative flex cursor-pointer flex-col items-center justify-center gap-3 rounded-md transition-transform duration-300 hover:scale-105" - > -
- {rank}위 -
- {category} -

{category} 카테고리

-
- ))} -
-
-
-
- ); -} diff --git a/src/components/main/Carousel/TopSlide/index.tsx b/src/components/main/Carousel/TopSlide/index.tsx deleted file mode 100644 index 10494005..00000000 --- a/src/components/main/Carousel/TopSlide/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import Image from 'next/image'; -import DoubleArrow from 'public/icons/DoubleArrow'; -import useInternalRouter from '@/hooks/useInternalRouter'; - -export default function TopSlide() { - const router = useInternalRouter(); - - return ( -
router.push('/main')} className="cursor-pointer bg-[#000000]"> -
-
- 실시간 업데이트! -

무슨 모임에 가입해야할지 모르겠다면?

-
- 실시간 - TOP 10 - 모임 보러가기 - -
-
-
- TOP 모임 -
-
-
- ); -} diff --git a/src/components/main/Carousel/index.tsx b/src/components/main/Carousel/index.tsx index 12b649a1..46683969 100644 --- a/src/components/main/Carousel/index.tsx +++ b/src/components/main/Carousel/index.tsx @@ -1,65 +1,63 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { type Variants } from 'framer-motion'; -import * as m from 'framer-motion/m'; -import ArrowBtn from 'public/icons/ArrowBtn'; -import FAQSlide from '@/components/main/Carousel/FAQSlide'; -import IntroduceSlide from '@/components/main/Carousel/IntroduceSlide'; -import NoticeBoardSlide from '@/components/main/Carousel/NoticeBoardSlide'; -import PopularCategorySlide from '@/components/main/Carousel/PopularCategorySlide'; -import TopSlide from '@/components/main/Carousel/TopSlide'; - -const zoomVariants: Variants = { - enter: { - scale: 1.1, - opacity: 0, - }, - center: { - scale: 1, - opacity: 1, - transition: { duration: 0.8 }, - }, -}; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import dynamic from 'next/dynamic'; +import Image from 'next/image'; + +const PopularCategorySlide = dynamic(() => import('@/components/main/Carousel/PopularCategorySlide'), { loading: () => , ssr: true }); +const IntroduceSlide = dynamic(() => import('@/components/main/Carousel/IntroduceSlide'), { loading: () => , ssr: true }); +const NoticeBoardSlide = dynamic(() => import('@/components/main/Carousel/NoticeBoardSlide'), { loading: () => , ssr: true }); +const FAQSlide = dynamic(() => import('@/components/main/Carousel/FAQSlide'), { loading: () => , ssr: true }); interface CarouselProps { - handleScrollToFilter: () => void; + base64: { + design: string; + develop: string; + food: string; + server: string; + study: string; + web: string; + }; } -export default function Carousel({ handleScrollToFilter }: CarouselProps) { - const [currentIndex, setCurrentIndex] = useState(0); +const TOTAL_SLIDES = 4; - const slides = [ - , - , - , - , - , - ]; +function Carousel({ base64 }: CarouselProps) { + const [currentIndex, setCurrentIndex] = useState(0); const timeoutRef = useRef(null); const intervalRef = useRef(null); const handleNext = useCallback(() => { if (timeoutRef.current) return; - setCurrentIndex((prev) => (prev + 1) % slides.length); + setCurrentIndex((prev) => (prev + 1) % TOTAL_SLIDES); timeoutRef.current = setTimeout(() => { timeoutRef.current = null; }, 500); - }, [slides.length]); + }, []); - const handlePrev = () => { + const handlePrev = useCallback(() => { if (timeoutRef.current) return; - setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length); + setCurrentIndex((prev) => (prev - 1 + TOTAL_SLIDES) % TOTAL_SLIDES); timeoutRef.current = setTimeout(() => { timeoutRef.current = null; }, 500); - }; + }, []); + + const slides = useMemo( + () => ({ + 0: , + 1: , + 2: , + 3: , + }), + [base64], + ); useEffect(() => { intervalRef.current = setInterval(() => { handleNext(); - }, 4000); + }, 5000); return () => { if (intervalRef.current) { @@ -68,56 +66,50 @@ export default function Carousel({ handleScrollToFilter }: CarouselProps) { }; }, [handleNext]); - useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }, []); - return ( - - - {slides[currentIndex]} - +
+
{slides[currentIndex as keyof typeof slides]}
-
- {slides.map((_, index) => ( - // eslint-disable-next-line jsx-a11y/control-has-associated-label -
+ ); +} + +export function CarouselSkeleton() { + return ( +
+
+
); } + +export default memo(Carousel); diff --git a/src/components/main/Dropdown/index.tsx b/src/components/main/Dropdown/index.tsx index 35165498..5f914399 100644 --- a/src/components/main/Dropdown/index.tsx +++ b/src/components/main/Dropdown/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import DownArrow from 'public/icons/DownArrow'; +import Image from 'next/image'; interface DropdownProps { buttonLabel: React.ReactNode; @@ -44,7 +44,15 @@ export default function Dropdown({ buttonLabel, children, isOpen, setIsOpen, cla className={`flex items-center rounded-lg border border-gray-100 p-2 text-13-16-response font-semibold text-gray-900 mobile:gap-1 tablet:px-4 ${dropOpen && 'bg-blue-800 text-white'} ${value && 'bg-blue-800 text-white'}`} > {buttonLabel} - + 드롭다운 화살표 {isOpen &&
{children}
}
diff --git a/src/components/main/FilterSection/CategoryList/CategoryItems/index.tsx b/src/components/main/FilterSection/CategoryList/CategoryItems/index.tsx deleted file mode 100644 index f2ec673d..00000000 --- a/src/components/main/FilterSection/CategoryList/CategoryItems/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Image from 'next/image'; -import { useCategory, useSetCategory } from '@/store/useFilterStore'; - -interface CategoryItemsProps { - option: { icon: string; id: string; label: string }; -} - -export default function CategoryItems({ option }: CategoryItemsProps) { - const category = useCategory(); - const setCategory = useSetCategory(); - - return ( -
{ - setCategory(option.id); - }} - > - - -
- ); -} diff --git a/src/components/main/FilterSection/CategoryList/index.tsx b/src/components/main/FilterSection/CategoryList/index.tsx deleted file mode 100644 index cfc43dbb..00000000 --- a/src/components/main/FilterSection/CategoryList/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import CategoryItems from '@/components/main/FilterSection/CategoryList/CategoryItems'; -import { FILTER_OPTIONS } from '@/constants/filter'; - -export default function CategoryList() { - return ( -
-
-
- filter - {FILTER_OPTIONS.map((option) => ( - - ))} -
-
-
-
- ); -} diff --git a/src/components/main/FilterSection/CloseDateToggle/index.tsx b/src/components/main/FilterSection/CloseDateToggle/index.tsx deleted file mode 100644 index b6175304..00000000 --- a/src/components/main/FilterSection/CloseDateToggle/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useState } from 'react'; -import Image from 'next/image'; -import { Toast } from '@/components/shared/Toast'; -import { useSetCloseDate } from '@/store/useFilterStore'; - -export default function CloseDateToggle() { - const [toggleValue, setToggleValue] = useState(false); - - const setCloseDate = useSetCloseDate(); - - const handleCloseDateFilterToggle = () => { - const updatedToggleValue = !toggleValue; - setToggleValue(updatedToggleValue); - setCloseDate(updatedToggleValue ? 'closeDate' : ''); - - if (updatedToggleValue) { - Toast('success', '마감 임박순 필터가 적용되었습니다.'); - } else { - Toast('info', '마감 임박순 필터가 해제되었습니다.'); - } - }; - - return ( -
- 마감 임박순 - 마감임박 -
- ); -} diff --git a/src/components/main/FilterSection/DateDropdown/index.tsx b/src/components/main/FilterSection/DateDropdown/index.tsx deleted file mode 100644 index 490dcb70..00000000 --- a/src/components/main/FilterSection/DateDropdown/index.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useState } from 'react'; -import Dropdown from '@/components/main/Dropdown'; -import Calendar from '@/components/shared/Calendar'; -import { Toast } from '@/components/shared/Toast'; -import { useSetDateEnd, useSetDateStart } from '@/store/useFilterStore'; - -export default function DateDropdown() { - const [startDate, setStartDate] = useState(null); - const [endDate, setEndDate] = useState(null); - const [dateDropOpen, setDateDropOpen] = useState(false); - const [isApplyDisabled, setIsApplyDisabled] = useState(false); - - const setDateStart = useSetDateStart(); - const setDateEnd = useSetDateEnd(); - - const handleDateChange = (data: { rangeEnd?: string; rangeStart?: string }) => { - if (data.rangeStart) { - setStartDate(data.rangeStart); - setEndDate(null); - setIsApplyDisabled(false); - } - if (data.rangeEnd) { - setEndDate(data.rangeEnd); - setIsApplyDisabled(false); - } - }; - - const handleSubmit = useCallback(() => { - if (startDate && endDate) { - setDateStart(startDate); - setDateEnd(endDate); - setIsApplyDisabled(true); - Toast('success', '날짜가 적용되었습니다.'); - setDateDropOpen(false); - } else { - Toast('error', '날짜 범위를 선택하세요'); - } - }, [endDate, setDateEnd, setDateStart, startDate]); - - const handleInitClick = useCallback(() => { - if (!startDate && !endDate) { - Toast('error', '날짜를 선택하세요'); - return; - } - - if (startDate && endDate) { - setStartDate(null); - setEndDate(null); - setDateStart(undefined); - setDateEnd(undefined); - setIsApplyDisabled(false); - setDateDropOpen(false); - Toast('info', '날짜 선택이 초기화되었습니다.'); - } - }, [startDate, endDate, setDateEnd, setDateStart]); - - return ( - - {startDate.replace(/-/g, '.')} - {endDate.replace(/-/g, '.')} - - ) : ( - <> - 모임 - 날짜 - - ) - } - className="left-date-calendar" - > -
- -
- - -
-
-
- ); -} diff --git a/src/components/main/FilterSection/RegionDropdown/index.tsx b/src/components/main/FilterSection/RegionDropdown/index.tsx deleted file mode 100644 index 65b4f9e5..00000000 --- a/src/components/main/FilterSection/RegionDropdown/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from 'react'; -import Dropdown from '@/components/main/Dropdown'; -import { Toast } from '@/components/shared/Toast'; -import { REGION_DATA } from '@/constants/filter'; -import { useLocation, useSetLocation } from '@/store/useFilterStore'; - -export default function RegionDropdown() { - const [regionDropOpen, setRegionDropOpen] = useState(false); - - const location = useLocation(); - const setLocation = useSetLocation(); - - const handleInitClick = () => { - setLocation(undefined); - setRegionDropOpen(false); - - Toast('info', '지역 필터가 초기화되었습니다.'); - }; - - const handleRegionSelect = (value: string) => { - setLocation(value); - setRegionDropOpen(false); - - Toast('success', `${value} 지역이 선택되었습니다.`); - }; - - return ( - -
    -
  • - 전체 -
  • - {REGION_DATA.map((value) => ( -
  • handleRegionSelect(value)} className="p-2 hover:bg-gray-50"> - {value} -
  • - ))} -
-
- ); -} diff --git a/src/components/main/FilterSection/index.tsx b/src/components/main/FilterSection/index.tsx index 99280c1d..529fc64f 100644 --- a/src/components/main/FilterSection/index.tsx +++ b/src/components/main/FilterSection/index.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo } from 'react'; -import CategoryList from '@/components/main/FilterSection/CategoryList'; -import CloseDateToggle from '@/components/main/FilterSection/CloseDateToggle'; -import DateDropdown from '@/components/main/FilterSection/DateDropdown'; -import RegionDropdown from '@/components/main/FilterSection/RegionDropdown'; +import CategoryList from '@/components/main/HeaderSection/FilterList/CategoryList'; +import CloseDateToggle from '@/components/main/HeaderSection/FilterList/CloseDateToggle'; +import DateDropdown from '@/components/main/HeaderSection/FilterList/DateDropdown'; +import RegionDropdown from '@/components/main/HeaderSection/FilterList/RegionDropdown'; import { Toast } from '@/components/shared/Toast'; import { IS_SERVER } from '@/constants/server'; import useInternalRouter from '@/hooks/useInternalRouter'; diff --git a/src/components/main/HeaderSection/FilterList/CategoryList/index.tsx b/src/components/main/HeaderSection/FilterList/CategoryList/index.tsx new file mode 100644 index 00000000..69fd456c --- /dev/null +++ b/src/components/main/HeaderSection/FilterList/CategoryList/index.tsx @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; +import { FILTER_OPTIONS } from '@/constants/filter'; +import { useCategory, useSetCategory } from '@/store/useFilterStore'; + +export default function CategoryList() { + const category = useCategory(); + const setCategory = useSetCategory(); + + const handleCategoryClick = useCallback( + (id: string) => { + setCategory(id); + }, + [setCategory], + ); + + return ( +
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
+ ); +} diff --git a/src/components/main/HeaderSection/FilterList/CloseDateToggle/index.tsx b/src/components/main/HeaderSection/FilterList/CloseDateToggle/index.tsx new file mode 100644 index 00000000..e55b17ea --- /dev/null +++ b/src/components/main/HeaderSection/FilterList/CloseDateToggle/index.tsx @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react'; +import { Toast } from '@/components/shared/Toast'; +import { useSetCloseDate } from '@/store/useFilterStore'; + +export default function CloseDateToggle() { + const [toggleValue, setToggleValue] = useState(false); + const setCloseDate = useSetCloseDate(); + + const handleCloseDateFilterToggle = useCallback(() => { + setToggleValue(!toggleValue); + setCloseDate(!toggleValue ? 'closeDate' : ''); + + if (!toggleValue) { + Toast('success', '마감순으로 정렬되었습니다.'); + } else { + Toast('info', '최신순으로 정렬되었습니다.'); + } + }, [setCloseDate, toggleValue]); + + return ( + + ); +} diff --git a/src/components/main/HeaderSection/FilterList/DateDropdown/index.tsx b/src/components/main/HeaderSection/FilterList/DateDropdown/index.tsx new file mode 100644 index 00000000..b568002f --- /dev/null +++ b/src/components/main/HeaderSection/FilterList/DateDropdown/index.tsx @@ -0,0 +1,116 @@ +import { useCallback, useState } from 'react'; +import dynamic from 'next/dynamic'; +import Image from 'next/image'; +import { Toast } from '@/components/shared/Toast'; +import { useAlertStore } from '@/store/useAlertStore'; +import { useSetDateEnd, useSetDateStart } from '@/store/useFilterStore'; + +const Alert = dynamic(() => import('@/components/shared/Alert'), { loading: () => null, ssr: false }); +const Calendar = dynamic(() => import('@/components/shared/Calendar'), { + loading: () =>
Loading...
, + ssr: false, +}); + +export default function DateDropdown() { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [isApplyDisabled, setIsApplyDisabled] = useState(false); + + const setDateStart = useSetDateStart(); + const setDateEnd = useSetDateEnd(); + + const { openDateAlert, closeDateAlert } = useAlertStore(); + + const handleDateChange = (data: { rangeEnd?: string; rangeStart?: string }) => { + if (data.rangeStart) { + setStartDate(data.rangeStart); + setEndDate(null); + setIsApplyDisabled(false); + } + if (data.rangeEnd) { + setEndDate(data.rangeEnd); + setIsApplyDisabled(false); + } + }; + + const handleSubmit = useCallback(() => { + if (startDate && endDate) { + setDateStart(startDate); + setDateEnd(endDate); + setIsApplyDisabled(true); + closeDateAlert(); + Toast('success', '날짜가 적용되었습니다.'); + } else { + Toast('error', '날짜 범위를 선택하세요'); + } + }, [closeDateAlert, endDate, setDateEnd, setDateStart, startDate]); + + const handleInitClick = useCallback(() => { + if (!startDate && !endDate) { + Toast('error', '날짜를 선택하세요'); + return; + } + + if (startDate && endDate) { + setStartDate(null); + setEndDate(null); + setDateStart(undefined); + setDateEnd(undefined); + setIsApplyDisabled(false); + closeDateAlert(); + Toast('info', '날짜 선택이 초기화되었습니다.'); + } + }, [startDate, endDate, setDateStart, setDateEnd, closeDateAlert]); + + return ( + <> + + +
+ +
+ + +
+
+
+ + ); +} diff --git a/src/components/main/HeaderSection/FilterList/RegionDropdown/index.tsx b/src/components/main/HeaderSection/FilterList/RegionDropdown/index.tsx new file mode 100644 index 00000000..8d9e9e5e --- /dev/null +++ b/src/components/main/HeaderSection/FilterList/RegionDropdown/index.tsx @@ -0,0 +1,60 @@ +import dynamic from 'next/dynamic'; +import Image from 'next/image'; +import { Toast } from '@/components/shared/Toast'; +import { REGION_DATA } from '@/constants/filter'; +import { useAlertStore } from '@/store/useAlertStore'; +import { useLocation, useSetLocation } from '@/store/useFilterStore'; + +const Alert = dynamic(() => import('@/components/shared/Alert'), { ssr: false }); + +export default function RegionDropdown() { + const { openAlert, closeAlert } = useAlertStore(); + + const location = useLocation(); + const setLocation = useSetLocation(); + + const handleInitClick = () => { + setLocation(undefined); + closeAlert(); + Toast('info', '지역 필터가 초기화되었습니다.'); + }; + + const handleRegionSelect = (value: string) => { + setLocation(value); + closeAlert(); + Toast('success', `${value} 지역이 선택되었습니다.`); + }; + + return ( + <> + + +
    +
  • + 전체 +
  • + {REGION_DATA.map((value) => ( +
  • handleRegionSelect(value)} className="p-2 hover:bg-gray-50"> + {value} +
  • + ))} +
+
+ + ); +} diff --git a/src/components/main/HeaderSection/FilterList/index.tsx b/src/components/main/HeaderSection/FilterList/index.tsx new file mode 100644 index 00000000..52e90ebe --- /dev/null +++ b/src/components/main/HeaderSection/FilterList/index.tsx @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; +import CategoryList from '@/components/main/HeaderSection/FilterList/CategoryList'; +import CloseDateToggle from '@/components/main/HeaderSection/FilterList/CloseDateToggle'; +import DateDropdown from '@/components/main/HeaderSection/FilterList/DateDropdown'; +import RegionDropdown from '@/components/main/HeaderSection/FilterList/RegionDropdown'; +import { Toast } from '@/components/shared/Toast'; +import useInternalRouter from '@/hooks/useInternalRouter'; +import { userStore } from '@/store/userStore'; + +export default function FilterList() { + const router = useInternalRouter(); + const isLoggedIn = userStore((state) => state.isLoggedIn); + + const handleCreateButtonClick = useCallback(() => { + if (isLoggedIn) { + void router.push('/create'); + } else { + Toast('error', '로그인이 필요합니다.'); + } + }, [isLoggedIn, router]); + + return ( +
+
+
+ {/* 필터 버튼들 */} +
+ + + +
+ + {/* 구분선 */} + + + {/* 카테고리 버튼들 */} + +
+
+ +
+ ); +} diff --git a/src/components/main/HeaderSection/Header/index.tsx b/src/components/main/HeaderSection/Header/index.tsx deleted file mode 100644 index baef1b6b..00000000 --- a/src/components/main/HeaderSection/Header/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import Image from 'next/image'; -import { FILTER_OPTIONS } from '@/constants/filter'; -import { useCategory } from '@/store/useFilterStore'; - -export default function Header() { - const category = useCategory(); - - const selectedOption = FILTER_OPTIONS.find((option) => option.id === (category ?? '')); - - return ( -
- {selectedOption && 타이틀 로고} -

{category || '전체'}

-
- ); -} diff --git a/src/components/main/HeaderSection/SearchBar/index.tsx b/src/components/main/HeaderSection/SearchBar/index.tsx index 49bb09ed..58023c67 100644 --- a/src/components/main/HeaderSection/SearchBar/index.tsx +++ b/src/components/main/HeaderSection/SearchBar/index.tsx @@ -1,15 +1,16 @@ import type { ChangeEvent, FormEvent } from 'react'; import { useCallback, useEffect, useState } from 'react'; -import Search from 'public/icons/Search'; +import Image from 'next/image'; import { useKeyword, useSetKeyword, useSetPage } from '@/store/useFilterStore'; export default function SearchBar() { const keyword = useKeyword(); - const setPage = useSetPage(); const setKeyword = useSetKeyword(); const [searchValue, setSearchValue] = useState(keyword || ''); + const setPage = useSetPage(); + const handleSearchChange = useCallback( (e: ChangeEvent) => { const { value } = e.target; @@ -38,18 +39,18 @@ export default function SearchBar() { }, [keyword]); return ( -
- + +
); } diff --git a/src/components/main/HeaderSection/index.tsx b/src/components/main/HeaderSection/index.tsx index 3827f494..0bebfb55 100644 --- a/src/components/main/HeaderSection/index.tsx +++ b/src/components/main/HeaderSection/index.tsx @@ -1,11 +1,33 @@ -import Header from '@/components/main/HeaderSection/Header'; +import { memo } from 'react'; +import FilterList from '@/components/main/HeaderSection/FilterList'; import SearchBar from '@/components/main/HeaderSection/SearchBar'; +import Skeleton from '@/components/shared/Skeleton'; -export default function HeaderSection() { +function HeaderSection() { return ( -
-
- +
+ {/* 헤더 영역 */} +
+

전체 카테고리

+ +
+ + {/* 필터 영역 */} + +
+ ); +} + +export default memo(HeaderSection); + +export function HeaderSkeleton() { + return ( +
+
+

전체 카테고리

+ +
+
); } diff --git a/src/components/main/CardSection/CardContent/index.tsx b/src/components/main/MainCardSection/CardSection/CardContent/index.tsx similarity index 92% rename from src/components/main/CardSection/CardContent/index.tsx rename to src/components/main/MainCardSection/CardSection/CardContent/index.tsx index f57d24bd..a3da27b2 100644 --- a/src/components/main/CardSection/CardContent/index.tsx +++ b/src/components/main/MainCardSection/CardSection/CardContent/index.tsx @@ -50,8 +50,8 @@ export default function CardContent({ gathering }: CardContentProps) {
- {groupName} - + {groupName} + {category} | {location} diff --git a/src/components/main/CardSection/CardImage/index.tsx b/src/components/main/MainCardSection/CardSection/CardImage/index.tsx similarity index 83% rename from src/components/main/CardSection/CardImage/index.tsx rename to src/components/main/MainCardSection/CardSection/CardImage/index.tsx index b36a3f70..37bdb225 100644 --- a/src/components/main/CardSection/CardImage/index.tsx +++ b/src/components/main/MainCardSection/CardSection/CardImage/index.tsx @@ -1,5 +1,4 @@ import { useMemo, useState } from 'react'; -import { Bagel_Fat_One } from 'next/font/google'; import Image from 'next/image'; import Tag from '@/components/shared/Tag'; import type { GetGatheringResponse } from '@manchui-api'; @@ -8,8 +7,6 @@ interface CardImageProps { gathering: GetGatheringResponse['data']['gatheringList'][number]; } -const bagelFatOne = Bagel_Fat_One({ weight: '400', subsets: ['latin'] }); - export default function CardImage({ gathering }: CardImageProps) { const { gatheringImage, currentUsers, maxUsers, closed, gatheringDate } = gathering; @@ -26,10 +23,7 @@ export default function CardImage({ gathering }: CardImageProps) { const [imageSrc, setImageSrc] = useState(gatheringImage); - const handleImageError = () => { - setImageSrc('/images/no-img.png'); - }; - + const handleImageError = () => setImageSrc('/images/no-img.png'); return (
{showTag && }
{(currentUsers >= maxUsers || closed) && (
- {closed ? 'CLOSED' : 'FULL'} + {closed ? 'CLOSED' : 'FULL'}
)}
diff --git a/src/components/main/MainCardSection/CardSection/index.tsx b/src/components/main/MainCardSection/CardSection/index.tsx new file mode 100644 index 00000000..4dd2c64f --- /dev/null +++ b/src/components/main/MainCardSection/CardSection/index.tsx @@ -0,0 +1,51 @@ +import { memo } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import CardContent from '@/components/main/MainCardSection/CardSection/CardContent'; +import CardImage from '@/components/main/MainCardSection/CardSection/CardImage'; +import type { GetGatheringResponse } from '@manchui-api'; + +interface CardSectionProps { + gathering: GetGatheringResponse['data']['gatheringList'][number]; +} + +function CardSection({ gathering }: CardSectionProps) { + return ( + + + + + ); +} + +export default memo(CardSection); + +export function CardSkeleton() { + return ( +
+
+
+ ); +} + +export function MessageWithLink({ message, buttonText, link, onClick }: { buttonText: string; link?: string; message?: string; onClick?: () => void }) { + return ( +
+ {message} + {link ? ( + + {buttonText} + 오류 화살표 + + ) : ( + + )} +
+ ); +} diff --git a/src/components/main/MainCardSection/index.tsx b/src/components/main/MainCardSection/index.tsx index 3daf13e1..e66de346 100644 --- a/src/components/main/MainCardSection/index.tsx +++ b/src/components/main/MainCardSection/index.tsx @@ -1,29 +1,32 @@ -import CardSection, { CardSkeleton, MessageWithLink } from '@/components/main/CardSection'; +import { useMemo } from 'react'; +import CardSection, { CardSkeleton, MessageWithLink } from '@/components/main/MainCardSection/CardSection'; import NoData from '@/components/shared/NoData'; +import PAGE_SIZE_BY_DEVICE from '@/constants/pageSize'; +import useDeviceState from '@/hooks/useDeviceState'; import type { GetGatheringResponse } from '@manchui-api'; interface MainCardSectionProps { isError: boolean; isLoading: boolean; - mainData: GetGatheringResponse['data']['gatheringList']; - pageSize: number; + mainData: GetGatheringResponse['data']['gatheringList'][number][] | undefined; scrollRef?: React.RefObject; } -export default function MainCardSection({ isLoading, isError, mainData, pageSize, scrollRef }: MainCardSectionProps) { +export default function MainCardSection({ isLoading, isError, mainData, scrollRef }: MainCardSectionProps) { + const deviceState = useDeviceState(); + const pageSize = useMemo(() => PAGE_SIZE_BY_DEVICE.MAIN[deviceState], [deviceState]); + return ( -
-
- {isLoading && !isError - ? Array.from({ length: pageSize }).map((_, idx) => ) - : mainData.map((gathering) => )} - {mainData.length === 0 && !isError && !isLoading && } - {isError && ( -
- window.location.reload()} /> -
- )} -
+
+ {isLoading + ? Array.from({ length: pageSize }).map((_, idx) => ) + : mainData?.map((gathering) => )} + {mainData?.length === 0 && !isError && !isLoading && } + {isError && ( +
+ window.location.reload()} /> +
+ )}
); } diff --git a/src/components/main/MainContainer/index.tsx b/src/components/main/MainContainer/index.tsx deleted file mode 100644 index 09d64830..00000000 --- a/src/components/main/MainContainer/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function MainContainer({ children }: { children: React.ReactNode }) { - return
{children}
; -} diff --git a/src/components/main/SpeedDial/SpeedCreate.tsx b/src/components/main/SpeedDial/SpeedCreate.tsx deleted file mode 100644 index fd2686d7..00000000 --- a/src/components/main/SpeedDial/SpeedCreate.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Props } from '@/components/shared/Svg'; -import { Svg } from '@/components/shared/Svg'; - -export default function SpeedCreate({ color = '#FFFFFF', className, ...props }: Props) { - return ( - - - - - ); -} diff --git a/src/components/main/SpeedDial/SpeedIntroduce.tsx b/src/components/main/SpeedDial/SpeedIntroduce.tsx deleted file mode 100644 index da06e390..00000000 --- a/src/components/main/SpeedDial/SpeedIntroduce.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { Props } from '@/components/shared/Svg'; -import { Svg } from '@/components/shared/Svg'; - -export default function SpeedIntroduce({ color = '#FFFFFF', className, ...props }: Props) { - return ( - - - - - ); -} diff --git a/src/components/main/SpeedDial/SpeedReview.tsx b/src/components/main/SpeedDial/SpeedReview.tsx deleted file mode 100644 index 311d803b..00000000 --- a/src/components/main/SpeedDial/SpeedReview.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Props } from '@/components/shared/Svg'; -import { Svg } from '@/components/shared/Svg'; - -export default function SpeedReview({ color = '#FFFFFF', className, ...props }: Props) { - return ( - - - - ); -} diff --git a/src/components/main/SpeedDial/index.tsx b/src/components/main/SpeedDial/index.tsx deleted file mode 100644 index cb52bf74..00000000 --- a/src/components/main/SpeedDial/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useState } from 'react'; -import { AnimatePresence } from 'framer-motion'; -import * as m from 'framer-motion/m'; -import Device from '@/constants/device'; -import { SPEED_DIAL_BUTTONS } from '@/constants/speedDial'; -import useDeviceState from '@/hooks/useDeviceState'; -import useInternalRouter from '@/hooks/useInternalRouter'; - -export default function SpeedDial() { - const [isOpen, setIsOpen] = useState(false); - - const router = useInternalRouter(); - - const deviceState = useDeviceState(); - - const toggleSpeedDial = () => setIsOpen((prev) => !prev); - - if (deviceState !== Device.Tablet && deviceState !== Device.Mobile) { - return null; - } - - return ( -
- - {isOpen && ( - - {SPEED_DIAL_BUTTONS.map((button, i) => ( - -
-
- {button.label} -
-
- -
- - ))} - - )} - - -
- ); -} diff --git a/src/components/mypage/card-style/index.tsx b/src/components/mypage/card-style/index.tsx index cb57ce8d..f4792b73 100644 --- a/src/components/mypage/card-style/index.tsx +++ b/src/components/mypage/card-style/index.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import Lottie from 'lottie-react'; +import dynamic from 'next/dynamic'; import getMyAttendance from '@/apis/mypage/get-mypage-attendance'; import getMyGathering from '@/apis/mypage/get-mypage-gathring'; -import { MessageWithLink } from '@/components/main/CardSection'; +import { MessageWithLink } from '@/components/main/MainCardSection/CardSection'; import PaginationBtn from '@/components/shared/PaginationBtn'; import useFilterStore from '@/store/useFilterStore'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -13,6 +13,8 @@ import MyReviewList from '../my-review-list'; import Empty from 'public/lottie/empty.json'; +const Lottie = dynamic(() => import('lottie-light-react'), { ssr: false }); + export function CardComponents({ category }: { category: string }) { const queryClient = useQueryClient(); const [review, setReview] = useState('작성 가능한 리뷰'); diff --git a/src/components/mypage/my-review-list/index.tsx b/src/components/mypage/my-review-list/index.tsx index c5fd7ade..e655a969 100644 --- a/src/components/mypage/my-review-list/index.tsx +++ b/src/components/mypage/my-review-list/index.tsx @@ -1,7 +1,7 @@ -import Lottie from 'lottie-react'; +import dynamic from 'next/dynamic'; import getMyReviewable from '@/apis/mypage/get-mypage-reviewable'; import getMyReviews from '@/apis/mypage/get-mypage-reviews'; -import { MessageWithLink } from '@/components/main/CardSection'; +import { MessageWithLink } from '@/components/main/MainCardSection/CardSection'; import PaginationBtn from '@/components/shared/PaginationBtn'; import useFilterStore from '@/store/useFilterStore'; import { useQuery } from '@tanstack/react-query'; @@ -11,6 +11,8 @@ import { ReviewableCard } from '../card-style/reviewable-card'; import Empty from 'public/lottie/empty.json'; +const Lottie = dynamic(() => import('lottie-light-react'), { ssr: false }); + export default function MyReviewList({ category, review, handleRemoveItem }: { category: string; handleRemoveItem: (id: number) => void; review: string }) { const isReview = category === '나의 리뷰' && review === '작성 가능한 리뷰'; const { page } = useFilterStore(); diff --git a/src/components/review/FilterSection/index.tsx b/src/components/review/FilterSection/index.tsx index 819af469..057c05c6 100644 --- a/src/components/review/FilterSection/index.tsx +++ b/src/components/review/FilterSection/index.tsx @@ -1,7 +1,7 @@ import { type Dispatch, type SetStateAction } from 'react'; -import CategoryList from '@/components/main/FilterSection/CategoryList'; -import DateDropdown from '@/components/main/FilterSection/DateDropdown'; -import RegionDropdown from '@/components/main/FilterSection/RegionDropdown'; +import CategoryList from '@/components/main/HeaderSection/FilterList/CategoryList'; +import DateDropdown from '@/components/main/HeaderSection/FilterList/DateDropdown'; +import RegionDropdown from '@/components/main/HeaderSection/FilterList/RegionDropdown'; import SortToggle from './SortToggle'; diff --git a/src/components/review/ReviewCardList/index.tsx b/src/components/review/ReviewCardList/index.tsx index a6a3a2df..8c3f3a00 100644 --- a/src/components/review/ReviewCardList/index.tsx +++ b/src/components/review/ReviewCardList/index.tsx @@ -1,7 +1,7 @@ /* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */ // 스켈레톤 나중에 추가 -import { MessageWithLink } from '@/components/main/CardSection'; +import { MessageWithLink } from '@/components/main/MainCardSection/CardSection'; import type { GetReviewResponse } from '@manchui-api'; import { ReviewCard } from '../ReviewCard'; @@ -18,8 +18,8 @@ export default function ReviewCardList({ data, isLoading, isError, skeletonCount
{data?.reviewContentList.map((reviewContent) => )} {data?.reviewCount === 0 && ( -
- +
+
)} diff --git a/src/components/shared/Alert/index.tsx b/src/components/shared/Alert/index.tsx new file mode 100644 index 00000000..25c1fc1c --- /dev/null +++ b/src/components/shared/Alert/index.tsx @@ -0,0 +1,29 @@ +import { createPortal } from 'react-dom'; +import { useAlertStore } from '@/store/useAlertStore'; + +interface AlertProps { + children: React.ReactNode; + type?: 'region' | 'date'; +} + +export default function Alert({ children, type = 'region' }: AlertProps) { + const { isAlertOpen, isDateAlertOpen, closeAlert, closeDateAlert } = useAlertStore(); + + // type에 따라 서로 다른 상태와 핸들러 선택 + const isOpen = type === 'region' ? isAlertOpen : isDateAlertOpen; + const closeHandler = type === 'region' ? closeAlert : closeDateAlert; + + if (!isOpen) return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) closeHandler(); + }} + > +
{children}
+
, + document.body, + ); +} diff --git a/src/components/shared/Calendar/CalendarGrid/index.tsx b/src/components/shared/Calendar/CalendarGrid/index.tsx index 9ea09c6f..5bae5e40 100644 --- a/src/components/shared/Calendar/CalendarGrid/index.tsx +++ b/src/components/shared/Calendar/CalendarGrid/index.tsx @@ -67,7 +67,10 @@ export default function CalendarGrid({ currentDate, onDateSelect, rangeStart, ra 'text-gray-300': isPastDate, }); - const hoverClasses = selectionType === 'range' && (!rangeStart || (rangeStart && !rangeEnd)) ? 'hover:bg-blue-700' : ''; + const hoverClasses = + selectionType === 'range' && (!rangeStart || (rangeStart && !rangeEnd)) + ? `hover:bg-blue-700 ${!isSunday && !isSaturday && !isToday ? 'hover:text-white' : ''}` + : ''; const dayClasses = twMerge( 'cursor-pointer rounded-lg py-[6px] text-center text-sm font-medium transition duration-200 ease-in-out', diff --git a/src/components/shared/Calendar/CalendarSelector/index.tsx b/src/components/shared/Calendar/CalendarSelector/index.tsx index 621bec4f..8f643520 100644 --- a/src/components/shared/Calendar/CalendarSelector/index.tsx +++ b/src/components/shared/Calendar/CalendarSelector/index.tsx @@ -1,6 +1,5 @@ import { useCallback, useMemo } from 'react'; -import ArrowBtn from 'public/icons/ArrowBtn'; -import DownArrow from 'public/icons/DownArrow'; +import Image from 'next/image'; interface CalendarSelectorProps { currentDate: Date; @@ -29,8 +28,9 @@ export default function CalendarSelector({ setDropOpen, dropOpen, currentDate, s return (
- setDropOpen(!dropOpen)} className="flex cursor-pointer items-center gap-1 text-13-15-response font-semibold text-gray-700"> - {currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월 + setDropOpen(!dropOpen)} className="flex cursor-pointer items-center text-md font-semibold tablet:text-lg"> + {currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월{' '} + 오른쪽 {dropOpen && (
diff --git a/src/components/shared/Calendar/index.tsx b/src/components/shared/Calendar/index.tsx index 047330fc..4f2c9998 100644 --- a/src/components/shared/Calendar/index.tsx +++ b/src/components/shared/Calendar/index.tsx @@ -1,8 +1,15 @@ -/* eslint-disable tailwindcss/no-custom-classname */ import { useCallback, useState } from 'react'; import clsx from 'clsx'; -import CalendarGrid from '@/components/shared/Calendar/CalendarGrid'; -import CalendarSelector from '@/components/shared/Calendar/CalendarSelector'; +import dynamic from 'next/dynamic'; + +const CalendarSelector = dynamic(() => import('@/components/shared/Calendar/CalendarSelector'), { + loading: () =>
Loading...
, + ssr: false, +}); +const CalendarGrid = dynamic(() => import('@/components/shared/Calendar/CalendarGrid'), { + loading: () =>
Loading...
, + ssr: false, +}); interface CalendarProps { endDate?: string | null; diff --git a/src/components/shared/ErrorBoundary/index.tsx b/src/components/shared/ErrorBoundary/index.tsx new file mode 100644 index 00000000..3d1dfb8e --- /dev/null +++ b/src/components/shared/ErrorBoundary/index.tsx @@ -0,0 +1,70 @@ +import type { ErrorInfo } from 'react'; +import React from 'react'; + +interface Props { + children: React.ReactNode; + fallbackComponent?: React.ReactNode; +} + +interface State { + hasError: boolean; // 에러가 발생했는지 여부 +} + +// ErrorBoundary는 라이프 사이클을 사용해야 하기 때문에 클래스형 컴포넌트를 사용해야 할 수 밖에 없습니다. +class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + // 1. 이 라이프 사이클에서 에러가 발생하면 컴포넌트를 업데이트를 하면서 리턴된 값을 가지고 state를 업데이트 합니다. + // 2. 이렇게 업데이트된 state는 + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.log({ error, errorInfo }); + } + + render() { + const { hasError } = this.state; + const { fallbackComponent, children } = this.props; + + // 3. 다시 리렌더링이 될 거고 이 당시에 state를 바라보고 에러가 발생을 했다면 아래 컴포넌트로 대체를 해줍니다. + if (hasError) { + if (fallbackComponent != null) return fallbackComponent; + + // 공통 에러 컴포넌트 + return ( +
+
+
+

+ + + +

+

알 수 없는 문제가 발생했습니다.

+

잠시 후 다시 시도해주세요 :)

+ + +
+
+
+ ); + } + + // 4. 에러가 발생하지 않았다면 기존에 우리가 그려주고 싶은 컴포넌트를 그려주는 역할을 합니다. + return children; + } +} + +export default ErrorBoundary; diff --git a/src/components/shared/GNB/Notification/index.tsx b/src/components/shared/GNB/Notification/index.tsx index 262f414a..35281fd3 100644 --- a/src/components/shared/GNB/Notification/index.tsx +++ b/src/components/shared/GNB/Notification/index.tsx @@ -28,9 +28,7 @@ export default function Notification() { useEffect( function handleScrollFetch() { - if ((isIntersecting || isIntersectingInMobile) && hasNextPage) { - void fetchNextPage(); - } + if ((isIntersecting || isIntersectingInMobile) && hasNextPage) void fetchNextPage(); }, [isIntersecting, hasNextPage, isIntersectingInMobile, fetchNextPage], ); diff --git a/src/components/shared/GNB/index.tsx b/src/components/shared/GNB/index.tsx index 53c22dc3..c8e8ec00 100644 --- a/src/components/shared/GNB/index.tsx +++ b/src/components/shared/GNB/index.tsx @@ -52,11 +52,11 @@ export default function GNB() { }, [login, data, logoutStore, queryClient, updateUser]); return ( -