@farmzone/fz-template-react 1.0.6 → 1.0.8

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 (64) hide show
  1. package/README.md +102 -102
  2. package/bin/create.js +108 -108
  3. package/package.json +24 -24
  4. package/template/.env.example +5 -5
  5. package/template/.prettierrc +9 -9
  6. package/template/eslint.config.js +26 -26
  7. package/template/index.css +32 -32
  8. package/template/index.html +19 -19
  9. package/template/package.json +54 -54
  10. package/template/pnpm-lock.yaml +4214 -4214
  11. package/template/public/mockServiceWorker.js +349 -349
  12. package/template/src/app/App.tsx +26 -26
  13. package/template/src/app/api/api.ts +178 -178
  14. package/template/src/app/api/queries.ts +335 -335
  15. package/template/src/app/api/queryKey.ts +7 -7
  16. package/template/src/app/api/token.ts +8 -7
  17. package/template/src/app/layout/Layout.tsx +33 -33
  18. package/template/src/app/layout/ListContents.tsx +9 -9
  19. package/template/src/app/layout/ListHeader.tsx +41 -41
  20. package/template/src/app/layout/MultiTabNav.tsx +106 -101
  21. package/template/src/app/layout/Sidebar.tsx +33 -33
  22. package/template/src/app/layout/UserInfo.tsx +95 -94
  23. package/template/src/app/layout/menu.ts +79 -55
  24. package/template/src/app/layout/tabSwitchStore.ts +11 -11
  25. package/template/src/app/router/Router.tsx +56 -56
  26. package/template/src/app/store/index.ts +26 -26
  27. package/template/src/index.tsx +21 -21
  28. package/template/src/mocks/browser.ts +17 -17
  29. package/template/src/mocks/handlers.ts +43 -43
  30. package/template/src/mocks/scenarios.ts +57 -57
  31. package/template/src/pages/dashboard/index.tsx +541 -541
  32. package/template/src/pages/error/Error.tsx +29 -29
  33. package/template/src/pages/error/NotFound.tsx +27 -27
  34. package/template/src/pages/login/index.tsx +317 -317
  35. package/template/src/pages/post/PostFormModal.tsx +128 -128
  36. package/template/src/pages/post/detail/index.tsx +545 -545
  37. package/template/src/pages/post/index.tsx +266 -266
  38. package/template/src/pages/sample/SampleFormModal.tsx +188 -188
  39. package/template/src/pages/sample/detail/index.tsx +551 -517
  40. package/template/src/pages/sample/index.tsx +298 -298
  41. package/template/src/pages/sample/modal/index.tsx +308 -308
  42. package/template/src/pages/system/log/index.tsx +173 -173
  43. package/template/src/pages/user/config/columns.tsx +102 -102
  44. package/template/src/pages/user/config/schema.ts +54 -54
  45. package/template/src/pages/user/index.tsx +704 -650
  46. package/template/src/shared/components/CommentInput.tsx +243 -243
  47. package/template/src/shared/components/FilePreviewCard.tsx +71 -71
  48. package/template/src/shared/config/text.ts +27 -27
  49. package/template/src/shared/config/type.ts +40 -40
  50. package/template/src/shared/utils/format.ts +11 -11
  51. package/template/src/types/auth.ts +10 -10
  52. package/template/src/types/comment.ts +33 -33
  53. package/template/src/types/common.ts +19 -19
  54. package/template/src/types/dashboard.ts +53 -53
  55. package/template/src/types/index.ts +16 -16
  56. package/template/src/types/log.ts +21 -21
  57. package/template/src/types/post.ts +32 -32
  58. package/template/src/types/sample.ts +33 -33
  59. package/template/src/types/user.ts +51 -51
  60. package/template/src/vite-env.d.ts +10 -10
  61. package/template/tsconfig.app.json +32 -32
  62. package/template/tsconfig.json +7 -7
  63. package/template/tsconfig.node.json +26 -26
  64. package/template/vite.config.ts +13 -13
