@sayren/mcp 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +22 -19
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +29 -12
- package/template/_gitignore +4 -2
- package/template/package.json +8 -10
- package/template/{app → src}/components/product-card.tsx +6 -2
- package/template/{app → src}/components/product-description.tsx +1 -1
- package/template/{app → src}/components/site-header.tsx +4 -1
- package/template/src/components/submit-button.tsx +32 -0
- package/template/{app → src}/lib/analytics.ts +1 -1
- package/template/{app → src}/lib/api.server.ts +6 -5
- package/template/{app → src}/lib/api.ts +1 -1
- package/template/src/lib/cart-session.server.ts +28 -0
- package/template/src/lib/checkout-intent.test.ts +27 -0
- package/template/{app → src}/lib/checkout-intent.ts +20 -8
- package/template/{app → src}/lib/config.server.ts +5 -4
- package/template/{app → src}/lib/config.ts +1 -1
- package/template/src/lib/format.test.ts +14 -0
- package/template/src/lib/format.ts +30 -0
- package/template/{app → src}/lib/safe-redirect.ts +1 -1
- package/template/src/lib/session.server.ts +121 -0
- package/template/src/lib/social-login.server.ts +54 -0
- package/template/src/routeTree.gen.ts +283 -0
- package/template/src/router.tsx +17 -0
- package/template/src/routes/__root.tsx +90 -0
- package/template/src/routes/account.tsx +213 -0
- package/template/src/routes/auth.callback.ts +70 -0
- package/template/{app → src}/routes/cart.tsx +94 -78
- package/template/{app/routes/checkout-complete.tsx → src/routes/checkout.complete.tsx} +25 -18
- package/template/{app/routes/checkout.tsx → src/routes/checkout.index.tsx} +134 -94
- package/template/{app/routes/home.tsx → src/routes/index.tsx} +19 -13
- package/template/src/routes/login.tsx +198 -0
- package/template/{app/routes/order-detail.tsx → src/routes/orders.$orderId.tsx} +25 -13
- package/template/{app/routes/orders.tsx → src/routes/orders.index.tsx} +16 -14
- package/template/{app/routes/product-detail.tsx → src/routes/products.$productId.tsx} +77 -55
- package/template/{app/routes/products.tsx → src/routes/products.index.tsx} +62 -38
- package/template/src/start.ts +18 -0
- package/template/tsconfig.json +3 -4
- package/template/vite.config.ts +3 -2
- package/template/vitest.config.ts +1 -1
- package/template/app/lib/cart-session.server.ts +0 -18
- package/template/app/lib/format.ts +0 -10
- package/template/app/lib/session.server.ts +0 -99
- package/template/app/lib/social-login.server.ts +0 -31
- package/template/app/root.tsx +0 -78
- package/template/app/routes/auth-callback.tsx +0 -44
- package/template/app/routes/login.tsx +0 -153
- package/template/app/routes.ts +0 -18
- package/template/react-router.config.ts +0 -6
- /package/template/{app → src}/lib/config.test.ts +0 -0
- /package/template/{app → src}/lib/payment-popup.ts +0 -0
- /package/template/{app → src}/lib/safe-redirect.test.ts +0 -0
- /package/template/{app → src}/lib/social-buttons.ts +0 -0
- /package/template/{app/app.css → src/styles.css} +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
|
|
2
|
+
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { SubmitButton } from "../components/submit-button";
|
|
8
|
+
import { apiFor, authFor } from "../lib/api.server";
|
|
9
|
+
import { formatDateTime } from "../lib/format";
|
|
10
|
+
import { readToken } from "../lib/session.server";
|
|
11
|
+
import { SOCIAL_BUTTONS } from "../lib/social-buttons";
|
|
12
|
+
import { writeSocialLogin } from "../lib/social-login.server";
|
|
13
|
+
|
|
14
|
+
const PROVIDER_NAME: Record<SocialProvider, string> = {
|
|
15
|
+
kakao: "카카오",
|
|
16
|
+
naver: "네이버",
|
|
17
|
+
google: "구글",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const accountSearch = z.object({
|
|
21
|
+
linked: z.enum(["kakao", "naver", "google"]).optional().catch(undefined),
|
|
22
|
+
error: z.string().optional().catch(undefined),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** 내 정보와 로그인 수단 — 로그인하지 않았으면 로그인 화면으로 보낸다 */
|
|
26
|
+
const getAccount = createServerFn({ method: "GET" }).handler(async () => {
|
|
27
|
+
const accessToken = readToken();
|
|
28
|
+
if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/account" } });
|
|
29
|
+
const [me, identities, store] = await Promise.all([
|
|
30
|
+
apiFor({ accessToken }).member.me(),
|
|
31
|
+
authFor().identities(accessToken),
|
|
32
|
+
apiFor().store.get(),
|
|
33
|
+
]);
|
|
34
|
+
// 셀러가 켠 소셜 로그인만 연결할 수 있다
|
|
35
|
+
const providers = store.loginMethods.filter(
|
|
36
|
+
(method): method is SocialProvider => method !== "password",
|
|
37
|
+
);
|
|
38
|
+
return { me, identities, providers };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const providerInput = z.object({ provider: z.enum(["kakao", "naver", "google"]) });
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 소셜 계정 연결 시작 — 로그인과 같은 콜백(`/auth/callback`)으로 돌아오고, 쿠키의 `linkProvider`로 연결임을 안다.
|
|
45
|
+
* 연결은 흐름을 시작한 이 구매자에게만 된다(서버가 확인한다).
|
|
46
|
+
*/
|
|
47
|
+
const startLink = createServerFn({ method: "POST" })
|
|
48
|
+
.validator(providerInput)
|
|
49
|
+
.handler(async ({ data }) => {
|
|
50
|
+
const accessToken = readToken();
|
|
51
|
+
if (!accessToken) return { url: null, error: "다시 로그인해 주세요" };
|
|
52
|
+
const origin = new URL(getRequestUrl()).origin;
|
|
53
|
+
try {
|
|
54
|
+
const started = await authFor().linkIdp(accessToken, data.provider, {
|
|
55
|
+
redirectUri: `${origin}/auth/callback`,
|
|
56
|
+
});
|
|
57
|
+
writeSocialLogin({
|
|
58
|
+
state: started.state,
|
|
59
|
+
codeVerifier: started.codeVerifier,
|
|
60
|
+
redirectTo: "/account",
|
|
61
|
+
linkProvider: data.provider,
|
|
62
|
+
});
|
|
63
|
+
return { url: started.url, error: null };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { url: null, error: accountErrorMessage(error) };
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const unlink = createServerFn({ method: "POST" })
|
|
70
|
+
.validator(providerInput)
|
|
71
|
+
.handler(async ({ data }) => {
|
|
72
|
+
const accessToken = readToken();
|
|
73
|
+
if (!accessToken) return { error: "다시 로그인해 주세요" };
|
|
74
|
+
try {
|
|
75
|
+
await authFor().unlinkIdp(accessToken, data.provider);
|
|
76
|
+
return { error: null };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { error: accountErrorMessage(error) };
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const ERROR_MESSAGE: Record<string, string> = {
|
|
83
|
+
IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼 있어요",
|
|
84
|
+
PROVIDER_ALREADY_LINKED:
|
|
85
|
+
"같은 서비스의 다른 계정이 이미 연결돼 있어요. 해제한 뒤 다시 연결해 주세요",
|
|
86
|
+
LAST_LOGIN_METHOD: "마지막 로그인 수단은 해제할 수 없어요. 다른 계정을 먼저 연결해 주세요",
|
|
87
|
+
access_denied: "연결을 취소했어요",
|
|
88
|
+
state_mismatch: "연결 요청이 만료됐어요. 다시 시도해 주세요",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function accountErrorMessage(error: unknown): string {
|
|
92
|
+
if (error instanceof ApiError)
|
|
93
|
+
return ERROR_MESSAGE[error.code] ?? "처리하지 못했어요. 잠시 후 다시 시도해 주세요";
|
|
94
|
+
return "처리하지 못했어요. 잠시 후 다시 시도해 주세요";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const Route = createFileRoute("/account")({
|
|
98
|
+
validateSearch: accountSearch,
|
|
99
|
+
loader: () => getAccount(),
|
|
100
|
+
head: () => ({ meta: [{ title: "내 정보" }] }),
|
|
101
|
+
component: Account,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function Account() {
|
|
105
|
+
const { me, identities, providers } = Route.useLoaderData();
|
|
106
|
+
const { linked, error: callbackError } = Route.useSearch();
|
|
107
|
+
const router = useRouter();
|
|
108
|
+
const [pending, setPending] = useState(false);
|
|
109
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
110
|
+
const error =
|
|
111
|
+
actionError ??
|
|
112
|
+
(callbackError
|
|
113
|
+
? (ERROR_MESSAGE[callbackError] ?? "연결하지 못했어요. 다시 시도해 주세요")
|
|
114
|
+
: null);
|
|
115
|
+
|
|
116
|
+
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
|
|
119
|
+
const [action, provider] = (submitter?.value ?? "").split(":") as [string, SocialProvider];
|
|
120
|
+
if (!provider) return;
|
|
121
|
+
setPending(true);
|
|
122
|
+
setActionError(null);
|
|
123
|
+
if (action === "link") {
|
|
124
|
+
void startLink({ data: { provider } }).then((result) => {
|
|
125
|
+
// 공급자 화면은 다른 사이트라 문서째 이동한다
|
|
126
|
+
if (result.url) window.location.assign(result.url);
|
|
127
|
+
else {
|
|
128
|
+
setActionError(result.error);
|
|
129
|
+
setPending(false);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
void unlink({ data: { provider } }).then(async (result) => {
|
|
135
|
+
if (result.error) setActionError(result.error);
|
|
136
|
+
await router.invalidate();
|
|
137
|
+
setPending(false);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="mx-auto max-w-lg space-y-8 py-10">
|
|
143
|
+
<div className="space-y-1">
|
|
144
|
+
<h1 className="font-bold text-2xl">내 정보</h1>
|
|
145
|
+
<p className="text-muted text-sm">
|
|
146
|
+
{me.name}
|
|
147
|
+
{me.email ? ` · ${me.email}` : ""}
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<section className="space-y-3">
|
|
152
|
+
<h2 className="font-bold text-lg">로그인 수단</h2>
|
|
153
|
+
{linked ? (
|
|
154
|
+
<p className="rounded-md bg-chip px-3 py-2 text-sm">
|
|
155
|
+
{PROVIDER_NAME[linked]} 계정을 연결했어요
|
|
156
|
+
</p>
|
|
157
|
+
) : null}
|
|
158
|
+
<form onSubmit={submit}>
|
|
159
|
+
<ul className="divide-y divide-line rounded-lg border border-line">
|
|
160
|
+
<li className="flex items-center justify-between gap-3 px-4 py-3">
|
|
161
|
+
<span className="font-medium">이메일·비밀번호</span>
|
|
162
|
+
<span className="text-muted text-sm">
|
|
163
|
+
{identities.hasPassword ? "사용 중" : "없음"}
|
|
164
|
+
</span>
|
|
165
|
+
</li>
|
|
166
|
+
{identities.hasExternalAuth ? (
|
|
167
|
+
<li className="flex items-center justify-between gap-3 px-4 py-3">
|
|
168
|
+
<span className="font-medium">스토어 회원 계정</span>
|
|
169
|
+
<span className="text-muted text-sm">사용 중</span>
|
|
170
|
+
</li>
|
|
171
|
+
) : null}
|
|
172
|
+
{providers.map((provider) => {
|
|
173
|
+
const identity = identities.identities.find((item) => item.provider === provider);
|
|
174
|
+
return (
|
|
175
|
+
<li key={provider} className="flex items-center justify-between gap-3 px-4 py-3">
|
|
176
|
+
<span className="flex flex-col gap-0.5">
|
|
177
|
+
<span className="font-medium">{PROVIDER_NAME[provider]}</span>
|
|
178
|
+
<span className="text-muted text-xs">
|
|
179
|
+
{identity
|
|
180
|
+
? `${identity.email ?? "이메일 비공개"} · ${formatDateTime(identity.linkedAt)} 연결`
|
|
181
|
+
: "연결 안 됨"}
|
|
182
|
+
</span>
|
|
183
|
+
</span>
|
|
184
|
+
{identity ? (
|
|
185
|
+
<SubmitButton
|
|
186
|
+
value={`unlink:${provider}`}
|
|
187
|
+
disabled={pending}
|
|
188
|
+
className="h-9 rounded-md border border-line px-3 text-sm disabled:opacity-50"
|
|
189
|
+
>
|
|
190
|
+
연결 해제
|
|
191
|
+
</SubmitButton>
|
|
192
|
+
) : (
|
|
193
|
+
<SubmitButton
|
|
194
|
+
value={`link:${provider}`}
|
|
195
|
+
disabled={pending}
|
|
196
|
+
className={`h-9 rounded-md px-3 font-semibold text-sm disabled:opacity-50 ${SOCIAL_BUTTONS[provider].className}`}
|
|
197
|
+
>
|
|
198
|
+
연결
|
|
199
|
+
</SubmitButton>
|
|
200
|
+
)}
|
|
201
|
+
</li>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
</ul>
|
|
205
|
+
</form>
|
|
206
|
+
{error ? <p className="text-point text-sm">{error}</p> : null}
|
|
207
|
+
<p className="text-muted text-xs">
|
|
208
|
+
연결한 계정으로도 같은 회원으로 로그인해요. 마지막 로그인 수단은 해제할 수 없어요.
|
|
209
|
+
</p>
|
|
210
|
+
</section>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
+
import { IdpCallbackError } from "@sayren/storefront-sdk/auth";
|
|
3
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
4
|
+
import { authFor } from "../lib/api.server";
|
|
5
|
+
import { clearCartToken, readCartToken } from "../lib/cart-session.server";
|
|
6
|
+
import { readToken, signIn } from "../lib/session.server";
|
|
7
|
+
import { clearSocialLogin, readSocialLogin } from "../lib/social-login.server";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 소셜 로그인에서 돌아오는 곳 — 쿠키에 둔 state·verifier로 1회용 code를 구매자 토큰으로 바꾸고 세션 쿠키를 심는다.
|
|
11
|
+
* 비회원 장바구니가 있으면 합친다. 실패하면 로그인 화면으로 이유를 들고 돌아간다.
|
|
12
|
+
*
|
|
13
|
+
* 화면이 없는 서버 라우트다. 공급자가 문서째 보내 오므로 응답은 리다이렉트 하나다.
|
|
14
|
+
*/
|
|
15
|
+
export const Route = createFileRoute("/auth/callback")({
|
|
16
|
+
server: {
|
|
17
|
+
handlers: {
|
|
18
|
+
GET: async ({ request }) => {
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const stored = readSocialLogin();
|
|
21
|
+
clearSocialLogin();
|
|
22
|
+
const go = (location: string) =>
|
|
23
|
+
new Response(null, { status: 302, headers: { Location: location } });
|
|
24
|
+
const fail = (reason: string) => go(`/login?error=${encodeURIComponent(reason)}`);
|
|
25
|
+
if (!stored) return fail("state_mismatch");
|
|
26
|
+
|
|
27
|
+
// 계정 연결 — 로그인한 구매자에 공급자 계정을 붙이고 계정 화면으로 돌아간다
|
|
28
|
+
if (stored.linkProvider) {
|
|
29
|
+
const accessToken = readToken();
|
|
30
|
+
if (!accessToken) return go("/login?redirectTo=%2Faccount");
|
|
31
|
+
try {
|
|
32
|
+
await authFor().linkIdpCallback(accessToken, stored.linkProvider, {
|
|
33
|
+
code: url.searchParams.get("code"),
|
|
34
|
+
state: url.searchParams.get("state"),
|
|
35
|
+
error: url.searchParams.get("error"),
|
|
36
|
+
expectedState: stored.state,
|
|
37
|
+
codeVerifier: stored.codeVerifier,
|
|
38
|
+
});
|
|
39
|
+
return go(`/account?linked=${stored.linkProvider}`);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof IdpCallbackError)
|
|
42
|
+
return go(`/account?error=${encodeURIComponent(error.reason)}`);
|
|
43
|
+
if (error instanceof ApiError)
|
|
44
|
+
return go(`/account?error=${encodeURIComponent(error.code)}`);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cartToken = readCartToken();
|
|
50
|
+
try {
|
|
51
|
+
const tokens = await authFor().idpCallback({
|
|
52
|
+
code: url.searchParams.get("code"),
|
|
53
|
+
state: url.searchParams.get("state"),
|
|
54
|
+
error: url.searchParams.get("error"),
|
|
55
|
+
expectedState: stored.state,
|
|
56
|
+
codeVerifier: stored.codeVerifier,
|
|
57
|
+
cartToken: cartToken ?? undefined,
|
|
58
|
+
});
|
|
59
|
+
signIn(tokens);
|
|
60
|
+
if (cartToken) clearCartToken();
|
|
61
|
+
return go(stored.redirectTo);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof IdpCallbackError) return fail(error.reason);
|
|
64
|
+
if (error instanceof ApiError) return fail("provider_error");
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { SubmitButton } from "../components/submit-button";
|
|
2
6
|
import { apiFor } from "../lib/api.server";
|
|
3
|
-
import {
|
|
7
|
+
import { readCartToken, writeCartToken } from "../lib/cart-session.server";
|
|
4
8
|
import { formatPrice } from "../lib/format";
|
|
5
9
|
import { readToken } from "../lib/session.server";
|
|
6
|
-
import type { Route } from "./+types/cart";
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export async function loader({ request, context }: Route.LoaderArgs) {
|
|
13
|
-
const accessToken = readToken(context);
|
|
14
|
-
const cartToken = await readCartToken(request);
|
|
11
|
+
const getCart = createServerFn({ method: "GET" }).handler(async () => {
|
|
12
|
+
const accessToken = readToken();
|
|
13
|
+
const cartToken = readCartToken();
|
|
15
14
|
// 비회원이 아직 장바구니를 만든 적이 없으면 서버에 물어볼 것이 없다
|
|
16
15
|
if (!accessToken && !cartToken) {
|
|
17
16
|
return {
|
|
@@ -19,42 +18,56 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|
|
19
18
|
loginRequired: false,
|
|
20
19
|
};
|
|
21
20
|
}
|
|
22
|
-
const api = apiFor(
|
|
21
|
+
const api = apiFor({ accessToken, cartToken });
|
|
23
22
|
// 회원만 주문받는 스토어면 비회원에게 주문 버튼 대신 로그인 안내를 보인다(담기는 그대로 된다)
|
|
24
23
|
const [cart, store] = await Promise.all([api.cart.get(), accessToken ? null : api.store.get()]);
|
|
25
24
|
return { cart, loginRequired: store ? !store.guestCheckout : false };
|
|
26
|
-
}
|
|
25
|
+
});
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
27
|
+
const changeCart = createServerFn({ method: "POST" })
|
|
28
|
+
.validator(
|
|
29
|
+
z.discriminatedUnion("intent", [
|
|
30
|
+
z.object({ intent: z.literal("remove"), cartItemId: z.string() }),
|
|
31
|
+
z.object({
|
|
32
|
+
intent: z.literal("update"),
|
|
33
|
+
cartItemId: z.string(),
|
|
34
|
+
quantity: z.number().int().min(1).max(999),
|
|
35
|
+
}),
|
|
36
|
+
]),
|
|
37
|
+
)
|
|
38
|
+
.handler(async ({ data }) => {
|
|
39
|
+
const cartToken = readCartToken();
|
|
40
|
+
let issued: string | null = null;
|
|
41
|
+
const api = apiFor({
|
|
42
|
+
accessToken: readToken(),
|
|
43
|
+
cartToken,
|
|
44
|
+
onCartToken: (token) => {
|
|
45
|
+
issued = token;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
if (data.intent === "remove") await api.cart.removeItem(data.cartItemId);
|
|
49
|
+
else await api.cart.updateItem(data.cartItemId, { quantity: data.quantity });
|
|
50
|
+
const token = issued ?? cartToken;
|
|
51
|
+
if (token) writeCartToken(token);
|
|
40
52
|
});
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
});
|
|
48
|
-
}
|
|
54
|
+
export const Route = createFileRoute("/cart")({
|
|
55
|
+
loader: () => getCart(),
|
|
56
|
+
head: () => ({ meta: [{ title: "장바구니" }] }),
|
|
57
|
+
component: CartPage,
|
|
58
|
+
});
|
|
49
59
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
60
|
+
function CartPage() {
|
|
61
|
+
const { cart, loginRequired } = Route.useLoaderData();
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const [pending, setPending] = useState(false);
|
|
55
64
|
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
const change = (data: Parameters<typeof changeCart>[0]["data"]) => {
|
|
66
|
+
setPending(true);
|
|
67
|
+
changeCart({ data })
|
|
68
|
+
.then(() => router.invalidate())
|
|
69
|
+
.finally(() => setPending(false));
|
|
70
|
+
};
|
|
58
71
|
|
|
59
72
|
if (!cart.items.length) {
|
|
60
73
|
return (
|
|
@@ -82,7 +95,11 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
|
|
|
82
95
|
className="size-20 rounded-md bg-chip object-cover"
|
|
83
96
|
/>
|
|
84
97
|
<div className="flex flex-1 flex-col gap-1">
|
|
85
|
-
<Link
|
|
98
|
+
<Link
|
|
99
|
+
to="/products/$productId"
|
|
100
|
+
params={{ productId: item.productId }}
|
|
101
|
+
className="text-sm"
|
|
102
|
+
>
|
|
86
103
|
{item.productName}
|
|
87
104
|
</Link>
|
|
88
105
|
{item.optionName ? (
|
|
@@ -94,8 +111,18 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
|
|
|
94
111
|
</span>
|
|
95
112
|
) : null}
|
|
96
113
|
<div className="flex items-center gap-3 pt-1">
|
|
97
|
-
<
|
|
98
|
-
|
|
114
|
+
<form
|
|
115
|
+
className="flex items-center gap-2"
|
|
116
|
+
onSubmit={(event) => {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
const form = new FormData(event.currentTarget);
|
|
119
|
+
change({
|
|
120
|
+
intent: "update",
|
|
121
|
+
cartItemId: item.cartItemId,
|
|
122
|
+
quantity: Number(form.get("quantity")),
|
|
123
|
+
});
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
99
126
|
<input
|
|
100
127
|
type="number"
|
|
101
128
|
name="quantity"
|
|
@@ -104,26 +131,20 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
|
|
|
104
131
|
defaultValue={item.quantity}
|
|
105
132
|
className="h-9 w-20 rounded-md border border-line px-2 text-sm"
|
|
106
133
|
/>
|
|
107
|
-
<
|
|
108
|
-
type="submit"
|
|
109
|
-
name="intent"
|
|
110
|
-
value="update"
|
|
111
|
-
className="text-muted text-xs underline"
|
|
112
|
-
>
|
|
134
|
+
<SubmitButton disabled={pending} className="text-muted text-xs underline">
|
|
113
135
|
수량 변경
|
|
114
|
-
</
|
|
115
|
-
</
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
>
|
|
136
|
+
</SubmitButton>
|
|
137
|
+
</form>
|
|
138
|
+
<form
|
|
139
|
+
onSubmit={(event) => {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
change({ intent: "remove", cartItemId: item.cartItemId });
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<SubmitButton disabled={pending} className="text-muted text-xs underline">
|
|
124
145
|
삭제
|
|
125
|
-
</
|
|
126
|
-
</
|
|
146
|
+
</SubmitButton>
|
|
147
|
+
</form>
|
|
127
148
|
</div>
|
|
128
149
|
</div>
|
|
129
150
|
<span className="font-semibold text-sm">{formatPrice(item.totalPrice)}</span>
|
|
@@ -153,31 +174,26 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
|
|
|
153
174
|
이 스토어는 회원만 주문할 수 있어요. 담은 상품은 로그인하면 그대로 이어져요
|
|
154
175
|
</p>
|
|
155
176
|
<Link
|
|
156
|
-
to="/login
|
|
177
|
+
to="/login"
|
|
178
|
+
search={{ redirectTo: "/cart" }}
|
|
157
179
|
className="flex h-12 items-center justify-center rounded-md bg-ink font-semibold text-white"
|
|
158
180
|
>
|
|
159
181
|
로그인하고 주문하기
|
|
160
182
|
</Link>
|
|
161
183
|
</div>
|
|
184
|
+
) : purchasable.length ? (
|
|
185
|
+
/* 주문 조건은 URL에 둔다 — 새로고침·뒤로가기가 그대로 동작한다 */
|
|
186
|
+
<Link
|
|
187
|
+
to="/checkout"
|
|
188
|
+
search={{ cartItemId: purchasable.map((item) => item.cartItemId) }}
|
|
189
|
+
className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white md:w-64"
|
|
190
|
+
>
|
|
191
|
+
주문하기
|
|
192
|
+
</Link>
|
|
162
193
|
) : (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<input
|
|
167
|
-
key={item.cartItemId}
|
|
168
|
-
type="hidden"
|
|
169
|
-
name="cartItemId"
|
|
170
|
-
value={item.cartItemId}
|
|
171
|
-
/>
|
|
172
|
-
))}
|
|
173
|
-
<button
|
|
174
|
-
type="submit"
|
|
175
|
-
disabled={!purchasable.length}
|
|
176
|
-
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
177
|
-
>
|
|
178
|
-
주문하기
|
|
179
|
-
</button>
|
|
180
|
-
</Form>
|
|
194
|
+
<span className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white opacity-50 md:w-64">
|
|
195
|
+
주문하기
|
|
196
|
+
</span>
|
|
181
197
|
)}
|
|
182
198
|
</div>
|
|
183
199
|
</div>
|
|
@@ -1,28 +1,35 @@
|
|
|
1
|
-
import { Link } from "react-router";
|
|
1
|
+
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
3
|
+
import { z } from "zod";
|
|
2
4
|
import { apiFor } from "../lib/api.server";
|
|
3
5
|
import { formatPrice } from "../lib/format";
|
|
4
6
|
import { readToken } from "../lib/session.server";
|
|
5
|
-
import type { Route } from "./+types/checkout-complete";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
return [{ title: "주문 완료" }];
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function loader({ request, context }: Route.LoaderArgs) {
|
|
12
|
-
const orderId = new URL(request.url).searchParams.get("orderId");
|
|
13
|
-
if (!orderId) return { orderId: null, order: null };
|
|
8
|
+
const completeSearch = z.object({ orderId: z.string().optional().catch(undefined) });
|
|
14
9
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
const getCompletedOrder = createServerFn({ method: "GET" })
|
|
11
|
+
.validator(completeSearch)
|
|
12
|
+
.handler(async ({ data }) => {
|
|
13
|
+
if (!data.orderId) return null;
|
|
14
|
+
// 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
|
|
15
|
+
const accessToken = readToken();
|
|
16
|
+
if (!accessToken) return null;
|
|
17
|
+
return apiFor({ accessToken })
|
|
18
|
+
.myOrders.get(data.orderId)
|
|
19
|
+
.catch(() => null);
|
|
20
|
+
});
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
22
|
+
export const Route = createFileRoute("/checkout/complete")({
|
|
23
|
+
validateSearch: completeSearch,
|
|
24
|
+
loaderDeps: ({ search }) => search,
|
|
25
|
+
loader: ({ deps }) => getCompletedOrder({ data: deps }),
|
|
26
|
+
head: () => ({ meta: [{ title: "주문 완료" }] }),
|
|
27
|
+
component: CheckoutComplete,
|
|
28
|
+
});
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
const
|
|
30
|
+
function CheckoutComplete() {
|
|
31
|
+
const order = Route.useLoaderData();
|
|
32
|
+
const { orderId } = Route.useSearch();
|
|
26
33
|
|
|
27
34
|
return (
|
|
28
35
|
<div className="space-y-6 py-10 text-center">
|