@sayren/mcp 0.1.8 → 0.1.9
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 +1 -1
- 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 +1 -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/{app → src}/lib/social-login.server.ts +25 -10
- package/template/src/routeTree.gen.ts +262 -0
- package/template/src/router.tsx +17 -0
- package/template/src/routes/__root.tsx +90 -0
- package/template/src/routes/auth.callback.ts +48 -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/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,198 @@
|
|
|
1
|
+
import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
|
|
2
|
+
import { createFileRoute } 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 { clearCartToken, readCartToken } from "../lib/cart-session.server";
|
|
10
|
+
import { safeRedirect } from "../lib/safe-redirect";
|
|
11
|
+
import { signIn } from "../lib/session.server";
|
|
12
|
+
import { SOCIAL_BUTTONS } from "../lib/social-buttons";
|
|
13
|
+
import { writeSocialLogin } from "../lib/social-login.server";
|
|
14
|
+
|
|
15
|
+
const loginSearch = z.object({
|
|
16
|
+
redirectTo: z.string().optional().catch(undefined),
|
|
17
|
+
error: z.string().optional().catch(undefined),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** 켜진 로그인 방법 — 셀러가 콘솔 설정 › 로그인에서 켠 것만 온다 */
|
|
21
|
+
const getLoginMethods = createServerFn({ method: "GET" }).handler(async () => {
|
|
22
|
+
const store = await apiFor().store.get();
|
|
23
|
+
return store.loginMethods;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 소셜 로그인 시작 — state·PKCE verifier를 쿠키에 두고 공급자 로그인 화면 주소를 돌려준다. 돌아오는 곳은
|
|
28
|
+
* `/auth/callback`이고, 이 앱의 주소가 콘솔의 스토어프런트 도메인에 등록돼 있어야 한다.
|
|
29
|
+
*/
|
|
30
|
+
const startSocial = createServerFn({ method: "POST" })
|
|
31
|
+
.validator(z.object({ provider: z.enum(["kakao", "naver", "google"]), redirectTo: z.unknown() }))
|
|
32
|
+
.handler(async ({ data }) => {
|
|
33
|
+
const origin = new URL(getRequestUrl()).origin;
|
|
34
|
+
try {
|
|
35
|
+
const started = await authFor().idp(data.provider, {
|
|
36
|
+
redirectUri: `${origin}/auth/callback`,
|
|
37
|
+
});
|
|
38
|
+
writeSocialLogin({
|
|
39
|
+
state: started.state,
|
|
40
|
+
codeVerifier: started.codeVerifier,
|
|
41
|
+
redirectTo: safeRedirect(data.redirectTo),
|
|
42
|
+
});
|
|
43
|
+
return { url: started.url, error: null };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { url: null, error: loginErrorMessage(error) };
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 로그인 — 화면은 이 앱이 그리고, 확인은 sayren이 한다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면
|
|
51
|
+
* 회원 장바구니로 합쳐지고 그 토큰은 닫힌다. 그래서 장바구니 쿠키도 지운다.
|
|
52
|
+
*/
|
|
53
|
+
const passwordLogin = createServerFn({ method: "POST" })
|
|
54
|
+
.validator(z.object({ email: z.string(), password: z.string(), redirectTo: z.unknown() }))
|
|
55
|
+
.handler(async ({ data }) => {
|
|
56
|
+
const cartToken = readCartToken();
|
|
57
|
+
try {
|
|
58
|
+
const tokens = await authFor().session({
|
|
59
|
+
email: data.email,
|
|
60
|
+
password: data.password,
|
|
61
|
+
cartToken: cartToken ?? undefined,
|
|
62
|
+
});
|
|
63
|
+
signIn(tokens);
|
|
64
|
+
if (cartToken) clearCartToken();
|
|
65
|
+
return { redirectTo: safeRedirect(data.redirectTo), error: null };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { redirectTo: null, error: loginErrorMessage(error) };
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function loginErrorMessage(error: unknown): string {
|
|
72
|
+
if (error instanceof ApiError) {
|
|
73
|
+
if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않아요";
|
|
74
|
+
if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받아요";
|
|
75
|
+
if (error.code === "ACCOUNT_LOCKED")
|
|
76
|
+
return "로그인을 여러 번 실패해 잠시 잠겼어요. 10분 뒤에 다시 시도해 주세요";
|
|
77
|
+
if (error.status === 400) return "이메일과 비밀번호를 확인해 주세요";
|
|
78
|
+
}
|
|
79
|
+
return "로그인하지 못했어요. 잠시 후 다시 시도해 주세요";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const CALLBACK_ERRORS: Record<string, string> = {
|
|
83
|
+
access_denied: "로그인을 취소했어요",
|
|
84
|
+
state_mismatch: "로그인 요청이 만료됐어요. 다시 시도해 주세요",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Route = createFileRoute("/login")({
|
|
88
|
+
validateSearch: loginSearch,
|
|
89
|
+
loader: () => getLoginMethods(),
|
|
90
|
+
head: () => ({ meta: [{ title: "로그인" }] }),
|
|
91
|
+
component: Login,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function Login() {
|
|
95
|
+
const loginMethods = Route.useLoaderData();
|
|
96
|
+
const { redirectTo, error: callbackError } = Route.useSearch();
|
|
97
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
98
|
+
const [pending, setPending] = useState(false);
|
|
99
|
+
const social = loginMethods.filter((method): method is SocialProvider => method !== "password");
|
|
100
|
+
const password = loginMethods.includes("password");
|
|
101
|
+
const error =
|
|
102
|
+
actionError ??
|
|
103
|
+
(callbackError
|
|
104
|
+
? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했어요. 다시 시도해 주세요")
|
|
105
|
+
: null);
|
|
106
|
+
|
|
107
|
+
const submitSocial = (event: React.FormEvent<HTMLFormElement>) => {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
|
|
110
|
+
const provider = submitter?.value as SocialProvider | undefined;
|
|
111
|
+
if (!provider) return;
|
|
112
|
+
setPending(true);
|
|
113
|
+
void startSocial({ data: { provider, redirectTo } }).then((result) => {
|
|
114
|
+
// 공급자 로그인 화면은 다른 사이트라 문서째 이동한다
|
|
115
|
+
if (result.url) window.location.assign(result.url);
|
|
116
|
+
else {
|
|
117
|
+
setActionError(result.error);
|
|
118
|
+
setPending(false);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const submitPassword = (event: React.FormEvent<HTMLFormElement>) => {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
const form = new FormData(event.currentTarget);
|
|
126
|
+
setPending(true);
|
|
127
|
+
void passwordLogin({
|
|
128
|
+
data: {
|
|
129
|
+
email: String(form.get("email") ?? ""),
|
|
130
|
+
password: String(form.get("password") ?? ""),
|
|
131
|
+
redirectTo,
|
|
132
|
+
},
|
|
133
|
+
}).then((result) => {
|
|
134
|
+
// 로그인 상태가 모든 화면에 새로 반영되도록 문서째 이동한다
|
|
135
|
+
if (result.redirectTo) window.location.assign(result.redirectTo);
|
|
136
|
+
else {
|
|
137
|
+
setActionError(result.error);
|
|
138
|
+
setPending(false);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className="mx-auto max-w-sm space-y-5 py-10">
|
|
145
|
+
<h1 className="font-bold text-2xl">로그인</h1>
|
|
146
|
+
{social.length ? (
|
|
147
|
+
<form onSubmit={submitSocial} className="space-y-2">
|
|
148
|
+
{social.map((provider) => (
|
|
149
|
+
<SubmitButton
|
|
150
|
+
key={provider}
|
|
151
|
+
name="social"
|
|
152
|
+
value={provider}
|
|
153
|
+
disabled={pending}
|
|
154
|
+
className={`h-12 w-full rounded-md font-semibold ${SOCIAL_BUTTONS[provider].className}`}
|
|
155
|
+
>
|
|
156
|
+
{SOCIAL_BUTTONS[provider].label}
|
|
157
|
+
</SubmitButton>
|
|
158
|
+
))}
|
|
159
|
+
</form>
|
|
160
|
+
) : null}
|
|
161
|
+
{social.length && password ? (
|
|
162
|
+
<div className="flex items-center gap-3 text-muted text-xs">
|
|
163
|
+
<span className="h-px flex-1 bg-line" />
|
|
164
|
+
또는 이메일로
|
|
165
|
+
<span className="h-px flex-1 bg-line" />
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
{password ? (
|
|
169
|
+
<form onSubmit={submitPassword} className="space-y-3">
|
|
170
|
+
<input
|
|
171
|
+
type="email"
|
|
172
|
+
name="email"
|
|
173
|
+
placeholder="이메일"
|
|
174
|
+
required
|
|
175
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
176
|
+
/>
|
|
177
|
+
<input
|
|
178
|
+
type="password"
|
|
179
|
+
name="password"
|
|
180
|
+
placeholder="비밀번호"
|
|
181
|
+
required
|
|
182
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
183
|
+
/>
|
|
184
|
+
<SubmitButton
|
|
185
|
+
disabled={pending}
|
|
186
|
+
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
187
|
+
>
|
|
188
|
+
로그인
|
|
189
|
+
</SubmitButton>
|
|
190
|
+
</form>
|
|
191
|
+
) : null}
|
|
192
|
+
{error ? <p className="text-point text-sm">{error}</p> : null}
|
|
193
|
+
{social.length ? (
|
|
194
|
+
<p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 돼요</p>
|
|
195
|
+
) : null}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -1,22 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
+
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { z } from "zod";
|
|
2
5
|
import { apiFor } from "../lib/api.server";
|
|
3
6
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
4
7
|
import { readToken } from "../lib/session.server";
|
|
5
|
-
import type { Route } from "./+types/order-detail";
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
9
|
+
const getOrder = createServerFn({ method: "GET" })
|
|
10
|
+
.validator(z.object({ orderId: z.string() }))
|
|
11
|
+
.handler(async ({ data }) => {
|
|
12
|
+
const accessToken = readToken();
|
|
13
|
+
if (!accessToken) {
|
|
14
|
+
throw redirect({ to: "/login", search: { redirectTo: `/orders/${data.orderId}` } });
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return await apiFor({ accessToken }).myOrders.get(data.orderId);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error instanceof ApiError && error.status === 404) throw notFound();
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
10
23
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
24
|
+
export const Route = createFileRoute("/orders/$orderId")({
|
|
25
|
+
loader: ({ params }) => getOrder({ data: { orderId: params.orderId } }),
|
|
26
|
+
head: ({ params }) => ({ meta: [{ title: `주문 ${params.orderId}` }] }),
|
|
27
|
+
component: OrderDetail,
|
|
28
|
+
});
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
const
|
|
30
|
+
function OrderDetail() {
|
|
31
|
+
const order = Route.useLoaderData();
|
|
20
32
|
|
|
21
33
|
return (
|
|
22
34
|
<div className="space-y-6">
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import { Link, redirect } from "react-router";
|
|
1
|
+
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
3
|
import { apiFor } from "../lib/api.server";
|
|
3
4
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
4
5
|
import { readToken } from "../lib/session.server";
|
|
5
|
-
import type { Route } from "./+types/orders";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
7
|
+
const getOrders = createServerFn({ method: "GET" }).handler(async () => {
|
|
8
|
+
const accessToken = readToken();
|
|
9
|
+
if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
|
|
10
|
+
return apiFor({ accessToken }).myOrders.list({ size: 20 });
|
|
11
|
+
});
|
|
10
12
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
13
|
+
export const Route = createFileRoute("/orders/")({
|
|
14
|
+
loader: () => getOrders(),
|
|
15
|
+
head: () => ({ meta: [{ title: "주문 내역" }] }),
|
|
16
|
+
component: Orders,
|
|
17
|
+
});
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
const
|
|
19
|
+
function Orders() {
|
|
20
|
+
const orders = Route.useLoaderData();
|
|
20
21
|
|
|
21
22
|
if (!orders.contents.length) {
|
|
22
23
|
return <p className="py-16 text-center text-muted">아직 주문이 없어요</p>;
|
|
@@ -29,7 +30,8 @@ export default function Orders({ loaderData }: Route.ComponentProps) {
|
|
|
29
30
|
{orders.contents.map((order) => (
|
|
30
31
|
<li key={order.orderId}>
|
|
31
32
|
<Link
|
|
32
|
-
to=
|
|
33
|
+
to="/orders/$orderId"
|
|
34
|
+
params={{ orderId: order.orderId }}
|
|
33
35
|
className="flex items-center justify-between gap-4 rounded-lg border border-line p-4 hover:border-ink"
|
|
34
36
|
>
|
|
35
37
|
<span className="flex flex-col gap-1">
|
|
@@ -1,55 +1,66 @@
|
|
|
1
|
+
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
+
import { createFileRoute, Link, notFound, useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
1
4
|
import { useState } from "react";
|
|
2
|
-
import {
|
|
5
|
+
import { z } from "zod";
|
|
3
6
|
import { ProductDescription } from "../components/product-description";
|
|
7
|
+
import { SubmitButton } from "../components/submit-button";
|
|
4
8
|
import { useTrack } from "../lib/analytics";
|
|
5
9
|
import { apiFor } from "../lib/api.server";
|
|
6
|
-
import {
|
|
10
|
+
import { readCartToken, writeCartToken } from "../lib/cart-session.server";
|
|
7
11
|
import { formatPrice } from "../lib/format";
|
|
8
12
|
import { readToken } from "../lib/session.server";
|
|
9
|
-
import type { Route } from "./+types/product-detail";
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
14
|
+
const getProduct = createServerFn({ method: "GET" })
|
|
15
|
+
.validator(z.object({ productId: z.string() }))
|
|
16
|
+
.handler(async ({ data }) => {
|
|
17
|
+
try {
|
|
18
|
+
return await apiFor().catalog.getProduct(data.productId);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error instanceof ApiError && error.status === 404) throw notFound();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
* 장바구니
|
|
26
|
+
* 장바구니 담기 — 서버에서 처리한다. 비회원 장바구니 토큰은 서버가 새로 발급할 수 있어
|
|
23
27
|
* 응답 쿠키에 다시 심는다.
|
|
24
28
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
const addToCart = createServerFn({ method: "POST" })
|
|
30
|
+
.validator(
|
|
31
|
+
z.object({
|
|
32
|
+
productId: z.string(),
|
|
33
|
+
optionId: z.string().optional(),
|
|
34
|
+
quantity: z.number().int().min(1).max(999),
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
.handler(async ({ data }) => {
|
|
38
|
+
const cartToken = readCartToken();
|
|
39
|
+
let issued: string | null = null;
|
|
40
|
+
const api = apiFor({
|
|
41
|
+
accessToken: readToken(),
|
|
42
|
+
cartToken,
|
|
43
|
+
onCartToken: (token) => {
|
|
44
|
+
issued = token;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
await api.cart.addItem(data);
|
|
48
|
+
const token = issued ?? cartToken;
|
|
49
|
+
if (token) writeCartToken(token);
|
|
39
50
|
});
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
52
|
+
export const Route = createFileRoute("/products/$productId")({
|
|
53
|
+
loader: ({ params }) => getProduct({ data: { productId: params.productId } }),
|
|
54
|
+
head: ({ loaderData }) => ({ meta: [{ title: loaderData ? loaderData.name : "상품" }] }),
|
|
55
|
+
component: ProductDetail,
|
|
56
|
+
});
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
const
|
|
58
|
+
function ProductDetail() {
|
|
59
|
+
const product = Route.useLoaderData();
|
|
50
60
|
useTrack({ name: "product_view", productId: product.productId });
|
|
51
|
-
const
|
|
52
|
-
const submitting =
|
|
61
|
+
const navigate = useNavigate();
|
|
62
|
+
const [submitting, setSubmitting] = useState(false);
|
|
63
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
64
|
const hasOptions = product.optionGroups.length > 0;
|
|
54
65
|
const [quantity, setQuantity] = useState(1);
|
|
55
66
|
const [variantId, setVariantId] = useState(
|
|
@@ -58,6 +69,7 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
|
|
|
58
69
|
const variant = product.variants.find((v) => v.variantId === variantId);
|
|
59
70
|
const price =
|
|
60
71
|
(product.discountedPrice ?? product.salePrice) + (variant ? variant.additionalPrice : 0);
|
|
72
|
+
const optionId = hasOptions ? variantId : undefined;
|
|
61
73
|
|
|
62
74
|
return (
|
|
63
75
|
<div className="grid gap-8 md:grid-cols-2">
|
|
@@ -86,7 +98,18 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
|
|
|
86
98
|
</p>
|
|
87
99
|
</div>
|
|
88
100
|
|
|
89
|
-
<
|
|
101
|
+
<form
|
|
102
|
+
className="space-y-4"
|
|
103
|
+
onSubmit={(event) => {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
setSubmitting(true);
|
|
106
|
+
setError(null);
|
|
107
|
+
addToCart({ data: { productId: product.productId, optionId, quantity } })
|
|
108
|
+
.then(() => navigate({ to: "/cart" }))
|
|
109
|
+
.catch(() => setError("장바구니에 담지 못했어요. 다시 시도해주세요"))
|
|
110
|
+
.finally(() => setSubmitting(false));
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
90
113
|
{hasOptions ? (
|
|
91
114
|
<label className="block space-y-1">
|
|
92
115
|
<span className="text-sm">옵션</span>
|
|
@@ -119,30 +142,29 @@ export default function ProductDetail({ loaderData }: Route.ComponentProps) {
|
|
|
119
142
|
/>
|
|
120
143
|
</label>
|
|
121
144
|
|
|
122
|
-
<
|
|
123
|
-
type="submit"
|
|
124
|
-
name="intent"
|
|
125
|
-
value="cart"
|
|
145
|
+
<SubmitButton
|
|
126
146
|
disabled={submitting || product.soldOut}
|
|
127
147
|
className="h-12 w-full rounded-md border border-ink font-semibold disabled:opacity-50"
|
|
128
148
|
>
|
|
129
149
|
장바구니
|
|
130
|
-
</
|
|
131
|
-
</
|
|
150
|
+
</SubmitButton>
|
|
151
|
+
</form>
|
|
132
152
|
|
|
133
|
-
{/* 바로구매는
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
{/* 바로구매는 주문서로 바로 간다 — 장바구니를 거치지 않는다 */}
|
|
154
|
+
{product.soldOut ? (
|
|
155
|
+
<span className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white opacity-50">
|
|
156
|
+
바로구매
|
|
157
|
+
</span>
|
|
158
|
+
) : (
|
|
159
|
+
<Link
|
|
160
|
+
to="/checkout"
|
|
161
|
+
search={{ productId: product.productId, optionId, quantity }}
|
|
162
|
+
className="flex h-12 w-full items-center justify-center rounded-md bg-ink font-semibold text-white"
|
|
142
163
|
>
|
|
143
164
|
바로구매
|
|
144
|
-
</
|
|
145
|
-
|
|
165
|
+
</Link>
|
|
166
|
+
)}
|
|
167
|
+
{error ? <p className="text-point text-sm">{error}</p> : null}
|
|
146
168
|
|
|
147
169
|
<section className="space-y-2 border-line border-t pt-5">
|
|
148
170
|
<h2 className="font-semibold">상품 설명</h2>
|
|
@@ -1,34 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
3
|
+
import { z } from "zod";
|
|
2
4
|
import { ProductCard } from "../components/product-card";
|
|
3
5
|
import { useTrack } from "../lib/analytics";
|
|
4
6
|
import { apiFor } from "../lib/api.server";
|
|
5
|
-
import type { Route } from "./+types/products";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** 목록·검색 조건 — URL에 둔다. 공유·뒤로가기가 그대로 동작한다 */
|
|
9
|
+
const productsSearch = z.object({
|
|
10
|
+
keyword: z.string().optional().catch(undefined),
|
|
11
|
+
categoryId: z.string().optional().catch(undefined),
|
|
12
|
+
sort: z.enum(["recommend", "latest"]).optional().catch(undefined),
|
|
13
|
+
page: z.coerce.number().int().min(1).optional().catch(undefined),
|
|
14
|
+
});
|
|
15
|
+
type ProductsSearch = z.infer<typeof productsSearch>;
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
const api = apiFor(request);
|
|
22
|
-
const [categories, page] = await Promise.all([
|
|
23
|
-
api.catalog.listCategories(),
|
|
24
|
-
api.catalog.searchProducts(params),
|
|
25
|
-
]);
|
|
26
|
-
return { categories, page, applied: params };
|
|
27
|
-
}
|
|
17
|
+
const searchProducts = createServerFn({ method: "GET" })
|
|
18
|
+
.validator(productsSearch)
|
|
19
|
+
.handler(async ({ data }) => {
|
|
20
|
+
const api = apiFor();
|
|
21
|
+
const [categories, page] = await Promise.all([
|
|
22
|
+
api.catalog.listCategories(),
|
|
23
|
+
api.catalog.searchProducts({ ...data, page: data.page ?? 1, size: 24 }),
|
|
24
|
+
]);
|
|
25
|
+
return { categories, page };
|
|
26
|
+
});
|
|
28
27
|
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
export const Route = createFileRoute("/products/")({
|
|
29
|
+
validateSearch: productsSearch,
|
|
30
|
+
loaderDeps: ({ search }) => search,
|
|
31
|
+
loader: ({ deps }) => searchProducts({ data: deps }),
|
|
32
|
+
head: () => ({ meta: [{ title: "전체 상품" }] }),
|
|
33
|
+
component: Products,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function Products() {
|
|
37
|
+
const { categories, page } = Route.useLoaderData();
|
|
38
|
+
const applied = Route.useSearch();
|
|
39
|
+
const navigate = useNavigate();
|
|
32
40
|
useTrack(
|
|
33
41
|
applied.keyword
|
|
34
42
|
? { name: "search", query: applied.keyword, resultCount: page.totalElements }
|
|
@@ -46,7 +54,19 @@ export default function Products({ loaderData }: Route.ComponentProps) {
|
|
|
46
54
|
|
|
47
55
|
return (
|
|
48
56
|
<div className="space-y-6">
|
|
49
|
-
|
|
57
|
+
{/* 하이드레이션 전에는 GET 폼으로 그대로 제출되고, 뒤에는 화면을 새로 그리지 않고 이동한다 */}
|
|
58
|
+
<form
|
|
59
|
+
action="/products"
|
|
60
|
+
className="flex gap-2"
|
|
61
|
+
onSubmit={(event) => {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
const keyword = String(new FormData(event.currentTarget).get("keyword") ?? "").trim();
|
|
64
|
+
void navigate({
|
|
65
|
+
to: "/products",
|
|
66
|
+
search: { keyword: keyword || undefined, categoryId: applied.categoryId },
|
|
67
|
+
});
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
50
70
|
<input
|
|
51
71
|
type="search"
|
|
52
72
|
name="keyword"
|
|
@@ -60,7 +80,7 @@ export default function Products({ loaderData }: Route.ComponentProps) {
|
|
|
60
80
|
<button type="submit" className="h-11 rounded-md bg-ink px-5 font-semibold text-white">
|
|
61
81
|
검색
|
|
62
82
|
</button>
|
|
63
|
-
</
|
|
83
|
+
</form>
|
|
64
84
|
|
|
65
85
|
<div className="flex flex-wrap gap-2">
|
|
66
86
|
<Link
|
|
@@ -72,7 +92,8 @@ export default function Products({ loaderData }: Route.ComponentProps) {
|
|
|
72
92
|
{categories.map((category) => (
|
|
73
93
|
<Link
|
|
74
94
|
key={category.categoryId}
|
|
75
|
-
to=
|
|
95
|
+
to="/products"
|
|
96
|
+
search={{ categoryId: category.categoryId }}
|
|
76
97
|
className={`rounded-full px-3 py-1.5 text-sm ${
|
|
77
98
|
applied.categoryId === category.categoryId ? "bg-ink text-white" : "bg-chip"
|
|
78
99
|
}`}
|
|
@@ -92,7 +113,7 @@ export default function Products({ loaderData }: Route.ComponentProps) {
|
|
|
92
113
|
</div>
|
|
93
114
|
)}
|
|
94
115
|
|
|
95
|
-
<Pagination page={page.page} totalPages={page.totalPages}
|
|
116
|
+
<Pagination page={page.page} totalPages={page.totalPages} search={applied} />
|
|
96
117
|
</div>
|
|
97
118
|
);
|
|
98
119
|
}
|
|
@@ -100,22 +121,21 @@ export default function Products({ loaderData }: Route.ComponentProps) {
|
|
|
100
121
|
function Pagination({
|
|
101
122
|
page,
|
|
102
123
|
totalPages,
|
|
103
|
-
|
|
124
|
+
search,
|
|
104
125
|
}: {
|
|
105
126
|
page: number;
|
|
106
127
|
totalPages: number;
|
|
107
|
-
|
|
128
|
+
search: ProductsSearch;
|
|
108
129
|
}) {
|
|
109
130
|
if (totalPages <= 1) return null;
|
|
110
|
-
const linkTo = (next: number) => {
|
|
111
|
-
const params = new URLSearchParams(searchParams);
|
|
112
|
-
params.set("page", String(next));
|
|
113
|
-
return `/products?${params.toString()}`;
|
|
114
|
-
};
|
|
115
131
|
return (
|
|
116
132
|
<nav className="flex items-center justify-center gap-3 text-sm">
|
|
117
133
|
{page > 1 ? (
|
|
118
|
-
<Link
|
|
134
|
+
<Link
|
|
135
|
+
to="/products"
|
|
136
|
+
search={{ ...search, page: page - 1 }}
|
|
137
|
+
className="rounded-md border border-line px-3 py-1.5"
|
|
138
|
+
>
|
|
119
139
|
이전
|
|
120
140
|
</Link>
|
|
121
141
|
) : null}
|
|
@@ -123,7 +143,11 @@ function Pagination({
|
|
|
123
143
|
{page} / {totalPages}
|
|
124
144
|
</span>
|
|
125
145
|
{page < totalPages ? (
|
|
126
|
-
<Link
|
|
146
|
+
<Link
|
|
147
|
+
to="/products"
|
|
148
|
+
search={{ ...search, page: page + 1 }}
|
|
149
|
+
className="rounded-md border border-line px-3 py-1.5"
|
|
150
|
+
>
|
|
127
151
|
다음
|
|
128
152
|
</Link>
|
|
129
153
|
) : null}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
|
|
2
|
+
import { refreshBuyerSession } from "./lib/session.server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 서버 함수 호출은 같은 사이트에서 온 것만 받는다. 구매자 세션이 쿠키라 다른 사이트가 결제·장바구니
|
|
6
|
+
* 서버 함수를 대신 부르지 못하게 한다.
|
|
7
|
+
*/
|
|
8
|
+
const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });
|
|
9
|
+
|
|
10
|
+
/** 구매자 세션 — 모든 요청에서 액세스 토큰을 정하고 만료가 가까우면 갱신한다(`readToken`으로 읽는다) */
|
|
11
|
+
const buyerSession = createMiddleware().server(async ({ next }) => {
|
|
12
|
+
const accessToken = await refreshBuyerSession();
|
|
13
|
+
return next({ context: { accessToken } });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const startInstance = createStart(() => ({
|
|
17
|
+
requestMiddleware: [csrf, buyerSession],
|
|
18
|
+
}));
|