Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/onBoarding/my-history-onboarding.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/onBoarding/my-page-onboarding.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/onBoarding/notice-board-onboarding.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/onBoarding/task-list-onboarding.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/onBoarding/team-create-onboarding.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion src/api/hooks/auth/usePostSignup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ErrorResponse>) => {
const responseData = err.response?.data;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <OnboardingModal onClose={handleClose} />;
};

export default function LoadingOnBoarding() {
return (
<Suspense fallback={<LoadingSpinner size="lg" />}>
<OnboardingContent />
</Suspense>
);
}
215 changes: 215 additions & 0 deletions src/app/(route)/_components/Onboarding/OnboardingModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AnimatePresence>
{isOpen && (
<motion.div
role="dialog"
aria-modal="true"
aria-label="서비스 온보딩"
variants={overlayVariants}
initial="hidden"
animate="visible"
exit="exit"
className={cn(
"fixed inset-0 z-50 flex items-center justify-center",
"bg-background-primary",
"tablet:bg-black/60 tablet:backdrop-blur-sm tablet:px-10",
"pc:px-10",
)}
>
<motion.div
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
className={cn(
"w-full h-dvh flex flex-col bg-background-primary",
"tablet:h-[500px] tablet:max-w-[768px] tablet:rounded-2xl tablet:shadow-2xl",
"pc:h-[600px] pc:max-w-[1180px]",
)}
>
<div className={cn("flex-1 flex flex-col overflow-hidden", "tablet:flex-row tablet:p-8", "pc:p-10")}>
<div
className={cn(
"relative w-full h-[45vh] min-h-[350px] max-h-[400px] shrink-0 overflow-hidden",
"tablet:order-2 tablet:flex-1 tablet:h-[300px] tablet:min-h-0 tablet:max-h-none tablet:ml-8 tablet:rounded-2xl",
"pc:h-[400px] pc:ml-10",
)}
>
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={currentStep}
custom={direction}
variants={contentVariants}
initial="enter"
animate="center"
exit="exit"
className="absolute inset-0"
>
{currentData.imageSrc ? (
<Image
src={currentData.imageSrc}
alt={currentData.title}
fill
className={cn("object-contain", "bg-background-secondary tablet:rounded-2xl")}
priority
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-background-secondary text-text-disabled">
이미지 준비중
</div>
)}
</motion.div>
</AnimatePresence>
</div>
<div
className={cn(
"flex flex-col justify-center px-6 py-4 my-auto overflow-hidden",
"tablet:order-1 tablet:flex-1 tablet:px-0 tablet:py-4 tablet:justify-center",
"pc:py-6",
)}
>
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={currentStep}
custom={direction}
variants={contentVariants}
initial="enter"
animate="center"
exit="exit"
>
<span
className={cn(
"block mobile:text-md-semibold mobile:hidden text-center text-brand-primary mb-3",
"tablet:text-lg-semibold tablet:mb-4 tablet:text-left",
"pc:text-xl-semibold",
)}
>
Step {currentStep + 1} / {totalSteps}
</span>
<h2
className={cn(
"mobile:text-2xl-bold text-text-primary text-center whitespace-pre-line",
"tablet:text-2xl-bold tablet:whitespace-pre-line tablet:text-left",
"pc:text-3xl-bold",
)}
>
{currentData.title}
</h2>
<p
className={cn(
"mobile:text-md-regular text-text-secondary mt-4 text-center whitespace-normal-line",
"tablet:text-lg-regular tablet:mt-6 tablet:whitespace-pre-line tablet:text-left",
"pc:text-xl-regular pc:mt-8",
)}
>
{currentData.description}
</p>
</motion.div>
</AnimatePresence>
</div>
</div>
<div
className={cn(
"shrink-0 flex items-center justify-between px-6 py-4 pb-safe",
"tablet:px-8 tablet:py-6",
"pc:px-10 pc:py-8",
)}
>
<button
type="button"
onClick={handleComplete}
className={cn(
"text-md-medium text-text-secondary hover:text-text-primary transition-colors",
"min-w-[60px]",
"pc:text-lg-medium",
)}
>
건너뛰기
</button>
<nav aria-label="온보딩 단계" className="flex gap-2 items-center justify-center">
{ONBOARDING_STEPS.map((_, index) => (
<button
key={index}
type="button"
onClick={() => handleStepClick(index)}
aria-label={`${index + 1}단계로 이동`}
aria-current={index === currentStep ? "step" : undefined}
className={cn(
"h-2 rounded-full transition-all duration-300 cursor-pointer",
index === currentStep
? "w-8 bg-brand-primary"
: "w-2 bg-interaction-inactive hover:bg-interaction-hover",
)}
/>
))}
</nav>
<button
type="button"
onClick={handleNext}
className={cn(
"text-md-bold text-brand-primary hover:text-brand-primary/80 transition-colors",
"min-w-[60px] text-right",
"pc:text-lg-bold",
)}
>
{isLastStep ? "시작하기" : "다음"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};

export default OnBoardingModal;
Original file line number Diff line number Diff line change
@@ -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 },
}),
};
Original file line number Diff line number Diff line change
@@ -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",
},
];
6 changes: 6 additions & 0 deletions src/app/(route)/_components/Onboarding/_type/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface OnboardingStep {
id: number;
title: string;
description: string;
imageSrc: string;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { cn } from "@/utils";
import Image from "next/image";

interface ImageSource {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)}
Expand Down
4 changes: 3 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -8,12 +9,13 @@ export default async function Page() {
const startDestination = token ? "/team" : "/login";

return (
<main className="mobile:h-[calc(100vh-52px)] min-w-0 min-h-screen">
<main className="mobile:h-[calc(100vh-52px)] min-w-0 min-h-screen overflow-x-hidden">
<HeroSection link={startDestination} />
<KanbanSection />
<DetailSection />
<CooperationSection />
<ConversionSection link={startDestination} />
<LandingOnboarding />
</main>
);
}
2 changes: 1 addition & 1 deletion src/common/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)}
>
Expand Down
Loading