diff --git a/.husky/pre-commit b/.husky/pre-commit index 3e079c93..f12f9c15 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,4 +4,4 @@ . "$(dirname "$0")/_/husky.sh" echo "✅ Lint-staged 시작..." -yarn lint-staged +npx lint-staged diff --git a/package.json b/package.json index 421a2590..d471b175 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "prepare": "husky" }, "lint-staged": { + "ignore": ["src/legacy-components/**"], "src/**/*.{js,jsx,ts,tsx}": [ "eslint --fix --cache", "prettier --write" diff --git a/src/amplitude.d.ts b/src/amplitude.d.ts new file mode 100644 index 00000000..7391b50d --- /dev/null +++ b/src/amplitude.d.ts @@ -0,0 +1,8 @@ +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + amplitude: any; + } + } + + export {}; \ No newline at end of file diff --git a/src/assets/icons/AddIcon.svg b/src/assets/icons/AddIcon.svg new file mode 100644 index 00000000..27388f27 --- /dev/null +++ b/src/assets/icons/AddIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/common/CheckList.tsx b/src/common/CheckList.tsx index d12af0c9..1ab3ac2c 100644 --- a/src/common/CheckList.tsx +++ b/src/common/CheckList.tsx @@ -10,6 +10,8 @@ import { useDeleteTodoMutation } from '@hook/todo/useDeleteTodoMutation'; import { ReactTagManager } from 'react-gtm-ts'; import { useAddTodoMutation } from '@hook/todo/useAddTodoMutation.ts'; import Plus from '@assets/icons/plus.svg?react'; +import AddIcon from '@assets/icons/AddIcon.svg?react'; +import BookMarkIcon from '@assets/icons/bookmark.svg?react'; import { useUpdateTodoMutation } from '@hook/todo/useUpdateTodoMutation.ts'; type ChecklistItem = string | { id?: number; text: string }; @@ -72,6 +74,17 @@ const CheckList = ({ if (!trimmedText) return; if (isAddingNew) { + // Amplitude 이벤트 - 할일 추가 시도 (inpage) + if (window.amplitude) { + window.amplitude.track('todo_create', { + source_method: 'inpage', + source_page: window.location.pathname, + todo_length: trimmedText.length, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: todo_create_attempt (inpage)'); + } + mutate( { todoTitle: trimmedText }, { @@ -222,7 +235,7 @@ const CheckList = ({
{isMyToPage && ( -
+
{isEditing ? ( <> +
+ +
+ + {post.description} + + {post.dDay && ( + + {post.dDay} + + )} +
+ +
+
+ +
+ {post.saveCount} +
+
+
+
+ ); + })} + + {showToast && ( +
+ } + text={toastMessage} + width="w-[274px]" + /> +
+ )} +
+ ); +}; + +export default CommunityContents; diff --git a/src/pages/community/components/CommunityDropdown.tsx b/src/pages/community/components/CommunityDropdown.tsx new file mode 100644 index 00000000..1f98e3f7 --- /dev/null +++ b/src/pages/community/components/CommunityDropdown.tsx @@ -0,0 +1,113 @@ +import DropDownIcon from '@assets/icons/drop_down.svg?react'; +import clsx from 'clsx'; +import { useDropdown } from '@hook/useDropdown'; +import { useEffect, useState } from 'react'; +import { useGetCommunityQuery } from '@hook/community/query/useGetCommunityQuery'; +import { useCommunityStore } from '@store/useCommunityStore'; +import { findJobIdByName } from '@utils/data/community/jobs'; + +export interface CommunityDropdownProps { + options: T[]; + value: T | ''; + onSelect: (value: T) => void; + placeholder?: string; + className?: string; +} + +export default function CommunityDropdown({ + options, + value, + onSelect, + placeholder = '선택', + className, +}: CommunityDropdownProps) { + const { isOpen, toggle, ref } = useDropdown(); + const [selected, setSelected] = useState(value || (options[0] ?? '')); + const [userSelected, setUserSelected] = useState(false); + const [initializedFromApi, setInitializedFromApi] = useState(false); + const { data: communityData } = useGetCommunityQuery(); + const { setSelectedJob } = useCommunityStore(); + + useEffect(() => { + if (!initializedFromApi) return; // API 초기 반영 전에는 옵션/외부값으로 덮어쓰지 않음 + // 부모가 초기 표시용으로 전달한 첫 옵션 값은 무시하고, 실제 변경만 반영 + if (value && value !== (options[0] as T)) { + setSelected(value); + return; + } + if (!userSelected) { + setSelected((prev) => prev || (options[0] ?? '')); + } + }, [value, options, userSelected, initializedFromApi]); + + // 최초 렌더 시 API 데이터의 첫 값을 기본값으로 사용 (외부 value가 없을 때) + useEffect(() => { + const raw = (communityData as { data?: unknown } | undefined)?.data; + let apiDefault: T | undefined; + if (typeof raw === 'string') { + apiDefault = raw as T; + } else if (Array.isArray(raw)) { + const first = (raw as { jobName?: string }[])[0]?.jobName; + if (typeof first === 'string') apiDefault = first as T; + } + + // 최초 1회: API 값으로 초기화하고 이후에는 사용자의 선택/외부값을 우선 + if (!initializedFromApi && apiDefault) { + setSelected(apiDefault); + setSelectedJob({ + name: String(apiDefault), + id: findJobIdByName(String(apiDefault)), + }); + setInitializedFromApi(true); + } + }, [communityData, initializedFromApi, setSelectedJob]); + + const label = (selected || placeholder) as string; + const shouldScroll = options.length > 8; + + return ( +
+ {/* Toggle */} +
+
{label}
+ +
+ + {isOpen && ( +
    + {options.map((opt) => ( +
  • { + setSelected(opt); + setUserSelected(true); + setSelectedJob({ + name: String(opt), + id: findJobIdByName(String(opt)), + }); + onSelect(opt); + toggle(); + }} + className={clsx( + 'cursor-pointer px-5 py-4 font-B01-M hover:text-purple-500', + selected === opt ? 'text-purple-500' : 'text-gray-400' + )} + > + {opt} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/pages/community/components/CommunityLeftSide.tsx b/src/pages/community/components/CommunityLeftSide.tsx new file mode 100644 index 00000000..b3c479e1 --- /dev/null +++ b/src/pages/community/components/CommunityLeftSide.tsx @@ -0,0 +1,88 @@ +import CommunityDropdown from './CommunityDropdown'; +import { useNavigate } from 'react-router-dom'; +import { useCommunityStore } from '@store/useCommunityStore'; +import { useGetHotPopularQuery } from '@hook/community/query/useGetHotPopularQuery'; +import Bookmark from '@assets/icons/bookmark.svg?react'; +import Arrow from '@assets/icons/arrow.svg?react'; +import jobNames, { findJobIdByName } from '@utils/data/community/jobs'; + +const CommunityLeftSide = () => { + const navigate = useNavigate(); + const { selectedJobName } = useCommunityStore(); + const { data: popularTodos = [] } = useGetHotPopularQuery(); + return ( +
+
+ { + console.log(value); + }} + className="cursor-pointer" + /> + +
꿈꾸는 드리머
+ +
{ + // 하드코딩 매핑된 jobId 사용 + const id = findJobIdByName(selectedJobName); + navigate(`/others/${id ?? 1}`); + }} + > + 전체보기 + +
+
+ +
+
+ {' '} + {selectedJobName} HOT 할 일 +
+
+ {popularTodos.map((item, idx) => ( +
+
+
{idx + 1}
+
+ 프로필이미지 + +
+
+
+ {item.description} +
+
+ {item.dDay} +
+
+
+ {item.name} +
+
+
+
+ +
+ + {item.saveCount} +
+
+ ))} +
+
+
+ ); +}; + +export default CommunityLeftSide; diff --git a/src/pages/community/components/CommunityRightSide.tsx b/src/pages/community/components/CommunityRightSide.tsx new file mode 100644 index 00000000..f4b2eee4 --- /dev/null +++ b/src/pages/community/components/CommunityRightSide.tsx @@ -0,0 +1,126 @@ +import { useState, useMemo } from 'react'; +import DropDownIcon from '@assets/icons/drop_down.svg?react'; +import CommunityContents from './CommunityContents'; +import { useCommunityStore } from '@store/useCommunityStore'; +import { useCommunityGetTodo } from '@hook/community/query/useCommunityGetTodo'; +import LoadingSpinner from '@common/LoadingSpinner'; +import { useInfiniteScroll } from '@hook/community/useInfinityScroll'; + +type Level = '전체' | '씨앗' | '새싹' | '꿈나무'; +type Sort = '최신순' | '인기순'; + +const levels: { value: Level; label: string; api: string }[] = [ + { value: '전체', label: '전체', api: '' }, + { value: '씨앗', label: '1단계: 씨앗', api: '씨앗 단계' }, + { value: '새싹', label: '2단계: 새싹', api: '새싹 단계' }, + { value: '꿈나무', label: '3단계: 꿈나무', api: '꿈나무 단계' }, +]; + +const sortOptions: Sort[] = ['최신순', '인기순']; +const toApiLevel = (v: Level) => levels.find((l) => l.value === v)?.api ?? ''; + +const CommunityRightSide = () => { + const { selectedJobName } = useCommunityStore(); + + const [active, setActive] = useState('전체'); + const [isOpen, setIsOpen] = useState(false); + const [sort, setSort] = useState('최신순'); + + const size = 10; + + const { data, isLoading, isError, isFetching, fetchNextPage, hasNextPage } = + useCommunityGetTodo({ + jobName: selectedJobName, + level: toApiLevel(active), + sort, + size, + }); + + const items = useMemo( + () => data?.pages.flatMap((p) => p.content) ?? [], + [data] + ); + + const Observer = useInfiniteScroll({ + onIntersect: () => fetchNextPage(), + enabled: !!hasNextPage && !isFetching && !isLoading && !isError, + rootMargin: '200px', + }); + + const handleSelect = (option: Sort) => { + setSort(option); + setIsOpen(false); + }; + + return ( +
+
+
+ {levels.map(({ value, label }) => ( + + ))} +
+ +
+ + + {isOpen && ( +
+ {sortOptions.map((option) => ( +
handleSelect(option)} + className={`w-full cursor-pointer px-5 py-6 text-left ${ + sort === option + ? 'text-purple-500 font-B01-SB' + : 'text-gray-400 font-B01-M' + }`} + > + {option} +
+ ))} +
+ )} +
+
+ + {isLoading && isFetching && items.length > 0 && ( +
+ +
+ )} + {isError &&

에러가 발생했습니다

} + + {items.length > 0 && ( + + )} + +
+ + {!hasNextPage && items.length > 0 && ( +

마지막 할 일 입니다.

+ )} +
+ ); +}; + +export default CommunityRightSide; diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index 5921e733..221a3dcf 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -8,14 +8,18 @@ import LoginBanner from './components/LoginBanner'; import ToastModal from '@common/modal/ToastModal'; import Info from '@assets/icons/info.svg?react'; import { useMdTodoQuery } from '@hook/todo/useMdTodoQuery'; +import { useGetInfo } from '@hook/mypage/useMypageQuery'; +import PrePareStepModal from '@common/modal/PrePareStepModal'; const Home = () => { const isLoggedIn = !!localStorage.getItem('accessToken'); const location = useLocation(); const navigate = useNavigate(); const [toast, setToast] = useState(null); + const [prepareOpen, setPrepareOpen] = useState(false); const { data, isLoading } = useMdTodoQuery(); + const { data: myInfo } = useGetInfo(); const hasNoJob = isLoggedIn && @@ -36,6 +40,17 @@ const Home = () => { } }, [location.state, location.pathname, navigate]); + useEffect(() => { + if (!isLoggedIn) return; + if (!myInfo) return; + const hasLevel = Boolean( + myInfo.level && String(myInfo.level).trim().length > 0 + ); + if (!hasLevel) { + setPrepareOpen(true); + } + }, [isLoggedIn, myInfo]); + return (
{toast && ( @@ -44,14 +59,15 @@ const Home = () => {
)} - {!isLoggedIn ? ( - - ) : hasNoJob ? ( - - ) : ( - + {prepareOpen && ( + setPrepareOpen(false)} + jobName={myInfo?.job?.jobName} + /> )} + {!isLoggedIn ? : hasNoJob ? : } +
diff --git a/src/pages/home/components/Banner.tsx b/src/pages/home/components/Banner.tsx index ea10c2b1..80ff3c6d 100644 --- a/src/pages/home/components/Banner.tsx +++ b/src/pages/home/components/Banner.tsx @@ -1,50 +1,23 @@ -import { HomeCard } from './HomeImage'; -import MyDreamArrow from '@assets/icons/myDreamarrow.svg?react'; import Arrow from '@assets/icons/arrow.svg?react'; import Bell from '@assets/images/bell.webp'; import { useNoBannerQuery } from '@hook/useHomeQuery'; import { useNavigate } from 'react-router-dom'; +import Tag from '@common/Tag.tsx'; +import SliderContainer from './SliderContainer'; -interface BannerProps { - goToOnboard?: boolean; -} - -const Banner = ({ goToOnboard = false }: BannerProps) => { +const Banner = () => { const { data: jobList } = useNoBannerQuery(); const navigate = useNavigate(); - const handleClick = () => { - if (goToOnboard) { - navigate('/jobselect'); - } else { - navigate('/login'); - } - }; - return (
- - -
-
직업 담으러 가기
- -
- -
- - {' '} - 나에게 딱 맞는 직업은 뭘까? - -
- - 인생 2막의 시작은
- 두드림과 함께 하세요! -
+
+
+ 인생 2막의 시작은
+ 두드림과 함께 하세요!
+
@@ -73,9 +46,7 @@ const Banner = ({ goToOnboard = false }: BannerProps) => { key={job['job-name']} className="flex flex-row items-center gap-4" > -
- {job['job-name']} -
+
{job.count}건
))} diff --git a/src/pages/home/components/LoginBanner.tsx b/src/pages/home/components/LoginBanner.tsx index 2c9526bd..406b263b 100644 --- a/src/pages/home/components/LoginBanner.tsx +++ b/src/pages/home/components/LoginBanner.tsx @@ -1,101 +1,37 @@ -import { LoginHomeCard } from './HomeImage'; -import MyDreamArrow from '@assets/icons/myDreamarrow.svg?react'; import Arrow from '@assets/icons/arrow.svg?react'; import Bell from '@assets/images/bell.webp'; import { useBannerQuery } from '@hook/useHomeQuery'; import { useUserStore } from '@store/useUserStore'; import { useNavigate } from 'react-router-dom'; import { useFilterStore } from '@store/filterStore'; -import CheckList from '@common/CheckList'; -import { useMdTodoQuery } from '@hook/todo/useMdTodoQuery'; -import { useState } from 'react'; -import { useMdTodoCompleteMutation } from '@hook/mydream/useMdTodoCompleMutation'; -import { useQueryClient } from '@tanstack/react-query'; +import SliderContainer from '@pages/home/components/SliderContainer.tsx'; +import { useGetInfo } from '@hook/mypage/useMypageQuery'; const LoginBanner = () => { const { data: jobList } = useBannerQuery(); const regionName = useUserStore((s) => s.regionName); const navigate = useNavigate(); const setSelection = useFilterStore((s) => s.setSelection); - const { data: todoData, isLoading } = useMdTodoQuery(); - const { mutate: completeTodo } = useMdTodoCompleteMutation(); - const queryClient = useQueryClient(); - const [checkedIds, setCheckedIds] = useState([]); - - const todoItems = - todoData?.todos - .filter((t) => !t.completed) - .slice(0, 4) - .map((t) => ({ id: t.todoId, text: t.title })) ?? []; - - const handleCheckChange = (newIds: number[]) => { - const updated = [ - ...newIds - .filter((id) => !checkedIds.includes(id)) - .map((id) => ({ id, completed: true })), - ...checkedIds - .filter((id) => !newIds.includes(id)) - .map((id) => ({ id, completed: false })), - ]; - - Promise.all( - updated.map( - ({ id, completed }) => - new Promise((resolve) => - completeTodo({ todoId: id, completed }, { onSuccess: resolve }) - ) - ) - ).then(() => { - queryClient.invalidateQueries({ queryKey: ['mdTodo'] }); - setCheckedIds([]); - }); - - setCheckedIds(newIds); - }; - - const displayedItems = todoItems.filter((i) => !checkedIds.includes(i.id!)); + const nickname = localStorage.getItem('nickname'); + const { data: myInfo } = useGetInfo(); + const levelLabel = (() => { + const level = myInfo?.level?.trim(); + if (!level) return null; + const normalized = level.replace(/\s*단계\s*$/u, ''); + return normalized.length > 0 ? normalized : null; + })(); return (
-
- -
- navigate('/mytodo')} - > - 나의 할일 가기 - - -
- -
- - {todoData ? `${todoData.daysAgo}일째 꿈꾸는중` : '꿈꾸는중'} - -
- {todoData?.jobName ?? '직업 준비중'} -
-
- -
- {isLoading ? ( -
로딩중...
- ) : ( - - )} +
+
+ 안녕하세요, +
+ {levelLabel ? `[${levelLabel}] ` : ''} + {nickname}님
+
-
Bell diff --git a/src/pages/home/components/Slider.tsx b/src/pages/home/components/Slider.tsx new file mode 100644 index 00000000..f6108662 --- /dev/null +++ b/src/pages/home/components/Slider.tsx @@ -0,0 +1,36 @@ +import BaseImage from '@assets/images/profile.png'; +import Tag from '@common/Tag.tsx'; + +interface SliderProps { + text: string; + user: string; + tags: string[]; +} + +const Slider = ({ text, user, tags }: SliderProps) => { + return ( +
+

+ {text} +

+ +
+ 프로필이미지 + {user} +
+
+ {tags.map((tag, index) => ( + + ))} +
+
+
+
+ ); +}; + +export default Slider; diff --git a/src/pages/home/components/SliderContainer.tsx b/src/pages/home/components/SliderContainer.tsx new file mode 100644 index 00000000..df897d37 --- /dev/null +++ b/src/pages/home/components/SliderContainer.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import Slider from './Slider'; +import { usePopularQuery } from '@hook/home/usePopularQuery'; + +const SLIDER_HEIGHT = 168; +const GAP = 20; +const TRANSITION_DURATION = 500; + +const VIEWPORT_HEIGHT = SLIDER_HEIGHT * 2 + GAP; + +const SliderContainer = () => { + const { data: popularTodos } = usePopularQuery(); + + const extendedSliderData = useMemo(() => { + if (!popularTodos || popularTodos.length === 0) return []; + + const transformedData = popularTodos.map((todo) => ({ + id: todo.todoId, + text: todo.title, + user: todo.memberNickname, + tags: [todo.jobName, todo.memberLevel || '레벨 없음'], + })); + + const firstClone = { + ...transformedData[0], + id: `clone-first-${transformedData[0].id}`, + }; + return [...transformedData, firstClone]; + }, [popularTodos]); + + const [activeIndex, setActiveIndex] = useState(1); + const [isTransitioning, setIsTransitioning] = useState(true); + const intervalRef = useRef(null); + + const startInterval = () => { + intervalRef.current = window.setInterval(() => { + setActiveIndex((prevIndex) => prevIndex + 1); + }, 5000); + }; + + useEffect(() => { + if (extendedSliderData.length > 0) { + startInterval(); + } + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [extendedSliderData.length]); + + useEffect(() => { + if (activeIndex === extendedSliderData.length) { + const timer = setTimeout(() => { + setIsTransitioning(false); + setActiveIndex(1); + }, TRANSITION_DURATION); + return () => clearTimeout(timer); + } + + if (activeIndex === 1 && !isTransitioning) { + requestAnimationFrame(() => { + setIsTransitioning(true); + }); + } + }, [activeIndex, isTransitioning, extendedSliderData.length]); + + const handleMouseEnter = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + + const handleMouseLeave = () => { + startInterval(); + }; + + return ( +
+ + +
+
+ {extendedSliderData.map((data) => ( +
+ +
+ ))} +
+
+
+ ); +}; + +export default SliderContainer; diff --git a/src/pages/jobDetail/JobInfo.tsx b/src/pages/jobDetail/JobInfo.tsx index 0d23bb0d..377514f1 100644 --- a/src/pages/jobDetail/JobInfo.tsx +++ b/src/pages/jobDetail/JobInfo.tsx @@ -7,12 +7,10 @@ import LoadingSpinner from '@common/LoadingSpinner'; import CostIcon from '@assets/icons/cost.svg?react'; import CertificationIcon from '@assets/icons/certification.svg?react'; import CalendarIcon from '@assets/icons/calendar.svg?react'; -// import Button from '@common/Button'; import { useState } from 'react'; import AddJobModal from '@common/modal/AddJobModal'; -// import ProfileCard from './components/ProfileCard'; import WorkStrong from './components/WorkStrong'; -// import { useAddJobMutation } from '@hook/useAddJobMutation'; +import RecommendTodo from './components/RecommendTodo'; const JobInfo = () => { const navigate = useNavigate(); @@ -23,22 +21,9 @@ const JobInfo = () => { error, } = useJobDetailQuery(Number(jobId)); const [isModalOpen, setIsModalOpen] = useState(false); - // const addJobMutation = useAddJobMutation(); const isLoggedIn = !!localStorage.getItem('accessToken'); - // const handleAddJob = () => { - // if (!jobId) return; - // addJobMutation.mutate(Number(jobId), { - // onSuccess: () => { - // setIsModalOpen(true); - // }, - // onError: () => { - // alert('이미 담은 직업입니다.'); - // }, - // }); - // }; - if (isLoading) return (
@@ -50,19 +35,19 @@ const JobInfo = () => { return (
-
+
navigate('/jobfound')} /> -
+
{jobDetail?.jobName}
{jobDetail?.jobDescription}
- +
@@ -110,37 +95,20 @@ const JobInfo = () => {
-
- - - - {/*
-
*/} +
+
- {/* */} + + {isModalOpen && isLoggedIn && ( + setIsModalOpen(false)} /> + )}
- - - {isModalOpen && isLoggedIn && ( - setIsModalOpen(false)} /> - )}
); }; diff --git a/src/pages/jobDetail/components/DetailTab.tsx b/src/pages/jobDetail/components/DetailTab.tsx new file mode 100644 index 00000000..3fe0c8a4 --- /dev/null +++ b/src/pages/jobDetail/components/DetailTab.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { + useJobTodoCategory, + JobTodoCategoryProps, +} from '@hook/jobinfo/useJobTodoCategory'; +import ReadyContent from './TabContent/ReadyContent'; +import SproutContent from './TabContent/SproutContent'; +import TreeContents from './TabContent/TreeContents'; + +type TabKey = 'ready' | 'start' | 'challenge'; + +type Tab = { + key: TabKey; + label: string; +}; + +const tabs: Tab[] = [ + { key: 'ready', label: '씨앗 단계' }, + { key: 'start', label: '새싹 단계' }, + { key: 'challenge', label: '꿈나무 단계' }, +]; + +const category: Record = { + ready: 'SEED', + start: 'SPROUT', + challenge: 'TREE', +}; + +const DetailTab = ({ jobId }: { jobId: number }) => { + const [active, setActive] = useState('ready'); + + const { data, isLoading, isError, error } = useJobTodoCategory( + Number(jobId), + category[active] + ); + + const renderContent = () => { + if (!jobId) return; + if (isLoading) return

불러오는 중…

; + if (isError) + return

{(error as Error)?.message}

; + + switch (active) { + case 'ready': + return ( + + ); + case 'start': + return ( + + ); + case 'challenge': + return ( + + ); + default: + return null; + } + }; + + return ( +
+
+ {tabs.map((t) => { + const isActive = active === t.key; + return ( + + ); + })} +
+ +
{renderContent()}
+
+ ); +}; + +export default DetailTab; diff --git a/src/pages/jobDetail/components/JobView.tsx b/src/pages/jobDetail/components/JobView.tsx index 802b114f..929b11b2 100644 --- a/src/pages/jobDetail/components/JobView.tsx +++ b/src/pages/jobDetail/components/JobView.tsx @@ -1,4 +1,3 @@ -import Arrow from '@assets/icons/arrow.svg?react'; import { JobViewRequest, useJobViewQuery, @@ -7,6 +6,7 @@ import { import LoadingSpinner from '@common/LoadingSpinner'; import { useState } from 'react'; import SelectModal from '@common/modal/SelectModal'; +import Button from '@common/Button'; import { useFilterStore } from '@store/filterStore'; import { useNavigate } from 'react-router-dom'; @@ -31,28 +31,19 @@ const JobView = ({ jobName }: JobViewComponentProps) => { if (error) return
에러가 발생했어요.
; return ( -
-
-
{jobName} 채용 정보
-
{ - setSelection('job', jobName); - navigate('/jobsearch'); - }} - > -
더 보러가기
- -
+
+
+ {' '} + {jobName}
채용 정보
-
+
{jobView && jobView.length > 0 ? ( - jobView.slice(0, 3).map((view) => ( + jobView.slice(0, 2).map((view) => (
setSelectedItem(view)} - className="flex h-auto w-full cursor-pointer flex-col items-start rounded-[30px] border-2 border-gray-200 p-[30px] hover:shadow-shadow2" + className="flex w-full cursor-pointer flex-col items-start rounded-[30px] border-[1.2px] border-gray-300 bg-white p-[30px] hover:bg-gray-100" >
{view.deadline} @@ -76,6 +67,17 @@ const JobView = ({ jobName }: JobViewComponentProps) => { onClose={() => setSelectedItem(null)} /> )} + +
); }; diff --git a/src/pages/jobDetail/components/RecommendTodo.tsx b/src/pages/jobDetail/components/RecommendTodo.tsx new file mode 100644 index 00000000..cc6ef733 --- /dev/null +++ b/src/pages/jobDetail/components/RecommendTodo.tsx @@ -0,0 +1,20 @@ +import Arrow from '@assets/icons/arrow.svg?react'; +import DetailTab from './DetailTab'; + +const RecommendTodo = ({ jobId }: { jobId: number }) => { + return ( +
+
+
추천 할 일
+
+
더 많은 할 일 보기
+ +
+
+ + +
+ ); +}; + +export default RecommendTodo; diff --git a/src/pages/jobDetail/components/TabContent/ReadyContent.tsx b/src/pages/jobDetail/components/TabContent/ReadyContent.tsx new file mode 100644 index 00000000..ea363be2 --- /dev/null +++ b/src/pages/jobDetail/components/TabContent/ReadyContent.tsx @@ -0,0 +1,91 @@ +import ToastModal from '@common/modal/ToastModal'; +import { JobTodoCategoryProps } from '@hook/jobinfo/useJobTodoCategory'; +import Info from '@assets/icons/info.svg?react'; +import { useState } from 'react'; +import { useAddMyTodoMutation } from '@hook/jobinfo/useAddMyTodoMutation.ts'; + +interface ReadyContentProps { + jobId: number; + data?: JobTodoCategoryProps; +} + +const ReadyContent = ({ data }: ReadyContentProps) => { + const seedData = data?.jobTodos ?? []; + + const { mutate, isPending } = useAddMyTodoMutation(); + const [clickedId, setClickedId] = useState(null); + const [completedId, setCompletedId] = useState>(new Set()); + const [showToast, setShowToast] = useState(false); + + const handleAdd = (jobTodoId: number) => { + if (completedId.has(jobTodoId)) return; + + setClickedId(jobTodoId); + mutate(jobTodoId, { + onSuccess: () => { + setCompletedId((prev) => new Set(prev).add(jobTodoId)); + setShowToast(true); + setTimeout(() => setShowToast(false), 2000); + }, + onSettled: () => setClickedId(null), + }); + }; + + if (!seedData.length) { + return ( +

+ 씨앗 단계에 등록된 할 일이 없습니다. +

+ ); + } + + return ( +
+ {seedData.map((seed) => { + const isLoading = isPending && clickedId === seed.JobTodoId; + const isCompleted = completedId.has(seed.JobTodoId); + + return ( +
+
+ {seed.title} +
+ + +
+ ); + })} + + {showToast && ( +
+ } + text="내 할일 목록에 추가 되었습니다" + width="w-[350px]" + /> +
+ )} +
+ ); +}; + +export default ReadyContent; diff --git a/src/pages/jobDetail/components/TabContent/SproutContent.tsx b/src/pages/jobDetail/components/TabContent/SproutContent.tsx new file mode 100644 index 00000000..09a4d0ca --- /dev/null +++ b/src/pages/jobDetail/components/TabContent/SproutContent.tsx @@ -0,0 +1,91 @@ +import { JobTodoCategoryProps } from '@hook/jobinfo/useJobTodoCategory'; +import { useState } from 'react'; +import ToastModal from '@common/modal/ToastModal'; +import Info from '@assets/icons/info.svg?react'; +import { useAddMyTodoMutation } from '@hook/jobinfo/useAddMyTodoMutation.ts'; + +interface SproutContentProps { + jobId: number; + data?: JobTodoCategoryProps; +} + +const SproutContent = ({ data }: SproutContentProps) => { + const sproutData = data?.jobTodos ?? []; + + const { mutate, isPending } = useAddMyTodoMutation(); + const [clickedId, setClickedId] = useState(null); + const [completedId, setCompletedId] = useState>(new Set()); + const [showToast, setShowToast] = useState(false); + + const handleAdd = (jobTodoId: number) => { + if (completedId.has(jobTodoId)) return; + + setClickedId(jobTodoId); + mutate(jobTodoId, { + onSuccess: () => { + setCompletedId((prev) => new Set(prev).add(jobTodoId)); + setShowToast(true); + setTimeout(() => setShowToast(false), 2000); + }, + onSettled: () => setClickedId(null), + }); + }; + + if (!sproutData.length) { + return ( +

+ 새싹 단계에 등록된 할 일이 없습니다. +

+ ); + } + + return ( +
+ {sproutData.map((sprout) => { + const isLoading = isPending && clickedId === sprout.JobTodoId; + const isCompleted = completedId.has(sprout.JobTodoId); + + return ( +
+
+ {sprout.title} +
+ + +
+ ); + })} + + {showToast && ( +
+ } + text="내 할일 목록에 추가 되었습니다" + width="w-[350px]" + /> +
+ )} +
+ ); +}; + +export default SproutContent; diff --git a/src/pages/jobDetail/components/TabContent/TreeContents.tsx b/src/pages/jobDetail/components/TabContent/TreeContents.tsx new file mode 100644 index 00000000..be1249e3 --- /dev/null +++ b/src/pages/jobDetail/components/TabContent/TreeContents.tsx @@ -0,0 +1,91 @@ +import { JobTodoCategoryProps } from '@hook/jobinfo/useJobTodoCategory'; +import { useState } from 'react'; +import ToastModal from '@common/modal/ToastModal'; +import Info from '@assets/icons/info.svg?react'; +import { useAddMyTodoMutation } from '@hook/jobinfo/useAddMyTodoMutation.ts'; + +interface TreeContentsProps { + jobId: number; + data?: JobTodoCategoryProps; +} + +const TreeContents = ({ data }: TreeContentsProps) => { + const treeData = data?.jobTodos ?? []; + + const { mutate, isPending } = useAddMyTodoMutation(); + const [clickedId, setClickedId] = useState(null); + const [completedId, setCompletedId] = useState>(new Set()); + const [showToast, setShowToast] = useState(false); + + const handleAdd = (jobTodoId: number) => { + if (completedId.has(jobTodoId)) return; + + setClickedId(jobTodoId); + mutate(jobTodoId, { + onSuccess: () => { + setCompletedId((prev) => new Set(prev).add(jobTodoId)); + setShowToast(true); + setTimeout(() => setShowToast(false), 2000); + }, + onSettled: () => setClickedId(null), + }); + }; + + if (!treeData.length) { + return ( +

+ 꿈나무 단계에 등록된 할 일이 없습니다. +

+ ); + } + + return ( +
+ {treeData.map((tree) => { + const isLoading = isPending && clickedId === tree.JobTodoId; + const isCompleted = completedId.has(tree.JobTodoId); + + return ( +
+
+ {tree.title} +
+ + +
+ ); + })} + + {showToast && ( +
+ } + text="내 할일 목록에 추가 되었습니다" + width="w-[350px]" + /> +
+ )} +
+ ); +}; + +export default TreeContents; diff --git a/src/pages/jobDetail/components/WorkStrong.tsx b/src/pages/jobDetail/components/WorkStrong.tsx index 897f83e7..54a21f02 100644 --- a/src/pages/jobDetail/components/WorkStrong.tsx +++ b/src/pages/jobDetail/components/WorkStrong.tsx @@ -18,9 +18,9 @@ const WorkStrong = ({ physical, stress, relationship }: JobIntensityProps) => { setVisibleTooltip((prev) => (prev === key ? null : key)); }; return ( -
-
업무 강도
- +
+
업무 강도
+
diff --git a/src/pages/jobRecommend/JobRecommendPage.tsx b/src/pages/jobRecommend/JobRecommendPage.tsx index 4a2b582d..ca34207d 100644 --- a/src/pages/jobRecommend/JobRecommendPage.tsx +++ b/src/pages/jobRecommend/JobRecommendPage.tsx @@ -69,6 +69,16 @@ const JobRecommendPage = () => { source_page: location.pathname, }); + // Amplitude 이벤트 - 온보딩 결과에서 직업 담기 + if (window.amplitude) { + window.amplitude.track('job_onboarding_select', { + job_id: jobId, + source_page: location.pathname, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: job_onboarding_select'); + } + addJob(jobId, { onSuccess: () => { setIsErrorToast(false); diff --git a/src/pages/jobSelect/JobSelect.tsx b/src/pages/jobSelect/JobSelect.tsx index dbad214f..78c4d3b6 100644 --- a/src/pages/jobSelect/JobSelect.tsx +++ b/src/pages/jobSelect/JobSelect.tsx @@ -55,6 +55,7 @@ const JobSelect = () => { if (isFirstJobModal && pendingJob) { saveJobMutation.mutate(pendingJob.id, { onSuccess: () => { + // GTM 이벤트 ReactTagManager.action({ event: 'job_select', job_id: pendingJob.id, @@ -62,6 +63,19 @@ const JobSelect = () => { clickText: '직업 담기', source_page: location.pathname, }); + + // Amplitude 이벤트 - 직업 담기 + if (window.amplitude) { + window.amplitude.track('job_select', { + method: 'job_add', + job_id: pendingJob.id, + job_name: pendingJob.name, + source_page: location.pathname, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: job_select (job_add)'); + } + setSelectedJob({ id: pendingJob.id, name: pendingJob.name }); setHasEverSelectedJob(true); queryClient.invalidateQueries({ queryKey: ['mypageInfo'] }); @@ -81,6 +95,30 @@ const JobSelect = () => { setOpenModal(true); }; + const handleJobRecommendClick = () => { + // Amplitude 이벤트 - 직업 추천 받기 (job_select) + if (window.amplitude) { + window.amplitude.track('job_select', { + method: 'job_recommend', + source_page: location.pathname, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: job_select (job_recommend)'); + } + + // Amplitude 이벤트 - 온보딩 시작 + if (window.amplitude) { + window.amplitude.track('job_onboarding_start', { + source_page: location.pathname, + referrer: document.referrer, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: job_onboarding_start'); + } + + navigate('/onboard'); + }; + return (
@@ -177,7 +215,7 @@ const JobSelect = () => { color="primary" type="button" className="h-[71px] w-[196px] items-center justify-center font-T04-B" - onClick={() => navigate('/onboard')} + onClick={handleJobRecommendClick} />
diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 5ad79a1a..290ae761 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -18,6 +18,13 @@ const LoginPage = () => { setTimeout(() => { navigate('/signup'); }, 200); + if (window.amplitude) { + window.amplitude.track('signup_start', { + source_page: location.pathname, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: signup_start'); // 콘솔에서 전송 확인용 + } }; return ( diff --git a/src/pages/login/components/LoginForm.tsx b/src/pages/login/components/LoginForm.tsx index 78590d98..8fe6bec2 100644 --- a/src/pages/login/components/LoginForm.tsx +++ b/src/pages/login/components/LoginForm.tsx @@ -16,6 +16,16 @@ const LoginForm = () => { }); const { mutate } = useLoginMutation(); const onSubmit = (formData: LoginFormValues) => { + if (window.amplitude) { + window.amplitude.track('click_login', { + login_method: 'email', + has_id: !!formData.id, + has_password: !!formData.password, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: click_login'); // 콘솔에서 전송 확인용 + } + mutate({ loginId: formData.id, password: formData.password, diff --git a/src/pages/myTodo/MyTodoPage.tsx b/src/pages/myTodo/MyTodoPage.tsx index ae043cb3..872adf5c 100644 --- a/src/pages/myTodo/MyTodoPage.tsx +++ b/src/pages/myTodo/MyTodoPage.tsx @@ -1,12 +1,7 @@ import { Outlet } from 'react-router-dom'; -import SidebarLayout from '@outlet/SidebarLayout.tsx'; const MyTodoPage = () => { - return ( - - - - ); + return ; }; export default MyTodoPage; diff --git a/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx b/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx index 3525c394..fc15f90e 100644 --- a/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx +++ b/src/pages/myTodo/components/scrap/ScrapEmptyState.tsx @@ -7,7 +7,7 @@ interface Props { } const ScrapEmptyState = ({ type, onNavigate }: Props) => ( -
+
스크랩 비어 있음

스크랩한 {type === 'job' ? '채용' : '학원'} 정보가 아직 없어요! diff --git a/src/pages/onboard/OnBoardingPage.tsx b/src/pages/onboard/OnBoardingPage.tsx index 8cb01e60..d1c85e24 100644 --- a/src/pages/onboard/OnBoardingPage.tsx +++ b/src/pages/onboard/OnBoardingPage.tsx @@ -34,6 +34,7 @@ const OnBoardingPage = () => { useEffect(() => { return () => { + // GTM 이벤트 - 온보딩 종료 ReactTagManager.action({ event: 'onboarding_exit', category: '온보딩', @@ -41,10 +42,12 @@ const OnBoardingPage = () => { step: stepRef.current + 1, question: questionRef.current + 1, }); + }; }, []); const handleSubmit = () => { + // GTM 이벤트 - 온보딩 완료 ReactTagManager.action({ event: 'onboarding_complete', category: '온보딩', @@ -52,6 +55,19 @@ const OnBoardingPage = () => { step: curStep + 1, question: curQuestionIndex + 1, }); + + // Amplitude 이벤트 - 온보딩 완료 + if (window.amplitude) { + window.amplitude.track('job_onboarding_complete', { + source_page: window.location.pathname, + step: curStep + 1, + question: curQuestionIndex + 1, + total_steps: stepQuestions.length, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: job_onboarding_complete'); + } + mutate(buildPayload()); }; diff --git a/src/pages/signup/components/Signup2.tsx b/src/pages/signup/components/Signup2.tsx index 8c2b7d70..e344f9dd 100644 --- a/src/pages/signup/components/Signup2.tsx +++ b/src/pages/signup/components/Signup2.tsx @@ -55,6 +55,21 @@ const Signup2 = () => { setDuplicateSuccess(false); return; } + + // Amplitude 이벤트 전송 - 회원가입 완료 + if (window.amplitude) { + window.amplitude.track('signup_success', { + email: email, + login_id: loginId, + nickname: data.nickname, + gender: selectedGender ?? gender, + birth_date: data.date, + region_code: regionCode, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: signup_success'); + } + const requestData = { loginId, password, @@ -159,7 +174,7 @@ const Signup2 = () => { { + render={({ field: { onChange, ...rest } }) => { const handleInputChange = ( e: React.ChangeEvent ) => { diff --git a/src/pages/signup/components/SignupEmailVerify.tsx b/src/pages/signup/components/SignupEmailVerify.tsx index 6dfa34df..7f291155 100644 --- a/src/pages/signup/components/SignupEmailVerify.tsx +++ b/src/pages/signup/components/SignupEmailVerify.tsx @@ -65,6 +65,16 @@ const SignupEmailVerify = ({ onNext, email }: SignupEmailVerifyProps) => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (successMessage === '올바른 인증번호입니다.') { + // Amplitude 이벤트 전송 + if (window.amplitude) { + window.amplitude.track('email_verify_submit', { + email: email, + verification_status: 'success', + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: email_verify_submit'); + } + onNext(); } setField('email', email); @@ -84,7 +94,17 @@ const SignupEmailVerify = ({ onNext, email }: SignupEmailVerifyProps) => { setErrorMessage(''); setSuccessMessage(''); } catch (error) { + console.error('인증번호 재전송에 실패했습니다:', error); setErrorMessage('인증번호 재전송에 실패했습니다. 다시 시도해주세요.'); + + // Amplitude 이벤트 전송 - 인증번호 재전송 + if (window.amplitude) { + window.amplitude.track('email_verify_resend', { + email: email, + timestamp: new Date().toISOString(), + }); + console.log('Amplitude event sent: email_verify_resend'); + } } }; diff --git a/src/pages/signup/components/SingupAgree.tsx b/src/pages/signup/components/SingupAgree.tsx index b8444933..5097ee51 100644 --- a/src/pages/signup/components/SingupAgree.tsx +++ b/src/pages/signup/components/SingupAgree.tsx @@ -50,7 +50,8 @@ const SingupAgree = ({ onNext, setEmail }: SignupProps) => { }); setEmail(data.email); onNext(); - } catch (err) { + } catch (error) { + console.error('이미 가입된 이메일 주소입니다.', error); alert('이미 가입된 이메일 주소입니다.'); } }; diff --git a/src/store/useCommunityStore.ts b/src/store/useCommunityStore.ts new file mode 100644 index 00000000..77ed2743 --- /dev/null +++ b/src/store/useCommunityStore.ts @@ -0,0 +1,14 @@ +import { create } from 'zustand'; + +interface CommunityStore { + selectedJobName: string; + selectedJobId: number | null; + setSelectedJob: (job: { name: string; id: number | null }) => void; +} + +export const useCommunityStore = create((set) => ({ + selectedJobName: '', + selectedJobId: null, + setSelectedJob: ({ name, id }) => + set({ selectedJobName: name, selectedJobId: id }), +})); diff --git a/src/utils/data/community/jobs.ts b/src/utils/data/community/jobs.ts new file mode 100644 index 00000000..0171ef50 --- /dev/null +++ b/src/utils/data/community/jobs.ts @@ -0,0 +1,36 @@ +export interface JobItem { + id: number; + name: string; +} + +export const jobs: JobItem[] = [ + { id: 1, name: '요양보호사' }, + { id: 2, name: '간호조무사' }, + { id: 3, name: '보육교사' }, + { id: 4, name: '사회복지사' }, + { id: 5, name: '직업상담사' }, + { id: 6, name: '심리상담사' }, + { id: 7, name: '급식 도우미' }, + { id: 8, name: '사무보조원' }, + { id: 9, name: '회계사무원' }, + { id: 10, name: '수의테크니션' }, + { id: 11, name: '웨딩 헬퍼' }, + { id: 12, name: '미용사 (일반)' }, + { id: 13, name: '미용사 (피부)' }, + { id: 14, name: '미용사 (네일)' }, + { id: 15, name: '미용사 (메이크업)' }, + { id: 16, name: '반려동물미용사' }, + { id: 17, name: '레크리에이션 지도사' }, + { id: 18, name: '바리스타' }, + { id: 19, name: '공인중개사' }, + { id: 20, name: '산후조리사' }, +]; + +export const jobNames: string[] = jobs.map((j) => j.name); + +export const findJobIdByName = (name: string): number | null => { + const found = jobs.find((j) => j.name === name); + return found ? found.id : null; +}; + +export default jobNames; diff --git a/src/validation/home/popularSchema.ts b/src/validation/home/popularSchema.ts new file mode 100644 index 00000000..be7b2913 --- /dev/null +++ b/src/validation/home/popularSchema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const PopularTodoSchema = z.object({ + todoId: z.number(), + title: z.string(), + profileImage: z.string().url(), + memberNickname: z.string(), + memberLevel: z.string().nullable(), + jobName: z.string(), + saveCount: z.number(), +}); + +export const PopularResponseSchema = z.object({ + success: z.boolean(), + timestamp: z.string(), + data: z.array(PopularTodoSchema), +}); + +export type PopularTodo = z.infer; +export type PopularResponse = z.infer;