@uniai-fe/uds-templates 0.4.33 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +12 -0
  2. package/dist/styles.css +142 -19
  3. package/package.json +4 -4
  4. package/src/auth/signup/hooks/index.ts +3 -0
  5. package/src/auth/signup/hooks/useSignupAgreementForm.ts +48 -0
  6. package/src/auth/signup/hooks/useSignupFarmCodeForm.ts +87 -0
  7. package/src/auth/signup/hooks/useSignupTypeSelectForm.ts +56 -0
  8. package/src/auth/signup/img/select-user-type-default.svg +15 -0
  9. package/src/auth/signup/img/select-user-type-selected.svg +3 -0
  10. package/src/auth/signup/index.ts +10 -0
  11. package/src/auth/signup/markup/AccountForm.tsx +1 -0
  12. package/src/auth/signup/markup/AgreementForm.tsx +229 -0
  13. package/src/auth/signup/markup/FarmCodeForm.tsx +122 -0
  14. package/src/auth/signup/markup/Template.tsx +58 -2
  15. package/src/auth/signup/markup/TypeSelectForm.tsx +102 -0
  16. package/src/auth/signup/markup/UserInfoForm.tsx +9 -0
  17. package/src/auth/signup/markup/VerificationForm.tsx +36 -26
  18. package/src/auth/signup/markup/index.ts +3 -0
  19. package/src/auth/signup/styles/signup.scss +95 -20
  20. package/src/auth/signup/types/hooks.ts +132 -0
  21. package/src/auth/signup/types/props.ts +210 -4
  22. package/src/edge-case/EdgeCase.tsx +16 -0
  23. package/src/edge-case/components/Empty.tsx +42 -0
  24. package/src/edge-case/components/Loading.tsx +42 -0
  25. package/src/edge-case/components/NotFound.tsx +74 -0
  26. package/src/edge-case/components/index.tsx +3 -0
  27. package/src/edge-case/img/404.svg +3 -0
  28. package/src/edge-case/index.scss +1 -0
  29. package/src/edge-case/index.tsx +11 -0
  30. package/src/edge-case/styles/edge-case.scss +56 -0
  31. package/src/edge-case/styles/index.scss +1 -0
  32. package/src/edge-case/types/index.ts +94 -0
  33. package/src/index.scss +1 -0
  34. package/src/index.tsx +1 -0
