@sayren/mcp 0.1.8 → 0.1.9

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 (52) hide show
  1. package/dist/index.mjs +22 -19
  2. package/package.json +1 -1
  3. package/template/README.md +29 -12
  4. package/template/_gitignore +4 -2
  5. package/template/package.json +8 -10
  6. package/template/{app → src}/components/product-card.tsx +6 -2
  7. package/template/{app → src}/components/product-description.tsx +1 -1
  8. package/template/{app → src}/components/site-header.tsx +1 -1
  9. package/template/src/components/submit-button.tsx +32 -0
  10. package/template/{app → src}/lib/analytics.ts +1 -1
  11. package/template/{app → src}/lib/api.server.ts +6 -5
  12. package/template/{app → src}/lib/api.ts +1 -1
  13. package/template/src/lib/cart-session.server.ts +28 -0
  14. package/template/src/lib/checkout-intent.test.ts +27 -0
  15. package/template/{app → src}/lib/checkout-intent.ts +20 -8
  16. package/template/{app → src}/lib/config.server.ts +5 -4
  17. package/template/{app → src}/lib/config.ts +1 -1
  18. package/template/src/lib/format.test.ts +14 -0
  19. package/template/src/lib/format.ts +30 -0
  20. package/template/{app → src}/lib/safe-redirect.ts +1 -1
  21. package/template/src/lib/session.server.ts +121 -0
  22. package/template/{app → src}/lib/social-login.server.ts +25 -10
  23. package/template/src/routeTree.gen.ts +262 -0
  24. package/template/src/router.tsx +17 -0
  25. package/template/src/routes/__root.tsx +90 -0
  26. package/template/src/routes/auth.callback.ts +48 -0
  27. package/template/{app → src}/routes/cart.tsx +94 -78
  28. package/template/{app/routes/checkout-complete.tsx → src/routes/checkout.complete.tsx} +25 -18
  29. package/template/{app/routes/checkout.tsx → src/routes/checkout.index.tsx} +134 -94
  30. package/template/{app/routes/home.tsx → src/routes/index.tsx} +19 -13
  31. package/template/src/routes/login.tsx +198 -0
  32. package/template/{app/routes/order-detail.tsx → src/routes/orders.$orderId.tsx} +25 -13
  33. package/template/{app/routes/orders.tsx → src/routes/orders.index.tsx} +16 -14
  34. package/template/{app/routes/product-detail.tsx → src/routes/products.$productId.tsx} +77 -55
  35. package/template/{app/routes/products.tsx → src/routes/products.index.tsx} +62 -38
  36. package/template/src/start.ts +18 -0
  37. package/template/tsconfig.json +3 -4
  38. package/template/vite.config.ts +3 -2
  39. package/template/vitest.config.ts +1 -1
  40. package/template/app/lib/cart-session.server.ts +0 -18
  41. package/template/app/lib/format.ts +0 -10
  42. package/template/app/lib/session.server.ts +0 -99
  43. package/template/app/root.tsx +0 -78
  44. package/template/app/routes/auth-callback.tsx +0 -44
  45. package/template/app/routes/login.tsx +0 -153
  46. package/template/app/routes.ts +0 -18
  47. package/template/react-router.config.ts +0 -6
  48. /package/template/{app → src}/lib/config.test.ts +0 -0
  49. /package/template/{app → src}/lib/payment-popup.ts +0 -0
  50. /package/template/{app → src}/lib/safe-redirect.test.ts +0 -0
  51. /package/template/{app → src}/lib/social-buttons.ts +0 -0
  52. /package/template/{app/app.css → src/styles.css} +0 -0
