@sayren/mcp 0.1.8 → 0.1.10

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 (55) hide show
  1. package/dist/index.mjs +22 -19
  2. package/package.json +3 -3
  3. package/template/.template-meta.json +2 -2
  4. package/template/README.md +29 -12
  5. package/template/_gitignore +4 -2
  6. package/template/package.json +8 -10
  7. package/template/{app → src}/components/product-card.tsx +6 -2
  8. package/template/{app → src}/components/product-description.tsx +1 -1
  9. package/template/{app → src}/components/site-header.tsx +4 -1
  10. package/template/src/components/submit-button.tsx +32 -0
  11. package/template/{app → src}/lib/analytics.ts +1 -1
  12. package/template/{app → src}/lib/api.server.ts +6 -5
  13. package/template/{app → src}/lib/api.ts +1 -1
  14. package/template/src/lib/cart-session.server.ts +28 -0
  15. package/template/src/lib/checkout-intent.test.ts +27 -0
  16. package/template/{app → src}/lib/checkout-intent.ts +20 -8
  17. package/template/{app → src}/lib/config.server.ts +5 -4
  18. package/template/{app → src}/lib/config.ts +1 -1
  19. package/template/src/lib/format.test.ts +14 -0
  20. package/template/src/lib/format.ts +30 -0
  21. package/template/{app → src}/lib/safe-redirect.ts +1 -1
  22. package/template/src/lib/session.server.ts +121 -0
  23. package/template/src/lib/social-login.server.ts +54 -0
  24. package/template/src/routeTree.gen.ts +283 -0
  25. package/template/src/router.tsx +17 -0
  26. package/template/src/routes/__root.tsx +90 -0
  27. package/template/src/routes/account.tsx +213 -0
  28. package/template/src/routes/auth.callback.ts +70 -0
  29. package/template/{app → src}/routes/cart.tsx +94 -78
  30. package/template/{app/routes/checkout-complete.tsx → src/routes/checkout.complete.tsx} +25 -18
  31. package/template/{app/routes/checkout.tsx → src/routes/checkout.index.tsx} +134 -94
  32. package/template/{app/routes/home.tsx → src/routes/index.tsx} +19 -13
  33. package/template/src/routes/login.tsx +198 -0
  34. package/template/{app/routes/order-detail.tsx → src/routes/orders.$orderId.tsx} +25 -13
  35. package/template/{app/routes/orders.tsx → src/routes/orders.index.tsx} +16 -14
  36. package/template/{app/routes/product-detail.tsx → src/routes/products.$productId.tsx} +77 -55
  37. package/template/{app/routes/products.tsx → src/routes/products.index.tsx} +62 -38
  38. package/template/src/start.ts +18 -0
  39. package/template/tsconfig.json +3 -4
  40. package/template/vite.config.ts +3 -2
  41. package/template/vitest.config.ts +1 -1
  42. package/template/app/lib/cart-session.server.ts +0 -18
  43. package/template/app/lib/format.ts +0 -10
  44. package/template/app/lib/session.server.ts +0 -99
  45. package/template/app/lib/social-login.server.ts +0 -31
  46. package/template/app/root.tsx +0 -78
  47. package/template/app/routes/auth-callback.tsx +0 -44
  48. package/template/app/routes/login.tsx +0 -153
  49. package/template/app/routes.ts +0 -18
  50. package/template/react-router.config.ts +0 -6
  51. /package/template/{app → src}/lib/config.test.ts +0 -0
  52. /package/template/{app → src}/lib/payment-popup.ts +0 -0
  53. /package/template/{app → src}/lib/safe-redirect.test.ts +0 -0
  54. /package/template/{app → src}/lib/social-buttons.ts +0 -0
  55. /package/template/{app/app.css → src/styles.css} +0 -0
@@ -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
@@ -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">