diff --git a/frontend/components.json b/frontend/components.json index 97089fe56..34dc04fbd 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -1,23 +1,23 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/global.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/shared/components", - "utils": "@/shared/lib/utils", - "ui": "@/shared/components/ui", - "lib": "@/shared/lib", - "hooks": "@/shared/hooks" - }, - "registries": {} + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/global.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/shared/components", + "utils": "@/shared/lib/utils", + "ui": "@/shared/components/ui", + "lib": "@/shared/lib", + "hooks": "@/shared/hooks" + }, + "registries": {} } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b701de80..0094f64e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { AuthProvider } from "@/features/auth/providers/AuthProvider"; import EpisodeArchivePage from "@/features/episode_archive/pages/EpisodeArchivePage"; import HomePage from "@/features/home/pages/HomePage"; import LandingPage from "@/features/landing/pages/LandingPage"; -// import CreateMindmapPage from "@/features/mindmap/pages/CreateMindmapPage"; +import CreateMindmapFunnelPage from "@/features/mindmap/pages/CreateMindmapPage"; import MindmapListPage from "@/features/mindmap/pages/MindmapListPage"; import MindmapPage from "@/features/mindmap/pages/MindmapPage"; import SelfDiagnosisPage from "@/features/self_diagnosis/pages/SelfDiagnosisPage"; @@ -42,13 +42,13 @@ const router = createBrowserRouter([ element: , }, { - path: PATHS.self_diagnosis.list, + path: PATHS.self_diagnoses.list, element: , }, - // { - // path: PATHS.mindmap.create, - // element: , - // }, + { + path: PATHS.mindmap.create, + element: , + }, { path: PATHS.mindmap.detail, element: , diff --git a/frontend/src/assets/img/img_landing_episode.png b/frontend/src/assets/img/img_landing_episode.png new file mode 100644 index 000000000..bc014556f Binary files /dev/null and b/frontend/src/assets/img/img_landing_episode.png differ diff --git a/frontend/src/assets/img/img_landing_main.png b/frontend/src/assets/img/img_landing_main.png new file mode 100644 index 000000000..208c30b7b Binary files /dev/null and b/frontend/src/assets/img/img_landing_main.png differ diff --git a/frontend/src/assets/img/img_landing_mindmap.png b/frontend/src/assets/img/img_landing_mindmap.png new file mode 100644 index 000000000..7950d794b Binary files /dev/null and b/frontend/src/assets/img/img_landing_mindmap.png differ diff --git a/frontend/src/assets/img/img_landing_selftest.png b/frontend/src/assets/img/img_landing_selftest.png new file mode 100644 index 000000000..aee3bb90f Binary files /dev/null and b/frontend/src/assets/img/img_landing_selftest.png differ diff --git a/frontend/public/landing_bg.png b/frontend/src/assets/img/landing_bg.png similarity index 100% rename from frontend/public/landing_bg.png rename to frontend/src/assets/img/landing_bg.png diff --git a/frontend/src/assets/img/logo.png b/frontend/src/assets/img/logo.png new file mode 100644 index 000000000..b32d05053 Binary files /dev/null and b/frontend/src/assets/img/logo.png differ diff --git a/frontend/src/features/landing/pages/LandingPage.tsx b/frontend/src/features/landing/pages/LandingPage.tsx index 93b4793ce..752d641c1 100644 --- a/frontend/src/features/landing/pages/LandingPage.tsx +++ b/frontend/src/features/landing/pages/LandingPage.tsx @@ -1,18 +1,128 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { useAuth } from "@/features/auth/hooks/useAuth"; import Button from "@/shared/components/button/Button"; +import CallToActionButton from "@/shared/components/call_to_action_button/CallToActionButton"; import GlobalNavigationBar from "@/shared/components/global_navigation_bar/GlobalNavigationBar"; +import Icon from "@/shared/components/icon/Icon"; import Popover from "@/shared/components/popover/Popover"; import UserBox from "@/shared/components/user_box/UserBox"; import { linkTo } from "@/shared/utils/route"; +import { cn } from "@/utils/cn"; + +type FocusKey = "mindmap" | "self_diagnosis" | "episode_archive"; const LandingPage = () => { const { user, logout } = useAuth(); + const scrollRootRef = useRef(null); + + const [focused, setFocused] = useState("mindmap"); + + const sectionRefs = useRef>({ + mindmap: null, + self_diagnosis: null, + episode_archive: null, + }); + + const setSectionRef = useCallback( + (key: FocusKey) => (el: HTMLElement | null) => { + sectionRefs.current[key] = el; + }, + [], + ); + + const getHref = useCallback( + (from: FocusKey | "start"): string => { + if (!user) { + return linkTo.login(); + } + + switch (from) { + case "start": + return linkTo.home(); + case "mindmap": + return linkTo.mindmap.list(); + case "episode_archive": + return linkTo.episode_archive(); + case "self_diagnosis": + return linkTo.self_diagnosis.list(); + default: + return linkTo.home(); + } + }, + [user], + ); + + const scrollTo = useCallback((key: FocusKey) => { + const root = scrollRootRef.current; + const target = sectionRefs.current[key]; + if (!root || !target) return; + + target.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, []); + + const gnbRef = useRef(null); + + useEffect(() => { + const root = scrollRootRef.current; + if (!root) return; + + const targets = Object.entries(sectionRefs.current) + .map(([key, el]) => ({ key: key, el })) + .filter((v) => Boolean(v.el)); + + if (targets.length === 0) { + return; + } + + const thresholds = Array.from({ length: 21 }, (_, i) => i / 20); + + const observer = new IntersectionObserver( + (entries) => { + // 겹치는 애들 + const intersecting = entries.filter((e) => e.isIntersecting); + if (intersecting.length === 0) { + return; + } + + const best = intersecting.reduce((prev, cur) => { + return prev.intersectionRatio >= cur.intersectionRatio ? prev : cur; + }); + + const key = (best.target as HTMLElement).dataset.section as FocusKey | undefined; + if (!key) { + return; + } + + setFocused((prev) => (prev === key ? prev : key)); + }, + { + root, + rootMargin: "-45% 0px -45% 0px", // 가운데 10%만 감지 영역 + threshold: thresholds, + }, + ); + + targets.forEach(({ el }) => observer.observe(el as Element)); + + return () => observer.disconnect(); + }, []); + return ( - <> -
+
+
{ ) : ( - - - +
+ + + + + + +
) } /> - -
-

