diff --git a/src/app/signup/designer/step2/page.tsx b/src/app/signup/designer/step2/page.tsx deleted file mode 100644 index 5842e6a..0000000 --- a/src/app/signup/designer/step2/page.tsx +++ /dev/null @@ -1,165 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; - -import { - SIGNUP_MAX_ID_LENGTH, - SIGNUP_MAX_PASSWORD_LENGTH, - useSignupStep2Form, -} from "@/features/signup"; -import { StepTwoDesignerIcon } from "@/shared/assets/icons"; -import Button from "@/shared/ui/Button"; -import InputField from "@/shared/ui/input/InputField"; - -const Page = () => { - const router = useRouter(); - const form = useSignupStep2Form(); - - return ( -
-
-
-
-
-

회원가입

- -
- -
-
-

아이디

-
-
- - -
- {form.userIdMessage != null && ( -

- {form.userIdMessage} -

- )} -
-
- - 0 && form.isPasswordValid} - label="비밀번호" - maxLength={SIGNUP_MAX_PASSWORD_LENGTH} - placeholder="비밀번호를 입력해주세요" - showPasswordToggle - type="password" - value={form.password} - onChange={form.handlePasswordChange} - /> - - -
-

이메일

-
- - -
- {form.isVerificationCodeVisible && ( - - {form.verificationTimerText} - - ) : undefined - } - value={form.verificationCode} - onChange={form.handleVerificationCodeChange} - /> - )} -
-
-
- -
- - -
-
-
-
- ); -}; - -export default Page; diff --git a/src/app/signup/instructor/step1/page.tsx b/src/app/signup/instructor/step1/page.tsx deleted file mode 100644 index 9985ff3..0000000 --- a/src/app/signup/instructor/step1/page.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { type ChangeEvent, useState } from "react"; - -import { - INSTRUCTOR_TERMS, - SIGNUP_MAX_NAME_LENGTH, - SIGNUP_MAX_PHONE_NUMBER_LENGTH, -} from "@/features/signup"; -import { - CheckboxFillIcon, - CheckboxGrayIcon, - CloseIcon, - StepOneInstructorIcon, -} from "@/shared/assets/icons"; -import Button from "@/shared/ui/Button"; -import InputField from "@/shared/ui/input/InputField"; - -type InstructorTermsId = (typeof INSTRUCTOR_TERMS)[number]["id"]; - -const CheckIcon = ({ isChecked }: { isChecked: boolean }) => { - const Icon = isChecked ? CheckboxFillIcon : CheckboxGrayIcon; - - return ; -}; - -const formatPhoneNumber = (value: string) => { - const numbers = value.replace(/\D/g, "").slice(0, SIGNUP_MAX_PHONE_NUMBER_LENGTH); - - if (numbers.length <= 3) return numbers; - if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`; - - return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}`; -}; - -const createCheckedTerms = (value: boolean) => - Object.fromEntries(INSTRUCTOR_TERMS.map(({ id }) => [id, value])) as Record< - InstructorTermsId, - boolean - >; - -const Page = () => { - const router = useRouter(); - const [name, setName] = useState(""); - const [phoneNumber, setPhoneNumber] = useState(""); - const [selectedTermId, setSelectedTermId] = useState(null); - const [checkedTerms, setCheckedTerms] = useState>(() => - createCheckedTerms(false), - ); - - const selectedTerm = INSTRUCTOR_TERMS.find(({ id }) => id === selectedTermId); - const isAllAgreed = INSTRUCTOR_TERMS.every(({ id }) => checkedTerms[id]); - const isNextEnabled = isAllAgreed && name.trim().length > 0 && phoneNumber.trim().length > 0; - - const toggleAllTerms = () => { - const nextValue = !isAllAgreed; - - setCheckedTerms(createCheckedTerms(nextValue)); - }; - - const toggleTerm = (termId: InstructorTermsId) => { - setCheckedTerms(prev => ({ ...prev, [termId]: !prev[termId] })); - }; - - const handleNameChange = (event: ChangeEvent) => { - setName(event.target.value.slice(0, SIGNUP_MAX_NAME_LENGTH)); - }; - - const handlePhoneNumberChange = (event: ChangeEvent) => { - setPhoneNumber(formatPhoneNumber(event.target.value)); - }; - - return ( -
-
-
-
-
-

회원가입

- -
- -
-
-

약관 동의

- -
- - -
- -
- {INSTRUCTOR_TERMS.map(({ id, label }) => ( -
- - - -
- ))} -
-
-
- - setName("")} - onChange={handleNameChange} - /> - setPhoneNumber("")} - onChange={handlePhoneNumberChange} - /> -
-
- -
- - -
-
-
- - {selectedTerm != null && ( -
setSelectedTermId(null)} - > -
event.stopPropagation()} - > -
-

{selectedTerm.modalTitle}

- -
-
-