@@ -0,0 +1,229 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { useMemo, useState } from "react";
5
+ import type { SubmitEvent } from "react";
6
+ import {
7
+ Button,
8
+ CheckboxField,
9
+ DrawerBody,
10
+ DrawerContent,
11
+ DrawerFooter,
12
+ DrawerHeader,
13
+ DrawerOverlay,
14
+ DrawerPortal,
15
+ DrawerRoot,
16
+ DrawerTitle,
17
+ } from "@uniai-fe/uds-primitives";
18
+ import type {
19
+ AuthSignupAgreementOption,
20
+ AuthSignupAgreementProps,
21
+ } from "../types";
22
+ import { useSignupAgreementForm } from "../hooks";
23
+ import CheckAgreeIcon from "../img/check-agree.svg";
24
+ import ChevronOpenDetailIcon from "../img/chevron-open-detail.svg";
25
+
26
+ const AGREEMENT_DETAIL_FALLBACK = (
27
+ <>
28
+ <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
29
+ <p>Nulla facilisi. Integer porta, nisl at volutpat posuere, erat arcu.</p>
30
+ </>
31
+ );
32
+
33
+ /**
34
+ * 회원가입 Step4; 약관 동의 폼
35
+ * @component
36
+ * @param {AuthSignupAgreementProps} props agreement props
37
+ * @param {AuthSignupAgreementOption[]} props.agreements 약관 목록
38
+ * @param {Record<string, boolean>} props.agreementState 약관 체크 상태
39
+ * @param {boolean} [props.isThirdPartyRequired] 제3자 제공 동의 필수 여부
40
+ * @param {string} [props.thirdPartyAgreementId] 제3자 제공 동의 id
41
+ * @param {(agreementId: string) => void} props.onToggleAgreement 단일 약관 토글
42
+ * @param {(options?: AuthSignupAgreementToggleAllOptions) => void} props.onToggleAll 전체 동의 토글
43
+ * @param {(agreementId: string) => void} [props.onOpenAgreementDetail] 약관 상세 열기
44
+ * @param {React.FormHTMLAttributes<HTMLFormElement>} [props.formAttr] form attr
45
+ * @param {import("react").ReactNode} [props.submitLabel] CTA 라벨
46
+ */
47
+ export function AuthSignupAgreementForm({
48
+ agreements,
49
+ agreementState,
50
+ isThirdPartyRequired,
51
+ thirdPartyAgreementId,
52
+ onToggleAgreement,
53
+ onToggleAll,
54
+ onOpenAgreementDetail,
55
+ formAttr,
56
+ submitLabel,
57
+ submitDisabled,
58
+ onSubmit,
59
+ }: AuthSignupAgreementProps) {
60
+ const [openedAgreementId, setOpenedAgreementId] = useState<string | null>(
61
+ null,
62
+ );
63
+
64
+ const { requiredAgreementIds, allRequiredChecked, disabled } =
65
+ useSignupAgreementForm({
66
+ agreements,
67
+ agreementState,
68
+ isThirdPartyRequired,
69
+ thirdPartyAgreementId,
70
+ });
71
+
72
+ const openedAgreement = useMemo(() => {
73
+ if (!openedAgreementId) {
74
+ return null;
75
+ }
76
+
77
+ return agreements.find(option => option.id === openedAgreementId) ?? null;
78
+ }, [agreements, openedAgreementId]);
79
+
80
+ const handleCloseDrawer = () => {
81
+ setOpenedAgreementId(null);
82
+ };
83
+
84
+ const handleOpenAgreementDetail = (agreementId: string) => {
85
+ onOpenAgreementDetail?.(agreementId);
86
+ setOpenedAgreementId(agreementId);
87
+ };
88
+
89
+ // React 19 submit 이벤트 계약과 동일하게 SubmitEvent로 맞춘다.
90
+ const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
91
+ formAttr?.onSubmit?.(event);
92
+
93
+ if (event.defaultPrevented) {
94
+ return;
95
+ }
96
+
97
+ event.preventDefault();
98
+ if (!disabled && !submitDisabled) {
99
+ onSubmit();
100
+ }
101
+ };
102
+
103
+ const renderAgreementLabel = (
104
+ option: AuthSignupAgreementOption,
105
+ required: boolean,
106
+ ) => (
107
+ <span className="auth-signup-agreement-label">
108
+ <span
109
+ className="auth-signup-agreement-badge"
110
+ data-required={required ? "true" : "false"}
111
+ >
112
+ [{required ? "필수" : "선택"}]
113
+ </span>
114
+ <span className="auth-signup-agreement-title">{option.label}</span>
115
+ </span>
116
+ );
117
+
118
+ return (
119
+ <form
120
+ className={clsx("auth-signup-form", "auth-signup-form-agreement")}
121
+ {...formAttr}
122
+ onSubmit={handleSubmit}
123
+ >
124
+ {agreements.length ? (
125
+ <section className="auth-signup-agreements" aria-label="약관 동의">
126
+ <CheckboxField
127
+ size="large"
128
+ checked={allRequiredChecked}
129
+ onCheckedChange={() => onToggleAll({ requiredOnly: true })}
130
+ fieldClassName="auth-signup-agreement-all-field"
131
+ labelWrapperClassName="auth-signup-agreement-all"
132
+ className="auth-signup-agreement-all-checkbox"
133
+ label={
134
+ <span className="auth-signup-agreement-all-label">
135
+ 필수 약관에 모두 동의하기
136
+ </span>
137
+ }
138
+ />
139
+ <div className="auth-signup-agreements-list">
140
+ {agreements.map(option => {
141
+ const checked = Boolean(agreementState[option.id]);
142
+ const required = requiredAgreementIds.includes(option.id);
143
+
144
+ return (
145
+ <div
146
+ className="auth-signup-agreement-row"
147
+ key={option.id}
148
+ data-required={required ? "true" : undefined}
149
+ data-checked={checked ? "true" : undefined}
150
+ >
151
+ {/* 약관 토글은 Figma 아이콘 사양에 맞춰 카드 버튼으로 렌더링한다. */}
152
+ <button
153
+ type="button"
154
+ className="auth-signup-agreement-toggle"
155
+ aria-pressed={checked}
156
+ data-checked={checked ? "true" : undefined}
157
+ onClick={() => onToggleAgreement(option.id)}
158
+ >
159
+ <span
160
+ className="auth-signup-agreement-icon"
161
+ aria-hidden="true"
162
+ >
163
+ <CheckAgreeIcon />
164
+ </span>
165
+ {renderAgreementLabel(option, required)}
166
+ </button>
167
+ <button
168
+ type="button"
169
+ className="auth-signup-agreement-detail"
170
+ aria-label={`${option.label} 약관 상세 보기`}
171
+ onClick={() => handleOpenAgreementDetail(option.id)}
172
+ >
173
+ <span aria-hidden="true">
174
+ <ChevronOpenDetailIcon />
175
+ </span>
176
+ </button>
177
+ </div>
178
+ );
179
+ })}
180
+ </div>
181
+ </section>
182
+ ) : null}
183
+ <Button.Default
184
+ type="submit"
185
+ fill="solid"
186
+ size="xlarge"
187
+ priority="primary"
188
+ block
189
+ disabled={disabled || Boolean(submitDisabled)}
190
+ >
191
+ {submitLabel ?? "다음"}
192
+ </Button.Default>
193
+ <DrawerRoot
194
+ open={Boolean(openedAgreement)}
195
+ onOpenChange={isOpen => {
196
+ if (!isOpen) {
197
+ handleCloseDrawer();
198
+ }
199
+ }}
200
+ >
201
+ <DrawerPortal>
202
+ <DrawerOverlay />
203
+ <DrawerContent>
204
+ <DrawerHeader>
205
+ <DrawerTitle>{openedAgreement?.label ?? "약관 상세"}</DrawerTitle>
206
+ </DrawerHeader>
207
+ <DrawerBody className="auth-signup-agreement-drawer-body">
208
+ {openedAgreement?.description ?? AGREEMENT_DETAIL_FALLBACK}
209
+ </DrawerBody>
210
+ <DrawerFooter>
211
+ <Button.Default
212
+ type="button"
213
+ fill="solid"
214
+ size="medium"
215
+ priority="primary"
216
+ block
217
+ onClick={handleCloseDrawer}
218
+ >
219
+ 닫기
220
+ </Button.Default>
221
+ </DrawerFooter>
222
+ </DrawerContent>
223
+ </DrawerPortal>
224
+ </DrawerRoot>
225
+ </form>
226
+ );
227
+ }
228
+
229
+ export default AuthSignupAgreementForm;
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { useEffect } from "react";
5
+ import { useFormContext } from "react-hook-form";
6
+ import { Button, Form, Input } from "@uniai-fe/uds-primitives";
7
+ import type { AuthSignupFarmCodeProps, AuthSignupFormValues } from "../types";
8
+ import { useSignupFarmCodeForm } from "../hooks";
9
+ import { getSignupFieldDefaultValue } from "../utils/getSignupFieldDefaultValue";
10
+
11
+ /**
12
+ * 회원가입 Step3; 농장 식별번호 입력 폼
13
+ * @component
14
+ * @param {AuthSignupFarmCodeProps} props step props
15
+ * @param {AuthSignupFarmCodeFields} props.fields 필드 설정
16
+ * @param {React.FormHTMLAttributes<HTMLFormElement>} [props.formAttr] form attr
17
+ * @param {import("react").ReactNode} [props.submitLabel] CTA 라벨
18
+ * @param {AuthSignupFarmCodeProps["onSubmit"]} props.onSubmit 제출 핸들러
19
+ */
20
+ export function AuthSignupFarmCodeForm({
21
+ fields,
22
+ formAttr,
23
+ submitLabel,
24
+ onSubmit,
25
+ }: AuthSignupFarmCodeProps) {
26
+ const form = useFormContext<AuthSignupFormValues>();
27
+
28
+ useEffect(() => {
29
+ const config = fields.farmCode;
30
+ const fieldName = config.attr?.name ?? "farmCode";
31
+ const currentValue = form.getValues(
32
+ fieldName as keyof AuthSignupFormValues,
33
+ );
34
+
35
+ if (
36
+ (typeof currentValue !== "string" || currentValue.length === 0) &&
37
+ config.attr?.defaultValue === undefined
38
+ ) {
39
+ form.setValue(
40
+ fieldName as keyof AuthSignupFormValues,
41
+ getSignupFieldDefaultValue(config),
42
+ );
43
+ }
44
+ }, [fields, form]);
45
+
46
+ const {
47
+ register,
48
+ helpers,
49
+ disabled,
50
+ onSubmit: handleSubmit,
51
+ } = useSignupFarmCodeForm({
52
+ fields,
53
+ form,
54
+ onSubmit,
55
+ });
56
+
57
+ return (
58
+ <form
59
+ className={clsx("auth-signup-form", "auth-signup-form-farm-code")}
60
+ {...formAttr}
61
+ onSubmit={handleSubmit}
62
+ >
63
+ <div className="auth-signup-fields">
64
+ <Form.Field.Template
65
+ width={fields.farmCode.template?.width ?? "full"}
66
+ state={helpers.farmCode?.state === "error" ? "error" : undefined}
67
+ className={clsx(
68
+ "auth-signup-field",
69
+ "auth-signup-field-farm-code",
70
+ fields.farmCode.template?.className,
71
+ fields.farmCode.className,
72
+ )}
73
+ headerProps={{
74
+ ...fields.farmCode.template?.headerProps,
75
+ label:
76
+ fields.farmCode.template?.headerProps?.label ??
77
+ fields.farmCode.label ??
78
+ "농장 식별번호",
79
+ }}
80
+ footer={
81
+ fields.farmCode.template?.footer ??
82
+ helpers.farmCode?.text ??
83
+ fields.farmCode.helper
84
+ }
85
+ footerProps={{
86
+ ...fields.farmCode.template?.footerProps,
87
+ ...(helpers.farmCode?.state
88
+ ? { "data-state": helpers.farmCode.state }
89
+ : {}),
90
+ }}
91
+ >
92
+ <Input.Base
93
+ {...(fields.farmCode.props ?? {})}
94
+ placeholder={fields.farmCode.props?.placeholder ?? "6자리 숫자"}
95
+ register={register.farmCode}
96
+ state={helpers.farmCode?.state ?? fields.farmCode.props?.state}
97
+ block={
98
+ fields.farmCode.block ?? fields.farmCode.props?.block ?? true
99
+ }
100
+ inputMode={fields.farmCode.props?.inputMode ?? "numeric"}
101
+ maxLength={fields.farmCode.props?.maxLength ?? 6}
102
+ pattern={fields.farmCode.props?.pattern ?? "[0-9]*"}
103
+ priority={fields.farmCode.props?.priority ?? "secondary"}
104
+ size={fields.farmCode.props?.size ?? "large"}
105
+ />
106
+ </Form.Field.Template>
107
+ </div>
108
+ <Button.Default
109
+ type="submit"
110
+ fill="solid"
111
+ size="xlarge"
112
+ priority="primary"
113
+ block
114
+ disabled={disabled}
115
+ >
116
+ {submitLabel ?? "다음"}
117
+ </Button.Default>
118
+ </form>
119
+ );
120
+ }
121
+
122
+ export default AuthSignupFarmCodeForm;
@@ -3,21 +3,49 @@ import type { ReactNode } from "react";
3
3
  import { AuthContainer } from "../../common/container";
