@sayren/mcp 0.5.2 → 0.6.1

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 (32) hide show
  1. package/dist/index.mjs +33 -3
  2. package/package.json +3 -3
  3. package/template/.template-meta.json +3 -2
  4. package/template/README.md +87 -55
  5. package/template/package.json +2 -2
  6. package/template/public/favicon.svg +1 -0
  7. package/template/src/components/product-card.tsx +2 -0
  8. package/template/src/components/site-header.tsx +16 -6
  9. package/template/src/components/submit-button.tsx +4 -0
  10. package/template/src/lib/config.server.ts +1 -1
  11. package/template/src/lib/guest-order.test.ts +18 -0
  12. package/template/src/lib/guest-order.ts +13 -0
  13. package/template/src/lib/order-status.ts +17 -0
  14. package/template/src/lib/page-title.test.ts +19 -0
  15. package/template/src/lib/page-title.ts +23 -0
  16. package/template/src/lib/payment-options.test.ts +61 -0
  17. package/template/src/lib/payment-options.ts +39 -0
  18. package/template/src/routeTree.gen.ts +42 -0
  19. package/template/src/routes/__root.tsx +40 -6
  20. package/template/src/routes/account.tsx +166 -4
  21. package/template/src/routes/cart.tsx +9 -2
  22. package/template/src/routes/checkout.complete.tsx +22 -10
  23. package/template/src/routes/checkout.index.tsx +40 -17
  24. package/template/src/routes/checkout.return.tsx +132 -20
  25. package/template/src/routes/guest-order.tsx +183 -0
  26. package/template/src/routes/index.tsx +2 -3
  27. package/template/src/routes/login.tsx +23 -3
  28. package/template/src/routes/orders.$orderId.tsx +13 -8
  29. package/template/src/routes/orders.index.tsx +2 -1
  30. package/template/src/routes/products.$productId.tsx +12 -5
  31. package/template/src/routes/products.index.tsx +6 -1
  32. package/template/src/routes/signup.tsx +235 -0
@@ -12,7 +12,9 @@ import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as IndexRouteImport } from './routes/index'
13
13
  import { Route as AccountRouteImport } from './routes/account'
14
14
  import { Route as CartRouteImport } from './routes/cart'
15
+ import { Route as GuestOrderRouteImport } from './routes/guest-order'
15
16
  import { Route as LoginRouteImport } from './routes/login'
17
+ import { Route as SignupRouteImport } from './routes/signup'
16
18
  import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
17
19
  import { Route as CheckoutIndexRouteImport } from './routes/checkout.index'
18
20
  import { Route as CheckoutCompleteRouteImport } from './routes/checkout.complete'
@@ -37,11 +39,21 @@ const CartRoute = CartRouteImport.update({
37
39
  path: '/cart',
38
40
  getParentRoute: () => rootRouteImport,
39
41
  } as any)
42
+ const GuestOrderRoute = GuestOrderRouteImport.update({
43
+ id: '/guest-order',
44
+ path: '/guest-order',
45
+ getParentRoute: () => rootRouteImport,
46
+ } as any)
40
47
  const LoginRoute = LoginRouteImport.update({
41
48
  id: '/login',
42
49
  path: '/login',
43
50
  getParentRoute: () => rootRouteImport,
44
51
  } as any)