@@ -0,0 +1,198 @@
1
+ import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
2
+ import { createFileRoute } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
4
+ import { getRequestUrl } from "@tanstack/react-start/server";
5
+ import { useState } from "react";
6
+ import { z } from "zod";
7
+ import { SubmitButton } from "../components/submit-button";
8
+ import { apiFor, authFor } from "../lib/api.server";
9
+ import { clearCartToken, readCartToken } from "../lib/cart-session.server";
10
+ import { safeRedirect } from "../lib/safe-redirect";
11
+ import { signIn } from "../lib/session.server";
12
+ import { SOCIAL_BUTTONS } from "../lib/social-buttons";
13
+ import { writeSocialLogin } from "../lib/social-login.server";
14
+
15
+ const loginSearch = z.object({
16
+ redirectTo: z.string().optional().catch(undefined),
17
+ error: z.string().optional().catch(undefined),
18
+ });
19
+
20
+ /** 켜진 로그인 방법 — 셀러가 콘솔 설정 › 로그인에서 켠 것만 온다 */
21
+ const getLoginMethods = createServerFn({ method: "GET" }).handler(async () => {
22
+ const store = await apiFor().store.get();
23
+ return store.loginMethods;
24
+ });
25
+
26
+ /**
27
+ * 소셜 로그인 시작 — state·PKCE verifier를 쿠키에 두고 공급자 로그인 화면 주소를 돌려준다. 돌아오는 곳은
28
+ * `/auth/callback`이고, 이 앱의 주소가 콘솔의 스토어프런트 도메인에 등록돼 있어야 한다.
29
+ */
30
+ const startSocial = createServerFn({ method: "POST" })
31
+ .validator(z.object({ provider: z.enum(["kakao", "naver", "google"]), redirectTo: z.unknown() }))
32
+ .handler(async ({ data }) => {
33
+ const origin = new URL(getRequestUrl()).origin;
34
+ try {
35
+ const started = await authFor().idp(data.provider, {
36
+ redirectUri: `${origin}/auth/callback`,
37
+ });
38
+ writeSocialLogin({
39
+ state: started.state,
40
+ codeVerifier: started.codeVerifier,
41
+ redirectTo: safeRedirect(data.redirectTo),
42
+ });
43
+ return { url: started.url, error: null };
44
+ } catch (error) {
45
+ return { url: null, error: loginErrorMessage(error) };
46
+ }
47
+ });
48
+
49
+ /**
50
+ * 로그인 — 화면은 이 앱이 그리고, 확인은 sayren이 한다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면
51
+ * 회원 장바구니로 합쳐지고 그 토큰은 닫힌다. 그래서 장바구니 쿠키도 지운다.
52
+ */
53
+ const passwordLogin = createServerFn({ method: "POST" })
54
+ .validator(z.object({ email: z.string(), password: z.string(), redirectTo: z.unknown() }))
55
+ .handler(async ({ data }) => {
56
+ const cartToken = readCartToken();
57
+ try {
58
+ const tokens = await authFor().session({
59
+ email: data.email,
60
+ password: data.password,
61
+ cartToken: cartToken ?? undefined,
62
+ });
63
+ signIn(tokens);
64
+ if (cartToken) clearCartToken();
65
+ return { redirectTo: safeRedirect(data.redirectTo), error: null };
66
+ } catch (error) {
67
+ return { redirectTo: null, error: loginErrorMessage(error) };
68
+ }
69
+ });
70
+
71
+ function loginErrorMessage(error: unknown): string {
72
+ if (error instanceof ApiError) {
73
+ if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않아요";
74
+ if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받아요";
75
+ if (error.code === "ACCOUNT_LOCKED")
76
+ return "로그인을 여러 번 실패해 잠시 잠겼어요. 10분 뒤에 다시 시도해 주세요";
77
+ if (error.status === 400) return "이메일과 비밀번호를 확인해 주세요";
78
+ }
79
+ return "로그인하지 못했어요. 잠시 후 다시 시도해 주세요";
80
+ }
81
+
82
+ const CALLBACK_ERRORS: Record<string, string> = {
83
+ access_denied: "로그인을 취소했어요",
84
+ state_mismatch: "로그인 요청이 만료됐어요. 다시 시도해 주세요",
85
+ };
86
+
87
+ export const Route = createFileRoute("/login")({
88
+ validateSearch: loginSearch,
89
+ loader: () => getLoginMethods(),
90
+ head: () => ({ meta: [{ title: "로그인" }] }),
91
+ component: Login,
92
+ });
93
+
94
+ function Login() {
95
+ const loginMethods = Route.useLoaderData();
96
+ const { redirectTo, error: callbackError } = Route.useSearch();
97
+ const [actionError, setActionError] = useState<string | null>(null);
98
+ const [pending, setPending] = useState(false);
99
+ const social = loginMethods.filter((method): method is SocialProvider => method !== "password");
100
+ const password = loginMethods.includes("password");
101
+ const error =
102
+ actionError ??
103
+ (callbackError
104
+ ? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했어요. 다시 시도해 주세요")
105
+ : null);
106
+
107
+ const submitSocial = (event: React.FormEvent<HTMLFormElement>) => {
108
+ event.preventDefault();
109
+ const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
110
+ const provider = submitter?.value as SocialProvider | undefined;
111
+ if (!provider) return;
112
+ setPending(true);
113
+ void startSocial({ data: { provider, redirectTo } }).then((result) => {
114
+ // 공급자 로그인 화면은 다른 사이트라 문서째 이동한다
115
+ if (result.url) window.location.assign(result.url);
116
+ else {
117
+ setActionError(result.error);
118
+ setPending(false);
119
+ }
120
+ });
121
+ };
122
+
123
+ const submitPassword = (event: React.FormEvent<HTMLFormElement>) => {
124
+ event.preventDefault();
125
+ const form = new FormData(event.currentTarget);
126
+ setPending(true);
127
+ void passwordLogin({
128
+ data: {
129
+ email: String(form.get("email") ?? ""),
130
+ password: String(form.get("password") ?? ""),
131
+ redirectTo,
132
+ },
133
+ }).then((result) => {
134
+ // 로그인 상태가 모든 화면에 새로 반영되도록 문서째 이동한다
135
+ if (result.redirectTo) window.location.assign(result.redirectTo);
136
+ else {
137
+ setActionError(result.error);
138
+ setPending(false);
139
+ }
140
+ });
141
+ };
142
+
143
+ return (
144
+ <div className="mx-auto max-w-sm space-y-5 py-10">
145
+ <h1 className="font-bold text-2xl">로그인</h1>
146
+ {social.length ? (
147
+ <form onSubmit={submitSocial} className="space-y-2">
148
+ {social.map((provider) => (
149
+ <SubmitButton
150
+ key={provider}
151
+ name="social"
152
+ value={provider}
153
+ disabled={pending}
154
+ className={`h-12 w-full rounded-md font-semibold ${SOCIAL_BUTTONS[provider].className}`}
155
+ >
156
+ {SOCIAL_BUTTONS[provider].label}
157
+ </SubmitButton>
158
+ ))}
159
+ </form>
160
+ ) : null}
161
+ {social.length && password ? (
162
+ <div className="flex items-center gap-3 text-muted text-xs">
163
+ <span className="h-px flex-1 bg-line" />
164
+ 또는 이메일로
165
+ <span className="h-px flex-1 bg-line" />
166
+ </div>
167
+ ) : null}
168
+ {password ? (
169
+ <form onSubmit={submitPassword} className="space-y-3">
170
+ <input
171
+ type="email"
172
+ name="email"
173
+ placeholder="이메일"
174
+ required
175
+ className="h-11 w-full rounded-md border border-line px-3"
176
+ />
177
+ <input
178
+ type="password"
179
+ name="password"
180
+ placeholder="비밀번호"
181
+ required
182
+ className="h-11 w-full rounded-md border border-line px-3"
183
+ />
184
+ <SubmitButton
185
+ disabled={pending}
186
+ className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
187
+ >
188
+ 로그인
189
+ </SubmitButton>
190
+ </form>
191
+ ) : null}
192
+ {error ? <p className="text-point text-sm">{error}</p> : null}
193
+ {social.length ? (
194
+ <p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 돼요</p>
195
+ ) : null}
196
+ </div>
197
+ );
198
+ }
@@ -1,22 +1,34 @@
1
- import { Link, redirect } from "react-router";
1
+ import { ApiError } from "@sayren/storefront-sdk";
2
+ import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
4
+ import { z } from "zod";
2
5
  import { apiFor } from "../lib/api.server";
