@sayren/mcp 0.1.7 → 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/src/lib/social-buttons.ts +6 -0
- package/template/src/lib/social-login.server.ts +46 -0
- 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/lib/social-login.server.ts +0 -38
- package/template/app/root.tsx +0 -78
- package/template/app/routes/auth-callback.tsx +0 -44
- package/template/app/routes/login.tsx +0 -152
- 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/app.css → src/styles.css} +0 -0
|
@@ -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">
|
|
@@ -1,101 +1,116 @@
|
|
|
1
1
|
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { useRef, useState } from "react";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SubmitButton } from "../components/submit-button";
|
|
4
7
|
import { browserApi } from "../lib/api";
|
|
5
8
|
import { apiFor } from "../lib/api.server";
|
|
6
9
|
import { readCartToken } from "../lib/cart-session.server";
|
|
7
|
-
import { checkoutIntentFrom } from "../lib/checkout-intent";
|
|
10
|
+
import { checkoutIntentFrom, checkoutSearch } from "../lib/checkout-intent";
|
|
11
|
+
import type { PublicConfig } from "../lib/config";
|
|
8
12
|
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
9
13
|
import { formatPrice } from "../lib/format";
|
|
10
|
-
import { createPaymentPopup } from "../lib/payment-popup";
|
|
14
|
+
import { createPaymentPopup, type PaymentPopup } from "../lib/payment-popup";
|
|
11
15
|
import { readToken } from "../lib/session.server";
|
|
12
|
-
import type { Route } from "./+types/checkout";
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
+
const createCheckout = createServerFn({ method: "POST" })
|
|
18
|
+
.validator(z.object({ search: checkoutSearch, back: z.string() }))
|
|
19
|
+
.handler(async ({ data }) => {
|
|
20
|
+
const intent = checkoutIntentFrom(data.search);
|
|
21
|
+
if (!intent) throw redirect({ to: "/cart" });
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
const accessToken = readToken();
|
|
24
|
+
const api = apiFor({ accessToken, cartToken: readCartToken() });
|
|
25
|
+
// 주문서 세션은 진입할 때마다 새로 만든다 — 조회 API가 없고, 가격·재고를 매번 다시 계산한다
|
|
26
|
+
const [checkout, member] = await Promise.all([
|
|
27
|
+
api.checkout.create(intent).catch((error: unknown) => {
|
|
28
|
+
// 회원만 주문받는 스토어 — 로그인 뒤 돌아온다. 장바구니 주문은 로그인하며 장바구니가 합쳐져 항목 id가
|
|
29
|
+
// 바뀔 수 있으니 장바구니로, 바로구매는 같은 주문서로 돌아온다
|
|
30
|
+
if (error instanceof ApiError && error.code === "GUEST_CHECKOUT_DISABLED") {
|
|
31
|
+
throw redirect({
|
|
32
|
+
to: "/login",
|
|
33
|
+
search: { redirectTo: intent.cartItemIds ? "/cart" : data.back },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}),
|
|
38
|
+
accessToken ? api.member.me().catch(() => null) : null,
|
|
39
|
+
]);
|
|
40
|
+
const config: PublicConfig = { apiBaseUrl: API_BASE_URL, storeCode: resolveStoreCode() };
|
|
41
|
+
return { checkout, member, config };
|
|
42
|
+
});
|
|
22
43
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
throw error;
|
|
36
|
-
}),
|
|
37
|
-
accessToken ? api.member.me().catch(() => null) : null,
|
|
38
|
-
]);
|
|
39
|
-
return {
|
|
40
|
-
checkout,
|
|
41
|
-
member,
|
|
42
|
-
config: { apiBaseUrl: API_BASE_URL, storeCode: resolveStoreCode(request) },
|
|
43
|
-
};
|
|
44
|
-
}
|
|
44
|
+
const paymentForm = z.object({
|
|
45
|
+
checkoutId: z.string(),
|
|
46
|
+
receiverName: z.string(),
|
|
47
|
+
phone: z.string(),
|
|
48
|
+
zipCode: z.string(),
|
|
49
|
+
address1: z.string(),
|
|
50
|
+
address2: z.string(),
|
|
51
|
+
deliveryMemo: z.string(),
|
|
52
|
+
guestName: z.string(),
|
|
53
|
+
email: z.string(),
|
|
54
|
+
orderPassword: z.string(),
|
|
55
|
+
});
|
|
45
56
|
|
|
46
57
|
/** 결제 요청은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다 */
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
58
|
+
const requestPayment = createServerFn({ method: "POST" })
|
|
59
|
+
.validator(paymentForm)
|
|
60
|
+
.handler(async ({ data }) => {
|
|
61
|
+
const accessToken = readToken();
|
|
62
|
+
const api = apiFor({ accessToken, cartToken: readCartToken() });
|
|
63
|
+
const payment = await api.checkout.requestPayment(data.checkoutId, {
|
|
64
|
+
shippingAddress: {
|
|
65
|
+
receiverName: data.receiverName,
|
|
66
|
+
phone: data.phone,
|
|
67
|
+
zipCode: data.zipCode,
|
|
68
|
+
address1: data.address1,
|
|
69
|
+
address2: data.address2 || undefined,
|
|
70
|
+
deliveryMemo: data.deliveryMemo || undefined,
|
|
71
|
+
},
|
|
72
|
+
paymentMethod: "CARD",
|
|
73
|
+
guest: accessToken
|
|
74
|
+
? undefined
|
|
75
|
+
: {
|
|
76
|
+
name: data.guestName,
|
|
77
|
+
phone: data.phone,
|
|
78
|
+
email: data.email,
|
|
79
|
+
orderPassword: data.orderPassword,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// 브라우저에는 팝업 주소와 상태 조회 키만 내려준다
|
|
83
|
+
return { paymentId: payment.paymentId, popupUrl: payment.pgParams.popupUrl };
|
|
73
84
|
});
|
|
74
|
-
return { payment };
|
|
75
|
-
}
|
|
76
85
|
|
|
77
|
-
export
|
|
78
|
-
|
|
86
|
+
export const Route = createFileRoute("/checkout/")({
|
|
87
|
+
validateSearch: checkoutSearch,
|
|
88
|
+
loaderDeps: ({ search }) => search,
|
|
89
|
+
loader: ({ deps, location }) => createCheckout({ data: { search: deps, back: location.href } }),
|
|
90
|
+
head: () => ({ meta: [{ title: "주문서" }] }),
|
|
91
|
+
component: Checkout,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function Checkout() {
|
|
95
|
+
const { checkout, member, config } = Route.useLoaderData();
|
|
79
96
|
const navigate = useNavigate();
|
|
80
|
-
const popupRef = useRef<
|
|
97
|
+
const popupRef = useRef<PaymentPopup | null>(null);
|
|
81
98
|
const [notice, setNotice] = useState<string | null>(null);
|
|
82
99
|
const [checking, setChecking] = useState(false);
|
|
100
|
+
const [submitting, setSubmitting] = useState(false);
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
}, [actionData]);
|
|
102
|
+
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
103
|
+
// 문서를 다시 부르지 않는다 — 팝업 제어 상태가 사라진다
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
const form = new FormData(event.currentTarget);
|
|
106
|
+
const field = (name: string) => String(form.get(name) ?? "");
|
|
107
|
+
let paymentId: string | null = null;
|
|
92
108
|
|
|
93
|
-
const handleSubmit = () => {
|
|
94
109
|
// 클릭 콜스택에서 동기적으로 연다 — 응답을 기다린 뒤 열면 차단된다
|
|
95
|
-
|
|
110
|
+
const popup = createPaymentPopup({
|
|
96
111
|
onCompleted: (message) => {
|
|
97
112
|
if (message.orderId) {
|
|
98
|
-
void navigate(
|
|
113
|
+
void navigate({ to: "/checkout/complete", search: { orderId: message.orderId } });
|
|
99
114
|
} else {
|
|
100
115
|
setNotice("결제가 완료됐어요. 주문 내역에서 확인해주세요.");
|
|
101
116
|
}
|
|
@@ -105,26 +120,56 @@ export default function Checkout({ loaderData, actionData }: Route.ComponentProp
|
|
|
105
120
|
},
|
|
106
121
|
onClosed: () => {
|
|
107
122
|
// 닫혔다고 실패로 단정하지 않는다 — 서버에 결제 상태를 물어본다
|
|
108
|
-
const paymentId = actionDataPaymentId(actionData);
|
|
109
123
|
if (!paymentId) return;
|
|
110
124
|
setChecking(true);
|
|
111
125
|
void pollPaymentStatus(config, paymentId).then((status) => {
|
|
112
126
|
setChecking(false);
|
|
113
127
|
if (status?.status === "completed" && status.orderId) {
|
|
114
|
-
void navigate(
|
|
128
|
+
void navigate({ to: "/checkout/complete", search: { orderId: status.orderId } });
|
|
115
129
|
} else {
|
|
116
130
|
setNotice("결제창이 닫혔어요. 결제를 마쳤다면 잠시 뒤 주문 내역에서 확인해주세요.");
|
|
117
131
|
}
|
|
118
132
|
});
|
|
119
133
|
},
|
|
120
134
|
});
|
|
121
|
-
popupRef.current
|
|
135
|
+
popupRef.current?.close();
|
|
136
|
+
popupRef.current = popup;
|
|
137
|
+
popup.open();
|
|
138
|
+
|
|
139
|
+
// 결제 요청 응답이 오면 이미 열어 둔 팝업을 결제 주소로 보낸다
|
|
140
|
+
setSubmitting(true);
|
|
141
|
+
setNotice(null);
|
|
142
|
+
requestPayment({
|
|
143
|
+
data: {
|
|
144
|
+
checkoutId: checkout.checkoutId,
|
|
145
|
+
receiverName: field("receiverName"),
|
|
146
|
+
phone: field("phone"),
|
|
147
|
+
zipCode: field("zipCode"),
|
|
148
|
+
address1: field("address1"),
|
|
149
|
+
address2: field("address2"),
|
|
150
|
+
deliveryMemo: field("deliveryMemo"),
|
|
151
|
+
guestName: field("guestName"),
|
|
152
|
+
email: field("email"),
|
|
153
|
+
orderPassword: field("orderPassword"),
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
.then((payment) => {
|
|
157
|
+
paymentId = payment.paymentId;
|
|
158
|
+
if (!popup.navigate(payment.popupUrl)) {
|
|
159
|
+
popup.close();
|
|
160
|
+
setNotice("결제창이 열리지 않았어요. 팝업 차단을 풀고 다시 시도해주세요.");
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
.catch(() => {
|
|
164
|
+
popup.close();
|
|
165
|
+
setNotice("결제를 시작하지 못했어요. 입력한 내용을 확인하고 다시 시도해주세요.");
|
|
166
|
+
})
|
|
167
|
+
.finally(() => setSubmitting(false));
|
|
122
168
|
};
|
|
123
169
|
|
|
124
170
|
return (
|
|
125
171
|
<div className="grid gap-8 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:items-start">
|
|
126
|
-
<
|
|
127
|
-
<input type="hidden" name="checkoutId" value={checkout.checkoutId} />
|
|
172
|
+
<form onSubmit={handleSubmit} className="space-y-6">
|
|
128
173
|
<section className="space-y-3">
|
|
129
174
|
<h2 className="font-bold text-lg">배송지</h2>
|
|
130
175
|
<Field label="수령인" name="receiverName" defaultValue={member?.name ?? ""} required />
|
|
@@ -150,11 +195,14 @@ export default function Checkout({ loaderData, actionData }: Route.ComponentProp
|
|
|
150
195
|
</section>
|
|
151
196
|
)}
|
|
152
197
|
|
|
153
|
-
<
|
|
198
|
+
<SubmitButton
|
|
199
|
+
disabled={submitting}
|
|
200
|
+
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
201
|
+
>
|
|
154
202
|
{formatPrice(checkout.amounts.totalAmount)} 결제하기
|
|
155
|
-
</
|
|
203
|
+
</SubmitButton>
|
|
156
204
|
<p className="text-center text-muted text-xs">결제창은 새 팝업에서 열려요</p>
|
|
157
|
-
</
|
|
205
|
+
</form>
|
|
158
206
|
|
|
159
207
|
<aside className="space-y-4 rounded-lg border border-line p-5">
|
|
160
208
|
<h2 className="font-bold text-lg">주문 상품</h2>
|
|
@@ -195,20 +243,12 @@ export default function Checkout({ loaderData, actionData }: Route.ComponentProp
|
|
|
195
243
|
);
|
|
196
244
|
}
|
|
197
245
|
|
|
198
|
-
function actionDataPaymentId(actionData: Route.ComponentProps["actionData"]): string | null {
|
|
199
|
-
return actionData?.payment?.paymentId ?? null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
246
|
/** 팝업이 결과 없이 닫힌 경우 — 서버 상태가 진실이다 */
|
|
203
|
-
async function pollPaymentStatus(
|
|
204
|
-
config: { apiBaseUrl: string; storeCode: string },
|
|
205
|
-
paymentId: string,
|
|
206
|
-
) {
|
|
247
|
+
async function pollPaymentStatus(config: PublicConfig, paymentId: string) {
|
|
207
248
|
const api = browserApi(config);
|
|
208
249
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
209
250
|
const status = await api.payments.getStatus(paymentId).catch(() => null);
|
|
210
251
|
if (status && status.status !== "pending" && status.status !== "processing") return status;
|
|
211
|
-
if (status?.status === "completed") return status;
|
|
212
252
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
213
253
|
}
|
|
214
254
|
return null;
|
|
@@ -237,7 +277,7 @@ function Field({
|
|
|
237
277
|
{label}
|
|
238
278
|
{required ? <span className="text-point"> *</span> : null}
|
|
239
279
|
</span>
|
|
240
|
-
{/* 프리필은 defaultValue로 준다 —
|
|
280
|
+
{/* 프리필은 defaultValue로 준다 — SSR HTML에 값이 실린다 */}
|
|
241
281
|
<input
|
|
242
282
|
type={type}
|
|
243
283
|
name={name}
|
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ProductCard as ProductCardData } from "@sayren/storefront-sdk";
|
|
2
|
+
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
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/home";
|
|
6
|
-
|
|
7
|
-
export function meta() {
|
|
8
|
-
return [{ title: "스토어" }, { name: "description", content: "지금 인기 있는 상품" }];
|
|
9
|
-
}
|
|
10
7
|
|
|
11
8
|
/**
|
|
12
9
|
* 홈 — 서버에서 상품을 먼저 받아 그린다(SSR). 클라이언트에서 다시 받지 않는다.
|
|
13
10
|
* 확장 지점: 배너·기획전 레일을 추가하려면 여기서 데이터를 더 불러 `Promise.all`에 넣는다.
|
|
14
11
|
*/
|
|
15
|
-
|
|
16
|
-
const api = apiFor(
|
|
12
|
+
const getHome = createServerFn({ method: "GET" }).handler(async () => {
|
|
13
|
+
const api = apiFor();
|
|
17
14
|
const [categories, recommended, latest] = await Promise.all([
|
|
18
15
|
api.catalog.listCategories(),
|
|
19
16
|
api.catalog.searchProducts({ sort: "recommend", size: 8 }),
|
|
20
17
|
api.catalog.searchProducts({ sort: "latest", size: 8 }),
|
|
21
18
|
]);
|
|
22
19
|
return { categories, recommended: recommended.contents, latest: latest.contents };
|
|
23
|
-
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const Route = createFileRoute("/")({
|
|
23
|
+
head: () => ({
|
|
24
|
+
meta: [{ title: "스토어" }, { name: "description", content: "지금 인기 있는 상품" }],
|
|
25
|
+
}),
|
|
26
|
+
loader: () => getHome(),
|
|
27
|
+
component: Home,
|
|
28
|
+
});
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
const { categories, recommended, latest } =
|
|
30
|
+
function Home() {
|
|
31
|
+
const { categories, recommended, latest } = Route.useLoaderData();
|
|
27
32
|
|
|
28
33
|
return (
|
|
29
34
|
<div className="space-y-10">
|
|
@@ -32,7 +37,8 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|
|
32
37
|
{categories.map((category) => (
|
|
33
38
|
<Link
|
|
34
39
|
key={category.categoryId}
|
|
35
|
-
to=
|
|
40
|
+
to="/products"
|
|
41
|
+
search={{ categoryId: category.categoryId }}
|
|
36
42
|
className="rounded-full bg-chip px-3 py-1.5 text-sm hover:bg-line"
|
|
37
43
|
>
|
|
38
44
|
{category.name}
|
|
@@ -54,7 +60,7 @@ function ProductSection({
|
|
|
54
60
|
}: {
|
|
55
61
|
listId: string;
|
|
56
62
|
title: string;
|
|
57
|
-
products:
|
|
63
|
+
products: ProductCardData[];
|
|
58
64
|
}) {
|
|
59
65
|
useTrack(
|
|
60
66
|
products.length
|