4
4
  import type {
5
5
  AuthSignupStepIndicatorItem,
6
+ AuthSignupTypeValue,
6
7
  AuthSignupTemplateProps,
7
8
  } from "../types";
9
+ import { AuthSignupTypeSelectForm } from "./TypeSelectForm";
8
10
  import { AuthSignupUserInfoForm } from "./UserInfoForm";
11
+ import { AuthSignupFarmCodeForm } from "./FarmCodeForm";
12
+ import { AuthSignupAgreementForm } from "./AgreementForm";
9
13
  import { AuthSignupVerificationForm } from "./VerificationForm";
10
14
  import { AuthSignupAccountForm } from "./AccountForm";
11
15
  import { AuthSignupComplete } from "./Complete";
12
16
  import { AuthStageHeader } from "../../common/container/header";
13
17
 
14
- const DEFAULT_STEPS: AuthSignupStepIndicatorItem[] = [
18
+ const LEGACY_DEFAULT_STEPS: AuthSignupStepIndicatorItem[] = [
15
19
  { id: "userInfo", label: "기본 정보" },
16
20
  { id: "verifyAgreement", label: "약관 · 인증" },
17
21
  { id: "generateAccount", label: "계정 생성" },
18
22
  { id: "complete", label: "완료" },
19
23
  ];
20
24
 
25
+ const NEXT_DEFAULT_STEPS: AuthSignupStepIndicatorItem[] = [
26
+ { id: "typeSelect", label: "가입 유형" },
27
+ { id: "identity", label: "본인 확인" },
28
+ { id: "farmCode", label: "농장 코드" },
29
+ { id: "agreement", label: "약관 동의" },
30
+ { id: "account", label: "계정 생성" },
31
+ { id: "complete", label: "완료" },
32
+ ];
33
+
34
+ const resolveDefaultSteps = (
35
+ isNextFlow: boolean,
36
+ signupType?: AuthSignupTypeValue,
37
+ ) => {
38
+ if (!isNextFlow) {
39
+ return LEGACY_DEFAULT_STEPS;
40
+ }
41
+
42
+ if (signupType === "regionManager") {
43
+ return NEXT_DEFAULT_STEPS.filter(item => item.id !== "farmCode");
44
+ }
45
+
46
+ return NEXT_DEFAULT_STEPS;
47
+ };
48
+
21
49
  /**
22
50
  * 회원가입 템플릿; Step1~4 화면을 AuthContainer 위에서 전환한다.
23
51
  * @component
@@ -30,15 +58,26 @@ const DEFAULT_STEPS: AuthSignupStepIndicatorItem[] = [
30
58
  export function AuthSignupTemplate({
31
59
  className,
32
60
  header,
61
+ signupType,
33
62
  step,
34
63
  stepIndicator,
35
64
  footer,
65
+ typeSelect,
66
+ identity,
67
+ farmCode,
68
+ agreement,
36
69
  userInfo,
37
70
  verification,
38
71
  account,
39
72
  complete,
40
73
  }: AuthSignupTemplateProps) {
41
- const steps = stepIndicator?.items ?? DEFAULT_STEPS;
74
+ const isNextFlow =
75
+ Boolean(typeSelect || identity || farmCode || agreement) ||
76
+ ["typeSelect", "identity", "farmCode", "agreement", "account"].includes(
77
+ step,
78
+ );
79
+ const steps =
80
+ stepIndicator?.items ?? resolveDefaultSteps(isNextFlow, signupType);
42
81
  const currentStep = stepIndicator?.current ?? step;
43
82
  const defaultStepIndex = Math.max(
44
83
  0,
@@ -72,12 +111,29 @@ export function AuthSignupTemplate({
72
111
 
73
112
  let content: ReactNode = null;
74
113
  switch (currentStep) {
114
+ case "typeSelect":
115
+ content = typeSelect ? (
116
+ <AuthSignupTypeSelectForm {...typeSelect} />
117
+ ) : null;
118
+ break;
119
+ case "identity":
120
+ content = <AuthSignupUserInfoForm {...(identity ?? userInfo)} />;
121
+ break;
122
+ case "farmCode":
123
+ content = farmCode ? <AuthSignupFarmCodeForm {...farmCode} /> : null;
124
+ break;
125
+ case "agreement":
126
+ content = agreement ? <AuthSignupAgreementForm {...agreement} /> : null;
127
+ break;
75
128
  case "userInfo":
76
129
  content = <AuthSignupUserInfoForm {...userInfo} />;
77
130
  break;
78
131
  case "verifyAgreement":
79
132
  content = <AuthSignupVerificationForm {...verification} />;
80
133
  break;
134
+ case "account":
135
+ content = <AuthSignupAccountForm {...account} />;
136
+ break;
81
137
  case "generateAccount":
82
138
  content = <AuthSignupAccountForm {...account} />;
83
139
  break;
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { useFormContext } from "react-hook-form";
5
+ import { Button } from "@uniai-fe/uds-primitives";
6
+ import type { AuthSignupFormValues, AuthSignupTypeSelectProps } from "../types";
7
+ import { useSignupTypeSelectForm } from "../hooks";
8
+ import CheckIcon from "../img/select-user-type-default.svg";
9
+
10
+ /**
11
+ * 회원가입 Step1; 가입 유형 선택 폼
12
+ * @component
13
+ * @param {AuthSignupTypeSelectProps} props step props
14
+ * @param {AuthSignupTypeSelectOption[]} props.options 선택 옵션 목록
15
+ * @param {React.FormHTMLAttributes<HTMLFormElement>} [props.formAttr] form attr
16
+ * @param {import("react").ReactNode} [props.submitLabel] CTA 라벨
17
+ * @param {AuthSignupTypeSelectProps["onSubmit"]} props.onSubmit 제출 핸들러
18
+ */
19
+ export function AuthSignupTypeSelectForm({
20
+ options,
21
+ formAttr,
22
+ submitLabel,
23
+ onSubmit,
24
+ }: AuthSignupTypeSelectProps) {
25
+ const form = useFormContext<AuthSignupFormValues>();
26
+ const {
27
+ selectedType,
28
+ onValueChange,
29
+ disabled,
30
+ onSubmit: handleSubmit,
31
+ } = useSignupTypeSelectForm({
32
+ form,
33
+ onSubmit,
34
+ });
35
+
36
+ return (
37
+ <form
38
+ className={clsx("auth-signup-form", "auth-signup-form-type-select")}
39
+ {...formAttr}
40
+ onSubmit={handleSubmit}
41
+ >
42
+ <div className="auth-signup-type-options" role="radiogroup">
43
+ {options.map(option => {
44
+ const isSelected = selectedType === option.id;
45
+
46
+ return (
47
+ <div className="auth-signup-type-option-group" key={option.id}>
48
+ <p className="auth-signup-type-question">{option.question}</p>
49
+ <Button.Default
50
+ className="auth-signup-type-option"
51
+ role="radio"
52
+ aria-checked={isSelected}
53
+ data-selected={isSelected ? "true" : "false"}
54
+ data-user-action={isSelected ? "hover" : undefined}
55
+ disabled={option.disabled}
56
+ fill="outlined"
57
+ size="xlarge"
58
+ priority={isSelected ? "primary" : "tertiary"}
59
+ block
60
+ left={
61
+ // 단일 svg asset을 currentColor 기반으로 재사용해 선택 상태를 버튼 foreground와 동기화한다.
62
+ <CheckIcon
63
+ width={24}
64
+ height={24}
65
+ viewBox="0 0 24 24"
66
+ aria-hidden="true"
67
+ />
68
+ }
69
+ onClick={() => {
70
+ if (!option.disabled) {
71
+ onValueChange(option.id);
72
+ }
73
+ }}
74
+ >
75
+ <span className="auth-signup-type-option-label">
76
+ {option.label}
77
+ </span>
78
+ </Button.Default>
79
+ {option.description ? (
80
+ <p className="auth-signup-type-description">
81
+ {option.description}
82
+ </p>
83
+ ) : null}
84
+ </div>
85
+ );
86
+ })}
87
+ </div>
88
+ <Button.Default
89
+ type="submit"
90
+ fill="solid"
91
+ size="xlarge"
92
+ priority="primary"
93
+ block
94
+ disabled={disabled}
95
+ >
96
+ {submitLabel ?? "다음"}
97
+ </Button.Default>
98
+ </form>
99
+ );
100
+ }
101
+
102
+ export default AuthSignupTypeSelectForm;
@@ -71,6 +71,11 @@ export function AuthSignupUserInfoForm({
71
71
  {renderedFields.includes("name") ? (
72
72
  <Form.Field.Template
73
73
  width={fields.name.template?.width ?? "full"}
74
+ state={
75
+ (helpers.name?.state ?? fields.name.props?.state) === "error"
76
+ ? "error"
77
+ : undefined
78
+ }
74
79
  className={clsx(
75
80
  "auth-signup-field",
76
81
  "auth-signup-field-name",
@@ -109,6 +114,10 @@ export function AuthSignupUserInfoForm({
109
114
  <AuthCodePhoneTemplate
110
115
  templateProps={{
111
116
  width: fields.phone.template?.width ?? "full",
117
+ state:
118
+ (helpers.phone?.state ?? fields.phone.props?.state) === "error"
119
+ ? "error"
120
+ : undefined,
112
121
  className: clsx(
113
122
  "auth-signup-field",
114
123
  "auth-signup-field-phone",
@@ -53,6 +53,13 @@ const resolveCodeValue = (inputProps?: AuthCodeInputProps): string => {
53
53
  return "";
54
54
  };
55
55
 
56
+ const AGREEMENT_DETAIL_FALLBACK = (
57
+ <>
58
+ <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
59
+ <p>Nulla facilisi. Integer porta, nisl at volutpat posuere, erat arcu.</p>
60
+ </>
61
+ );
62
+
56
63
  /**
57
64
  * 회원가입 Step2; 약관 동의 + 이메일 인증
58
65
  * @component
@@ -145,6 +152,7 @@ export function AuthSignupVerificationForm({
145
152
  const emailFooterProps = fields.email.template?.footerProps ?? {};
146
153
  const emailTemplateProps: AuthFormFieldTemplateProps = {
147
154
  width: fields.email.template?.width ?? "full",
155
+ state: helpers.email?.state === "error" ? "error" : undefined,
148
156
  className: clsx(
149
157
  "auth-signup-field",
150
158
  "auth-signup-field-email",
@@ -301,14 +309,20 @@ export function AuthSignupVerificationForm({
301
309
  </div>
302
310
  {agreements.length ? (
303
311
  <section className="auth-signup-agreements" aria-label="약관 동의">
304
- <div className="auth-signup-agreement-all">
305
- <CheckboxField
306
- size="large"
307
- label={<span>필수 약관에 모두 동의하기</span>}
308
- checked={allRequiredChecked}
309
- onCheckedChange={() => onToggleAll({ requiredOnly: true })}
310
- />
311
- </div>
312
+ {/* all-agree는 checkbox primitive를 유지하고 step3 카드 스타일만 signup에서 덮어쓴다. */}
313
+ <CheckboxField
314
+ size="large"
315
+ checked={allRequiredChecked}
316
+ onCheckedChange={() => onToggleAll({ requiredOnly: true })}
317
+ fieldClassName="auth-signup-agreement-all-field"
318
+ labelWrapperClassName="auth-signup-agreement-all"
319
+ className="auth-signup-agreement-all-checkbox"
320
+ label={
321
+ <span className="auth-signup-agreement-all-label">
322
+ 필수 약관에 모두 동의하기
323
+ </span>
324
+ }
325
+ />
312
326
  <div className="auth-signup-agreements-list">
313
327
  {agreements.map(option => {
314
328
  const checked = Boolean(agreementState[option.id]);
@@ -320,7 +334,7 @@ export function AuthSignupVerificationForm({
320
334
  data-required={option.required ? "true" : undefined}
321
335
  data-checked={checked ? "true" : undefined}
322
336
  >
323
- {/* 약관 토글은 Figma 아이콘 사양대로 커스텀 버튼으로 구성한다. */}
337
+ {/* 하위 약관 row는 primitive checkbox가 아니라 커스텀 check icon을 유지한다. */}
324
338
  <button
325
339
  type="button"
326
340
  className="auth-signup-agreement-toggle"
@@ -336,18 +350,16 @@ export function AuthSignupVerificationForm({
336
350
  </span>
337
351
  {renderAgreementLabel(option)}
338
352
  </button>
339
- {onOpenAgreementDetail ? (
340
- <button
341
- type="button"
342
- className="auth-signup-agreement-detail"
343
- aria-label={`${option.label} 약관 상세 보기`}
344
- onClick={() => handleOpenAgreementDetail(option.id)}
345
- >
346
- <span aria-hidden="true">
347
- <ChevronOpenDetailIcon />
348
- </span>
349
- </button>
350
- ) : null}
353
+ <button
354
+ type="button"
355
+ className="auth-signup-agreement-detail"
356
+ aria-label={`${option.label} 약관 상세 보기`}
357
+ onClick={() => handleOpenAgreementDetail(option.id)}
358
+ >
359
+ <span aria-hidden="true">
360
+ <ChevronOpenDetailIcon />
361
+ </span>
362
+ </button>
351
363
  </div>
352
364
  );
353
365
  })}
@@ -378,11 +390,9 @@ export function AuthSignupVerificationForm({
378
390
  <DrawerHeader>
379
391
  <DrawerTitle>{openedAgreement?.label ?? "약관 상세"}</DrawerTitle>
380
392
  </DrawerHeader>
381
- {openedAgreement?.description ? (
382
- <DrawerBody className="auth-signup-agreement-drawer-body">
383
- {openedAgreement.description}
384
- </DrawerBody>
385
- ) : null}
393
+ <DrawerBody className="auth-signup-agreement-drawer-body">
394
+ {openedAgreement?.description ?? AGREEMENT_DETAIL_FALLBACK}
395
+ </DrawerBody>
386
396
  <DrawerFooter>
387
397
  <Button.Default
388
398
  type="button"