3
6
  import { formatDateTime, formatPrice } from "../lib/format";
4
7
  import { readToken } from "../lib/session.server";
5
- import type { Route } from "./+types/order-detail";
6
8
 
7
- export function meta({ params }: Route.MetaArgs) {
8
- return [{ title: `주문 ${params.orderId}` }];
9
- }
9
+ const getOrder = createServerFn({ method: "GET" })
10
+ .validator(z.object({ orderId: z.string() }))
11
+ .handler(async ({ data }) => {
12
+ const accessToken = readToken();
13
+ if (!accessToken) {
14
+ throw redirect({ to: "/login", search: { redirectTo: `/orders/${data.orderId}` } });
15
+ }
16
+ try {
17
+ return await apiFor({ accessToken }).myOrders.get(data.orderId);
18
+ } catch (error) {
19
+ if (error instanceof ApiError && error.status === 404) throw notFound();
20
+ throw error;
21
+ }
22
+ });
10
23
 
11
- export async function loader({ request, params, context }: Route.LoaderArgs) {
12
- const accessToken = readToken(context);
13
- if (!accessToken) throw redirect(`/login?redirectTo=/orders/${params.orderId}`);
14
- const api = apiFor(request, { accessToken });
15
- return { order: await api.myOrders.get(params.orderId) };
16
- }
24
+ export const Route = createFileRoute("/orders/$orderId")({
25
+ loader: ({ params }) => getOrder({ data: { orderId: params.orderId } }),
26
+ head: ({ params }) => ({ meta: [{ title: `주문 ${params.orderId}` }] }),
27
+ component: OrderDetail,
28
+ });
17
29
 
