@uniai-fe/uds-templates 0.5.15 → 0.5.17

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.
package/README.md CHANGED
@@ -156,6 +156,7 @@ Next.js 서비스에서 primitives와 동일한 방식으로 **Raw TypeScript**
156
156
  5. 비로그인 기본 버튼은 `ServiceInquiry.OpenButton`, 로그인 후 page-frame 진입 버튼은 `ServiceInquiry.NavButton`, 그 외 커스텀 버튼은 `ServiceInquiry.useOpen`으로 모달 open을 연결한다.
157
157
  6. modal footer confirm이 `ServiceInquiry.Form` submit 진입을 담당한다.
158
158
  7. 실제 submit은 서비스 앱의 `useMutation + Next.js route handler + Modal.Alert` 조합으로 처리한다.
159
+ 8. 표시 문구는 field별 `label`/`placeholder`/`helper`/`requiredMessage`, `inquiryTypeField.options`, `dialogOptions`, `NavButton.label`, `OpenButton.ariaLabel`로 필요한 항목만 주입한다.
159
160
 
160
161
  `service-inquiry`는 구조와 request context까지만 제공하고, 네트워크 상태/재시도/성공·실패 피드백은 서비스 앱이 소유합니다.
161
162
 
package/dist/styles.css CHANGED
@@ -1962,6 +1962,9 @@
1962
1962
  font-weight: var(--service-inquiry-type-font-weight-default);
1963
1963
  line-height: 1.4;
1964
1964
  }
1965
+ .service-inquiry-type-option :where(.chip-label) {
1966
+ line-height: 1.4;
1967
+ }
1965
1968
 
