@sayren/mcp 0.6.0 → 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.
Files changed (40) hide show
  1. package/dist/index.mjs +6 -4
  2. package/package.json +3 -3
  3. package/template/.template-meta.json +2 -2
  4. package/template/README.md +32 -2
  5. package/template/package.json +6 -0
  6. package/template/src/components/fulfillment-codes.tsx +110 -0
  7. package/template/src/components/fulfillment-downloads.tsx +88 -0
  8. package/template/src/components/text-field.tsx +71 -0
  9. package/template/src/lib/catalog-queries.ts +47 -0
  10. package/template/src/lib/code-reveal.test.ts +30 -0
  11. package/template/src/lib/code-reveal.ts +32 -0
  12. package/template/src/lib/coupon-code.test.ts +19 -0
  13. package/template/src/lib/coupon-code.ts +60 -0
  14. package/template/src/lib/download.test.ts +41 -0
  15. package/template/src/lib/download.ts +41 -0
  16. package/template/src/lib/form-schemas.test.ts +144 -0
  17. package/template/src/lib/form-schemas.ts +131 -0
  18. package/template/src/lib/guest-order.test.ts +18 -0
  19. package/template/src/lib/guest-order.ts +13 -0
  20. package/template/src/lib/order-status.test.ts +29 -0
  21. package/template/src/lib/order-status.ts +28 -2
  22. package/template/src/lib/page-title.test.ts +19 -0
  23. package/template/src/lib/page-title.ts +23 -0
  24. package/template/src/lib/products-search.test.ts +25 -0
  25. package/template/src/lib/products-search.ts +49 -0
  26. package/template/src/router.tsx +26 -1
  27. package/template/src/routes/__root.tsx +13 -6
  28. package/template/src/routes/account.tsx +2 -1
  29. package/template/src/routes/auth.callback.ts +13 -9
  30. package/template/src/routes/cart.tsx +13 -6
  31. package/template/src/routes/checkout.complete.tsx +2 -1
  32. package/template/src/routes/checkout.index.tsx +441 -147
  33. package/template/src/routes/checkout.return.tsx +7 -1
  34. package/template/src/routes/guest-order.tsx +174 -72
  35. package/template/src/routes/login.tsx +2 -1
  36. package/template/src/routes/orders.$orderId.tsx +98 -3
  37. package/template/src/routes/orders.index.tsx +2 -1
  38. package/template/src/routes/products.$productId.tsx +4 -1
  39. package/template/src/routes/products.index.tsx +46 -47
  40. package/template/src/routes/signup.tsx +61 -90
@@ -1,42 +1,33 @@
1
- import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
2
- import { createServerFn } from "@tanstack/react-start";
3
- import { z } from "zod";
1
+ import { productSortSchema } from "@sayren/storefront-sdk";
2
+ import { useSuspenseQuery } from "@tanstack/react-query";
3
+ import { createFileRoute, Link } from "@tanstack/react-router";
4
+ import { useQueryStates } from "nuqs";
4
5
  import { ProductCard } from "../components/product-card";
5
6
  import { useTrack } from "../lib/analytics";