18
- export default function OrderDetail({ loaderData }: Route.ComponentProps) {
19
- const { order } = loaderData;
30
+ function OrderDetail() {
31
+ const order = Route.useLoaderData();
20
32
 
21
33
  return (
22
34
  <div className="space-y-6">
@@ -1,22 +1,23 @@
1
- import { Link, redirect } from "react-router";
1
+ import { createFileRoute, Link, redirect } from "@tanstack/react-router";
2
+ import { createServerFn } from "@tanstack/react-start";
2
3
  import { apiFor } from "../lib/api.server";
3
4
  import { formatDateTime, formatPrice } from "../lib/format";
4
5
  import { readToken } from "../lib/session.server";
5
- import type { Route } from "./+types/orders";
6
6
 
7
- export function meta() {
8
- return [{ title: "주문 내역" }];
9
- }
7
+ const getOrders = createServerFn({ method: "GET" }).handler(async () => {
8
+ const accessToken = readToken();
9
+ if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
10
+ return apiFor({ accessToken }).myOrders.list({ size: 20 });
11
+ });
10
12
 
11
- export async function loader({ request, context }: Route.LoaderArgs) {
12
- const accessToken = readToken(context);
13
- if (!accessToken) throw redirect("/login?redirectTo=/orders");
14
- const api = apiFor(request, { accessToken });
15
- return { orders: await api.myOrders.list({ size: 20 }) };
16
- }
13
+ export const Route = createFileRoute("/orders/")({
14
+ loader: () => getOrders(),
15
+ head: () => ({ meta: [{ title: "주문 내역" }] }),
16
+ component: Orders,
17
+ });
17
18
 
18
- export default function Orders({ loaderData }: Route.ComponentProps) {
19
- const { orders } = loaderData;
19
+ function Orders() {
20
+ const orders = Route.useLoaderData();
20
21
 
21
22
  if (!orders.contents.length) {
22
23
  return <p className="py-16 text-center text-muted">아직 주문이 없어요</p>;
@@ -29,7 +30,8 @@ export default function Orders({ loaderData }: Route.ComponentProps) {
29
30
  {orders.contents.map((order) => (
30
31
  <li key={order.orderId}>
31
32
  <Link
32
- to={`/orders/${order.orderId}`}
33
+ to="/orders/$orderId"
34
+ params={{ orderId: order.orderId }}
33
35
  className="flex items-center justify-between gap-4 rounded-lg border border-line p-4 hover:border-ink"
34
36
  >
35
37
  <span className="flex flex-col gap-1">
@@ -1,55 +1,66 @@
1
+ import { ApiError } from "@sayren/storefront-sdk";
2
+ import { createFileRoute, Link, notFound, useNavigate } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
1
4
  import { useState } from "react";
2
- import { Form, redirect, useNavigation } from "react-router";
5
+ import { z } from "zod";
3
6
  import { ProductDescription } from "../components/product-description";
7
+ import { SubmitButton } from "../components/submit-button";
4
8
  import { useTrack } from "../lib/analytics";
5
9
  import { apiFor } from "../lib/api.server";
6
- import { cartCookie, readCartToken } from "../lib/cart-session.server";
10
+ import { readCartToken, writeCartToken } from "../lib/cart-session.server";
7
11
  import { formatPrice } from "../lib/format";
8
12
  import { readToken } from "../lib/session.server";
9
- import type { Route } from "./+types/product-detail";
10
13
 
11
- export function meta({ loaderData }: Route.MetaArgs) {
12
- return [{ title: loaderData ? loaderData.product.name : "상품" }];
13
- }
14
-
15
- export async function loader({ request, params }: Route.LoaderArgs) {
16
- const api = apiFor(request);
17
- const product = await api.catalog.getProduct(params.productId);
18
- return { product };
19
- }
14
+ const getProduct = createServerFn({ method: "GET" })
15
+ .validator(z.object({ productId: z.string() }))
16
+ .handler(async ({ data }) => {
17
+ try {
18
+ return await apiFor().catalog.getProduct(data.productId);
19
+ } catch (error) {
20
+ if (error instanceof ApiError && error.status === 404) throw notFound();
21
+ throw error;
22
+ }
23
+ });
20
24
 
21
25
  /**
22
- * 장바구니 담기·바로구매를 서버에서 처리한다. 비회원 장바구니 토큰은 서버가 새로 발급할 수 있어
26
+ * 장바구니 담기 — 서버에서 처리한다. 비회원 장바구니 토큰은 서버가 새로 발급할 수 있어
23
27
  * 응답 쿠키에 다시 심는다.
24
28
  */
25
- export async function action({ request, params, context }: Route.ActionArgs) {
26
- const form = await request.formData();
27
- const optionId = String(form.get("optionId") ?? "") || undefined;
28
- const quantity = Number(form.get("quantity") ?? 1);
29
-
30
- const accessToken = readToken(context);
31
- let cartToken = await readCartToken(request);
32
- let issued: string | null = null;
33
- const api = apiFor(request, {
34
- accessToken,
35
- cartToken,
36
- onCartToken: (token) => {
37
- issued = token;
38
- },
29
+ const addToCart = createServerFn({ method: "POST" })
30
+ .validator(
31
+ z.object({
32
+ productId: z.string(),
33
+ optionId: z.string().optional(),
34
+ quantity: z.number().int().min(1).max(999),
35
+ }),
36
+ )
37
+ .handler(async ({ data }) => {
38
+ const cartToken = readCartToken();
39
+ let issued: string | null = null;
40
+ const api = apiFor({
41
+ accessToken: readToken(),
42
+ cartToken,
43
+ onCartToken: (token) => {
44
+ issued = token;
45
+ },
46
+ });
47
+ await api.cart.addItem(data);
48
+ const token = issued ?? cartToken;
49
+ if (token) writeCartToken(token);
39
50
  });
40
51
 
41
- await api.cart.addItem({ productId: params.productId, optionId, quantity });
42
- cartToken = issued ?? cartToken;
43
- return redirect("/cart", {
44
- headers: cartToken ? { "Set-Cookie": await cartCookie.serialize(cartToken) } : undefined,
45
- });
46
- }
52
+ export const Route = createFileRoute("/products/$productId")({
53
+ loader: ({ params }) => getProduct({ data: { productId: params.productId } }),
54
+ head: ({ loaderData }) => ({ meta: [{ title: loaderData ? loaderData.name : "상품" }] }),
55
+ component: ProductDetail,
56
+ });
47
57
 
48
- export default function ProductDetail({ loaderData }: Route.ComponentProps) {
49
- const { product } = loaderData;
58
+ function ProductDetail() {
59
+ const product = Route.useLoaderData();
50
60
  useTrack({ name: "product_view", productId: product.productId });
51
- const navigation = useNavigation();
52
- const submitting = navigation.state !== "idle";
61
+ const navigate = useNavigate();
62
+ const [submitting, setSubmitting] = useState(false);
63
+ const [error, setError] = useState<string | null>(null);
53
64
  const hasOptions = product.optionGroups.length > 0;
54
65
  const [quantity, setQuantity] = useState(1);
55
66
  const [variantId, setVariantId] = useState(
@@ -58,6 +69,7 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
58
69
  const variant = product.variants.find((v) => v.variantId === variantId);
59
70
  const price =
60
71
  (product.discountedPrice ?? product.salePrice) + (variant ? variant.additionalPrice : 0);
72
+ const optionId = hasOptions ? variantId : undefined;
61
73
 
62
74
  return (
63
75
  <div className="grid gap-8 md:grid-cols-2">
@@ -86,7 +98,18 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
86
98
  </p>
87
99
  </div>
88
100
 
89
- <Form method="post" className="space-y-4">
101
+ <form
102
+ className="space-y-4"
103
+ onSubmit={(event) => {
104
+ event.preventDefault();
105
+ setSubmitting(true);
106
+ setError(null);
107
+ addToCart({ data: { productId: product.productId, optionId, quantity } })
108
+ .then(() => navigate({ to: "/cart" }))
109
+ .catch(() => setError("장바구니에 담지 못했어요. 다시 시도해주세요"))
110
+ .finally(() => setSubmitting(false));
111
+ }}
112
+ >
90
113
  {hasOptions ? (
91
114
  <label className="block space-y-1">
92
115
  <span className="text-sm">옵션</span>
@@ -119,30 +142,29 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
119
142
  />
120
143
  </label>
121
144
 
122
- <button
123
- type="submit"
124
- name="intent"
125
- value="cart"
145
+ <SubmitButton
126
146
  disabled={submitting || product.soldOut}
127
147
  className="h-12 w-full rounded-md border border-ink font-semibold disabled:opacity-50"
128
148
  >
129
149
  장바구니
130
- </button>
131
- </Form>
150
+ </SubmitButton>
151
+ </form>
132
152
 
133
- {/* 바로구매는 주문서를 GET으로 연다 — 장바구니를 거치지 않는다 */}
134
- <Form method="get" action="/checkout" className="space-y-0">
135
- <input type="hidden" name="productId" value={product.productId} />
136
- {hasOptions ? <input type="hidden" name="optionId" value={variantId} /> : null}
137
- <input type="hidden" name="quantity" value={quantity} />
138
- <button
139
- type="submit"
140
- disabled={submitting || product.soldOut}
141
- className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
153
+ {/* 바로구매는 주문서로 바로 간다 — 장바구니를 거치지 않는다 */}
154
+ {product.soldOut ? (
155
+ <span className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white opacity-50">
156
+ 바로구매
157
+ </span>
158
+ ) : (
159
+ <Link
160
+ to="/checkout"
161
+ search={{ productId: product.productId, optionId, quantity }}
162
+ className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white"
142
163
  >
143
164
  바로구매
144
- </button>
145
- </Form>
165
+ </Link>
166
+ )}
167
+ {error ? <p className="text-point text-sm">{error}</p> : null}
146
168
 
147
169
  <section className="space-y-2 border-line border-t pt-5">
148
170
  <h2 className="font-semibold">상품 설명</h2>
@@ -1,34 +1,42 @@
1
- import { Form, Link, useSearchParams } from "react-router";
1
+ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
2
+ import { createServerFn } from "@tanstack/react-start";
3
+ import { z } from "zod";
2
4
  import { ProductCard } from "../components/product-card";
3
5
  import { useTrack } from "../lib/analytics";
4
6
  import { apiFor } from "../lib/api.server";
5
- import type { Route } from "./+types/products";
6
7
 
7
- export function meta() {
8
- return [{ title: "전체 상품" }];
9
- }
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>;
10
16
 
11
- /** 목록·검색 — 필터 상태는 URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
12
- export async function loader({ request }: Route.LoaderArgs) {
13
- const url = new URL(request.url);
14
- const params = {
15
- keyword: url.searchParams.get("keyword") ?? undefined,
16
- categoryId: url.searchParams.get("categoryId") ?? undefined,
17
- sort: (url.searchParams.get("sort") ?? undefined) as "recommend" | "latest" | undefined,
18
- page: Number(url.searchParams.get("page") ?? 1),
19
- size: 24,
20
- };
21
- const api = apiFor(request);
22
- const [categories, page] = await Promise.all([
23
- api.catalog.listCategories(),
24
- api.catalog.searchProducts(params),
25
- ]);
26
- return { categories, page, applied: params };
27
- }
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
+ });
28
27
 
29
- export default function Products({ loaderData }: Route.ComponentProps) {
30
- const { categories, page, applied } = loaderData;
31
- const [searchParams] = useSearchParams();
28
+ export const Route = createFileRoute("/products/")({
29
+ validateSearch: productsSearch,
30
+ loaderDeps: ({ search }) => search,
31
+ loader: ({ deps }) => searchProducts({ data: deps }),
32
+ head: () => ({ meta: [{ title: "전체 상품" }] }),
33
+ component: Products,
34
+ });
35
+
36
+ function Products() {
37
+ const { categories, page } = Route.useLoaderData();
38
+ const applied = Route.useSearch();
39
+ const navigate = useNavigate();
32
40
  useTrack(
33
41
  applied.keyword
34
42
  ? { name: "search", query: applied.keyword, resultCount: page.totalElements }
@@ -46,7 +54,19 @@ export default function Products({ loaderData }: Route.ComponentProps) {
46
54
 
47
55
  return (
48
56
  <div className="space-y-6">
49
- <Form method="get" className="flex gap-2">
57
+ {/* 하이드레이션 전에는 GET 폼으로 그대로 제출되고, 뒤에는 화면을 새로 그리지 않고 이동한다 */}
58
+ <form
59
+ action="/products"
60
+ className="flex gap-2"
61
+ onSubmit={(event) => {
62
+ event.preventDefault();
63
+ const keyword = String(new FormData(event.currentTarget).get("keyword") ?? "").trim();
64
+ void navigate({
65
+ to: "/products",
66
+ search: { keyword: keyword || undefined, categoryId: applied.categoryId },
67
+ });
68
+ }}
69
+ >
50
70
  <input
51
71
  type="search"
52
72
  name="keyword"
@@ -60,7 +80,7 @@ export default function Products({ loaderData }: Route.ComponentProps) {
60
80
  <button type="submit" className="h-11 rounded-md bg-ink px-5 font-semibold text-white">
61
81
  검색
62
82
  </button>
63
- </Form>
83
+ </form>
64
84
 
65
85
  <div className="flex flex-wrap gap-2">
66
86
  <Link
@@ -72,7 +92,8 @@ export default function Products({ loaderData }: Route.ComponentProps) {
72
92
  {categories.map((category) => (
73
93
  <Link
74
94
  key={category.categoryId}
75
- to={`/products?categoryId=${category.categoryId}`}
95
+ to="/products"
96
+ search={{ categoryId: category.categoryId }}
76
97
  className={`rounded-full px-3 py-1.5 text-sm ${
77
98
  applied.categoryId === category.categoryId ? "bg-ink text-white" : "bg-chip"
78
99
  }`}
@@ -92,7 +113,7 @@ export default function Products({ loaderData }: Route.ComponentProps) {
92
113
  </div>
93
114
  )}
94
115
 
95
- <Pagination page={page.page} totalPages={page.totalPages} searchParams={searchParams} />
116
+ <Pagination page={page.page} totalPages={page.totalPages} search={applied} />
96
117
  </div>
97
118
  );
98
119
  }
@@ -100,22 +121,21 @@ export default function Products({ loaderData }: Route.ComponentProps) {
100
121
  function Pagination({
101
122
  page,
102
123
  totalPages,
103
- searchParams,
124
+ search,
104
125
  }: {
105
126
  page: number;
106
127
  totalPages: number;
107
- searchParams: URLSearchParams;
128
+ search: ProductsSearch;
108
129
  }) {
109
130
  if (totalPages <= 1) return null;
110
- const linkTo = (next: number) => {
111
- const params = new URLSearchParams(searchParams);
112
- params.set("page", String(next));
113
- return `/products?${params.toString()}`;
114
- };
115
131
  return (
116
132
  <nav className="flex items-center justify-center gap-3 text-sm">
117
133
  {page > 1 ? (
118
- <Link to={linkTo(page - 1)} className="rounded-md border border-line px-3 py-1.5">
134
+ <Link
135
+ to="/products"
136
+ search={{ ...search, page: page - 1 }}
137
+ className="rounded-md border border-line px-3 py-1.5"
138
+ >
119
139
  이전
120
140
  </Link>
121
141
  ) : null}
@@ -123,7 +143,11 @@ function Pagination({
123
143
  {page} / {totalPages}
124
144
  </span>
125
145
  {page < totalPages ? (
126
- <Link to={linkTo(page + 1)} className="rounded-md border border-line px-3 py-1.5">
146
+ <Link
147
+ to="/products"
148
+ search={{ ...search, page: page + 1 }}
149
+ className="rounded-md border border-line px-3 py-1.5"
150
+ >
127
151
  다음
128
152
  </Link>
129
153
  ) : null}
@@ -0,0 +1,18 @@
1
+ import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
2
+ import { refreshBuyerSession } from "./lib/session.server";
3
+
4
+ /**
5
+ * 서버 함수 호출은 같은 사이트에서 온 것만 받는다. 구매자 세션이 쿠키라 다른 사이트가 결제·장바구니
6
+ * 서버 함수를 대신 부르지 못하게 한다.
7
+ */
8
+ const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });
9
+
10
+ /** 구매자 세션 — 모든 요청에서 액세스 토큰을 정하고 만료가 가까우면 갱신한다(`readToken`으로 읽는다) */
11
+ const buyerSession = createMiddleware().server(async ({ next }) => {
12
+ const accessToken = await refreshBuyerSession();
13
+ return next({ context: { accessToken } });
14
+ });
15
+
16
+ export const startInstance = createStart(() => ({
17
+ requestMiddleware: [csrf, buyerSession],
18
+ }));