@yeongseoksong/framework 1.2.0 → 1.4.0

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.
@@ -0,0 +1,207 @@
1
+ import * as zustand_middleware from 'zustand/middleware';
2
+ import * as zustand from 'zustand';
3
+ import { ReactNode, FormEvent } from 'react';
4
+
5
+ /**
6
+ * 로그인한 사용자. 소비자 앱의 사용자 모델이 더 넓으면 `login()`에 넘기기 전에
7
+ * 이 형태로 좁혀서 넣는다 — 스토어는 화면이 필요로 하는 최소 정보만 갖는다.
8
+ */
9
+ interface AuthUser {
10
+ id: string;
11
+ email: string;
12
+ name?: string;
13
+ roles?: string[];
14
+ }
15
+ interface AuthState {
16
+ user: AuthUser | null;
17
+ /** user의 파생값이지만, 셀렉터를 `(s) => s.isAuthenticated` 한 줄로 유지하려고 상태로 둔다. */
18
+ isAuthenticated: boolean;
19
+ /** localStorage 복원이 끝났는지. 정적 프리렌더 대비용 — 아래 주석 참고. */
20
+ hasHydrated: boolean;
21
+ login: (user: AuthUser) => void;
22
+ logout: () => void;
23
+ setHasHydrated: (value: boolean) => void;
24
+ }
25
+ /**
26
+ * 인증 세션.
27
+ *
28
+ * **액세스 토큰을 여기 넣지 말 것.** `partialize`가 사용자 프로필만 저장하도록
29
+ * 막아 두었다. 토큰은 httpOnly 쿠키로 두는 게 소비자 앱의 몫이다 — localStorage에
30
+ * 담으면 XSS 한 번에 그대로 털린다.
31
+ *
32
+ * `skipHydration`을 켠 이유: 자동 복원은 모듈 평가 시점에 일어나서 **첫 클라이언트
33
+ * 렌더가 이미 로그인 상태**가 된다. 반면 서버가 만든 HTML에는 항상 로그아웃 상태가
34
+ * 박혀 나가므로(`output: "export"`면 빌드 시점에 고정) 하이드레이션 불일치가 난다.
35
+ * 복원을 이펙트로 미루면 첫 렌더가 서버와 일치하고, 그 다음 틱에 세션이 반영된다.
36
+ * 복원을 트리거하고 완료 여부를 돌려주는 게 아래 `useAuthHydrated()`다.
37
+ */
38
+ declare const useAuthStore: zustand.UseBoundStore<Omit<zustand.StoreApi<AuthState>, "setState" | "persist"> & {
39
+ setState(partial: AuthState | Partial<AuthState> | ((state: AuthState) => AuthState | Partial<AuthState>), replace?: false | undefined): unknown;
40
+ setState(state: AuthState | ((state: AuthState) => AuthState), replace: true): unknown;
41
+ persist: {
42
+ setOptions: (options: Partial<zustand_middleware.PersistOptions<AuthState, unknown, unknown>>) => void;
43
+ clearStorage: () => void;
44
+ rehydrate: () => Promise<void> | void;
45
+ hasHydrated: () => boolean;
46
+ onHydrate: (fn: (state: AuthState) => void) => () => void;
47
+ onFinishHydration: (fn: (state: AuthState) => void) => () => void;
48
+ getOptions: () => Partial<zustand_middleware.PersistOptions<AuthState, unknown, unknown>>;
49
+ };
50
+ }>;
51
+ /**
52
+ * 저장된 세션을 복원하고, 복원이 끝났는지를 돌려준다.
53
+ *
54
+ * 로그인 여부에 따라 다르게 그리는 UI는 이 값이 `false`인 동안 **로그아웃 상태로**
55
+ * 그려야 서버 HTML과 어긋나지 않는다.
56
+ *
57
+ * ```tsx
58
+ * const hydrated = useAuthHydrated()
59
+ * const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
60
+ * if (hydrated && isAuthenticated) { ... }
61
+ * ```
62
+ */
63
+ declare function useAuthHydrated(): boolean;
64
+
65
+ interface UiState {
66
+ /** 전역 로딩 오버레이 — 라우트 전환·일괄 작업처럼 화면 전체를 막아야 할 때 */
67
+ globalLoading: boolean;
68
+ setGlobalLoading: (value: boolean) => void;
69
+ /** 관리자형 레이아웃의 사이드 내비 열림 상태 */
70
+ sideNavOpened: boolean;
71
+ openSideNav: () => void;
72
+ closeSideNav: () => void;
73
+ toggleSideNav: () => void;
74
+ }
75
+ /**
76
+ * 화면 간 공유가 필요한 클라이언트 UI 상태.
77
+ *
78
+ * `SdHeader`의 모바일 드로어는 **일부러 여기 두지 않는다.** 그건 인스턴스 로컬
79
+ * 상태(`useDisclosure`)여야 한다 — 카탈로그처럼 한 페이지에 헤더가 둘 이상 렌더되면
80
+ * 스토어를 공유하는 순간 드로어가 함께 열린다.
81
+ */
82
+ declare const useUiStore: zustand.UseBoundStore<zustand.StoreApi<UiState>>;
83
+
84
+ /**
85
+ * 어떤 작업이 끝난 뒤 항상 돌려야 하는 뒷정리 — 라우팅, 모달 닫기, 목록 새로고침 등.
86
+ *
87
+ * 폼에 딸린 개념이 아니다. 제출·삭제·업로드처럼 "끝나면 정리한다"가 필요한 곳이면
88
+ * 어느 스토어에서든 같은 타입과 실행기를 쓴다.
89
+ */
90
+ /**
91
+ * 뒷정리 함수. **인자를 받지 않는다** — 대개 결과와 무관한 정리·이동이라
92
+ * 기존 함수(`close`, `refetch`, `() => router.push('/')`)를 그대로 꽂을 수 있어야 한다.
93
+ * 반환값은 무시하고, 프로미스면 기다린다.
94
+ */
95
+ type Finalizer = () => unknown | Promise<unknown>;
96
+ /** 하나 또는 여러 개. 여러 개면 넘긴 순서대로 실행된다. */
97
+ type Finalizers = Finalizer | Finalizer[];
98
+
99
+ /**
100
+ * 모든 폼이 공유하는 상태 + 제출 파이프라인.
101
+ *
102
+ * 폼 하나하나가 `useState`로 값·에러·제출중 여부를 따로 들고 있으면 제출 처리(중복
103
+ * 클릭 차단, 성공/실패 알림, 성공 후 초기화)가 폼마다 조금씩 달라진다. 그 셋을 여기
104
+ * 한 곳에 묶어 두고, 화면은 `useSdForm()` 하나만 부르면 되게 한다.
105
+ *
106
+ * 스토어는 `formId`로 칸을 나눠 쓰므로 한 화면에 폼이 여러 개 떠 있어도 섞이지 않고,
107
+ * 반대로 **같은 id를 쓰면 서로 다른 컴포넌트가 같은 폼을 공유**한다(마법사 단계 분리,
108
+ * 페이지를 오간 뒤 입력값 복원 등).
109
+ */
110
+ type FormValues = Record<string, unknown>;
111
+ /** 값이 유효하면 `null`, 아니면 사용자에게 보여줄 메시지를 돌려준다. */
112
+ type FormRule<V extends FormValues> = (value: unknown, values: V) => string | null;
113
+ type FormErrors<V extends FormValues> = Partial<Record<keyof V, string>>;
114
+ interface FormEntry {
115
+ values: FormValues;
116
+ errors: Record<string, string>;
117
+ submitting: boolean;
118
+ /** 제출 실패 메시지 — 폼 상단에 노출한다. 필드 에러는 `errors`가 담는다. */
119
+ error: ReactNode | null;
120
+ }
121
+ interface FormStoreState {
122
+ forms: Record<string, FormEntry>;
123
+ /** 첫 마운트 시 초기값을 심는다. 이미 있으면 건드리지 않는다(입력값 보존). */
124
+ ensureForm: (formId: string, initialValues: FormValues) => void;
125
+ setValue: (formId: string, name: string, value: unknown) => void;
126
+ setValues: (formId: string, values: FormValues) => void;
127
+ setErrors: (formId: string, errors: Record<string, string>) => void;
128
+ setSubmitting: (formId: string, submitting: boolean) => void;
129
+ setError: (formId: string, error: ReactNode | null) => void;
130
+ resetForm: (formId: string, initialValues: FormValues) => void;
131
+ /** 폼 칸 자체를 버린다 — 값까지 지우고 싶을 때만. */
132
+ removeForm: (formId: string) => void;
133
+ }
134
+ declare const useFormStore: zustand.UseBoundStore<zustand.StoreApi<FormStoreState>>;
135
+ interface SdFormOptions<V extends FormValues> {
136
+ /** 스토어에서 이 폼이 쓸 칸의 이름. 같은 id를 쓰는 컴포넌트끼리는 상태를 공유한다. */
137
+ id: string;
138
+ initialValues: V;
139
+ /** 필드별 검증 규칙. 제출 시 한 번에 돌린다. */
140
+ rules?: Partial<Record<keyof V, FormRule<V>>>;
141
+ /** 실제 전송. 예외를 던지면 실패로 처리된다(에러 토스트 + 폼 상단 메시지). */
142
+ onSubmit: (values: V) => void | Promise<void>;
143
+ /** 성공 토스트 메시지. `false`면 토스트를 띄우지 않는다. */
144
+ successMessage?: ReactNode | false;
145
+ /** 실패 메시지 변환. 기본값은 `Error.message`, 없으면 일반 문구. */
146
+ errorMessage?: (error: unknown) => ReactNode;
147
+ /** 성공 후 초기값으로 되돌릴지 */
148
+ resetOnSuccess?: boolean;
149
+ onSuccess?: (values: V) => void;
150
+ /** 전송이 실패했을 때. 예외를 그대로 받는다. */
151
+ onError?: (error: unknown) => void;
152
+ /**
153
+ * 성공·실패와 무관하게 전송이 끝나면 **항상** 실행된다 — `finally`에 해당한다.
154
+ * 라우팅, 모달 닫기, 목록 새로고침처럼 결과와 무관한 뒷정리 자리다.
155
+ *
156
+ * 타입은 폼 전용이 아니라 공용 `Finalizers`(`util/finalize.util.ts`)다 — 인자를 받지
157
+ * 않으므로 기존 함수를 그대로 꽂고(`finalize: closeModal`), 여러 개는 배열로 묶는다.
158
+ * 결과에 따라 갈라져야 하는 일은 `onSuccess` / `onError`에 둔다.
159
+ *
160
+ * 검증에서 걸려 전송 자체를 하지 않은 경우에는 실행되지 않는다(아직 "끝난" 게 아니다).
161
+ * `submitting`이 내려간 **뒤에** 실행되므로, 여기서 `reset()`이나 다른 제출을 불러도 안전하다.
162
+ */
163
+ finalize?: Finalizers;
164
+ }
165
+ interface SdFormApi<V extends FormValues> {
166
+ values: V;
167
+ errors: FormErrors<V>;
168
+ /** 전송 중 — 버튼 `loading`에 그대로 넘긴다. 이 동안 재제출은 무시된다. */
169
+ submitting: boolean;
170
+ /** 제출 실패 메시지 */
171
+ error: ReactNode | null;
172
+ setValue: <K extends keyof V>(name: K, value: V[K]) => void;
173
+ setValues: (values: Partial<V>) => void;
174
+ reset: () => void;
175
+ /** Mantine 입력 컴포넌트에 펼쳐 넣는다. 체크박스는 `{ type: 'checkbox' }`. */
176
+ getInputProps: (name: keyof V, options?: {
177
+ type?: 'input' | 'checkbox';
178
+ }) => Record<string, unknown>;
179
+ /** `<form onSubmit={...}>`에 그대로 넘긴다. */
180
+ onSubmit: (event?: FormEvent<HTMLFormElement>) => void;
181
+ }
182
+ /**
183
+ * 폼 하나를 스토어에 붙이고, 값·에러·제출을 한 묶음으로 돌려준다.
184
+ *
185
+ * 제출은 항상 같은 순서로 흐른다:
186
+ * 검증 → (통과 시) submitting on → `onSubmit` → 성공 토스트·초기화·`onSuccess`
187
+ * → 실패 시 에러 토스트 + 상단 메시지·`onError` → submitting off → `finalize`.
188
+ */
189
+ declare function useSdForm<V extends FormValues>({ id, initialValues, rules, onSubmit, successMessage, errorMessage, resetOnSuccess, onSuccess, onError, finalize, }: SdFormOptions<V>): SdFormApi<V>;
190
+ /**
191
+ * 자주 쓰는 검증 규칙. `rules`에 그대로 꽂아 쓴다.
192
+ *
193
+ * ```ts
194
+ * rules: { email: formRules.email(), password: formRules.minLength(8) }
195
+ * ```
196
+ */
197
+ declare const formRules: {
198
+ required: <V extends FormValues>(message?: string) => FormRule<V>;
199
+ email: <V extends FormValues>(message?: string) => FormRule<V>;
200
+ minLength: <V extends FormValues>(length: number, message?: string) => FormRule<V>;
201
+ /** 체크박스 동의 — 반드시 true여야 한다. */
202
+ checked: <V extends FormValues>(message?: string) => FormRule<V>;
203
+ /** 다른 필드와 같은 값인지 — 비밀번호 확인 등. */
204
+ sameAs: <V extends FormValues>(other: keyof V, message?: string) => FormRule<V>;
205
+ };
206
+
207
+ export { type AuthUser, type FormErrors, type FormRule, type FormValues, type SdFormApi, type SdFormOptions, formRules, useAuthHydrated, useAuthStore, useFormStore, useSdForm, useUiStore };
@@ -0,0 +1,355 @@
1
+ "use client";
2
+ var __defProp = Object.defineProperty;
3
+ var __defProps = Object.defineProperties;
4
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
5
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
8
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
9
+ var __spreadValues = (a, b) => {
10
+ for (var prop in b || (b = {}))
11
+ if (__hasOwnProp.call(b, prop))
12
+ __defNormalProp(a, prop, b[prop]);
13
+ if (__getOwnPropSymbols)
14
+ for (var prop of __getOwnPropSymbols(b)) {
15
+ if (__propIsEnum.call(b, prop))
16
+ __defNormalProp(a, prop, b[prop]);
17
+ }
18
+ return a;
19
+ };
20
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
21
+ var __restKey = (key) => typeof key === "symbol" ? key : key + "";
22
+ var __objRest = (source, exclude) => {
23
+ var target = {};
24
+ for (var prop in source)
25
+ if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0)
26
+ target[prop] = source[prop];
27
+ if (source != null && __getOwnPropSymbols)
28
+ for (var prop of __getOwnPropSymbols(source)) {
29
+ if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop))
30
+ target[prop] = source[prop];
31
+ }
32
+ return target;
33
+ };
34
+ var __async = (__this, __arguments, generator) => {
35
+ return new Promise((resolve, reject) => {
36
+ var fulfilled = (value) => {
37
+ try {
38
+ step(generator.next(value));
39
+ } catch (e) {
40
+ reject(e);
41
+ }
42
+ };
43
+ var rejected = (value) => {
44
+ try {
45
+ step(generator.throw(value));
46
+ } catch (e) {
47
+ reject(e);
48
+ }
49
+ };
50
+ var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
51
+ step((generator = generator.apply(__this, __arguments)).next());
52
+ });
53
+ };
54
+
55
+ // store/auth.store.ts
56
+ import { useEffect } from "react";
57
+ import { create } from "zustand";
58
+ import { createJSONStorage, persist } from "zustand/middleware";
59
+ var useAuthStore = create()(
60
+ persist(
61
+ (set) => ({
62
+ user: null,
63
+ isAuthenticated: false,
64
+ hasHydrated: false,
65
+ login: (user) => set({ user, isAuthenticated: true }),
66
+ logout: () => set({ user: null, isAuthenticated: false }),
67
+ setHasHydrated: (value) => set({ hasHydrated: value })
68
+ }),
69
+ {
70
+ name: "sd-auth",
71
+ storage: createJSONStorage(() => localStorage),
72
+ partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
73
+ skipHydration: true,
74
+ onRehydrateStorage: () => (state) => state == null ? void 0 : state.setHasHydrated(true)
75
+ }
76
+ )
77
+ );
78
+ var rehydrateStarted = false;
79
+ function useAuthHydrated() {
80
+ const hasHydrated = useAuthStore((state) => state.hasHydrated);
81
+ useEffect(() => {
82
+ if (rehydrateStarted) return;
83
+ rehydrateStarted = true;
84
+ void useAuthStore.persist.rehydrate();
85
+ }, []);
86
+ return hasHydrated;
87
+ }
88
+
89
+ // store/ui.store.ts
90
+ import { create as create2 } from "zustand";
91
+ var useUiStore = create2()((set) => ({
92
+ globalLoading: false,
93
+ setGlobalLoading: (value) => set({ globalLoading: value }),
94
+ sideNavOpened: false,
95
+ openSideNav: () => set({ sideNavOpened: true }),
96
+ closeSideNav: () => set({ sideNavOpened: false }),
97
+ toggleSideNav: () => set((state) => ({ sideNavOpened: !state.sideNavOpened }))
98
+ }));
99
+
100
+ // store/form.state.ts
101
+ import { useCallback, useEffect as useEffect2, useMemo } from "react";
102
+ import { create as create3 } from "zustand";
103
+
104
+ // ui/atom/Toast.tsx
105
+ import { Notifications, notifications } from "@mantine/notifications";
106
+ import { IconAlertTriangle, IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
107
+ import { jsx } from "react/jsx-runtime";
108
+ var toastStyles = {
109
+ /** 성공 — 저장·전송 완료 */
110
+ Success: { color: "green", icon: /* @__PURE__ */ jsx(IconCheck, { size: 18 }), title: "\uC644\uB8CC" },
111
+ /** 실패 — 요청 오류·검증 실패 */
112
+ Error: { color: "red", icon: /* @__PURE__ */ jsx(IconX, { size: 18 }), title: "\uC624\uB958" },
113
+ /** 주의 — 되돌릴 수 없는 동작 안내 등 */
114
+ Warning: { color: "amber", icon: /* @__PURE__ */ jsx(IconAlertTriangle, { size: 18 }), title: "\uC8FC\uC758" },
115
+ /** 안내 — 중립 정보 */
116
+ Info: { color: "primary", icon: /* @__PURE__ */ jsx(IconInfoCircle, { size: 18 }), title: "\uC548\uB0B4" },
117
+ /** 진행 중 — 스피너 + 자동 닫힘 없음. Update로 결과 변형으로 바꾼다. */
118
+ Loading: { loading: true, autoClose: false, withCloseButton: false, title: "\uCC98\uB9AC \uC911" }
119
+ };
120
+ function createToast(defaults) {
121
+ return function SdToast2(message, options) {
122
+ return notifications.show(__spreadValues(__spreadProps(__spreadValues({}, defaults), { message }), options));
123
+ };
124
+ }
125
+ var SdToast = {
126
+ Success: createToast(toastStyles.Success),
127
+ Error: createToast(toastStyles.Error),
128
+ Warning: createToast(toastStyles.Warning),
129
+ Info: createToast(toastStyles.Info),
130
+ Loading: createToast(toastStyles.Loading),
131
+ /**
132
+ * 이미 떠 있는 알림을 다른 변형으로 교체한다 — `Loading` → `Success`/`Error` 전환용.
133
+ * `loading: false`·`autoClose`를 먼저 깔아 두므로 스피너가 그대로 남지 않는다.
134
+ */
135
+ Update: (id, variant, message, options) => notifications.update(__spreadValues(__spreadProps(__spreadValues({
136
+ id,
137
+ loading: false,
138
+ autoClose: 4e3,
139
+ withCloseButton: true
140
+ }, toastStyles[variant]), {
141
+ message
142
+ }), options)),
143
+ /** 특정 알림 닫기 */
144
+ Hide: (id) => notifications.hide(id),
145
+ /** 떠 있는 알림 전부 닫기 */
146
+ Clean: () => notifications.clean()
147
+ };
148
+ var SdToastSuccess = SdToast.Success;
149
+ var SdToastError = SdToast.Error;
150
+ var SdToastWarning = SdToast.Warning;
151
+ var SdToastInfo = SdToast.Info;
152
+ var SdToastLoading = SdToast.Loading;
153
+ var SdToastUpdate = SdToast.Update;
154
+ var SdToastHide = SdToast.Hide;
155
+ var SdToastClean = SdToast.Clean;
156
+
157
+ // util/finalize.util.ts
158
+ function runFinalizers(finalize, label = "finalize") {
159
+ return __async(this, null, function* () {
160
+ if (!finalize) return;
161
+ const list = Array.isArray(finalize) ? finalize : [finalize];
162
+ for (const fn of list) {
163
+ try {
164
+ yield fn();
165
+ } catch (caught) {
166
+ console.error(`[${label}] finalize\uC5D0\uC11C \uC608\uC678\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4.`, caught);
167
+ }
168
+ }
169
+ });
170
+ }
171
+
172
+ // store/form.state.ts
173
+ var EMPTY_ERRORS = {};
174
+ function readChangePayload(payload) {
175
+ if (payload && typeof payload === "object" && "currentTarget" in payload) {
176
+ const target = payload.currentTarget;
177
+ if (target && typeof target === "object" && "value" in target) {
178
+ return target.type === "checkbox" ? target.checked : target.value;
179
+ }
180
+ }
181
+ return payload;
182
+ }
183
+ function blankEntry(values) {
184
+ return { values, errors: {}, submitting: false, error: null };
185
+ }
186
+ function patch(state, formId, next) {
187
+ const current = state.forms[formId];
188
+ if (!current) return state;
189
+ return __spreadProps(__spreadValues({}, state), { forms: __spreadProps(__spreadValues({}, state.forms), { [formId]: __spreadValues(__spreadValues({}, current), next) }) });
190
+ }
191
+ var useFormStore = create3()((set) => ({
192
+ forms: {},
193
+ ensureForm: (formId, initialValues) => set(
194
+ (state) => state.forms[formId] ? state : __spreadProps(__spreadValues({}, state), { forms: __spreadProps(__spreadValues({}, state.forms), { [formId]: blankEntry(initialValues) }) })
195
+ ),
196
+ setValue: (formId, name, value) => set((state) => {
197
+ const current = state.forms[formId];
198
+ if (!current) return state;
199
+ const _a = current.errors, { [name]: _removed } = _a, restErrors = __objRest(_a, [__restKey(name)]);
200
+ return patch(state, formId, {
201
+ values: __spreadProps(__spreadValues({}, current.values), { [name]: value }),
202
+ errors: restErrors
203
+ });
204
+ }),
205
+ setValues: (formId, values) => set((state) => {
206
+ const current = state.forms[formId];
207
+ if (!current) return state;
208
+ return patch(state, formId, { values: __spreadValues(__spreadValues({}, current.values), values) });
209
+ }),
210
+ setErrors: (formId, errors) => set((state) => patch(state, formId, { errors })),
211
+ setSubmitting: (formId, submitting) => set((state) => patch(state, formId, { submitting })),
212
+ setError: (formId, error) => set((state) => patch(state, formId, { error })),
213
+ resetForm: (formId, initialValues) => set((state) => __spreadProps(__spreadValues({}, state), { forms: __spreadProps(__spreadValues({}, state.forms), { [formId]: blankEntry(initialValues) }) })),
214
+ removeForm: (formId) => set((state) => {
215
+ const _a = state.forms, { [formId]: _removed } = _a, rest = __objRest(_a, [__restKey(formId)]);
216
+ return __spreadProps(__spreadValues({}, state), { forms: rest });
217
+ })
218
+ }));
219
+ function useSdForm({
220
+ id,
221
+ initialValues,
222
+ rules,
223
+ onSubmit,
224
+ successMessage,
225
+ errorMessage,
226
+ resetOnSuccess,
227
+ onSuccess,
228
+ onError,
229
+ finalize
230
+ }) {
231
+ var _a, _b, _c, _d;
232
+ const entry = useFormStore((state) => state.forms[id]);
233
+ const store = useFormStore;
234
+ useEffect2(() => {
235
+ store.getState().ensureForm(id, initialValues);
236
+ }, [id]);
237
+ const values = (_a = entry == null ? void 0 : entry.values) != null ? _a : initialValues;
238
+ const errors = (_b = entry == null ? void 0 : entry.errors) != null ? _b : EMPTY_ERRORS;
239
+ const submitting = (_c = entry == null ? void 0 : entry.submitting) != null ? _c : false;
240
+ const error = (_d = entry == null ? void 0 : entry.error) != null ? _d : null;
241
+ const setValue = useCallback(
242
+ (name, value) => store.getState().setValue(id, String(name), value),
243
+ [id, store]
244
+ );
245
+ const setValues = useCallback(
246
+ (next) => store.getState().setValues(id, next),
247
+ [id, store]
248
+ );
249
+ const reset = useCallback(
250
+ () => store.getState().resetForm(id, initialValues),
251
+ // initialValues 참조가 바뀌어도 reset의 의미는 같다.
252
+ // eslint-disable-next-line react-hooks/exhaustive-deps
253
+ [id, store]
254
+ );
255
+ const getInputProps = useCallback(
256
+ (name, options) => {
257
+ const key = String(name);
258
+ const fieldError = errors[name];
259
+ const current = values[name];
260
+ const onChange = (payload) => store.getState().setValue(id, key, readChangePayload(payload));
261
+ if ((options == null ? void 0 : options.type) === "checkbox") {
262
+ return { checked: Boolean(current), error: fieldError, onChange };
263
+ }
264
+ return {
265
+ // undefined일 때만 ''로 메운다 — null은 그대로 둬야 Select·Date가 "값 없음"으로 읽는다.
266
+ value: current === void 0 ? "" : current,
267
+ error: fieldError,
268
+ onChange
269
+ };
270
+ },
271
+ [errors, id, store, values]
272
+ );
273
+ const handleSubmit = useCallback(
274
+ (event) => __async(null, null, function* () {
275
+ var _a2, _b2;
276
+ event == null ? void 0 : event.preventDefault();
277
+ const state = store.getState();
278
+ const current = state.forms[id];
279
+ if (current == null ? void 0 : current.submitting) return;
280
+ const currentValues = (_a2 = current == null ? void 0 : current.values) != null ? _a2 : initialValues;
281
+ if (rules) {
282
+ const found = {};
283
+ for (const [name, rule] of Object.entries(rules)) {
284
+ const message = rule == null ? void 0 : rule(currentValues[name], currentValues);
285
+ if (message) found[name] = message;
286
+ }
287
+ if (Object.keys(found).length > 0) {
288
+ state.setErrors(id, found);
289
+ return;
290
+ }
291
+ }
292
+ state.setErrors(id, {});
293
+ state.setError(id, null);
294
+ state.setSubmitting(id, true);
295
+ try {
296
+ yield onSubmit(currentValues);
297
+ if (successMessage !== false) SdToast.Success(successMessage != null ? successMessage : "\uC800\uC7A5\uD588\uC2B5\uB2C8\uB2E4.");
298
+ if (resetOnSuccess) state.resetForm(id, initialValues);
299
+ onSuccess == null ? void 0 : onSuccess(currentValues);
300
+ } catch (caught) {
301
+ const message = (_b2 = errorMessage == null ? void 0 : errorMessage(caught)) != null ? _b2 : caught instanceof Error ? caught.message : "\uC694\uCCAD\uC744 \uCC98\uB9AC\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.";
302
+ state.setError(id, message);
303
+ SdToast.Error(message);
304
+ onError == null ? void 0 : onError(caught);
305
+ } finally {
306
+ store.getState().setSubmitting(id, false);
307
+ yield runFinalizers(finalize, "useSdForm");
308
+ }
309
+ }),
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ [
312
+ id,
313
+ store,
314
+ rules,
315
+ onSubmit,
316
+ successMessage,
317
+ errorMessage,
318
+ resetOnSuccess,
319
+ onSuccess,
320
+ onError,
321
+ finalize
322
+ ]
323
+ );
324
+ return useMemo(
325
+ () => ({
326
+ values,
327
+ errors,
328
+ submitting,
329
+ error,
330
+ setValue,
331
+ setValues,
332
+ reset,
333
+ getInputProps,
334
+ onSubmit: handleSubmit
335
+ }),
336
+ [values, errors, submitting, error, setValue, setValues, reset, getInputProps, handleSubmit]
337
+ );
338
+ }
339
+ var formRules = {
340
+ required: (message = "\uD544\uC218 \uC785\uB825 \uD56D\uBAA9\uC785\uB2C8\uB2E4.") => (value) => value === void 0 || value === null || String(value).trim() === "" ? message : null,
341
+ email: (message = "\uC774\uBA54\uC77C \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.") => (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value != null ? value : "")) ? null : message,
342
+ minLength: (length, message) => (value) => String(value != null ? value : "").length >= length ? null : message != null ? message : `${length}\uC790 \uC774\uC0C1 \uC785\uB825\uD558\uC138\uC694.`,
343
+ /** 체크박스 동의 — 반드시 true여야 한다. */
344
+ checked: (message = "\uB3D9\uC758\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.") => (value) => value === true ? null : message,
345
+ /** 다른 필드와 같은 값인지 — 비밀번호 확인 등. */
346
+ sameAs: (other, message = "\uAC12\uC774 \uC77C\uCE58\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.") => (value, values) => value === values[other] ? null : message
347
+ };
348
+ export {
349
+ formRules,
350
+ useAuthHydrated,
351
+ useAuthStore,
352
+ useFormStore,
353
+ useSdForm,
354
+ useUiStore
355
+ };