6
- import { apiFor } from "../lib/api.server";
7
-
8
- /** 목록·검색 조건 — URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
9
- const productsSearch = z.object({
10
- keyword: z.string().optional().catch(undefined),
11
- categoryId: z.string().optional().catch(undefined),
12
- sort: z.enum(["recommend", "latest"]).optional().catch(undefined),
13
- page: z.coerce.number().int().min(1).optional().catch(undefined),
14
- });
15
- type ProductsSearch = z.infer<typeof productsSearch>;
16
-
17
- const searchProducts = createServerFn({ method: "GET" })
18
- .validator(productsSearch)
19
- .handler(async ({ data }) => {
20
- const api = apiFor();
21
- const [categories, page] = await Promise.all([
22
- api.catalog.listCategories(),
23
- api.catalog.searchProducts({ ...data, page: data.page ?? 1, size: 24 }),
24
- ]);
25
- return { categories, page };
26
- });
7
+ import { categoriesQuery, productsQuery } from "../lib/catalog-queries";
8
+ import { pageTitle } from "../lib/page-title";
9
+ import { parseProductsSearch, productsSearchParams, SORT_LABELS } from "../lib/products-search";
27
10
 
28
11
  export const Route = createFileRoute("/products/")({
29
- validateSearch: productsSearch,
12
+ // 목록·검색 조건은 URL에 둔다 — 읽는 규칙은 nuqs 파서 하나다(`lib/products-search.ts`)
13
+ validateSearch: parseProductsSearch,
30
14
  loaderDeps: ({ search }) => search,
31
- loader: ({ deps }) => searchProducts({ data: deps }),
32
- head: () => ({ meta: [{ title: "전체 상품" }] }),
15
+ // 서버 렌더에서 미리 받아 쿼리 캐시에 넣는다. 화면은 같은 쿼리를 캐시에서 읽는다
16
+ loader: ({ context, deps }) =>
17
+ Promise.all([
18
+ context.queryClient.ensureQueryData(categoriesQuery()),
19
+ context.queryClient.ensureQueryData(productsQuery(deps)),
20
+ ]),
21
+ head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "전체 상품") }] }),
33
22
  component: Products,
34
23
  });
35
24
 
36
25
  function Products() {
37
- const { categories, page } = Route.useLoaderData();
38
26
  const applied = Route.useSearch();
39
- const navigate = useNavigate();
27
+ const { data: categories } = useSuspenseQuery(categoriesQuery());
28
+ const { data: page } = useSuspenseQuery(productsQuery(applied));
29
+ // 검색어·정렬을 바꾸면 URL만 고친다. 라우터가 바뀐 조건으로 loader를 다시 부른다
30
+ const [, setSearch] = useQueryStates(productsSearchParams, { history: "push" });
40
31
  useTrack(
41
32
  applied.keyword
42
33
  ? { name: "search", query: applied.keyword, resultCount: page.totalElements }
@@ -60,14 +51,12 @@ function Products() {
60
51
  {/* 하이드레이션 전에는 GET 폼으로 그대로 제출되고, 뒤에는 화면을 새로 그리지 않고 이동한다 */}
61
52
  <form
62
53
  action="/products"
63
- className="flex gap-2"
54
+ className="flex flex-wrap gap-2"
64
55
  onSubmit={(event) => {
65
56
  event.preventDefault();
66
57
  const keyword = String(new FormData(event.currentTarget).get("keyword") ?? "").trim();
67
- void navigate({
68
- to: "/products",
69
- search: { keyword: keyword || undefined, categoryId: applied.categoryId },
70
- });
58
+ // 새 검색은 1페이지부터 — 카테고리·정렬은 그대로 둔다
59
+ void setSearch({ keyword: keyword || null, page: null });
71
60
  }}
72
61
  >
73
62
  <input
