@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.
- package/dist/index.mjs +38 -8
- package/package.json +3 -3
- package/template/.template-meta.json +3 -2
- package/template/README.md +84 -54
- package/template/package.json +2 -2
- package/template/public/favicon.svg +1 -0
- package/template/src/components/product-card.tsx +2 -0
- package/template/src/components/site-header.tsx +16 -6
- package/template/src/components/submit-button.tsx +4 -0
- package/template/src/lib/config.server.ts +1 -1
- package/template/src/lib/order-status.ts +17 -0
- package/template/src/lib/payment-options.test.ts +61 -0
- package/template/src/lib/payment-options.ts +39 -0
- package/template/src/lib/payments.ts +2 -1
- package/template/src/routeTree.gen.ts +42 -0
- package/template/src/routes/__root.tsx +38 -6
- package/template/src/routes/account.tsx +164 -3
- package/template/src/routes/cart.tsx +7 -1
- package/template/src/routes/checkout.complete.tsx +20 -9
- package/template/src/routes/checkout.index.tsx +39 -17
- package/template/src/routes/checkout.return.tsx +125 -19
- package/template/src/routes/guest-order.tsx +171 -0
- package/template/src/routes/index.tsx +2 -3
- package/template/src/routes/login.tsx +21 -2
- package/template/src/routes/orders.$orderId.tsx +9 -7
- package/template/src/routes/products.$productId.tsx +8 -4
- package/template/src/routes/products.index.tsx +4 -0
- package/template/src/routes/signup.tsx +234 -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,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
|
-
|
|
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 () =>
|
|
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 {
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
14
|
-
// 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
|
|
13
|
+
// 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 주고 비회원 주문 조회로 보낸다
|
|
15
14
|
const accessToken = readToken();
|
|
16
|
-
if (!accessToken) return null;
|
|
17
|
-
|
|
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">주문 내역에서 주문을
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
// 복귀 주소 —
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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
|
}
|