diff --git a/public/onBoarding/my-history-onboarding.png b/public/onBoarding/my-history-onboarding.png new file mode 100644 index 00000000..70ab4e47 Binary files /dev/null and b/public/onBoarding/my-history-onboarding.png differ diff --git a/public/onBoarding/my-page-onboarding.png b/public/onBoarding/my-page-onboarding.png new file mode 100644 index 00000000..7bebb9d6 Binary files /dev/null and b/public/onBoarding/my-page-onboarding.png differ diff --git a/public/onBoarding/notice-board-onboarding.png b/public/onBoarding/notice-board-onboarding.png new file mode 100644 index 00000000..c9e6ebbc Binary files /dev/null and b/public/onBoarding/notice-board-onboarding.png differ diff --git a/public/onBoarding/task-list-onboarding.png b/public/onBoarding/task-list-onboarding.png new file mode 100644 index 00000000..7c9d9c23 Binary files /dev/null and b/public/onBoarding/task-list-onboarding.png differ diff --git a/public/onBoarding/team-create-onboarding.png b/public/onBoarding/team-create-onboarding.png new file mode 100644 index 00000000..acbd383d Binary files /dev/null and b/public/onBoarding/team-create-onboarding.png differ diff --git a/src/api/hooks/auth/usePostSignup.ts b/src/api/hooks/auth/usePostSignup.ts index 563d45f7..fb131981 100644 --- a/src/api/hooks/auth/usePostSignup.ts +++ b/src/api/hooks/auth/usePostSignup.ts @@ -30,7 +30,12 @@ const usePostSignup = () => { tokenStorage.setAccessToken(data.accessToken); success("회원가입이 완료되었습니다!"); - router.replace("/"); + + if (typeof window !== "undefined") { + sessionStorage.removeItem("hasSeenOnboarding"); + } + + router.replace("/?onboarding=true"); }, onError: (err: AxiosError) => { const responseData = err.response?.data; diff --git a/src/app/(route)/_components/LoadingOnboarding/LoadingOnboarding.tsx b/src/app/(route)/_components/LoadingOnboarding/LoadingOnboarding.tsx new file mode 100644 index 00000000..629910d3 --- /dev/null +++ b/src/app/(route)/_components/LoadingOnboarding/LoadingOnboarding.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Suspense } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import dynamic from "next/dynamic"; +import { LoadingSpinner } from "@/features"; + +const OnboardingModal = dynamic(() => import("@/app/(route)/_components/Onboarding/OnboardingModal"), { ssr: false }); + +const OnboardingContent = () => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const isOnboarding = searchParams.get("onboarding") === "true"; + + const handleClose = () => { + router.replace(pathname, { scroll: false }); + }; + + if (!isOnboarding) return null; + + return ; +}; + +export default function LoadingOnBoarding() { + return ( + }> + + + ); +} diff --git a/src/app/(route)/_components/Onboarding/OnboardingModal.tsx b/src/app/(route)/_components/Onboarding/OnboardingModal.tsx new file mode 100644 index 00000000..61a22d47 --- /dev/null +++ b/src/app/(route)/_components/Onboarding/OnboardingModal.tsx @@ -0,0 +1,215 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { cn } from "@/utils"; +import { ONBOARDING_STEPS } from "./_constants/onBoardingData"; +import { overlayVariants, modalVariants, contentVariants } from "./_constants/onBoardingAnimations"; + +interface OnboardingModalProps { + onClose: () => void; +} + +const STORAGE_KEY = "hasSeenOnboarding"; + +const OnBoardingModal = ({ onClose }: OnboardingModalProps) => { + const [isOpen, setIsOpen] = useState(() => { + if (typeof window !== "undefined") { + return !sessionStorage.getItem(STORAGE_KEY); + } + return false; + }); + + const [currentStep, setCurrentStep] = useState(0); + const [direction, setDirection] = useState(0); + + const currentData = ONBOARDING_STEPS[currentStep]; + const isLastStep = currentStep === ONBOARDING_STEPS.length - 1; + const totalSteps = ONBOARDING_STEPS.length; + + const handleComplete = () => { + sessionStorage.setItem(STORAGE_KEY, "true"); + setIsOpen(false); + onClose(); + }; + + const handleNext = () => { + if (currentStep < ONBOARDING_STEPS.length - 1) { + setDirection(1); + setCurrentStep((prev) => prev + 1); + } else { + handleComplete(); + } + }; + + const handleStepClick = (stepIndex: number) => { + setDirection(stepIndex > currentStep ? 1 : -1); + setCurrentStep(stepIndex); + }; + + return ( + + {isOpen && ( + + +
+
+ + + {currentData.imageSrc ? ( + {currentData.title} + ) : ( +
+ 이미지 준비중 +
+ )} +
+
+
+
+ + + + Step {currentStep + 1} / {totalSteps} + +

+ {currentData.title} +

+

+ {currentData.description} +

+
+
+
+
+
+ + + +
+
+
+ )} +
+ ); +}; + +export default OnBoardingModal; diff --git a/src/app/(route)/_components/Onboarding/_constants/onBoardingAnimations.ts b/src/app/(route)/_components/Onboarding/_constants/onBoardingAnimations.ts new file mode 100644 index 00000000..7c62145e --- /dev/null +++ b/src/app/(route)/_components/Onboarding/_constants/onBoardingAnimations.ts @@ -0,0 +1,38 @@ +import type { Variants } from "framer-motion"; + +export const overlayVariants: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +export const modalVariants: Variants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { + opacity: 1, + scale: 1, + transition: { type: "spring", damping: 25, stiffness: 300 }, + }, + exit: { + opacity: 0, + scale: 0.95, + transition: { duration: 0.2 }, + }, +}; + +export const contentVariants: Variants = { + enter: (direction: number) => ({ + x: direction > 0 ? 100 : -100, + opacity: 0, + }), + center: { + x: 0, + opacity: 1, + transition: { type: "spring", damping: 25, stiffness: 300 }, + }, + exit: (direction: number) => ({ + x: direction > 0 ? -100 : 100, + opacity: 0, + transition: { duration: 0.2 }, + }), +}; diff --git a/src/app/(route)/_components/Onboarding/_constants/onBoardingData.ts b/src/app/(route)/_components/Onboarding/_constants/onBoardingData.ts new file mode 100644 index 00000000..aafeb083 --- /dev/null +++ b/src/app/(route)/_components/Onboarding/_constants/onBoardingData.ts @@ -0,0 +1,37 @@ +import { OnboardingStep } from "../_type/type"; + +export const ONBOARDING_STORAGE_KEY = "hasSeenOnboarding"; + +export const ONBOARDING_STEPS: OnboardingStep[] = [ + { + id: 1, + title: "팀을 만들어\n협업을 시작하세요", + description: "새로운 팀을 생성하거나,\n초대 코드로 기존 팀에 참여하여 동료들을 만나보세요.", + imageSrc: "/onBoarding/team-create-onboarding.png", + }, + { + id: 2, + title: "할 일을 체계적으로\n관리하고 공유하세요", + description: + "해야 할 일을 등록하고 우선순위를 정해보세요.\n상세 페이지에서 진행 상황을 꼼꼼하게 체크할 수 있습니다.", + imageSrc: "/onBoarding/task-list-onboarding.png", + }, + { + id: 3, + title: "자유롭게 소통하며\n아이디어를 나누세요", + description: "팀 페이지에서 멤버들을 확인하고,\n자유게시판에서 업무 외적인 이야기도 편하게 나누어보세요.", + imageSrc: "/onBoarding/notice-board-onboarding.png", + }, + { + id: 4, + title: "나의 활동 기록을\n한눈에 확인하세요", + description: "마이 히스토리에서 내가 완료한 일들을 모아보세요.\n성실하게 쌓여가는 기록들이 성장의 밑거름이 됩니다.", + imageSrc: "/onBoarding/my-history-onboarding.png", + }, + { + id: 5, + title: "모든 준비가\n완료되었습니다!", + description: "계정 설정에서 프로필을 멋지게 꾸며보세요.\n이제 최고의 팀원들과 함께 프로젝트를 시작해볼까요?", + imageSrc: "/onBoarding/my-page-onboarding.png", + }, +]; diff --git a/src/app/(route)/_components/Onboarding/_type/type.ts b/src/app/(route)/_components/Onboarding/_type/type.ts new file mode 100644 index 00000000..96f46425 --- /dev/null +++ b/src/app/(route)/_components/Onboarding/_type/type.ts @@ -0,0 +1,6 @@ +export interface OnboardingStep { + id: number; + title: string; + description: string; + imageSrc: string; +} diff --git a/src/app/(route)/_landing/_components/_internal/DeviceImage/DeviceImage.tsx b/src/app/(route)/_landing/_components/_internal/DeviceImage/DeviceImage.tsx index 0cff0256..9e35674b 100644 --- a/src/app/(route)/_landing/_components/_internal/DeviceImage/DeviceImage.tsx +++ b/src/app/(route)/_landing/_components/_internal/DeviceImage/DeviceImage.tsx @@ -1,4 +1,3 @@ -import { cn } from "@/utils"; import Image from "next/image"; interface ImageSource { diff --git a/src/app/(route)/team/[teamId]/task-list/[taskListId]/_detail/_components/DetailPage/DetailPage.tsx b/src/app/(route)/team/[teamId]/task-list/[taskListId]/_detail/_components/DetailPage/DetailPage.tsx index d629e114..f47d9e16 100644 --- a/src/app/(route)/team/[teamId]/task-list/[taskListId]/_detail/_components/DetailPage/DetailPage.tsx +++ b/src/app/(route)/team/[teamId]/task-list/[taskListId]/_detail/_components/DetailPage/DetailPage.tsx @@ -44,8 +44,8 @@ const DetailPage = ({ id, teamId, taskListId }: DetailPageProps) => { className={cn( "w-full min-h-[calc(100vh-52px)] flex flex-col px-4 py-3 space-y-6 bg-background-primary", "fixed inset-x-0 inset-y-10 z-[999] shadow-lg hide-scrollbar", - "tablet:px-7 tablet:py-10 tablet:inset-x-[150px] tablet:inset-y-0", - "pc:top-0 pc:bottom-0 pc:right-0 pc:left-auto", + "tablet:top-0 tablet:bottom-0 tablet:right-0 tablet:left-auto", + "tablet:w-[520px] tablet:min-w-[520px]", "pc:w-[420px] pc:min-w-[420px]", "pc:max-h-[100vh] pc:overflow-y-auto pc:shadow-none", )} diff --git a/src/app/page.tsx b/src/app/page.tsx index 80877605..202ca012 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,6 @@ import { cookies } from "next/headers"; import { HeroSection, KanbanSection, DetailSection, CooperationSection, ConversionSection } from "./(route)/_landing"; +import LandingOnboarding from "./(route)/_components/LoadingOnboarding/LoadingOnboarding"; export default async function Page() { const cookieStore = await cookies(); @@ -8,12 +9,13 @@ export default async function Page() { const startDestination = token ? "/team" : "/login"; return ( -
+
+
); } diff --git a/src/common/Dropdown/Dropdown.tsx b/src/common/Dropdown/Dropdown.tsx index 3c30b1c7..fb0b6336 100644 --- a/src/common/Dropdown/Dropdown.tsx +++ b/src/common/Dropdown/Dropdown.tsx @@ -72,7 +72,7 @@ const Dropdown = ({ left: position.left, }} className={cn( - "min-w-[120px] bg-background-primary border rounded-xl shadow-md", + "min-w-[120px] bg-background-primary border rounded-xl shadow-md z-[999]", PLACEMENT_TRANSFORM[placement], )} >