@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.
- package/dist/index.mjs +6 -4
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +32 -2
- package/template/package.json +6 -0
- package/template/src/components/fulfillment-codes.tsx +110 -0
- package/template/src/components/fulfillment-downloads.tsx +88 -0
- package/template/src/components/text-field.tsx +71 -0
- package/template/src/lib/catalog-queries.ts +47 -0
- package/template/src/lib/code-reveal.test.ts +30 -0
- package/template/src/lib/code-reveal.ts +32 -0
- package/template/src/lib/coupon-code.test.ts +19 -0
- package/template/src/lib/coupon-code.ts +60 -0
- package/template/src/lib/download.test.ts +41 -0
- package/template/src/lib/download.ts +41 -0
- package/template/src/lib/form-schemas.test.ts +144 -0
- package/template/src/lib/form-schemas.ts +131 -0
- package/template/src/lib/guest-order.test.ts +18 -0
- package/template/src/lib/guest-order.ts +13 -0
- package/template/src/lib/order-status.test.ts +29 -0
- package/template/src/lib/order-status.ts +28 -2
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- package/template/src/lib/products-search.test.ts +25 -0
- package/template/src/lib/products-search.ts +49 -0
- package/template/src/router.tsx +26 -1
- package/template/src/routes/__root.tsx +13 -6
- package/template/src/routes/account.tsx +2 -1
- package/template/src/routes/auth.callback.ts +13 -9
- package/template/src/routes/cart.tsx +13 -6
- package/template/src/routes/checkout.complete.tsx +2 -1
- package/template/src/routes/checkout.index.tsx +441 -147
- package/template/src/routes/checkout.return.tsx +7 -1
- package/template/src/routes/guest-order.tsx +174 -72
- package/template/src/routes/login.tsx +2 -1
- package/template/src/routes/orders.$orderId.tsx +98 -3
- package/template/src/routes/orders.index.tsx +2 -1
- package/template/src/routes/products.$productId.tsx +4 -1
- package/template/src/routes/products.index.tsx +46 -47
- package/template/src/routes/signup.tsx +61 -90
|
@@ -1,42 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
+
// 목록·검색 조건은 URL에 둔다 — 읽는 규칙은 nuqs 파서 하나다(`lib/products-search.ts`)
|
|
13
|
+
validateSearch: parseProductsSearch,
|
|
30
14
|
loaderDeps: ({ search }) => search,
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
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}
|
|
125
|
+
<Pagination page={page.page} totalPages={page.totalPages} />
|
|
121
126
|
</div>
|
|
122
127
|
);
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
|
|
126
|
-
|
|
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={{ ...
|
|
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={{ ...
|
|
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 {
|
|
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 [
|
|
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 = (
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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={
|
|
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
|
-
}
|