diff --git a/app/(protected)/onboardingadd/page.tsx b/app/(protected)/onboardingadd/page.tsx new file mode 100644 index 00000000..1de8cb3a --- /dev/null +++ b/app/(protected)/onboardingadd/page.tsx @@ -0,0 +1,5 @@ +import { OnboardingAddControl } from "@/src/features/onboarding-add"; + +export default function Page() { + return ; +} diff --git a/src/features/onboarding-add/index.ts b/src/features/onboarding-add/index.ts new file mode 100644 index 00000000..2878c029 --- /dev/null +++ b/src/features/onboarding-add/index.ts @@ -0,0 +1,14 @@ +export { default as OnboardingAddControl } from "@/src/features/onboarding-add/ui/onboarding-add-control"; + +export type { + OnboardingAddQuestion, + OnboardingAddAnswers, +} from "@/src/features/onboarding-add/model/onboarding-add.types"; + +export { default as OnboardingAddShell } from "@/src/features/onboarding-add/ui/onboarding-add-shell"; +export { default as OnboardingAddRenderer } from "@/src/features/onboarding-add/ui/onboarding-add-renderer"; + +export { default as BooleanStep } from "@/src/features/onboarding-add/ui/steps/step-boolean"; +export { default as InputStep } from "@/src/features/onboarding-add/ui/steps/step-input"; +export { default as DateStep } from "@/src/features/onboarding-add/ui/steps/step-date"; +export { default as SelectStep } from "@/src/features/onboarding-add/ui/steps/step-select"; diff --git a/src/features/onboarding-add/model/onboarding-add.types.ts b/src/features/onboarding-add/model/onboarding-add.types.ts new file mode 100644 index 00000000..065922ca --- /dev/null +++ b/src/features/onboarding-add/model/onboarding-add.types.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const OnboardingAddQuestionSchema = z.discriminatedUnion("type", [ + z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + info: z.string().nullable().optional(), + required: z.boolean().optional(), + + type: z.literal("BOOLEAN"), + type_data: z + .object({ + trueLabel: z.string().optional(), + falseLabel: z.string().optional(), + }) + .optional(), + }), + + z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + info: z.string().nullable().optional(), + required: z.boolean().optional(), + + type: z.literal("INPUT"), + type_data: z.object({ + inputType: z.enum(["text", "number", "date"]), + placeholder: z.string().optional(), + }), + }), + + z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + info: z.string().nullable().optional(), + required: z.boolean().optional(), + + type: z.literal("SELECT"), + type_data: z.object({ + options: z.array( + z.object({ + label: z.string(), + value: z.string(), + }), + ), + }), + }), +]); + +export type OnboardingAddQuestion = z.infer; + +export type OnboardingAddAnswers = Record; diff --git a/src/features/onboarding-add/ui/onboarding-add-control.tsx b/src/features/onboarding-add/ui/onboarding-add-control.tsx new file mode 100644 index 00000000..b89475b4 --- /dev/null +++ b/src/features/onboarding-add/ui/onboarding-add-control.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +import Checkbox from "@/src/shared/ui/checkbox"; + +import type { + OnboardingAddAnswers, + OnboardingAddQuestion, +} from "@/src/features/onboarding-add"; + +import { + OnboardingAddShell, + OnboardingAddRenderer, +} from "@/src/features/onboarding-add"; + +// 임시 +const MOCK_QUESTIONS: OnboardingAddQuestion[] = [ + { + id: "hasSubscriptionAccount", + title: "청약통장이 있나요?", + description: "지원 자격 확인을 위해 필요합니다.", + type: "BOOLEAN", + required: true, + type_data: { + trueLabel: "있어요", + falseLabel: "없어요", + }, + }, + { + id: "subscriptionOpenedAt", + title: "청약통장 개설일자를 입력해주세요", + description: "가입기간 계산에 사용됩니다.", + type: "INPUT", + required: true, + type_data: { + inputType: "date", + placeholder: "YYYY-MM-DD", + }, + }, + { + id: "subscriptionPaymentCount", + title: "청약통장 납입 횟수를 알려주세요", + description: "납입 횟수 요건 확인에 사용됩니다.", + type: "INPUT", + required: true, + type_data: { + inputType: "number", + placeholder: "예) 24", + }, + }, +]; + +const STEP_KEY = "addStep"; +const MIN_STEP = 0; + +function clampStep(value: number, maxStep: number) { + if (Number.isNaN(value)) return MIN_STEP; + if (value < MIN_STEP) return MIN_STEP; + if (value > maxStep) return maxStep; + return value; +} + +function isEmptyAnswer(value: OnboardingAddAnswers[string] | undefined) { + if (value === undefined) return true; + if (typeof value === "string") return value.trim().length === 0; + return false; +} + +function unknownKey(id: string) { + return `unknown_${id}`; +} + +function isUnknown(sp: URLSearchParams, id: string) { + return sp.get(unknownKey(id)) === "1"; +} + +function setUnknown(sp: URLSearchParams, id: string, value: boolean) { + if (value) sp.set(unknownKey(id), "1"); + else sp.delete(unknownKey(id)); +} + +function queryToAnswer( + question: OnboardingAddQuestion, + sp: URLSearchParams, +): boolean | string | number | undefined { + const raw = sp.get(question.id); + if (raw === null) return undefined; + + if (question.type === "BOOLEAN") { + if (raw === "true") return true; + if (raw === "false") return false; + return undefined; + } + + if (question.type === "INPUT") { + if (question.type_data.inputType === "number") { + const n = Number(raw); + return Number.isNaN(n) ? undefined : n; + } + return raw; + } + + if (question.type === "SELECT") return raw; + + return undefined; +} + +function setOrDelete(sp: URLSearchParams, key: string, value: unknown) { + if (value === undefined || value === null) return sp.delete(key); + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length ? sp.set(key, trimmed) : sp.delete(key); + } + + sp.set(key, String(value)); +} + +export default function OnboardingAddControl() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const questions = MOCK_QUESTIONS; + + const totalStepCount = questions.length; + const maxStep = Math.max(0, totalStepCount - 1); + + const urlParams = useMemo( + () => new URLSearchParams(searchParams.toString()), + [searchParams], + ); + + const currentStepIndex = useMemo(() => { + return clampStep(Number(searchParams.get(STEP_KEY) ?? 0), maxStep); + }, [searchParams, maxStep]); + + const currentQuestion = questions[currentStepIndex]; + + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === maxStep; + + const primaryButtonLabel = isLastStep ? "완료" : "다음"; + + const unknownChecked = useMemo(() => { + if (!currentQuestion) return false; + return isUnknown(urlParams, currentQuestion.id); + }, [urlParams, currentQuestion]); + + const currentValue = useMemo(() => { + if (!currentQuestion) return undefined; + return queryToAnswer(currentQuestion, urlParams); + }, [currentQuestion, urlParams]); + + const canProceed = useMemo(() => { + if (!currentQuestion) return false; + + if (unknownChecked) return true; + + if (currentQuestion.required && isEmptyAnswer(currentValue)) return false; + + if ( + currentQuestion.type === "INPUT" && + currentQuestion.type_data.inputType === "date" + ) { + const v = typeof currentValue === "string" ? currentValue : ""; + return /^\d{4}-\d{2}-\d{2}$/.test(v); + } + + return true; + }, [unknownChecked, currentQuestion, currentValue]); + + const replaceQuery = (next: URLSearchParams) => { + router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + }; + + const replaceStep = (nextStep: number) => { + const next = new URLSearchParams(searchParams.toString()); + next.set(STEP_KEY, String(clampStep(nextStep, maxStep))); + replaceQuery(next); + }; + + const handleBack = () => { + if (!currentQuestion) return; + if (!isFirstStep) replaceStep(currentStepIndex - 1); + }; + + const handlePrimary = () => { + if (!currentQuestion) return; + if (!canProceed) return; + + if (isLastStep) { + const sp = new URLSearchParams(searchParams.toString()); + const answers: OnboardingAddAnswers = {}; + + for (const q of questions) { + if (isUnknown(sp, q.id)) continue; + + const v = queryToAnswer(q, sp); + if (v !== undefined) answers[q.id] = v; + } + + console.log("[onboarding-add] submit:", answers); + return; + } + + replaceStep(currentStepIndex + 1); + }; + + const handleUnknownToggle = (checked: boolean) => { + if (!currentQuestion) return; + + const next = new URLSearchParams(searchParams.toString()); + + if (checked) next.delete(currentQuestion.id); + + setUnknown(next, currentQuestion.id, checked); + next.set(STEP_KEY, String(currentStepIndex)); + + replaceQuery(next); + }; + + const handleChange = (value: boolean | string | number) => { + if (!currentQuestion) return; + + const next = new URLSearchParams(searchParams.toString()); + + next.delete(unknownKey(currentQuestion.id)); + + if (currentQuestion.type === "BOOLEAN") { + setOrDelete(next, currentQuestion.id, value === true ? "true" : "false"); + } else { + setOrDelete(next, currentQuestion.id, value); + } + + next.set(STEP_KEY, String(currentStepIndex)); + replaceQuery(next); + }; + + if (!totalStepCount) { + return ( +
+

