diff --git a/.storybook/main.ts b/.storybook/main.ts index c59de776..36e2cecc 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,8 +5,14 @@ interface StorybookConfig extends BaseStorybookConfig { } const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], - addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions"], + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-onboarding", + "@storybook/addon-interactions", + "@storybook/addon-styling", + ], framework: { name: "@storybook/nextjs", options: {}, @@ -20,6 +26,9 @@ const config: StorybookConfig = { }; return config; }, + docs: { + autodocs: "tag", + }, }; export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index b3a90d14..2469e5aa 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -26,7 +26,9 @@ const withProviders: Decorator = (Story) => { return ( - +
+ +
); @@ -34,12 +36,78 @@ const withProviders: Decorator = (Story) => { const preview: Preview = { parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/i, }, }, + viewport: { + defaultViewport: "desktop", + viewports: { + // 모바일 - 아이폰 + iphone12: { + name: "iPhone 12", + styles: { + width: "390px", + height: "844px", + }, + }, + iphone12promax: { + name: "iPhone 12 Pro Max", + styles: { + width: "428px", + height: "926px", + }, + }, + // 모바일 - 갤럭시 + galaxys8: { + name: "Galaxy S8", + styles: { + width: "360px", + height: "740px", + }, + }, + galaxys20: { + name: "Galaxy S20", + styles: { + width: "412px", + height: "915px", + }, + }, + // 태블릿 - 아이패드 + ipadMini: { + name: "iPad Mini", + styles: { + width: "768px", + height: "1024px", + }, + }, + ipadAir: { + name: "iPad Air", + styles: { + width: "820px", + height: "1180px", + }, + }, + ipadPro: { + name: "iPad Pro", + styles: { + width: "1024px", + height: "1366px", + }, + }, + // 데스크톱 + desktop: { + name: "Desktop", + styles: { + width: "1440px", + height: "1024px", + }, + }, + }, + }, }, decorators: [withProviders], }; diff --git a/package-lock.json b/package-lock.json index 11e5c410..cd98da26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@chromatic-com/storybook": "^3.2.2", "@storybook/addon-essentials": "^8.4.4", "@storybook/addon-interactions": "^8.4.4", + "@storybook/addon-links": "^8.4.7", "@storybook/addon-onboarding": "^8.4.4", "@storybook/addon-styling": "^2.0.0", "@storybook/blocks": "^8.4.4", @@ -5180,6 +5181,31 @@ "storybook": "^8.4.6" } }, + "node_modules/@storybook/addon-links": { + "version": "8.4.7", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.4.7.tgz", + "integrity": "sha512-L/1h4dMeMKF+MM0DanN24v5p3faNYbbtOApMgg7SlcBT/tgo3+cAjkgmNpYA8XtKnDezm+T2mTDhB8mmIRZpIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf": "^0.1.11", + "@storybook/global": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^8.4.7" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@storybook/addon-measure": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.4.6.tgz", @@ -5628,9 +5654,9 @@ } }, "node_modules/@storybook/core": { - "version": "8.4.6", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.4.6.tgz", - "integrity": "sha512-WeojVtHy0/t50tzw/15S+DLzKsj8BN9yWdo3vJMvm+nflLFvfq1XvD9WGOWeaFp8E/o3AP+4HprXG0r42KEJtA==", + "version": "8.4.7", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.4.7.tgz", + "integrity": "sha512-7Z8Z0A+1YnhrrSXoKKwFFI4gnsLbWzr8fnDCU6+6HlDukFYh8GHRcZ9zKfqmy6U3hw2h8H5DrHsxWfyaYUUOoA==", "dev": true, "license": "MIT", "dependencies": { @@ -15754,13 +15780,13 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "8.4.6", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.6.tgz", - "integrity": "sha512-J6juZSZT2u3PUW0QZYZZYxBq6zU5O0OrkSgkMXGMg/QrS9to9IHmt4FjEMEyACRbXo8POcB/fSXa3VpGe7bv3g==", + "version": "8.4.7", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.7.tgz", + "integrity": "sha512-RP/nMJxiWyFc8EVMH5gp20ID032Wvk+Yr3lmKidoegto5Iy+2dVQnUoElZb2zpbVXNHWakGuAkfI0dY1Hfp/vw==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core": "8.4.6" + "@storybook/core": "8.4.7" }, "bin": { "getstorybook": "bin/index.cjs", diff --git a/package.json b/package.json index 656422c6..dbab117e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@chromatic-com/storybook": "^3.2.2", "@storybook/addon-essentials": "^8.4.4", "@storybook/addon-interactions": "^8.4.4", + "@storybook/addon-links": "^8.4.7", "@storybook/addon-onboarding": "^8.4.4", "@storybook/addon-styling": "^2.0.0", "@storybook/blocks": "^8.4.4", diff --git a/src/app/(pages)/albaList/components/SortSection.tsx b/src/app/(pages)/albaList/components/SortSection.tsx new file mode 100644 index 00000000..deac4da9 --- /dev/null +++ b/src/app/(pages)/albaList/components/SortSection.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React, { useState } from "react"; +import FilterDropdown from "@/app/components/button/dropdown/FilterDropdown"; +import { formSortOptions } from "@/constants/formOptions"; + +type FormSortOption = (typeof formSortOptions)[keyof typeof formSortOptions]; + +const SORT_OPTIONS = [ + { label: "최신순", value: formSortOptions.MOST_RECENT }, + { label: "시급높은순", value: formSortOptions.HIGHEST_WAGE }, + { label: "지원자 많은순", value: formSortOptions.MOST_APPLIED }, + { label: "스크랩 많은순", value: formSortOptions.MOST_SCRAPPED }, +]; + +export default function SortSection() { + const [currentSort, setCurrentSort] = useState(formSortOptions.MOST_RECENT); + + const currentLabel = SORT_OPTIONS.find((opt) => opt.value === currentSort)?.label || SORT_OPTIONS[0].label; + + const handleSortChange = (selected: string) => { + const option = SORT_OPTIONS.find((opt) => opt.label === selected); + if (option) { + setCurrentSort(option.value); + } + }; + + return ( + option.label)} + className="!w-28 md:!w-40" + initialValue={currentLabel} + onChange={handleSortChange} + /> + ); +} diff --git a/src/app/(pages)/albaList/layout.tsx b/src/app/(pages)/albaList/layout.tsx new file mode 100644 index 00000000..900388a3 --- /dev/null +++ b/src/app/(pages)/albaList/layout.tsx @@ -0,0 +1,17 @@ +import React, { Suspense } from "react"; + +export default function AlbaListLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
로딩 중...
+
+ } + > + {children} + + + ); +} diff --git a/src/app/(pages)/albaList/page.tsx b/src/app/(pages)/albaList/page.tsx index cc655d8a..dda444ec 100644 --- a/src/app/(pages)/albaList/page.tsx +++ b/src/app/(pages)/albaList/page.tsx @@ -1,5 +1,135 @@ "use client"; +import React, { useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import { useForms } from "@/hooks/queries/form/useForms"; +import FilterDropdown from "@/app/components/button/dropdown/FilterDropdown"; +import { filterRecruitingOptions } from "@/constants/filterOptions"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import SortSection from "./components/SortSection"; +import AlbaListItem from "@/app/components/card/cardList/AlbaListItem"; + +const FORMS_PER_PAGE = 10; + export default function AlbaList() { - return
AlbaList
; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // URL 쿼리 파라미터에서 필터 상태 가져오기 + const isRecruiting = searchParams.get("isRecruiting"); + + // 초기 마운트 시 필터 값 설정 + useEffect(() => { + const params = new URLSearchParams(searchParams); + if (!params.has("isRecruiting")) { + params.set("isRecruiting", "true"); + router.push(`${pathname}?${params.toString()}`); + } + }, []); + + // 무한 스크롤을 위한 Intersection Observer 설정 + const { ref, inView } = useInView({ + threshold: 0.1, + triggerOnce: false, + rootMargin: "100px", + }); + + // 알바폼 목록 조회 + const { data, isLoading, error, hasNextPage, fetchNextPage, isFetchingNextPage } = useForms({ + limit: FORMS_PER_PAGE, + isRecruiting: isRecruiting === "true" ? true : isRecruiting === "false" ? false : undefined, + }); + + // 모집 여부 필터 변경 함수 + const handleRecruitingFilter = (selected: string) => { + const option = filterRecruitingOptions.find((opt) => opt.label === selected); + if (option) { + const params = new URLSearchParams(searchParams); + if (selected === "전체") { + params.delete("isRecruiting"); + } else { + params.set("isRecruiting", String(option.value)); + } + router.push(`${pathname}?${params.toString()}`); + } + }; + + // 현재 필터 상태에 따른 초기값 설정 + const getInitialRecruitingValue = (isRecruiting: string | null) => { + if (!isRecruiting) return "전체"; + const option = filterRecruitingOptions.find((opt) => String(opt.value) === isRecruiting); + return option?.label || "전체"; + }; + + // 스크롤이 하단에 도달하면 다음 페이지 로드 + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage, isFetchingNextPage]); + + // 에러 상태 처리 + if (error) { + return ( +
+

알바 목록을 불러오는데 실패했습니다.

+
+ ); + } + + // 로딩 상태 처리 + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* 필터 드롭다운 섹션 */} +
+
+ option.label)} + initialValue={getInitialRecruitingValue(isRecruiting)} + onChange={handleRecruitingFilter} + /> + +
+
+ + {/* 알바폼 목록 랜더링 */} + {!data?.pages?.[0]?.data?.length ? ( +
+

등록된 알바 공고가 없습니다.

+
+ ) : ( +
+
+ {data?.pages.map((page) => ( + + {page.data.map((form) => ( +
+ +
+ ))} +
+ ))} +
+ + {/* 무한 스크롤 트리거 영역 */} +
+ {isFetchingNextPage && ( +
+
+
+ )} +
+
+ )} +
+ ); } diff --git a/src/app/(pages)/mypage/components/FilterBar/SortSection.tsx b/src/app/(pages)/mypage/components/FilterBar/SortSection.tsx index af99a553..aee7ccbe 100644 --- a/src/app/(pages)/mypage/components/FilterBar/SortSection.tsx +++ b/src/app/(pages)/mypage/components/FilterBar/SortSection.tsx @@ -3,14 +3,14 @@ import React from "react"; import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; -import { useSortStore } from "@/store/sortStore"; +import { useMySortStore } from "@/store/mySortStore"; import FilterDropdown from "@/app/components/button/dropdown/FilterDropdown"; import { SORT_OPTIONS, DEFAULT_SORT_VALUES } from "./constants"; export default function SortSection() { const searchParams = useSearchParams(); const currentTab = (searchParams.get("tab") || "posts") as keyof typeof SORT_OPTIONS; - const { orderBy, setOrderBy } = useSortStore(); + const { orderBy, setOrderBy } = useMySortStore(); const options = SORT_OPTIONS[currentTab]; const currentLabel = options.find((opt) => opt.value === orderBy[currentTab])?.label || options[0].label; const isReadOnly = currentTab === "comments"; diff --git a/src/app/(pages)/mypage/components/sections/PostsSection.tsx b/src/app/(pages)/mypage/components/sections/PostsSection.tsx index edf826f2..d6d4aa8c 100644 --- a/src/app/(pages)/mypage/components/sections/PostsSection.tsx +++ b/src/app/(pages)/mypage/components/sections/PostsSection.tsx @@ -3,7 +3,7 @@ import React, { useEffect } from "react"; import { useInView } from "react-intersection-observer"; import { useMyPosts } from "@/hooks/queries/user/me/useMyPosts"; -import { useSortStore } from "@/store/sortStore"; +import { useMySortStore } from "@/store/mySortStore"; import type { PostListType } from "@/types/response/post"; // 한 페이지당 게시글 수 @@ -11,7 +11,7 @@ const POSTS_PER_PAGE = 10; export default function PostsSection() { // 정렬 상태 관리 - const { orderBy } = useSortStore(); + const { orderBy } = useMySortStore(); // 무한 스크롤을 위한 Intersection Observer 설정 const { ref, inView } = useInView({ diff --git a/src/app/(pages)/mypage/components/sections/ScrapsSection.tsx b/src/app/(pages)/mypage/components/sections/ScrapsSection.tsx index 32690eb3..2d6b613d 100644 --- a/src/app/(pages)/mypage/components/sections/ScrapsSection.tsx +++ b/src/app/(pages)/mypage/components/sections/ScrapsSection.tsx @@ -3,7 +3,7 @@ import React, { useEffect } from "react"; import { useInView } from "react-intersection-observer"; import { useMyScraps } from "@/hooks/queries/user/me/useMyScraps"; -import { useSortStore } from "@/store/sortStore"; +import { useMySortStore } from "@/store/mySortStore"; import type { FormListType } from "@/types/response/form"; import FilterDropdown from "@/app/components/button/dropdown/FilterDropdown"; import { filterPublicOptions, filterRecruitingOptions } from "@/constants/filterOptions"; @@ -19,7 +19,7 @@ export default function ScrapsSection() { // URL 쿼리 파라미터에서 필터 상태 가져오기 const isPublic = searchParams.get("isPublic"); const isRecruiting = searchParams.get("isRecruiting"); - const { orderBy } = useSortStore(); + const { orderBy } = useMySortStore(); // 초기 마운트 시 필터 값 설정 useEffect(() => { diff --git a/src/app/api/forms/route.ts b/src/app/api/forms/route.ts index 73744db2..7b67d189 100644 --- a/src/app/api/forms/route.ts +++ b/src/app/api/forms/route.ts @@ -37,12 +37,6 @@ export async function POST(req: NextRequest) { // 알바폼 목록 조회 export async function GET(req: NextRequest) { try { - const accessToken = cookies().get("accessToken")?.value; - - if (!accessToken) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); - } - const { searchParams } = new URL(req.url); const params = { cursor: searchParams.get("cursor"), diff --git a/src/app/components/card/cardList/AlbaListItem.tsx b/src/app/components/card/cardList/AlbaListItem.tsx new file mode 100644 index 00000000..544ace99 --- /dev/null +++ b/src/app/components/card/cardList/AlbaListItem.tsx @@ -0,0 +1,177 @@ +import Image from "next/image"; +import { formatRecruitDate } from "@/utils/workDayFormatter"; +import { getRecruitmentStatus, getRecruitmentDday } from "@/utils/recruitDateFormatter"; +import { BsThreeDotsVertical } from "react-icons/bs"; +import Chip from "@/app/components/chip/Chip"; +import { useState, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import useModalStore from "@/store/modalStore"; +import Indicator from "../../pagination/Indicator"; +import { FormListType } from "@/types/response/form"; +import { useFormScrap } from "@/hooks/queries/form/useFormScap"; + +/** + * 알바폼 리스트 아이템 컴포넌트 + * 알바폼 정보를 카드 형태로 표시하며, 이미지 인디케이터와 지원하기/스크랩 기능을 포함 + */ +const AlbaListItem = ({ + id, + imageUrls, + isPublic, + recruitmentStartDate, + recruitmentEndDate, + title, + applyCount, + scrapCount, +}: FormListType) => { + // 라우터 및 상태 관리 + const router = useRouter(); + const { openModal } = useModalStore(); + const { scrap, isLoading: isScrapLoading } = useFormScrap(id); + const [showDropdown, setShowDropdown] = useState(false); // 드롭다운 메뉴 표시 상태 + const [currentImageIndex, setCurrentImageIndex] = useState(0); // 현재 이미지 인덱스 + const dropdownRef = useRef(null); // 드롭다운 메뉴 참조 + + // 모집 상태 및 D-day 계산 + const recruitmentStatus = getRecruitmentStatus(recruitmentEndDate); + const dDay = getRecruitmentDday(recruitmentEndDate); + + // 드롭다운 메뉴 외부 클릭 감지 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(false); + } + }; + + if (showDropdown) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showDropdown]); + + // 지원하기 + const handleFormApplication = () => { + setShowDropdown(false); + openModal("customForm", { + isOpen: true, + title: "지원하기", + content: "정말로 지원하시겠습니까?", + onConfirm: () => { + router.push(`/apply/${id}`); + }, + onCancel: () => {}, + }); + }; + + // 스크랩 + const handleFormScrap = () => { + setShowDropdown(false); + openModal("customForm", { + isOpen: true, + title: "스크랩 확인", + content: "이 공고를 스크랩하시겠습니까?", + onConfirm: () => { + scrap(); + }, + onCancel: () => {}, + }); + }; + + return ( +
+ {/* 이미지 슬라이더 영역 */} +
+ {/* 현재 이미지 */} + {imageUrls[currentImageIndex] && ( + {`Recruit + )} + + {/* 이미지 인디케이터 */} + {imageUrls.length > 1 && ( +
+ +
+ )} +
+ + {/* 콘텐츠 영역 */} +
+ {/* 상단 영역 */} +
+ {/* 상태 표시 영역 (공개여부, 모집상태, 날짜) */} +
+
+
+ + + + {formatRecruitDate(recruitmentStartDate, true)} ~ {formatRecruitDate(recruitmentEndDate, true)} + +
+
+ {/* 케밥 메뉴 */} +
+ + {/* 드롭다운 메뉴 */} + {showDropdown && ( +
+ + +
+ )} +
+
+ + {/* 제목 */} +
{title}
+
+ + {/* 통계 정보 영역 - mt-auto 제거하고 부모 컨테이너에 justify-between 추가 */} +
+
+ 지원자 {applyCount}명 +
+
+
+ 스크랩 {scrapCount}명 +
+
+
+ {dDay} +
+
+
+
+ ); +}; + +export default AlbaListItem; diff --git a/src/app/components/card/cardList/RecruitListItem.tsx b/src/app/components/card/cardList/RecruitListItem.tsx deleted file mode 100644 index 0c4e2f75..00000000 --- a/src/app/components/card/cardList/RecruitListItem.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import Image from "next/image"; -import { formatRecruitDate } from "@/utils/workDayFormatter"; -import { getRecruitmentStatus, getRecruitmentDday } from "@/utils/recruitDateFormatter"; -import { BsThreeDotsVertical } from "react-icons/bs"; -import Chip from "@/app/components/chip/Chip"; -import { useState, useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; -import useModalStore from "@/store/modalStore"; -import Indicator from "../../pagination/Indicator"; - -/** - * 채용 공고 리스트 아이템 컴포넌트 Props - */ -interface RecruitListItemProps { - id: string; // formId를 id로 변경 - imageUrls: string[]; // 이미지 URL 배열 - isPublic: boolean; // 공개 여부 - recruitmentStartDate: Date; // 모집 시작일 - recruitmentEndDate: Date; // 모집 종료일 - title: string; // 제목 - applyCount: number; // 지원자 수 - scrapCount: number; // 스크랩 수 - location: string; // 위치 -} - -/** - * 채용 공고 리스트 아이템 컴포넌트 - * 채용 공고 정보를 카드 형태로 표시하며, 이미지 인디케이터와 수정/삭제 기능을 포함 - */ -const RecruitListItem = ({ - id, - imageUrls, - isPublic, - recruitmentStartDate, - recruitmentEndDate, - title, - applyCount, - scrapCount, - location, -}: RecruitListItemProps) => { - // 라우터 및 상태 관리 - const router = useRouter(); - const { openModal } = useModalStore(); - const [showDropdown, setShowDropdown] = useState(false); // 드롭다운 메뉴 표시 상태 - const [currentImageIndex, setCurrentImageIndex] = useState(0); // 현재 표시 중인 이미지 인덱스 - const dropdownRef = useRef(null); // 드롭다운 메뉴 참조 - - // 모집 상태 및 D-day 계산 - const recruitmentStatus = getRecruitmentStatus(recruitmentEndDate); - const dDay = getRecruitmentDday(recruitmentEndDate); - - // 드롭다운 메뉴 외부 클릭 감지 - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowDropdown(false); - } - }; - - if (showDropdown) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [showDropdown]); - - // 수정 페이지로 이동 - const handleEdit = () => { - router.push(`/albaList/${id}`); - setShowDropdown(false); - }; - - // 삭제 모달 표시 - const handleDelete = () => { - setShowDropdown(false); - openModal("deleteForm", { - id, - isOpen: true, - title: "삭제 확인", - message: "정말로 삭제하시겠습니까?", - onConfirm: () => { - router.push("/albaList"); - router.refresh(); - }, - onCancel: () => {}, - }); - }; - - return ( -
- {/* 이미지 슬라이더 영역 */} -
- {/* 현재 이미지 */} - {imageUrls[currentImageIndex] && ( - {`Recruit - )} - - {/* 이미지 인디케이터 */} - {imageUrls.length > 1 && ( -
- -
- )} -
- - {/* 콘텐츠 영역 */} -
- {/* 상태 표시 영역 (공개여부, 모집상태, 날짜) */} -
-
-
- - - - {formatRecruitDate(recruitmentStartDate, true)} ~ {formatRecruitDate(recruitmentEndDate, true)} - - - {formatRecruitDate(recruitmentStartDate)} ~ {formatRecruitDate(recruitmentEndDate)} - -
-
- {/* 케밥 메뉴 */} -
- - {/* 드롭다운 메뉴 */} - {showDropdown && ( -
- - -
- )} -
-
- - {/* 제목 */} -

{title}

- - {/* 위치 정보 */} -

{location}

- - {/* 통계 정보 (지원자, 스크랩, D-day) */} -
-
- 지원자 {applyCount}명 -
-
-
- 스크랩 {scrapCount}명 -
-
-
- {dDay} -
-
-
-
- ); -}; - -export default RecruitListItem; diff --git a/src/app/components/card/cardList/ScrapListItem.tsx b/src/app/components/card/cardList/ScrapListItem.tsx new file mode 100644 index 00000000..766c0c85 --- /dev/null +++ b/src/app/components/card/cardList/ScrapListItem.tsx @@ -0,0 +1,176 @@ +import Image from "next/image"; +import { formatRecruitDate } from "@/utils/workDayFormatter"; +import { getRecruitmentStatus, getRecruitmentDday } from "@/utils/recruitDateFormatter"; +import { BsThreeDotsVertical } from "react-icons/bs"; +import Chip from "@/app/components/chip/Chip"; +import { useState, useEffect, useRef } from "react"; +import useModalStore from "@/store/modalStore"; +import Indicator from "../../pagination/Indicator"; +import { FormListType } from "@/types/response/form"; +import { useFormScrap } from "@/hooks/queries/form/useFormScap"; +import { useRouter } from "next/navigation"; + +/** + * 알바폼 스크랩 리스트 아이템 컴포넌트 + * 알바폼 스크랩 정보를 카드 형태로 표시하며, 이미지 인디케이터와 지원하기/스크랩 취소 기능을 포함 + */ +const ScrapListItem = ({ + id, + imageUrls, + isPublic, + recruitmentStartDate, + recruitmentEndDate, + title, + applyCount, + scrapCount, +}: FormListType) => { + const { openModal } = useModalStore(); + const router = useRouter(); + const { unscrap, isLoading: isUnscrapLoading } = useFormScrap(id); + const [showDropdown, setShowDropdown] = useState(false); // 드롭다운 메뉴 표시 상태 + const [currentImageIndex, setCurrentImageIndex] = useState(0); // 현재 표시 중인 이미지 인덱스 + const dropdownRef = useRef(null); // 드롭다운 메뉴 참조 + + // 모집 상태 및 D-day 계산 + const recruitmentStatus = getRecruitmentStatus(recruitmentEndDate); + const dDay = getRecruitmentDday(recruitmentEndDate); + + // 드롭다운 메뉴 외부 클릭 감지 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(false); + } + }; + + if (showDropdown) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showDropdown]); + + // 지원하기 + const handleFormApplication = () => { + setShowDropdown(false); + openModal("customForm", { + isOpen: true, + title: "지원하기", + content: "정말로 지원하시겠습니까?", + onConfirm: () => { + router.push(`/apply/${id}`); + }, + onCancel: () => {}, + }); + }; + + // 스크랩 취소 + const handleFormUnscrap = () => { + setShowDropdown(false); + openModal("customForm", { + isOpen: true, + title: "스크랩 취소 확인", + content: "정말로 스크랩을 취소하시겠습니까?", + onConfirm: () => { + unscrap(); + }, + onCancel: () => {}, + }); + }; + + return ( +
+ {/* 이미지 슬라이더 영역 */} +
+ {/* 현재 이미지 */} + {imageUrls[currentImageIndex] && ( + {`Recruit + )} + + {/* 이미지 인디케이터 */} + {imageUrls.length > 1 && ( +
+ +
+ )} +
+ + {/* 콘텐츠 영역 */} +
+ {/* 상단 영역 */} +
+ {/* 상태 표시 영역 (공개여부, 모집상태, 날짜) */} +
+
+
+ + + + {formatRecruitDate(recruitmentStartDate, true)} ~ {formatRecruitDate(recruitmentEndDate, true)} + +
+
+ {/* 케밥 메뉴 */} +
+ + {/* 드롭다운 메뉴 */} + {showDropdown && ( +
+ + +
+ )} +
+
+ + {/* 제목 */} +
{title}
+
+ + {/* 통계 정보 영역 - mt-auto 제거하고 부모 컨테이너에 justify-between 추가 */} +
+
+ 지원자 {applyCount}명 +
+
+
+ 스크랩 {scrapCount}명 +
+
+
+ {dDay} +
+
+
+
+ ); +}; + +export default ScrapListItem; diff --git a/src/app/components/chip/Chip.tsx b/src/app/components/chip/Chip.tsx index 01b8fbc3..69a5ad19 100644 --- a/src/app/components/chip/Chip.tsx +++ b/src/app/components/chip/Chip.tsx @@ -17,19 +17,19 @@ interface ChipProps { * @param textStyle - 추가 스타일 */ const Chip: React.FC = ({ label = "Label", variant, border, icon, textStyle = "" }: ChipProps) => { - const wrapperStyle = "rounded flex items-center justify-center"; + const wrapperStyle = "rounded flex items-center justify-center min-w-[60px] m-1"; const paddingStyle = icon ? "px-[10px] py-1 md:px-[14.5px] md:py-1 lg:px-[10px] lg:py-[6px]" : "px-2 py-1 md:px-[10px] lg:py-[6px] lg:px-3"; - const varioantStyle = + const variantStyle = variant === "positive" ? "bg-primary-orange-50 text-primary-orange-300" : "bg-line-100 text-grayscale-200"; const baseTextStyle = - "text-xs leading-[20px] md:text-sm md:leading-[24px] lg:text-base lg:leading-[26px] font-medium"; + "text-xs leading-[20px] md:leading-[24px] lg:text-base lg:leading-[26px] font-medium tracking-tight"; const borderStyle = border ? "border border-primary-orange-100" : ""; - const iconStyle = "flex items-center justify-center mr-[6px] md:mr-[8px] lg:mr-[9px]"; + const iconStyle = "flex items-center justify-center"; return ( -
+
{icon && {icon}} {label}
diff --git a/src/app/components/modal/ModalLayout.tsx b/src/app/components/modal/ModalLayout.tsx index 6b03609f..ea6b7a54 100644 --- a/src/app/components/modal/ModalLayout.tsx +++ b/src/app/components/modal/ModalLayout.tsx @@ -8,6 +8,7 @@ import SelectProgressModal from "./modals/confirm/SelectProgressModal"; import ChangePasswordModal from "./modals/form/ChangePasswordModal"; import EditMyProfileModal from "./modals/form/EditMyProfileModal"; import EditOwnerProfileModal from "./modals/form/EditOwnerProfileModal"; +import CustomFormModal from "./modals/confirm/CustomFormModal"; import { ModalType } from "@/types/modal"; import { useEffect } from "react"; @@ -21,6 +22,7 @@ const ModalComponents = { changePassword: ChangePasswordModal, editMyProfile: EditMyProfileModal, editOwnerProfile: EditOwnerProfileModal, + customForm: CustomFormModal, } as const; const ModalLayout = () => { diff --git a/src/app/stories/design-system/components/card/cardList/AlbaListItem.stories.tsx b/src/app/stories/design-system/components/card/cardList/AlbaListItem.stories.tsx new file mode 100644 index 00000000..237118b0 --- /dev/null +++ b/src/app/stories/design-system/components/card/cardList/AlbaListItem.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ModalLayout from "@/app/components/modal/ModalLayout"; +import AlbaListItem from "@/app/components/card/cardList/AlbaListItem"; + +const meta: Meta = { + title: "Design System/Components/Card/CardList/AlbaListItem", + component: AlbaListItem, + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + navigation: { + push: () => {}, + replace: () => {}, + prefetch: () => {}, + }, + }, + }, + decorators: [ + (Story) => ( +
+ + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +const mockProps = { + id: 1, + imageUrls: [ + "https://images.unsplash.com/photo-1514933651103-005eec06c04b?q=80&w=1974&auto=format&fit=crop", + "https://images.unsplash.com/photo-1574126154517-d1e0d89ef734?q=80&w=1974&auto=format&fit=crop", + "https://images.unsplash.com/photo-1554118811-1e0d58224f24?q=80&w=2047&auto=format&fit=crop", + "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?q=80&w=2070&auto=format&fit=crop", + "https://images.unsplash.com/photo-1523242942815-1ba8fc07f592?q=80&w=2070&auto=format&fit=crop", + ], + isPublic: true, + recruitmentStartDate: new Date("2024-06-01"), + recruitmentEndDate: new Date("2024-12-31"), + title: "서울 강남구 카페 직원 모집합니다", + applyCount: 5, + scrapCount: 10, +}; + +// 640px 미만 +export const Mobile: Story = { + args: { + ...mockProps, + }, + parameters: { + viewport: { + defaultViewport: "mobile1", // 320px + }, + }, +}; + +// sm (640px ~ 768px) +export const Tablet: Story = { + args: { + ...mockProps, + }, + parameters: { + viewport: { + defaultViewport: "tablet", // 640px + }, + }, +}; + +// md 이상 (768px 이상) +export const Desktop: Story = { + args: { + ...mockProps, + }, + parameters: { + viewport: { + defaultViewport: "desktop", // 768px 이상 + }, + }, +}; + +// 비공개 게시글 +export const PrivatePost: Story = { + args: { + ...mockProps, + isPublic: false, + }, +}; + +// 긴 제목 +export const LongTitle: Story = { + args: { + ...mockProps, + title: "서울 강남구 카페에서 주말 아르바이트 직원을 모집합니다. 경력자 우대, 성실한 분 환영합니다.", + }, +}; + +// 높은 지원자/스크랩 수 +export const HighStats: Story = { + args: { + ...mockProps, + applyCount: 999, + scrapCount: 999, + }, +}; + +// 마감 임박 +export const NearDeadline: Story = { + args: { + ...mockProps, + recruitmentEndDate: new Date(Date.now() + 24 * 60 * 60 * 1000), // 내일 + }, +}; diff --git a/src/app/stories/design-system/components/card/cardList/RecruitListItem.stories.tsx b/src/app/stories/design-system/components/card/cardList/ScapListItem.stories.tsx similarity index 79% rename from src/app/stories/design-system/components/card/cardList/RecruitListItem.stories.tsx rename to src/app/stories/design-system/components/card/cardList/ScapListItem.stories.tsx index 245083f1..b2a66e85 100644 --- a/src/app/stories/design-system/components/card/cardList/RecruitListItem.stories.tsx +++ b/src/app/stories/design-system/components/card/cardList/ScapListItem.stories.tsx @@ -1,10 +1,10 @@ -import RecruitListItem from "@/app/components/card/cardList/RecruitListItem"; import type { Meta, StoryObj } from "@storybook/react"; import ModalLayout from "@/app/components/modal/ModalLayout"; +import ScrapListItem from "@/app/components/card/cardList/ScrapListItem"; -const meta: Meta = { - title: "Design System/Components/Card/CardList/RecruitListItem", - component: RecruitListItem, +const meta: Meta = { + title: "Design System/Components/Card/CardList/ScrapListItem", + component: ScrapListItem, parameters: { layout: "centered", nextjs: { @@ -27,10 +27,10 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const mockProps = { - id: "1", + id: 1, imageUrls: [ "https://images.unsplash.com/photo-1514933651103-005eec06c04b?q=80&w=1974&auto=format&fit=crop", "https://images.unsplash.com/photo-1574126154517-d1e0d89ef734?q=80&w=1974&auto=format&fit=crop", @@ -44,9 +44,6 @@ const mockProps = { title: "서울 강남구 카페 직원 모집합니다", applyCount: 5, scrapCount: 10, - location: "서울특별시 강남구 역삼동", - onEdit: () => console.log("Edit clicked"), - onDelete: () => console.log("Delete clicked"), }; // 640px 미만 @@ -101,14 +98,6 @@ export const LongTitle: Story = { }, }; -// 긴 주소 -export const LongLocation: Story = { - args: { - ...mockProps, - location: "서울특별시 강남구 역삼동 테헤란로 123길 45, 67층 890호 (주)알바가게", - }, -}; - // 높은 지원자/스크랩 수 export const HighStats: Story = { args: { diff --git a/src/app/stories/design-system/pages/albaList/mock/data.tsx b/src/app/stories/design-system/pages/albaList/mock/data.tsx new file mode 100644 index 00000000..f930258b --- /dev/null +++ b/src/app/stories/design-system/pages/albaList/mock/data.tsx @@ -0,0 +1,67 @@ +import { FormListType } from "@/types/response/form"; + +const titles = [ + "편의점 야간 알바 구합니다", + "주말 카페 알바 모집", + "피자집 배달 직원 구함", + "학원 사무보조 알바", + "서빙 알바 급구", + "물류센터 상하차", + "마트 진열 알바", + "영화관 매점 알바", + "호텔 룸메이드 모집", + "주차관리 알바", +]; + +const images = [ + "https://images.unsplash.com/photo-1514933651103-005eec06c04b?q=80&w=1974&auto=format&fit=crop", + "https://images.unsplash.com/photo-1574126154517-d1e0d89ef734?q=80&w=1974&auto=format&fit=crop", + "https://images.unsplash.com/photo-1554118811-1e0d58224f24?q=80&w=2047&auto=format&fit=crop", + "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?q=80&w=2070&auto=format&fit=crop", + "https://images.unsplash.com/photo-1523242942815-1ba8fc07f592?q=80&w=2070&auto=format&fit=crop", +]; + +// 단일 알바 데이터 생성 +function generateMockForm(id: number): FormListType { + const now = new Date(); + const startDate = new Date(); + const endDate = new Date(); + + startDate.setDate(now.getDate() + Math.floor(Math.random() * 7)); + endDate.setDate(startDate.getDate() + Math.floor(Math.random() * 30) + 7); + + return { + id, + title: titles[Math.floor(Math.random() * titles.length)], + applyCount: Math.floor(Math.random() * 50), + scrapCount: Math.floor(Math.random() * 30), + recruitmentEndDate: endDate, + recruitmentStartDate: startDate, + imageUrls: [images[Math.floor(Math.random() * images.length)]], + isPublic: Math.random() > 0.2, + createdAt: now, + updatedAt: now, + } as FormListType; +} + +// 전체 데이터 풀 생성 (100개) +const TOTAL_ITEMS = 100; +const mockDataPool = Array.from({ length: TOTAL_ITEMS }, (_, index) => generateMockForm(index + 1)); + +// 무한 스크롤용 데이터 가져오기 +export function fetchMockData(page: number, limit: number = 10) { + const start = (page - 1) * limit; + const end = start + limit; + const hasMore = end < TOTAL_ITEMS; + + return { + items: mockDataPool.slice(start, end), + hasMore, + nextPage: hasMore ? page + 1 : null, + }; +} + +// 초기 데이터 가져오기 +export function getInitialMockData(limit: number = 10) { + return fetchMockData(1, limit); +} diff --git a/src/app/stories/design-system/pages/albaList/page.stories.tsx b/src/app/stories/design-system/pages/albaList/page.stories.tsx new file mode 100644 index 00000000..79ca6b40 --- /dev/null +++ b/src/app/stories/design-system/pages/albaList/page.stories.tsx @@ -0,0 +1,181 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import AlbaList from "./page"; +import AlbaListLayout from "@/app/(pages)/albaList/layout"; +import { FormListType } from "@/types/response/form"; +import { fetchMockData, getInitialMockData } from "./mock/data"; +import React, { useState, useEffect } from "react"; +import { useInView } from "react-intersection-observer"; + +const meta = { + title: "Design System/Pages/AlbaList/Page", + component: AlbaList, + parameters: { + layout: "fullscreen", + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// 무한 스크롤 기능을 가진 컨테이너 컴포넌트 +const InfiniteScrollContainer = () => { + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const { ref, inView } = useInView({ + threshold: 0, + rootMargin: "100px", + }); + + // 초기 데이터 로드 + useEffect(() => { + const loadInitialData = async () => { + setIsLoading(true); + const initialData = getInitialMockData(); + setItems(initialData.items); + setHasMore(initialData.hasMore); + setIsLoading(false); + }; + loadInitialData(); + }, []); + + // 무한 스크롤 기능 + useEffect(() => { + if (inView && hasMore && !isLoading) { + const loadMoreData = async () => { + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 800)); + const nextData = fetchMockData(page + 1); + setItems((prev) => [...prev, ...nextData.items]); + setHasMore(nextData.hasMore); + setPage((prev) => prev + 1); + setIsLoading(false); + }; + loadMoreData(); + } + }, [inView, hasMore, page, isLoading]); + + return {}} scrollRef={ref} />; +}; + +// 기본 기능 테스트 +export const Default: Story = { + render: () => ( + + + + ), +}; + +// 데바일 - 아이폰 +export const IPhone12: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "iphone12", + }, + }, +}; + +export const IPhone12Pro: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "iphone12promax", + }, + }, +}; + +// 데바일 - 갤럭시 +export const GalaxyS8: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "galaxys8", + }, + }, +}; + +export const GalaxyS20: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "galaxys20", + }, + }, +}; + +// 태블릿 - 아이패드 +export const IPadMini: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "ipadMini", + }, + }, +}; + +export const IPadAir: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "ipadAir", + }, + }, +}; + +export const IPadPro: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "ipadPro", + }, + }, +}; + +// 데스크톱 뷰 +export const Desktop: Story = { + render: () => ( + + + + ), + parameters: { + viewport: { + defaultViewport: "desktop", + }, + }, +}; diff --git a/src/app/stories/design-system/pages/albaList/page.tsx b/src/app/stories/design-system/pages/albaList/page.tsx new file mode 100644 index 00000000..c9bd8222 --- /dev/null +++ b/src/app/stories/design-system/pages/albaList/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import FilterDropdown from "@/app/components/button/dropdown/FilterDropdown"; +import { filterRecruitingOptions } from "@/constants/filterOptions"; +import { FormListType } from "@/types/response/form"; +import AlbaListItem from "@/app/components/card/cardList/AlbaListItem"; +import { fetchMockData, getInitialMockData } from "./mock/data"; +import SortSection from "@/app/(pages)/albaList/components/SortSection"; + +interface AlbaListProps { + mockData?: FormListType[][]; + isLoading?: boolean; + onInView?: () => void; + scrollRef?: (node?: Element | null) => void; +} + +const AlbaList: React.FC = () => { + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const { ref, inView } = useInView({ + threshold: 0.1, + triggerOnce: false, + rootMargin: "100px", + }); + + // 초기 데이터 로드 + useEffect(() => { + const loadInitialData = async () => { + setIsLoading(true); + const initialData = getInitialMockData(); + setItems(initialData.items); + setHasMore(initialData.hasMore); + setIsLoading(false); + }; + loadInitialData(); + }, []); + + // 무한 스크롤 + useEffect(() => { + if (inView && hasMore && !isLoading) { + const loadMoreData = async () => { + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 800)); + const nextData = fetchMockData(page + 1); + setItems((prev) => [...prev, ...nextData.items]); + setHasMore(nextData.hasMore); + setPage((prev) => prev + 1); + setIsLoading(false); + }; + loadMoreData(); + } + }, [inView, hasMore, page, isLoading]); + + // 로딩 상태 처리 + if (isLoading && items.length === 0) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* 필터 드롭다운 섹션 */} +
+
+ option.label)} + initialValue="전체" + onChange={() => {}} + /> + +
+
+ + {/* 알바폼 목록 랜더링 */} + {items.length === 0 ? ( +
+

등록된 알바 공고가 없습니다.

+
+ ) : ( +
+
+ {items.map((form) => ( +
+ +
+ ))} +
+ + {/* 무한 스크롤 트리거 영역 */} +
+ {isLoading && ( +
+
+
+ )} +
+
+ )} +
+ ); +}; + +export default AlbaList; diff --git a/src/hooks/queries/form/useFormApplication.ts b/src/hooks/queries/form/useFormApplication.ts new file mode 100644 index 00000000..9a0de3f6 --- /dev/null +++ b/src/hooks/queries/form/useFormApplication.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import toast from "react-hot-toast"; +import { ApplicationSchema } from "@/schemas/applicationSchema"; + +export const useFormApplication = (formId: number) => { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (data: ApplicationSchema) => { + const response = await axios.post(`/api/forms/${formId}/applications`, data); + return response.data; + }, + onSuccess: () => { + // 지원 성공 시 관련 쿼리 무효화 + queryClient.invalidateQueries({ queryKey: ["forms", formId] }); + toast.success("지원이 완료되었습니다."); + }, + onError: (error) => { + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || "지원에 실패했습니다."; + console.error("Apply form error:", { + status: error.response?.status, + data: error.response?.data, + }); + toast.error(errorMessage); + } else { + console.error("Unexpected error:", error); + toast.error("지원 중 오류가 발생했습니다."); + } + }, + }); + + return { + formApplication: mutation.mutate, + isLoading: mutation.isPending, + error: mutation.error, + }; +}; diff --git a/src/hooks/queries/form/useFormScap.ts b/src/hooks/queries/form/useFormScap.ts new file mode 100644 index 00000000..5e0f8a8b --- /dev/null +++ b/src/hooks/queries/form/useFormScap.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import toast from "react-hot-toast"; + +export const useFormScrap = (formId: number) => { + const queryClient = useQueryClient(); + + const scrapMutation = useMutation({ + mutationFn: async () => { + const response = await axios.post(`/api/forms/${formId}/scrap`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["forms", formId] }); + toast.success("스크랩이 완료되었습니다."); + }, + onError: (error) => { + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || "스크랩에 실패했습니다."; + toast.error(errorMessage); + } else { + toast.error("스크랩 중 오류가 발생했습니다."); + } + }, + }); + + const unscrapMutation = useMutation({ + mutationFn: async () => { + const response = await axios.delete(`/api/forms/${formId}/scrap`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["forms", formId] }); + toast.success("스크랩이 취소되었습니다."); + }, + onError: (error) => { + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || "스크랩 취소에 실패했습니다."; + toast.error(errorMessage); + } else { + toast.error("스크랩 취소 중 오류가 발생했습니다."); + } + }, + }); + + return { + scrap: scrapMutation.mutate, + unscrap: unscrapMutation.mutate, + isLoading: scrapMutation.isPending || unscrapMutation.isPending, + error: scrapMutation.error || unscrapMutation.error, + }; +}; diff --git a/src/store/sortStore.ts b/src/store/mySortStore.ts similarity index 63% rename from src/store/sortStore.ts rename to src/store/mySortStore.ts index 97bbc375..7732cb92 100644 --- a/src/store/sortStore.ts +++ b/src/store/mySortStore.ts @@ -2,28 +2,28 @@ import { create } from "zustand"; import { postSortOptions } from "@/constants/postOptions"; import { formSortOptions } from "@/constants/formOptions"; -type PageType = "posts" | "comments" | "scrap"; +type MyPageTabType = "posts" | "comments" | "scrap"; -interface SortState { +interface MySortState { orderBy: { posts: string; comments: string; scrap: string; }; - setOrderBy: (pageType: PageType, value: string) => void; + setOrderBy: (tabType: MyPageTabType, value: string) => void; } -export const useSortStore = create((set) => ({ +export const useMySortStore = create((set) => ({ orderBy: { posts: postSortOptions.MOST_RECENT, comments: postSortOptions.MOST_RECENT, scrap: formSortOptions.MOST_RECENT, }, - setOrderBy: (pageType, value) => + setOrderBy: (tabType, value) => set((state) => ({ orderBy: { ...state.orderBy, - [pageType]: value, + [tabType]: value, }, })), })); diff --git a/src/types/modal.d.ts b/src/types/modal.d.ts index 4ee9c9e7..7b8c9b88 100644 --- a/src/types/modal.d.ts +++ b/src/types/modal.d.ts @@ -2,6 +2,7 @@ export type ModalType = | "applicationDetail" | "formContinue" | "recruitmentClosed" + | "customForm" | "deleteForm" | "selectProgress" | "changePassword" @@ -59,6 +60,7 @@ export type ModalPropsMap = { applicationDetail: ApplicationDetailProps; formContinue: AlertModalProps; recruitmentClosed: AlertModalProps; + customForm: CustomFormModalProps; deleteForm: ConfirmFormModalProps; selectProgress: ConfirmFormModalProps; changePassword: FormModalProps;