@sayren/mcp 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,11 +5,13 @@ 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";
14
16
  import appCss from "../styles.css?url";
15
17
 
@@ -22,16 +24,42 @@ const getAnalyticsConfig = createServerFn({ method: "GET" }).handler(
22
24
  }),
23
25
  );
24
26
 
27
+ /**
28
+ * 스토어 이름·로고 — 셀러가 콘솔 설정 › 스토어 정보에서 정한 값이다. 헤더와 문서 제목에 쓴다.
29
+ * 읽지 못해도 화면은 그린다(각 화면이 자기 데이터로 오류를 보인다).
30
+ */
31
+ const getStoreBrand = createServerFn({ method: "GET" }).handler(
32
+ async (): Promise<StoreBrand | null> => {
33
+ const store = await apiFor()
34
+ .store.get()
35
+ .catch(() => null);
36
+ return store ? { name: store.name, logoUrl: store.logoUrl } : null;
37
+ },
38
+ );
39
+
40
+ interface RootData {
41
+ analytics: AnalyticsConfig;
42
+ store: StoreBrand | null;
43
+ }
44
+
25
45
  export const Route = createRootRoute({
26
- head: () => ({
46
+ head: ({ loaderData }) => ({
27
47
  meta: [
28
48
  { charSet: "utf-8" },
29
49
  { name: "viewport", content: "width=device-width, initial-scale=1" },
30
- { title: "스토어" },
50
+ // 화면마다 제목을 덮어쓴다. 여기 값은 제목을 정하지 않은 화면의 기본값이다
51
+ { title: loaderData?.store?.name ?? "스토어" },
52
+ ],
53
+ links: [
54
+ { rel: "stylesheet", href: appCss },
55
+ // 확장 지점 — 파비콘은 public/favicon.svg를 바꾼다
56
+ { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
31
57
  ],
32
- links: [{ rel: "stylesheet", href: appCss }],
33
58
  }),
34
- loader: async () => ({ analytics: await getAnalyticsConfig() }),
59
+ loader: async (): Promise<RootData> => {
60
+ const [analytics, store] = await Promise.all([getAnalyticsConfig(), getStoreBrand()]);
61
+ return { analytics, store };
62
+ },
35
63
  // 설정은 바뀌지 않는다 — 화면을 옮길 때마다 다시 받지 않는다
36
64
  staleTime: Number.POSITIVE_INFINITY,
37
65
  shellComponent: RootDocument,
@@ -41,13 +69,17 @@ export const Route = createRootRoute({
41
69
  });
42
70
 
43
71
  function RootDocument({ children }: { children: React.ReactNode }) {
72
+ // 셸은 오류 화면도 감싼다 — loader가 실패했을 수 있어 값이 없을 때를 함께 다룬다
73
+ const store = useRouterState({
74
+ select: (state) => (state.matches[0]?.loaderData as RootData | undefined)?.store ?? null,
75
+ });
44
76
  return (
45
77
  <html lang="ko">
46
78
  <head>
47
79
  <HeadContent />
48
80
  </head>
49
81
  <body className="min-h-screen bg-white text-ink">
50
- <SiteHeader />
82
+ <SiteHeader store={store} />
51
83
  <main className="mx-auto w-full max-w-5xl px-4 py-8">{children}</main>
52
84
  <Scripts />
53
85
  </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,7 @@ 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 { readToken, signOut } from "../lib/session.server";
11
15
  import { SOCIAL_BUTTONS } from "../lib/social-buttons";
12
16
  import { writeSocialLogin } from "../lib/social-login.server";
13
17
 
@@ -104,6 +108,61 @@ const unlink = createServerFn({ method: "POST" })
104
108
  }
105
109
  });
106
110
 
111
+ /**
112
+ * 로그아웃 — 서버에서 이 구매자의 리프레시 토큰을 모두 폐기하고 세션 쿠키를 지운다.
113
+ * 서버 호출이 실패해도 쿠키는 지운다(이 브라우저에서는 로그아웃된다).
114
+ */
115
+ const logout = createServerFn({ method: "POST" }).handler(async () => {
116
+ const accessToken = readToken();
117
+ if (accessToken)
118
+ await authFor()
119
+ .signOut(accessToken)
120
+ .catch(() => undefined);
121
+ signOut();
122
+ });
123
+
124
+ /**
125
+ * 회원 탈퇴 — 되돌릴 수 없다. 비밀번호가 있는 구매자는 비밀번호를 다시 받는다. 소셜 로그인만 쓰는 구매자는
126
+ * 최근 10분 안에 로그인한 세션이어야 한다(아니면 403 `REAUTH_REQUIRED`). 성공하면 세션 쿠키를 지운다.
127
+ */
128
+ const withdraw = createServerFn({ method: "POST" })
129
+ .validator(z.object({ password: z.string().optional() }))
130
+ .handler(async ({ data }) => {
131
+ const accessToken = readToken();
132
+ if (!accessToken) return { error: "다시 로그인해 주십시오" };
133
+ try {
134
+ await authFor().withdraw(accessToken, { password: data.password || undefined });
135
+ signOut();
136
+ return { error: null };
137
+ } catch (error) {
138
+ return { error: withdrawErrorMessage(error) };
139
+ }
140
+ });
141
+
142
+ function withdrawErrorMessage(error: unknown): string {
143
+ if (!(error instanceof ApiError)) return "탈퇴하지 못했습니다. 잠시 후 다시 시도해 주십시오";
144
+ if (error.code === "WITHDRAWAL_BLOCKED") {
145
+ const details = withdrawalBlockedDetailsSchema.safeParse(error.details);
146
+ const reasons = details.success
147
+ ? [
148
+ details.data.inProgressOrderItems
149
+ ? `진행 중인 주문 ${details.data.inProgressOrderItems}건`
150
+ : null,
151
+ details.data.openClaims ? `처리 중인 취소·반품 ${details.data.openClaims}건` : null,
152
+ details.data.pendingPayments ? `진행 중인 결제 ${details.data.pendingPayments}건` : null,
153
+ ].filter(Boolean)
154
+ : [];
155
+ return `${reasons.length ? `${reasons.join(", ")}이 있어` : "진행 중인 주문이 있어"} 지금은 탈퇴할 수 없습니다`;
156
+ }
157
+ if (error.code === "PASSWORD_MISMATCH") return "비밀번호가 맞지 않습니다";
158
+ if (error.code === "PASSWORD_REQUIRED") return "비밀번호를 입력해 주십시오";
159
+ if (error.code === "REAUTH_REQUIRED")
160
+ return "보안을 위해 다시 로그인한 뒤 10분 안에 탈퇴해 주십시오";
161
+ if (error.code === "ACCOUNT_LOCKED")
162
+ return "비밀번호를 여러 번 틀려 잠시 잠겼습니다. 10분 뒤에 다시 시도해 주십시오";
163
+ return "탈퇴하지 못했습니다. 잠시 후 다시 시도해 주십시오";
164
+ }
165
+
107
166
  const ERROR_MESSAGE: Record<string, string> = {
108
167
  IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼 있습니다",
109
168
  PROVIDER_ALREADY_LINKED:
@@ -238,7 +297,11 @@ function Account() {
238
297
  })}
239
298
  </ul>
240
299
  </form>
241
- {error ? <p className="text-point text-sm">{error}</p> : null}
300
+ {error ? (
301
+ <p role="alert" className="text-point text-sm">
302
+ {error}
303
+ </p>
304
+ ) : null}
242
305
  <p className="text-muted text-xs">
243
306
  연결한 계정으로도 같은 회원으로 로그인합니다. 마지막 로그인 수단은 해제할 수 없습니다.
244
307
  </p>
@@ -308,6 +371,104 @@ function Account() {
308
371
  </li>
309
372
  </ul>
310
373
  </section>
374
+
375
+ <section className="space-y-3">
376
+ <h2 className="font-bold text-lg">계정</h2>
377
+ <button
378
+ type="button"
379
+ disabled={pending}
380
+ onClick={() => {
381
+ setPending(true);
382
+ // 로그아웃 상태가 모든 화면에 새로 반영되도록 문서째 이동한다
383
+ void logout().finally(() => window.location.assign("/"));
384
+ }}
385
+ className="h-10 rounded-md border border-line px-4 text-sm disabled:opacity-50"
386
+ >
387
+ 로그아웃
388
+ </button>
389
+ <WithdrawForm hasPassword={identities.hasPassword} />
390
+ </section>
311
391
  </div>
312
392
  );
313
393
  }
394
+
395
+ /** 회원 탈퇴 — 확인을 한 번 더 받는다. 주문·리뷰·문의 기록은 스토어가 보관한다 */
396
+ function WithdrawForm({ hasPassword }: { hasPassword: boolean }) {
397
+ const [open, setOpen] = useState(false);
398
+ const [pending, setPending] = useState(false);
399
+ const [error, setError] = useState<string | null>(null);
400
+
401
+ if (!open) {
402
+ return (
403
+ <button
404
+ type="button"
405
+ onClick={() => setOpen(true)}
406
+ className="block text-muted text-sm underline"
407
+ >
408
+ 회원 탈퇴
409
+ </button>
410
+ );
411
+ }
412
+ return (
413
+ <form
414
+ aria-label="회원 탈퇴"
415
+ className="space-y-3 rounded-md bg-chip p-4 text-sm"
416
+ onSubmit={(event) => {
417
+ event.preventDefault();
418
+ const form = new FormData(event.currentTarget);
419
+ setPending(true);
420
+ setError(null);
421
+ void withdraw({ data: { password: String(form.get("password") ?? "") } })
422
+ .then((result) => {
423
+ if (!result.error) {
424
+ window.location.assign("/");
425
+ return;
426
+ }
427
+ setError(result.error);
428
+ setPending(false);
429
+ })
430
+ .catch(() => {
431
+ setError("탈퇴하지 못했습니다. 잠시 후 다시 시도해 주십시오");
432
+ setPending(false);
433
+ });
434
+ }}
435
+ >
436
+ <p>
437
+ 탈퇴하면 계정·배송지·장바구니가 지워지고 되돌릴 수 없습니다. 주문·리뷰·문의 기록은 스토어가
438
+ 보관합니다.
439
+ </p>
440
+ {hasPassword ? (
441
+ <label className="block space-y-1">
442
+ <span>비밀번호 확인</span>
443
+ <input
444
+ type="password"
445
+ name="password"
446
+ autoComplete="current-password"
447
+ required
448
+ className="h-10 w-full rounded-md border border-line bg-white px-3"
449
+ />
450
+ </label>
451
+ ) : (
452
+ <p className="text-muted text-xs">
453
+ 소셜 로그인 회원은 로그인한 지 10분 안에만 탈퇴할 수 있습니다.
454
+ </p>
455
+ )}
456
+ {error ? (
457
+ <p role="alert" className="text-point">
458
+ {error}
459
+ </p>
460
+ ) : null}
461
+ <div className="flex items-center gap-3">
462
+ <SubmitButton
463
+ disabled={pending}
464
+ className="h-10 rounded-md bg-ink px-4 font-semibold text-white disabled:opacity-50"
465
+ >
466
+ 탈퇴하기
467
+ </SubmitButton>
468
+ <button type="button" onClick={() => setOpen(false)} className="text-muted underline">
469
+ 취소
470
+ </button>
471
+ </div>
472
+ </form>
473
+ );
474
+ }
@@ -92,6 +92,7 @@ function CartPage() {
92
92
  <li key={item.cartItemId} className="flex gap-4 py-4">
93
93
  <ProductThumb
94
94
  src={item.thumbnailUrl}
95
+ alt={item.productName}
95
96
  className="size-20 rounded-md bg-chip object-cover"
96
97
  />
97
98
  <div className="flex flex-1 flex-col gap-1">
@@ -126,6 +127,7 @@ function CartPage() {
126
127
  <input
127
128
  type="number"
128
129
  name="quantity"
130
+ aria-label={`${item.productName} 수량`}
129
131
  min={1}
130
132
  max={999}
131
133
  defaultValue={item.quantity}
@@ -141,7 +143,11 @@ function CartPage() {
141
143
  change({ intent: "remove", cartItemId: item.cartItemId });
142
144
  }}
143
145
  >
144
- <SubmitButton disabled={pending} className="text-muted text-xs underline">
146
+ <SubmitButton
147
+ disabled={pending}
148
+ ariaLabel={`${item.productName} 삭제`}
149
+ className="text-muted text-xs underline"
150
+ >
145
151
  삭제
146
152
  </SubmitButton>
147
153
  </form>
@@ -10,13 +10,13 @@ const completeSearch = z.object({ orderId: z.string().optional().catch(undefined
10
10
  const getCompletedOrder = createServerFn({ method: "GET" })
11
11
  .validator(completeSearch)
12
12
  .handler(async ({ data }) => {
13
- if (!data.orderId) return null;
14
- // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
13
+ // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 주고 비회원 주문 조회로 보낸다
15
14
  const accessToken = readToken();
16
- if (!accessToken) return null;
17
- return apiFor({ accessToken })
15
+ if (!data.orderId || !accessToken) return { order: null, member: Boolean(accessToken) };
16
+ const order = await apiFor({ accessToken })
18
17
  .myOrders.get(data.orderId)
19
18
  .catch(() => null);
19
+ return { order, member: true };
20
20
  });
21
21
 
22
22
  export const Route = createFileRoute("/checkout/complete")({
@@ -28,7 +28,7 @@ export const Route = createFileRoute("/checkout/complete")({
28
28
  });
29
29
 
30
30
  function CheckoutComplete() {
31
- const order = Route.useLoaderData();
31
+ const { order, member } = Route.useLoaderData();
32
32
  const { orderId } = Route.useSearch();
33
33
 
34
34
  return (
@@ -38,7 +38,7 @@ function CheckoutComplete() {
38
38
  {orderId ? (
39
39
  <p className="text-muted text-sm">주문번호 {orderId}</p>
40
40
  ) : (
41
- <p className="text-muted text-sm">주문 내역에서 주문을 확인해주십시오</p>
41
+ <p className="text-muted text-sm">주문 내역에서 주문을 확인해 주십시오</p>
42
42
  )}
43
43
  </div>
44
44
 
@@ -61,9 +61,20 @@ function CheckoutComplete() {
61
61
  ) : null}
62
62
 
63
63
  <div className="flex justify-center gap-2">
64
- <Link to="/orders" className="rounded-md border border-ink px-5 py-3">
65
- 주문 내역
66
- </Link>
64
+ {/* 비회원 주문은 주문번호·연락처·주문 조회 비밀번호로 찾는다 */}
65
+ {member || !orderId ? (
66
+ <Link to="/orders" className="rounded-md border border-ink px-5 py-3">
67
+ 주문 내역
68
+ </Link>
69
+ ) : (
70
+ <Link
71
+ to="/guest-order"
72
+ search={{ orderId }}
73
+ className="rounded-md border border-ink px-5 py-3"
74
+ >
75
+ 비회원 주문 조회
76
+ </Link>
77
+ )}
67
78
  <Link to="/products" className="rounded-md bg-ink px-5 py-3 text-white">
68
79
  계속 쇼핑하기
69
80
  </Link>
@@ -12,7 +12,7 @@ import type { PaymentResult } from "@sayren/storefront-sdk/payments";
12
12
  import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
13
13
  import { createServerFn } from "@tanstack/react-start";
14
14
  import { getRequestUrl } from "@tanstack/react-start/server";
15
- import { useState } from "react";
15
+ import { useId, useState } from "react";
16
16
  import { z } from "zod";
17
17
  import { SubmitButton } from "../components/submit-button";
18
18
  import { apiFor } from "../lib/api.server";
@@ -21,6 +21,7 @@ import { checkoutIntentFrom, checkoutSearch } from "../lib/checkout-intent";
21
21
  import type { PublicConfig } from "../lib/config";
22
22
  import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
23
23
  import { formatPrice } from "../lib/format";
24
+ import { forgetPaymentOptions, rememberPaymentOptions } from "../lib/payment-options";
24
25
  import { PAYMENT_RETURN_PATH, paymentsFor } from "../lib/payments";
25
26
  import { readToken } from "../lib/session.server";
26
27
 
@@ -66,7 +67,7 @@ const quoteDelivery = createServerFn({ method: "POST" })
66
67
  } catch {
67
68
  return {
68
69
  quote: null,
69
- error: "배송비를 확인하지 못했습니다. 우편번호를 다시 확인해주십시오.",
70
+ error: "배송비를 확인하지 못했습니다. 우편번호를 다시 확인해 주십시오.",
70
71
  };
71
72
  }
72
73
  });
@@ -95,7 +96,7 @@ const startPayment = createServerFn({ method: "POST" })
95
96
  .handler(async ({ data }) => {
96
97
  const accessToken = readToken();
97
98
  const api = apiFor({ accessToken, cartToken: readCartToken() });
98
- // 복귀 주소 — 이 도메인이 셀러 콘솔 결제 도메인에 있어야 한다(테스트 결제는 localhost 허용)
99
+ // 복귀 주소 — 결제 도메인을 등록한 스토어는 이 도메인이 목록 안이어야 한다(테스트 결제는 localhost 허용)
99
100
  const returnUrl = new URL(PAYMENT_RETURN_PATH, new URL(getRequestUrl()).origin);
100
101
  returnUrl.searchParams.set("back", data.back);
101
102
  try {
@@ -129,20 +130,20 @@ const startPayment = createServerFn({ method: "POST" })
129
130
 
130
131
  function startErrorMessage(error: unknown): string {
131
132
  if (!(error instanceof ApiError))
132
- return "결제를 시작하지 못했습니다. 잠시 후 다시 시도해주십시오.";
133
+ return "결제를 시작하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
133
134
  switch (error.code) {
134
135
  case "PAYMENT_OPTION_UNAVAILABLE":
135
- return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을 골라주십시오.";
136
+ return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을 골라 주십시오.";
136
137
  case "PAYMENT_PROVIDER_UNAVAILABLE":
137
- return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해주십시오.";
138
+ return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해 주십시오.";
138
139
  case "PAYMENT_NOT_CONFIGURED":
139
- return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해주십시오.";
140
+ return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해 주십시오.";
140
141
  case "CASH_RECEIPT_NOT_AVAILABLE":
141
142
  return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있습니다.";
142
143
  case "RETURN_URL_NOT_ALLOWED":
143
- return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주십시오.";
144
+ return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해 주십시오.";
144
145
  default:
145
- return "결제를 시작하지 못했습니다. 입력한 내용을 확인하고 다시 시도해주십시오.";
146
+ return "결제를 시작하지 못했습니다. 입력한 내용을 확인하고 다시 시도해 주십시오.";
146
147
  }
147
148
  }
148
149
 
@@ -196,6 +197,7 @@ function Checkout() {
196
197
 
197
198
  const settle = (result: PaymentResult, back: string) => {
198
199
  if (result.status === "COMPLETED") {
200
+ forgetPaymentOptions(result.paymentId);
199
201
  void navigate({
200
202
  to: "/checkout/complete",
201
203
  search: { orderId: result.orderId },
@@ -214,10 +216,10 @@ function Checkout() {
214
216
  }
215
217
  setNotice(
216
218
  result.status === "CANCELED"
217
- ? "결제를 취소했습니다. 다른 결제수단으로 다시 시도해주십시오."
219
+ ? "결제를 취소했습니다. 다른 결제수단으로 다시 시도해 주십시오."
218
220
  : result.status === "FAILED"
219
221
  ? result.message
220
- : "결제 결과를 확인하지 못했습니다. 주문 내역을 확인해주십시오.",
222
+ : "결제 결과를 확인하지 못했습니다. 주문 내역을 확인해 주십시오.",
221
223
  );
222
224
  };
223
225
 
@@ -225,7 +227,7 @@ function Checkout() {
225
227
  event.preventDefault();
226
228
  const option = options.find((item) => paymentOptionKey(item) === selected);
227
229
  if (!option) {
228
- setNotice("결제수단을 골라주십시오.");
230
+ setNotice("결제수단을 골라 주십시오.");
229
231
  return;
230
232
  }
231
233
  const form = new FormData(event.currentTarget);
@@ -237,7 +239,7 @@ function Checkout() {
237
239
  identityNumber: field("cashReceiptNumber"),
238
240
  });
239
241
  if (!parsed.success) {
240
- setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해주십시오.");
242
+ setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해 주십시오.");
241
243
  return;
242
244
  }
243
245
  cashReceipt = parsed.data;
@@ -274,12 +276,14 @@ function Checkout() {
274
276
  const paymentStart = paymentStartSchema.parse(JSON.parse(start));
275
277
  // 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다. 화면에 보여 준 금액과 다를 수 있다
276
278
  setQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });
279
+ // 리다이렉트 결제가 실패해 복귀 화면으로 오면 같은 결제를 다른 옵션으로 다시 시도한다 — 그때 쓸 목록
280
+ rememberPaymentOptions(paymentStart.paymentId, options);
277
281
  // 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
278
282
  settle(await payments.open(paymentStart, { window: paymentWindow }), back);
279
283
  })
280
284
  .catch(() => {
281
285
  paymentWindow.close();
282
- setNotice("결제를 시작하지 못했습니다. 잠시 후 다시 시도해주십시오.");
286
+ setNotice("결제를 시작하지 못했습니다. 잠시 후 다시 시도해 주십시오.");
283
287
  })
284
288
  .finally(() => setSubmitting(false));
285
289
  };
@@ -445,7 +449,11 @@ function Checkout() {
445
449
  테스트 결제입니다. 실제로 돈이 오가지 않습니다.
446
450
  </p>
447
451
  ) : null}
448
- {notice ? <p className="rounded-md bg-chip px-3 py-2 text-sm">{notice}</p> : null}
452
+ {notice ? (
453
+ <p role="status" className="rounded-md bg-chip px-3 py-2 text-sm">
454
+ {notice}
455
+ </p>
456
+ ) : null}
449
457
  </aside>
450
458
  </div>
451
459
  );
@@ -481,11 +489,18 @@ function Field({
481
489
  hint?: string;
482
490
  onBlur?: (value: string) => void;
483
491
  }) {
492
+ const hintId = useId();
484
493
  return (
485
494
  <label className="block space-y-1">
486
495
  <span className="text-sm">
487
496
  {label}
488
- {required ? <span className="text-point"> *</span> : null}
497
+ {/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
498
+ {required ? (
499
+ <span aria-hidden="true" className="text-point">
500
+ {" "}
501
+ *
502
+ </span>
503
+ ) : null}
489
504
  </span>
490
505
  {/* 프리필은 defaultValue로 준다 — SSR HTML에 값이 실린다 */}
491
506
  <input
@@ -494,10 +509,17 @@ function Field({
494
509
  defaultValue={defaultValue}
495
510
  placeholder={placeholder}
496
511
  required={required}
512
+ // 이름은 라벨 글자 그대로다(필수 표시·안내 문구는 빼고) — 안내는 aria-describedby로 잇는다
513
+ aria-label={label}
514
+ aria-describedby={hint ? hintId : undefined}
497
515
  onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined}
498
516
  className="h-11 w-full rounded-md border border-line px-3"
499
517
  />
500
- {hint ? <span className="text-muted text-xs">{hint}</span> : null}
518
+ {hint ? (
519
+ <span id={hintId} className="block text-muted text-xs">
520
+ {hint}
521
+ </span>
522
+ ) : null}
501
523
  </label>
502
524
  );
503
525
  }