환영합니다!

-
- + +
+
+

경험의 경로를 알려주다

+ + + +

+ 흩어진 경험을 하나의 흐름으로, +
+ 취업 준비의 모든 과정을 한 곳에서 +

+ +
+ + + + 에피소드 시작하기 + +
+ +
+
+ {/* */} +
+
+
+
+ +
+
+
+
+ {/* */} + +
+

마인드맵

+

+ 경험을 시각적으로 구조화하고 관리하세요. +
+ 노드로 연결하며 개요 구조를 체계적으로 정리할 수 있어요. +

+ +
    +
  • + + + 한줄 카테고리/클러스터 구조화 + +
  • +
  • + + + 실시간 협업 및 공유 기능 + +
  • +
+ +
+ + + 마인드맵 시작하기 + + +
+
+
+
+
+
+ +
+
+
+
+
+

기술문제 셀프진단

+

+ 기술 자소서 문항을 기준으로 빈틈을 점검하세요. +
+ 부족한 역량을 발견하고, 에피소드를 준비할 수 있어요. +

+ +
    +
  • + + + 주요 직무별 자소서 문항 분석 + +
  • +
  • + + + 기술 역량을 드러내는 질문 추천 + +
  • +
+ +
+ + + 기술문제 셀프진단 하기 + + +
+
+ {/* + */} +
+
+
+
+ +
+
+
+
+ {/* */} + +
+

에피소드 보관함

+

+ 모든 경험을 STAR 기반으로 체계적으로 정리해요. +
+ 자기소개서에 필요한 에피소드를 완성할 수 있어요. +

+ +
    +
  • + + + STAR 형식으로 에피소드 작성 + +
  • +
  • + + + 마인드맵 기반 에피소드 연결 + +
  • +
  • + + 태그 기반 검색 및 정리 +
  • +