추가 온보딩 질문이 없습니다.

+
+ ); + } + + if (!currentQuestion) { + return ( +
+

+ 추가 온보딩 질문을 불러오지 못했습니다. +

+
+ ); + } + + return ( + +
+ handleUnknownToggle(e.target.checked)} + className="border-slate-300 text-slate-900 focus:ring-slate-900" + /> +
+ +
+ +
+
+ ); +} diff --git a/src/features/onboarding-add/ui/onboarding-add-renderer.tsx b/src/features/onboarding-add/ui/onboarding-add-renderer.tsx new file mode 100644 index 00000000..2e8b0659 --- /dev/null +++ b/src/features/onboarding-add/ui/onboarding-add-renderer.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { OnboardingAddQuestion } from "@/src/features/onboarding-add"; + +import { + BooleanStep, + InputStep, + DateStep, + SelectStep, +} from "@/src/features/onboarding-add"; + +type OnboardingAddRendererProps = { + question: OnboardingAddQuestion; + value: boolean | string | number | undefined; + onChange: (value: boolean | string | number) => void; +}; + +export default function OnboardingAddRenderer({ + question, + value, + onChange, +}: OnboardingAddRendererProps) { + if (question.type === "BOOLEAN") { + return ( + + ); + } + + if (question.type === "INPUT") { + if (question.type_data.inputType === "date") { + return ( + onChange(v)} + /> + ); + } + + return ( + + ); + } + + if (question.type === "SELECT") { + return ( + + ); + } + + return null; +} diff --git a/src/features/onboarding-add/ui/onboarding-add-shell.tsx b/src/features/onboarding-add/ui/onboarding-add-shell.tsx new file mode 100644 index 00000000..df26c9ee --- /dev/null +++ b/src/features/onboarding-add/ui/onboarding-add-shell.tsx @@ -0,0 +1,113 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ChevronLeft } from "lucide-react"; + +import { Progress } from "@/src/shared/ui/progress"; +import Button from "@/src/shared/ui/button"; + +type OnboardingAddShellProps = { + children: ReactNode; + + currentStepIndex: number; + totalStepCount: number; + + title?: ReactNode; + description?: ReactNode; + + headerRight?: ReactNode; + + onBack: () => void; + backDisabled?: boolean; + + primaryButtonLabel: string; + onPrimary: () => void; + primaryDisabled?: boolean; +}; + +export default function OnboardingAddShell({ + children, + currentStepIndex, + totalStepCount, + title, + description, + headerRight, + onBack, + backDisabled = false, + primaryButtonLabel, + onPrimary, + primaryDisabled = false, +}: OnboardingAddShellProps) { + const progressValue = + totalStepCount <= 1 + ? 0 + : Math.round((currentStepIndex / (totalStepCount - 1)) * 100); + + return ( +
+
{ + e.preventDefault(); + onPrimary(); + }} + className="min-h-dvh" + > +
+ + +
+ +

+ {currentStepIndex + 1} / {totalStepCount} +

+
+
+ +
+ {title || description || headerRight ? ( +
+
+ {title ? ( +

{title}

+ ) : null} + {description ? ( +

{description}

+ ) : null} +
+ + {headerRight ? ( +
{headerRight}
+ ) : null} +
+ ) : null} + +
+ {children} +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/src/features/onboarding-add/ui/steps/step-boolean.tsx b/src/features/onboarding-add/ui/steps/step-boolean.tsx new file mode 100644 index 00000000..a4c91705 --- /dev/null +++ b/src/features/onboarding-add/ui/steps/step-boolean.tsx @@ -0,0 +1,50 @@ +"use client"; + +import Button from "@/src/shared/ui/button"; +import cn from "@/src/shared/lib/cn"; + +type BooleanStepProps = { + value?: boolean; + trueLabel?: string; + falseLabel?: string; + onChange: (value: boolean) => void; +}; + +export default function BooleanStep({ + value, + trueLabel = "예", + falseLabel = "아니오", + onChange, +}: BooleanStepProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/features/onboarding-add/ui/steps/step-date.tsx b/src/features/onboarding-add/ui/steps/step-date.tsx new file mode 100644 index 00000000..923b9e37 --- /dev/null +++ b/src/features/onboarding-add/ui/steps/step-date.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useMemo } from "react"; + +import Input from "@/src/shared/ui/input"; +import Label from "@/src/shared/ui/label"; + +type DateStepProps = { + value?: string; // "YYYY-MM-DD" + onChange: (value: string) => void; +}; + +const onlyDigits = (v: string) => v.replace(/\D/g, ""); +const clamp = (v: string, max: number) => v.slice(0, max); + +function splitDate(value?: string) { + if (!value) return { y: "", m: "", d: "" }; + const [y = "", m = "", d = ""] = value.split("-"); + return { y, m, d }; +} + +export default function DateStep({ value, onChange }: DateStepProps) { + const parts = useMemo(() => splitDate(value), [value]); + + const commit = (y: string, m: string, d: string) => { + const next = [y, m, d].filter((x) => x.length > 0).join("-"); + onChange(next); + }; + + return ( +
+
+ + { + const y = clamp(onlyDigits(e.target.value), 4); + commit(y, parts.m, parts.d); + }} + className="h-12 w-[88px] rounded-xl border border-slate-200 bg-transparent px-3 text-center text-black placeholder:text-slate-400" + /> + +
+ +
+ + { + const m = clamp(onlyDigits(e.target.value), 2); + commit(parts.y, m, parts.d); + }} + className="h-12 w-[64px] rounded-xl border border-slate-200 bg-transparent px-3 text-center text-black placeholder:text-slate-400" + /> + +
+ +
+ + { + const d = clamp(onlyDigits(e.target.value), 2); + commit(parts.y, parts.m, d); + }} + className="h-12 w-[64px] rounded-xl border border-slate-200 bg-transparent px-3 text-center text-black placeholder:text-slate-400" + /> + +
+
+ ); +} diff --git a/src/features/onboarding-add/ui/steps/step-input.tsx b/src/features/onboarding-add/ui/steps/step-input.tsx new file mode 100644 index 00000000..80b78037 --- /dev/null +++ b/src/features/onboarding-add/ui/steps/step-input.tsx @@ -0,0 +1,30 @@ +"use client"; + +import Input from "@/src/shared/ui/input"; + +type InputStepProps = { + value?: string | number; + inputType: "text" | "number" | "date"; + placeholder?: string; + onChange: (value: string | number) => void; +}; + +export default function InputStep({ + value, + inputType, + placeholder, + onChange, +}: InputStepProps) { + return ( + { + if (inputType !== "number") return onChange(e.target.value); + return onChange(e.target.value === "" ? "" : Number(e.target.value)); + }} + /> + ); +} diff --git a/src/features/onboarding-add/ui/steps/step-select.tsx b/src/features/onboarding-add/ui/steps/step-select.tsx new file mode 100644 index 00000000..ed363a8a --- /dev/null +++ b/src/features/onboarding-add/ui/steps/step-select.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Button from "@/src/shared/ui/button"; +import cn from "@/src/shared/lib/cn"; + +type SelectOption = { + label: string; + value: string; +}; + +type SelectStepProps = { + value?: string; + options: SelectOption[]; + onChange: (value: string) => void; +}; + +export default function SelectStep({ + value, + options, + onChange, +}: SelectStepProps) { + return ( +
+ {options.map((option) => { + const isSelected = option.value === value; + + return ( + + ); + })} +
+ ); +}