diff --git a/src/features/signup-user-flow/model/admin.ts b/src/features/signup-user-flow/model/admin.ts new file mode 100644 index 0000000..cec44de --- /dev/null +++ b/src/features/signup-user-flow/model/admin.ts @@ -0,0 +1,101 @@ +import { + ADMIN_COLLEGE_OPTIONS, + ADMIN_DEPARTMENT_OPTIONS, +} from "./data/adminOptions"; +import { PARTNER_ADDRESS_OPTIONS } from "./data/partnerAddressOptions"; +import type { SignupAdminFormState } from "./types"; + +export function findAdminCollegeOption(value: string | null) { + if (!value) { + return null; + } + + return ADMIN_COLLEGE_OPTIONS.find((item) => item.value === value) ?? null; +} + +export function findAdminDepartmentOption(value: string | null) { + if (!value) { + return null; + } + + return ADMIN_DEPARTMENT_OPTIONS.find((item) => item.value === value) ?? null; +} + +export function shouldShowAdminOfficeAddress(admin: SignupAdminFormState) { + return shouldShowAdminOfficeAddressBySelection( + admin.organizationType, + admin.collegeId, + admin.departmentId, + ); +} + +export function shouldShowAdminOfficeAddressBySelection( + organizationType: SignupAdminFormState["organizationType"], + collegeId: SignupAdminFormState["collegeId"], + departmentId: SignupAdminFormState["departmentId"], +) { + if (organizationType === "GENERAL_STUDENT_COUNCIL") { + return true; + } + + if (organizationType === "COLLEGE_STUDENT_COUNCIL") { + return Boolean(collegeId); + } + + if (organizationType === "DEPARTMENT_STUDENT_COUNCIL") { + return Boolean(collegeId && departmentId); + } + + return false; +} + +export function isAdminOrganizationInfoComplete(admin: SignupAdminFormState) { + if (!admin.organizationType || !shouldShowAdminOfficeAddress(admin)) { + return false; + } + + if (admin.organizationType === "COLLEGE_STUDENT_COUNCIL") { + return ( + Boolean(admin.collegeId) && + Boolean(admin.officeAddressId) && + admin.officeAddressDetail.length > 0 + ); + } + + if (admin.organizationType === "DEPARTMENT_STUDENT_COUNCIL") { + return ( + Boolean(admin.collegeId) && + Boolean(admin.departmentId) && + Boolean(admin.officeAddressId) && + admin.officeAddressDetail.length > 0 + ); + } + + return Boolean(admin.officeAddressId) && admin.officeAddressDetail.length > 0; +} + +export function getAdminCompletionName(admin: SignupAdminFormState) { + if (admin.organizationType === "GENERAL_STUDENT_COUNCIL") { + return "숭실대학교 총학생회"; + } + + if (admin.organizationType === "COLLEGE_STUDENT_COUNCIL") { + const college = findAdminCollegeOption(admin.collegeId); + return college?.label ? `${college.label} 학생회` : ""; + } + + if (admin.organizationType === "DEPARTMENT_STUDENT_COUNCIL") { + const department = findAdminDepartmentOption(admin.departmentId); + return department?.label ? `${department.label} 학생회` : ""; + } + + return ""; +} + +export function findAddressOption(value: string | null) { + if (!value) { + return null; + } + + return PARTNER_ADDRESS_OPTIONS.find((item) => item.id === value) ?? null; +} diff --git a/src/features/signup-user-flow/model/agreements.ts b/src/features/signup-user-flow/model/agreements.ts new file mode 100644 index 0000000..efd7146 --- /dev/null +++ b/src/features/signup-user-flow/model/agreements.ts @@ -0,0 +1,12 @@ +import type { SignupAgreementState } from "@/entities/signup"; + +export function getNextAgreementState( + current: SignupAgreementState, + partial: Partial, +): SignupAgreementState { + const next = { ...current, ...partial }; + return { + ...next, + agreeAll: next.agreePrivacy && next.agreeMarketing, + }; +} diff --git a/src/features/signup-user-flow/model/constants.ts b/src/features/signup-user-flow/model/constants.ts index 163beaf..fa4fd01 100644 --- a/src/features/signup-user-flow/model/constants.ts +++ b/src/features/signup-user-flow/model/constants.ts @@ -1,12 +1,6 @@ -import type { SignupStep } from "./types"; - -export const VERIFICATION_SUCCESS_CODE = "0502"; - -export const SIGNUP_PROGRESS_STEPS: SignupStep[] = [ - "identity", - "role", - "school", - "studentInput1", - "studentInput2", - "studentInput3", -]; +export { + getSignupFlowConfig, + getSignupFlowVariant, + SIGNUP_FLOW_CONFIG, + VERIFICATION_SUCCESS_CODE, +} from "./flowConfig"; diff --git a/src/features/signup-user-flow/model/data/adminOptions.ts b/src/features/signup-user-flow/model/data/adminOptions.ts new file mode 100644 index 0000000..2f2df12 --- /dev/null +++ b/src/features/signup-user-flow/model/data/adminOptions.ts @@ -0,0 +1,24 @@ +import type { SelectItem } from "@/shared/ui/select"; + +export const ADMIN_ORGANIZATION_TYPE_OPTIONS: SelectItem[] = [ + { label: "총학생회", value: "GENERAL_STUDENT_COUNCIL" }, + { label: "단과대학 학생회", value: "COLLEGE_STUDENT_COUNCIL" }, + { label: "학과/부 학생회", value: "DEPARTMENT_STUDENT_COUNCIL" }, +]; + +export const ADMIN_COLLEGE_OPTIONS: SelectItem[] = [ + { label: "인문대학", value: "HUMANITIES" }, + { label: "자연과학대학", value: "NATURAL_SCIENCE" }, + { label: "IT대학", value: "IT" }, + { label: "공과대학", value: "ENGINEERING" }, + { label: "사회과학대학", value: "SOCIAL_SCIENCE" }, +]; + +export const ADMIN_DEPARTMENT_OPTIONS: SelectItem[] = [ + { label: "컴퓨터학부", value: "COMPUTER" }, + { label: "소프트웨어학부", value: "SOFTWARE" }, + { label: "글로벌미디어학부", value: "GLOBAL_MEDIA" }, + { label: "전자정보공학부", value: "EE" }, + { label: "AI융합학과", value: "AI" }, +]; + diff --git a/src/features/signup-user-flow/model/data/partnerAddressOptions.ts b/src/features/signup-user-flow/model/data/partnerAddressOptions.ts new file mode 100644 index 0000000..b21b43a --- /dev/null +++ b/src/features/signup-user-flow/model/data/partnerAddressOptions.ts @@ -0,0 +1,11 @@ +import type { AddressSearchItem } from "@/shared/ui/address-search/types"; + +export const PARTNER_ADDRESS_OPTIONS: AddressSearchItem[] = [ + { id: "sangdo-50", label: "서울시 동작구 상도로 50" }, + { id: "sangdo-360", label: "서울시 동작구 상도로 360" }, + { id: "sangdo-72", label: "서울시 동작구 상도로 72" }, + { id: "sangdo-72-1", label: "서울시 동작구 상도로 72-1" }, + { id: "sangdo-72-2", label: "서울시 동작구 상도로 72-2" }, + { id: "sangdo-72-3", label: "서울시 동작구 상도로 72-3" }, +]; + diff --git a/src/features/signup-user-flow/model/flowConfig.ts b/src/features/signup-user-flow/model/flowConfig.ts new file mode 100644 index 0000000..2801f91 --- /dev/null +++ b/src/features/signup-user-flow/model/flowConfig.ts @@ -0,0 +1,148 @@ +import { USER_TYPE } from "@/entities/user/model/types"; +import type { UserType } from "@/entities/user/model/types"; +import type { SignupFlowVariant, SignupStep } from "./types"; + +export const VERIFICATION_SUCCESS_CODE = "0502"; + +type SignupFlowConfig = { + stepOrder: SignupStep[]; + progressSteps: SignupStep[]; + progressByStep: Partial>; + buttonLabelByStep: Partial>; +}; + +const SHARED_BUTTON_LABELS: Partial> = { + identity: "인증완료", + role: "확인", + complete: "가입완료", +}; + +const CONTINUED_PROGRESS = { + identity: 16.67, + role: 50, + postRoleEntry: 66.67, + postRoleDetails: 83.33, + finalize: 100, +} as const; + +export const SIGNUP_FLOW_CONFIG: Record = { + student: { + stepOrder: [ + "login1", + "loginForm", + "identity", + "role", + "school", + "studentInput1", + "studentInput2", + "studentInput3", + "complete", + ], + progressSteps: [ + "identity", + "role", + "school", + "studentInput1", + "studentInput2", + "studentInput3", + ], + progressByStep: { + identity: CONTINUED_PROGRESS.identity, + role: CONTINUED_PROGRESS.role, + school: CONTINUED_PROGRESS.postRoleEntry, + studentInput1: CONTINUED_PROGRESS.postRoleDetails, + studentInput2: CONTINUED_PROGRESS.postRoleDetails, + studentInput3: CONTINUED_PROGRESS.postRoleDetails, + }, + buttonLabelByStep: { + ...SHARED_BUTTON_LABELS, + school: "완료", + studentInput2: "완료", + studentInput3: "완료", + }, + }, + partner: { + stepOrder: [ + "login1", + "loginForm", + "identity", + "role", + "partnerCredentials", + "partnerCompanyInfo", + "partnerBusinessRegistration", + "complete", + ], + progressSteps: [ + "identity", + "role", + "partnerCredentials", + "partnerCompanyInfo", + "partnerBusinessRegistration", + ], + progressByStep: { + identity: CONTINUED_PROGRESS.identity, + role: CONTINUED_PROGRESS.role, + partnerCredentials: CONTINUED_PROGRESS.postRoleEntry, + partnerCompanyInfo: CONTINUED_PROGRESS.postRoleDetails, + partnerBusinessRegistration: CONTINUED_PROGRESS.finalize, + }, + buttonLabelByStep: { + ...SHARED_BUTTON_LABELS, + partnerCredentials: "입력완료", + partnerCompanyInfo: "입력완료", + partnerBusinessRegistration: "완료", + }, + }, + admin: { + stepOrder: [ + "login1", + "loginForm", + "identity", + "role", + "adminCredentials", + "adminOrganizationType", + "adminOrganizationInfo", + "adminSealRegistration", + "complete", + ], + progressSteps: [ + "identity", + "role", + "adminCredentials", + "adminOrganizationType", + "adminOrganizationInfo", + "adminSealRegistration", + ], + progressByStep: { + identity: CONTINUED_PROGRESS.identity, + role: CONTINUED_PROGRESS.role, + adminCredentials: CONTINUED_PROGRESS.postRoleEntry, + adminOrganizationType: CONTINUED_PROGRESS.postRoleDetails, + adminOrganizationInfo: CONTINUED_PROGRESS.postRoleDetails, + adminSealRegistration: CONTINUED_PROGRESS.finalize, + }, + buttonLabelByStep: { + ...SHARED_BUTTON_LABELS, + adminCredentials: "입력완료", + adminOrganizationType: "입력완료", + adminOrganizationInfo: "입력완료", + adminSealRegistration: "완료", + }, + }, +}; + +export function getSignupFlowVariant(role: UserType | null): SignupFlowVariant { + if (role === USER_TYPE.PARTNER) { + return "partner"; + } + + if (role === USER_TYPE.ADMIN) { + return "admin"; + } + + return "student"; +} + +export function getSignupFlowConfig(variant: SignupFlowVariant) { + return SIGNUP_FLOW_CONFIG[variant]; +} diff --git a/src/features/signup-user-flow/model/mock/signupUserFlow.mock.ts b/src/features/signup-user-flow/model/mock/signupUserFlow.mock.ts index 1ed8601..a38930c 100644 --- a/src/features/signup-user-flow/model/mock/signupUserFlow.mock.ts +++ b/src/features/signup-user-flow/model/mock/signupUserFlow.mock.ts @@ -1,18 +1,46 @@ import type { SignupFormState } from "../types"; export const DEFAULT_SIGNUP_FORM_STATE: SignupFormState = { - email: "", - password: "", - phone: "", - verificationCode: "", - isCodeSent: false, - verificationAttempted: false, + auth: { + email: "", + password: "", + }, + identity: { + phone: "", + verificationCode: "", + isCodeSent: false, + verificationAttempted: false, + }, role: null, - school: "숭실대학교", - major: "글로벌미디어학부", - studentId: "20231649", - agreeAll: false, - agreePrivacy: false, - agreeMarketing: false, - name: "김숭실", + student: { + school: "숭실대학교", + major: "글로벌미디어학부", + studentId: "20231649", + }, + partner: { + email: "", + password: "", + companyName: "", + officeAddressId: null, + officeAddressDetail: "", + businessRegistrationFileName: "", + }, + admin: { + email: "", + password: "", + organizationType: null, + collegeId: null, + departmentId: null, + officeAddressId: null, + officeAddressDetail: "", + sealFileName: "", + }, + agreements: { + agreeAll: false, + agreePrivacy: false, + agreeMarketing: false, + }, + profile: { + name: "김숭실", + }, }; diff --git a/src/features/signup-user-flow/model/signupStepSchemas.ts b/src/features/signup-user-flow/model/signupStepSchemas.ts new file mode 100644 index 0000000..532a78c --- /dev/null +++ b/src/features/signup-user-flow/model/signupStepSchemas.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +export const identityStepSchema = z.object({ + phone: z.string().min(1), + verificationCode: z.string().min(1), +}); + +export const schoolStepSchema = z.object({ + school: z.string().min(1).nullable().refine((value) => value !== null), +}); + +export const credentialsStepSchema = z.object({ + email: z.string().min(1), + password: z.string().min(1), +}); + +export const partnerCompanyInfoStepSchema = z.object({ + companyName: z.string().min(1), + officeAddressId: z.string().min(1), + officeAddressDetail: z.string().min(1), +}); + +export const adminOrganizationTypeStepSchema = z.object({ + organizationType: z + .string() + .min(1) + .nullable() + .refine((value) => value !== null), +}); + +export const adminOrganizationInfoStepSchema = z + .object({ + organizationType: z.string().min(1).nullable(), + collegeId: z.string().nullable(), + departmentId: z.string().nullable(), + officeAddressId: z.string().min(1), + officeAddressDetail: z.string().min(1), + }) + .superRefine((values, ctx) => { + if (!values.organizationType) { + return; + } + + if ( + values.organizationType === "COLLEGE_STUDENT_COUNCIL" && + !values.collegeId + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["collegeId"], + message: "단과대를 선택해주세요", + }); + } + + if ( + values.organizationType === "DEPARTMENT_STUDENT_COUNCIL" && + (!values.collegeId || !values.departmentId) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["departmentId"], + message: "학과/부를 선택해주세요", + }); + } + }); + +export const agreementStepSchema = z.object({ + agreeAll: z.boolean(), + agreePrivacy: z.boolean(), + agreeMarketing: z.boolean(), +}); + +export const fileAgreementStepSchema = agreementStepSchema.extend({ + fileName: z.string().min(1), +}); + +export type IdentityStepValues = z.infer; +export type SchoolStepValues = z.infer; +export type CredentialsStepValues = z.infer; +export type PartnerCompanyInfoStepValues = z.infer< + typeof partnerCompanyInfoStepSchema +>; +export type AdminOrganizationTypeStepValues = z.infer< + typeof adminOrganizationTypeStepSchema +>; +export type AdminOrganizationInfoStepValues = z.infer< + typeof adminOrganizationInfoStepSchema +>; +export type AgreementStepValues = z.infer; +export type FileAgreementStepValues = z.infer; diff --git a/src/features/signup-user-flow/model/types.ts b/src/features/signup-user-flow/model/types.ts index b52da60..4c46768 100644 --- a/src/features/signup-user-flow/model/types.ts +++ b/src/features/signup-user-flow/model/types.ts @@ -1,28 +1,80 @@ +import type { SignupAgreementState, SignupSchool } from "@/entities/signup"; +import type { UserType } from "@/entities/user/model/types"; + export type SignupStep = | "login1" | "loginForm" | "identity" | "role" + | "adminCredentials" + | "adminOrganizationType" + | "adminOrganizationInfo" + | "adminSealRegistration" | "school" | "studentInput1" | "studentInput2" | "studentInput3" + | "partnerCredentials" + | "partnerCompanyInfo" + | "partnerBusinessRegistration" | "complete"; -import type { SignupAgreementState, SignupSchool } from "@/entities/signup"; -import type { UserType } from "@/entities/user/model/types"; +export type SignupFlowVariant = "student" | "partner" | "admin"; -export type SignupFormState = { +export type SignupAdminOrganizationType = + | "GENERAL_STUDENT_COUNCIL" + | "COLLEGE_STUDENT_COUNCIL" + | "DEPARTMENT_STUDENT_COUNCIL"; + +export type SignupAuthFormState = { email: string; password: string; +}; + +export type SignupIdentityFormState = { phone: string; verificationCode: string; isCodeSent: boolean; verificationAttempted: boolean; - role: UserType | null; +}; + +export type SignupStudentFormState = { school: SignupSchool | null; major: string; studentId: string; -} & SignupAgreementState & { - name: string; - }; +}; + +export type SignupPartnerFormState = { + email: string; + password: string; + companyName: string; + officeAddressId: string | null; + officeAddressDetail: string; + businessRegistrationFileName: string; +}; + +export type SignupAdminFormState = { + email: string; + password: string; + organizationType: SignupAdminOrganizationType | null; + collegeId: string | null; + departmentId: string | null; + officeAddressId: string | null; + officeAddressDetail: string; + sealFileName: string; +}; + +export type SignupProfileState = { + name: string; +}; + +export type SignupFormState = { + auth: SignupAuthFormState; + identity: SignupIdentityFormState; + role: UserType | null; + student: SignupStudentFormState; + partner: SignupPartnerFormState; + admin: SignupAdminFormState; + agreements: SignupAgreementState; + profile: SignupProfileState; +}; diff --git a/src/features/signup-user-flow/model/useSignupFlowController.ts b/src/features/signup-user-flow/model/useSignupFlowController.ts new file mode 100644 index 0000000..1a78b65 --- /dev/null +++ b/src/features/signup-user-flow/model/useSignupFlowController.ts @@ -0,0 +1,126 @@ +import { useSignupFlowPresentation } from "./useSignupFlowPresentation"; +import { useSignupFlowViewModel } from "./useSignupFlowViewModel"; +import { useSignupLoginViewModel } from "./useSignupLoginViewModel"; +import { useSignupOverlays } from "./useSignupOverlays"; +import { useSignupStepActions } from "./useSignupStepActions"; + +export function useSignupFlowController() { + const { + step, + form, + formMethods, + progress, + countdown, + goTo, + goNext, + setAuthEmail, + setAuthPassword, + setPhone, + setVerificationCode, + sendVerificationCode, + setRole, + setSchool, + setPartnerEmail, + setPartnerPassword, + setAdminEmail, + setAdminPassword, + setAdminOrganizationType, + setAdminCollege, + setAdminDepartment, + setAdminOfficeAddress, + setAdminOfficeAddressDetail, + selectAdminSealMock, + setPartnerCompanyName, + setPartnerOfficeAddress, + setPartnerOfficeAddressDetail, + selectPartnerBusinessRegistrationMock, + progressSteps, + currentProgressIndex, + showProgress, + showBottomButton, + isBottomDisabled, + isVerificationError, + buttonLabel, + completeDisplayName, + agreementHandlers, + } = useSignupFlowPresentation(); + + const overlays = useSignupOverlays({ + adminOfficeAddressId: form.admin.officeAddressId, + partnerOfficeAddressId: form.partner.officeAddressId, + onSendVerificationCode: sendVerificationCode, + onSelectAdminOfficeAddress: setAdminOfficeAddress, + onSelectPartnerOfficeAddress: setPartnerOfficeAddress, + }); + + const stepContentActions = useSignupStepActions({ + step, + goTo, + overlays: { + openResendSheet: overlays.resendSheet.open, + openPartnerAddressSearch: overlays.addressSearch.openForPartner, + openAdminAddressSearch: overlays.addressSearch.openForAdmin, + }, + identity: { + setPhone, + setVerificationCode, + sendVerificationCode, + }, + student: { + setRole, + setSchool, + }, + partner: { + setPartnerEmail, + setPartnerPassword, + setPartnerCompanyName, + setPartnerOfficeAddressDetail, + selectPartnerBusinessRegistrationMock, + }, + admin: { + setAdminEmail, + setAdminPassword, + setAdminOrganizationType, + setAdminCollege, + setAdminDepartment, + setAdminOfficeAddressDetail, + selectAdminSealMock, + }, + agreementHandlers, + }); + + const flow = useSignupFlowViewModel({ + step, + progress, + progressSteps, + currentProgressIndex, + showProgress, + showBottomButton, + isBottomDisabled, + buttonLabel, + goTo, + goNext, + }); + + const login = useSignupLoginViewModel({ + email: form.auth.email, + password: form.auth.password, + onChangeEmail: setAuthEmail, + onChangePassword: setAuthPassword, + onPressSignup: () => goTo("identity"), + }); + + return { + formMethods, + flow, + login, + stepContent: { + step, + countdown, + completeDisplayName, + isVerificationError, + actions: stepContentActions, + }, + overlays, + }; +} diff --git a/src/features/signup-user-flow/model/useSignupFlowPresentation.ts b/src/features/signup-user-flow/model/useSignupFlowPresentation.ts new file mode 100644 index 0000000..9ccaf1c --- /dev/null +++ b/src/features/signup-user-flow/model/useSignupFlowPresentation.ts @@ -0,0 +1,111 @@ +import { useEffect, useMemo } from "react"; +import { USER_TYPE } from "@/entities/user/model/types"; +import { getAdminCompletionName } from "./admin"; +import { VERIFICATION_SUCCESS_CODE } from "./flowConfig"; +import { useSignupUserFlow } from "./useSignupUserFlow"; + +export function useSignupFlowPresentation() { + const flow = useSignupUserFlow(); + const { + step, + form, + flowConfig, + goTo, + isCurrentStepValid, + setAgreeAll, + setAgreePrivacy, + setAgreeMarketing, + activateStudentInput3, + } = flow; + + useEffect(() => { + if (step !== "login1") { + return; + } + + const timer = setTimeout(() => { + goTo("loginForm"); + }, 2000); + + return () => clearTimeout(timer); + }, [goTo, step]); + + const progressSteps = flowConfig.progressSteps; + const currentProgressIndex = progressSteps.indexOf(step); + const showProgress = step !== "complete"; + const showBottomButton = step !== "studentInput1"; + const isBottomDisabled = + step === "studentInput2" + ? true + : !isCurrentStepValid && step !== "complete"; + const isVerificationError = + form.identity.verificationAttempted && + form.identity.verificationCode !== VERIFICATION_SUCCESS_CODE; + const buttonLabel = flowConfig.buttonLabelByStep[step] ?? "완료"; + const completeDisplayName = useMemo(() => { + if (form.role === USER_TYPE.PARTNER) { + return form.partner.companyName || form.profile.name; + } + + if (form.role === USER_TYPE.ADMIN) { + return getAdminCompletionName(form.admin) || form.profile.name; + } + + return form.profile.name; + }, [form.admin, form.partner.companyName, form.profile.name, form.role]); + + const agreementHandlers = useMemo(() => { + const toggleAgreementAndActivate = ( + current: boolean, + setter: (checked: boolean) => void, + shouldActivate: (next: boolean) => boolean = (next) => next, + ) => { + const next = !current; + setter(next); + if (step === "studentInput2" && shouldActivate(next)) { + activateStudentInput3(); + } + }; + + return { + onToggleAgreeAll: () => { + toggleAgreementAndActivate(form.agreements.agreeAll, setAgreeAll); + }, + onToggleAgreePrivacy: () => { + toggleAgreementAndActivate( + form.agreements.agreePrivacy, + setAgreePrivacy, + ); + }, + onToggleAgreeMarketing: () => { + toggleAgreementAndActivate( + form.agreements.agreeMarketing, + setAgreeMarketing, + (next) => next && form.agreements.agreePrivacy, + ); + }, + }; + }, [ + activateStudentInput3, + form.agreements.agreeAll, + form.agreements.agreeMarketing, + form.agreements.agreePrivacy, + setAgreeAll, + setAgreeMarketing, + setAgreePrivacy, + step, + ]); + + return { + ...flow, + progressSteps, + currentProgressIndex, + showProgress, + showBottomButton, + isBottomDisabled, + isVerificationError, + buttonLabel, + completeDisplayName, + agreementHandlers, + }; +} diff --git a/src/features/signup-user-flow/model/useSignupFlowViewModel.ts b/src/features/signup-user-flow/model/useSignupFlowViewModel.ts new file mode 100644 index 0000000..895c5cd --- /dev/null +++ b/src/features/signup-user-flow/model/useSignupFlowViewModel.ts @@ -0,0 +1,50 @@ +import { router } from "expo-router"; +import type { SignupStep } from "./types"; + +type UseSignupFlowViewModelParams = { + step: SignupStep; + progress: number; + progressSteps: SignupStep[]; + currentProgressIndex: number; + showProgress: boolean; + showBottomButton: boolean; + isBottomDisabled: boolean; + buttonLabel: string; + goTo: (step: SignupStep) => void; + goNext: () => void; +}; + +export function useSignupFlowViewModel({ + step, + progress, + progressSteps, + currentProgressIndex, + showProgress, + showBottomButton, + isBottomDisabled, + buttonLabel, + goTo, + goNext, +}: UseSignupFlowViewModelParams) { + return { + step, + progress, + progressSteps, + currentProgressIndex, + showProgress, + showBottomButton, + isBottomDisabled, + buttonLabel, + onSegmentPress: (segmentIndex: number) => { + if (segmentIndex >= currentProgressIndex) { + return; + } + + goTo(progressSteps[segmentIndex]); + }, + onBottomButtonPress: + step === "complete" + ? () => router.replace("/(protected)/(student)/(tabs)/home") + : goNext, + }; +} diff --git a/src/features/signup-user-flow/model/useSignupFormActions.ts b/src/features/signup-user-flow/model/useSignupFormActions.ts new file mode 100644 index 0000000..f0b533d --- /dev/null +++ b/src/features/signup-user-flow/model/useSignupFormActions.ts @@ -0,0 +1,168 @@ +import { useMemo } from "react"; +import type { SignupSchool } from "@/entities/signup"; +import type { UserType } from "@/entities/user/model/types"; +import type { AddressSearchItem } from "@/shared/ui/address-search"; +import { + findAdminCollegeOption, + findAdminDepartmentOption, +} from "./admin"; +import { getNextAgreementState } from "./agreements"; +import type { + SignupAdminOrganizationType, + SignupFormState, + SignupIdentityFormState, +} from "./types"; + +type SignupObjectSectionKey = Exclude; + +type UpdateForm = (updater: (prev: SignupFormState) => SignupFormState) => void; +type UpdateSection = ( + section: K, + updater: (value: SignupFormState[K]) => SignupFormState[K], +) => void; +type SetSectionField = < + K extends SignupObjectSectionKey, + F extends keyof SignupFormState[K], +>( + section: K, + field: F, + value: SignupFormState[K][F], +) => void; + +type UseSignupFormActionsParams = { + updateForm: UpdateForm; + updateSection: UpdateSection; + setSectionField: SetSectionField; + startVerificationCountdown: () => void; +}; + +function resetVerificationAttempt( + identity: SignupIdentityFormState, + verificationCode: string, +) { + return { + ...identity, + verificationCode, + verificationAttempted: false, + }; +} + +export function useSignupFormActions({ + updateForm, + updateSection, + setSectionField, + startVerificationCountdown, +}: UseSignupFormActionsParams) { + return useMemo( + () => ({ + auth: { + setEmail: (email: string) => setSectionField("auth", "email", email), + setPassword: (password: string) => + setSectionField("auth", "password", password), + }, + identity: { + setPhone: (phone: string) => setSectionField("identity", "phone", phone), + setVerificationCode: (verificationCode: string) => { + updateSection("identity", (identity) => + resetVerificationAttempt(identity, verificationCode), + ); + }, + sendVerificationCode: () => { + updateSection("identity", (identity) => ({ + ...identity, + isCodeSent: true, + verificationAttempted: false, + })); + startVerificationCountdown(); + }, + }, + student: { + setRole: (role: UserType) => updateForm((prev) => ({ ...prev, role })), + setSchool: (school: SignupSchool) => + setSectionField("student", "school", school), + }, + partner: { + setEmail: (email: string) => setSectionField("partner", "email", email), + setPassword: (password: string) => + setSectionField("partner", "password", password), + setCompanyName: (companyName: string) => + setSectionField("partner", "companyName", companyName), + setOfficeAddress: ({ id }: AddressSearchItem) => + setSectionField("partner", "officeAddressId", id), + setOfficeAddressDetail: (officeAddressDetail: string) => + setSectionField("partner", "officeAddressDetail", officeAddressDetail), + selectBusinessRegistrationMock: () => + setSectionField( + "partner", + "businessRegistrationFileName", + "사업자등록증.jpg", + ), + }, + admin: { + setEmail: (email: string) => setSectionField("admin", "email", email), + setPassword: (password: string) => + setSectionField("admin", "password", password), + setOrganizationType: ( + organizationType: SignupAdminOrganizationType | null, + ) => { + updateSection("admin", (admin) => ({ + ...admin, + organizationType, + collegeId: null, + departmentId: null, + officeAddressId: null, + officeAddressDetail: "", + sealFileName: "", + })); + }, + setCollege: (value: string | null) => { + const selectedCollege = findAdminCollegeOption(value); + + updateSection("admin", (admin) => ({ + ...admin, + collegeId: selectedCollege?.value ?? null, + departmentId: null, + })); + }, + setDepartment: (value: string | null) => { + const selectedDepartment = findAdminDepartmentOption(value); + + setSectionField( + "admin", + "departmentId", + selectedDepartment?.value ?? null, + ); + }, + setOfficeAddress: ({ id }: AddressSearchItem) => + setSectionField("admin", "officeAddressId", id), + setOfficeAddressDetail: (officeAddressDetail: string) => + setSectionField("admin", "officeAddressDetail", officeAddressDetail), + selectSealMock: () => setSectionField("admin", "sealFileName", "IMG.127"), + }, + agreements: { + setPrivacy: (checked: boolean) => { + updateSection("agreements", (agreements) => + getNextAgreementState(agreements, { + agreePrivacy: checked, + }), + ); + }, + setMarketing: (checked: boolean) => { + updateSection("agreements", (agreements) => + getNextAgreementState(agreements, { + agreeMarketing: checked, + }), + ); + }, + setAll: (checked: boolean) => { + updateSection("agreements", () => ({ + agreeAll: checked, + agreePrivacy: checked, + agreeMarketing: checked, + })); + }, + }, + }), + [setSectionField, startVerificationCountdown, updateForm, updateSection], + ); +} diff --git a/src/features/signup-user-flow/model/useSignupLoginViewModel.ts b/src/features/signup-user-flow/model/useSignupLoginViewModel.ts new file mode 100644 index 0000000..8408b5d --- /dev/null +++ b/src/features/signup-user-flow/model/useSignupLoginViewModel.ts @@ -0,0 +1,26 @@ +type UseSignupLoginViewModelParams = { + email: string; + password: string; + onChangeEmail: (value: string) => void; + onChangePassword: (value: string) => void; + onPressSignup: () => void; +}; + +export function useSignupLoginViewModel({ + email, + password, + onChangeEmail, + onChangePassword, + onPressSignup, +}: UseSignupLoginViewModelParams) { + return { + email, + password, + onChangeEmail, + onChangePassword, + onPressLogin: () => { + console.log("로그인 성공"); + }, + onPressSignup, + }; +} diff --git a/src/features/signup-user-flow/model/useSignupOverlays.ts b/src/features/signup-user-flow/model/useSignupOverlays.ts new file mode 100644 index 0000000..0a86d04 --- /dev/null +++ b/src/features/signup-user-flow/model/useSignupOverlays.ts @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { AddressSearchItem } from "@/shared/ui/address-search/types"; +import { PARTNER_ADDRESS_OPTIONS } from "./data/partnerAddressOptions"; + +type AddressSearchTarget = "partner" | "admin" | null; + +type UseSignupOverlaysParams = { + adminOfficeAddressId: string | null; + partnerOfficeAddressId: string | null; + onSendVerificationCode: () => void; + onSelectAdminOfficeAddress: (item: AddressSearchItem) => void; + onSelectPartnerOfficeAddress: (item: AddressSearchItem) => void; +}; + +export function useSignupOverlays({ + adminOfficeAddressId, + partnerOfficeAddressId, + onSendVerificationCode, + onSelectAdminOfficeAddress, + onSelectPartnerOfficeAddress, +}: UseSignupOverlaysParams) { + const [isResendSheetVisible, setResendSheetVisible] = useState(false); + const [isAddressSearchVisible, setAddressSearchVisible] = useState(false); + const [addressSearchTarget, setAddressSearchTarget] = + useState(null); + + return { + resendSheet: { + visible: isResendSheetVisible, + open: () => setResendSheetVisible(true), + close: () => setResendSheetVisible(false), + action: () => { + onSendVerificationCode(); + setResendSheetVisible(false); + }, + }, + addressSearch: { + visible: isAddressSearchVisible, + items: PARTNER_ADDRESS_OPTIONS, + selectedItemId: + addressSearchTarget === "admin" + ? adminOfficeAddressId + : partnerOfficeAddressId, + openForPartner: () => { + setAddressSearchTarget("partner"); + setAddressSearchVisible(true); + }, + openForAdmin: () => { + setAddressSearchTarget("admin"); + setAddressSearchVisible(true); + }, + close: () => { + setAddressSearchVisible(false); + setAddressSearchTarget(null); + }, + selectItem: (item: AddressSearchItem) => { + if (addressSearchTarget === "admin") { + onSelectAdminOfficeAddress(item); + } else { + onSelectPartnerOfficeAddress(item); + } + setAddressSearchVisible(false); + setAddressSearchTarget(null); + }, + }, + }; +} diff --git a/src/features/signup-user-flow/model/useSignupStepActions.ts b/src/features/signup-user-flow/model/useSignupStepActions.ts new file mode 100644 index 0000000..5635722 --- /dev/null +++ b/src/features/signup-user-flow/model/useSignupStepActions.ts @@ -0,0 +1,101 @@ +import { useMemo } from "react"; +import type { UserType } from "@/entities/user/model/types"; +import type { SignupAdminOrganizationType } from "./types"; + +type UseSignupStepActionsParams = { + step: string; + goTo: (step: "studentInput2" | "adminOrganizationInfo") => void; + overlays: { + openResendSheet: () => void; + openPartnerAddressSearch: () => void; + openAdminAddressSearch: () => void; + }; + identity: { + setPhone: (value: string) => void; + setVerificationCode: (value: string) => void; + sendVerificationCode: () => void; + }; + student: { + setRole: (value: UserType) => void; + setSchool: (value: "숭실대학교") => void; + }; + partner: { + setPartnerEmail: (value: string) => void; + setPartnerPassword: (value: string) => void; + setPartnerCompanyName: (value: string) => void; + setPartnerOfficeAddressDetail: (value: string) => void; + selectPartnerBusinessRegistrationMock: () => void; + }; + admin: { + setAdminEmail: (value: string) => void; + setAdminPassword: (value: string) => void; + setAdminOrganizationType: ( + value: SignupAdminOrganizationType | null, + ) => void; + setAdminCollege: (value: string | null) => void; + setAdminDepartment: (value: string | null) => void; + setAdminOfficeAddressDetail: (value: string) => void; + selectAdminSealMock: () => void; + }; + agreementHandlers: { + onToggleAgreeAll: () => void; + onToggleAgreePrivacy: () => void; + onToggleAgreeMarketing: () => void; + }; +}; + +export function useSignupStepActions({ + step, + goTo, + overlays, + identity, + student, + partner, + admin, + agreementHandlers, +}: UseSignupStepActionsParams) { + return useMemo( + () => ({ + identity: { + onChangePhone: identity.setPhone, + onChangeVerificationCode: identity.setVerificationCode, + onSendCode: identity.sendVerificationCode, + onPressInfoLink: overlays.openResendSheet, + }, + student: { + onSelectRole: student.setRole, + onSelectSchool: student.setSchool, + onPressStudentVerify: () => goTo("studentInput2"), + }, + partner: { + onChangePartnerEmail: partner.setPartnerEmail, + onChangePartnerPassword: partner.setPartnerPassword, + onChangePartnerCompanyName: partner.setPartnerCompanyName, + onChangePartnerOfficeAddressDetail: + partner.setPartnerOfficeAddressDetail, + onPressPartnerOfficeAddress: overlays.openPartnerAddressSearch, + onPressPartnerBusinessRegistrationUpload: + partner.selectPartnerBusinessRegistrationMock, + }, + admin: { + onChangeAdminEmail: admin.setAdminEmail, + onChangeAdminPassword: admin.setAdminPassword, + onChangeAdminOrganizationType: ( + value: SignupAdminOrganizationType | null, + ) => { + admin.setAdminOrganizationType(value); + if (step === "adminOrganizationType" && value) { + goTo("adminOrganizationInfo"); + } + }, + onChangeAdminCollege: admin.setAdminCollege, + onChangeAdminDepartment: admin.setAdminDepartment, + onChangeAdminOfficeAddressDetail: admin.setAdminOfficeAddressDetail, + onPressAdminOfficeAddress: overlays.openAdminAddressSearch, + onPressAdminSealUpload: admin.selectAdminSealMock, + }, + agreements: agreementHandlers, + }), + [admin, agreementHandlers, goTo, identity, overlays, partner, step, student], + ); +} diff --git a/src/features/signup-user-flow/model/useSignupUserFlow.ts b/src/features/signup-user-flow/model/useSignupUserFlow.ts index 4422822..3bcec08 100644 --- a/src/features/signup-user-flow/model/useSignupUserFlow.ts +++ b/src/features/signup-user-flow/model/useSignupUserFlow.ts @@ -1,65 +1,93 @@ -import { useMemo, useState } from "react"; -import type { SignupSchool } from "@/entities/signup"; -import type { UserType } from "@/entities/user/model/types"; +import { useCallback, useMemo, useState } from "react"; +import { + type Path, + type PathValue, + useForm, +} from "react-hook-form"; import { useCountdownTimer } from "../hooks/useCountdownTimer"; -import { VERIFICATION_SUCCESS_CODE } from "./constants"; +import { + getSignupFlowConfig, + getSignupFlowVariant, + VERIFICATION_SUCCESS_CODE, +} from "./flowConfig"; import { DEFAULT_SIGNUP_FORM_STATE } from "./mock/signupUserFlow.mock"; -import type { SignupFormState, SignupStep } from "./types"; +import { useSignupFormActions } from "./useSignupFormActions"; +import { isSignupStepValid } from "./validation"; +import type { + SignupFormState, + SignupStep, +} from "./types"; -const STEP_ORDER: SignupStep[] = [ - "login1", - "loginForm", - "identity", - "role", - "school", - "studentInput1", - "studentInput2", - "studentInput3", - "complete", -]; +type SignupObjectSectionKey = Exclude; export function useSignupUserFlow() { const [step, setStep] = useState("login1"); - const [form, setForm] = useState(DEFAULT_SIGNUP_FORM_STATE); + const formMethods = useForm({ + mode: "onChange", + defaultValues: DEFAULT_SIGNUP_FORM_STATE, + }); + const { watch, setValue, getValues } = formMethods; + const form = watch(); const timer = useCountdownTimer({ initialSeconds: 300 }); - const progress = useMemo(() => { - switch (step) { - case "identity": - return 16.67; - case "role": - return 50; - case "school": - return 66.67; - case "studentInput1": - case "studentInput2": - case "studentInput3": - return 83.33; - default: - return 0; - } - }, [step]); - - const isCurrentStepValid = useMemo(() => { - switch (step) { - case "identity": - return ( - form.phone.length > 0 && - form.verificationCode.length > 0 && - form.isCodeSent && - timer.secondsLeft > 0 + const updateForm = useCallback( + (updater: (prev: SignupFormState) => SignupFormState) => { + const nextForm = updater(getValues()); + (Object.keys(nextForm) as Array).forEach((key) => { + setValue( + key as Path, + nextForm[key] as PathValue>, + { shouldDirty: true, shouldValidate: true }, ); - case "role": - return Boolean(form.role); - case "school": - return Boolean(form.school); - case "studentInput2": - case "studentInput3": - return form.agreePrivacy; - default: - return true; - } - }, [form, step, timer.secondsLeft]); + }); + }, + [getValues, setValue], + ); + + const updateSection = useCallback( + ( + section: K, + updater: ( + value: SignupFormState[K], + ) => SignupFormState[K], + ) => { + const nextValue = updater(getValues(section as Path) as SignupFormState[K]); + setValue( + section as Path, + nextValue as PathValue>, + { shouldDirty: true, shouldValidate: true }, + ); + }, + [getValues, setValue], + ); + + const setSectionField = useCallback( + ( + section: K, + field: F, + value: SignupFormState[K][F], + ) => { + setValue( + `${String(section)}.${String(field)}` as Path, + value as PathValue>, + { shouldDirty: true, shouldValidate: true }, + ); + }, + [setValue], + ); + + const flowVariant = useMemo(() => getSignupFlowVariant(form.role), [form.role]); + const flowConfig = useMemo(() => getSignupFlowConfig(flowVariant), [flowVariant]); + + const progress = useMemo( + () => flowConfig.progressByStep[step] ?? 0, + [flowConfig.progressByStep, step], + ); + + const isCurrentStepValid = useMemo( + () => isSignupStepValid({ step, form, secondsLeft: timer.secondsLeft }), + [form, step, timer.secondsLeft], + ); const goTo = (nextStep: SignupStep) => { setStep(nextStep); @@ -70,91 +98,27 @@ export function useSignupUserFlow() { if (!isCurrentStepValid) { return; } - if (form.verificationCode !== VERIFICATION_SUCCESS_CODE) { - setForm((prev) => ({ ...prev, verificationAttempted: true })); + if (form.identity.verificationCode !== VERIFICATION_SUCCESS_CODE) { + setSectionField("identity", "verificationAttempted", true); return; } } - const currentIndex = STEP_ORDER.indexOf(step); - const nextStep = STEP_ORDER[currentIndex + 1]; - if (!nextStep) { - return; - } - - if (!isCurrentStepValid) { + const currentIndex = flowConfig.stepOrder.indexOf(step); + const nextStep = flowConfig.stepOrder[currentIndex + 1]; + if (!nextStep || !isCurrentStepValid) { return; } setStep(nextStep); }; - const setPhone = (phone: string) => { - setForm((prev) => ({ ...prev, phone })); - }; - - const setEmail = (email: string) => { - setForm((prev) => ({ ...prev, email })); - }; - - const setPassword = (password: string) => { - setForm((prev) => ({ ...prev, password })); - }; - - const setVerificationCode = (verificationCode: string) => { - setForm((prev) => ({ - ...prev, - verificationCode, - verificationAttempted: false, - })); - }; - - const sendVerificationCode = () => { - setForm((prev) => ({ - ...prev, - isCodeSent: true, - verificationAttempted: false, - })); - timer.start(); - }; - - const setRole = (role: UserType) => { - setForm((prev) => ({ ...prev, role })); - }; - - const setSchool = (school: SignupSchool) => { - setForm((prev) => ({ ...prev, school })); - }; - - const setAgreementState = ( - updater: (prev: SignupFormState) => Partial, - ) => { - setForm((prev) => { - const partial = updater(prev); - const next = { ...prev, ...partial }; - return { - ...next, - agreeAll: next.agreePrivacy && next.agreeMarketing, - }; - }); - }; - - const setAgreePrivacy = (checked: boolean) => { - setAgreementState(() => ({ agreePrivacy: checked })); - }; - - const setAgreeMarketing = (checked: boolean) => { - setAgreementState(() => ({ agreeMarketing: checked })); - }; - - const setAgreeAll = (checked: boolean) => { - setForm((prev) => ({ - ...prev, - agreeAll: checked, - agreePrivacy: checked, - agreeMarketing: checked, - })); - }; + const actions = useSignupFormActions({ + updateForm, + updateSection, + setSectionField, + startVerificationCountdown: timer.start, + }); const activateStudentInput3 = () => { setStep("studentInput3"); @@ -163,22 +127,40 @@ export function useSignupUserFlow() { return { step, form, + flowVariant, + flowConfig, progress, isCurrentStepValid, countdown: timer.mmss, - isCountdownExpired: form.isCodeSent && timer.secondsLeft === 0, + isCountdownExpired: form.identity.isCodeSent && timer.secondsLeft === 0, + formMethods, goTo, goNext, - setEmail, - setPassword, - setPhone, - setVerificationCode, - sendVerificationCode, - setRole, - setSchool, - setAgreePrivacy, - setAgreeMarketing, - setAgreeAll, + setAuthEmail: actions.auth.setEmail, + setAuthPassword: actions.auth.setPassword, + setPhone: actions.identity.setPhone, + setVerificationCode: actions.identity.setVerificationCode, + sendVerificationCode: actions.identity.sendVerificationCode, + setRole: actions.student.setRole, + setSchool: actions.student.setSchool, + setPartnerEmail: actions.partner.setEmail, + setPartnerPassword: actions.partner.setPassword, + setAdminEmail: actions.admin.setEmail, + setAdminPassword: actions.admin.setPassword, + setAdminOrganizationType: actions.admin.setOrganizationType, + setAdminCollege: actions.admin.setCollege, + setAdminDepartment: actions.admin.setDepartment, + setAdminOfficeAddress: actions.admin.setOfficeAddress, + setAdminOfficeAddressDetail: actions.admin.setOfficeAddressDetail, + selectAdminSealMock: actions.admin.selectSealMock, + setPartnerCompanyName: actions.partner.setCompanyName, + setPartnerOfficeAddress: actions.partner.setOfficeAddress, + setPartnerOfficeAddressDetail: actions.partner.setOfficeAddressDetail, + selectPartnerBusinessRegistrationMock: + actions.partner.selectBusinessRegistrationMock, + setAgreePrivacy: actions.agreements.setPrivacy, + setAgreeMarketing: actions.agreements.setMarketing, + setAgreeAll: actions.agreements.setAll, activateStudentInput3, }; } diff --git a/src/features/signup-user-flow/model/validation.ts b/src/features/signup-user-flow/model/validation.ts new file mode 100644 index 0000000..4a61a68 --- /dev/null +++ b/src/features/signup-user-flow/model/validation.ts @@ -0,0 +1,96 @@ +import { + adminOrganizationInfoStepSchema, + adminOrganizationTypeStepSchema, + agreementStepSchema, + credentialsStepSchema, + fileAgreementStepSchema, + identityStepSchema, + partnerCompanyInfoStepSchema, + schoolStepSchema, +} from "./signupStepSchemas"; +import type { SignupFormState, SignupStep } from "./types"; + +export function isSignupStepValid({ + step, + form, + secondsLeft, +}: { + step: SignupStep; + form: SignupFormState; + secondsLeft: number; +}) { + switch (step) { + case "identity": + return ( + identityStepSchema.safeParse({ + phone: form.identity.phone, + verificationCode: form.identity.verificationCode, + }).success && + form.identity.isCodeSent && + secondsLeft > 0 + ); + case "role": + return Boolean(form.role); + case "adminCredentials": + return credentialsStepSchema.safeParse({ + email: form.admin.email, + password: form.admin.password, + }).success; + case "adminOrganizationType": + return adminOrganizationTypeStepSchema.safeParse({ + organizationType: form.admin.organizationType, + }).success; + case "school": + return schoolStepSchema.safeParse({ + school: form.student.school, + }).success; + case "partnerCredentials": + return credentialsStepSchema.safeParse({ + email: form.partner.email, + password: form.partner.password, + }).success; + case "adminOrganizationInfo": + return adminOrganizationInfoStepSchema.safeParse({ + organizationType: form.admin.organizationType, + collegeId: form.admin.collegeId, + departmentId: form.admin.departmentId, + officeAddressId: form.admin.officeAddressId, + officeAddressDetail: form.admin.officeAddressDetail, + }).success; + case "adminSealRegistration": + return ( + fileAgreementStepSchema.safeParse({ + fileName: form.admin.sealFileName, + agreeAll: form.agreements.agreeAll, + agreePrivacy: form.agreements.agreePrivacy, + agreeMarketing: form.agreements.agreeMarketing, + }).success && form.agreements.agreePrivacy + ); + case "partnerCompanyInfo": + return partnerCompanyInfoStepSchema.safeParse({ + companyName: form.partner.companyName, + officeAddressId: form.partner.officeAddressId, + officeAddressDetail: form.partner.officeAddressDetail, + }).success; + case "partnerBusinessRegistration": + return ( + fileAgreementStepSchema.safeParse({ + fileName: form.partner.businessRegistrationFileName, + agreeAll: form.agreements.agreeAll, + agreePrivacy: form.agreements.agreePrivacy, + agreeMarketing: form.agreements.agreeMarketing, + }).success && form.agreements.agreePrivacy + ); + case "studentInput2": + case "studentInput3": + return ( + agreementStepSchema.safeParse({ + agreeAll: form.agreements.agreeAll, + agreePrivacy: form.agreements.agreePrivacy, + agreeMarketing: form.agreements.agreeMarketing, + }).success && form.agreements.agreePrivacy + ); + default: + return true; + } +} diff --git a/src/features/signup-user-flow/ui/LabeledInputField.tsx b/src/features/signup-user-flow/ui/LabeledInputField.tsx index d243460..937e2e8 100644 --- a/src/features/signup-user-flow/ui/LabeledInputField.tsx +++ b/src/features/signup-user-flow/ui/LabeledInputField.tsx @@ -4,7 +4,7 @@ import { FormFieldInput } from "@/shared/ui/FormField/FormFieldInput"; import type { ColorTokenKey } from "@/shared/ui/FormField/types"; type LabeledInputFieldProps = { - label: string; + label?: string; placeholder?: string; value: string; onChangeText?: (text: string) => void; @@ -12,6 +12,7 @@ type LabeledInputFieldProps = { editable?: boolean; inputTextColor?: ColorTokenKey; inputBorderColor?: ColorTokenKey; + secureTextEntry?: boolean; }; export function LabeledInputField({ @@ -23,10 +24,13 @@ export function LabeledInputField({ editable = true, inputTextColor, inputBorderColor, + secureTextEntry, }: LabeledInputFieldProps) { return ( - - {label} + + {label ? ( + {label} + ) : null} ); diff --git a/src/features/signup-user-flow/ui/SignupStepContent.tsx b/src/features/signup-user-flow/ui/SignupStepContent.tsx index 1b08a58..1ee22e2 100644 --- a/src/features/signup-user-flow/ui/SignupStepContent.tsx +++ b/src/features/signup-user-flow/ui/SignupStepContent.tsx @@ -1,97 +1,234 @@ -import type { - SignupFormState, - SignupStep, -} from "@/features/signup-user-flow/model/types"; +import { + findAddressOption, +} from "@/features/signup-user-flow/model/admin"; +import type { SignupFormState, SignupStep } from "@/features/signup-user-flow/model/types"; +import { useFormContext } from "react-hook-form"; +import { AdminCredentialsStepSection } from "./sections/AdminCredentialsStepSection"; +import { AdminOrganizationInfoStepSection } from "./sections/AdminOrganizationInfoStepSection"; +import { AdminOrganizationTypeStepSection } from "./sections/AdminOrganizationTypeStepSection"; +import { AdminSealRegistrationStepSection } from "./sections/AdminSealRegistrationStepSection"; import { CompleteStepSection } from "./sections/CompleteStepSection"; import { IdentityStepSection } from "./sections/IdentityStepSection"; +import { PartnerBusinessRegistrationStepSection } from "./sections/PartnerBusinessRegistrationStepSection"; +import { PartnerCompanyInfoStepSection } from "./sections/PartnerCompanyInfoStepSection"; +import { PartnerCredentialsStepSection } from "./sections/PartnerCredentialsStepSection"; import { RoleStepSection } from "./sections/RoleStepSection"; import { SchoolStepSection } from "./sections/SchoolStepSection"; import { StudentAgreementStepSection } from "./sections/StudentAgreementStepSection"; import { StudentVerificationStepSection } from "./sections/StudentVerificationStepSection"; -type SignupStepContentProps = { - step: SignupStep; - form: SignupFormState; - countdown: string; - isVerificationError: boolean; +type IdentityActions = { onChangePhone: (value: string) => void; onChangeVerificationCode: (value: string) => void; onSendCode: () => void; onPressInfoLink: () => void; +}; + +type StudentActions = { onSelectRole: (role: NonNullable) => void; - onSelectSchool: (school: NonNullable) => void; + onSelectSchool: (school: NonNullable) => void; onPressStudentVerify: () => void; +}; + +type PartnerActions = { + onChangePartnerEmail: (value: string) => void; + onChangePartnerPassword: (value: string) => void; + onChangePartnerCompanyName: (value: string) => void; + onChangePartnerOfficeAddressDetail: (value: string) => void; + onPressPartnerOfficeAddress: () => void; + onPressPartnerBusinessRegistrationUpload: () => void; +}; + +type AdminActions = { + onChangeAdminEmail: (value: string) => void; + onChangeAdminPassword: (value: string) => void; + onChangeAdminOrganizationType: ( + value: NonNullable | null, + ) => void; + onChangeAdminCollege: (value: string | null) => void; + onChangeAdminDepartment: (value: string | null) => void; + onChangeAdminOfficeAddressDetail: (value: string) => void; + onPressAdminOfficeAddress: () => void; + onPressAdminSealUpload: () => void; +}; + +type AgreementActions = { onToggleAgreeAll: () => void; onToggleAgreePrivacy: () => void; onToggleAgreeMarketing: () => void; }; +type SignupStepContentProps = { + step: SignupStep; + countdown: string; + completeDisplayName: string; + isVerificationError: boolean; + actions: { + identity: IdentityActions; + student: StudentActions; + partner: PartnerActions; + admin: AdminActions; + agreements: AgreementActions; + }; +}; + export function SignupStepContent({ step, - form, countdown, + completeDisplayName, isVerificationError, - onChangePhone, - onChangeVerificationCode, - onSendCode, - onPressInfoLink, - onSelectRole, - onSelectSchool, - onPressStudentVerify, - onToggleAgreeAll, - onToggleAgreePrivacy, - onToggleAgreeMarketing, + actions, }: SignupStepContentProps) { + const { watch } = useFormContext(); + const form = watch(); + switch (step) { + case "login1": + case "loginForm": + return null; case "identity": return ( ); case "role": return ( - + ); case "school": return ( ); case "studentInput1": return ( ); case "studentInput2": case "studentInput3": return ( + ); + case "partnerCredentials": + return ( + + ); + case "adminCredentials": + return ( + + ); + case "adminOrganizationType": + return ( + + ); + case "adminOrganizationInfo": + return ( + + ); + case "adminSealRegistration": + return ( + + ); + case "partnerCompanyInfo": + return ( + + ); + case "partnerBusinessRegistration": + return ( + ); case "complete": - return ; - default: + return ; + default: { + const _exhaustive: never = step; return null; + } } } diff --git a/src/features/signup-user-flow/ui/components/AgreementFooter.tsx b/src/features/signup-user-flow/ui/components/AgreementFooter.tsx new file mode 100644 index 0000000..32047f1 --- /dev/null +++ b/src/features/signup-user-flow/ui/components/AgreementFooter.tsx @@ -0,0 +1,34 @@ +import type { SignupAgreementState } from "@/entities/signup"; +import { SignupAgreementSection } from "../SignupAgreementSection"; +import { View } from "react-native"; + +type AgreementFooterProps = { + agreements: SignupAgreementState; + onToggleAll: () => void; + onTogglePrivacy: () => void; + onToggleMarketing: () => void; + className?: string; +}; + +export function AgreementFooter({ + agreements, + onToggleAll, + onTogglePrivacy, + onToggleMarketing, + className = "mb-[22px] mt-auto", +}: AgreementFooterProps) { + return ( + // Footer는 각 step 섹션에서 항상 화면 하단에 고정되는 패턴이라 공통화합니다. + + + + ); +} + diff --git a/src/features/signup-user-flow/ui/components/CredentialsFields.tsx b/src/features/signup-user-flow/ui/components/CredentialsFields.tsx new file mode 100644 index 0000000..8cd91d0 --- /dev/null +++ b/src/features/signup-user-flow/ui/components/CredentialsFields.tsx @@ -0,0 +1,35 @@ +import { View } from "react-native"; +import { LabeledInputField } from "../LabeledInputField"; + +type CredentialsFieldsProps = { + emailValue: string; + passwordValue: string; + onChangeEmail: (value: string) => void; + onChangePassword: (value: string) => void; +}; + +export function CredentialsFields({ + emailValue, + passwordValue, + onChangeEmail, + onChangePassword, +}: CredentialsFieldsProps) { + return ( + + + + + ); +} + diff --git a/src/features/signup-user-flow/ui/components/FileUploadButton.tsx b/src/features/signup-user-flow/ui/components/FileUploadButton.tsx new file mode 100644 index 0000000..29e5b50 --- /dev/null +++ b/src/features/signup-user-flow/ui/components/FileUploadButton.tsx @@ -0,0 +1,41 @@ +import { CheckIcon, ImageUploadIcon } from "@/shared/assets/icons"; +import { colorTokens } from "@/shared/styles/tokens"; +import { Pressable, Text } from "react-native"; + +type FileUploadButtonProps = { + fileName: string; + onPressUpload: () => void; + placeholderText?: string; + className?: string; +}; + +export function FileUploadButton({ + fileName, + onPressUpload, + placeholderText = "갤러리에서 사진 업로드", + className, +}: FileUploadButtonProps) { + const hasUploadedFile = fileName.length > 0; + + return ( + + + {fileName || placeholderText} + + {hasUploadedFile ? ( + + ) : ( + + )} + + ); +} + diff --git a/src/features/signup-user-flow/ui/components/OfficeAddressPicker.tsx b/src/features/signup-user-flow/ui/components/OfficeAddressPicker.tsx new file mode 100644 index 0000000..2b66ba6 --- /dev/null +++ b/src/features/signup-user-flow/ui/components/OfficeAddressPicker.tsx @@ -0,0 +1,41 @@ +import { Pressable, View } from "react-native"; +import { SearchIcon } from "@/shared/assets/icons"; +import { LabeledInputField } from "../LabeledInputField"; + +type OfficeAddressPickerProps = { + officeAddress: string; + officeAddressDetail: string; + onPressOfficeAddress: () => void; + onChangeOfficeAddressDetail: (value: string) => void; + gapClassName?: string; +}; + +export function OfficeAddressPicker({ + officeAddress, + officeAddressDetail, + onPressOfficeAddress, + onChangeOfficeAddressDetail, + gapClassName = "gap-[15px]", +}: OfficeAddressPickerProps) { + return ( + + + + } + /> + + + + + + ); +} + diff --git a/src/features/signup-user-flow/ui/index.ts b/src/features/signup-user-flow/ui/index.ts index 84cb2b7..82f5d02 100644 --- a/src/features/signup-user-flow/ui/index.ts +++ b/src/features/signup-user-flow/ui/index.ts @@ -1,4 +1,4 @@ -export { LabeledInputField } from "./LabeledInputField"; +export { FormProvider } from "react-hook-form"; export { LoginFormScreen } from "./LoginFormScreen"; export { LoginIntroScreen } from "./LoginIntroScreen"; export { SelectableOptionField } from "./SelectableOptionField"; diff --git a/src/features/signup-user-flow/ui/layout/AdminStepLayout.tsx b/src/features/signup-user-flow/ui/layout/AdminStepLayout.tsx new file mode 100644 index 0000000..01a7f53 --- /dev/null +++ b/src/features/signup-user-flow/ui/layout/AdminStepLayout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; +import { SignupStepTitle } from "../SignupStepTitle"; + +type AdminStepLayoutProps = { + children: ReactNode; + contentGapClassName?: string; + firstLine: string; + secondLine: string; +}; + +export function AdminStepLayout({ + children, + contentGapClassName = "gap-[56px]", + firstLine, + secondLine, +}: AdminStepLayoutProps) { + return ( + + + {children} + + ); +} + diff --git a/src/features/signup-user-flow/ui/sections/AdminCredentialsStepSection.tsx b/src/features/signup-user-flow/ui/sections/AdminCredentialsStepSection.tsx new file mode 100644 index 0000000..8fdb400 --- /dev/null +++ b/src/features/signup-user-flow/ui/sections/AdminCredentialsStepSection.tsx @@ -0,0 +1,30 @@ +import { AdminStepLayout } from "../layout/AdminStepLayout"; +import { CredentialsFields } from "../components/CredentialsFields"; + +type AdminCredentialsStepSectionProps = { + adminEmail: string; + adminPassword: string; + onChangeAdminEmail: (value: string) => void; + onChangeAdminPassword: (value: string) => void; +}; + +export function AdminCredentialsStepSection({ + adminEmail, + adminPassword, + onChangeAdminEmail, + onChangeAdminPassword, +}: AdminCredentialsStepSectionProps) { + return ( + + + + ); +} diff --git a/src/features/signup-user-flow/ui/sections/AdminOrganizationInfoStepSection.tsx b/src/features/signup-user-flow/ui/sections/AdminOrganizationInfoStepSection.tsx new file mode 100644 index 0000000..603b932 --- /dev/null +++ b/src/features/signup-user-flow/ui/sections/AdminOrganizationInfoStepSection.tsx @@ -0,0 +1,106 @@ +import { View } from "react-native"; +import { shouldShowAdminOfficeAddressBySelection } from "@/features/signup-user-flow/model/admin"; +import type { SignupAdminOrganizationType } from "@/features/signup-user-flow/model/types"; +import { + ADMIN_COLLEGE_OPTIONS, + ADMIN_DEPARTMENT_OPTIONS, + ADMIN_ORGANIZATION_TYPE_OPTIONS, +} from "@/features/signup-user-flow/model/data/adminOptions"; +import { Select } from "@/shared/ui/select"; +import { AdminStepLayout } from "../layout/AdminStepLayout"; +import { LabeledInputField } from "../LabeledInputField"; +import { OfficeAddressPicker } from "../components/OfficeAddressPicker"; + +type AdminOrganizationInfoStepSectionProps = { + organizationType: SignupAdminOrganizationType | null; + collegeId: string | null; + departmentId: string | null; + officeAddress: string; + officeAddressDetail: string; + onChangeOrganizationType: (value: SignupAdminOrganizationType | null) => void; + onChangeCollege: (value: string | null) => void; + onChangeDepartment: (value: string | null) => void; + onChangeOfficeAddressDetail: (value: string) => void; + onPressOfficeAddress: () => void; +}; + +export function AdminOrganizationInfoStepSection({ + organizationType, + collegeId, + departmentId, + officeAddress, + officeAddressDetail, + onChangeOrganizationType, + onChangeCollege, + onChangeDepartment, + onChangeOfficeAddressDetail, + onPressOfficeAddress, +}: AdminOrganizationInfoStepSectionProps) { + const shouldShowOfficeAddress = shouldShowAdminOfficeAddressBySelection( + organizationType as SignupAdminOrganizationType | null, + collegeId, + departmentId, + ); + + return ( + + + + ) : null} + + {organizationType === "DEPARTMENT_STUDENT_COUNCIL" ? ( + + onChangeOrganizationType(value as SignupAdminOrganizationType | null) + } + placeholder="단위 선택" + presentation="inline" + /> + + ); +} diff --git a/src/features/signup-user-flow/ui/sections/AdminSealRegistrationStepSection.tsx b/src/features/signup-user-flow/ui/sections/AdminSealRegistrationStepSection.tsx new file mode 100644 index 0000000..51bc596 --- /dev/null +++ b/src/features/signup-user-flow/ui/sections/AdminSealRegistrationStepSection.tsx @@ -0,0 +1,46 @@ +import { AgreementFooter } from "../components/AgreementFooter"; +import { FileUploadButton } from "../components/FileUploadButton"; +import { AdminStepLayout } from "../layout/AdminStepLayout"; + +type AdminSealRegistrationStepSectionProps = { + sealFileName: string; + agreeAll: boolean; + agreePrivacy: boolean; + agreeMarketing: boolean; + onPressUpload: () => void; + onToggleAll: () => void; + onTogglePrivacy: () => void; + onToggleMarketing: () => void; +}; + +export function AdminSealRegistrationStepSection({ + sealFileName, + agreeAll, + agreePrivacy, + agreeMarketing, + onPressUpload, + onToggleAll, + onTogglePrivacy, + onToggleMarketing, +}: AdminSealRegistrationStepSectionProps) { + return ( + + + + + + ); +} diff --git a/src/features/signup-user-flow/ui/sections/IdentityStepSection.tsx b/src/features/signup-user-flow/ui/sections/IdentityStepSection.tsx index be23c69..87e313b 100644 --- a/src/features/signup-user-flow/ui/sections/IdentityStepSection.tsx +++ b/src/features/signup-user-flow/ui/sections/IdentityStepSection.tsx @@ -45,7 +45,10 @@ export function IdentityStepSection({ ) : ( - + 인증번호 받기 diff --git a/src/features/signup-user-flow/ui/sections/PartnerBusinessRegistrationStepSection.tsx b/src/features/signup-user-flow/ui/sections/PartnerBusinessRegistrationStepSection.tsx new file mode 100644 index 0000000..9a27ab1 --- /dev/null +++ b/src/features/signup-user-flow/ui/sections/PartnerBusinessRegistrationStepSection.tsx @@ -0,0 +1,48 @@ +import { View } from "react-native"; +import { SignupStepTitle } from "../SignupStepTitle"; +import { AgreementFooter } from "../components/AgreementFooter"; +import { FileUploadButton } from "../components/FileUploadButton"; + +type PartnerBusinessRegistrationStepSectionProps = { + businessRegistrationFileName: string; + agreeAll: boolean; + agreePrivacy: boolean; + agreeMarketing: boolean; + onPressUpload: () => void; + onToggleAll: () => void; + onTogglePrivacy: () => void; + onToggleMarketing: () => void; +}; + +export function PartnerBusinessRegistrationStepSection({ + businessRegistrationFileName, + agreeAll, + agreePrivacy, + agreeMarketing, + onPressUpload, + onToggleAll, + onTogglePrivacy, + onToggleMarketing, +}: PartnerBusinessRegistrationStepSectionProps) { + return ( + + + + + + + + ); +} diff --git a/src/features/signup-user-flow/ui/sections/PartnerCompanyInfoStepSection.tsx b/src/features/signup-user-flow/ui/sections/PartnerCompanyInfoStepSection.tsx new file mode 100644 index 0000000..796ab26 --- /dev/null +++ b/src/features/signup-user-flow/ui/sections/PartnerCompanyInfoStepSection.tsx @@ -0,0 +1,45 @@ +import { SignupStepTitle } from "../SignupStepTitle"; +import { LabeledInputField } from "../LabeledInputField"; +import { OfficeAddressPicker } from "../components/OfficeAddressPicker"; +import { View } from "react-native"; + +type PartnerCompanyInfoStepSectionProps = { + partnerCompanyName: string; + partnerOfficeAddress: string; + partnerOfficeAddressDetail: string; + onChangePartnerCompanyName: (value: string) => void; + onChangePartnerOfficeAddressDetail: (value: string) => void; + onPressOfficeAddress: () => void; +}; + +export function PartnerCompanyInfoStepSection({ + partnerCompanyName, + partnerOfficeAddress, + partnerOfficeAddressDetail, + onChangePartnerCompanyName, + onChangePartnerOfficeAddressDetail, + onPressOfficeAddress, +}: PartnerCompanyInfoStepSectionProps) { + return ( + + + + + + + + ); +} diff --git a/src/features/signup-user-flow/ui/sections/PartnerCredentialsStepSection.tsx b/src/features/signup-user-flow/ui/sections/PartnerCredentialsStepSection.tsx new file mode 100644 index 0000000..a067c82 --- /dev/null +++ b/src/features/signup-user-flow/ui/sections/PartnerCredentialsStepSection.tsx @@ -0,0 +1,32 @@ +import { SignupStepTitle } from "../SignupStepTitle"; +import { View } from "react-native"; +import { CredentialsFields } from "../components/CredentialsFields"; + +type PartnerCredentialsStepSectionProps = { + partnerEmail: string; + partnerPassword: string; + onChangePartnerEmail: (value: string) => void; + onChangePartnerPassword: (value: string) => void; +}; + +export function PartnerCredentialsStepSection({ + partnerEmail, + partnerPassword, + onChangePartnerEmail, + onChangePartnerPassword, +}: PartnerCredentialsStepSectionProps) { + return ( + + + + + ); +} diff --git a/src/features/signup-user-flow/ui/sections/StudentAgreementStepSection.tsx b/src/features/signup-user-flow/ui/sections/StudentAgreementStepSection.tsx index b7ea090..96bf5d6 100644 --- a/src/features/signup-user-flow/ui/sections/StudentAgreementStepSection.tsx +++ b/src/features/signup-user-flow/ui/sections/StudentAgreementStepSection.tsx @@ -1,6 +1,6 @@ import { Text, View } from "react-native"; import { LabeledInputField } from "../LabeledInputField"; -import { SignupAgreementSection } from "../SignupAgreementSection"; +import { AgreementFooter } from "../components/AgreementFooter"; type StudentAgreementStepSectionProps = { major: string; @@ -52,16 +52,12 @@ export function StudentAgreementStepSection({ /> - - - + ); } diff --git a/src/shared/assets/icons/image-upload-icon.svg b/src/shared/assets/icons/image-upload-icon.svg new file mode 100644 index 0000000..8b147d6 --- /dev/null +++ b/src/shared/assets/icons/image-upload-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/assets/icons/index.ts b/src/shared/assets/icons/index.ts index 17bad60..f27205f 100644 --- a/src/shared/assets/icons/index.ts +++ b/src/shared/assets/icons/index.ts @@ -10,11 +10,13 @@ export { default as CloseIcon } from "./close-icon.svg"; export { default as ExitRightIcon } from "./exit-right-icon.svg"; export { default as FolderIcon } from "./folder-icon.svg"; export { default as HeadphoneIcon } from "./headphone-icon.svg"; +export { default as ImageUploadIcon } from "./image-upload-icon.svg"; export { default as InfoFillIcon } from "./info-fill-icon.svg"; export { default as InfoIcon } from "./info-icon.svg"; export { default as ListIcon } from "./list-icon.svg"; export { default as LoginCheckIcon } from "./login-check-icon.svg"; export { default as LoginNoIcon } from "./login-no-icon.svg"; +export { default as LocationIcon } from "./location-icon.svg"; export { default as Logo } from "./logo.svg"; export { default as QRIcon } from "./qr-icon.svg"; export { default as SearchIcon } from "./search-icon.svg"; diff --git a/src/shared/assets/icons/location-icon.svg b/src/shared/assets/icons/location-icon.svg new file mode 100644 index 0000000..3781353 --- /dev/null +++ b/src/shared/assets/icons/location-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/ui/SearchBar/SearchBar.tsx b/src/shared/ui/SearchBar/SearchBar.tsx index 3c4f5ef..4d3271b 100644 --- a/src/shared/ui/SearchBar/SearchBar.tsx +++ b/src/shared/ui/SearchBar/SearchBar.tsx @@ -1,32 +1,24 @@ import { Pressable, Text, TextInput, View } from "react-native"; -import { BackArrowIcon, CloseIcon, SearchIcon } from "@/shared/assets/icons"; +import { + BackArrowIcon, + CloseIcon, + LocationIcon, + SearchIcon, +} from "@/shared/assets/icons"; import { colorTokens } from "@/shared/styles/tokens"; import type { SearchBarProps } from "./types"; const DEFAULT_PLACEHOLDER = "찾으시는 제휴 가게가 없나요?"; -/** - * 서치바 공통 컴포넌트 - * - * - mode="default": 전체 영역이 Pressable. 검색 화면 진입 전 사용. - * - mode="active": 실제 TextInput. 검색 서브페이지에서 사용. - * 뒤로가기 버튼 포함, 텍스트 입력 시 X(초기화) 버튼 노출. - * - * Safe area 패딩은 이 컴포넌트를 사용하는 페이지/레이아웃에서 처리. - * - * 아이콘 파일: - * src/shared/assets/icons/search-icon.svg → SearchIcon - * src/shared/assets/icons/close-icon.svg → CloseIcon - * src/shared/assets/icons/back-arrow-icon.svg → BackArrowIcon - */ + export function SearchBar(props: SearchBarProps) { if (props.mode === "default") { return ( {({ pressed }) => ( {props.placeholder ?? DEFAULT_PLACEHOLDER} @@ -50,8 +42,11 @@ export function SearchBar(props: SearchBarProps) { ); } + const LeadingIcon = + props.iconVariant === "location" ? LocationIcon : SearchIcon; + return ( - + - - + void; onBack: () => void; autoFocus?: boolean; + iconVariant?: "search" | "location"; }; export type SearchBarProps = SearchBarDefaultProps | SearchBarActiveProps; diff --git a/src/shared/ui/address-search/AddressSearchDialog.tsx b/src/shared/ui/address-search/AddressSearchDialog.tsx new file mode 100644 index 0000000..e4ec561 --- /dev/null +++ b/src/shared/ui/address-search/AddressSearchDialog.tsx @@ -0,0 +1,71 @@ +import { useEffect, useMemo, useState } from "react"; +import { Modal, View } from "react-native"; +import { SearchBar } from "@/shared/ui/SearchBar"; +import { AddressSearchOptionRow } from "./AddressSearchOptionRow"; +import type { AddressSearchItem } from "./types"; + +type AddressSearchDialogProps = { + visible: boolean; + items: AddressSearchItem[]; + selectedItemId?: string | null; + placeholder?: string; + onClose: () => void; + onSelectItem: (item: AddressSearchItem) => void; +}; + +export function AddressSearchDialog({ + visible, + items, + selectedItemId, + placeholder = "예 ) 상도로 360로", + onClose, + onSelectItem, +}: AddressSearchDialogProps) { + const [query, setQuery] = useState(""); + + useEffect(() => { + if (visible) { + setQuery(""); + } + }, [visible]); + + const filteredItems = useMemo(() => { + const normalizedQuery = query.trim(); + if (normalizedQuery.length === 0) { + return items; + } + + return items.filter((item) => item.label.includes(normalizedQuery)); + }, [items, query]); + + return ( + + + + + + {filteredItems.map((item, index) => ( + { + onSelectItem(item); + setQuery(""); + }} + /> + ))} + + + + ); +} diff --git a/src/shared/ui/address-search/AddressSearchOptionRow.tsx b/src/shared/ui/address-search/AddressSearchOptionRow.tsx new file mode 100644 index 0000000..afb4495 --- /dev/null +++ b/src/shared/ui/address-search/AddressSearchOptionRow.tsx @@ -0,0 +1,39 @@ +import { Pressable, Text, View } from "react-native"; +import { CheckIcon } from "@/shared/assets/icons"; +import { colorTokens } from "@/shared/styles/tokens"; + +type AddressSearchOptionRowProps = { + label: string; + selected: boolean; + showDivider: boolean; + onPress: () => void; +}; + +export function AddressSearchOptionRow({ + label, + selected, + showDivider, + onPress, +}: AddressSearchOptionRowProps) { + return ( + + + {label} + + {selected ? : } + + ); +} diff --git a/src/shared/ui/address-search/index.ts b/src/shared/ui/address-search/index.ts new file mode 100644 index 0000000..e1958d8 --- /dev/null +++ b/src/shared/ui/address-search/index.ts @@ -0,0 +1,3 @@ +export { AddressSearchDialog } from "./AddressSearchDialog"; +export { AddressSearchOptionRow } from "./AddressSearchOptionRow"; +export type { AddressSearchItem } from "./types"; diff --git a/src/shared/ui/address-search/types.ts b/src/shared/ui/address-search/types.ts new file mode 100644 index 0000000..ca5faa4 --- /dev/null +++ b/src/shared/ui/address-search/types.ts @@ -0,0 +1,4 @@ +export type AddressSearchItem = { + id: string; + label: string; +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 725d27c..53e497e 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,3 +1,4 @@ +export * from "./address-search"; export * from "./app-top-bar"; export * from "./bottom-sheet"; export * from "./checkbox"; diff --git a/src/shared/ui/select/DropdownSelect.tsx b/src/shared/ui/select/DropdownSelect.tsx new file mode 100644 index 0000000..24dd21b --- /dev/null +++ b/src/shared/ui/select/DropdownSelect.tsx @@ -0,0 +1,174 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useState } from "react"; +import { Text, View } from "react-native"; +import { Dropdown } from "react-native-element-dropdown"; +import { shadows } from "@/shared/styles/shadows"; +import { colorTokens } from "@/shared/styles/tokens"; +import type { SelectItem, SelectSize } from "./types"; + +const SIZES = { + sm: { + fieldPx: 12, + fieldPy: 10, + fontSize: 14, + }, + md: { + fieldPx: 12, + fieldPy: 12, + fontSize: 16, + }, +} as const; + +type DropdownSelectProps = { + items: SelectItem[]; + value: string | null; + onChange: (value: string | null) => void; + placeholder: string; + disabled: boolean; + readOnly: boolean; + label?: string; + helperText?: string; + errorText?: string; + size: SelectSize; + testID?: string; +}; + +export function DropdownSelect({ + items, + value, + onChange, + placeholder, + disabled, + readOnly, + label, + helperText, + errorText, + size, + testID, +}: DropdownSelectProps) { + const sizeToken = SIZES[size]; + const [isOpen, setIsOpen] = useState(false); + const [disabledTapNonce, setDisabledTapNonce] = useState(0); + + return ( + + {!!label && ( + {label} + )} + + setIsOpen(true)} + onBlur={() => setIsOpen(false)} + onChange={(item: SelectItem) => { + if (item?.disabled) { + setDisabledTapNonce((n) => n + 1); + return; + } + onChange(item?.value ?? null); + }} + style={{ + ...(isOpen + ? { + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + borderBottomColor: colorTokens.neutralVariant, + } + : { + borderRadius: 12, + borderBottomColor: "transparent", + }), + borderBottomWidth: 2, + backgroundColor: colorTokens.canvas, + paddingHorizontal: sizeToken.fieldPx, + paddingVertical: sizeToken.fieldPy, + opacity: disabled ? 0.3 : 1, + }} + containerStyle={{ + marginTop: -2, + borderBottomLeftRadius: 12, + borderBottomRightRadius: 12, + overflow: "hidden", + backgroundColor: colorTokens.canvas, + ...shadows.neutral, + }} + itemContainerStyle={{ + paddingHorizontal: 16, + paddingVertical: 14, + opacity: 1, + }} + renderRightIcon={() => ( + + )} + placeholderStyle={{ + fontFamily: "Pretendard-Regular", + fontSize: sizeToken.fontSize, + color: colorTokens.contentSecondary, + }} + selectedTextStyle={{ + fontFamily: "Pretendard-Regular", + fontSize: sizeToken.fontSize, + color: colorTokens.contentPrimary, + }} + renderItem={(item: SelectItem) => { + const isSelected = item.value === value; + const isDisabled = Boolean(item.disabled); + + return ( + + + {item.label} + + + {isDisabled ? ( + + ) : isSelected ? ( + + ) : null} + + ); + }} + /> + + {errorText ? ( + {errorText} + ) : helperText ? ( + + {helperText} + + ) : null} + + ); +} diff --git a/src/shared/ui/select/InlineSelect.tsx b/src/shared/ui/select/InlineSelect.tsx new file mode 100644 index 0000000..5a88b62 --- /dev/null +++ b/src/shared/ui/select/InlineSelect.tsx @@ -0,0 +1,164 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useMemo, useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { shadows } from "@/shared/styles/shadows"; +import { colorTokens } from "@/shared/styles/tokens"; +import type { SelectItem, SelectSize, SelectTextTone } from "./types"; + +const SIZES = { + sm: { + fontSize: 14, + }, + md: { + fontSize: 16, + }, +} as const; + +type InlineSelectProps = { + items: SelectItem[]; + value: string | null; + onChange: (value: string | null) => void; + placeholder: string; + disabled: boolean; + readOnly: boolean; + label?: string; + helperText?: string; + errorText?: string; + size: SelectSize; + placeholderTone?: SelectTextTone; + optionTone?: SelectTextTone; + testID?: string; +}; + +export function InlineSelect({ + items, + value, + onChange, + placeholder, + disabled, + readOnly, + label, + helperText, + errorText, + size, + placeholderTone = "muted", + optionTone = "muted", + testID, +}: InlineSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const sizeToken = SIZES[size]; + const selectedItem = useMemo( + () => items.find((item) => item.value === value) ?? null, + [items, value], + ); + const getToneClassName = (tone: SelectTextTone) => + tone === "default" ? "text-content-primary" : "text-content-secondary"; + + return ( + + {!!label && ( + {label} + )} + + + setIsOpen((prev) => !prev)} + className="flex-row items-center justify-between px-[12px] py-[12px]" + style={{ + borderBottomWidth: isOpen ? 2 : 0, + borderBottomColor: isOpen + ? colorTokens.neutralVariant + : "transparent", + opacity: disabled ? 0.3 : 1, + }} + > + + {selectedItem?.label ?? placeholder} + + + + + {isOpen ? ( + + {items.map((item, index) => { + const isSelected = item.value === value; + const isLast = index === items.length - 1; + + return ( + { + if (item.disabled) { + return; + } + onChange(item.value); + setIsOpen(false); + }} + className="flex-row items-center justify-between px-[16px] py-[14px]" + style={{ + borderBottomWidth: isLast ? 0 : 0.5, + borderBottomColor: colorTokens.neutralVariant, + opacity: item.disabled ? 0.4 : 1, + }} + > + + {item.label} + + + {item.disabled ? ( + + ) : isSelected ? ( + + ) : ( + + )} + + ); + })} + + ) : null} + + + {errorText ? ( + {errorText} + ) : helperText ? ( + + {helperText} + + ) : null} + + ); +} diff --git a/src/shared/ui/select/Select.tsx b/src/shared/ui/select/Select.tsx index 682433f..cd1d2a8 100644 --- a/src/shared/ui/select/Select.tsx +++ b/src/shared/ui/select/Select.tsx @@ -1,168 +1,11 @@ -import { Ionicons } from "@expo/vector-icons"; -import { useState } from "react"; -import { Text, View } from "react-native"; -import { Dropdown } from "react-native-element-dropdown"; -import { shadows } from "@/shared/styles/shadows"; -import { colorTokens } from "@/shared/styles/tokens"; -import type { SelectItem, SelectProps } from "./types"; +import { DropdownSelect } from "./DropdownSelect"; +import { InlineSelect } from "./InlineSelect"; +import type { SelectProps } from "./types"; -const SIZES = { - sm: { - fieldPx: 12, - fieldPy: 10, - fontSize: 14, - }, - md: { - fieldPx: 12, - fieldPy: 12, - fontSize: 16, - }, -} as const; +export function Select(props: SelectProps) { + if (props.presentation === "inline") { + return ; + } -export function Select({ - items, - value, - onChange, - placeholder = "선택", - disabled = false, - label, - helperText, - errorText, - size = "md", - testID, -}: SelectProps) { - const sizeToken = SIZES[size]; - const [isOpen, setIsOpen] = useState(false); - const [disabledTapNonce, setDisabledTapNonce] = useState(0); - - return ( - - {!!label && ( - - {label} - - )} - - setIsOpen(true)} - onBlur={() => setIsOpen(false)} - onChange={(item: SelectItem) => { - // disabled 항목은 선택 무시 - if (item?.disabled) { - setDisabledTapNonce((n) => n + 1); - return; - } - onChange(item?.value ?? null); - }} - // NOTE: Dropdown의 `style`은 내부에서 width를 측정하는 컨테이너(View)에 적용됩니다. - // 따라서 필드 UI(보더/패딩)를 여기로 옮기면, 옵션 리스트 컨테이너 폭도 필드와 동일하게 맞습니다. - style={{ - ...(isOpen - ? { - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - borderBottomColor: colorTokens.neutralVariant, - } - : { - borderRadius: 12, - borderBottomColor: "transparent", - }), - borderBottomWidth: 2, - backgroundColor: colorTokens.canvas, - paddingHorizontal: sizeToken.fieldPx, - paddingVertical: sizeToken.fieldPy, - opacity: disabled ? 0.3 : 1, - }} - containerStyle={{ - marginTop: -2, - borderBottomLeftRadius: 12, - borderBottomRightRadius: 12, - overflow: "hidden", - backgroundColor: colorTokens.canvas, - ...shadows.neutral, - }} - itemContainerStyle={{ - paddingHorizontal: 16, - paddingVertical: 14, - opacity: 1, - }} - // activeColor={colorTokens.primaryTint} - renderRightIcon={() => ( - - )} - placeholderStyle={{ - fontFamily: "Pretendard-Regular", - fontSize: sizeToken.fontSize, - color: colorTokens.contentSecondary, - }} - selectedTextStyle={{ - fontFamily: "Pretendard-Regular", - fontSize: sizeToken.fontSize, - color: colorTokens.contentPrimary, - }} - renderItem={(item: SelectItem) => { - const isSelected = item.value === value; - const isDisabled = Boolean(item.disabled); - - return ( - - - {item.label} - - - {isDisabled ? ( - - ) : isSelected ? ( - - ) : null} - - ); - }} - /> - - {errorText ? ( - {errorText} - ) : helperText ? ( - - {helperText} - - ) : null} - - ); + return ; } diff --git a/src/shared/ui/select/index.ts b/src/shared/ui/select/index.ts index 61f2840..f5c7c18 100644 --- a/src/shared/ui/select/index.ts +++ b/src/shared/ui/select/index.ts @@ -4,4 +4,5 @@ export type { SelectPresentation, SelectProps, SelectSize, + SelectTextTone, } from "./types"; diff --git a/src/shared/ui/select/types.ts b/src/shared/ui/select/types.ts index 0881f78..f1c0dd9 100644 --- a/src/shared/ui/select/types.ts +++ b/src/shared/ui/select/types.ts @@ -6,6 +6,7 @@ export type SelectItem = { export type SelectSize = "sm" | "md"; export type SelectPresentation = "modal" | "inline"; +export type SelectTextTone = "default" | "muted"; export type SelectProps = { /** 옵션 목록 */ @@ -29,6 +30,12 @@ export type SelectProps = { /** 드롭다운 표시 방식 */ presentation?: SelectPresentation; + /** 미선택 상태 텍스트 톤 */ + placeholderTone?: SelectTextTone; + + /** 선택되지 않은 옵션 텍스트 톤 */ + optionTone?: SelectTextTone; + /** (옵션) 상단 라벨 */ label?: string; diff --git a/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx b/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx index b002740..1a4e426 100644 --- a/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx +++ b/src/widgets/signup-user-flow/ui/SignupUserFlowWidget.tsx @@ -1,193 +1,108 @@ -import { router } from "expo-router"; -import { useEffect, useState } from "react"; -import { View } from "react-native"; +import { useSignupFlowController } from "@/features/signup-user-flow/model/useSignupFlowController"; import { - SIGNUP_PROGRESS_STEPS, - VERIFICATION_SUCCESS_CODE, -} from "@/features/signup-user-flow/model/constants"; -import { useSignupUserFlow } from "@/features/signup-user-flow/model/useSignupUserFlow"; -import { - LoginFormScreen, - LoginIntroScreen, - SignupProgressBar, - SignupStepContent, + FormProvider, + LoginFormScreen, + LoginIntroScreen, + SignupProgressBar, + SignupStepContent, } from "@/features/signup-user-flow/ui"; +import { AddressSearchDialog } from "@/shared/ui/address-search"; import { BottomActionSheet } from "@/shared/ui/bottom-sheet"; import { MediumButton } from "@/shared/ui/buttons/SubmitButton"; - -const BUTTON_LABEL_BY_STEP = { - identity: "인증완료", - role: "확인", - school: "완료", - studentInput2: "완료", - studentInput3: "완료", - complete: "가입완료", -} as const; +import { ScrollView, View } from "react-native"; export function SignupUserFlowWidget() { - const [isResendSheetVisible, setResendSheetVisible] = useState(false); - const { - step, - form, - progress, - isCurrentStepValid, - countdown, - goTo, - goNext, - setEmail, - setPassword, - setPhone, - setVerificationCode, - sendVerificationCode, - setRole, - setSchool, - setAgreePrivacy, - setAgreeMarketing, - setAgreeAll, - activateStudentInput3, - } = useSignupUserFlow(); - - useEffect(() => { - if (step !== "login1") { - return; - } - - const timer = setTimeout(() => { - goTo("loginForm"); - }, 2000); - return () => clearTimeout(timer); - }, [goTo, step]); + const { + formMethods, + flow, + login, + stepContent, + overlays, + } = useSignupFlowController(); - if (step === "login1") { - return ( - {}} - disabled - /> - ); - } + if (flow.step === "login1") { + return ( + {}} + disabled + /> + ); + } - if (step === "loginForm") { - return ( - { - console.log("로그인 성공"); - }} - onPressSignup={() => goTo("identity")} - /> - ); - } + if (flow.step === "loginForm") { + return ( + + ); + } - const currentProgressIndex = SIGNUP_PROGRESS_STEPS.indexOf(step); - const showProgress = step !== "complete"; - const showBottomButton = step !== "studentInput1"; - const isBottomDisabled = - step === "studentInput2" - ? true - : !isCurrentStepValid && step !== "complete"; - const isVerificationError = - form.verificationAttempted && - form.verificationCode !== VERIFICATION_SUCCESS_CODE; - const buttonLabel = - BUTTON_LABEL_BY_STEP[step as keyof typeof BUTTON_LABEL_BY_STEP] ?? "완료"; - const toggleAgreementAndActivate = ( - current: boolean, - setter: (checked: boolean) => void, - shouldActivate: (next: boolean) => boolean = (next) => next, - ) => { - const next = !current; - setter(next); - if (shouldActivate(next)) { - activateStudentInput3(); - } - }; - const handleToggleAgreeAll = () => { - toggleAgreementAndActivate(form.agreeAll, setAgreeAll); - }; - const handleToggleAgreePrivacy = () => { - toggleAgreementAndActivate(form.agreePrivacy, setAgreePrivacy); - }; - const handleToggleAgreeMarketing = () => { - toggleAgreementAndActivate( - form.agreeMarketing, - setAgreeMarketing, - (next) => next && form.agreePrivacy, - ); - }; + return ( + + + + {flow.showProgress ? ( + + ) : null} - return ( - - {showProgress ? ( - { - if (segmentIndex >= currentProgressIndex) { - return; - } - goTo(SIGNUP_PROGRESS_STEPS[segmentIndex]); - }} - /> - ) : null} + + + + - - setResendSheetVisible(true)} - onSelectRole={setRole} - onSelectSchool={setSchool} - onPressStudentVerify={() => goTo("studentInput2")} - onToggleAgreeAll={handleToggleAgreeAll} - onToggleAgreePrivacy={handleToggleAgreePrivacy} - onToggleAgreeMarketing={handleToggleAgreeMarketing} - /> - + {flow.showBottomButton ? ( + + + + {flow.buttonLabel} + + + + ) : null} - {showBottomButton ? ( - - - router.replace("/(protected)/(student)/(tabs)/home") - : goNext - } - disabled={isBottomDisabled} - > - {buttonLabel} - - - - ) : null} + - setResendSheetVisible(false)} - onAction={() => { - sendVerificationCode(); - setResendSheetVisible(false); - }} - /> - - ); + + + + ); }