@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
@@ -1,17 +1,16 @@
1
- import { Form, Link, redirect } from "react-router";
1
+ import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
2
+ import { createServerFn } from "@tanstack/react-start";
3
+ import { useState } from "react";
4
+ import { z } from "zod";
5
+ import { SubmitButton } from "../components/submit-button";
2
6
  import { apiFor } from "../lib/api.server";
3
- import { cartCookie, readCartToken } from "../lib/cart-session.server";
7
+ import { readCartToken, writeCartToken } from "../lib/cart-session.server";
4
8
  import { formatPrice } from "../lib/format";
5
9
  import { readToken } from "../lib/session.server";
6
- import type { Route } from "./+types/cart";
7
10
 
8
- export function meta() {
9
- return [{ title: "장바구니" }];
10
- }
11
-
12
- export async function loader({ request, context }: Route.LoaderArgs) {
13
- const accessToken = readToken(context);
14
- const cartToken = await readCartToken(request);
11
+ const getCart = createServerFn({ method: "GET" }).handler(async () => {
12
+ const accessToken = readToken();
13
+ const cartToken = readCartToken();
15
14
  // 비회원이 아직 장바구니를 만든 적이 없으면 서버에 물어볼 것이 없다
16
15
  if (!accessToken && !cartToken) {
17
16
  return {
@@ -19,42 +18,56 @@ export async function loader({ request, context }: Route.LoaderArgs) {
19
18
  loginRequired: false,
20
19
  };
21
20
  }
22
- const api = apiFor(request, { accessToken, cartToken });
21
+ const api = apiFor({ accessToken, cartToken });
23
22
  // 회원만 주문받는 스토어면 비회원에게 주문 버튼 대신 로그인 안내를 보인다(담기는 그대로 된다)
24
23
  const [cart, store] = await Promise.all([api.cart.get(), accessToken ? null : api.store.get()]);
25
24
  return { cart, loginRequired: store ? !store.guestCheckout : false };
26
- }
25
+ });
27
26
 
28
- export async function action({ request, context }: Route.ActionArgs) {
29
- const form = await request.formData();
30
- const intent = String(form.get("intent") ?? "");
31
- const accessToken = readToken(context);
32
- const cartToken = await readCartToken(request);
33
- let issued: string | null = null;
34
- const api = apiFor(request, {
35
- accessToken,
36
- cartToken,
37
- onCartToken: (token) => {
38
- issued = token;
39
- },
27
+ const changeCart = createServerFn({ method: "POST" })
28
+ .validator(
29
+ z.discriminatedUnion("intent", [
30
+ z.object({ intent: z.literal("remove"), cartItemId: z.string() }),
31
+ z.object({
32
+ intent: z.literal("update"),
33
+ cartItemId: z.string(),
34
+ quantity: z.number().int().min(1).max(999),
35
+ }),
36
+ ]),
37
+ )
38
+ .handler(async ({ data }) => {
39
+ const cartToken = readCartToken();
40
+ let issued: string | null = null;
41
+ const api = apiFor({
42
+ accessToken: readToken(),
43
+ cartToken,
44
+ onCartToken: (token) => {
45
+ issued = token;
46
+ },
47
+ });
48
+ if (data.intent === "remove") await api.cart.removeItem(data.cartItemId);
49
+ else await api.cart.updateItem(data.cartItemId, { quantity: data.quantity });
50
+ const token = issued ?? cartToken;
51
+ if (token) writeCartToken(token);
40
52
  });
41
53
 
42
- if (intent === "remove") {
43
- await api.cart.removeItem(String(form.get("cartItemId")));
44
- } else if (intent === "update") {
45
- await api.cart.updateItem(String(form.get("cartItemId")), {
46
- quantity: Number(form.get("quantity")),
47
- });
48
- }
54
+ export const Route = createFileRoute("/cart")({
55
+ loader: () => getCart(),
56
+ head: () => ({ meta: [{ title: "장바구니" }] }),
57
+ component: CartPage,
58
+ });
49
59
 
50
- const token = issued ?? cartToken;
51
- return redirect("/cart", {
52
- headers: token ? { "Set-Cookie": await cartCookie.serialize(token) } : undefined,
53
- });
54
- }
60
+ function CartPage() {
61
+ const { cart, loginRequired } = Route.useLoaderData();
62
+ const router = useRouter();
63
+ const [pending, setPending] = useState(false);
55
64
 
56
- export default function CartPage({ loaderData }: Route.ComponentProps) {
57
- const { cart, loginRequired } = loaderData;
65
+ const change = (data: Parameters<typeof changeCart>[0]["data"]) => {
66
+ setPending(true);
67
+ changeCart({ data })
68
+ .then(() => router.invalidate())
69
+ .finally(() => setPending(false));
70
+ };
58
71
 
59
72
  if (!cart.items.length) {
60
73
  return (
@@ -82,7 +95,11 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
82
95
  className="size-20 rounded-md bg-chip object-cover"
83
96
  />
84
97
  <div className="flex flex-1 flex-col gap-1">
85
- <Link to={`/products/${item.productId}`} className="text-sm">
98
+ <Link
99
+ to="/products/$productId"
100
+ params={{ productId: item.productId }}
101
+ className="text-sm"
102
+ >
86
103
  {item.productName}
87
104
  </Link>
88
105
  {item.optionName ? (
@@ -94,8 +111,18 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
94
111
  </span>
95
112
  ) : null}
96
113
  <div className="flex items-center gap-3 pt-1">
97
- <Form method="post" className="flex items-center gap-2">
98
- <input type="hidden" name="cartItemId" value={item.cartItemId} />
114
+ <form
115
+ className="flex items-center gap-2"
116
+ onSubmit={(event) => {
117
+ event.preventDefault();
118
+ const form = new FormData(event.currentTarget);
119
+ change({
120
+ intent: "update",
121
+ cartItemId: item.cartItemId,
122
+ quantity: Number(form.get("quantity")),
123
+ });
124
+ }}
125
+ >
99
126
  <input
100
127
  type="number"
101
128
  name="quantity"
@@ -104,26 +131,20 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
104
131
  defaultValue={item.quantity}
105
132
  className="h-9 w-20 rounded-md border border-line px-2 text-sm"
106
133
  />
107
- <button
108
- type="submit"
109
- name="intent"
110
- value="update"
111
- className="text-muted text-xs underline"
112
- >
134
+ <SubmitButton disabled={pending} className="text-muted text-xs underline">
113
135
  수량 변경
114
- </button>
115
- </Form>
116
- <Form method="post">
117
- <input type="hidden" name="cartItemId" value={item.cartItemId} />
118
- <button
119
- type="submit"
120
- name="intent"
121
- value="remove"
122
- className="text-muted text-xs underline"
123
- >
136
+ </SubmitButton>
137
+ </form>
138
+ <form
139
+ onSubmit={(event) => {
140
+ event.preventDefault();
141
+ change({ intent: "remove", cartItemId: item.cartItemId });
142
+ }}
143
+ >
144
+ <SubmitButton disabled={pending} className="text-muted text-xs underline">
124
145
  삭제
125
- </button>
126
- </Form>
146
+ </SubmitButton>
147
+ </form>
127
148
  </div>
128
149
  </div>
129
150
  <span className="font-semibold text-sm">{formatPrice(item.totalPrice)}</span>
@@ -153,31 +174,26 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
153
174
  이 스토어는 회원만 주문할 수 있어요. 담은 상품은 로그인하면 그대로 이어져요
154
175
  </p>
155
176
  <Link
156
- to="/login?redirectTo=/cart"
177
+ to="/login"
178
+ search={{ redirectTo: "/cart" }}
157
179
  className="flex h-12 items-center justify-center rounded-md bg-ink font-semibold text-white"
158
180
  >
159
181
  로그인하고 주문하기
160
182
  </Link>
161
183
  </div>
184
+ ) : purchasable.length ? (
185
+ /* 주문 조건은 URL에 둔다 — 새로고침·뒤로가기가 그대로 동작한다 */
186
+ <Link
187
+ to="/checkout"
188
+ search={{ cartItemId: purchasable.map((item) => item.cartItemId) }}
189
+ className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white md:w-64"
190
+ >
191
+ 주문하기
192
+ </Link>
162
193
  ) : (
163
- /* 주문서는 GET으로 연다 — 진입 조건이 URL에 남아 새로고침·뒤로가기가 그대로 동작한다 */
164
- <Form method="get" action="/checkout" className="w-full md:w-64">
165
- {purchasable.map((item) => (
166
- <input
167
- key={item.cartItemId}
168
- type="hidden"
169
- name="cartItemId"
170
- value={item.cartItemId}
171
- />
172
- ))}
173
- <button
174
- type="submit"
175
- disabled={!purchasable.length}
176
- className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
177
- >
178
- 주문하기
179
- </button>
180
- </Form>
194
+ <span className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white opacity-50 md:w-64">
195
+ 주문하기
196
+ </span>
181
197
  )}
182
198
  </div>
183
199
  </div>
@@ -1,28 +1,35 @@
1
- import { Link } from "react-router";
1
+ import { createFileRoute, Link } from "@tanstack/react-router";
2
+ import { createServerFn } from "@tanstack/react-start";
3
+ import { z } from "zod";
2
4
  import { apiFor } from "../lib/api.server";
3
5
  import { formatPrice } from "../lib/format";
4
6
  import { readToken } from "../lib/session.server";
5
- import type { Route } from "./+types/checkout-complete";
6
7
 
7
- export function meta() {
8
- return [{ title: "주문 완료" }];
9
- }
10
-
11
- export async function loader({ request, context }: Route.LoaderArgs) {
12
- const orderId = new URL(request.url).searchParams.get("orderId");
13
- if (!orderId) return { orderId: null, order: null };
8
+ const completeSearch = z.object({ orderId: z.string().optional().catch(undefined) });
14
9
 
15
- // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
16
- const accessToken = readToken(context);
17
- if (!accessToken) return { orderId, order: null };
10
+ const getCompletedOrder = createServerFn({ method: "GET" })
11
+ .validator(completeSearch)
12
+ .handler(async ({ data }) => {
13
+ if (!data.orderId) return null;
14
+ // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
15
+ const accessToken = readToken();
16
+ if (!accessToken) return null;
17
+ return apiFor({ accessToken })
18
+ .myOrders.get(data.orderId)
19
+ .catch(() => null);
20
+ });
18
21
 
19
- const api = apiFor(request, { accessToken });
20
- const order = await api.myOrders.get(orderId).catch(() => null);
21
- return { orderId, order };
22
- }
22
+ export const Route = createFileRoute("/checkout/complete")({
23
+ validateSearch: completeSearch,
24
+ loaderDeps: ({ search }) => search,
25
+ loader: ({ deps }) => getCompletedOrder({ data: deps }),
26
+ head: () => ({ meta: [{ title: "주문 완료" }] }),
27
+ component: CheckoutComplete,
28
+ });
23
29
 
24
- export default function CheckoutComplete({ loaderData }: Route.ComponentProps) {
25
- const { orderId, order } = loaderData;
30
+ function CheckoutComplete() {
31
+ const order = Route.useLoaderData();
32
+ const { orderId } = Route.useSearch();
26
33
 
27
34
  return (
28
35
  <div className="space-y-6 py-10 text-center">
@@ -1,101 +1,116 @@
1
1
  import { ApiError } from "@sayren/storefront-sdk";
2
- import { useEffect, useRef, useState } from "react";
3
- import { Form, redirect, useNavigate } from "react-router";
2
+ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
4
+ import { useRef, useState } from "react";
5
+ import { z } from "zod";
6
+ import { SubmitButton } from "../components/submit-button";
4
7
  import { browserApi } from "../lib/api";
5
8
  import { apiFor } from "../lib/api.server";
6
9
  import { readCartToken } from "../lib/cart-session.server";
7
- import { checkoutIntentFrom } from "../lib/checkout-intent";
10
+ import { checkoutIntentFrom, checkoutSearch } from "../lib/checkout-intent";
11
+ import type { PublicConfig } from "../lib/config";
8
12
  import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
9
13
  import { formatPrice } from "../lib/format";
10
- import { createPaymentPopup } from "../lib/payment-popup";
14
+ import { createPaymentPopup, type PaymentPopup } from "../lib/payment-popup";
11
15
  import { readToken } from "../lib/session.server";
12
- import type { Route } from "./+types/checkout";
13
16
 
14
- export function meta() {
15
- return [{ title: "주문서" }];
16
- }
17
+ const createCheckout = createServerFn({ method: "POST" })
18
+ .validator(z.object({ search: checkoutSearch, back: z.string() }))
19
+ .handler(async ({ data }) => {
20
+ const intent = checkoutIntentFrom(data.search);
21
+ if (!intent) throw redirect({ to: "/cart" });
17
22
 
18
- export async function loader({ request, context }: Route.LoaderArgs) {
19
- const url = new URL(request.url);
20
- const intent = checkoutIntentFrom(url.searchParams);
21
- if (!intent) throw redirect("/cart");
23
+ const accessToken = readToken();
24
+ const api = apiFor({ accessToken, cartToken: readCartToken() });
25
+ // 주문서 세션은 진입할 때마다 새로 만든다 — 조회 API가 없고, 가격·재고를 매번 다시 계산한다
26
+ const [checkout, member] = await Promise.all([
27
+ api.checkout.create(intent).catch((error: unknown) => {
28
+ // 회원만 주문받는 스토어 — 로그인 뒤 돌아온다. 장바구니 주문은 로그인하며 장바구니가 합쳐져 항목 id가
29
+ // 바뀔 수 있으니 장바구니로, 바로구매는 같은 주문서로 돌아온다
30
+ if (error instanceof ApiError && error.code === "GUEST_CHECKOUT_DISABLED") {
31
+ throw redirect({
32
+ to: "/login",
33
+ search: { redirectTo: intent.cartItemIds ? "/cart" : data.back },
34
+ });
35
+ }
36
+ throw error;
37
+ }),
38
+ accessToken ? api.member.me().catch(() => null) : null,
39
+ ]);
40
+ const config: PublicConfig = { apiBaseUrl: API_BASE_URL, storeCode: resolveStoreCode() };
41
+ return { checkout, member, config };
42
+ });
22
43
 
23
- const accessToken = readToken(context);
24
- const cartToken = await readCartToken(request);
25
- const api = apiFor(request, { accessToken, cartToken });
26
- // 주문서 세션은 진입할 때마다 새로 만든다 — 조회 API가 없고, 가격·재고를 매번 다시 계산한다
27
- const [checkout, member] = await Promise.all([
28
- api.checkout.create(intent).catch((error: unknown) => {
29
- // 회원만 주문받는 스토어 — 로그인 뒤 돌아온다. 장바구니 주문은 로그인하며 장바구니가 합쳐져 항목 id가
30
- // 바뀔 수 있으니 장바구니로, 바로구매는 같은 주문서로 돌아온다
31
- if (error instanceof ApiError && error.code === "GUEST_CHECKOUT_DISABLED") {
32
- const back = intent.cartItemIds ? "/cart" : `${url.pathname}${url.search}`;
33
- throw redirect(`/login?redirectTo=${encodeURIComponent(back)}`);
34
- }
35
- throw error;
36
- }),
37
- accessToken ? api.member.me().catch(() => null) : null,
38
- ]);
39
- return {
40
- checkout,
41
- member,
42
- config: { apiBaseUrl: API_BASE_URL, storeCode: resolveStoreCode(request) },
43
- };
44
- }
44
+ const paymentForm = z.object({
45
+ checkoutId: z.string(),
46
+ receiverName: z.string(),
47
+ phone: z.string(),
48
+ zipCode: z.string(),
49
+ address1: z.string(),
50
+ address2: z.string(),
51
+ deliveryMemo: z.string(),
52
+ guestName: z.string(),
53
+ email: z.string(),
54
+ orderPassword: z.string(),
55
+ });
45
56
 
46
57
  /** 결제 요청은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다 */
47
- export async function action({ request, context }: Route.ActionArgs) {
48
- const form = await request.formData();
49
- const checkoutId = String(form.get("checkoutId") ?? "");
50
- const accessToken = readToken(context);
51
- const cartToken = await readCartToken(request);
52
- const api = apiFor(request, { accessToken, cartToken });
53
-
54
- const guestName = String(form.get("guestName") ?? "");
55
- const payment = await api.checkout.requestPayment(checkoutId, {
56
- shippingAddress: {
57
- receiverName: String(form.get("receiverName") ?? ""),
58
- phone: String(form.get("phone") ?? ""),
59
- zipCode: String(form.get("zipCode") ?? ""),
60
- address1: String(form.get("address1") ?? ""),
61
- address2: String(form.get("address2") ?? "") || undefined,
62
- deliveryMemo: String(form.get("deliveryMemo") ?? "") || undefined,
63
- },
64
- paymentMethod: "CARD",
65
- guest: accessToken
66
- ? undefined
67
- : {
68
- name: guestName,
69
- phone: String(form.get("phone") ?? ""),
70
- email: String(form.get("email") ?? ""),
71
- orderPassword: String(form.get("orderPassword") ?? ""),
72
- },
58
+ const requestPayment = createServerFn({ method: "POST" })
59
+ .validator(paymentForm)
60
+ .handler(async ({ data }) => {
61
+ const accessToken = readToken();
62
+ const api = apiFor({ accessToken, cartToken: readCartToken() });
63
+ const payment = await api.checkout.requestPayment(data.checkoutId, {
64
+ shippingAddress: {
65
+ receiverName: data.receiverName,
66
+ phone: data.phone,
67
+ zipCode: data.zipCode,
68
+ address1: data.address1,
69
+ address2: data.address2 || undefined,
70
+ deliveryMemo: data.deliveryMemo || undefined,
71
+ },
72
+ paymentMethod: "CARD",
73
+ guest: accessToken
74
+ ? undefined
75
+ : {
76
+ name: data.guestName,
77
+ phone: data.phone,
78
+ email: data.email,
79
+ orderPassword: data.orderPassword,
80
+ },
81
+ });
82
+ // 브라우저에는 팝업 주소와 상태 조회 키만 내려준다
83
+ return { paymentId: payment.paymentId, popupUrl: payment.pgParams.popupUrl };
73
84
  });
74
- return { payment };
75
- }
76
85
 
77
- export default function Checkout({ loaderData, actionData }: Route.ComponentProps) {
78
- const { checkout, member, config } = loaderData;
86
+ export const Route = createFileRoute("/checkout/")({
87
+ validateSearch: checkoutSearch,
88
+ loaderDeps: ({ search }) => search,
89
+ loader: ({ deps, location }) => createCheckout({ data: { search: deps, back: location.href } }),
90
+ head: () => ({ meta: [{ title: "주문서" }] }),
91
+ component: Checkout,
92
+ });
93
+
94
+ function Checkout() {
95
+ const { checkout, member, config } = Route.useLoaderData();
79
96
  const navigate = useNavigate();
80
- const popupRef = useRef<ReturnType<typeof createPaymentPopup> | null>(null);
97
+ const popupRef = useRef<PaymentPopup | null>(null);
81
98
  const [notice, setNotice] = useState<string | null>(null);
82
99
  const [checking, setChecking] = useState(false);
100
+ const [submitting, setSubmitting] = useState(false);
83
101
 
84
- // 결제 요청 응답이 오면 이미 열어 둔 팝업을 결제 주소로 보낸다
85
- useEffect(() => {
86
- const popupUrl = actionData?.payment?.pgParams?.popupUrl;
87
- if (!popupUrl || !popupRef.current) return;
88
- if (!popupRef.current.navigate(popupUrl)) {
89
- setNotice("결제창이 열리지 않았어요. 팝업 차단을 풀고 다시 시도해주세요.");
90
- }
91
- }, [actionData]);
102
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
103
+ // 문서를 다시 부르지 않는다 — 팝업 제어 상태가 사라진다
104
+ event.preventDefault();
105
+ const form = new FormData(event.currentTarget);
106
+ const field = (name: string) => String(form.get(name) ?? "");
107
+ let paymentId: string | null = null;
92
108
 
93
- const handleSubmit = () => {
94
109
  // 클릭 콜스택에서 동기적으로 연다 — 응답을 기다린 뒤 열면 차단된다
95
- popupRef.current = createPaymentPopup({
110
+ const popup = createPaymentPopup({
96
111
  onCompleted: (message) => {
97
112
  if (message.orderId) {
98
- void navigate(`/checkout/complete?orderId=${message.orderId}`);
113
+ void navigate({ to: "/checkout/complete", search: { orderId: message.orderId } });
99
114
  } else {
100
115
  setNotice("결제가 완료됐어요. 주문 내역에서 확인해주세요.");
101
116
  }
@@ -105,26 +120,56 @@ export default function Checkout({ loaderData, actionData }: Route.ComponentProp
105
120
  },
106
121
  onClosed: () => {
107
122
  // 닫혔다고 실패로 단정하지 않는다 — 서버에 결제 상태를 물어본다
108
- const paymentId = actionDataPaymentId(actionData);
109
123
  if (!paymentId) return;
110
124
  setChecking(true);
111
125
  void pollPaymentStatus(config, paymentId).then((status) => {
112
126
  setChecking(false);
113
127
  if (status?.status === "completed" && status.orderId) {
114
- void navigate(`/checkout/complete?orderId=${status.orderId}`);
128
+ void navigate({ to: "/checkout/complete", search: { orderId: status.orderId } });
115
129
  } else {
116
130
  setNotice("결제창이 닫혔어요. 결제를 마쳤다면 잠시 뒤 주문 내역에서 확인해주세요.");
117
131
  }
118
132
  });
119
133
  },
120
134
  });
121
- popupRef.current.open();
135
+ popupRef.current?.close();
136
+ popupRef.current = popup;
137
+ popup.open();
138
+
139
+ // 결제 요청 응답이 오면 이미 열어 둔 팝업을 결제 주소로 보낸다
140
+ setSubmitting(true);
141
+ setNotice(null);
142
+ requestPayment({
143
+ data: {
144
+ checkoutId: checkout.checkoutId,
145
+ receiverName: field("receiverName"),
146
+ phone: field("phone"),
147
+ zipCode: field("zipCode"),
148
+ address1: field("address1"),
149
+ address2: field("address2"),
150
+ deliveryMemo: field("deliveryMemo"),
151
+ guestName: field("guestName"),
152
+ email: field("email"),
153
+ orderPassword: field("orderPassword"),
154
+ },
155
+ })
156
+ .then((payment) => {
157
+ paymentId = payment.paymentId;
158
+ if (!popup.navigate(payment.popupUrl)) {
159
+ popup.close();
160
+ setNotice("결제창이 열리지 않았어요. 팝업 차단을 풀고 다시 시도해주세요.");
161
+ }
162
+ })
163
+ .catch(() => {
164
+ popup.close();
165
+ setNotice("결제를 시작하지 못했어요. 입력한 내용을 확인하고 다시 시도해주세요.");
166
+ })
167
+ .finally(() => setSubmitting(false));
122
168
  };
123
169
 
124
170
  return (
125
171
  <div className="grid gap-8 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:items-start">
126
- <Form method="post" onSubmit={handleSubmit} className="space-y-6">
127
- <input type="hidden" name="checkoutId" value={checkout.checkoutId} />
172
+ <form onSubmit={handleSubmit} className="space-y-6">
128
173
  <section className="space-y-3">
129
174
  <h2 className="font-bold text-lg">배송지</h2>
130
175
  <Field label="수령인" name="receiverName" defaultValue={member?.name ?? ""} required />
@@ -150,11 +195,14 @@ export default function Checkout({ loaderData, actionData }: Route.ComponentProp
150
195
  </section>
151
196
  )}
152
197
 
153
- <button type="submit" className="h-12 w-full rounded-md bg-ink font-semibold text-white">
198
+ <SubmitButton
199
+ disabled={submitting}
200
+ className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
201
+ >
154
202
  {formatPrice(checkout.amounts.totalAmount)} 결제하기
155
- </button>
203
+ </SubmitButton>
156
204
  <p className="text-center text-muted text-xs">결제창은 새 팝업에서 열려요</p>
157
- </Form>
205
+ </form>
158
206
 
159
207
  <aside className="space-y-4 rounded-lg border border-line p-5">
160
208
  <h2 className="font-bold text-lg">주문 상품</h2>
@@ -195,20 +243,12 @@ export default function Checkout({ loaderData, actionData }: Route.ComponentProp
195
243
  );
196
244
  }
197
245
 
198
- function actionDataPaymentId(actionData: Route.ComponentProps["actionData"]): string | null {
199
- return actionData?.payment?.paymentId ?? null;
200
- }
201
-
202
246
  /** 팝업이 결과 없이 닫힌 경우 — 서버 상태가 진실이다 */
203
- async function pollPaymentStatus(
204
- config: { apiBaseUrl: string; storeCode: string },
205
- paymentId: string,
206
- ) {
247
+ async function pollPaymentStatus(config: PublicConfig, paymentId: string) {
207
248
  const api = browserApi(config);
208
249
  for (let attempt = 0; attempt < 6; attempt += 1) {
209
250
  const status = await api.payments.getStatus(paymentId).catch(() => null);
210
251
  if (status && status.status !== "pending" && status.status !== "processing") return status;
211
- if (status?.status === "completed") return status;
212
252
  await new Promise((resolve) => setTimeout(resolve, 1500));
213
253
  }
214
254
  return null;
@@ -237,7 +277,7 @@ function Field({
237
277
  {label}
238
278
  {required ? <span className="text-point"> *</span> : null}
239
279
  </span>
240
- {/* 프리필은 defaultValue로 준다 — register만 쓰면 SSR HTML에 값이 실리지 않는다 */}
280
+ {/* 프리필은 defaultValue로 준다 — SSR HTML에 값이 실린다 */}
241
281
  <input
242
282
  type={type}
243
283
  name={name}
@@ -1,29 +1,34 @@
1
- import { Link } from "react-router";
1
+ import type { ProductCard as ProductCardData } from "@sayren/storefront-sdk";
2
+ import { createFileRoute, Link } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
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/home";
6
-
7
- export function meta() {
8
- return [{ title: "스토어" }, { name: "description", content: "지금 인기 있는 상품" }];
9
- }
10
7
 
11
8
  /**
12
9
  * 홈 — 서버에서 상품을 먼저 받아 그린다(SSR). 클라이언트에서 다시 받지 않는다.
13
10
  * 확장 지점: 배너·기획전 레일을 추가하려면 여기서 데이터를 더 불러 `Promise.all`에 넣는다.
14
11
  */
15
- export async function loader({ request }: Route.LoaderArgs) {
16
- const api = apiFor(request);
12
+ const getHome = createServerFn({ method: "GET" }).handler(async () => {
13
+ const api = apiFor();
17
14
  const [categories, recommended, latest] = await Promise.all([
18
15
  api.catalog.listCategories(),
19
16
  api.catalog.searchProducts({ sort: "recommend", size: 8 }),
20
17
  api.catalog.searchProducts({ sort: "latest", size: 8 }),
21
18
  ]);
22
19
  return { categories, recommended: recommended.contents, latest: latest.contents };
23
- }
20
+ });
21
+
22
+ export const Route = createFileRoute("/")({
23
+ head: () => ({
24
+ meta: [{ title: "스토어" }, { name: "description", content: "지금 인기 있는 상품" }],
25
+ }),
26
+ loader: () => getHome(),
27
+ component: Home,
28
+ });
24
29
 
25
- export default function Home({ loaderData }: Route.ComponentProps) {
26
- const { categories, recommended, latest } = loaderData;
30
+ function Home() {
31
+ const { categories, recommended, latest } = Route.useLoaderData();
27
32
 
28
33
  return (
29
34
  <div className="space-y-10">
@@ -32,7 +37,8 @@ export default function Home({ loaderData }: Route.ComponentProps) {
32
37
  {categories.map((category) => (
33
38
  <Link
34
39
  key={category.categoryId}
35
- to={`/products?categoryId=${category.categoryId}`}
40
+ to="/products"
41
+ search={{ categoryId: category.categoryId }}
36
42
  className="rounded-full bg-chip px-3 py-1.5 text-sm hover:bg-line"
37
43
  >
38
44
  {category.name}
@@ -54,7 +60,7 @@ function ProductSection({
54
60
  }: {
55
61
  listId: string;
56
62
  title: string;
57
- products: Awaited<ReturnType<typeof loader>>["recommended"];
63
+ products: ProductCardData[];
58
64
  }) {
59
65
  useTrack(
60
66
  products.length