@@ -76,7 +65,7 @@ function Products() {
76
65
  aria-label="상품 검색"
77
66
  defaultValue={applied.keyword ?? ""}
78
67
  placeholder="상품 검색"
79
- className="h-11 flex-1 rounded-md border border-line px-3"
68
+ className="h-11 min-w-0 flex-1 rounded-md border border-line px-3"
80
69
  />
81
70
  {applied.categoryId ? (
82
71
  <input type="hidden" name="categoryId" value={applied.categoryId} />
@@ -84,6 +73,22 @@ function Products() {
84
73
  <button type="submit" className="h-11 rounded-md bg-ink px-5 font-semibold text-white">
85
74
  검색
86
75
  </button>
76
+ <select
77
+ name="sort"
78
+ aria-label="정렬"
79
+ value={applied.sort ?? "recommend"}
80
+ onChange={(event) => {
81
+ const sort = productSortSchema.parse(event.target.value);
82
+ void setSearch({ sort, page: null });
83
+ }}
84
+ className="h-11 rounded-md border border-line px-3 text-sm"
85
+ >
86
+ {productSortSchema.options.map((sort) => (
87
+ <option key={sort} value={sort}>
88
+ {SORT_LABELS[sort]}
89
+ </option>
90
+ ))}
91
+ </select>
87
92
  </form>
88
93
 
89
94
  <div className="flex flex-wrap gap-2">
@@ -117,27 +122,21 @@ function Products() {
117
122
  </div>
118
123
  )}
119
124
 
120
- <Pagination page={page.page} totalPages={page.totalPages} search={applied} />
125
+ <Pagination page={page.page} totalPages={page.totalPages} />
121
126
  </div>
122
127
  );
123
128
  }
124
129
 
125
- function Pagination({
126
- page,
127
- totalPages,
128
- search,
129
- }: {
130
- page: number;
131
- totalPages: number;
132
- search: ProductsSearch;
133
- }) {
130
+ /** 페이지 이동은 링크다 — 자바스크립트 없이도 넘어가고, 다른 조건은 그대로 둔다 */
131
+ function Pagination({ page, totalPages }: { page: number; totalPages: number }) {
134
132
  if (totalPages <= 1) return null;
133
+ const pageOf = (next: number) => (next > 1 ? next : undefined);
135
134
  return (
136
135
  <nav className="flex items-center justify-center gap-3 text-sm">
137
136
  {page > 1 ? (
138
137
  <Link
139
138
  to="/products"
140
- search={{ ...search, page: page - 1 }}
139
+ search={(prev) => ({ ...prev, page: pageOf(page - 1) })}
141
140
  className="rounded-md border border-line px-3 py-1.5"
142
141
  >
143
142
  이전
@@ -149,7 +148,7 @@ function Pagination({
149
148
  {page < totalPages ? (
150
149
  <Link
151
150
  to="/products"
152
- search={{ ...search, page: page + 1 }}
151
+ search={(prev) => ({ ...prev, page: pageOf(page + 1) })}
153
152
  className="rounded-md border border-line px-3 py-1.5"
154
153
  >
155
154
  다음
@@ -1,11 +1,16 @@
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";
13
+ import { pageTitle } from "../lib/page-title";
9
14
  import { safeRedirect } from "../lib/safe-redirect";
10
15
  import { signIn } from "../lib/session.server";
11
16
 
@@ -82,64 +87,79 @@ function signupErrorMessage(error: unknown): string {
82
87
  export const Route = createFileRoute("/signup")({
83
88
  validateSearch: signupSearch,
84
89
  loader: () => getSignupAvailable(),
85
- head: () => ({ meta: [{ title: "회원가입" }] }),
90
+ head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "회원가입") }] }),
86
91
  component: Signup,
87
92
  });
88
93
 
89
94
  function Signup() {
90
95
  const { redirectTo } = Route.useSearch();
91
96
  const [error, setError] = useState<string | null>(null);
92
- 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;
93
104
 
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);
105
+ const submit = form.handleSubmit(async (values) => {
99
106
  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);
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
+ },
123
119
  });
124
- };
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;
125
130
 
126
131
  return (
127
132
  <div className="mx-auto max-w-sm space-y-5 py-10">
128
133
  <h1 className="font-bold text-2xl">회원가입</h1>
129
134
  <form onSubmit={submit} className="space-y-3">
130
- <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
+ />
131
143
  <TextField
132
144
  label="비밀번호"
133
- name="password"
145
+ registration={form.register("password")}
146
+ error={errors.password?.message}
134
147
  type="password"
135
148
  autoComplete="new-password"
136
149
  required
137
150
  hint="10자 이상, 영문·숫자·특수문자 중 2종 이상"
138
151
  />
139
- <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
+ />
140
159
  <TextField
141
160
  label="휴대폰 번호"
142
- name="phone"
161
+ registration={form.register("phone")}
162
+ error={errors.phone?.message}
143
163
  type="tel"
144
164
  autoComplete="tel"
145
165
  placeholder="01012345678"
@@ -148,21 +168,22 @@ function Signup() {
148
168
  <fieldset className="space-y-2 rounded-md border border-line p-3 text-sm">
149
169
  <legend className="px-1 font-medium">약관 동의</legend>
150
170
  <label className="flex items-center gap-2">
151
- <input type="checkbox" name="terms" required />
171
+ <input type="checkbox" {...form.register("agreements.terms")} required />
152
172
  이용약관 동의 (필수)
153
173
  </label>
154
174
  <label className="flex items-center gap-2">
155
- <input type="checkbox" name="privacy" required />
175
+ <input type="checkbox" {...form.register("agreements.privacy")} required />
156
176
  개인정보 수집이용 동의 (필수)
157
177
  </label>
158
178
  <label className="flex items-center gap-2">
159
- <input type="checkbox" name="marketing" />
179
+ <input type="checkbox" {...form.register("agreements.marketing")} />
160
180
  마케팅 수신 동의 (선택)
161
181
  </label>
182
+ {agreementError ? <p className="text-point text-xs">{agreementError}</p> : null}
162
183
  </fieldset>
163
184
 
164
185
  <SubmitButton
165
- disabled={pending}
186
+ disabled={isSubmitting || leaving}
166
187
  className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
167
188
  >
168
189
  가입하기
@@ -182,53 +203,3 @@ function Signup() {
182
203
  </div>
183
204
  );
184
205
  }
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
- }