+
{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;