@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.
- package/dist/index.mjs +33 -3
- package/package.json +3 -3
- package/template/.template-meta.json +3 -2
- package/template/README.md +87 -55
- 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/guest-order.test.ts +18 -0
- package/template/src/lib/guest-order.ts +13 -0
- package/template/src/lib/order-status.ts +17 -0
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- package/template/src/lib/payment-options.test.ts +61 -0
- package/template/src/lib/payment-options.ts +39 -0
- package/template/src/routeTree.gen.ts +42 -0
- package/template/src/routes/__root.tsx +40 -6
- package/template/src/routes/account.tsx +166 -4
- package/template/src/routes/cart.tsx +9 -2
- package/template/src/routes/checkout.complete.tsx +22 -10
- package/template/src/routes/checkout.index.tsx +40 -17
- package/template/src/routes/checkout.return.tsx +132 -20
- package/template/src/routes/guest-order.tsx +183 -0
- package/template/src/routes/index.tsx +2 -3
- package/template/src/routes/login.tsx +23 -3
- package/template/src/routes/orders.$orderId.tsx +13 -8
- package/template/src/routes/orders.index.tsx +2 -1
- package/template/src/routes/products.$productId.tsx +12 -5
- package/template/src/routes/products.index.tsx +6 -1
- 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
|
-
{
|
|
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 () =>
|
|
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 {
|
|
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 {
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
14
|
-
// 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
|
|
14
|
+
// 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 주고 비회원 주문 조회로 보낸다
|
|
15
15
|
const accessToken = readToken();
|
|
16
|
-
if (!accessToken) return null;
|
|
17
|
-
|
|
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">주문 내역에서 주문을
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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>
|