52
+ const SignupRoute = SignupRouteImport.update({
53
+ id: '/signup',
54
+ path: '/signup',
55
+ getParentRoute: () => rootRouteImport,
56
+ } as any)
45
57
  const AuthCallbackRoute = AuthCallbackRouteImport.update({
46
58
  id: '/auth/callback',
47
59
  path: '/auth/callback',
@@ -87,7 +99,9 @@ export interface FileRoutesByFullPath {
87
99
  '/': typeof IndexRoute
88
100
  '/account': typeof AccountRoute
89
101
  '/cart': typeof CartRoute
102
+ '/guest-order': typeof GuestOrderRoute
90
103
  '/login': typeof LoginRoute
104
+ '/signup': typeof SignupRoute
91
105
  '/auth/callback': typeof AuthCallbackRoute
92
106
  '/checkout/complete': typeof CheckoutCompleteRoute
93
107
  '/checkout/return': typeof CheckoutReturnRoute
@@ -101,7 +115,9 @@ export interface FileRoutesByTo {
101
115
  '/': typeof IndexRoute
102
116
  '/account': typeof AccountRoute
103
117
  '/cart': typeof CartRoute
118
+ '/guest-order': typeof GuestOrderRoute
104
119
  '/login': typeof LoginRoute
120
+ '/signup': typeof SignupRoute
105
121
  '/auth/callback': typeof AuthCallbackRoute
106
122
  '/checkout/complete': typeof CheckoutCompleteRoute
107
123
  '/checkout/return': typeof CheckoutReturnRoute
@@ -116,7 +132,9 @@ export interface FileRoutesById {
116
132
  '/': typeof IndexRoute
117
133
  '/account': typeof AccountRoute
118
134
  '/cart': typeof CartRoute
135
+ '/guest-order': typeof GuestOrderRoute
119
136
  '/login': typeof LoginRoute
137
+ '/signup': typeof SignupRoute
120
138
  '/auth/callback': typeof AuthCallbackRoute
121
139
  '/checkout/complete': typeof CheckoutCompleteRoute
122
140
  '/checkout/return': typeof CheckoutReturnRoute
@@ -132,7 +150,9 @@ export interface FileRouteTypes {
132
150
  | '/'
133
151
  | '/account'
134
152
  | '/cart'
153
+ | '/guest-order'
135
154
  | '/login'
155
+ | '/signup'
136
156
  | '/auth/callback'
137
157
  | '/checkout/complete'
138
158
  | '/checkout/return'
@@ -146,7 +166,9 @@ export interface FileRouteTypes {
146
166
  | '/'
147
167
  | '/account'
148
168
  | '/cart'
169
+ | '/guest-order'
149
170
  | '/login'
171
+ | '/signup'
150
172
  | '/auth/callback'
151
173
  | '/checkout/complete'
152
174
  | '/checkout/return'
@@ -160,7 +182,9 @@ export interface FileRouteTypes {
160
182
  | '/'
161
183
  | '/account'
162
184
  | '/cart'
185
+ | '/guest-order'
163
186
  | '/login'
187
+ | '/signup'
164
188
  | '/auth/callback'
165
189
  | '/checkout/complete'
166
190
  | '/checkout/return'
@@ -175,7 +199,9 @@ export interface RootRouteChildren {
175
199
  IndexRoute: typeof IndexRoute
176
200
  AccountRoute: typeof AccountRoute
177
201
  CartRoute: typeof CartRoute
202
+ GuestOrderRoute: typeof GuestOrderRoute
178
203
  LoginRoute: typeof LoginRoute
204
+ SignupRoute: typeof SignupRoute
179
205
  AuthCallbackRoute: typeof AuthCallbackRoute
180
206
  CheckoutCompleteRoute: typeof CheckoutCompleteRoute
181
207
  CheckoutReturnRoute: typeof CheckoutReturnRoute
@@ -209,6 +235,13 @@ declare module '@tanstack/react-router' {
209
235
  preLoaderRoute: typeof CartRouteImport
210
236
  parentRoute: typeof rootRouteImport
211
237
  }
238
+ '/guest-order': {
239
+ id: '/guest-order'
240
+ path: '/guest-order'
241
+ fullPath: '/guest-order'
242
+ preLoaderRoute: typeof GuestOrderRouteImport
243
+ parentRoute: typeof rootRouteImport
244
+ }
212
245
  '/login': {
213
246
  id: '/login'
214
247
  path: '/login'
@@ -216,6 +249,13 @@ declare module '@tanstack/react-router' {
216
249
  preLoaderRoute: typeof LoginRouteImport
217
250
  parentRoute: typeof rootRouteImport
218
251
  }
252
+ '/signup': {
253
+ id: '/signup'
254
+ path: '/signup'
255
+ fullPath: '/signup'
256
+ preLoaderRoute: typeof SignupRouteImport
257
+ parentRoute: typeof rootRouteImport
258
+ }
219
259
  '/auth/callback': {
220
260
  id: '/auth/callback'
221
261
  path: '/auth/callback'
@@ -279,7 +319,9 @@ const rootRouteChildren: RootRouteChildren = {
279
319
  IndexRoute: IndexRoute,
280
320
  AccountRoute: AccountRoute,
281
321
  CartRoute: CartRoute,
322
+ GuestOrderRoute: GuestOrderRoute,
282
323
  LoginRoute: LoginRoute,
324
+ SignupRoute: SignupRoute,
283
325
  AuthCallbackRoute: AuthCallbackRoute,
284
326
  CheckoutCompleteRoute: CheckoutCompleteRoute,
285
327
  CheckoutReturnRoute: CheckoutReturnRoute,
@@ -5,12 +5,15 @@ import {
5
5
  Link,
6
6
  Outlet,
7
7
  Scripts,
8
+ useRouterState,
8
9
  } from "@tanstack/react-router";
9
10
  import { createServerFn } from "@tanstack/react-start";
10
11
  import { useEffect } from "react";
11
- import { SiteHeader } from "../components/site-header";
12
+ import { SiteHeader, type StoreBrand } from "../components/site-header";
12
13
  import { type AnalyticsConfig, startAnalytics } from "../lib/analytics";
14
+ import { apiFor } from "../lib/api.server";
13
15
  import { ANALYTICS_DEBUG, API_BASE_URL, resolveStoreCode } from "../lib/config.server";
16
+ import { FALLBACK_STORE_NAME } from "../lib/page-title";
14
17
  import appCss from "../styles.css?url";
15
18
 
16
19
  /** 방문 분석 설정 — 서버 값(`process.env`)이라 서버 함수로 내려준다 */
@@ -22,16 +25,43 @@ const getAnalyticsConfig = createServerFn({ method: "GET" }).handler(
22
25
  }),
23
26
  );
24
27
 
28
+ /**
29
+ * 스토어 이름·로고 — 셀러가 콘솔 설정 › 스토어 정보에서 정한 값이다. 헤더와 문서 제목에 쓴다.
30
+ * 읽지 못해도 화면은 그린다(각 화면이 자기 데이터로 오류를 보인다).
31
+ */
32
+ const getStoreBrand = createServerFn({ method: "GET" }).handler(
33
+ async (): Promise<StoreBrand | null> => {
34
+ const store = await apiFor()
35
+ .store.get()
36
+ .catch(() => null);
37
+ return store ? { name: store.name, logoUrl: store.logoUrl } : null;
38
+ },
39
+ );
40
+
41
+ interface RootData {
42
+ analytics: AnalyticsConfig;
43
+ store: StoreBrand | null;
44
+ }
45
+
25
46
  export const Route = createRootRoute({
26
- head: () => ({
47
+ head: ({ loaderData }) => ({
27
48
  meta: [
28
49
  { charSet: "utf-8" },
29
50
  { name: "viewport", content: "width=device-width, initial-scale=1" },
30
- { title: "스토어" },
51
+ // 화면마다 `{화면} | {스토어 이름}`으로 덮어쓴다(`lib/page-title.ts`). 여기 값은 홈처럼
52
+ // 화면 이름이 없는 곳의 제목이다
53
+ { title: loaderData?.store?.name ?? FALLBACK_STORE_NAME },
54
+ ],
55
+ links: [
56
+ { rel: "stylesheet", href: appCss },
57
+ // 확장 지점 — 파비콘은 public/favicon.svg를 바꾼다
58
+ { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
31
59
  ],
32
- links: [{ rel: "stylesheet", href: appCss }],
33
60
  }),
34
- loader: async () => ({ analytics: await getAnalyticsConfig() }),
61
+ loader: async (): Promise<RootData> => {
62
+ const [analytics, store] = await Promise.all([getAnalyticsConfig(), getStoreBrand()]);
63
+ return { analytics, store };
64
+ },
35
65
  // 설정은 바뀌지 않는다 — 화면을 옮길 때마다 다시 받지 않는다
36
66
  staleTime: Number.POSITIVE_INFINITY,
37
67
  shellComponent: RootDocument,
@@ -41,13 +71,17 @@ export const Route = createRootRoute({
41
71
  });
42
72
 
43
73
  function RootDocument({ children }: { children: React.ReactNode }) {
74
+ // 셸은 오류 화면도 감싼다 — loader가 실패했을 수 있어 값이 없을 때를 함께 다룬다
75
+ const store = useRouterState({
76
+ select: (state) => (state.matches[0]?.loaderData as RootData | undefined)?.store ?? null,
77
+ });
44
78
  return (
45
79
  <html lang="ko">
46
80
  <head>
47
81
  <HeadContent />
48
82
  </head>
49
83
  <body className="min-h-screen bg-white text-ink">
50
- <SiteHeader />
84
+ <SiteHeader store={store} />
51
85
  <main className="mx-auto w-full max-w-5xl px-4 py-8">{children}</main>
52
86
  <Scripts />
53
87
  </body>
@@ -1,4 +1,8 @@
1
- import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
1
+ import {
2
+ ApiError,
3
+ type SocialProvider,
4
+ withdrawalBlockedDetailsSchema,
5
+ } from "@sayren/storefront-sdk";
2
6
  import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
3
7
  import { createServerFn } from "@tanstack/react-start";
4
8
  import { getRequestUrl } from "@tanstack/react-start/server";
@@ -7,7 +11,8 @@ import { z } from "zod";
7
11
  import { SubmitButton } from "../components/submit-button";
8
12
  import { apiFor, authFor } from "../lib/api.server";
9
13
  import { formatDateTime } from "../lib/format";
10
- import { readToken } from "../lib/session.server";
14
+ import { pageTitle } from "../lib/page-title";
15
+ import { readToken, signOut } from "../lib/session.server";
11
16
  import { SOCIAL_BUTTONS } from "../lib/social-buttons";
12
17
  import { writeSocialLogin } from "../lib/social-login.server";
13
18
 
@@ -104,6 +109,61 @@ const unlink = createServerFn({ method: "POST" })
104
109
  }
105
110
  });
106
111
 
112
+ /**
113
+ * 로그아웃 — 서버에서 이 구매자의 리프레시 토큰을 모두 폐기하고 세션 쿠키를 지운다.
114
+ * 서버 호출이 실패해도 쿠키는 지운다(이 브라우저에서는 로그아웃된다).
115
+ */
116
+ const logout = createServerFn({ method: "POST" }).handler(async () => {
117
+ const accessToken = readToken();
118
+ if (accessToken)
119
+ await authFor()
120
+ .signOut(accessToken)
121
+ .catch(() => undefined);
122
+ signOut();
123
+ });
124
+
125
+ /**
126
+ * 회원 탈퇴 — 되돌릴 수 없다. 비밀번호가 있는 구매자는 비밀번호를 다시 받는다. 소셜 로그인만 쓰는 구매자는
127
+ * 최근 10분 안에 로그인한 세션이어야 한다(아니면 403 `REAUTH_REQUIRED`). 성공하면 세션 쿠키를 지운다.
128
+ */
129
+ const withdraw = createServerFn({ method: "POST" })
130
+ .validator(z.object({ password: z.string().optional() }))
131
+ .handler(async ({ data }) => {
132
+ const accessToken = readToken();
133
+ if (!accessToken) return { error: "다시 로그인해 주십시오" };
134
+ try {
135
+ await authFor().withdraw(accessToken, { password: data.password || undefined });
136
+ signOut();
137
+ return { error: null };
138
+ } catch (error) {
139
+ return { error: withdrawErrorMessage(error) };
140
+ }
141
+ });
142
+
143
+ function withdrawErrorMessage(error: unknown): string {
144
+ if (!(error instanceof ApiError)) return "탈퇴하지 못했습니다. 잠시 후 다시 시도해 주십시오";
145
+ if (error.code === "WITHDRAWAL_BLOCKED") {
146
+ const details = withdrawalBlockedDetailsSchema.safeParse(error.details);
147
+ const reasons = details.success
148
+ ? [
149
+ details.data.inProgressOrderItems
150
+ ? `진행 중인 주문 ${details.data.inProgressOrderItems}건`
151
+ : null,
152
+ details.data.openClaims ? `처리 중인 취소·반품 ${details.data.openClaims}건` : null,
153
+ details.data.pendingPayments ? `진행 중인 결제 ${details.data.pendingPayments}건` : null,
154
+ ].filter(Boolean)
155
+ : [];
156
+ return `${reasons.length ? `${reasons.join(", ")}이 있어` : "진행 중인 주문이 있어"} 지금은 탈퇴할 수 없습니다`;
157
+ }
158
+ if (error.code === "PASSWORD_MISMATCH") return "비밀번호가 맞지 않습니다";
159
+ if (error.code === "PASSWORD_REQUIRED") return "비밀번호를 입력해 주십시오";
160
+ if (error.code === "REAUTH_REQUIRED")
161
+ return "보안을 위해 다시 로그인한 뒤 10분 안에 탈퇴해 주십시오";
162
+ if (error.code === "ACCOUNT_LOCKED")
163
+ return "비밀번호를 여러 번 틀려 잠시 잠겼습니다. 10분 뒤에 다시 시도해 주십시오";
164
+ return "탈퇴하지 못했습니다. 잠시 후 다시 시도해 주십시오";
165
+ }
166
+
107
167
  const ERROR_MESSAGE: Record<string, string> = {
108
168
  IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼 있습니다",
109
169
  PROVIDER_ALREADY_LINKED:
@@ -122,7 +182,7 @@ function accountErrorMessage(error: unknown): string {
122
182
  export const Route = createFileRoute("/account")({
123
183
  validateSearch: accountSearch,
124
184
  loader: () => getAccount(),
125
- head: () => ({ meta: [{ title: "내 정보" }] }),
185
+ head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "내 정보") }] }),
126
186
  component: Account,
127
187
  });
128
188
 
@@ -238,7 +298,11 @@ function Account() {
238
298
  })}
239
299
  </ul>
240
300
  </form>
241
- {error ? <p className="text-point text-sm">{error}</p> : null}
301
+ {error ? (
302
+ <p role="alert" className="text-point text-sm">
303
+ {error}
304
+ </p>
305
+ ) : null}
242
306
  <p className="text-muted text-xs">
243
307
  연결한 계정으로도 같은 회원으로 로그인합니다. 마지막 로그인 수단은 해제할 수 없습니다.
244
308
  </p>
@@ -308,6 +372,104 @@ function Account() {
308
372
  </li>
309
373
  </ul>
310
374
  </section>
375
+
376
+ <section className="space-y-3">
377
+ <h2 className="font-bold text-lg">계정</h2>
378
+ <button
379
+ type="button"
380
+ disabled={pending}
381
+ onClick={() => {
382
+ setPending(true);
383
+ // 로그아웃 상태가 모든 화면에 새로 반영되도록 문서째 이동한다
384
+ void logout().finally(() => window.location.assign("/"));
385
+ }}
386
+ className="h-10 rounded-md border border-line px-4 text-sm disabled:opacity-50"
387
+ >
388
+ 로그아웃
389
+ </button>
390
+ <WithdrawForm hasPassword={identities.hasPassword} />
391
+ </section>
311
392
  </div>
312
393
  );
313
394
  }
395
+
396
+ /** 회원 탈퇴 — 확인을 한 번 더 받는다. 주문·리뷰·문의 기록은 스토어가 보관한다 */
397
+ function WithdrawForm({ hasPassword }: { hasPassword: boolean }) {
398
+ const [open, setOpen] = useState(false);
399
+ const [pending, setPending] = useState(false);
400
+ const [error, setError] = useState<string | null>(null);
401
+
402
+ if (!open) {
403
+ return (
404
+ <button
405
+ type="button"
406
+ onClick={() => setOpen(true)}
407
+ className="block text-muted text-sm underline"
408
+ >
409
+ 회원 탈퇴
410
+ </button>
411
+ );
412
+ }
413
+ return (
414
+ <form
415
+ aria-label="회원 탈퇴"
416
+ className="space-y-3 rounded-md bg-chip p-4 text-sm"
417
+ onSubmit={(event) => {
418
+ event.preventDefault();
419
+ const form = new FormData(event.currentTarget);
420
+ setPending(true);
421
+ setError(null);
422
+ void withdraw({ data: { password: String(form.get("password") ?? "") } })
423
+ .then((result) => {
424
+ if (!result.error) {
425
+ window.location.assign("/");
426
+ return;
427
+ }
428
+ setError(result.error);
429
+ setPending(false);
430
+ })
431
+ .catch(() => {
432
+ setError("탈퇴하지 못했습니다. 잠시 후 다시 시도해 주십시오");
433
+ setPending(false);
434
+ });
435
+ }}
436
+ >
437
+ <p>
438
+ 탈퇴하면 계정·배송지·장바구니가 지워지고 되돌릴 수 없습니다. 주문·리뷰·문의 기록은 스토어가
439
+ 보관합니다.
440
+ </p>
441
+ {hasPassword ? (
442
+ <label className="block space-y-1">
443
+ <span>비밀번호 확인</span>
444
+ <input
445
+ type="password"
446
+ name="password"
447
+ autoComplete="current-password"
448
+ required
449
+ className="h-10 w-full rounded-md border border-line bg-white px-3"
450
+ />
451
+ </label>
452
+ ) : (
453
+ <p className="text-muted text-xs">
454
+ 소셜 로그인 회원은 로그인한 지 10분 안에만 탈퇴할 수 있습니다.
455
+ </p>
456
+ )}
457
+ {error ? (
458
+ <p role="alert" className="text-point">
459
+ {error}
460
+ </p>
461
+ ) : null}
462
+ <div className="flex items-center gap-3">
463
+ <SubmitButton
464
+ disabled={pending}
465
+ className="h-10 rounded-md bg-ink px-4 font-semibold text-white disabled:opacity-50"
466
+ >
467
+ 탈퇴하기
468
+ </SubmitButton>
469
+ <button type="button" onClick={() => setOpen(false)} className="text-muted underline">
470
+ 취소
471
+ </button>
472
+ </div>
473
+ </form>
474
+ );
475
+ }
@@ -7,6 +7,7 @@ import { SubmitButton } from "../components/submit-button";
7
7
  import { apiFor } from "../lib/api.server";
8
8
  import { readCartToken, writeCartToken } from "../lib/cart-session.server";
9
9
  import { formatPrice } from "../lib/format";
10
+ import { pageTitle } from "../lib/page-title";
10
11
  import { readToken } from "../lib/session.server";
11
12
 
12
13
  const getCart = createServerFn({ method: "GET" }).handler(async () => {
@@ -54,7 +55,7 @@ const changeCart = createServerFn({ method: "POST" })
54
55
 
55
56
  export const Route = createFileRoute("/cart")({
56
57
  loader: () => getCart(),
57
- head: () => ({ meta: [{ title: "장바구니" }] }),
58
+ head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "장바구니") }] }),
58
59
  component: CartPage,
59
60
  });
60
61
 
@@ -92,6 +93,7 @@ function CartPage() {
92
93
  <li key={item.cartItemId} className="flex gap-4 py-4">
93
94
  <ProductThumb
94
95
  src={item.thumbnailUrl}
96
+ alt={item.productName}
95
97
  className="size-20 rounded-md bg-chip object-cover"
96
98
  />
97
99
  <div className="flex flex-1 flex-col gap-1">
@@ -126,6 +128,7 @@ function CartPage() {
126
128
  <input
127
129
  type="number"
128
130
  name="quantity"
131
+ aria-label={`${item.productName} 수량`}
129
132
  min={1}
130
133
  max={999}
131
134
  defaultValue={item.quantity}
@@ -141,7 +144,11 @@ function CartPage() {
141
144
  change({ intent: "remove", cartItemId: item.cartItemId });
142
145
  }}
143
146
  >
144
- <SubmitButton disabled={pending} className="text-muted text-xs underline">
147
+ <SubmitButton
148
+ disabled={pending}
149
+ ariaLabel={`${item.productName} 삭제`}
150
+ className="text-muted text-xs underline"
151
+ >
145
152
  삭제
146
153
  </SubmitButton>
147
154
  </form>
@@ -3,6 +3,7 @@ import { createServerFn } from "@tanstack/react-start";
3
3
  import { z } from "zod";
4
4
  import { apiFor } from "../lib/api.server";
5
5
  import { formatPrice } from "../lib/format";
6
+ import { pageTitle } from "../lib/page-title";
6
7
  import { readToken } from "../lib/session.server";
7
8
 
8
9
  const completeSearch = z.object({ orderId: z.string().optional().catch(undefined) });
@@ -10,25 +11,25 @@ const completeSearch = z.object({ orderId: z.string().optional().catch(undefined
10
11
  const getCompletedOrder = createServerFn({ method: "GET" })
11
12
  .validator(completeSearch)
12
13
  .handler(async ({ data }) => {
13
- if (!data.orderId) return null;
14
- // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
14
+ // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 주고 비회원 주문 조회로 보낸다
15
15
  const accessToken = readToken();
16
- if (!accessToken) return null;
17
- return apiFor({ accessToken })
16
+ if (!data.orderId || !accessToken) return { order: null, member: Boolean(accessToken) };
17
+ const order = await apiFor({ accessToken })
18
18
  .myOrders.get(data.orderId)
19
19
  .catch(() => null);
20
+ return { order, member: true };
20
21
  });
21
22
 
22
23
  export const Route = createFileRoute("/checkout/complete")({
23
24
  validateSearch: completeSearch,
24
25
  loaderDeps: ({ search }) => search,
25
26
  loader: ({ deps }) => getCompletedOrder({ data: deps }),
26
- head: () => ({ meta: [{ title: "주문 완료" }] }),
27
+ head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "주문 완료") }] }),
27
28
  component: CheckoutComplete,
28
29
  });
29
30
 
30
31
  function CheckoutComplete() {
31
- const order = Route.useLoaderData();
32
+ const { order, member } = Route.useLoaderData();
32
33
  const { orderId } = Route.useSearch();
33
34
 
34
35
  return (
@@ -38,7 +39,7 @@ function CheckoutComplete() {
38
39
  {orderId ? (
39
40
  <p className="text-muted text-sm">주문번호 {orderId}</p>
40
41
  ) : (
41
- <p className="text-muted text-sm">주문 내역에서 주문을 확인해주십시오</p>
42
+ <p className="text-muted text-sm">주문 내역에서 주문을 확인해 주십시오</p>
42
43
  )}
43
44
  </div>
44
45
 
@@ -61,9 +62,20 @@ function CheckoutComplete() {
61
62
  ) : null}
62
63
 
63
64
  <div className="flex justify-center gap-2">
64
- <Link to="/orders" className="rounded-md border border-ink px-5 py-3">
65
- 주문 내역
66
- </Link>
65
+ {/* 비회원 주문은 주문번호·연락처·주문 조회 비밀번호로 찾는다 */}
66
+ {member || !orderId ? (
67
+ <Link to="/orders" className="rounded-md border border-ink px-5 py-3">
68
+ 주문 내역
69
+ </Link>
70
+ ) : (
71
+ <Link
72
+ to="/guest-order"
73
+ search={{ orderId }}
74
+ className="rounded-md border border-ink px-5 py-3"
75
+ >
76
+ 비회원 주문 조회
77
+ </Link>
78
+ )}
67
79
  <Link to="/products" className="rounded-md bg-ink px-5 py-3 text-white">
68
80
  계속 쇼핑하기
69
81
  </Link>