회원가입
-
+ {progressIcon}
@@ -139,21 +142,16 @@ const Page = () => {
-
@@ -162,4 +160,4 @@ const Page = () => {
);
};
-export default Page;
+export default AccountStep;
diff --git a/src/app/signup/designer/step3/page.tsx b/src/features/signup/ui/DesignerAdditionalStep.tsx
similarity index 86%
rename from src/app/signup/designer/step3/page.tsx
rename to src/features/signup/ui/DesignerAdditionalStep.tsx
index 5005858..b902a60 100644
--- a/src/app/signup/designer/step3/page.tsx
+++ b/src/features/signup/ui/DesignerAdditionalStep.tsx
@@ -1,16 +1,22 @@
"use client";
-import { useRouter } from "next/navigation";
-import { type ChangeEvent, useState } from "react";
+import { type ChangeEvent, type ReactNode, useState } from "react";
-import { type BankCode, BankDropdown, type BankOption } from "@/features/signup";
-import { StepThreeDesignerIcon } from "@/shared/assets/icons";
import { useUploadedFiles } from "@/shared/lib/hooks/useUploadedFiles";
import Button from "@/shared/ui/Button";
import FileDragAndDrop from "@/shared/ui/FileDragAndDrop";
import FileUpload from "@/shared/ui/FileUpload";
import InputField from "@/shared/ui/input/InputField";
+import type { BankCode, BankOption } from "../config/signup";
+import BankDropdown from "./BankDropdown";
+
+type DesignerAdditionalStepProps = {
+ progressIcon: ReactNode;
+ onPrev: () => void;
+ onSubmit: () => void;
+};
+
const PORTFOLIO_MAX_FILE_COUNT = 3;
const PORTFOLIO_ALLOWED_EXTENSIONS = [".pdf", ".png"];
@@ -20,8 +26,11 @@ const isPortfolioFile = (file: File) => {
return PORTFOLIO_ALLOWED_EXTENSIONS.some(extension => fileName.endsWith(extension));
};
-const Page = () => {
- const router = useRouter();
+const DesignerAdditionalStep = ({
+ progressIcon,
+ onPrev,
+ onSubmit,
+}: DesignerAdditionalStepProps) => {
const [selectedBank, setSelectedBank] = useState
(null);
const [accountNumber, setAccountNumber] = useState("");
const [accountHolder, setAccountHolder] = useState("");
@@ -53,7 +62,7 @@ const Page = () => {
회원가입
-
+ {progressIcon}
@@ -104,12 +113,7 @@ const Page = () => {
-
router.push("/signup/designer/step2")}
- >
+
이전
{
type="button"
variant={isSubmitEnabled ? "medium_primary" : "medium_disabled"}
onClick={() => {
- if (isSubmitEnabled) router.push("/login");
+ if (isSubmitEnabled) onSubmit();
}}
>
가입하기
@@ -129,4 +133,4 @@ const Page = () => {
);
};
-export default Page;
+export default DesignerAdditionalStep;
diff --git a/src/features/signup/ui/SignupProgressIcon.tsx b/src/features/signup/ui/SignupProgressIcon.tsx
new file mode 100644
index 0000000..9245c7e
--- /dev/null
+++ b/src/features/signup/ui/SignupProgressIcon.tsx
@@ -0,0 +1,63 @@
+import { cn } from "@/shared/lib/utils/cn";
+
+type SignupProgressStep = 1 | 2 | 3;
+type SignupProgressTotalSteps = 2 | 3;
+
+type SignupProgressIconProps = {
+ currentStep: SignupProgressStep;
+ totalSteps: SignupProgressTotalSteps;
+ className?: string;
+};
+
+const getStepCircleClassName = (step: number, currentStep: SignupProgressStep) => {
+ if (step < currentStep) {
+ return "border border-main-main bg-purple-10 text-main-main";
+ }
+
+ if (step === currentStep) {
+ return "border border-transparent bg-main-main text-white";
+ }
+
+ return "border border-transparent bg-gray-30 text-gray-50";
+};
+
+const SignupProgressIcon = ({ currentStep, totalSteps, className }: SignupProgressIconProps) => {
+ const visibleSteps = Array.from({ length: totalSteps }, (_, index) => index + 1);
+
+ return (
+
+ {visibleSteps.map((step, index) => {
+ const isConnectorActive = step < currentStep;
+
+ return (
+
+
+ {step}
+
+ {index < visibleSteps.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export default SignupProgressIcon;
diff --git a/src/app/signup/designer/step1/page.tsx b/src/features/signup/ui/TermsProfileStep.tsx
similarity index 79%
rename from src/app/signup/designer/step1/page.tsx
rename to src/features/signup/ui/TermsProfileStep.tsx
index 2d43133..b05ee7f 100644
--- a/src/app/signup/designer/step1/page.tsx
+++ b/src/features/signup/ui/TermsProfileStep.tsx
@@ -1,23 +1,26 @@
"use client";
-import { useRouter } from "next/navigation";
-import { type ChangeEvent, useState } from "react";
-
-import {
- DESIGNER_TERMS,
- SIGNUP_MAX_NAME_LENGTH,
- SIGNUP_MAX_PHONE_NUMBER_LENGTH,
-} from "@/features/signup";
-import {
- CheckboxFillIcon,
- CheckboxGrayIcon,
- CloseIcon,
- StepOneDesignerIcon,
-} from "@/shared/assets/icons";
+import { type ChangeEvent, type ReactNode, useState } from "react";
+
+import { CheckboxFillIcon, CheckboxGrayIcon, CloseIcon } from "@/shared/assets/icons";
import Button from "@/shared/ui/Button";
import InputField from "@/shared/ui/input/InputField";
-type DesignerTermsId = (typeof DESIGNER_TERMS)[number]["id"];
+import { SIGNUP_MAX_NAME_LENGTH, SIGNUP_MAX_PHONE_NUMBER_LENGTH } from "../config/signup";
+
+type SignupTerm = {
+ id: string;
+ label: string;
+ modalTitle: string;
+ content: string;
+};
+
+type TermsProfileStepProps = {
+ terms: readonly SignupTerm[];
+ progressIcon: ReactNode;
+ onPrev: () => void;
+ onNext: () => void;
+};
const CheckIcon = ({ isChecked }: { isChecked: boolean }) => {
const Icon = isChecked ? CheckboxFillIcon : CheckboxGrayIcon;
@@ -34,32 +37,28 @@ const formatPhoneNumber = (value: string) => {
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}`;
};
-const createCheckedTerms = (value: boolean) =>
- Object.fromEntries(DESIGNER_TERMS.map(({ id }) => [id, value])) as Record<
- DesignerTermsId,
- boolean
- >;
+const createCheckedTerms = (terms: readonly SignupTerm[], value: boolean) =>
+ Object.fromEntries(terms.map(({ id }) => [id, value])) as Record;
-const Page = () => {
- const router = useRouter();
+const TermsProfileStep = ({ terms, progressIcon, onPrev, onNext }: TermsProfileStepProps) => {
const [name, setName] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
- const [selectedTermId, setSelectedTermId] = useState(null);
- const [checkedTerms, setCheckedTerms] = useState>(() =>
- createCheckedTerms(false),
+ const [selectedTermId, setSelectedTermId] = useState(null);
+ const [checkedTerms, setCheckedTerms] = useState>(() =>
+ createCheckedTerms(terms, false),
);
- const selectedTerm = DESIGNER_TERMS.find(({ id }) => id === selectedTermId);
- const isAllAgreed = DESIGNER_TERMS.every(({ id }) => checkedTerms[id]);
+ const selectedTerm = terms.find(({ id }) => id === selectedTermId);
+ const isAllAgreed = terms.every(({ id }) => checkedTerms[id]);
const isNextEnabled = isAllAgreed && name.trim().length > 0 && phoneNumber.trim().length > 0;
const toggleAllTerms = () => {
const nextValue = !isAllAgreed;
- setCheckedTerms(createCheckedTerms(nextValue));
+ setCheckedTerms(createCheckedTerms(terms, nextValue));
};
- const toggleTerm = (termId: DesignerTermsId) => {
+ const toggleTerm = (termId: string) => {
setCheckedTerms(prev => ({ ...prev, [termId]: !prev[termId] }));
};
@@ -78,7 +77,7 @@ const Page = () => {
회원가입
-
+ {progressIcon}
@@ -98,7 +97,7 @@ const Page = () => {
- {DESIGNER_TERMS.map(({ id, label }) => (
+ {terms.map(({ id, label }) => (
{
-
router.push("/signup")}
- >
+
이전
{
variant={isNextEnabled ? "medium_primary" : "medium_disabled"}
type="button"
onClick={() => {
- if (isNextEnabled) router.push("/signup/designer/step2");
+ if (isNextEnabled) onNext();
}}
>
다음
@@ -196,4 +190,4 @@ const Page = () => {
);
};
-export default Page;
+export default TermsProfileStep;
diff --git a/src/features/signup/ui/UserTypeStep.tsx b/src/features/signup/ui/UserTypeStep.tsx
new file mode 100644
index 0000000..634a51b
--- /dev/null
+++ b/src/features/signup/ui/UserTypeStep.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useState } from "react";
+
+import { UserTypeDesignerIcon, UserTypeInstructorIcon } from "@/shared/assets/icons";
+import Button from "@/shared/ui/Button";
+
+import type { SignupRole } from "../model/signup";
+import UserTypeBtn from "./UserTypeBtn";
+
+type UserTypeStepProps = {
+ onNext: (selectedType: SignupRole) => void;
+};
+
+const UserTypeStep = ({ onNext }: UserTypeStepProps) => {
+ const [selectedType, setSelectedType] = useState(null);
+
+ const handleNextClick = () => {
+ if (selectedType == null) return;
+
+ onNext(selectedType);
+ };
+
+ return (
+
+
+
+
+
+
회원가입을 진행하기 전!
+
가입하려는 회원 유형을 선택해주세요
+
+
+
+ }
+ type="디자이너"
+ description="외주를 맡고 싶어요!"
+ isSelected={selectedType === "designer"}
+ onClick={() => setSelectedType("designer")}
+ />
+ }
+ type="강사/교사"
+ description="외주를 맡기고 싶어요!"
+ isSelected={selectedType === "instructor"}
+ onClick={() => setSelectedType("instructor")}
+ />
+
+
+
+
+ 다음
+
+
+
+
+ );
+};
+
+export default UserTypeStep;
diff --git a/src/proxy.ts b/src/proxy.ts
new file mode 100644
index 0000000..fb6a949
--- /dev/null
+++ b/src/proxy.ts
@@ -0,0 +1,92 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
+type UserRole = "designer" | "instructor";
+
+const ACCESS_TOKEN_COOKIE_NAME = "accessToken";
+const USER_ROLE_COOKIE_NAME = "userRole";
+
+const ROLE_HOME_PATH: Record = {
+ designer: "/designer",
+ instructor: "/instructor",
+};
+
+const normalizeRole = (role?: string): UserRole | null => {
+ const normalizedRole = role?.toLowerCase();
+
+ if (normalizedRole === "designer" || normalizedRole === "instructor") {
+ return normalizedRole;
+ }
+
+ return null;
+};
+
+const parseJwtPayload = (token: string): Record | null => {
+ const [, payload] = token.split(".");
+
+ if (payload == null) return null;
+
+ try {
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
+ const paddedBase64 = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
+ const decodedPayload = atob(paddedBase64);
+
+ return JSON.parse(decodedPayload) as Record;
+ } catch {
+ return null;
+ }
+};
+
+const getUserRole = (request: NextRequest, accessToken: string): UserRole | null => {
+ const roleCookie = request.cookies.get(USER_ROLE_COOKIE_NAME)?.value;
+ const roleFromCookie = normalizeRole(roleCookie);
+
+ if (roleFromCookie != null) return roleFromCookie;
+
+ const tokenPayload = parseJwtPayload(accessToken);
+ const roleFromToken = typeof tokenPayload?.role === "string" ? tokenPayload.role : undefined;
+
+ return normalizeRole(roleFromToken);
+};
+
+const getRequiredRole = (pathname: string): UserRole | null => {
+ if (pathname.startsWith("/designer")) return "designer";
+ if (pathname.startsWith("/instructor")) return "instructor";
+
+ return null;
+};
+
+const redirect = (path: string, request: NextRequest) => {
+ return NextResponse.redirect(new URL(path, request.url));
+};
+
+export function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+ const requiredRole = getRequiredRole(pathname);
+
+ if (requiredRole == null) {
+ return NextResponse.next();
+ }
+
+ const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE_NAME)?.value;
+
+ if (accessToken == null) {
+ return redirect("/login", request);
+ }
+
+ const userRole = getUserRole(request, accessToken);
+
+ if (userRole == null) {
+ return redirect("/login", request);
+ }
+
+ if (userRole !== requiredRole) {
+ return redirect(ROLE_HOME_PATH[userRole], request);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/designer/:path*", "/instructor/:path*"],
+};
diff --git a/src/shared/assets/icons/icon_step_one_designer.svg b/src/shared/assets/icons/icon_step_one_designer.svg
deleted file mode 100644
index 3a26259..0000000
--- a/src/shared/assets/icons/icon_step_one_designer.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/src/shared/assets/icons/icon_step_one_instructor.svg b/src/shared/assets/icons/icon_step_one_instructor.svg
deleted file mode 100644
index e7b4587..0000000
--- a/src/shared/assets/icons/icon_step_one_instructor.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/src/shared/assets/icons/icon_step_three_designer.svg b/src/shared/assets/icons/icon_step_three_designer.svg
deleted file mode 100644
index 3424247..0000000
--- a/src/shared/assets/icons/icon_step_three_designer.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/src/shared/assets/icons/icon_step_two_designer.svg b/src/shared/assets/icons/icon_step_two_designer.svg
deleted file mode 100644
index 3cd9392..0000000
--- a/src/shared/assets/icons/icon_step_two_designer.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-
diff --git a/src/shared/assets/icons/icon_step_two_instructor.svg b/src/shared/assets/icons/icon_step_two_instructor.svg
deleted file mode 100644
index 02e2f57..0000000
--- a/src/shared/assets/icons/icon_step_two_instructor.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
diff --git a/src/shared/assets/icons/index.ts b/src/shared/assets/icons/index.ts
index 20791a9..2ad3947 100644
--- a/src/shared/assets/icons/index.ts
+++ b/src/shared/assets/icons/index.ts
@@ -33,10 +33,5 @@ export { default as ProfileCircleBoldIcon } from "@/shared/assets/icons/icon_pro
export { default as SearchIcon } from "@/shared/assets/icons/icon_search.svg";
export { default as SearchBoldIcon } from "@/shared/assets/icons/icon_search_bold.svg";
export { default as ShareIcon } from "@/shared/assets/icons/icon_share.svg";
-export { default as StepOneDesignerIcon } from "@/shared/assets/icons/icon_step_one_designer.svg";
-export { default as StepOneInstructorIcon } from "@/shared/assets/icons/icon_step_one_instructor.svg";
-export { default as StepThreeDesignerIcon } from "@/shared/assets/icons/icon_step_three_designer.svg";
-export { default as StepTwoDesignerIcon } from "@/shared/assets/icons/icon_step_two_designer.svg";
-export { default as StepTwoInstructorIcon } from "@/shared/assets/icons/icon_step_two_instructor.svg";
export { default as UserTypeDesignerIcon } from "@/shared/assets/icons/icon_user_type_designer.svg";
export { default as UserTypeInstructorIcon } from "@/shared/assets/icons/icon_user_type_instructor.svg";
diff --git a/src/shared/ui/Header.tsx b/src/shared/ui/Header.tsx
index b0c2c5e..dbee2c1 100644
--- a/src/shared/ui/Header.tsx
+++ b/src/shared/ui/Header.tsx
@@ -1,13 +1,74 @@
"use client";
import Link from "next/link";
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { EnterIcon, ProfileCircleIcon } from "@/shared/assets/icons";
import { PurpleLogo } from "@/shared/assets/logos";
+type UserRole = "designer" | "instructor";
+
+interface AuthState {
+ isLoggedIn: boolean;
+ role: UserRole | null;
+}
+
+const ACCESS_TOKEN_COOKIE_NAME = "accessToken";
+const USER_ROLE_COOKIE_NAME = "userRole";
+
+const ROLE_ACCOUNT_PATH: Record = {
+ designer: "/designer",
+ instructor: "/instructor/my",
+};
+
+const getCookieValue = (name: string) => {
+ if (typeof document === "undefined") return undefined;
+
+ const cookie = document.cookie.split("; ").find(cookie => cookie.startsWith(`${name}=`));
+
+ if (cookie == null) return undefined;
+
+ return decodeURIComponent(cookie.slice(name.length + 1));
+};
+
+const normalizeRole = (role?: string): UserRole | null => {
+ const normalizedRole = role?.toLowerCase();
+
+ if (normalizedRole === "designer" || normalizedRole === "instructor") {
+ return normalizedRole;
+ }
+
+ return null;
+};
+
const Header = () => {
- const [isLoggedIn, setIsLoggedIn] = useState(true);
+ const [authState, setAuthState] = useState({
+ isLoggedIn: false,
+ role: null,
+ });
+
+ useEffect(() => {
+ const syncAuthState = () => {
+ const accessToken = getCookieValue(ACCESS_TOKEN_COOKIE_NAME);
+ const role = normalizeRole(getCookieValue(USER_ROLE_COOKIE_NAME));
+
+ setAuthState({
+ isLoggedIn: accessToken != null && role != null,
+ role,
+ });
+ };
+
+ syncAuthState();
+ window.addEventListener("focus", syncAuthState);
+
+ return () => window.removeEventListener("focus", syncAuthState);
+ }, []);
+
+ const accountHref = useMemo(() => {
+ if (authState.role == null) return "/login";
+
+ return ROLE_ACCOUNT_PATH[authState.role];
+ }, [authState.role]);
return (
@@ -17,15 +78,11 @@ const Header = () => {
1:1 문의하기
FAQ
- {isLoggedIn ? (
-
setIsLoggedIn(false)}
- >
+ {authState.isLoggedIn ? (
+
내 계정
-
+
) : (
diff --git a/src/widgets/signup/index.ts b/src/widgets/signup/index.ts
new file mode 100644
index 0000000..c1997de
--- /dev/null
+++ b/src/widgets/signup/index.ts
@@ -0,0 +1 @@
+export { default as SignupFunnel } from "./ui/SignupFunnel";
diff --git a/src/widgets/signup/ui/SignupFunnel.tsx b/src/widgets/signup/ui/SignupFunnel.tsx
new file mode 100644
index 0000000..f7136e0
--- /dev/null
+++ b/src/widgets/signup/ui/SignupFunnel.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+import {
+ AccountStep,
+ DESIGNER_TERMS,
+ DesignerAdditionalStep,
+ INSTRUCTOR_TERMS,
+ SIGNUP_INITIAL_STEP,
+ SIGNUP_STEPS_BY_ROLE,
+ type SignupFunnelStep,
+ SignupProgressIcon,
+ type SignupRole,
+ TermsProfileStep,
+ UserTypeStep,
+} from "@/features/signup";
+
+const SignupFunnel = () => {
+ const router = useRouter();
+ const [selectedRole, setSelectedRole] = useState
(null);
+ const [currentStep, setCurrentStep] = useState(SIGNUP_INITIAL_STEP);
+
+ const handleRoleNext = (role: SignupRole) => {
+ setSelectedRole(role);
+ setCurrentStep(SIGNUP_STEPS_BY_ROLE[role][0]);
+ };
+
+ const movePrev = () => {
+ if (selectedRole == null || currentStep === SIGNUP_INITIAL_STEP) return;
+
+ const roleSteps = SIGNUP_STEPS_BY_ROLE[selectedRole];
+ const currentStepIndex = roleSteps.findIndex(step => step === currentStep);
+
+ if (currentStepIndex <= 0) {
+ setCurrentStep(SIGNUP_INITIAL_STEP);
+ return;
+ }
+
+ setCurrentStep(roleSteps[currentStepIndex - 1]);
+ };
+
+ const moveNext = () => {
+ if (selectedRole == null || currentStep === SIGNUP_INITIAL_STEP) return;
+
+ const roleSteps = SIGNUP_STEPS_BY_ROLE[selectedRole];
+ const currentStepIndex = roleSteps.findIndex(step => step === currentStep);
+ const nextStep = roleSteps[currentStepIndex + 1];
+
+ if (nextStep == null) {
+ router.push("/login");
+ return;
+ }
+
+ setCurrentStep(nextStep);
+ };
+
+ if (selectedRole == null || currentStep === "role") {
+ return ;
+ }
+
+ if (currentStep === "termsProfile") {
+ if (selectedRole === "designer") {
+ return (
+ }
+ onPrev={movePrev}
+ onNext={moveNext}
+ />
+ );
+ }
+
+ return (
+ }
+ onPrev={movePrev}
+ onNext={moveNext}
+ />
+ );
+ }
+
+ if (currentStep === "account") {
+ if (selectedRole === "designer") {
+ return (
+ }
+ nextButtonText="다음"
+ onPrev={movePrev}
+ onNext={moveNext}
+ />
+ );
+ }
+
+ return (
+ }
+ nextButtonText="가입하기"
+ onPrev={movePrev}
+ onNext={moveNext}
+ />
+ );
+ }
+
+ return (
+ }
+ onPrev={movePrev}
+ onSubmit={moveNext}
+ />
+ );
+};
+
+export default SignupFunnel;