@sayren/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +361 -0
- package/package.json +42 -0
- package/template/.env +2 -0
- package/template/README.md +42 -0
- package/template/app/app.css +22 -0
- package/template/app/components/product-card.tsx +45 -0
- package/template/app/components/product-description.tsx +28 -0
- package/template/app/components/site-header.tsx +25 -0
- package/template/app/lib/api.server.ts +34 -0
- package/template/app/lib/api.ts +7 -0
- package/template/app/lib/cart-session.server.ts +18 -0
- package/template/app/lib/checkout-intent.ts +30 -0
- package/template/app/lib/config.server.ts +19 -0
- package/template/app/lib/config.test.ts +16 -0
- package/template/app/lib/config.ts +19 -0
- package/template/app/lib/format.ts +10 -0
- package/template/app/lib/payment-popup.ts +98 -0
- package/template/app/lib/session.server.ts +24 -0
- package/template/app/root.tsx +57 -0
- package/template/app/routes/cart.tsx +161 -0
- package/template/app/routes/checkout-complete.tsx +66 -0
- package/template/app/routes/checkout.tsx +251 -0
- package/template/app/routes/home.tsx +67 -0
- package/template/app/routes/login.tsx +52 -0
- package/template/app/routes/order-detail.tsx +76 -0
- package/template/app/routes/orders.tsx +48 -0
- package/template/app/routes/product-detail.tsx +153 -0
- package/template/app/routes/products.tsx +117 -0
- package/template/app/routes.ts +17 -0
- package/template/package.json +37 -0
- package/template/react-router.config.ts +6 -0
- package/template/tsconfig.json +16 -0
- package/template/vite.config.ts +7 -0
- package/template/vitest.config.ts +5 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Form, redirect, useNavigate } from "react-router";
|
|
3
|
+
import { browserApi } from "../lib/api";
|
|
4
|
+
import { apiFor } from "../lib/api.server";
|
|
5
|
+
import { readCartToken } from "../lib/cart-session.server";
|
|
6
|
+
import { checkoutIntentFrom } from "../lib/checkout-intent";
|
|
7
|
+
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
8
|
+
import { formatPrice } from "../lib/format";
|
|
9
|
+
import { createPaymentPopup } from "../lib/payment-popup";
|
|
10
|
+
import { readToken } from "../lib/session.server";
|
|
11
|
+
import type { Route } from "./+types/checkout";
|
|
12
|
+
|
|
13
|
+
export function meta() {
|
|
14
|
+
return [{ title: "주문서" }];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
18
|
+
const intent = checkoutIntentFrom(new URL(request.url).searchParams);
|
|
19
|
+
if (!intent) throw redirect("/cart");
|
|
20
|
+
|
|
21
|
+
const accessToken = await readToken(request);
|
|
22
|
+
const cartToken = await readCartToken(request);
|
|
23
|
+
const api = apiFor(request, { accessToken, cartToken });
|
|
24
|
+
// 주문서 세션은 진입할 때마다 새로 만든다 — 조회 API가 없고, 가격·재고를 매번 다시 계산한다
|
|
25
|
+
const [checkout, member] = await Promise.all([
|
|
26
|
+
api.checkout.create(intent),
|
|
27
|
+
accessToken ? api.member.me().catch(() => null) : null,
|
|
28
|
+
]);
|
|
29
|
+
return {
|
|
30
|
+
checkout,
|
|
31
|
+
member,
|
|
32
|
+
config: { apiBaseUrl: API_BASE_URL, storeCode: resolveStoreCode(request) },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 결제 요청은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다 */
|
|
37
|
+
export async function action({ request }: Route.ActionArgs) {
|
|
38
|
+
const form = await request.formData();
|
|
39
|
+
const checkoutId = String(form.get("checkoutId") ?? "");
|
|
40
|
+
const accessToken = await readToken(request);
|
|
41
|
+
const cartToken = await readCartToken(request);
|
|
42
|
+
const api = apiFor(request, { accessToken, cartToken });
|
|
43
|
+
|
|
44
|
+
const guestName = String(form.get("guestName") ?? "");
|
|
45
|
+
const payment = await api.checkout.requestPayment(checkoutId, {
|
|
46
|
+
shippingAddress: {
|
|
47
|
+
receiverName: String(form.get("receiverName") ?? ""),
|
|
48
|
+
phone: String(form.get("phone") ?? ""),
|
|
49
|
+
zipCode: String(form.get("zipCode") ?? ""),
|
|
50
|
+
address1: String(form.get("address1") ?? ""),
|
|
51
|
+
address2: String(form.get("address2") ?? "") || undefined,
|
|
52
|
+
deliveryMemo: String(form.get("deliveryMemo") ?? "") || undefined,
|
|
53
|
+
},
|
|
54
|
+
paymentMethod: "CARD",
|
|
55
|
+
guest: accessToken
|
|
56
|
+
? undefined
|
|
57
|
+
: {
|
|
58
|
+
name: guestName,
|
|
59
|
+
phone: String(form.get("phone") ?? ""),
|
|
60
|
+
email: String(form.get("email") ?? ""),
|
|
61
|
+
orderPassword: String(form.get("orderPassword") ?? ""),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return { payment };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function Checkout({ loaderData, actionData }: Route.ComponentProps) {
|
|
68
|
+
const { checkout, member, config } = loaderData;
|
|
69
|
+
const navigate = useNavigate();
|
|
70
|
+
const popupRef = useRef<ReturnType<typeof createPaymentPopup> | null>(null);
|
|
71
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
72
|
+
const [checking, setChecking] = useState(false);
|
|
73
|
+
|
|
74
|
+
// 결제 요청 응답이 오면 이미 열어 둔 팝업을 결제 주소로 보낸다
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const popupUrl = actionData?.payment?.pgParams?.popupUrl;
|
|
77
|
+
if (!popupUrl || !popupRef.current) return;
|
|
78
|
+
if (!popupRef.current.navigate(popupUrl)) {
|
|
79
|
+
setNotice("결제창이 열리지 않았어요. 팝업 차단을 풀고 다시 시도해주세요.");
|
|
80
|
+
}
|
|
81
|
+
}, [actionData]);
|
|
82
|
+
|
|
83
|
+
const handleSubmit = () => {
|
|
84
|
+
// 클릭 콜스택에서 동기적으로 연다 — 응답을 기다린 뒤 열면 차단된다
|
|
85
|
+
popupRef.current = createPaymentPopup({
|
|
86
|
+
onCompleted: (message) => {
|
|
87
|
+
if (message.orderId) {
|
|
88
|
+
void navigate(`/checkout/complete?orderId=${message.orderId}`);
|
|
89
|
+
} else {
|
|
90
|
+
setNotice("결제가 완료됐어요. 주문 내역에서 확인해주세요.");
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
onFailed: (message) => {
|
|
94
|
+
setNotice(message.message ?? "결제가 실패했어요. 다시 시도해주세요.");
|
|
95
|
+
},
|
|
96
|
+
onClosed: () => {
|
|
97
|
+
// 닫혔다고 실패로 단정하지 않는다 — 서버에 결제 상태를 물어본다
|
|
98
|
+
const paymentId = actionDataPaymentId(actionData);
|
|
99
|
+
if (!paymentId) return;
|
|
100
|
+
setChecking(true);
|
|
101
|
+
void pollPaymentStatus(config, paymentId).then((status) => {
|
|
102
|
+
setChecking(false);
|
|
103
|
+
if (status?.status === "completed" && status.orderId) {
|
|
104
|
+
void navigate(`/checkout/complete?orderId=${status.orderId}`);
|
|
105
|
+
} else {
|
|
106
|
+
setNotice("결제창이 닫혔어요. 결제를 마쳤다면 잠시 뒤 주문 내역에서 확인해주세요.");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
popupRef.current.open();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="grid gap-8 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:items-start">
|
|
116
|
+
<Form method="post" onSubmit={handleSubmit} className="space-y-6">
|
|
117
|
+
<input type="hidden" name="checkoutId" value={checkout.checkoutId} />
|
|
118
|
+
<section className="space-y-3">
|
|
119
|
+
<h2 className="font-bold text-lg">배송지</h2>
|
|
120
|
+
<Field label="수령인" name="receiverName" defaultValue={member?.name ?? ""} required />
|
|
121
|
+
<Field label="연락처" name="phone" placeholder="01012345678" required />
|
|
122
|
+
<Field label="우편번호" name="zipCode" placeholder="04524" required />
|
|
123
|
+
<Field label="주소" name="address1" required />
|
|
124
|
+
<Field label="상세 주소" name="address2" />
|
|
125
|
+
<Field label="배송 메모" name="deliveryMemo" />
|
|
126
|
+
</section>
|
|
127
|
+
|
|
128
|
+
{member ? null : (
|
|
129
|
+
<section className="space-y-3">
|
|
130
|
+
<h2 className="font-bold text-lg">비회원 주문</h2>
|
|
131
|
+
<Field label="주문자명" name="guestName" required />
|
|
132
|
+
<Field label="이메일" name="email" type="email" required />
|
|
133
|
+
<Field
|
|
134
|
+
label="주문 조회 비밀번호"
|
|
135
|
+
name="orderPassword"
|
|
136
|
+
type="password"
|
|
137
|
+
required
|
|
138
|
+
hint="주문 내역을 찾을 때 쓸 6자 이상 비밀번호예요"
|
|
139
|
+
/>
|
|
140
|
+
</section>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<button type="submit" className="h-12 w-full rounded-md bg-ink font-semibold text-white">
|
|
144
|
+
{formatPrice(checkout.amounts.totalAmount)} 결제하기
|
|
145
|
+
</button>
|
|
146
|
+
<p className="text-center text-muted text-xs">결제창은 새 팝업에서 열려요</p>
|
|
147
|
+
</Form>
|
|
148
|
+
|
|
149
|
+
<aside className="space-y-4 rounded-lg border border-line p-5">
|
|
150
|
+
<h2 className="font-bold text-lg">주문 상품</h2>
|
|
151
|
+
<ul className="space-y-3">
|
|
152
|
+
{checkout.items.map((item) => (
|
|
153
|
+
<li
|
|
154
|
+
key={`${item.productId}-${item.optionId}`}
|
|
155
|
+
className="flex justify-between gap-3 text-sm"
|
|
156
|
+
>
|
|
157
|
+
<span className="flex flex-col">
|
|
158
|
+
<span>{item.productName}</span>
|
|
159
|
+
{item.optionName ? (
|
|
160
|
+
<span className="text-muted text-xs">{item.optionName}</span>
|
|
161
|
+
) : null}
|
|
162
|
+
<span className="text-muted text-xs">수량 {item.quantity}개</span>
|
|
163
|
+
</span>
|
|
164
|
+
<span>{formatPrice(item.totalPrice)}</span>
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ul>
|
|
168
|
+
<dl className="space-y-1 border-line border-t pt-3 text-sm">
|
|
169
|
+
<Row label="상품 금액" value={formatPrice(checkout.amounts.productAmount)} />
|
|
170
|
+
<Row label="배송비" value={formatPrice(checkout.amounts.deliveryFee)} />
|
|
171
|
+
<div className="flex justify-between border-line border-t pt-2 font-bold">
|
|
172
|
+
<dt>총 결제금액</dt>
|
|
173
|
+
<dd>{formatPrice(checkout.amounts.totalAmount)}</dd>
|
|
174
|
+
</div>
|
|
175
|
+
</dl>
|
|
176
|
+
{checkout.testPayment ? (
|
|
177
|
+
<p className="rounded-md bg-chip px-3 py-2 text-xs">
|
|
178
|
+
테스트 결제예요. 실제로 돈이 오가지 않아요.
|
|
179
|
+
</p>
|
|
180
|
+
) : null}
|
|
181
|
+
{checking ? <p className="text-muted text-sm">결제 상태를 확인하고 있어요…</p> : null}
|
|
182
|
+
{notice ? <p className="rounded-md bg-chip px-3 py-2 text-sm">{notice}</p> : null}
|
|
183
|
+
</aside>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function actionDataPaymentId(actionData: Route.ComponentProps["actionData"]): string | null {
|
|
189
|
+
return actionData?.payment?.paymentId ?? null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 팝업이 결과 없이 닫힌 경우 — 서버 상태가 진실이다 */
|
|
193
|
+
async function pollPaymentStatus(
|
|
194
|
+
config: { apiBaseUrl: string; storeCode: string },
|
|
195
|
+
paymentId: string,
|
|
196
|
+
) {
|
|
197
|
+
const api = browserApi(config);
|
|
198
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
199
|
+
const status = await api.payments.getStatus(paymentId).catch(() => null);
|
|
200
|
+
if (status && status.status !== "pending" && status.status !== "processing") return status;
|
|
201
|
+
if (status?.status === "completed") return status;
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function Field({
|
|
208
|
+
label,
|
|
209
|
+
name,
|
|
210
|
+
type = "text",
|
|
211
|
+
defaultValue,
|
|
212
|
+
placeholder,
|
|
213
|
+
required,
|
|
214
|
+
hint,
|
|
215
|
+
}: {
|
|
216
|
+
label: string;
|
|
217
|
+
name: string;
|
|
218
|
+
type?: string;
|
|
219
|
+
defaultValue?: string;
|
|
220
|
+
placeholder?: string;
|
|
221
|
+
required?: boolean;
|
|
222
|
+
hint?: string;
|
|
223
|
+
}) {
|
|
224
|
+
return (
|
|
225
|
+
<label className="block space-y-1">
|
|
226
|
+
<span className="text-sm">
|
|
227
|
+
{label}
|
|
228
|
+
{required ? <span className="text-point"> *</span> : null}
|
|
229
|
+
</span>
|
|
230
|
+
{/* 프리필은 defaultValue로 준다 — register만 쓰면 SSR HTML에 값이 실리지 않는다 */}
|
|
231
|
+
<input
|
|
232
|
+
type={type}
|
|
233
|
+
name={name}
|
|
234
|
+
defaultValue={defaultValue}
|
|
235
|
+
placeholder={placeholder}
|
|
236
|
+
required={required}
|
|
237
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
238
|
+
/>
|
|
239
|
+
{hint ? <span className="text-muted text-xs">{hint}</span> : null}
|
|
240
|
+
</label>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function Row({ label, value }: { label: string; value: string }) {
|
|
245
|
+
return (
|
|
246
|
+
<div className="flex justify-between">
|
|
247
|
+
<dt className="text-muted">{label}</dt>
|
|
248
|
+
<dd>{value}</dd>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Link } from "react-router";
|
|
2
|
+
import { ProductCard } from "../components/product-card";
|
|
3
|
+
import { apiFor } from "../lib/api.server";
|
|
4
|
+
import type { Route } from "./+types/home";
|
|
5
|
+
|
|
6
|
+
export function meta() {
|
|
7
|
+
return [{ title: "스토어" }, { name: "description", content: "지금 인기 있는 상품" }];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 홈 — 서버에서 상품을 먼저 받아 그린다(SSR). 클라이언트에서 다시 받지 않는다.
|
|
12
|
+
* 확장 지점: 배너·기획전 레일을 추가하려면 여기서 데이터를 더 불러 `Promise.all`에 넣는다.
|
|
13
|
+
*/
|
|
14
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
15
|
+
const api = apiFor(request);
|
|
16
|
+
const [categories, recommended, latest] = await Promise.all([
|
|
17
|
+
api.catalog.listCategories(),
|
|
18
|
+
api.catalog.searchProducts({ sort: "recommend", size: 8 }),
|
|
19
|
+
api.catalog.searchProducts({ sort: "latest", size: 8 }),
|
|
20
|
+
]);
|
|
21
|
+
return { categories, recommended: recommended.contents, latest: latest.contents };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function Home({ loaderData }: Route.ComponentProps) {
|
|
25
|
+
const { categories, recommended, latest } = loaderData;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-10">
|
|
29
|
+
{categories.length ? (
|
|
30
|
+
<nav className="flex flex-wrap gap-2">
|
|
31
|
+
{categories.map((category) => (
|
|
32
|
+
<Link
|
|
33
|
+
key={category.categoryId}
|
|
34
|
+
to={`/products?categoryId=${category.categoryId}`}
|
|
35
|
+
className="rounded-full bg-chip px-3 py-1.5 text-sm hover:bg-line"
|
|
36
|
+
>
|
|
37
|
+
{category.name}
|
|
38
|
+
</Link>
|
|
39
|
+
))}
|
|
40
|
+
</nav>
|
|
41
|
+
) : null}
|
|
42
|
+
|
|
43
|
+
<ProductSection title="추천 상품" products={recommended} />
|
|
44
|
+
<ProductSection title="새로 들어왔어요" products={latest} />
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ProductSection({
|
|
50
|
+
title,
|
|
51
|
+
products,
|
|
52
|
+
}: {
|
|
53
|
+
title: string;
|
|
54
|
+
products: Awaited<ReturnType<typeof loader>>["recommended"];
|
|
55
|
+
}) {
|
|
56
|
+
if (!products.length) return null;
|
|
57
|
+
return (
|
|
58
|
+
<section className="space-y-4">
|
|
59
|
+
<h2 className="font-bold text-xl">{title}</h2>
|
|
60
|
+
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
61
|
+
{products.map((product) => (
|
|
62
|
+
<ProductCard key={product.productId} product={product} />
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Form, redirect } from "react-router";
|
|
2
|
+
import { apiFor } from "../lib/api.server";
|
|
3
|
+
import { sessionCookie } from "../lib/session.server";
|
|
4
|
+
import type { Route } from "./+types/login";
|
|
5
|
+
|
|
6
|
+
export function meta() {
|
|
7
|
+
return [{ title: "로그인" }];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function action({ request }: Route.ActionArgs) {
|
|
11
|
+
const form = await request.formData();
|
|
12
|
+
const api = apiFor(request);
|
|
13
|
+
try {
|
|
14
|
+
const session = await api.auth.login({
|
|
15
|
+
email: String(form.get("email") ?? ""),
|
|
16
|
+
password: String(form.get("password") ?? ""),
|
|
17
|
+
});
|
|
18
|
+
return redirect(String(form.get("redirectTo") ?? "/orders"), {
|
|
19
|
+
headers: { "Set-Cookie": await sessionCookie.serialize(session.accessToken) },
|
|
20
|
+
});
|
|
21
|
+
} catch {
|
|
22
|
+
return { error: "이메일이나 비밀번호가 맞지 않아요" };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function Login({ actionData }: Route.ComponentProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="mx-auto max-w-sm space-y-5 py-10">
|
|
29
|
+
<h1 className="font-bold text-2xl">로그인</h1>
|
|
30
|
+
<Form method="post" className="space-y-3">
|
|
31
|
+
<input
|
|
32
|
+
type="email"
|
|
33
|
+
name="email"
|
|
34
|
+
placeholder="이메일"
|
|
35
|
+
required
|
|
36
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
37
|
+
/>
|
|
38
|
+
<input
|
|
39
|
+
type="password"
|
|
40
|
+
name="password"
|
|
41
|
+
placeholder="비밀번호"
|
|
42
|
+
required
|
|
43
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
44
|
+
/>
|
|
45
|
+
{actionData?.error ? <p className="text-point text-sm">{actionData.error}</p> : null}
|
|
46
|
+
<button type="submit" className="h-12 w-full rounded-md bg-ink font-semibold text-white">
|
|
47
|
+
로그인
|
|
48
|
+
</button>
|
|
49
|
+
</Form>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Link, redirect } from "react-router";
|
|
2
|
+
import { apiFor } from "../lib/api.server";
|
|
3
|
+
import { formatDateTime, formatPrice } from "../lib/format";
|
|
4
|
+
import { readToken } from "../lib/session.server";
|
|
5
|
+
import type { Route } from "./+types/order-detail";
|
|
6
|
+
|
|
7
|
+
export function meta({ params }: Route.MetaArgs) {
|
|
8
|
+
return [{ title: `주문 ${params.orderId}` }];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function loader({ request, params }: Route.LoaderArgs) {
|
|
12
|
+
const accessToken = await readToken(request);
|
|
13
|
+
if (!accessToken) throw redirect(`/login?redirectTo=/orders/${params.orderId}`);
|
|
14
|
+
const api = apiFor(request, { accessToken });
|
|
15
|
+
return { order: await api.myOrders.get(params.orderId) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function OrderDetail({ loaderData }: Route.ComponentProps) {
|
|
19
|
+
const { order } = loaderData;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="space-y-6">
|
|
23
|
+
<div className="space-y-1">
|
|
24
|
+
<Link to="/orders" className="text-muted text-sm underline">
|
|
25
|
+
주문 내역
|
|
26
|
+
</Link>
|
|
27
|
+
<h1 className="font-bold text-2xl">주문 {order.orderId}</h1>
|
|
28
|
+
<p className="text-muted text-sm">{formatDateTime(order.orderedAt)}</p>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<ul className="divide-y divide-line border-line border-y">
|
|
32
|
+
{order.items.map((item) => (
|
|
33
|
+
<li key={item.orderItemId} className="flex items-center gap-4 py-4">
|
|
34
|
+
<img
|
|
35
|
+
src={item.thumbnailUrl}
|
|
36
|
+
alt=""
|
|
37
|
+
className="size-16 rounded-md bg-chip object-cover"
|
|
38
|
+
/>
|
|
39
|
+
<span className="flex flex-1 flex-col gap-1 text-sm">
|
|
40
|
+
<span>{item.productName}</span>
|
|
41
|
+
{item.optionName ? (
|
|
42
|
+
<span className="text-muted text-xs">{item.optionName}</span>
|
|
43
|
+
) : null}
|
|
44
|
+
<span className="text-muted text-xs">
|
|
45
|
+
{item.status} · 수량 {item.quantity}개
|
|
46
|
+
</span>
|
|
47
|
+
</span>
|
|
48
|
+
<span className="text-sm">{formatPrice(item.totalPrice)}</span>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
|
|
53
|
+
<dl className="space-y-1 text-sm">
|
|
54
|
+
<div className="flex justify-between border-line border-t pt-1 font-bold">
|
|
55
|
+
<dt>결제 금액</dt>
|
|
56
|
+
<dd>{formatPrice(order.payment.totalAmount)}</dd>
|
|
57
|
+
</div>
|
|
58
|
+
{order.payment.receiptUrl ? (
|
|
59
|
+
<div className="flex justify-between">
|
|
60
|
+
<dt className="text-muted">영수증</dt>
|
|
61
|
+
<dd>
|
|
62
|
+
<a
|
|
63
|
+
href={order.payment.receiptUrl}
|
|
64
|
+
className="underline"
|
|
65
|
+
target="_blank"
|
|
66
|
+
rel="noreferrer"
|
|
67
|
+
>
|
|
68
|
+
열기
|
|
69
|
+
</a>
|
|
70
|
+
</dd>
|
|
71
|
+
</div>
|
|
72
|
+
) : null}
|
|
73
|
+
</dl>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Link, redirect } from "react-router";
|
|
2
|
+
import { apiFor } from "../lib/api.server";
|
|
3
|
+
import { formatDateTime, formatPrice } from "../lib/format";
|
|
4
|
+
import { readToken } from "../lib/session.server";
|
|
5
|
+
import type { Route } from "./+types/orders";
|
|
6
|
+
|
|
7
|
+
export function meta() {
|
|
8
|
+
return [{ title: "주문 내역" }];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
12
|
+
const accessToken = await readToken(request);
|
|
13
|
+
if (!accessToken) throw redirect("/login?redirectTo=/orders");
|
|
14
|
+
const api = apiFor(request, { accessToken });
|
|
15
|
+
return { orders: await api.myOrders.list({ size: 20 }) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function Orders({ loaderData }: Route.ComponentProps) {
|
|
19
|
+
const { orders } = loaderData;
|
|
20
|
+
|
|
21
|
+
if (!orders.contents.length) {
|
|
22
|
+
return <p className="py-16 text-center text-muted">아직 주문이 없어요</p>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-5">
|
|
27
|
+
<h1 className="font-bold text-2xl">주문 내역</h1>
|
|
28
|
+
<ul className="space-y-3">
|
|
29
|
+
{orders.contents.map((order) => (
|
|
30
|
+
<li key={order.orderId}>
|
|
31
|
+
<Link
|
|
32
|
+
to={`/orders/${order.orderId}`}
|
|
33
|
+
className="flex items-center justify-between gap-4 rounded-lg border border-line p-4 hover:border-ink"
|
|
34
|
+
>
|
|
35
|
+
<span className="flex flex-col gap-1">
|
|
36
|
+
<span className="text-sm">{order.orderId}</span>
|
|
37
|
+
<span className="text-muted text-xs">{formatDateTime(order.orderedAt)}</span>
|
|
38
|
+
</span>
|
|
39
|
+
<span className="font-semibold text-sm">
|
|
40
|
+
{formatPrice(order.payment.totalAmount)}
|
|
41
|
+
</span>
|
|
42
|
+
</Link>
|
|
43
|
+
</li>
|
|
44
|
+
))}
|
|
45
|
+
</ul>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Form, redirect, useNavigation } from "react-router";
|
|
3
|
+
import { ProductDescription } from "../components/product-description";
|
|
4
|
+
import { apiFor } from "../lib/api.server";
|
|
5
|
+
import { cartCookie, readCartToken } from "../lib/cart-session.server";
|
|
6
|
+
import { formatPrice } from "../lib/format";
|
|
7
|
+
import { readToken } from "../lib/session.server";
|
|
8
|
+
import type { Route } from "./+types/product-detail";
|
|
9
|
+
|
|
10
|
+
export function meta({ loaderData }: Route.MetaArgs) {
|
|
11
|
+
return [{ title: loaderData ? loaderData.product.name : "상품" }];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function loader({ request, params }: Route.LoaderArgs) {
|
|
15
|
+
const api = apiFor(request);
|
|
16
|
+
const product = await api.catalog.getProduct(params.productId);
|
|
17
|
+
return { product };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 장바구니 담기·바로구매를 서버에서 처리한다. 비회원 장바구니 토큰은 서버가 새로 발급할 수 있어
|
|
22
|
+
* 응답 쿠키에 다시 심는다.
|
|
23
|
+
*/
|
|
24
|
+
export async function action({ request, params }: Route.ActionArgs) {
|
|
25
|
+
const form = await request.formData();
|
|
26
|
+
const intent = String(form.get("intent") ?? "cart");
|
|
27
|
+
const optionId = String(form.get("optionId") ?? "") || undefined;
|
|
28
|
+
const quantity = Number(form.get("quantity") ?? 1);
|
|
29
|
+
|
|
30
|
+
const accessToken = await readToken(request);
|
|
31
|
+
let cartToken = await readCartToken(request);
|
|
32
|
+
let issued: string | null = null;
|
|
33
|
+
const api = apiFor(request, {
|
|
34
|
+
accessToken,
|
|
35
|
+
cartToken,
|
|
36
|
+
onCartToken: (token) => {
|
|
37
|
+
issued = token;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await api.cart.addItem({ productId: params.productId, optionId, quantity });
|
|
42
|
+
cartToken = issued ?? cartToken;
|
|
43
|
+
return redirect("/cart", {
|
|
44
|
+
headers: cartToken ? { "Set-Cookie": await cartCookie.serialize(cartToken) } : undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function ProductDetail({ loaderData }: Route.ComponentProps) {
|
|
49
|
+
const { product } = loaderData;
|
|
50
|
+
const navigation = useNavigation();
|
|
51
|
+
const submitting = navigation.state !== "idle";
|
|
52
|
+
const hasOptions = product.optionGroups.length > 0;
|
|
53
|
+
const [quantity, setQuantity] = useState(1);
|
|
54
|
+
const [variantId, setVariantId] = useState(
|
|
55
|
+
product.variants.find((variant) => !variant.soldOut)?.variantId ?? "",
|
|
56
|
+
);
|
|
57
|
+
const variant = product.variants.find((v) => v.variantId === variantId);
|
|
58
|
+
const price =
|
|
59
|
+
(product.discountedPrice ?? product.salePrice) + (variant ? variant.additionalPrice : 0);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="grid gap-8 md:grid-cols-2">
|
|
63
|
+
<div className="space-y-3">
|
|
64
|
+
<img src={product.thumbnailUrl} alt="" className="w-full rounded-lg bg-chip" />
|
|
65
|
+
{product.images.slice(0, 4).map((image) => (
|
|
66
|
+
<img
|
|
67
|
+
key={image}
|
|
68
|
+
src={image}
|
|
69
|
+
alt=""
|
|
70
|
+
className="w-full rounded-lg bg-chip"
|
|
71
|
+
loading="lazy"
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="space-y-5">
|
|
77
|
+
<div className="space-y-2">
|
|
78
|
+
<h1 className="font-bold text-2xl">{product.name}</h1>
|
|
79
|
+
<p className="font-bold text-xl">{formatPrice(price)}</p>
|
|
80
|
+
<p className="text-muted text-sm">
|
|
81
|
+
{product.delivery.type === "FREE"
|
|
82
|
+
? "무료배송"
|
|
83
|
+
: `배송비 ${formatPrice(product.delivery.fee)}`}
|
|
84
|
+
{product.delivery.estimatedDays ? ` · ${product.delivery.estimatedDays}` : ""}
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<Form method="post" className="space-y-4">
|
|
89
|
+
{hasOptions ? (
|
|
90
|
+
<label className="block space-y-1">
|
|
91
|
+
<span className="text-sm">옵션</span>
|
|
92
|
+
<select
|
|
93
|
+
name="optionId"
|
|
94
|
+
value={variantId}
|
|
95
|
+
onChange={(event) => setVariantId(event.target.value)}
|
|
96
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
97
|
+
>
|
|
98
|
+
{product.variants.map((option) => (
|
|
99
|
+
<option key={option.variantId} value={option.variantId} disabled={option.soldOut}>
|
|
100
|
+
{option.name}
|
|
101
|
+
{option.soldOut ? " (품절)" : ""}
|
|
102
|
+
</option>
|
|
103
|
+
))}
|
|
104
|
+
</select>
|
|
105
|
+
</label>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
<label className="block space-y-1">
|
|
109
|
+
<span className="text-sm">수량</span>
|
|
110
|
+
<input
|
|
111
|
+
type="number"
|
|
112
|
+
name="quantity"
|
|
113
|
+
min={1}
|
|
114
|
+
max={999}
|
|
115
|
+
value={quantity}
|
|
116
|
+
onChange={(event) => setQuantity(Number(event.target.value) || 1)}
|
|
117
|
+
className="h-11 w-24 rounded-md border border-line px-3"
|
|
118
|
+
/>
|
|
119
|
+
</label>
|
|
120
|
+
|
|
121
|
+
<button
|
|
122
|
+
type="submit"
|
|
123
|
+
name="intent"
|
|
124
|
+
value="cart"
|
|
125
|
+
disabled={submitting || product.soldOut}
|
|
126
|
+
className="h-12 w-full rounded-md border border-ink font-semibold disabled:opacity-50"
|
|
127
|
+
>
|
|
128
|
+
장바구니
|
|
129
|
+
</button>
|
|
130
|
+
</Form>
|
|
131
|
+
|
|
132
|
+
{/* 바로구매는 주문서를 GET으로 연다 — 장바구니를 거치지 않는다 */}
|
|
133
|
+
<Form method="get" action="/checkout" className="space-y-0">
|
|
134
|
+
<input type="hidden" name="productId" value={product.productId} />
|
|
135
|
+
{hasOptions ? <input type="hidden" name="optionId" value={variantId} /> : null}
|
|
136
|
+
<input type="hidden" name="quantity" value={quantity} />
|
|
137
|
+
<button
|
|
138
|
+
type="submit"
|
|
139
|
+
disabled={submitting || product.soldOut}
|
|
140
|
+
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
141
|
+
>
|
|
142
|
+
바로구매
|
|
143
|
+
</button>
|
|
144
|
+
</Form>
|
|
145
|
+
|
|
146
|
+
<section className="space-y-2 border-line border-t pt-5">
|
|
147
|
+
<h2 className="font-semibold">상품 설명</h2>
|
|
148
|
+
<ProductDescription description={product.description ?? ""} />
|
|
149
|
+
</section>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|