@@ -1,317 +1,317 @@
1
- import dayjs from "dayjs";
2
- import utc from "dayjs/plugin/utc";
3
- import { zodResolver } from "@hookform/resolvers/zod";
4
- import { debounce } from "es-toolkit";
5
- import Cookies from "js-cookie";
6
- import { Clock, Eye, EyeOff, Phone, X } from "lucide-react";
7
- import { useEffect, useMemo, useState } from "react";
8
- import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
9
- import { useNavigate } from "react-router";
10
- import { z } from "zod";
11
-
12
- import { usePostLogin } from "@/app/api/queries";
13
- import { clearUserToken } from "@/app/api/token";
14
- import { useUserStore } from "@/app/store";
15
- import { Button, Checkbox, Modal, ModalHeader } from "@farmzone/fz-react-ui";
16
-
17
- dayjs.extend(utc);
18
-
19
- const REMEMBERED_ID_KEY = "rememberedId";
20
- const FORM_ID = "login-form";
21
-
22
- const INPUT_CLASS =
23
- "w-full px-4 py-2 bg-white/90 border border-gray-200 rounded-md text-base placeholder:text-gray-400 focus:border-neutral-300 focus:outline-none";
24
-
25
- const loginSchema = z.object({
26
- userId: z.string().min(1, "아이디를 입력해 주세요."),
27
- password: z.string().min(1, "비밀번호를 입력해 주세요."),
28
- rememberId: z.boolean(),
29
- });
30
-
31
- type LoginFormData = z.infer<typeof loginSchema>;
32
-
33
- function LoginFormFields({ isLoginPending }: { isLoginPending: boolean }) {
34
- const { control, reset, watch } = useFormContext<LoginFormData>();
35
- const [showPassword, setShowPassword] = useState(false);
36
- const [isLoginDebouncing, setIsLoginDebouncing] = useState(false);
37
-
38
- const userId = watch("userId");
39
- const password = watch("password");
40
-
41
- useEffect(() => {
42
- const savedId = localStorage.getItem(REMEMBERED_ID_KEY);
43
- if (savedId) {
44
- reset({ userId: savedId, password: "", rememberId: true });
45
- }
46
- }, [reset]);
47
-
48
- const debouncedLoginClick = useMemo(
49
- () =>
50
- debounce(() => {
51
- setIsLoginDebouncing(false);
52
- (document.getElementById(FORM_ID) as HTMLFormElement | null)?.requestSubmit();
53
- }, 500),
54
- [],
55
- );
56
-
57
- useEffect(() => {
58
- return () => {
59
- debouncedLoginClick.cancel();
60
- setIsLoginDebouncing(false);
61
- };
62
- }, [debouncedLoginClick]);
63
-
64
- const canSubmit = Boolean(String(userId ?? "").trim() && String(password ?? "").trim());
65
-
66
- const handleLoginButtonClick = () => {
67
- if (!canSubmit) return;
68
- setIsLoginDebouncing(true);
69
- debouncedLoginClick();
70
- };
71
-
72
- return (
73
- <div className="space-y-4.5">
74
- <Controller
75
- name="userId"
76
- control={control}
77
- render={({ field }) => (
78
- <div className="space-y-2">
79
- <label htmlFor="login-userId" className="block text-sm font-semibold text-gray-700">
80
- 아이디
81
- </label>
82
- <input
83
- id="login-userId"
84
- name={field.name}
85
- ref={field.ref}
86
- value={String(field.value ?? "")}
87
- onChange={field.onChange}
88
- onBlur={field.onBlur}
89
- placeholder="아이디를 입력하세요"
90
- className={INPUT_CLASS}
91
- maxLength={20}
92
- autoComplete="username"
93
- onKeyDown={(e) => {
94
- if (e.key === "Enter") handleLoginButtonClick();
95
- }}
96
- />
97
- </div>
98
- )}
99
- />
100
-
101
- <Controller
102
- name="password"
103
- control={control}
104
- render={({ field }) => {
105
- const passwordValue = String(field.value ?? "");
106
- return (
107
- <div className="space-y-2">
108
- <label htmlFor="login-password" className="block text-sm font-semibold text-gray-700">
109
- 비밀번호
110
- </label>
111
- <div className="relative">
112
- <input
113
- id="login-password"
114
- name={field.name}
115
- ref={field.ref}
116
- type={showPassword ? "text" : "password"}
117
- value={passwordValue}
118
- onChange={field.onChange}
119
- onBlur={field.onBlur}
120
- placeholder="비밀번호를 입력하세요"
121
- className={INPUT_CLASS}
122
- maxLength={20}
123
- autoComplete="current-password"
124
- onKeyDown={(e) => {
125
- if (e.key === "Enter") handleLoginButtonClick();
126
- }}
127
- />
128
- <button
129
- type="button"
130
- className="absolute right-8 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-400 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-600"
131
- onClick={() => field.onChange("")}
132
- aria-label="비밀번호 지우기"
133
- >
134
- {passwordValue.length > 0 ? <X size={18} /> : null}
135
- </button>
136
- <button
137
- type="button"
138
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-400 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-600"
139
- onClick={() => setShowPassword((v) => !v)}
140
- aria-label={showPassword ? "비밀번호 숨기기" : "비밀번호 표시"}
141
- >
142
- {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
143
- </button>
144
- </div>
145
- </div>
146
- );
147
- }}
148
- />
149
-
150
- <div className="py-2">
151
- <Controller
152
- name="rememberId"
153
- control={control}
154
- render={({ field }) => (
155
- <Checkbox
156
- label="아이디 기억하기"
157
- checked={Boolean(field.value)}
158
- onChange={(checked) => field.onChange(checked)}
159
- wrapperClassName="justify-start"
160
- />
161
- )}
162
- />
163
- </div>
164
-
165
- <Button
166
- type="button"
167
- variant="save"
168
- disabled={!canSubmit || isLoginPending}
169
- aria-busy={isLoginDebouncing}
170
- onClick={handleLoginButtonClick}
171
- className={`relative h-12 w-full overflow-hidden rounded-md border-none py-3 text-lg font-semibold text-white outline-none transition-all duration-150 disabled:bg-gray-100 ${
172
- isLoginDebouncing || isLoginPending ? "opacity-50" : ""
173
- } ${!canSubmit ? "cursor-not-allowed text-gray-400" : "hover:border-blue-600"}`}
174
- >
175
- 로그인
176
- </Button>
177
- </div>
178
- );
179
- }
180
-
181
- export default function LoginPage() {
182
- const navigate = useNavigate();
183
- const { mutateAsync: postLogin, isPending: isLoginPending } = usePostLogin();
184
- const setUser = useUserStore((s) => s.setUser);
185
- const [isCustomerCenterOpen, setIsCustomerCenterOpen] = useState(false);
186
-
187
- const methods = useForm<LoginFormData>({
188
- resolver: zodResolver(loginSchema),
189
- defaultValues: { userId: "", password: "", rememberId: false },
190
- mode: "onSubmit",
191
- });
192
-
193
- useEffect(() => {
194
- const accessToken = Cookies.get("AccessToken");
195
- if (accessToken) {
196
- navigate("/", { replace: true });
197
- } else {
198
- clearUserToken();
199
- }
200
- }, [navigate]);
201
-
202
- const handleSubmit = methods.handleSubmit(async (data) => {
203
- try {
204
- const res = await postLogin({ userId: data.userId.trim(), password: data.password.trim() });
205
-
206
- const accessExpiry = dayjs.utc(res.accessTokenExpiresAt, "YYYY-MM-DD HH:mm:ss");
207
- const refreshExpiry = dayjs.utc(res.refreshTokenExpiresAt, "YYYY-MM-DD HH:mm:ss");
208
-
209
- Cookies.set("AccessToken", res.accessToken, { expires: accessExpiry.toDate() });
210
- Cookies.set("RefreshToken", res.refreshToken, { expires: refreshExpiry.toDate() });
211
- localStorage.setItem("currentId", res.id);
212
-
213
- if (data.rememberId) {
214
- localStorage.setItem(REMEMBERED_ID_KEY, data.userId.trim());
215
- } else {
216
- localStorage.removeItem(REMEMBERED_ID_KEY);
217
- }
218
-
219
- setUser({ id: res.id, userId: res.userId, name: res.name, role: res.role });
220
- navigate("/", { replace: true });
221
- } catch (error) {
222
- console.error(error);
223
- }
224
- });
225
-
226
- return (
227
- <div className="flex min-h-screen items-center justify-center bg-linear-to-br from-blue-50 via-white to-purple-50 p-4">
228
- <div className="w-full max-w-md shrink-0">
229
- {/* 카드 */}
230
- <div className="rounded-2xl border border-white/40 bg-white/80 px-8 py-10 shadow-2xl backdrop-blur-xl">
231
- {/* 로고 / 타이틀 */}
232
- <div className="mb-6 text-center">
233
- <div className="inline-flex items-center justify-center rounded-2xl bg-white">
234
- <img src="/favicon.ico" alt="logo" className="h-15 w-56 object-contain" />
235
- </div>
236
- </div>
237
-
238
- <FormProvider {...methods}>
239
- <form id={FORM_ID} onSubmit={handleSubmit} noValidate>
240
- <LoginFormFields isLoginPending={isLoginPending} />
241
- </form>
242
- </FormProvider>
243
- </div>
244
-
245
- {/* 고객센터 링크 */}
246
- <div className="mt-6 text-center">
247
- <p className="text-sm text-gray-600">
248
- 문의사항이 있으시면
249
- <button
250
- type="button"
251
- className="ml-1 cursor-pointer font-medium text-main transition-colors hover:underline"
252
- onClick={() => setIsCustomerCenterOpen(true)}
253
- >
254
- 고객센터
255
- </button>
256
- 로 연락 주세요
257
- </p>
258
- </div>
259
- </div>
260
-
261
- {/* 고객센터 모달 */}
262
- <Modal
263
- isOpen={isCustomerCenterOpen}
264
- onClose={() => setIsCustomerCenterOpen(false)}
265
- overlayClassName="min-h-dvh w-screen"
266
- contentClassName="max-w-md overflow-auto rounded-3xl bg-white"
267
- >
268
- <ModalHeader className="border-b border-blue-100 bg-linear-to-r from-blue-50 to-indigo-50 px-6 py-5 text-left">
269
- <div className="flex items-center gap-3">
270
- <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100">
271
- <Phone size={20} className="text-main" />
272
- </div>
273
- <h2 className="text-lg font-bold text-gray-900">고객센터</h2>
274
- </div>
275
- </ModalHeader>
276
-
277
- <div className="flex flex-col gap-6 px-6 pb-6 pt-4">
278
- <div className="flex flex-col gap-2">
279
- <div className="flex items-center gap-4 rounded-xl bg-blue-50 px-4 py-4">
280
- <div className="flex shrink-0 rounded-full bg-blue-100 p-2">
281
- <Phone size={17} className="text-blue-600" />
282
- </div>
283
- <div>
284
- <p className="text-xs font-medium text-gray-500">전화 문의</p>
285
- <a
286
- href="tel:1588-0000"
287
- className="text-xl font-bold tracking-wider text-blue-600 transition-colors hover:text-blue-700"
288
- >
289
- 1588-0000
290
- </a>
291
- </div>
292
- </div>
293
-
294
- <div className="flex items-center gap-4 rounded-xl bg-gray-50 px-4 py-4">
295
- <div className="flex shrink-0 rounded-full bg-gray-100 p-2">
296
- <Clock size={17} className="text-gray-500" />
297
- </div>
298
- <div>
299
- <p className="text-xs font-medium text-gray-500">운영 시간</p>
300
- <p className="mt-0.5 text-sm font-semibold text-gray-700">평일 09:00 ~ 18:00</p>
301
- <p className="text-xs text-gray-400">토·일·공휴일 휴무</p>
302
- </div>
303
- </div>
304
- </div>
305
-
306
- <Button
307
- type="button"
308
- onClick={() => setIsCustomerCenterOpen(false)}
309
- className="relative min-h-10 w-full overflow-hidden rounded-md border-none bg-sub-darkblue/85 py-3 text-lg font-semibold text-white outline-none transition-all duration-150 hover:bg-sub-darkblue/90 hover:text-white"
310
- >
311
- 닫기
312
- </Button>
313
- </div>
314
- </Modal>
315
- </div>
316
- );
317
- }
1
+ import dayjs from "dayjs";
2
+ import utc from "dayjs/plugin/utc";
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { debounce } from "es-toolkit";
5
+ import Cookies from "js-cookie";
6
+ import { Clock, Eye, EyeOff, Phone, X } from "lucide-react";
7
+ import { useEffect, useMemo, useState } from "react";
8
+ import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
9
+ import { useNavigate } from "react-router";
10
+ import { z } from "zod";
11
+
12
+ import { usePostLogin } from "@/app/api/queries";
13
+ import { clearUserToken } from "@/app/api/token";
14
+ import { useUserStore } from "@/app/store";
15
+ import { Button, Checkbox, Modal, ModalHeader } from "@farmzone/fz-react-ui";
16
+
17
+ dayjs.extend(utc);
18
+
19
+ const REMEMBERED_ID_KEY = "rememberedId";
20
+ const FORM_ID = "login-form";
21
+
22
+ const INPUT_CLASS =
23
+ "w-full px-4 py-2 bg-white/90 border border-gray-200 rounded-md text-base placeholder:text-gray-400 focus:border-neutral-300 focus:outline-none";
24
+
25
+ const loginSchema = z.object({
26
+ userId: z.string().min(1, "아이디를 입력해 주세요."),
27
+ password: z.string().min(1, "비밀번호를 입력해 주세요."),
28
+ rememberId: z.boolean(),
29
+ });
30
+
31
+ type LoginFormData = z.infer<typeof loginSchema>;
32
+
33
+ function LoginFormFields({ isLoginPending }: { isLoginPending: boolean }) {
34
+ const { control, reset, watch } = useFormContext<LoginFormData>();
35
+ const [showPassword, setShowPassword] = useState(false);
36
+ const [isLoginDebouncing, setIsLoginDebouncing] = useState(false);
37
+
38
+ const userId = watch("userId");
39
+ const password = watch("password");
40
+
41
+ useEffect(() => {
42
+ const savedId = localStorage.getItem(REMEMBERED_ID_KEY);
43
+ if (savedId) {
44
+ reset({ userId: savedId, password: "", rememberId: true });
45
+ }
46
+ }, [reset]);
47
+
48
+ const debouncedLoginClick = useMemo(
49
+ () =>
50
+ debounce(() => {
51
+ setIsLoginDebouncing(false);
52
+ (document.getElementById(FORM_ID) as HTMLFormElement | null)?.requestSubmit();
53
+ }, 500),
54
+ [],
55
+ );
56
+
57
+ useEffect(() => {
58
+ return () => {
59
+ debouncedLoginClick.cancel();
60
+ setIsLoginDebouncing(false);
61
+ };
62
+ }, [debouncedLoginClick]);
63
+
64
+ const canSubmit = Boolean(String(userId ?? "").trim() && String(password ?? "").trim());
65
+
66
+ const handleLoginButtonClick = () => {
67
+ if (!canSubmit) return;
68
+ setIsLoginDebouncing(true);
69
+ debouncedLoginClick();
70
+ };
71
+
72
+ return (
73
+ <div className="space-y-4.5">
74
+ <Controller
75
+ name="userId"
76
+ control={control}
77
+ render={({ field }) => (
78
+ <div className="space-y-2">
79
+ <label htmlFor="login-userId" className="block text-sm font-semibold text-gray-700">
80
+ 아이디
81
+ </label>
82
+ <input
83
+ id="login-userId"
84
+ name={field.name}
85
+ ref={field.ref}
86
+ value={String(field.value ?? "")}
87
+ onChange={field.onChange}
88
+ onBlur={field.onBlur}
89
+ placeholder="아이디를 입력하세요"
90
+ className={INPUT_CLASS}
91
+ maxLength={20}
92
+ autoComplete="username"
93
+ onKeyDown={(e) => {
94
+ if (e.key === "Enter") handleLoginButtonClick();
95
+ }}
96
+ />
97
+ </div>
98
+ )}
99
+ />
100
+
101
+ <Controller
102
+ name="password"
103
+ control={control}
104
+ render={({ field }) => {
105
+ const passwordValue = String(field.value ?? "");
106
+ return (
107
+ <div className="space-y-2">
108
+ <label htmlFor="login-password" className="block text-sm font-semibold text-gray-700">
109
+ 비밀번호
110
+ </label>
111
+ <div className="relative">
112
+ <input
113
+ id="login-password"
114
+ name={field.name}
115
+ ref={field.ref}
116
+ type={showPassword ? "text" : "password"}
117
+ value={passwordValue}
118
+ onChange={field.onChange}
119
+ onBlur={field.onBlur}
120
+ placeholder="비밀번호를 입력하세요"
121
+ className={INPUT_CLASS}
122
+ maxLength={20}
123
+ autoComplete="current-password"
124
+ onKeyDown={(e) => {
125
+ if (e.key === "Enter") handleLoginButtonClick();
126
+ }}
127
+ />
128
+ <button
129
+ type="button"
130
+ className="absolute right-8 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-400 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-600"
131
+ onClick={() => field.onChange("")}
132
+ aria-label="비밀번호 지우기"
133
+ >
134
+ {passwordValue.length > 0 ? <X size={18} /> : null}
135
+ </button>
136
+ <button
137
+ type="button"
138
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-400 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-600"
139
+ onClick={() => setShowPassword((v) => !v)}
140
+ aria-label={showPassword ? "비밀번호 숨기기" : "비밀번호 표시"}
141
+ >
142
+ {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
143
+ </button>
144
+ </div>
145
+ </div>
146
+ );
147
+ }}
148
+ />
149
+
150
+ <div className="py-2">
151
+ <Controller
152
+ name="rememberId"
153
+ control={control}
154
+ render={({ field }) => (
155
+ <Checkbox
156
+ label="아이디 기억하기"
157
+ checked={Boolean(field.value)}
158
+ onChange={(checked) => field.onChange(checked)}
159
+ wrapperClassName="justify-start"
160
+ />
161
+ )}
162
+ />
163
+ </div>
164
+
165
+ <Button
166
+ type="button"
167
+ variant="save"
168
+ disabled={!canSubmit || isLoginPending}
169
+ aria-busy={isLoginDebouncing}
170
+ onClick={handleLoginButtonClick}
171
+ className={`relative h-12 w-full overflow-hidden rounded-md border-none py-3 text-lg font-semibold text-white outline-none transition-all duration-150 disabled:bg-gray-100 ${
172
+ isLoginDebouncing || isLoginPending ? "opacity-50" : ""
173
+ } ${!canSubmit ? "cursor-not-allowed text-gray-400" : "hover:border-blue-600"}`}
174
+ >
175
+ 로그인
176
+ </Button>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ export default function LoginPage() {
182
+ const navigate = useNavigate();
183
+ const { mutateAsync: postLogin, isPending: isLoginPending } = usePostLogin();
184
+ const setUser = useUserStore((s) => s.setUser);
185
+ const [isCustomerCenterOpen, setIsCustomerCenterOpen] = useState(false);
186
+
187
+ const methods = useForm<LoginFormData>({
188
+ resolver: zodResolver(loginSchema),
189
+ defaultValues: { userId: "", password: "", rememberId: false },
190
+ mode: "onSubmit",
191
+ });
192
+
193
+ useEffect(() => {
194
+ const accessToken = Cookies.get("AccessToken");
195
+ if (accessToken) {
196
+ navigate("/", { replace: true });
197
+ } else {
198
+ clearUserToken();
199
+ }
200
+ }, [navigate]);
201
+
202
+ const handleSubmit = methods.handleSubmit(async (data) => {
203
+ try {
204
+ const res = await postLogin({ userId: data.userId.trim(), password: data.password.trim() });
205
+
206
+ const accessExpiry = dayjs.utc(res.accessTokenExpiresAt, "YYYY-MM-DD HH:mm:ss");
207
+ const refreshExpiry = dayjs.utc(res.refreshTokenExpiresAt, "YYYY-MM-DD HH:mm:ss");
208
+
209
+ Cookies.set("AccessToken", res.accessToken, { expires: accessExpiry.toDate() });
210
+ Cookies.set("RefreshToken", res.refreshToken, { expires: refreshExpiry.toDate() });
211
+ localStorage.setItem("currentId", res.id);
212
+
213
+ if (data.rememberId) {
214
+ localStorage.setItem(REMEMBERED_ID_KEY, data.userId.trim());
215
+ } else {
216
+ localStorage.removeItem(REMEMBERED_ID_KEY);
217
+ }
218
+
219
+ setUser({ id: res.id, userId: res.userId, name: res.name, role: res.role });
220
+ navigate("/", { replace: true });
221
+ } catch (error) {
222
+ console.error(error);
223
+ }
224
+ });
225
+
226
+ return (
227
+ <div className="flex min-h-screen items-center justify-center bg-linear-to-br from-blue-50 via-white to-purple-50 p-4">
228
+ <div className="w-full max-w-md shrink-0">
229
+ {/* 카드 */}
230
+ <div className="rounded-2xl border border-white/40 bg-white/80 px-8 py-10 shadow-2xl backdrop-blur-xl">
231
+ {/* 로고 / 타이틀 */}
232
+ <div className="mb-6 text-center">
233
+ <div className="inline-flex items-center justify-center rounded-2xl bg-white">
234
+ <img src="/favicon.ico" alt="logo" className="h-15 w-56 object-contain" />
235
+ </div>
236
+ </div>
237
+
238
+ <FormProvider {...methods}>
239
+ <form id={FORM_ID} onSubmit={handleSubmit} noValidate>
240
+ <LoginFormFields isLoginPending={isLoginPending} />
241
+ </form>
242
+ </FormProvider>
243
+ </div>
244
+
245
+ {/* 고객센터 링크 */}
246
+ <div className="mt-6 text-center">
247
+ <p className="text-sm text-gray-600">
248
+ 문의사항이 있으시면
249
+ <button
250
+ type="button"
251
+ className="ml-1 cursor-pointer font-medium text-main transition-colors hover:underline"
252
+ onClick={() => setIsCustomerCenterOpen(true)}
253
+ >
254
+ 고객센터
255
+ </button>
256
+ 로 연락 주세요
257
+ </p>
258
+ </div>
259
+ </div>
260
+
261
+ {/* 고객센터 모달 */}
262
+ <Modal
263
+ isOpen={isCustomerCenterOpen}
264
+ onClose={() => setIsCustomerCenterOpen(false)}
265
+ overlayClassName="min-h-dvh w-screen"
266
+ contentClassName="max-w-md overflow-auto rounded-3xl bg-white"
267
+ >
268
+ <ModalHeader className="border-b border-blue-100 bg-linear-to-r from-blue-50 to-indigo-50 px-6 py-5 text-left">
269
+ <div className="flex items-center gap-3">
270
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100">
271
+ <Phone size={20} className="text-main" />
272
+ </div>
273
+ <h2 className="text-lg font-bold text-gray-900">고객센터</h2>
274
+ </div>
275
+ </ModalHeader>
276
+
277
+ <div className="flex flex-col gap-6 px-6 pb-6 pt-4">
278
+ <div className="flex flex-col gap-2">
279
+ <div className="flex items-center gap-4 rounded-xl bg-blue-50 px-4 py-4">
280
+ <div className="flex shrink-0 rounded-full bg-blue-100 p-2">
281
+ <Phone size={17} className="text-blue-600" />
282
+ </div>
283
+ <div>
284
+ <p className="text-xs font-medium text-gray-500">전화 문의</p>
285
+ <a
286
+ href="tel:1588-0000"
287
+ className="text-xl font-bold tracking-wider text-blue-600 transition-colors hover:text-blue-700"
288
+ >
289
+ 1588-0000
290
+ </a>
291
+ </div>
292
+ </div>
293
+
294
+ <div className="flex items-center gap-4 rounded-xl bg-gray-50 px-4 py-4">
295
+ <div className="flex shrink-0 rounded-full bg-gray-100 p-2">
296
+ <Clock size={17} className="text-gray-500" />
297
+ </div>
298
+ <div>
299
+ <p className="text-xs font-medium text-gray-500">운영 시간</p>
300
+ <p className="mt-0.5 text-sm font-semibold text-gray-700">평일 09:00 ~ 18:00</p>
301
+ <p className="text-xs text-gray-400">토·일·공휴일 휴무</p>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ <Button
307
+ type="button"
308
+ onClick={() => setIsCustomerCenterOpen(false)}
309
+ className="relative min-h-10 w-full overflow-hidden rounded-md border-none bg-sub-darkblue/85 py-3 text-lg font-semibold text-white outline-none transition-all duration-150 hover:bg-sub-darkblue/90 hover:text-white"
310
+ >
311
+ 닫기
312
+ </Button>
313
+ </div>
314
+ </Modal>
315
+ </div>
316
+ );
317
+ }