1966
1969
  .service-inquiry-type-option:where([data-selected=true]) {
1967
1970
  background-color: var(--service-inquiry-type-bg-selected);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -13,6 +13,7 @@ import type { AuthLoginProps } from "../types";
13
13
  * @property {React.ReactNode} [props.footer] 하단 슬롯
14
14
  * @property {AuthLoginFieldOptions} props.fieldOptions 입력 필드 옵션
15
15
  * @property {AuthLoginLinkOptions} [props.linkOptions] 계정 찾기/회원가입 링크
16
+ * @property {AuthLoginTexts} [props.texts] 로그인 템플릿 기본 문구 옵션
16
17
  * @property {() => void} [props.onFindPasswordLinkClick] 비밀번호 찾기 버튼 클릭 핸들러
17
18
  */
18
19
  export default function AuthLoginContainer({
@@ -21,6 +22,7 @@ export default function AuthLoginContainer({
21
22
  footer,
22
23
  linkOptions,
23
24
  fieldOptions,
25
+ texts,
24
26
  onFindPasswordLinkClick,
25
27
  }: AuthLoginProps) {
26
28
  return (
@@ -29,10 +31,14 @@ export default function AuthLoginContainer({
29
31
  header={header}
30
32
  footer={footer}
31
33
  >
32
- <AuthLoginFormField {...fieldOptions} />
34
+ <AuthLoginFormField
35
+ {...fieldOptions}
36
+ texts={{ ...texts, ...fieldOptions.texts }}
37
+ />
33
38
  {/* 링크 옵션이 부분적으로만 넘어와도 내부에서 존재하는 항목만 렌더링한다. */}
34
39
  <AuthLoginLinkButtons
35
40
  linkOptions={linkOptions}
41
+ texts={texts}
36
42
  onFindPasswordClick={onFindPasswordLinkClick}
37
43
  />
38
44
  </AuthContainer>
@@ -14,12 +14,14 @@ import AuthLoginFormFieldPassword from "./Password";
14
14
  * @param {AuthLoginFieldOptions} props
15
15
  * @property {AuthLoginIdFieldOptions} [props.idField] 아이디 필드 옵션
16
16
  * @property {AuthLoginPasswordFieldOptions} [props.passwordField] 비밀번호 필드 옵션
17
+ * @property {AuthLoginFieldTexts} [props.texts] 로그인 form 기본 문구 옵션
17
18
  * @property {React.FormHTMLAttributes<HTMLFormElement>} [props.formAttr] <form> attr
18
19
  * @property {SubmitHandler<AuthLoginFormValues>} props.onLogin 로그인 콜백
19
20
  */
20
21
  export default function AuthLoginFormField({
21
22
  idField,
22
23
  passwordField,
24
+ texts,
23
25
  formAttr,
24
26
  onLogin,
25
27
  }: AuthLoginFieldOptions) {
@@ -73,6 +75,10 @@ export default function AuthLoginFormField({
73
75
  ? { text: passwordHelperNode, state: helpers.password.state }
74
76
  : undefined;
75
77
 
78
+ const idLabel = idField?.label ?? texts?.idLabel ?? "아이디";
79
+ const passwordLabel =
80
+ passwordField?.label ?? texts?.passwordLabel ?? "비밀번호";
81
+
76
82
  return (
77
83
  <form className="auth-login-form" {...formAttr} onSubmit={onSubmit}>
78
84
  <div className="auth-login-fields">
@@ -80,10 +86,13 @@ export default function AuthLoginFormField({
80
86
  <AuthLoginFormFieldId
81
87
  register={register.id}
82
88
  helper={idHelper}
83
- label={idField?.label ?? "아이디"}
89
+ label={idLabel}
84
90
  placeholder={
85
91
  idField?.placeholder ??
86
- `${idField?.label ?? "아이디"}를 입력해 주세요`
92
+ texts?.idPlaceholder ??
93
+ (typeof idLabel === "string" || typeof idLabel === "number"
94
+ ? `${idLabel}를 입력해 주세요`
95
+ : "아이디를 입력해 주세요")
87
96
  }
88
97
  inputProps={idField?.inputProps}
89
98
  templateProps={idField?.templateProps}
@@ -92,8 +101,12 @@ export default function AuthLoginFormField({
92
101
  <AuthLoginFormFieldPassword
93
102
  register={register.password}
94
103
  helper={passwordHelper}
95
- label={passwordField?.label ?? "비밀번호"}
96
- placeholder={passwordField?.placeholder ?? "비밀번호를 입력해 주세요"}
104
+ label={passwordLabel}
105
+ placeholder={
106
+ passwordField?.placeholder ??
107
+ texts?.passwordPlaceholder ??
108
+ "비밀번호를 입력해 주세요"
109
+ }
97
110
  inputProps={passwordField?.inputProps}
98
111
  templateProps={passwordField?.templateProps}
99
112
  />
@@ -105,7 +118,7 @@ export default function AuthLoginFormField({
105
118
  block
106
119
  disabled={disabled}
107
120
  >
108
- 로그인
121
+ {texts?.submit ?? "로그인"}
109
122
  </Button.Default>
110
123
  </div>
111
124
  </form>
@@ -1,21 +1,20 @@
1
1
  import Link from "next/link";
2
2
  import { Button, Divider } from "@uniai-fe/uds-primitives";
3
- import type { AuthLoginLinkOptions } from "../types";
3
+ import type { AuthLoginLinkButtonsProps } from "../types";
4
4
 
5
5
  /**
6
6
  * 로그인 링크 버튼 묶음; 전달된 링크만 선택적으로 렌더링한다.
7
7
  * @component
8
- * @param {{ linkOptions?: AuthLoginLinkOptions; onFindPasswordClick?: () => void; }} props
9
- * @param {AuthLoginLinkOptions} [props.linkOptions] 로그인 링크 옵션
8
+ * @param {AuthLoginLinkButtonsProps} props
9
+ * @param {AuthLoginLinkOptions} [props.linkOptions] 계정 관련 링크 옵션
10
+ * @param {AuthLoginLinkTexts} [props.texts] 링크/버튼 문구 옵션
10
11
  * @param {() => void} [props.onFindPasswordClick] 비밀번호 찾기 커스텀 클릭 핸들러
11
12
  */
12
13
  export default function AuthLoginLinkButtons({
13
14
  linkOptions,
15
+ texts,
14
16
  onFindPasswordClick,
15
- }: {
16
- linkOptions?: AuthLoginLinkOptions;
17
- onFindPasswordClick?: () => void;
18
- }) {
17
+ }: AuthLoginLinkButtonsProps) {
19
18
  const hrefFindId = linkOptions?.find?.id;
20
19
  const hrefFindPassword = linkOptions?.find?.password;
21
20
  const hrefSignup = linkOptions?.signup;
@@ -33,7 +32,7 @@ export default function AuthLoginLinkButtons({
33
32
  href={hrefFindId}
34
33
  className="auth-login-find-account-button auth-find-id-button"
35
34
  >
36
- <span>아이디 찾기</span>
35
+ <span>{texts?.findId ?? "아이디 찾기"}</span>
37
36
  </Link>
38
37
  )}
39
38
  {hrefFindId && hrefFindPassword && <Divider />}
@@ -45,14 +44,14 @@ export default function AuthLoginLinkButtons({
45
44
  className="auth-login-find-account-button auth-find-password-button"
46
45
  onClick={onFindPasswordClick}
47
46
  >
48
- <span>비밀번호 찾기</span>
47
+ <span>{texts?.findPassword ?? "비밀번호 찾기"}</span>
49
48
  </button>
50
49
  ) : (
51
50
  <Link
52
51
  href={hrefFindPassword}
53
52
  className="auth-login-find-account-button auth-find-password-button"
54
53
  >
55
- <span>비밀번호 찾기</span>
54
+ <span>{texts?.findPassword ?? "비밀번호 찾기"}</span>
56
55
  </Link>
57
56
  ))}
58
57
  </div>
@@ -67,7 +66,7 @@ export default function AuthLoginLinkButtons({
67
66
  priority="tertiary"
68
67
  size="small"
69
68
  >
70
- 회원가입
69
+ {texts?.signup ?? "회원가입"}
71
70
  </Button.Rounded>
72
71
  </div>
73
72
  )}
@@ -216,10 +216,71 @@ export interface AuthLoginIdFieldOptions extends AuthLoginFieldProps<InputProps>
216
216
  */
217
217
  export interface AuthLoginPasswordFieldOptions extends AuthLoginFieldProps<InputPasswordProps> {}
218
218
 
219
+ /**
220
+ * 로그인 필드 문구 옵션
221
+ * @property {ReactNode} [idLabel] 아이디 필드 기본 라벨
222
+ * @property {string} [idPlaceholder] 아이디 필드 기본 placeholder
223
+ * @property {ReactNode} [passwordLabel] 비밀번호 필드 기본 라벨
224
+ * @property {string} [passwordPlaceholder] 비밀번호 필드 기본 placeholder
225
+ * @property {ReactNode} [submit] submit 버튼 라벨
226
+ */
227
+ export interface AuthLoginFieldTexts {
228
+ /**
229
+ * 아이디 필드 기본 라벨
230
+ */
231
+ idLabel?: ReactNode;
232
+ /**
233
+ * 아이디 필드 기본 placeholder
234
+ */
235
+ idPlaceholder?: string;
236
+ /**
237
+ * 비밀번호 필드 기본 라벨
238
+ */
239
+ passwordLabel?: ReactNode;
240
+ /**
241
+ * 비밀번호 필드 기본 placeholder
242
+ */
243
+ passwordPlaceholder?: string;
244
+ /**
245
+ * submit 버튼 라벨
246
+ */
247
+ submit?: ReactNode;
248
+ }
249
+
250
+ /**
251
+ * 로그인 링크 문구 옵션
252
+ * @property {ReactNode} [findId] 아이디 찾기 링크 라벨
253
+ * @property {ReactNode} [findPassword] 비밀번호 찾기 링크 라벨
254
+ * @property {ReactNode} [signup] 회원가입 버튼 라벨
255
+ */
256
+ export interface AuthLoginLinkTexts {
257
+ /**
258
+ * 아이디 찾기 링크 라벨
259
+ */
260
+ findId?: ReactNode;
261
+ /**
262
+ * 비밀번호 찾기 링크 라벨
263
+ */
264
+ findPassword?: ReactNode;
265
+ /**
266
+ * 회원가입 버튼 라벨
267
+ */
268
+ signup?: ReactNode;
269
+ }
270
+
271
+ /**
272
+ * 로그인 템플릿 문구 옵션
273
+ * @extends AuthLoginFieldTexts
274
+ * @extends AuthLoginLinkTexts
275
+ */
276
+ export interface AuthLoginTexts
277
+ extends AuthLoginFieldTexts, AuthLoginLinkTexts {}
278
+
219
279
  /**
220
280
  * 로그인 필드 옵션; form attr + 제출 핸들러 포함
221
281
  * @property {AuthLoginIdFieldOptions} [idField] 로그인 아이디 필드 옵션
222
282
  * @property {AuthLoginPasswordFieldOptions} [passwordField] 로그인 비밀번호 필드 옵션
283
+ * @property {AuthLoginFieldTexts} [texts] 로그인 form 기본 문구 옵션
223
284
  * @property {React.FormHTMLAttributes<HTMLFormElement>} [formAttr] <form /> attributes
224
285
  * @property {SubmitHandler<Record<string, string>>} onLogin 로그인 콜백 이벤트
225
286
  */
@@ -232,6 +293,10 @@ export interface AuthLoginFieldOptions {
232
293
  * 비밀번호 필드 설정
233
294
  */
234
295
  passwordField?: AuthLoginPasswordFieldOptions;
296
+ /**
297
+ * 로그인 form 기본 문구 옵션
298
+ */
299
+ texts?: AuthLoginFieldTexts;
235
300
  /**
236
301
  * form attr
237
302
  */
@@ -271,6 +336,27 @@ export interface AuthLoginLinkOptions {
271
336
  signup?: string;
272
337
  }
273
338
 
339
+ /**
340
+ * 로그인 링크 버튼 props
341
+ * @property {AuthLoginLinkOptions} [linkOptions] 계정 관련 링크 옵션
342
+ * @property {AuthLoginLinkTexts} [texts] 링크/버튼 문구 옵션
343
+ * @property {() => void} [onFindPasswordClick] 비밀번호 찾기 링크버튼 클릭 콜백 이벤트
344
+ */
345
+ export interface AuthLoginLinkButtonsProps {
346
+ /**
347
+ * 계정 관련 링크 옵션
348
+ */
349
+ linkOptions?: AuthLoginLinkOptions;
350
+ /**
351
+ * 링크/버튼 문구 옵션
352
+ */
353
+ texts?: AuthLoginLinkTexts;
354
+ /**
355
+ * 비밀번호 찾기 링크버튼 클릭 콜백 이벤트
356
+ */
357
+ onFindPasswordClick?: () => void;
358
+ }
359
+
274
360
  /**
275
361
  * 로그인 템플릿 props; 컨테이너와 필드/링크 옵션을 조합한다.
276
362
  * @template TFields
@@ -279,6 +365,7 @@ export interface AuthLoginLinkOptions {
279
365
  * @property {ReactNode} [footer] 하단 콘텐츠
280
366
  * @property {AuthLoginFieldOptions} [fieldOptions] 입력 필드 옵션
281
367
  * @property {AuthLoginLinkOptions} [linkOptions] 계정관련 링크 옵션
368
+ * @property {AuthLoginTexts} [texts] 로그인 템플릿 기본 문구 옵션
282
369
  * @property {() => void} [onFindPasswordLinkClick] 비밀번호 찾기 링크버튼 클릭 콜백 이벤트
283
370
  */
284
371
  export interface AuthLoginProps extends Omit<AuthContainerProps, "children"> {
@@ -290,6 +377,10 @@ export interface AuthLoginProps extends Omit<AuthContainerProps, "children"> {
290
377
  * 링크 옵션
291
378
  */
292
379
  linkOptions?: AuthLoginLinkOptions;
380
+ /**
381
+ * 로그인 템플릿 기본 문구 옵션
382
+ */
383
+ texts?: AuthLoginTexts;
293
384
  /**
294
385
  * 비밀번호 찾기 링크 클릭 핸들러
295
386
  */
@@ -57,7 +57,13 @@ export default function CCTVCamListItem({
57
57
  footerOptions={{ activeTitle: true, activeOpenButton: true, cam }}
58
58
  // 변경 설명: list item custom overlay는 template seam 하나만 따라간다.
59
59
  renderOverlay={renderOverlay}
60
- {...{ isError, overlayMessage, isLive }}
60
+ {...{
61
+ isError,
62
+ overlayMessage,
63
+ isLive,
64
+ canReconnect: rtcCtx.canReconnect,
65
+ reconnectStream: rtcCtx.reconnectStream,
66
+ }}
61
67
  />
62
68
  </li>
63
69
  );
@@ -59,7 +59,13 @@ export default function CCTVPaginationListItem({
59
59
  title: cam.cam_name,
60
60
  }}
61
61
  footerOptions={{ cam }}
62
- {...{ isError, overlayMessage, isLive }}
62
+ {...{
63
+ isError,
64
+ overlayMessage,
65
+ isLive,
66
+ canReconnect: rtcCtx.canReconnect,
67
+ reconnectStream: rtcCtx.reconnectStream,
68
+ }}
63
69
  />
64
70
  </button>
65
71
  </li>
@@ -18,6 +18,8 @@ import type { CctvVideoTemplateProps } from "../../types/props";
18
18
  * @property {React.ReactNode} [overlayMessage]
19
19
  * @property {boolean} [isLive]
20
20
  * @property {CctvCompanyCameraData} [cam]
21
+ * @property {boolean} [canReconnect]
22
+ * @property {CctvRtcReconnectTrigger} [reconnectStream]
21
23
  * @property {CctvVideoRenderOverlay} [renderOverlay]
22
24
  * @property {React.Ref<HTMLVideoElement>} ref
23
25
  */
@@ -31,6 +33,8 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
31
33
  isError,
32
34
  isLive,
33
35
  overlayMessage,
36
+ canReconnect,
37
+ reconnectStream,
34
38
  renderOverlay,
35
39
  },
36
40
  ref,
@@ -56,6 +60,8 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
56
60
  isError,
57
61
  isLive,
58
62
  overlayMessage,
63
+ canReconnect,
64
+ reconnectStream,
59
65
  })
60
66
  : defaultOverlay;
61
67
 
@@ -57,7 +57,13 @@ export default function CCTVViewerDesktopVideo({
57
57
  footerOptions={{ activeTitle: false }}
58
58
  // 변경 설명: viewer video는 service custom overlay를 직접 수용하는 첫 public entry다.
59
59
  renderOverlay={renderOverlay}
60
- {...{ isError, overlayMessage, isLive }}
60
+ {...{
61
+ isError,
62
+ overlayMessage,
63
+ isLive,
64
+ canReconnect: rtcCtx.canReconnect,
65
+ reconnectStream: rtcCtx.reconnectStream,
66
+ }}
61
67
  />
62
68
  );
63
69
  }
@@ -10,11 +10,17 @@ import {
10
10
  useCctvApiUrl,
11
11
  useCctvRtcStreamRegistry,
12
12
  } from "../components/Provider";
13
- import type { UseCctvRtcStreamParams, UseCctvRtcStreamReturn } from "../types";
13
+ import type {
14
+ CctvRtcReconnectTrigger,
15
+ UseCctvRtcStreamParams,
16
+ UseCctvRtcStreamReturn,
17
+ } from "../types";
14
18
  import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
15
19
  import { getIsLive } from "../utils/video-state";
16
20
  import { useFormContext, useWatch } from "react-hook-form";
17
21
 
22
+ const AUTO_RECONNECT_INTERVAL_MS = 3000;
23
+
18
24
  /**
19
25
  * CCTV 영상 스트림을 WebRTC로 연결하는 커스텀 훅.
20
26
  * @hook
@@ -31,7 +37,9 @@ import { useFormContext, useWatch } from "react-hook-form";
31
37
  * isStreaming, // 현재 스트림 연결 절차가 진행 중인지 여부
32
38
  * isTokenLoading, // 토큰 발급 요청이 진행 중인지 여부
33
39
  * isTokenError, // 토큰 발급 요청이 실패했는지 여부
40
+ * canReconnect, // 재연결 가능한 종료 상태 여부
34
41
  * refetchToken, // 토큰 발급을 재시도하는 함수
42
+ * reconnectStream, // 재연결 trigger 함수
35
43
  * }
36
44
  */
37
45
  export function useCctvRtcStream({
@@ -51,6 +59,7 @@ export function useCctvRtcStream({
51
59
  const videoRef = useRef<HTMLVideoElement | null>(null);
52
60
  const activeStreamKeyRef = useRef<string | null>(null);
53
61
  const activeStreamIdentityKeyRef = useRef<string | null>(null);
62
+ const lastAutoReconnectAtRef = useRef(0);
54
63
 
55
64
  // RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
56
65
  const [connectionState, setConnectionState] =
@@ -61,6 +70,7 @@ export function useCctvRtcStream({
61
70
 
62
71
  // 현재 스트림 연결 절차가 진행 중인지 여부.
63
72
  const [isStreaming, setStreaming] = useState(false);
73
+ const [hasConnected, setHasConnected] = useState(false);
64
74
 
65
75
  // react-hook-form 컨텍스트에서 username을 추적한다.
66
76
  const { control } = useFormContext();
@@ -74,6 +84,7 @@ export function useCctvRtcStream({
74
84
  url: tokenUrl ?? contextTokenUrl,
75
85
  });
76
86
 
87
+ const { refetch: refetchRtcToken } = tokenQuery;
77
88
  const isTokenLoading = tokenQuery.isFetching;
78
89
  const isTokenError = tokenQuery.isError;
79
90
 
@@ -111,6 +122,10 @@ export function useCctvRtcStream({
111
122
  return [username, cam.company_id, cam.cam_id, endpoint].join("|");
112
123
  }, [cam?.cam_id, cam?.company_id, endpoint, username]);
113
124
 
125
+ useEffect(() => {
126
+ setHasConnected(false);
127
+ }, [streamIdentityKey]);
128
+
114
129
  // 토큰과 endpoint가 준비되면 WebRTC 스트림을 연결한다.
115
130
  useEffect(() => {
116
131
  const currentVideo = videoRef.current;
@@ -154,6 +169,7 @@ export function useCctvRtcStream({
154
169
  setConnectionState(snapshot.connectionState);
155
170
  setStreamError(snapshot.streamError);
156
171
  setStreaming(snapshot.isStreaming);
172
+ if (snapshot.connectionState === "connected") setHasConnected(true);
157
173
  if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
158
174
  currentVideo.srcObject = snapshot.stream;
159
175
  }
@@ -162,6 +178,7 @@ export function useCctvRtcStream({
162
178
  setConnectionState(snapshot.connectionState);
163
179
  setStreamError(snapshot.streamError);
164
180
  setStreaming(snapshot.isStreaming);
181
+ if (snapshot.connectionState === "connected") setHasConnected(true);
165
182
 
166
183
  if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
167
184
  currentVideo.srcObject = snapshot.stream;
@@ -182,17 +199,65 @@ export function useCctvRtcStream({
182
199
  cam?.cam_online,
183
200
  ]);
184
201
 
185
- const refetchToken = useCallback<typeof tokenQuery.refetch>(
202
+ const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
186
203
  async options => {
187
- const result = await tokenQuery.refetch(options);
204
+ const result = await refetchRtcToken(options);
188
205
  if (result.isSuccess && streamIdentityKey) {
189
206
  streamRegistry.closeByIdentity(streamIdentityKey);
190
207
  }
191
208
  return result;
192
209
  },
193
- [streamIdentityKey, streamRegistry, tokenQuery],
210
+ [refetchRtcToken, streamIdentityKey, streamRegistry],
194
211
  );
195
212
 
213
+ const canReconnect = useMemo(() => {
214
+ if (!cam?.cam_online) return false;
215
+ if (!hasConnected) return false;
216
+ if (!streamIdentityKey) return false;
217
+ if (isTokenLoading || isStreaming) return false;
218
+ if (isTokenError || streamError) return true;
219
+ if (connectionState === "failed") return true;
220
+ if (connectionState === "disconnected") return true;
221
+ if (connectionState === "closed") return true;
222
+ return false;
223
+ }, [
224
+ cam?.cam_online,
225
+ connectionState,
226
+ hasConnected,
227
+ isStreaming,
228
+ isTokenError,
229
+ isTokenLoading,
230
+ streamError,
231
+ streamIdentityKey,
232
+ ]);
233
+
234
+ const refetchToken = reconnectStream;
235
+
236
+ useEffect(() => {
237
+ if (typeof window === "undefined" || typeof document === "undefined")
238
+ return;
239
+
240
+ const reconnectOnFocus = () => {
241
+ if (document.visibilityState !== "visible") return;
242
+ if (!canReconnect) return;
243
+
244
+ const now = Date.now();
245
+ if (now - lastAutoReconnectAtRef.current < AUTO_RECONNECT_INTERVAL_MS)
246
+ return;
247
+
248
+ lastAutoReconnectAtRef.current = now;
249
+ void reconnectStream();
250
+ };
251
+
252
+ window.addEventListener("focus", reconnectOnFocus);
253
+ document.addEventListener("visibilitychange", reconnectOnFocus);
254
+
255
+ return () => {
256
+ window.removeEventListener("focus", reconnectOnFocus);
257
+ document.removeEventListener("visibilitychange", reconnectOnFocus);
258
+ };
259
+ }, [canReconnect, reconnectStream]);
260
+
196
261
  const liveState = useMemo(
197
262
  () =>
198
263
  cam
@@ -257,6 +322,8 @@ export function useCctvRtcStream({
257
322
  isStreaming,
258
323
  isTokenLoading,
259
324
  isTokenError,
325
+ canReconnect,
260
326
  refetchToken,
327
+ reconnectStream,
261
328
  };
262
329
  }
@@ -1,4 +1,8 @@
1
- import type { UseQueryResult } from "@tanstack/react-query";
1
+ import type {
2
+ QueryObserverResult,
3
+ RefetchOptions,
4
+ UseQueryResult,
5
+ } from "@tanstack/react-query";
2
6
  import type { API_Res_CctvCompany, API_Res_CctvRtcToken } from "./api";
3
7
  import type {
4
8
  CctvCompanyCameraData,
@@ -9,6 +13,13 @@ import type {
9
13
  import type { UseFormReturn } from "react-hook-form";
10
14
  import type { CctvBaseContext } from "./context";
11
15
 
16
+ /**
17
+ * CCTV; RTC stream 재연결 trigger
18
+ */
19
+ export type CctvRtcReconnectTrigger = (
20
+ options?: RefetchOptions,
21
+ ) => Promise<QueryObserverResult<API_Res_CctvRtcToken>>;
22
+
12
23
  /**
13
24
  * CCTV; useCctvRtcStream params
14
25
  * @property {CctvCompanyCameraData} [cam] 스트리밍 카메라 정보
@@ -77,17 +88,27 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
77
88
  * @property {boolean} isStreaming startWhepStream 진행 여부
78
89
  * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
79
90
  * @property {boolean} isTokenError 토큰 발급 실패 여부
91
+ * @property {boolean} canReconnect 재연결 가능한 종료 상태 여부
80
92
  * @property {() => Promise<void>} refetchToken 토큰 재발급 함수
93
+ * @property {() => Promise<QueryObserverResult<API_Res_CctvRtcToken>>} reconnectStream 재연결 trigger 함수
81
94
  */
82
95
  export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
83
96
  /**
84
97
  * <video /> ref
85
98
  */
86
99
  videoRef: React.RefObject<HTMLVideoElement | null>;
100
+ /**
101
+ * 재연결 가능한 종료 상태 여부
102
+ */
103
+ canReconnect: boolean;
87
104
  /**
88
105
  * 토큰 재발급 함수
89
106
  */
90
- refetchToken: UseQueryResult<API_Res_CctvRtcToken>["refetch"];
107
+ refetchToken: CctvRtcReconnectTrigger;
108
+ /**
109
+ * 재연결 trigger 함수
110
+ */
111
+ reconnectStream: CctvRtcReconnectTrigger;
91
112
  }
92
113
 
93
114
  /**
@@ -1,4 +1,5 @@
1
1
  import type { CctvCompanyCameraData, CctvCompanyCameraList } from "./list";
2
+ import type { CctvRtcReconnectTrigger } from "./hook";
2
3
 
3
4
  /**
4
5
  * CCTV; custom overlay render context
@@ -8,6 +9,8 @@ import type { CctvCompanyCameraData, CctvCompanyCameraList } from "./list";
8
9
  * @property {boolean | undefined} [isError] 영상 에러 상태 여부
9
10
  * @property {boolean | undefined} [isLive] 영상 live 상태 여부
10
11
  * @property {React.ReactNode} [overlayMessage] 기본 안내/에러 메시지 콘텐츠
12
+ * @property {boolean | undefined} [canReconnect] 재연결 가능한 종료 상태 여부
13
+ * @property {CctvRtcReconnectTrigger | undefined} [reconnectStream] 재연결 trigger 함수
11
14
  */
12
15
  export interface CctvVideoRenderOverlayContext {
13
16
  /**
@@ -34,6 +37,14 @@ export interface CctvVideoRenderOverlayContext {
34
37
  * 기본 안내/에러 메시지 콘텐츠
35
38
  */
36
39
  overlayMessage?: React.ReactNode;
40
+ /**
41
+ * 재연결 가능한 종료 상태 여부
42
+ */
43
+ canReconnect?: boolean;
44
+ /**
45
+ * 재연결 trigger 함수
46
+ */
47
+ reconnectStream?: CctvRtcReconnectTrigger;
37
48
  }
38
49
 
39
50
  /**
@@ -115,6 +126,8 @@ export interface CctvVideoOverlayFooterProps {
115
126
  * @property {boolean} [isError] 에러 상태 여부
116
127
  * @property {boolean} [isLive] 영상이 정상적으로 재생 중인지 여부
117
128
  * @property {React.ReactNode} [overlayMessage] 에러/안내 메시지 콘텐츠
129
+ * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
130
+ * @property {CctvRtcReconnectTrigger} [reconnectStream] 재연결 trigger 함수
118
131
  */
119
132
  export interface CctvVideoStateProps {
120
133
  /**
@@ -129,6 +142,14 @@ export interface CctvVideoStateProps {
129
142
  * 오버레이 메시지 콘텐츠
130
143
  */
131
144
  overlayMessage?: React.ReactNode;
145
+ /**
146
+ * 재연결 가능한 종료 상태 여부
147
+ */
148
+ canReconnect?: boolean;
149
+ /**
150
+ * 재연결 trigger 함수
151
+ */
152
+ reconnectStream?: CctvRtcReconnectTrigger;
132
153
  }
133
154
 
134
155
  /**
@@ -11,6 +11,7 @@ import type { UseCctvRtcStreamError, UseCctvRtcStreamState } from "./hook";
11
11
  * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
12
12
  * @property {boolean} isTokenError 토큰 발급 실패 여부
13
13
  * @property {string | null} streamError 스트림 오류 메시지
14
+ * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
14
15
  */
15
16
  export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
16
17
  /**
@@ -25,6 +26,10 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
25
26
  * 서버에서 cam 리스트를 가져오는 중인지 여부
26
27
  */
27
28
  isFetching?: boolean;
29
+ /**
30
+ * 재연결 가능한 종료 상태 여부
31
+ */
32
+ canReconnect?: boolean;
28
33
  }
29
34
 
30
35
  /**
@@ -48,10 +53,15 @@ export interface CctvVideoLiveParams extends UseCctvRtcStreamState {
48
53
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
49
54
  * @property {boolean} isTokenError 토큰 발급 실패 여부
50
55
  * @property {string | null} streamError 스트림 오류 메시지
56
+ * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
51
57
  */
52
58
  export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
53
59
  /**
54
60
  * 카메라 데이터
55
61
  */
56
62
  cam?: CctvCompanyCameraData;
63
+ /**
64
+ * 재연결 가능한 종료 상태 여부
65
+ */
66
+ canReconnect?: boolean;
57
67
  }
@@ -8,6 +8,8 @@ export const CCTV_MESSAGE = {
8
8
  selectCam: "카메라를 선택하세요.",
9
9
  fetching: "CCTV 데이터를 불러오는 중입니다.",
10
10
  preparing: "스트림을 준비하고 있습니다.",
11
+ sessionEnded:
12
+ "장시간 미사용으로 연결이 종료되었습니다. 계속 확인하시려면 다시 연결해 주세요.",
11
13
  tokenError: "토큰을 발급하지 못했습니다.",
12
14
  offline: "CCTV 연결 오류",
13
15
  } as const;
@@ -34,6 +36,7 @@ const RTC_PREPARING_STATES = new Set<RTCPeerConnectionState>([
34
36
  */
35
37
  export function getOverlayMessage({
36
38
  cam,
39
+ canReconnect,
37
40
  connectionState,
38
41
  hasCamProp = false,
39
42
  isFetching = false,
@@ -48,6 +51,7 @@ export function getOverlayMessage({
48
51
  }
49
52
 
50
53
  if (!cam.cam_online) return CCTV_MESSAGE.offline;
54
+ if (canReconnect) return CCTV_MESSAGE.sessionEnded;
51
55
  // 에러 상태가 준비 상태와 겹칠 때는 에러 문구가 최종 표시 계약을 우선한다.
52
56
  if (isTokenError) return CCTV_MESSAGE.tokenError;
53
57
  if (streamError) return streamError;
@@ -95,11 +99,13 @@ export function getIsLive({
95
99
  */
96
100
  export function getIsError({
97
101
  cam,
102
+ canReconnect,
98
103
  isTokenError,
99
104
  streamError,
100
105
  }: CctvVideoErrorParams): boolean {
101
106
  if (!cam) return true;
102
107
  if (!cam.cam_online) return true;
108
+ if (canReconnect) return true;
103
109
  if (isTokenError) return true;
104
110
  if (streamError) return true;
105
111
  return false;
@@ -3,13 +3,35 @@
3
3
  import { clsx } from "clsx";
4
4
  import { Alternate } from "@uniai-fe/uds-primitives";
5
5
  import NotFoundIcon from "../img/404.svg";
6
- import type { EdgeCaseNotFoundProps } from "../types";
6
+ import type { EdgeCaseNotFoundProps, EdgeCaseNotFoundTexts } from "../types";
7
+
8
+ const DEFAULT_NOT_FOUND_TEXTS: Required<EdgeCaseNotFoundTexts> = {
9
+ title: "이 페이지를 찾을 수 없습니다.",
10
+ description: (
11
+ <>
12
+ 페이지가 이동되었거나 삭제되었을 수 있습니다.
13
+ <br />
14
+ 페이지 주소를 다시 확인해 주세요.
15
+ </>
16
+ ),
17
+ inquiryPrefix: "관련된 문의사항은 ",
18
+ inquiryAction: "문의하기",
19
+ inquirySuffix: (
20
+ <>
21
+ 를 통해
22
+ <br />
23
+ 접수해 주시길 바랍니다.
24
+ </>
25
+ ),
26
+ prevAction: "이전 페이지로",
27
+ };
7
28
 
8
29
  /**
9
30
  * Edge Case Not Found; not-found 상태 화면 템플릿
10
31
  * @component
11
32
  * @param {EdgeCaseNotFoundProps} props
12
33
  * @param {string} [props.className] 최상위 edge-case container className override다.
34
+ * @param {EdgeCaseNotFoundTexts} [props.texts] not-found 기본 문구 override다.
13
35
  * @param {string} [props.inquiryHref] 문의 text action을 link로 렌더링할 href다.
14
36
  * @param {() => void} [props.onInquiry] 문의 text action을 button으로 렌더링할 콜백이다.
15
37
  * @param {string} [props.fallbackHref] onPrev가 없을 때 사용할 fallback href다.
@@ -19,11 +41,17 @@ import type { EdgeCaseNotFoundProps } from "../types";
19
41
  */
20
42
  export default function EdgeCaseNotFound({
21
43
  className,
44
+ texts,
22
45
  inquiryHref,
23
46
  onInquiry,
24
47
  fallbackHref = "/",
25
48
  onPrev,
26
49
  }: EdgeCaseNotFoundProps) {
50
+ const resolvedTexts = {
51
+ ...DEFAULT_NOT_FOUND_TEXTS,
52
+ ...texts,
53
+ };
54
+
27
55
  // 변경: data-edge-case forwarding이 없는 설치본에서도 NotFound preset 스타일이 적용되도록 class hook을 병행한다.
28
56
  return (
29
57
  <Alternate.Layout.Container
@@ -39,35 +67,29 @@ export default function EdgeCaseNotFound({
39
67
  />
40
68
  </Alternate.Layout.Figure>
41
69
  <Alternate.Layout.Title as="h1">
42
- 이 페이지를 찾을 수 없습니다.
70
+ {resolvedTexts.title}
43
71
  </Alternate.Layout.Title>
44
72
  <Alternate.Layout.Contents>
45
- <p>
46
- 페이지가 이동되었거나 삭제되었을 수 있습니다.
47
- <br />
48
- 페이지 주소를 다시 확인해 주세요.
49
- </p>
73
+ <p>{resolvedTexts.description}</p>
50
74
  {/* 변경: NotFound 본문 줄바꿈은 폭 제한이 아니라 Figma fixed copy의 br로 고정한다. */}
51
75
  {onInquiry || inquiryHref ? (
52
76
  <p>
53
- 관련된 문의사항은{" "}
77
+ {resolvedTexts.inquiryPrefix}
54
78
  <Alternate.Layout.TextButton href={inquiryHref} onClick={onInquiry}>
55
- 문의하기
79
+ {resolvedTexts.inquiryAction}
56
80
  </Alternate.Layout.TextButton>
57
- 를 통해
58
- <br />
59
- 접수해 주시길 바랍니다.
81
+ {resolvedTexts.inquirySuffix}
60
82
  </p>
61
83
  ) : null}
62
84
  </Alternate.Layout.Contents>
63
85
  {/* 변경: router/history 판정은 내부에서 실행하지 않고 onPrev 또는 fallback href만 연결한다. */}
64
86
  {onPrev ? (
65
87
  <Alternate.Layout.Button onClick={onPrev}>
66
- 이전 페이지로
88
+ {resolvedTexts.prevAction}
67
89
  </Alternate.Layout.Button>
68
90
  ) : (
69
91
  <Alternate.Layout.Button as="a" href={fallbackHref}>
70
- 이전 페이지로
92
+ {resolvedTexts.prevAction}
71
93
  </Alternate.Layout.Button>
72
94
  )}
73
95
  </Alternate.Layout.Container>
@@ -65,6 +65,7 @@ export interface EdgeCaseLoadingProps {
65
65
  /**
66
66
  * Edge Case Not Found Props; not-found 상태 화면 템플릿 props
67
67
  * @property {string} [className] 최상위 edge-case container className override다.
68
+ * @property {EdgeCaseNotFoundTexts} [texts] not-found 기본 문구 override다.
68
69
  * @property {string} [inquiryHref] 문의 text action을 link로 렌더링할 href다.
69
70
  * @property {() => void} [onInquiry] 문의 text action을 button으로 렌더링할 콜백이다.
70
71
  * @property {string} [fallbackHref] onPrev가 없을 때 사용할 fallback href다.
@@ -75,6 +76,10 @@ export interface EdgeCaseNotFoundProps {
75
76
  * 최상위 edge-case container className override다.
76
77
  */
77
78
  className?: string;
79
+ /**
80
+ * not-found 기본 문구 override다.
81
+ */
82
+ texts?: EdgeCaseNotFoundTexts;
78
83
  /**
79
84
  * 문의 text action을 link로 렌더링할 href다.
80
85
  */
@@ -92,3 +97,39 @@ export interface EdgeCaseNotFoundProps {
92
97
  */
93
98
  onPrev?: () => void;
94
99
  }
100
+
101
+ /**
102
+ * Edge Case Not Found Texts; not-found preset 문구
103
+ * @property {ReactNode} [title] 대표 제목 문구다.
104
+ * @property {ReactNode} [description] 설명 문구다.
105
+ * @property {ReactNode} [inquiryPrefix] 문의 action 앞 문구다.
106
+ * @property {ReactNode} [inquiryAction] 문의 text action 문구다.
107
+ * @property {ReactNode} [inquirySuffix] 문의 action 뒤 문구다.
108
+ * @property {ReactNode} [prevAction] 이전 페이지 action 문구다.
109
+ */
110
+ export interface EdgeCaseNotFoundTexts {
111
+ /**
112
+ * 대표 제목 문구다.
113
+ */
114
+ title?: ReactNode;
115
+ /**
116
+ * 설명 문구다.
117
+ */
118
+ description?: ReactNode;
119
+ /**
120
+ * 문의 action 앞 문구다.
121
+ */
122
+ inquiryPrefix?: ReactNode;
123
+ /**
124
+ * 문의 text action 문구다.
125
+ */
126
+ inquiryAction?: ReactNode;
127
+ /**
128
+ * 문의 action 뒤 문구다.
129
+ */
130
+ inquirySuffix?: ReactNode;
131
+ /**
132
+ * 이전 페이지 action 문구다.
133
+ */
134
+ prevAction?: ReactNode;
135
+ }
@@ -39,17 +39,33 @@ const ServiceInquiryForm = ({
39
39
  }: ServiceInquiryFormProps) => {
40
40
  const form = useFormContext<ServiceInquiryFormValues>();
41
41
  const selectedInquiryType = form.watch("inquiry_type");
42
+ const inquiryTypeOptions =
43
+ inquiryTypeField?.options ?? DEFAULT_INQUIRY_TYPE_OPTIONS;
44
+ const farmNameState = form.getFieldState("farm_name", form.formState);
45
+ const contactState = form.getFieldState("contact", form.formState);
46
+ const inquiryTypeState = form.getFieldState("inquiry_type", form.formState);
47
+ const textState = form.getFieldState("text", form.formState);
42
48
 
43
49
  // 변경 설명: Modal.Dialog confirm 기본 submit과 연결되도록 form.handleSubmit 결과를 그대로 onSubmit에 바인딩한다.
44
50
  const handleSubmit = form.handleSubmit(onSubmit);
45
51
 
46
52
  useEffect(() => {
47
- form.register("inquiry_type");
48
- if (!form.getValues("inquiry_type")) {
49
- // 변경 설명: 문의 유형은 선택형 form 상태로 유지하고, 서비스 앱이 필요하면 submit 경계에서 user_context로 접어 보낸다.
50
- form.setValue("inquiry_type", "접속이 안 돼요");
53
+ form.register("inquiry_type", {
54
+ required:
55
+ inquiryTypeField?.required === false
56
+ ? false
57
+ : (inquiryTypeField?.requiredMessage ?? "문의 유형을 선택해 주세요"),
58
+ });
59
+ if (!form.getValues("inquiry_type") && inquiryTypeOptions[0]) {
60
+ // 변경 설명: 문의 유형 값은 options에 주입된 표시값을 그대로 form value로 사용한다.
61
+ form.setValue("inquiry_type", inquiryTypeOptions[0]);
51
62
  }
52
- }, [form]);
63
+ }, [
64
+ form,
65
+ inquiryTypeField?.required,
66
+ inquiryTypeField?.requiredMessage,
67
+ inquiryTypeOptions,
68
+ ]);
53
69
 
54
70
  return (
55
71
  <form
@@ -64,6 +80,7 @@ const ServiceInquiryForm = ({
64
80
  "service-inquiry-field-farm-name",
65
81
  )}
66
82
  width="full"
83
+ state={farmNameState.invalid ? "error" : undefined}
67
84
  headerProps={{
68
85
  required: farmNameField?.required ?? true,
69
86
  ...(typeof farmNameField?.label === "string"
@@ -77,14 +94,21 @@ const ServiceInquiryForm = ({
77
94
  : farmNameField.label,
78
95
  }),
79
96
  }}
80
- footer={farmNameField?.helper}
97
+ footer={farmNameState.error?.message ?? farmNameField?.helper}
81
98
  >
82
99
  <Input.Base
83
100
  type="text"
84
101
  block={true}
102
+ state={farmNameState.invalid ? "error" : undefined}
85
103
  readOnly={farmNameField?.mode === "readonly"}
86
104
  placeholder={farmNameField?.placeholder ?? "이름을 입력해 주세요"}
87
- register={form.register("farm_name")}
105
+ register={form.register("farm_name", {
106
+ required:
107
+ farmNameField?.required === false
108
+ ? false
109
+ : (farmNameField?.requiredMessage ??
110
+ "이름을 입력해 주세요"),
111
+ })}
88
112
  />
89
113
  </Form.Field.Template>
90
114
  ) : null}
@@ -96,6 +120,7 @@ const ServiceInquiryForm = ({
96
120
  "service-inquiry-field-contact",
97
121
  )}
98
122
  width="full"
123
+ state={contactState.invalid ? "error" : undefined}
99
124
  headerProps={{
100
125
  required: contactField?.required ?? true,
101
126
  ...(typeof contactField?.label === "string"
@@ -110,6 +135,7 @@ const ServiceInquiryForm = ({
110
135
  }),
111
136
  }}
112
137
  footer={
138
+ contactState.error?.message ??
113
139
  contactField?.helper ??
114
140
  "필요한 경우 입력하신 연락처로 연락 드립니다."
115
141
  }
@@ -117,11 +143,18 @@ const ServiceInquiryForm = ({
117
143
  <Input.Base
118
144
  type="text"
119
145
  block={true}
146
+ state={contactState.invalid ? "error" : undefined}
120
147
  readOnly={contactField?.mode === "readonly"}
121
148
  placeholder={
122
149
  contactField?.placeholder ?? "연락처를 입력해 주세요"
123
150
  }
124
- register={form.register("contact")}
151
+ register={form.register("contact", {
152
+ required:
153
+ contactField?.required === false
154
+ ? false
155
+ : (contactField?.requiredMessage ??
156
+ "연락처를 입력해 주세요"),
157
+ })}
125
158
  />
126
159
  </Form.Field.Template>
127
160
  ) : null}
@@ -133,6 +166,7 @@ const ServiceInquiryForm = ({
133
166
  "service-inquiry-field-inquiry-type",
134
167
  )}
135
168
  width="full"
169
+ state={inquiryTypeState.invalid ? "error" : undefined}
136
170
  headerProps={{
137
171
  required: inquiryTypeField?.required ?? true,
138
172
  ...(typeof inquiryTypeField?.label === "string"
@@ -146,32 +180,30 @@ const ServiceInquiryForm = ({
146
180
  : inquiryTypeField.label,
147
181
  }),
148
182
  }}
149
- footer={inquiryTypeField?.helper}
183
+ footer={inquiryTypeState.error?.message ?? inquiryTypeField?.helper}
150
184
  >
151
185
  <div className="service-inquiry-type-grid">
152
- {(inquiryTypeField?.options ?? DEFAULT_INQUIRY_TYPE_OPTIONS).map(
153
- inquiryType => {
154
- return (
155
- <Chip.ClickableStyle
156
- key={inquiryType}
157
- className="service-inquiry-type-option"
158
- chipStyle="filter"
159
- fill="solid"
160
- selected={selectedInquiryType === inquiryType}
161
- // 변경 설명: inquiry_type은 영문 key 변환 없이 선택된 한글 label 원문을 form 값으로 유지한다.
162
- onClick={() =>
163
- form.setValue("inquiry_type", inquiryType, {
164
- shouldDirty: true,
165
- shouldTouch: true,
166
- shouldValidate: true,
167
- })
168
- }
169
- >
170
- {inquiryType}
171
- </Chip.ClickableStyle>
172
- );
173
- },
174
- )}
186
+ {inquiryTypeOptions.map(inquiryType => {
187
+ return (
188
+ <Chip.ClickableStyle
189
+ key={inquiryType}
190
+ className="service-inquiry-type-option"
191
+ chipStyle="filter"
192
+ fill="solid"
193
+ selected={selectedInquiryType === inquiryType}
194
+ // 변경 설명: inquiry_type은 변환 없이 선택된 label 원문을 form 값으로 유지한다.
195
+ onClick={() =>
196
+ form.setValue("inquiry_type", inquiryType, {
197
+ shouldDirty: true,
198
+ shouldTouch: true,
199
+ shouldValidate: true,
200
+ })
201
+ }
202
+ >
203
+ {inquiryType}
204
+ </Chip.ClickableStyle>
205
+ );
206
+ })}
175
207
  </div>
176
208
  </Form.Field.Template>
177
209
  ) : null}
@@ -183,6 +215,7 @@ const ServiceInquiryForm = ({
183
215
  "service-inquiry-field-text",
184
216
  )}
185
217
  width="full"
218
+ state={textState.invalid ? "error" : undefined}
186
219
  headerProps={{
187
220
  required: textField?.required ?? true,
188
221
  ...(typeof textField?.label === "string"
@@ -196,17 +229,24 @@ const ServiceInquiryForm = ({
196
229
  : textField.label,
197
230
  }),
198
231
  }}
199
- footer={textField?.helper}
232
+ footer={textState.error?.message ?? textField?.helper}
200
233
  >
201
234
  <Input.TextArea
202
235
  block={true}
236
+ state={textState.invalid ? "error" : undefined}
203
237
  placeholder={
204
238
  textField?.placeholder ??
205
239
  "어떤 문제가 있었는지 적어주세요.\n예: 화면이 멈췄어요."
206
240
  }
207
241
  height={160}
208
242
  maxLength={10000}
209
- register={form.register("text")}
243
+ register={form.register("text", {
244
+ required:
245
+ textField?.required === false
246
+ ? false
247
+ : (textField?.requiredMessage ??
248
+ "문의 내용을 입력해 주세요"),
249
+ })}
210
250
  />
211
251
  </Form.Field.Template>
212
252
  ) : null}
@@ -10,6 +10,7 @@ import clsx from "clsx";
10
10
  * @component
11
11
  * @param {UseOpenServiceInquiryOptions} props 문의 모달 열기 props
12
12
  * @param {string} [props.className]
13
+ * @param {string} [props.ariaLabel] button aria-label
13
14
  * @param {string} [props.stackKey] modal stack key
14
15
  * @param {ServiceInquiryFormProps} props.formProps 문의 form props
15
16
  * @param {Partial<DialogTemplateOptions<ServiceInquiryFormValues>>} [props.dialogOptions] 문의 모달 preset 위에 덮어쓸 dialog option
@@ -21,6 +22,7 @@ import clsx from "clsx";
21
22
  */
22
23
  export default function ServiceInquiryOpenButton({
23
24
  className,
25
+ ariaLabel = "문의하기",
24
26
  stackKey,
25
27
  formProps,
26
28
  dialogOptions,
@@ -38,6 +40,7 @@ export default function ServiceInquiryOpenButton({
38
40
  className={clsx("service-inquiry-open-button", className)}
39
41
  priority="tertiary"
40
42
  size="large"
43
+ aria-label={ariaLabel}
41
44
  // 변경 설명: 기본 제공 버튼은 고정 원형 `?` 버튼 사양으로 렌더링한다.
42
45
  onClick={openServiceInquiry}
43
46
  >
@@ -36,6 +36,11 @@
36
36
  font-size: var(--service-inquiry-type-font-size);
37
37
  font-weight: var(--service-inquiry-type-font-weight-default);
38
38
  line-height: 1.4;
39
+
40
+ :where(.chip-label) {
41
+ // 변경 설명: Chip 기본 label은 ellipsis를 위해 overflow hidden + line-height 1em을 갖기 때문에, service-inquiry의 큰 영문 라벨 descender가 잘리지 않도록 line-height만 모듈 범위에서 보정한다.
42
+ line-height: 1.4;
43
+ }
39
44
  }
40
45
 
41
46
  .service-inquiry-type-option:where([data-selected="true"]) {
@@ -1,12 +1,8 @@
1
1
  /**
2
2
  * Service Inquiry Form; 문의 유형 값
3
- * @typedef {"접속이 안 돼요" | "화면이 멈춰요" | "데이터가 안나와요" | "기타"} ServiceInquiryType
3
+ * @typedef {string} ServiceInquiryType
4
4
  */
5
- export type ServiceInquiryType =
6
- | "접속이 안 돼요"
7
- | "화면이 멈춰요"
8
- | "데이터가 안나와요"
9
- | "기타";
5
+ export type ServiceInquiryType = string;
10
6
 
11
7
  /**
12
8
  * Service Inquiry Form; 사용자 입력값
@@ -26,6 +26,7 @@ export type ServiceInquiryFieldMode = "editable" | "readonly";
26
26
  * @property {ReactNode} [label] field label
27
27
  * @property {ReactNode} [helper] field helper
28
28
  * @property {boolean} [required] required 표시 여부
29
+ * @property {string} [requiredMessage] required validation message
29
30
  */
30
31
  export interface ServiceInquiryFieldBaseProps {
31
32
  /**
@@ -40,6 +41,10 @@ export interface ServiceInquiryFieldBaseProps {
40
41
  * required 표시 여부
41
42
  */
42
43
  required?: boolean;
44
+ /**
45
+ * required validation message
46
+ */
47
+ requiredMessage?: string;
43
48
  }
44
49
 
45
50
  /**
@@ -163,6 +168,7 @@ export interface UseOpenServiceInquiryOptions extends ServiceInquiryCreateModalO
163
168
  /**
164
169
  * Service Inquiry Button props; 버튼 옵션
165
170
  * @property {string} [className]
171
+ * @property {string} [ariaLabel] button aria-label
166
172
  * @property {string} [stackKey] modal stack key
167
173
  * @property {ServiceInquiryFormProps} formProps 문의 form props
168
174
  * @property {Partial<DialogTemplateOptions<ServiceInquiryFormValues>>} [dialogOptions] 문의 모달 preset 위에 덮어쓸 dialog option
@@ -173,6 +179,10 @@ export interface ServiceInquiryOpenButtonProps extends UseOpenServiceInquiryOpti
173
179
  * button className
174
180
  */
175
181
  className?: string;
182
+ /**
183
+ * button aria-label
184
+ */
185
+ ariaLabel?: string;
176
186
  }
177
187
 
178
188
  /**