+ +
+ + + 에피소드 보관함 가기 + + +
+
+
+
+
+
+ +
+ + 에피소드 시작하기 + +
+
); }; diff --git a/frontend/src/features/mindmap/components/MindmapCard.tsx b/frontend/src/features/mindmap/components/MindmapCard.tsx index d6b4b3edc..9484187aa 100644 --- a/frontend/src/features/mindmap/components/MindmapCard.tsx +++ b/frontend/src/features/mindmap/components/MindmapCard.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from "react"; import { useNavigate } from "react-router"; import { useDeleteMindmap } from "@/features/mindmap/hooks/useDeleteMindmap"; -import { useUpdateMindmapFavorite } from "@/features/mindmap/hooks/useUpdateMindmapFavorite"; // 훅 import +import { useUpdateMindmapFavorite } from "@/features/mindmap/hooks/useUpdateMindmapFavorite"; import { useUpdateMindmapName } from "@/features/mindmap/hooks/useUpdateMindmapName"; import { MindmapItem, MindmapType } from "@/features/mindmap/types/mindmap"; import Button from "@/shared/components/button/Button"; @@ -21,15 +21,29 @@ import { getRelativeTime } from "@/utils/get_relative_time"; type Props = { data: MindmapItem; type?: MindmapType; + + interaction?: "navigate" | "select"; + selected?: boolean; + onSelect?: (mindmapId: string) => void; + + className?: string; }; -const MindmapCard = ({ data, type = "PUBLIC" }: Props) => { +const MindmapCard = ({ data, type = "PUBLIC", interaction = "navigate", selected, onSelect, className }: Props) => { const { mutate: deleteMindmap } = useDeleteMindmap(); const { mutate: updateMindmapName } = useUpdateMindmapName(); const { mutate: updateMindmapFavorite } = useUpdateMindmapFavorite(); const navigate = useNavigate(); + const handleCardClick = () => { + if (interaction === "select") { + onSelect?.(data.mindmapId); + return; + } + navigate(linkTo.mindmap.detail(data.mindmapId)); + }; + const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); if (window.confirm("정말 이 마인드맵을 삭제하시겠습니까?")) { @@ -54,7 +68,6 @@ const MindmapCard = ({ data, type = "PUBLIC" }: Props) => { if (value.length <= 20) { setEditName(value); } else { - // 20자가 넘어가면 잘라냄 (붙여넣기 대응) setEditName(value.slice(0, 20)); } }; @@ -97,10 +110,12 @@ const MindmapCard = ({ data, type = "PUBLIC" }: Props) => { return ( { - navigate(linkTo.mindmap.detail(data.mindmapId)); - }} + className={cn( + "min-h-50 overflow-hidden w-full", + selected && "outline-primary outline-2 shadow-md", + className, + )} + onClick={handleCardClick} header={
{isEditing ? ( @@ -163,32 +178,14 @@ const MindmapCard = ({ data, type = "PUBLIC" }: Props) => { } >
)}
} - bottomContents={ -
- {/* {displayTags.map((tag, index) => ( - - {tag} - - ))} - {hiddenTagCount > 0 && ( - - +{hiddenTagCount} - - )} */} -
- } + bottomContents={
} footer={
{getRelativeTime(data.updatedAt)} diff --git a/frontend/src/features/mindmap/components/MindmapCategoryStep.tsx b/frontend/src/features/mindmap/components/MindmapCategoryStep.tsx new file mode 100644 index 000000000..b74142de6 --- /dev/null +++ b/frontend/src/features/mindmap/components/MindmapCategoryStep.tsx @@ -0,0 +1,89 @@ +import { useCreateMindmap } from "@/features/mindmap/hooks/useCreateMindmap"; +import { ACTIVITY_CATEGORIES, ActivityCategory } from "@/features/mindmap/types/mindmap"; +import { CreateMindmapFunnel } from "@/features/mindmap/types/mindmap_funnel"; +import BottomSticky from "@/shared/components/bottom_sticky/BottomSticky"; +import Button from "@/shared/components/button/Button"; +import { EmojiCard } from "@/shared/components/emoji_card/EmojiCard"; +import Top from "@/shared/components/top/Top"; +import { FunnelInstance } from "@/shared/hooks/useFunnel"; +import { linkTo } from "@/shared/utils/route"; + +type CategoryStepFunnel = Extract, { step: "CATEGORY" }>; + +export function MindmapCategoryStep({ funnel }: { funnel: CategoryStepFunnel }) { + const { mutate: createMindmap, isPending } = useCreateMindmap(); + + const selected = funnel.context.categories ?? []; + + const toggle = (id: ActivityCategory) => { + funnel.history.setContext((prev) => { + const prevSelected = prev.categories ?? []; + const next = prevSelected.includes(id) ? prevSelected.filter((x) => x !== id) : [...prevSelected, id]; + return { ...prev, categories: next }; + }); + }; + + const canSubmit = selected.length > 0; + + const handleSubmit = () => { + createMindmap( + { + isShared: false, + title: "새로운 마인드맵", + }, + { + onSuccess: (data) => { + funnel.exit(linkTo.mindmap.detail(data.mindmap.mindmapId), { replace: false }); + }, + onError: (e) => { + console.error("마인드맵 생성에 실패했습니다.", e); + }, + }, + ); + }; + + return ( + <> +
+ + 마인드맵으로 정리할
활동 카테고리를 선택하세요. + + } + lower={ +

+ 사용 목적에 따라 알맞는 마인드맵 유형을 선택해 주세요. +

+ } + className="mb-12" + /> + +
+ {ACTIVITY_CATEGORIES.map((c) => ( + toggle(c.id)} + /> + ))} +
+
+ + + + + + ); +} diff --git a/frontend/src/features/mindmap/components/MindmapTypeCard.tsx b/frontend/src/features/mindmap/components/MindmapTypeCard.tsx new file mode 100644 index 000000000..0b7d0a01b --- /dev/null +++ b/frontend/src/features/mindmap/components/MindmapTypeCard.tsx @@ -0,0 +1,44 @@ +import Card from "@/shared/components/card/Card"; +import Icon, { IconName } from "@/shared/components/icon/Icon"; +import { cn } from "@/utils/cn"; + +type Props = { + icon: IconName; + title: string; + description: string; + isSelected: boolean; + onClick: () => void; +}; + +const MindmapTypeCard = ({ icon, title, description, isSelected, onClick }: Props) => { + return ( + + {isSelected && ( +
+ +
+ )} + + +

{title}

+
+ } + contents={ +
+

{description}

+
+ } + /> + ); +}; + +export default MindmapTypeCard; diff --git a/frontend/src/features/mindmap/components/MindmapTypeStep.tsx b/frontend/src/features/mindmap/components/MindmapTypeStep.tsx new file mode 100644 index 000000000..400cde45a --- /dev/null +++ b/frontend/src/features/mindmap/components/MindmapTypeStep.tsx @@ -0,0 +1,75 @@ +import MindmapTypeCard from "@/features/mindmap/components/MindmapTypeCard"; +import { CreateMindmapFunnel } from "@/features/mindmap/types/mindmap_funnel"; +import BottomSticky from "@/shared/components/bottom_sticky/BottomSticky"; +import Button from "@/shared/components/button/Button"; +import Top from "@/shared/components/top/Top"; +import { FunnelInstance } from "@/shared/hooks/useFunnel"; + +type TypeStepFunnel = Extract, { step: "TYPE" }>; + +const MINDMAP_TYPES = [ + { + id: "PRIVATE" as const, + icon: "ic_user" as const, + title: "개인 마인드맵", + description: "나만 사용할 수 있는 마인드맵이에요.\n개인 경험 정리와 자기소개서 준비에 적합해요.", + }, + { + id: "PUBLIC" as const, + icon: "ic_team" as const, + title: "팀 마인드맵", + description: "함께 사용하는 마인드맵이에요.\n프로젝트를 진행한 팀원들과 경험을 정리할 수 있어요.", + }, +] as const; + +export function MindmapTypeStep({ funnel }: { funnel: TypeStepFunnel }) { + const selectedType = funnel.context.mindmapType; + + return ( + <> +
+ 어떤 마인드맵을 만들까요?} + lower={ +

+ 사용 목적에 따라 알맞는 마인드맵 유형을 선택해 주세요. +

+ } + className="mb-12" + /> + +
+ {MINDMAP_TYPES.map((type) => ( + funnel.history.setContext({ mindmapType: type.id })} + /> + ))} +
+
+ + + + + + ); +} diff --git a/frontend/src/features/mindmap/components/TeamDetailStep.tsx b/frontend/src/features/mindmap/components/TeamDetailStep.tsx new file mode 100644 index 000000000..f029b4125 --- /dev/null +++ b/frontend/src/features/mindmap/components/TeamDetailStep.tsx @@ -0,0 +1,154 @@ +import { useMemo } from "react"; + +import { useCreateMindmap } from "@/features/mindmap/hooks/useCreateMindmap"; +import { CreateMindmapFunnel } from "@/features/mindmap/types/mindmap_funnel"; +import BottomSticky from "@/shared/components/bottom_sticky/BottomSticky"; +import Button from "@/shared/components/button/Button"; +import Input from "@/shared/components/Input/Input"; +import Top from "@/shared/components/top/Top"; +import { FunnelInstance } from "@/shared/hooks/useFunnel"; +import { linkTo } from "@/shared/utils/route"; + +type TeamDetailStepFunnel = Extract, { step: "TEAM_DETAIL" }>; + +function TextField(props: { + label: string; + required?: boolean; + value: string; + placeholder?: string; + onChange: (v: string) => void; +}) { + const { label, required, value, placeholder, onChange } = props; + + return ( +
+ + onChange(e.target.value)} /> +
+ ); +} + +function DashedAddButton(props: { onClick: () => void; label: string }) { + return ( + + ); +} + +export function TeamDetailStep({ funnel }: { funnel: TeamDetailStepFunnel }) { + const { mutate: createMindmap, isPending } = useCreateMindmap(); + + const projectName = funnel.context.projectName ?? ""; + const episodes = funnel.context.episodes ?? []; + + const updateProjectName = (v: string) => funnel.history.setContext({ projectName: v }); + const updateEpisode = (index: number, v: string) => { + funnel.history.setContext((prev) => { + const next = [...(prev.episodes || [])]; + next[index] = v; + return { ...prev, episodes: next }; + }); + }; + + const addEpisode = () => { + funnel.history.setContext((prev) => ({ ...prev, episodes: [...(prev.episodes || []), ""] })); + }; + + const cleanedEpisodes = useMemo(() => episodes.map((e) => e.trim()).filter(Boolean), [episodes]); + const canNext = projectName.trim().length > 0 && cleanedEpisodes.length > 0; + + const handleSubmit = () => { + createMindmap( + { + title: projectName, + isShared: true, + }, + { + onSuccess: (data) => { + funnel.exit(linkTo.mindmap.detail(data.mindmap.mindmapId), { replace: false }); + }, + onError: (error) => { + console.error(error.message || "마인드맵 생성 중 오류가 발생했습니다."); + }, + }, + ); + }; + + return ( + <> +
+
+ + 팀 마인드맵을 시작해볼까요? + + } + lower={ +

+ 프로젝트 이름과 함께, 정리할 경험을 입력해 주세요. +

+ } + className="mb-12" + /> +
+ +
+ +
정리할 에피소드
+
+ + {/* 2. 스크롤 영역 (에피소드 리스트) */} + {/* flex-1: 남은 공간 모두 차지 */} + {/* overflow-y-auto: 내용이 넘치면 이 영역 안에서만 스크롤 발생 */} +
+
+
+ {episodes.map((ep, idx) => ( +
+
에피소드 {idx + 1}
+ updateEpisode(idx, e.target.value)} + placeholder="에피소드 제목을 입력해 주세요" + /> +
+ ))} +
+
+ +
+
+
+
+ + + + + + ); +} diff --git a/frontend/src/features/mindmap/hooks/useCreateMindmap.ts b/frontend/src/features/mindmap/hooks/useCreateMindmap.ts new file mode 100644 index 000000000..d3d8d1f85 --- /dev/null +++ b/frontend/src/features/mindmap/hooks/useCreateMindmap.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { ApiError } from "@/features/auth/types/api"; +import { mindmapEndpoints } from "@/features/mindmap/api/mindmap_endpoints"; +import { mindmapKeys } from "@/features/mindmap/api/mindmap_query_keys"; +import { MindmapItem } from "@/features/mindmap/types/mindmap"; +import { post } from "@/shared/api/method"; + +type CreateMindmapRequest = { + isShared: boolean; + title: string; +}; + +type CreateMindmapResponse = { + mindmap: Omit; +}; + +const fetchCreateMindmap = (body: CreateMindmapRequest) => { + return post({ + endpoint: mindmapEndpoints.create, + data: body, + }); +}; + +export const useCreateMindmap = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: fetchCreateMindmap, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: mindmapKeys.lists() }); + }, + onError: (error) => { + console.error("마인드맵 생성 실패:", error.message); + }, + }); +}; diff --git a/frontend/src/features/mindmap/pages/CreateMindmapPage.tsx b/frontend/src/features/mindmap/pages/CreateMindmapPage.tsx new file mode 100644 index 000000000..9574fd05f --- /dev/null +++ b/frontend/src/features/mindmap/pages/CreateMindmapPage.tsx @@ -0,0 +1,33 @@ +import { MindmapCategoryStep } from "@/features/mindmap/components/MindmapCategoryStep"; +import { MindmapTypeStep } from "@/features/mindmap/components/MindmapTypeStep"; +import { TeamDetailStep } from "@/features/mindmap/components/TeamDetailStep"; +import { CreateMindmapFunnel } from "@/features/mindmap/types/mindmap_funnel"; +import Icon from "@/shared/components/icon/Icon"; +import MaxWidth from "@/shared/components/max_width/MaxWidth"; +import Top from "@/shared/components/top/Top"; +import { useFunnel } from "@/shared/hooks/useFunnel"; + +const CreateMindmapFunnelPage = () => { + const funnel = useFunnel({ + id: "create-mindmap-funnel", + initial: { step: "TYPE", context: {} }, + }); + + return ( + + funnel.history.back()}> + + + } + /> + + {funnel.step === "TYPE" && } + {funnel.step === "TEAM_DETAIL" && } + {funnel.step === "CATEGORY" && } + + ); +}; + +export default CreateMindmapFunnelPage; diff --git a/frontend/src/features/mindmap/pages/MindmapDetailPage.tsx b/frontend/src/features/mindmap/pages/MindmapDetailPage.tsx new file mode 100644 index 000000000..44188cfbd --- /dev/null +++ b/frontend/src/features/mindmap/pages/MindmapDetailPage.tsx @@ -0,0 +1,39 @@ +// import { useSearchParams } from "react-router"; + +// import DiagnosisResultSidebar from "@/features/self_diagnosis/components/DiagnosisResultSideBar"; +// import DiagnosisResultSidePanel from "@/features/self_diagnosis/components/DiagnosisResultSidePanel"; + +const MindmapDetailPage = () => { + // const [sp, setSp] = useSearchParams(); + // const diagnosisIdParam = sp.get("diagnosisId"); + // const diagnosisId = diagnosisIdParam ? Number(diagnosisIdParam) : null; + + return ( + <> +
마인드맵 캔버스 영역
+ + {/* {diagnosisId ? ( + { + sp.delete("diagnosisId"); + setSp(sp); + }} + /> + ) : null} */} + {/* {}} + onBack={() => {}} + /> */} + + ); +}; + +export default MindmapDetailPage; diff --git a/frontend/src/features/mindmap/types/mindmap_funnel.ts b/frontend/src/features/mindmap/types/mindmap_funnel.ts new file mode 100644 index 000000000..9e6c20a4b --- /dev/null +++ b/frontend/src/features/mindmap/types/mindmap_funnel.ts @@ -0,0 +1,13 @@ +import { ActivityCategory, MindmapType } from "@/features/mindmap/types/mindmap"; +import { SelectFrom } from "@/shared/types/utility_type"; + +export type CreateMindmapFunnel = { + TYPE: { mindmapType?: MindmapType }; + TEAM_DETAIL: { mindmapType: SelectFrom; projectName?: string; episodes: string[] }; + CATEGORY: { + mindmapType: SelectFrom; + categories?: ActivityCategory[]; + projectName?: string; + episodes?: string[]; + }; +}; diff --git a/frontend/src/global.css b/frontend/src/global.css index 56a3454ab..2e0b9a3e4 100644 --- a/frontend/src/global.css +++ b/frontend/src/global.css @@ -164,12 +164,24 @@ opacity: 1; visibility: visible; } + .action-buttons { opacity: 0; visibility: hidden; transition: opacity 0.2s; } + .bg-landing { + background-image: url("@/assets/img/landing_bg.png"); + background-repeat: no-repeat; + background-position: top center; + background-size: cover; + + background-attachment: local; + + min-height: 100%; + } + .moving-fragment-group { pointer-events: none; } diff --git a/frontend/src/shared/components/call_to_action_button/CallToActionButton.tsx b/frontend/src/shared/components/call_to_action_button/CallToActionButton.tsx index 83100b8d7..e151df2e9 100644 --- a/frontend/src/shared/components/call_to_action_button/CallToActionButton.tsx +++ b/frontend/src/shared/components/call_to_action_button/CallToActionButton.tsx @@ -23,7 +23,7 @@ const CallToActionButton = ({ variant = "primary", className, children }: Props) }; const variants = cva( - "py-2 pr-2 pl-5 typo-body-16-medium flex flex-row items-center gap-4 rounded-lg shadow-[0_0_10px_0_rgba(111,128,255,0.30)]", + "py-2 pr-2 pl-5 typo-body-16-medium flex flex-row items-center gap-4 rounded-lg shadow-[0_0_10px_0_rgba(111,128,255,0.30)] transition-colors", // transition 추가 권장 { variants: { variant: { @@ -31,6 +31,10 @@ const variants = cva( primary_accent: [COLOR_SET.primary_accent, "[&_svg]:text-black", "[&_div]:bg-white"], quaternary_accent_outlined: [ COLOR_SET.quaternary_accent_outlined, + "hover:bg-primary", + "hover:text-white", + "hover:[&_svg]:text-black", + "hover:[&_div]:bg-white", "[&_svg]:text-white", "[&_div]:bg-primary", ], @@ -38,5 +42,4 @@ const variants = cva( }, }, ); - export default CallToActionButton; diff --git a/frontend/src/shared/components/emoji_card/EmojiCard.tsx b/frontend/src/shared/components/emoji_card/EmojiCard.tsx new file mode 100644 index 000000000..eda152d6a --- /dev/null +++ b/frontend/src/shared/components/emoji_card/EmojiCard.tsx @@ -0,0 +1,20 @@ +import Icon from "@/shared/components/icon/Icon"; +import { cn } from "@/utils/cn"; + +export function EmojiCard(props: { emoji: string; label: string; selected: boolean; onClick: () => void }) { + const { emoji, label, selected, onClick } = props; + return ( + + ); +} diff --git a/frontend/src/shared/components/global_navigation_bar/GlobalNavigationBar.tsx b/frontend/src/shared/components/global_navigation_bar/GlobalNavigationBar.tsx index 6e63c7d34..c01bd16a8 100644 --- a/frontend/src/shared/components/global_navigation_bar/GlobalNavigationBar.tsx +++ b/frontend/src/shared/components/global_navigation_bar/GlobalNavigationBar.tsx @@ -57,7 +57,7 @@ const variants = cva("w-full h-18.5 border-b py-4 px-9 flex flex-row items-cente variants: { variant: { white: "bg-white border-gray-300", - transparent: "bg-white-op-20 border-white", + transparent: "bg-white/20 border-white/30 backdrop-blur-xs", }, }, }); diff --git a/frontend/src/shared/components/top/Top.tsx b/frontend/src/shared/components/top/Top.tsx index 718dd7c0c..b7a3b1304 100644 --- a/frontend/src/shared/components/top/Top.tsx +++ b/frontend/src/shared/components/top/Top.tsx @@ -27,7 +27,7 @@ const Top = ({
{upper} -
+
{leftSlot} {title}
diff --git a/frontend/src/shared/hooks/useFunnel.ts b/frontend/src/shared/hooks/useFunnel.ts new file mode 100644 index 000000000..0f8d1281f --- /dev/null +++ b/frontend/src/shared/hooks/useFunnel.ts @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router"; + +import { MissingRequiredKeys, UnknownRecord } from "@/shared/types/utility_type"; + +type StepContextMap = Record>; +type StepKey = Extract; + +export type FunnelItem = StepKey> = { + step: S; + context: SCM[S]; +}; + +type FunnelSnapshot = { + item: FunnelItem; + depth: number; + nextStep?: StepKey; +}; + +export type FunnelContextPatch = Partial & + Pick>; + +type HistoryController> = { + canGoBack: boolean; + + push: >(step: ToStep, patch: FunnelContextPatch) => void; + + setContext: (patch: Partial | ((prev: SCM[FromStep]) => SCM[FromStep])) => void; + + back: () => void; +}; + +type FunnelController> = { + step: Step; + context: SCM[Step]; + history: HistoryController; + exit: (url: string, options?: { replace?: boolean }) => void; +}; + +export type FunnelInstance = { + [S in StepKey]: FunnelController; +}[StepKey]; + +type UseFunnelOptions> = { + id: string; + initial: FunnelItem; +}; + +const STATE_KEY = "__USE_FUNNEL__"; + +const isRecord = (v: unknown): v is UnknownRecord => typeof v === "object" && v !== null; + +const isFunnelItemLike = (v: unknown): v is { step: string; context: UnknownRecord } => { + if (!isRecord(v)) return false; + if (typeof v.step !== "string") return false; + if (!isRecord(v.context)) return false; + return true; +}; + +/** + * 거대한 타입 가드 연쇄로 FunnelSnapshot을 반환합니다. + */ +function getSnapshotFromHistory(state: unknown, id: string): FunnelSnapshot | null { + // window.history.state as unknown이 record가 아닐 경우. + if (!isRecord(state)) return null; + + // 만약 STATE_KEY가 record가 아닐 경우 + const all = state[STATE_KEY]; + if (!isRecord(all)) return null; + + // id에 해당하는 snap이 record가 아닐 경우 + const snap = all[id]; + if (!isRecord(snap)) return null; + + const item = snap.item; + const depth = snap.depth; + const nextStep = snap.nextStep; + + // item이 funnelItem이 아닐 경우 + if (!isFunnelItemLike(item)) return null; + + // depth가 올바른 타입이. 아닐 경우 + if (typeof depth !== "number" || !Number.isFinite(depth) || depth < 0) return null; + + if (nextStep !== undefined && typeof nextStep !== "string") return null; + + return { + item: item as unknown as FunnelItem, + depth, + nextStep: nextStep as StepKey | undefined, + }; +} + +function writeSnapshotToHistory( + id: string, + snapshot: FunnelSnapshot, + mode: "push" | "replace", +) { + if (typeof window === "undefined") return; + + const currentState = window.history.state as unknown; + + const base: UnknownRecord = isRecord(currentState) ? currentState : {}; + const prevAllRaw = base[STATE_KEY]; + const prevAll: UnknownRecord = isRecord(prevAllRaw) ? prevAllRaw : {}; + + const nextState: UnknownRecord = { + ...base, + [STATE_KEY]: { + ...prevAll, + [id]: snapshot, + }, + }; + + if (mode === "push") window.history.pushState(nextState, ""); + else window.history.replaceState(nextState, ""); +} + +function mergeDefined(base: UnknownRecord, overlay: UnknownRecord): UnknownRecord { + const out: UnknownRecord = { ...base }; + for (const key of Object.keys(overlay)) { + const val = overlay[key]; + if (val !== undefined) out[key] = val; + } + return out; +} + +type PendingForward = { + expectedStep: StepKey; + fromContext: UnknownRecord; + patch: UnknownRecord; +}; + +export function useFunnel>( + options: UseFunnelOptions, +): FunnelInstance { + const { id, initial } = options; + + const initialSnapshotRef = useRef>({ + item: initial as FunnelItem, + depth: 0, + nextStep: undefined, + }); + + const snapshotRef = useRef>(initialSnapshotRef.current); + const [snapshot, _setSnapshot] = useState>(initialSnapshotRef.current); + + const pendingForwardRef = useRef | null>(null); + + const setSnapshotLocal = useCallback((next: FunnelSnapshot) => { + snapshotRef.current = next; + _setSnapshot(next); + }, []); + + const syncSnapshot = useCallback( + (next: FunnelSnapshot, mode: "push" | "replace") => { + setSnapshotLocal(next); + writeSnapshotToHistory(id, next, mode); + }, + [id, setSnapshotLocal], + ); + + useEffect(() => { + if (typeof window === "undefined") return; + + const restored = getSnapshotFromHistory(window.history.state as unknown, id); + + if (restored) { + setSnapshotLocal(restored); + return; + } + + writeSnapshotToHistory(id, snapshotRef.current, "replace"); + }, [id, setSnapshotLocal]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const handler = (event: PopStateEvent) => { + const restored = getSnapshotFromHistory(event.state as unknown, id); + if (!restored) return; + + const pending = pendingForwardRef.current; + + if (pending && restored.item.step === pending.expectedStep) { + pendingForwardRef.current = null; + + const baseCtx = restored.item.context as unknown as UnknownRecord; + + const merged = mergeDefined(mergeDefined(baseCtx, pending.fromContext), pending.patch); + + syncSnapshot( + { + ...restored, + item: { + step: restored.item.step, + context: merged as unknown as (typeof restored.item)["context"], + } as FunnelItem, + }, + "replace", + ); + return; + } + + setSnapshotLocal(restored); + }; + + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, [id, setSnapshotLocal, syncSnapshot]); + + const currentItem = snapshot.item ?? initialSnapshotRef.current.item; + + const push = useCallback( + >(step: ToStep, patch: FunnelContextPatch]>) => { + const cur = snapshotRef.current; + const prevItem = cur.item ?? (initialSnapshotRef.current.item as FunnelItem); + + // 1) 미래 step이 존재하고 동일 step이면 forward로 "복원" + if (typeof window !== "undefined" && cur.nextStep === step) { + pendingForwardRef.current = { + expectedStep: step, + fromContext: prevItem.context as unknown as UnknownRecord, + patch: (isRecord(patch) ? patch : {}) as UnknownRecord, + }; + + window.history.forward(); + return; + } + + if (typeof window !== "undefined") { + writeSnapshotToHistory(id, { ...cur, nextStep: step }, "replace"); + } + + const nextContext = { ...prevItem.context, ...(patch ?? {}) } as SCM[ToStep]; + const nextItem: FunnelItem = { step, context: nextContext }; + + syncSnapshot( + { + item: nextItem as FunnelItem, + depth: cur.depth + 1, + nextStep: undefined, + }, + "push", + ); + }, + [id, syncSnapshot], + ); + + const setContext = useCallback( + (patchOrUpdater: Partial]> | ((prev: SCM[StepKey]) => SCM[StepKey])) => { + const cur = snapshotRef.current; + const prevItem = cur.item ?? (initialSnapshotRef.current.item as FunnelItem); + const prevCtx = prevItem.context as SCM[StepKey]; + + const nextCtx = + typeof patchOrUpdater === "function" + ? patchOrUpdater(prevCtx) + : ({ ...prevCtx, ...(patchOrUpdater ?? {}) } as SCM[StepKey]); + + syncSnapshot( + { + ...cur, + item: { step: prevItem.step, context: nextCtx } as FunnelItem, + }, + "replace", + ); + }, + [syncSnapshot], + ); + + const navigate = useNavigate(); + + const back = useCallback(() => { + if (typeof window === "undefined") return; + window.history.back(); + }, []); + + const exit = useCallback( + (url: string, options?: { replace?: boolean }) => { + if (typeof window === "undefined") return; + + const depth = snapshotRef.current.depth; + + if (depth <= 0) { + navigate(url, { replace: true }); + return; + } + + const handleMoveComplete = () => { + window.removeEventListener("popstate", handleMoveComplete); + + // 2. 실제로 포인터가 뒤로 밀린 시점에 목적지로 이동 + if (options?.replace) { + navigate(url, { replace: true }); + } else { + navigate(url); + } + }; + + window.addEventListener("popstate", handleMoveComplete); + + window.history.go(-(depth + 1)); + }, + [navigate], + ); + + const history = useMemo(() => { + return { + canGoBack: snapshot.depth > 0, + push, + setContext, + back, + }; + }, [snapshot.depth, push, setContext, back]); + + return useMemo(() => { + return { + step: currentItem.step, + context: currentItem.context, + history, + exit, + } as FunnelInstance; + }, [currentItem.step, currentItem.context, history, exit]); +} diff --git a/frontend/src/shared/types/utility_type.ts b/frontend/src/shared/types/utility_type.ts new file mode 100644 index 000000000..cc83352bb --- /dev/null +++ b/frontend/src/shared/types/utility_type.ts @@ -0,0 +1,40 @@ +/** + * Extract는 자동완성이 지원이 안되므로 필요하다면 해당 타입으로 감싸 사용합니다. + */ +export type SelectFrom = K; + +/** + * T 객체 내의 특정 K(key)가 optional인지 아닌지 판단합니다. + */ +type IsOptionalKey = Pick extends Required> ? false : true; + +/** + * optional 타입인 key에 대해서 union으로 반환합니다.. + */ +type OptionalKeys = { + [K in keyof T]-?: IsOptionalKey extends true ? K : never; +}[keyof T]; + +/** + * required 타입인 key에 대해서 union으로 반환합니다. + */ +type RequiredKeys = Exclude>; + +type GuaranteedKey = + IsOptionalKey extends true + ? false + : From[K] extends To[K] + ? undefined extends From[K] + ? false + : true + : false; + +type IsGuaranteedKey = K extends keyof From + ? GuaranteedKey + : false; + +export type MissingRequiredKeys = { + [K in RequiredKeys]: IsGuaranteedKey extends true ? never : K; +}[RequiredKeys]; + +export type UnknownRecord = Record; diff --git a/frontend/src/shared/utils/route.ts b/frontend/src/shared/utils/route.ts index e2a29695e..b09c96b05 100644 --- a/frontend/src/shared/utils/route.ts +++ b/frontend/src/shared/utils/route.ts @@ -6,9 +6,16 @@ export const PATHS = { detail: "/mindmaps/:mindmap_id", }, episode_archive: "/episode_archive", - self_diagnosis: { + self_diagnoses: { list: "/self_diagnoses", detail: "/self_diagnoses/:self_diagnosis_id", + start: "/self_diagnoses/start", + question: "/self_diagnoses/:job_id", + question_result: "/self_diagnoses/:job_id", + }, + diagnoses: { + applyExisting: "/diagnoses/:diagnosisId/apply/existing", + applyNew: "/diagnoses/:diagnosisId/apply/new", }, login: "/login", landing: "/landing", @@ -26,9 +33,20 @@ export const linkTo = { episode_archive: () => PATHS.episode_archive, self_diagnosis: { - list: () => PATHS.self_diagnosis.list, + list: () => PATHS.self_diagnoses.list, + start: () => PATHS.self_diagnoses.start, detail: (selfDiagnosisId: number | string) => - PATHS.self_diagnosis.detail.replace(":self_diagnosis_id", String(selfDiagnosisId)), + PATHS.self_diagnoses.detail.replace(":self_diagnosis_id", String(selfDiagnosisId)), + question: (jobId: number | string) => PATHS.self_diagnoses.question.replace(":job_id", String(jobId)), + question_result: (jobId: number | string) => + PATHS.self_diagnoses.question_result.replace(":job_id", String(jobId)), + }, + + diagnoses: { + applyExisting: (diagnosisId: number | string) => + PATHS.diagnoses.applyExisting.replace(":diagnosisId", String(diagnosisId)), + applyNew: (diagnosisId: number | string) => + PATHS.diagnoses.applyNew.replace(":diagnosisId", String(diagnosisId)), }, login: () => PATHS.login, diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 0ea5be1d4..f46f5e35e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "paths": { "@/shared/*": ["shared/*"], + "@/assets/*": ["assets/*"], "@/utils/*": ["utils/*"], "@/icons/*": ["assets/icons/*"], "@/features/*": ["features/*"], diff --git a/frontend/vite.base.ts b/frontend/vite.base.ts index 74d34922b..ae7679e89 100644 --- a/frontend/vite.base.ts +++ b/frontend/vite.base.ts @@ -13,6 +13,7 @@ export default defineConfig({ { find: "@/icons", replacement: path.resolve(__dirname, "src/assets/icons") }, { find: "@/features", replacement: path.resolve(__dirname, "src/features") }, { find: "@/constants", replacement: path.resolve(__dirname, "src/constants") }, + { find: "@/assets", replacement: path.resolve(__dirname, "src/assets") }, ], }, });