- {selectedTerm.content} -

-
-
- )} -
- ); -}; - -export default Page; diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index ea40e64..f9b2450 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,64 +1,7 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -import { UserTypeBtn } from "@/features/signup"; -import { UserTypeDesignerIcon, UserTypeInstructorIcon } from "@/shared/assets/icons"; -import Button from "@/shared/ui/Button"; - -type UserType = "designer" | "instructor"; +import { SignupFunnel } from "@/widgets/signup"; const Page = () => { - const router = useRouter(); - const [selectedType, setSelectedType] = useState(null); - - const handleNextClick = () => { - if (selectedType == null) return; - - router.push(`/signup/${selectedType}/step1`); - }; - - return ( -
-
-
-
-
-

회원가입을 진행하기 전!

-

가입하려는 회원 유형을 선택해주세요

-
- -
- } - type="디자이너" - description="외주를 맡고 싶어요!" - isSelected={selectedType === "designer"} - onClick={() => setSelectedType("designer")} - /> - } - type="강사/교사" - description="외주를 맡기고 싶어요!" - isSelected={selectedType === "instructor"} - onClick={() => setSelectedType("instructor")} - /> -
-
- - -
-
-
- ); + return ; }; export default Page; diff --git a/src/features/signup/config/signupFunnel.ts b/src/features/signup/config/signupFunnel.ts new file mode 100644 index 0000000..358a609 --- /dev/null +++ b/src/features/signup/config/signupFunnel.ts @@ -0,0 +1,8 @@ +import type { SignupFunnelStep, SignupRole, SignupRoleStep } from "../model/signup"; + +export const SIGNUP_INITIAL_STEP = "role" satisfies SignupFunnelStep; + +export const SIGNUP_STEPS_BY_ROLE = { + designer: ["termsProfile", "account", "designerAdditional"], + instructor: ["termsProfile", "account"], +} as const satisfies Record; diff --git a/src/features/signup/index.ts b/src/features/signup/index.ts index 760d709..92a6301 100644 --- a/src/features/signup/index.ts +++ b/src/features/signup/index.ts @@ -1,4 +1,11 @@ export * from "./config/signup"; +export * from "./config/signupFunnel"; +export * from "./model/signup"; export { useSignupStep2Form } from "./model/useSignupStep2Form"; +export { default as AccountStep } from "./ui/AccountStep"; export { default as BankDropdown } from "./ui/BankDropdown"; +export { default as DesignerAdditionalStep } from "./ui/DesignerAdditionalStep"; +export { default as SignupProgressIcon } from "./ui/SignupProgressIcon"; +export { default as TermsProfileStep } from "./ui/TermsProfileStep"; export { default as UserTypeBtn } from "./ui/UserTypeBtn"; +export { default as UserTypeStep } from "./ui/UserTypeStep"; diff --git a/src/features/signup/model/signup.ts b/src/features/signup/model/signup.ts new file mode 100644 index 0000000..297247f --- /dev/null +++ b/src/features/signup/model/signup.ts @@ -0,0 +1,5 @@ +export type SignupRole = "designer" | "instructor"; + +export type SignupFunnelStep = "role" | "termsProfile" | "account" | "designerAdditional"; + +export type SignupRoleStep = Exclude; diff --git a/src/app/signup/instructor/step2/page.tsx b/src/features/signup/ui/AccountStep.tsx similarity index 89% rename from src/app/signup/instructor/step2/page.tsx rename to src/features/signup/ui/AccountStep.tsx index 37b0b56..624d5f9 100644 --- a/src/app/signup/instructor/step2/page.tsx +++ b/src/features/signup/ui/AccountStep.tsx @@ -1,18 +1,21 @@ "use client"; -import { useRouter } from "next/navigation"; +import { type ReactNode } from "react"; -import { - SIGNUP_MAX_ID_LENGTH, - SIGNUP_MAX_PASSWORD_LENGTH, - useSignupStep2Form, -} from "@/features/signup"; -import { StepTwoInstructorIcon } from "@/shared/assets/icons"; import Button from "@/shared/ui/Button"; import InputField from "@/shared/ui/input/InputField"; -const Page = () => { - const router = useRouter(); +import { SIGNUP_MAX_ID_LENGTH, SIGNUP_MAX_PASSWORD_LENGTH } from "../config/signup"; +import { useSignupStep2Form } from "../model/useSignupStep2Form"; + +type AccountStepProps = { + progressIcon: ReactNode; + nextButtonText: string; + onPrev: () => void; + onNext: () => void; +}; + +const AccountStep = ({ progressIcon, nextButtonText, onPrev, onNext }: AccountStepProps) => { const form = useSignupStep2Form(); return ( @@ -22,7 +25,7 @@ const Page = () => {

회원가입

- + {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 = () => {
-
- + +
+
+ ); +}; + +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 ? ( - + ) : (
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;