@sayren/mcp 0.5.1 → 0.6.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,234 @@
1
+ import { ApiError, signupRequestSchema } from "@sayren/storefront-sdk";
2
+ import { createFileRoute, Link, redirect } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
4
+ import { useId, useState } from "react";
5
+ import { z } from "zod";
6
+ import { SubmitButton } from "../components/submit-button";
7
+ import { apiFor, authFor } from "../lib/api.server";
8
+ import { clearCartToken, readCartToken } from "../lib/cart-session.server";
9
+ import { safeRedirect } from "../lib/safe-redirect";
10
+ import { signIn } from "../lib/session.server";
11
+
12
+ const signupSearch = z.object({
13
+ redirectTo: z.string().optional().catch(undefined),
14
+ });
15
+
16
+ /**
17
+ * 가입 화면은 이메일·비밀번호 로그인이 켜진 스토어에서만 연다. 꺼져 있으면 서버가 403
18
+ * `PASSWORD_LOGIN_DISABLED`로 거절하므로 로그인 화면(소셜 로그인)으로 보낸다.
19
+ */
20
+ const getSignupAvailable = createServerFn({ method: "GET" }).handler(async () => {
21
+ const store = await apiFor().store.get();
22
+ if (!store.loginMethods.includes("password")) throw redirect({ to: "/login" });
23
+ return null;
24
+ });
25
+
26
+ /**
27
+ * 가입 — 약관·개인정보 수집이용 동의는 필수이고 마케팅 수신은 선택이다. 서버가 가입과 같은 트랜잭션에서
28
+ * 동의 이력을 남긴다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면 회원 장바구니로 합쳐지고 그 토큰은 닫힌다.
29
+ */
30
+ const signUp = createServerFn({ method: "POST" })
31
+ .validator(
32
+ z.object({
33
+ email: z.string(),
34
+ password: z.string(),
35
+ name: z.string(),
36
+ phone: z.string(),
37
+ terms: z.boolean(),
38
+ privacy: z.boolean(),
39
+ marketing: z.boolean(),
40
+ redirectTo: z.unknown(),
41
+ }),
42
+ )
43
+ .handler(async ({ data }) => {
44
+ const cartToken = readCartToken();
45
+ // 입력 규칙(비밀번호 조합·휴대폰 형식)은 SDK 스키마가 원천이다 — 서버에 보내기 전에 같은 규칙으로 안내한다
46
+ const parsed = signupRequestSchema.safeParse({
47
+ email: data.email.trim(),
48
+ password: data.password,
49
+ name: data.name.trim(),
50
+ phone: data.phone.trim() || undefined,
51
+ agreements: { terms: data.terms, privacy: data.privacy, marketing: data.marketing },
52
+ cartToken: cartToken ?? undefined,
53
+ });
54
+ if (!parsed.success) {
55
+ const issue = parsed.error.issues[0];
56
+ const field = issue?.path[0];
57
+ if (field === "agreements") return { redirectTo: null, error: "필수 약관에 동의해 주십시오" };
58
+ if (field === "email") return { redirectTo: null, error: "이메일 형식을 확인해 주십시오" };
59
+ if (field === "name") return { redirectTo: null, error: "이름을 입력해 주십시오" };
60
+ return { redirectTo: null, error: issue?.message ?? "입력한 내용을 확인해 주십시오" };
61
+ }
62
+ try {
63
+ signIn(await authFor().signUp(parsed.data));
64
+ if (cartToken) clearCartToken();
65
+ // 어디서 왔는지 모르면 홈으로 간다
66
+ return { redirectTo: safeRedirect(data.redirectTo ?? "/"), error: null };
67
+ } catch (error) {
68
+ return { redirectTo: null, error: signupErrorMessage(error) };
69
+ }
70
+ });
71
+
72
+ function signupErrorMessage(error: unknown): string {
73
+ if (error instanceof ApiError) {
74
+ if (error.code === "EMAIL_ALREADY_EXISTS") return "이미 가입된 이메일입니다. 로그인해 주십시오";
75
+ if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인으로만 가입합니다";
76
+ if (error.status === 400) return "입력한 내용을 확인해 주십시오";
77
+ if (error.status === 429) return "요청이 많습니다. 잠시 후 다시 시도해 주십시오";
78
+ }
79
+ return "가입하지 못했습니다. 잠시 후 다시 시도해 주십시오";
80
+ }
81
+
82
+ export const Route = createFileRoute("/signup")({
83
+ validateSearch: signupSearch,
84
+ loader: () => getSignupAvailable(),
85
+ head: () => ({ meta: [{ title: "회원가입" }] }),
86
+ component: Signup,
87
+ });
88
+
89
+ function Signup() {
90
+ const { redirectTo } = Route.useSearch();
91
+ const [error, setError] = useState<string | null>(null);
92
+ const [pending, setPending] = useState(false);
93
+
94
+ const submit = (event: React.FormEvent<HTMLFormElement>) => {
95
+ event.preventDefault();
96
+ const form = new FormData(event.currentTarget);
97
+ const text = (name: string) => String(form.get(name) ?? "");
98
+ setPending(true);
99
+ setError(null);
100
+ void signUp({
101
+ data: {
102
+ email: text("email"),
103
+ password: text("password"),
104
+ name: text("name"),
105
+ phone: text("phone"),
106
+ terms: form.get("terms") === "on",
107
+ privacy: form.get("privacy") === "on",
108
+ marketing: form.get("marketing") === "on",
109
+ redirectTo,
110
+ },
111
+ })
112
+ .then((result) => {
113
+ // 로그인 상태가 모든 화면에 새로 반영되도록 문서째 이동한다
114
+ if (result.redirectTo) window.location.assign(result.redirectTo);
115
+ else {
116
+ setError(result.error);
117
+ setPending(false);
118
+ }
119
+ })
120
+ .catch(() => {
121
+ setError("가입하지 못했습니다. 잠시 후 다시 시도해 주십시오");
122
+ setPending(false);
123
+ });
124
+ };
125
+
126
+ return (
127
+ <div className="mx-auto max-w-sm space-y-5 py-10">
128
+ <h1 className="font-bold text-2xl">회원가입</h1>
129
+ <form onSubmit={submit} className="space-y-3">
130
+ <TextField label="이메일" name="email" type="email" autoComplete="email" required />
131
+ <TextField
132
+ label="비밀번호"
133
+ name="password"
134
+ type="password"
135
+ autoComplete="new-password"
136
+ required
137
+ hint="10자 이상, 영문·숫자·특수문자 중 2종 이상"
138
+ />
139
+ <TextField label="이름" name="name" autoComplete="name" required />
140
+ <TextField
141
+ label="휴대폰 번호"
142
+ name="phone"
143
+ type="tel"
144
+ autoComplete="tel"
145
+ placeholder="01012345678"
146
+ />
147
+
148
+ <fieldset className="space-y-2 rounded-md border border-line p-3 text-sm">
149
+ <legend className="px-1 font-medium">약관 동의</legend>
150
+ <label className="flex items-center gap-2">
151
+ <input type="checkbox" name="terms" required />
152
+ 이용약관 동의 (필수)
153
+ </label>
154
+ <label className="flex items-center gap-2">
155
+ <input type="checkbox" name="privacy" required />
156
+ 개인정보 수집이용 동의 (필수)
157
+ </label>
158
+ <label className="flex items-center gap-2">
159
+ <input type="checkbox" name="marketing" />
160
+ 마케팅 수신 동의 (선택)
161
+ </label>
162
+ </fieldset>
163
+
164
+ <SubmitButton
165
+ disabled={pending}
166
+ className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
167
+ >
168
+ 가입하기
169
+ </SubmitButton>
170
+ </form>
171
+ {error ? (
172
+ <p role="alert" className="text-point text-sm">
173
+ {error}
174
+ </p>
175
+ ) : null}
176
+ <p className="text-center text-muted text-sm">
177
+ 이미 회원이십니까?{" "}
178
+ <Link to="/login" search={{ redirectTo }} className="text-ink underline">
179
+ 로그인
180
+ </Link>
181
+ </p>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ function TextField({
187
+ label,
188
+ name,
189
+ type = "text",
190
+ autoComplete,
191
+ placeholder,
192
+ required,
193
+ hint,
194
+ }: {
195
+ label: string;
196
+ name: string;
197
+ type?: string;
198
+ autoComplete?: string;
199
+ placeholder?: string;
200
+ required?: boolean;
201
+ hint?: string;
202
+ }) {
203
+ const hintId = useId();
204
+ return (
205
+ <label className="block space-y-1">
206
+ <span className="text-sm">
207
+ {label}
208
+ {/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
209
+ {required ? (
210
+ <span aria-hidden="true" className="text-point">
211
+ {" "}
212
+ *
213
+ </span>
214
+ ) : null}
215
+ </span>
216
+ <input
217
+ type={type}
218
+ name={name}
219
+ autoComplete={autoComplete}
220
+ placeholder={placeholder}
221
+ required={required}
222
+ // 이름은 라벨 글자 그대로다(필수 표시·안내 문구는 빼고) — 안내는 aria-describedby로 잇는다
223
+ aria-label={label}
224
+ aria-describedby={hint ? hintId : undefined}
225
+ className="h-11 w-full rounded-md border border-line px-3"
226
+ />
227
+ {hint ? (
228
+ <span id={hintId} className="block text-muted text-xs">
229
+ {hint}
230
+ </span>
231
+ ) : null}
232
+ </label>
233
+ );
234
+ }