@sayren/mcp 0.6.1 → 0.8.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.
@@ -1,11 +1,15 @@
1
+ import { zodResolver } from "@hookform/resolvers/zod";
1
2
  import { ApiError, signupRequestSchema } from "@sayren/storefront-sdk";
2
3
  import { createFileRoute, Link, redirect } from "@tanstack/react-router";
3
4
  import { createServerFn } from "@tanstack/react-start";
4
- import { useId, useState } from "react";
5
+ import { useState } from "react";
6
+ import { useForm } from "react-hook-form";
5
7
  import { z } from "zod";
6
8
  import { SubmitButton } from "../components/submit-button";
9
+ import { TextField } from "../components/text-field";
7
10
  import { apiFor, authFor } from "../lib/api.server";
8
11
  import { clearCartToken, readCartToken } from "../lib/cart-session.server";
12
+ import { type SignupForm, type SignupFormInput, signupFormSchema } from "../lib/form-schemas";
9
13
  import { pageTitle } from "../lib/page-title";
10
14
  import { safeRedirect } from "../lib/safe-redirect";
11
15
  import { signIn } from "../lib/session.server";
@@ -90,57 +94,72 @@ export const Route = createFileRoute("/signup")({
90
94
  function Signup() {
91
95
  const { redirectTo } = Route.useSearch();
92
96
  const [error, setError] = useState<string | null>(null);
93
- const [pending, setPending] = useState(false);
97
+ const [leaving, setLeaving] = useState(false);
98
+ const form = useForm<SignupFormInput, unknown, SignupForm>({
99
+ resolver: zodResolver(signupFormSchema),
100
+ // 기본값을 주지 않는다 — 주면 등록할 때 DOM을 덮어 하이드레이션 전에 입력·체크한 값이 사라진다.
101
+ // 체크박스는 DOM에서 boolean으로 읽힌다
102
+ });
103
+ const { errors, isSubmitting } = form.formState;
94
104
 
95
- const submit = (event: React.FormEvent<HTMLFormElement>) => {
96
- event.preventDefault();
97
- const form = new FormData(event.currentTarget);
98
- const text = (name: string) => String(form.get(name) ?? "");
99
- setPending(true);
105
+ const submit = form.handleSubmit(async (values) => {
100
106
  setError(null);
101
- void signUp({
102
- data: {
103
- email: text("email"),
104
- password: text("password"),
105
- name: text("name"),
106
- phone: text("phone"),
107
- terms: form.get("terms") === "on",
108
- privacy: form.get("privacy") === "on",
109
- marketing: form.get("marketing") === "on",
110
- redirectTo,
111
- },
112
- })
113
- .then((result) => {
114
- // 로그인 상태가 모든 화면에 새로 반영되도록 문서째 이동한다
115
- if (result.redirectTo) window.location.assign(result.redirectTo);
116
- else {
117
- setError(result.error);
118
- setPending(false);
119
- }
120
- })
121
- .catch(() => {
122
- setError("가입하지 못했습니다. 잠시 후 다시 시도해 주십시오");
123
- setPending(false);
107
+ try {
108
+ const result = await signUp({
109
+ data: {
110
+ email: values.email,
111
+ password: values.password,
112
+ name: values.name,
113
+ phone: values.phone,
114
+ terms: values.agreements.terms,
115
+ privacy: values.agreements.privacy,
116
+ marketing: values.agreements.marketing,
117
+ redirectTo,
118
+ },
124
119
  });
125
- };
120
+ // 로그인 상태가 모든 화면에 새로 반영되도록 문서째 이동한다. 이동하는 동안 버튼을 다시 열지 않는다
121
+ if (result.redirectTo) {
122
+ setLeaving(true);
123
+ window.location.assign(result.redirectTo);
124
+ } else setError(result.error);
125
+ } catch {
126
+ setError("가입하지 못했습니다. 잠시 후 다시 시도해 주십시오");
127
+ }
128
+ });
129
+ const agreementError = errors.agreements?.terms?.message ?? errors.agreements?.privacy?.message;
126
130
 
127
131
  return (
128
132
  <div className="mx-auto max-w-sm space-y-5 py-10">
129
133
  <h1 className="font-bold text-2xl">회원가입</h1>
130
134
  <form onSubmit={submit} className="space-y-3">
131
- <TextField label="이메일" name="email" type="email" autoComplete="email" required />
135
+ <TextField
136
+ label="이메일"
137
+ registration={form.register("email")}
138
+ error={errors.email?.message}
139
+ type="email"
140
+ autoComplete="email"
141
+ required
142
+ />
132
143
  <TextField
133
144
  label="비밀번호"
134
- name="password"
145
+ registration={form.register("password")}
146
+ error={errors.password?.message}
135
147
  type="password"
136
148
  autoComplete="new-password"
137
149
  required
138
150
  hint="10자 이상, 영문·숫자·특수문자 중 2종 이상"
139
151
  />
140
- <TextField label="이름" name="name" autoComplete="name" required />
152
+ <TextField
153
+ label="이름"
154
+ registration={form.register("name")}
155
+ error={errors.name?.message}
156
+ autoComplete="name"
157
+ required
158
+ />
141
159
  <TextField
142
160
  label="휴대폰 번호"
143
- name="phone"
161
+ registration={form.register("phone")}
162
+ error={errors.phone?.message}
144
163
  type="tel"
145
164
  autoComplete="tel"
146
165
  placeholder="01012345678"
@@ -149,21 +168,22 @@ function Signup() {
149
168
  <fieldset className="space-y-2 rounded-md border border-line p-3 text-sm">
150
169
  <legend className="px-1 font-medium">약관 동의</legend>
151
170
  <label className="flex items-center gap-2">
152
- <input type="checkbox" name="terms" required />
171
+ <input type="checkbox" {...form.register("agreements.terms")} required />
153
172
  이용약관 동의 (필수)
154
173
  </label>
155
174
  <label className="flex items-center gap-2">
156
- <input type="checkbox" name="privacy" required />
175
+ <input type="checkbox" {...form.register("agreements.privacy")} required />
157
176
  개인정보 수집이용 동의 (필수)
158
177
  </label>
159
178
  <label className="flex items-center gap-2">
160
- <input type="checkbox" name="marketing" />
179
+ <input type="checkbox" {...form.register("agreements.marketing")} />
161
180
  마케팅 수신 동의 (선택)
162
181
  </label>
182
+ {agreementError ? <p className="text-point text-xs">{agreementError}</p> : null}
163
183
  </fieldset>
164
184
 
165
185
  <SubmitButton
166
- disabled={pending}
186
+ disabled={isSubmitting || leaving}
167
187
  className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
168
188
  >
169
189
  가입하기
@@ -183,53 +203,3 @@ function Signup() {
183
203
  </div>
184
204
  );
185
205
  }
186
-
187
- function TextField({
188
- label,
189
- name,
190
- type = "text",
191
- autoComplete,
192
- placeholder,
193
- required,
194
- hint,
195
- }: {
196
- label: string;
197
- name: string;
198
- type?: string;
199
- autoComplete?: string;
200
- placeholder?: string;
201
- required?: boolean;
202
- hint?: string;
203
- }) {
204
- const hintId = useId();
205
- return (
206
- <label className="block space-y-1">
207
- <span className="text-sm">
208
- {label}
209
- {/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
210
- {required ? (
211
- <span aria-hidden="true" className="text-point">
212
- {" "}
213
- *
214
- </span>
215
- ) : null}
216
- </span>
217
- <input
218
- type={type}
219
- name={name}
220
- autoComplete={autoComplete}
221
- placeholder={placeholder}
222
- required={required}
223
- // 이름은 라벨 글자 그대로다(필수 표시·안내 문구는 빼고) — 안내는 aria-describedby로 잇는다
224
- aria-label={label}
225
- aria-describedby={hint ? hintId : undefined}
226
- className="h-11 w-full rounded-md border border-line px-3"
227
- />
228
- {hint ? (
229
- <span id={hintId} className="block text-muted text-xs">
230
- {hint}
231
- </span>
232
- ) : null}
233
- </label>
234
- );
235
- }