@sayren/mcp 0.2.1 → 0.2.3
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/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +20 -1
- package/template/src/routes/account.tsx +103 -3
- package/template/src/routes/checkout.index.tsx +154 -11
- package/template/src/routes/login.tsx +4 -0
- package/template/src/routes/orders.$orderId.tsx +295 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sayren-mcp": "./dist/index.mjs"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
14
|
"zod": "^4.6.5",
|
|
15
|
-
"@sayren/
|
|
16
|
-
"@sayren/
|
|
15
|
+
"@sayren/storefront-sdk": "^0.11.0",
|
|
16
|
+
"@sayren/store-sdk": "^0.11.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# 스토어프론트 템플릿 (TanStack Router · TanStack Start SSR)
|
|
2
2
|
|
|
3
3
|
sayren MCP가 내려주는 스토어프론트 시작점이다. 구매 흐름 전체가 이미 붙어 있다.
|
|
4
|
-
홈, 목록·검색, 상세, 장바구니, 주문서·결제(결제수단 선택 → 결제창 → 결제 복귀), 완료, 로그인,
|
|
4
|
+
홈, 목록·검색, 상세, 장바구니, 주문서·결제(결제수단 선택 → 결제창 → 결제 복귀), 완료, 로그인,
|
|
5
|
+
주문 내역·주문 상세(취소·반품 신청)다.
|
|
5
6
|
|
|
6
7
|
## 시작하기
|
|
7
8
|
|
|
@@ -51,6 +52,14 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
51
52
|
| 헤더·전역 내비 | `src/components/site-header.tsx` |
|
|
52
53
|
| 상품 카드 | `src/components/product-card.tsx` |
|
|
53
54
|
| 화면 추가 | `src/routes/`에 파일 추가 |
|
|
55
|
+
| 약관 동의 | `src/routes/account.tsx`(동의 상태·재동의)·`src/routes/login.tsx`(소셜 가입 동의) |
|
|
56
|
+
|
|
57
|
+
소셜 로그인은 공급자 화면을 거치므로 구매자가 무엇에 동의했는지는 이 앱만 안다. 이 템플릿은 로그인 화면에서 동의를
|
|
58
|
+
받지 않고(기존 회원의 로그인까지 막히지 않게) **가입된 뒤 계정 화면에서 받는다** — 필수 동의 이력이 없는 구매자는
|
|
59
|
+
`GET /me`의 `reconsentRequired`가 `true`이고, 계정 화면의 약관 항목에서 동의하면 이력에 남는다.
|
|
60
|
+
가입 화면을 따로 두고 미리 받는 흐름을 쓰려면 `login.tsx`의 `auth.idp(provider, { redirectUri, agreements })`에
|
|
61
|
+
그 값을 넘긴다(필수 동의는 `terms`·`privacy`를 함께 보낸다). 서버가 로그인 흐름에 보관해 두고 계정을 만드는 것과
|
|
62
|
+
같은 트랜잭션에서 이력을 남긴다. 어느 쪽이든 동의하지 않은 구매자가 동의한 것으로 남지는 않는다.
|
|
54
63
|
|
|
55
64
|
## 건드리지 않는 편이 좋은 것
|
|
56
65
|
|
|
@@ -73,3 +82,13 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
73
82
|
- 주문서(`/checkout`)는 들어올 때마다 주문서 세션을 새로 만든다. 조회 API가 없고, 가격과 재고를
|
|
74
83
|
매번 다시 계산해야 하기 때문이다.
|
|
75
84
|
- 비회원 장바구니는 서버가 발급한 토큰으로만 찾는다. 쿠키를 지우면 담은 상품이 사라진다.
|
|
85
|
+
- 배송비는 배송 묶음마다 한 번 붙는다. 같은 출고지 + 같은 배송 정책인 상품이 한 묶음이고, 제주·도서산간 추가
|
|
86
|
+
배송비는 묶음마다 더하며 무료배송이어도 부과한다. 주문서를 만들 때는 배송지를 몰라 주문서의 `delivery.zipCode`가
|
|
87
|
+
null이고 추가 배송비가 0이다 — 주문서(`/checkout`)는 우편번호 입력을 마칠 때(blur) `quoteDelivery` 서버 함수로
|
|
88
|
+
`checkout.quoteDelivery(checkoutId, { zipCode })`를 불러 금액 요약을 갱신한다(주문서를 바꾸지 않는 읽기 계산이다).
|
|
89
|
+
**최종 금액은 결제 시작 응답의 `amounts`·`delivery`다** — 서버가 결제 시작이 보낸 배송지로 배송비를 다시 계산하므로
|
|
90
|
+
미리보기와 다를 수 있고, 화면은 그 값으로 덮어쓴다.
|
|
91
|
+
- 취소·반품은 수량 단위다. 주문 상세(`/orders/$orderId`)의 신청 수량 상한은 주문 상품의 `activeQuantity`
|
|
92
|
+
(남은 수량)이고, 접수 결과의 `expectedRefundAmount`는 수량 비율 상품 금액에서 반품 배송비를 뺀 값이다.
|
|
93
|
+
배송비 환불은 `deliveryFeeRefundAmount`로 따로 온다. 교환은 희망 옵션(`exchangeOptionId`)이 필요해 이 화면에
|
|
94
|
+
넣지 않았다.
|
|
@@ -26,20 +26,45 @@ const accountSearch = z.object({
|
|
|
26
26
|
const getAccount = createServerFn({ method: "GET" }).handler(async () => {
|
|
27
27
|
const accessToken = readToken();
|
|
28
28
|
if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/account" } });
|
|
29
|
-
const [me, identities, store] = await Promise.all([
|
|
29
|
+
const [me, identities, store, consents] = await Promise.all([
|
|
30
30
|
apiFor({ accessToken }).member.me(),
|
|
31
31
|
authFor().identities(accessToken),
|
|
32
32
|
apiFor().store.get(),
|
|
33
|
+
apiFor({ accessToken }).member.consents(),
|
|
33
34
|
]);
|
|
34
35
|
// 셀러가 켠 소셜 로그인만 연결할 수 있다
|
|
35
36
|
const providers = store.loginMethods.filter(
|
|
36
37
|
(method): method is SocialProvider => method !== "password",
|
|
37
38
|
);
|
|
38
|
-
return { me, identities, providers };
|
|
39
|
+
return { me, identities, providers, consents };
|
|
39
40
|
});
|
|
40
41
|
|
|
41
42
|
const providerInput = z.object({ provider: z.enum(["kakao", "naver", "google"]) });
|
|
42
43
|
|
|
44
|
+
const consentInput = z.object({
|
|
45
|
+
/** 개정된 필수 약관 재동의 */
|
|
46
|
+
reconsent: z.boolean().optional(),
|
|
47
|
+
/** 마케팅 수신 동의·철회 */
|
|
48
|
+
marketing: z.boolean().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** 재동의·마케팅 동의 변경 — 구매자 토큰은 서버에만 두므로 서버 함수에서 부른다 */
|
|
52
|
+
const changeConsents = createServerFn({ method: "POST" })
|
|
53
|
+
.validator(consentInput)
|
|
54
|
+
.handler(async ({ data }) => {
|
|
55
|
+
const accessToken = readToken();
|
|
56
|
+
if (!accessToken) return { error: "다시 로그인해 주세요" };
|
|
57
|
+
try {
|
|
58
|
+
await apiFor({ accessToken }).member.updateConsents({
|
|
59
|
+
...(data.reconsent ? { terms: true as const, privacy: true as const } : {}),
|
|
60
|
+
...(data.marketing === undefined ? {} : { marketing: data.marketing }),
|
|
61
|
+
});
|
|
62
|
+
return { error: null };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { error: accountErrorMessage(error) };
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
43
68
|
/**
|
|
44
69
|
* 소셜 계정 연결 시작 — 로그인과 같은 콜백(`/auth/callback`)으로 돌아오고, 쿠키의 `linkProvider`로 연결임을 안다.
|
|
45
70
|
* 연결은 흐름을 시작한 이 구매자에게만 된다(서버가 확인한다).
|
|
@@ -102,7 +127,7 @@ export const Route = createFileRoute("/account")({
|
|
|
102
127
|
});
|
|
103
128
|
|
|
104
129
|
function Account() {
|
|
105
|
-
const { me, identities, providers } = Route.useLoaderData();
|
|
130
|
+
const { me, identities, providers, consents } = Route.useLoaderData();
|
|
106
131
|
const { linked, error: callbackError } = Route.useSearch();
|
|
107
132
|
const router = useRouter();
|
|
108
133
|
const [pending, setPending] = useState(false);
|
|
@@ -113,6 +138,16 @@ function Account() {
|
|
|
113
138
|
? (ERROR_MESSAGE[callbackError] ?? "연결하지 못했어요. 다시 시도해 주세요")
|
|
114
139
|
: null);
|
|
115
140
|
|
|
141
|
+
const changeConsent = (data: { reconsent?: boolean; marketing?: boolean }) => {
|
|
142
|
+
setPending(true);
|
|
143
|
+
setActionError(null);
|
|
144
|
+
void changeConsents({ data }).then(async (result) => {
|
|
145
|
+
if (result.error) setActionError(result.error);
|
|
146
|
+
await router.invalidate();
|
|
147
|
+
setPending(false);
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
116
151
|
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
117
152
|
event.preventDefault();
|
|
118
153
|
const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
|
|
@@ -208,6 +243,71 @@ function Account() {
|
|
|
208
243
|
연결한 계정으로도 같은 회원으로 로그인해요. 마지막 로그인 수단은 해제할 수 없어요.
|
|
209
244
|
</p>
|
|
210
245
|
</section>
|
|
246
|
+
|
|
247
|
+
<section className="space-y-3">
|
|
248
|
+
<h2 className="font-bold text-lg">약관 동의</h2>
|
|
249
|
+
<ul className="divide-y divide-line rounded-lg border border-line">
|
|
250
|
+
<li className="flex items-center justify-between gap-3 px-4 py-3">
|
|
251
|
+
<span className="flex flex-col gap-0.5">
|
|
252
|
+
<span className="font-medium">이용약관 · 개인정보 수집이용</span>
|
|
253
|
+
<span className="text-muted text-xs">
|
|
254
|
+
{consents.reconsentRequired
|
|
255
|
+
? "약관이 개정됐어요. 다시 동의해 주세요"
|
|
256
|
+
: `동의 완료 · v${consents.terms.agreedVersion ?? "-"}`}
|
|
257
|
+
</span>
|
|
258
|
+
{/* 스토어가 약관 주소를 정했으면 열어 볼 수 있게 한다 */}
|
|
259
|
+
<span className="flex gap-2 text-xs">
|
|
260
|
+
{consents.terms.currentDocumentUrl ? (
|
|
261
|
+
<a
|
|
262
|
+
href={consents.terms.currentDocumentUrl}
|
|
263
|
+
target="_blank"
|
|
264
|
+
rel="noreferrer"
|
|
265
|
+
className="text-muted underline"
|
|
266
|
+
>
|
|
267
|
+
이용약관
|
|
268
|
+
</a>
|
|
269
|
+
) : null}
|
|
270
|
+
{consents.privacy.currentDocumentUrl ? (
|
|
271
|
+
<a
|
|
272
|
+
href={consents.privacy.currentDocumentUrl}
|
|
273
|
+
target="_blank"
|
|
274
|
+
rel="noreferrer"
|
|
275
|
+
className="text-muted underline"
|
|
276
|
+
>
|
|
277
|
+
개인정보 수집이용
|
|
278
|
+
</a>
|
|
279
|
+
) : null}
|
|
280
|
+
</span>
|
|
281
|
+
</span>
|
|
282
|
+
{consents.reconsentRequired ? (
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
disabled={pending}
|
|
286
|
+
onClick={() => changeConsent({ reconsent: true })}
|
|
287
|
+
className="h-9 rounded-md bg-ink px-3 font-semibold text-sm text-white disabled:opacity-50"
|
|
288
|
+
>
|
|
289
|
+
재동의
|
|
290
|
+
</button>
|
|
291
|
+
) : null}
|
|
292
|
+
</li>
|
|
293
|
+
<li className="flex items-center justify-between gap-3 px-4 py-3">
|
|
294
|
+
<span className="flex flex-col gap-0.5">
|
|
295
|
+
<span className="font-medium">마케팅 수신</span>
|
|
296
|
+
<span className="text-muted text-xs">
|
|
297
|
+
{consents.marketing.agreed ? "동의 중" : "받지 않음"}
|
|
298
|
+
</span>
|
|
299
|
+
</span>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
disabled={pending}
|
|
303
|
+
onClick={() => changeConsent({ marketing: !consents.marketing.agreed })}
|
|
304
|
+
className="h-9 rounded-md border border-line px-3 text-sm disabled:opacity-50"
|
|
305
|
+
>
|
|
306
|
+
{consents.marketing.agreed ? "수신 해제" : "수신 동의"}
|
|
307
|
+
</button>
|
|
308
|
+
</li>
|
|
309
|
+
</ul>
|
|
310
|
+
</section>
|
|
211
311
|
</div>
|
|
212
312
|
);
|
|
213
313
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ApiError,
|
|
3
|
+
type CheckoutDelivery,
|
|
4
|
+
cashReceiptAvailable,
|
|
5
|
+
cashReceiptRequestSchema,
|
|
6
|
+
type DeliveryQuoteResult,
|
|
3
7
|
paymentOptionKey,
|
|
4
8
|
paymentOptionSchema,
|
|
5
9
|
paymentStartSchema,
|
|
@@ -47,6 +51,23 @@ const createCheckout = createServerFn({ method: "POST" })
|
|
|
47
51
|
return { checkout, member, config };
|
|
48
52
|
});
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 배송비 미리보기 — 주문서를 만들 때는 배송지를 몰라 제주·도서산간 추가 배송비가 빠져 있다.
|
|
56
|
+
* 우편번호를 받으면 다시 계산해 보여 준다(주문서를 바꾸지 않는 읽기 계산이다).
|
|
57
|
+
* 결제 시작과 마찬가지로 서버 함수로 부른다 — 구매자 토큰이 브라우저 JS에 나가지 않는다.
|
|
58
|
+
*/
|
|
59
|
+
const quoteDelivery = createServerFn({ method: "POST" })
|
|
60
|
+
.validator(z.object({ checkoutId: z.string(), zipCode: z.string() }))
|
|
61
|
+
.handler(async ({ data }) => {
|
|
62
|
+
const api = apiFor({ accessToken: readToken(), cartToken: readCartToken() });
|
|
63
|
+
try {
|
|
64
|
+
const quote = await api.checkout.quoteDelivery(data.checkoutId, { zipCode: data.zipCode });
|
|
65
|
+
return { quote, error: null };
|
|
66
|
+
} catch {
|
|
67
|
+
return { quote: null, error: "배송비를 확인하지 못했어요. 우편번호를 다시 확인해주세요." };
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
50
71
|
const paymentForm = z.object({
|
|
51
72
|
checkoutId: z.string(),
|
|
52
73
|
option: paymentOptionSchema,
|
|
@@ -61,6 +82,8 @@ const paymentForm = z.object({
|
|
|
61
82
|
guestName: z.string(),
|
|
62
83
|
email: z.string(),
|
|
63
84
|
orderPassword: z.string(),
|
|
85
|
+
/** 현금영수증 신청 — 계좌이체·가상계좌에서 구매자가 켰을 때만 */
|
|
86
|
+
cashReceipt: cashReceiptRequestSchema.optional(),
|
|
64
87
|
});
|
|
65
88
|
|
|
66
89
|
/** 결제 시작은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다. 결제창 호출 값(공개 값)만 내려준다 */
|
|
@@ -92,6 +115,7 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
92
115
|
email: data.email,
|
|
93
116
|
orderPassword: data.orderPassword,
|
|
94
117
|
},
|
|
118
|
+
cashReceipt: data.cashReceipt,
|
|
95
119
|
});
|
|
96
120
|
// 결제 시작 값은 JSON으로 넘기고 브라우저에서 SDK 스키마로 다시 읽는다 — 서버 함수 직렬화 타입에 맞춘다
|
|
97
121
|
return { start: JSON.stringify(start), error: null };
|
|
@@ -109,6 +133,8 @@ function startErrorMessage(error: unknown): string {
|
|
|
109
133
|
return "결제창을 준비하지 못했어요. 다른 결제수단으로 시도해주세요.";
|
|
110
134
|
case "PAYMENT_NOT_CONFIGURED":
|
|
111
135
|
return "결제 수단을 준비하고 있어요. 잠시 후 다시 시도해주세요.";
|
|
136
|
+
case "CASH_RECEIPT_NOT_AVAILABLE":
|
|
137
|
+
return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있어요.";
|
|
112
138
|
case "RETURN_URL_NOT_ALLOWED":
|
|
113
139
|
return "이 쇼핑몰 주소에서는 결제할 수 없어요. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주세요.";
|
|
114
140
|
default:
|
|
@@ -131,6 +157,38 @@ function Checkout() {
|
|
|
131
157
|
const [submitting, setSubmitting] = useState(false);
|
|
132
158
|
const options = checkout.paymentOptions;
|
|
133
159
|
const [selected, setSelected] = useState(options[0] ? paymentOptionKey(options[0]) : "");
|
|
160
|
+
/**
|
|
161
|
+
* 배송지를 반영한 금액. 배송비 미리보기와 결제 시작 응답이 덮어쓴다 — 최종 금액은 결제 시작 응답의 `amounts`다
|
|
162
|
+
* (서버가 보낸 배송지로 배송비를 다시 계산한다).
|
|
163
|
+
*/
|
|
164
|
+
const [quote, setQuote] = useState<DeliveryQuoteResult | null>(null);
|
|
165
|
+
/** 같은 우편번호로 blur가 반복돼도 다시 부르지 않는다 */
|
|
166
|
+
const [quotedZip, setQuotedZip] = useState<string | null>(null);
|
|
167
|
+
const amounts = quote?.amounts ?? checkout.amounts;
|
|
168
|
+
const delivery = quote?.delivery ?? checkout.delivery;
|
|
169
|
+
|
|
170
|
+
// 디바운스 타이머 대신 blur에서 부른다 — 우편번호는 5자리를 한 번에 채우는 값이고, FormData 폼에
|
|
171
|
+
// 입력 상태를 따로 들지 않아도 된다
|
|
172
|
+
const handleZipBlur = (zipCode: string) => {
|
|
173
|
+
if (!/^[0-9]{5}$/.test(zipCode) || zipCode === quotedZip) return;
|
|
174
|
+
setQuotedZip(zipCode);
|
|
175
|
+
void quoteDelivery({ data: { checkoutId: checkout.checkoutId, zipCode } })
|
|
176
|
+
.then(({ quote: next, error }) => {
|
|
177
|
+
if (next) setQuote(next);
|
|
178
|
+
else {
|
|
179
|
+
setQuotedZip(null);
|
|
180
|
+
setNotice(error);
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
.catch(() => setQuotedZip(null));
|
|
184
|
+
};
|
|
185
|
+
const [cashReceiptOn, setCashReceiptOn] = useState(false);
|
|
186
|
+
const [cashReceiptType, setCashReceiptType] = useState<"INCOME_DEDUCTION" | "EXPENSE_PROOF">(
|
|
187
|
+
"INCOME_DEDUCTION",
|
|
188
|
+
);
|
|
189
|
+
const selectedOption = options.find((item) => paymentOptionKey(item) === selected);
|
|
190
|
+
// 현금영수증은 계좌이체·가상계좌에서만 받는다
|
|
191
|
+
const cashReceiptShown = !!selectedOption && cashReceiptAvailable(selectedOption.method);
|
|
134
192
|
|
|
135
193
|
const settle = (result: PaymentResult, back: string) => {
|
|
136
194
|
if (result.status === "COMPLETED") {
|
|
@@ -168,6 +226,18 @@ function Checkout() {
|
|
|
168
226
|
}
|
|
169
227
|
const form = new FormData(event.currentTarget);
|
|
170
228
|
const field = (name: string) => String(form.get(name) ?? "");
|
|
229
|
+
let cashReceipt: z.infer<typeof cashReceiptRequestSchema> | undefined;
|
|
230
|
+
if (cashReceiptShown && cashReceiptOn) {
|
|
231
|
+
const parsed = cashReceiptRequestSchema.safeParse({
|
|
232
|
+
type: cashReceiptType,
|
|
233
|
+
identityNumber: field("cashReceiptNumber"),
|
|
234
|
+
});
|
|
235
|
+
if (!parsed.success) {
|
|
236
|
+
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해주세요.");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
cashReceipt = parsed.data;
|
|
240
|
+
}
|
|
171
241
|
const back = window.location.pathname + window.location.search;
|
|
172
242
|
const payments = paymentsFor(config);
|
|
173
243
|
// 결제창은 클릭 시점에 열어야 팝업 차단에 걸리지 않는다 — 서버 함수를 기다리기 전에 빈 창을 먼저 연다
|
|
@@ -188,6 +258,7 @@ function Checkout() {
|
|
|
188
258
|
guestName: field("guestName"),
|
|
189
259
|
email: field("email"),
|
|
190
260
|
orderPassword: field("orderPassword"),
|
|
261
|
+
cashReceipt,
|
|
191
262
|
},
|
|
192
263
|
})
|
|
193
264
|
.then(async ({ start, error }) => {
|
|
@@ -196,13 +267,11 @@ function Checkout() {
|
|
|
196
267
|
setNotice(error);
|
|
197
268
|
return;
|
|
198
269
|
}
|
|
270
|
+
const paymentStart = paymentStartSchema.parse(JSON.parse(start));
|
|
271
|
+
// 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다. 화면에 보여 준 금액과 다를 수 있다
|
|
272
|
+
setQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });
|
|
199
273
|
// 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
|
|
200
|
-
settle(
|
|
201
|
-
await payments.open(paymentStartSchema.parse(JSON.parse(start)), {
|
|
202
|
-
window: paymentWindow,
|
|
203
|
-
}),
|
|
204
|
-
back,
|
|
205
|
-
);
|
|
274
|
+
settle(await payments.open(paymentStart, { window: paymentWindow }), back);
|
|
206
275
|
})
|
|
207
276
|
.catch(() => {
|
|
208
277
|
paymentWindow.close();
|
|
@@ -218,7 +287,14 @@ function Checkout() {
|
|
|
218
287
|
<h2 className="font-bold text-lg">배송지</h2>
|
|
219
288
|
<Field label="수령인" name="receiverName" defaultValue={member?.name ?? ""} required />
|
|
220
289
|
<Field label="연락처" name="phone" placeholder="01012345678" required />
|
|
221
|
-
<Field
|
|
290
|
+
<Field
|
|
291
|
+
label="우편번호"
|
|
292
|
+
name="zipCode"
|
|
293
|
+
placeholder="04524"
|
|
294
|
+
required
|
|
295
|
+
onBlur={handleZipBlur}
|
|
296
|
+
hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줘요"
|
|
297
|
+
/>
|
|
222
298
|
<Field label="주소" name="address1" required />
|
|
223
299
|
<Field label="상세 주소" name="address2" />
|
|
224
300
|
<Field label="배송 메모" name="deliveryMemo" />
|
|
@@ -272,11 +348,52 @@ function Checkout() {
|
|
|
272
348
|
)}
|
|
273
349
|
</fieldset>
|
|
274
350
|
|
|
351
|
+
{cashReceiptShown ? (
|
|
352
|
+
<fieldset className="space-y-3">
|
|
353
|
+
<legend className="mb-3 font-bold text-lg">현금영수증</legend>
|
|
354
|
+
<label className="flex items-center gap-2 text-sm">
|
|
355
|
+
<input
|
|
356
|
+
type="checkbox"
|
|
357
|
+
checked={cashReceiptOn}
|
|
358
|
+
onChange={(event) => setCashReceiptOn(event.target.checked)}
|
|
359
|
+
/>
|
|
360
|
+
현금영수증 신청
|
|
361
|
+
</label>
|
|
362
|
+
{cashReceiptOn ? (
|
|
363
|
+
<>
|
|
364
|
+
<label className="block space-y-1">
|
|
365
|
+
<span className="text-sm">용도</span>
|
|
366
|
+
<select
|
|
367
|
+
name="cashReceiptType"
|
|
368
|
+
value={cashReceiptType}
|
|
369
|
+
onChange={(event) =>
|
|
370
|
+
setCashReceiptType(event.target.value as typeof cashReceiptType)
|
|
371
|
+
}
|
|
372
|
+
className="w-full rounded-md border border-line px-3 py-2"
|
|
373
|
+
>
|
|
374
|
+
<option value="INCOME_DEDUCTION">소득공제</option>
|
|
375
|
+
<option value="EXPENSE_PROOF">지출증빙</option>
|
|
376
|
+
</select>
|
|
377
|
+
</label>
|
|
378
|
+
<Field
|
|
379
|
+
label={
|
|
380
|
+
cashReceiptType === "EXPENSE_PROOF"
|
|
381
|
+
? "사업자등록번호"
|
|
382
|
+
: "휴대폰 번호 또는 현금영수증카드 번호"
|
|
383
|
+
}
|
|
384
|
+
name="cashReceiptNumber"
|
|
385
|
+
required
|
|
386
|
+
/>
|
|
387
|
+
</>
|
|
388
|
+
) : null}
|
|
389
|
+
</fieldset>
|
|
390
|
+
) : null}
|
|
391
|
+
|
|
275
392
|
<SubmitButton
|
|
276
393
|
disabled={submitting || options.length === 0}
|
|
277
394
|
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
278
395
|
>
|
|
279
|
-
{formatPrice(
|
|
396
|
+
{formatPrice(amounts.totalAmount)} 결제하기
|
|
280
397
|
</SubmitButton>
|
|
281
398
|
</form>
|
|
282
399
|
|
|
@@ -300,13 +417,25 @@ function Checkout() {
|
|
|
300
417
|
))}
|
|
301
418
|
</ul>
|
|
302
419
|
<dl className="space-y-1 border-line border-t pt-3 text-sm">
|
|
303
|
-
<Row label="상품 금액" value={formatPrice(
|
|
304
|
-
|
|
420
|
+
<Row label="상품 금액" value={formatPrice(amounts.productAmount)} />
|
|
421
|
+
{/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다 더한다 */}
|
|
422
|
+
<Row label="기본 배송비" value={formatPrice(delivery.baseFee)} />
|
|
423
|
+
{delivery.remoteSurcharge > 0 ? (
|
|
424
|
+
<Row
|
|
425
|
+
label={`${delivery.remoteAreaLabel ?? "지역"} 추가 배송비`}
|
|
426
|
+
value={formatPrice(delivery.remoteSurcharge)}
|
|
427
|
+
/>
|
|
428
|
+
) : null}
|
|
305
429
|
<div className="flex justify-between border-line border-t pt-2 font-bold">
|
|
306
430
|
<dt>총 결제금액</dt>
|
|
307
|
-
<dd>{formatPrice(
|
|
431
|
+
<dd>{formatPrice(amounts.totalAmount)}</dd>
|
|
308
432
|
</div>
|
|
309
433
|
</dl>
|
|
434
|
+
{deliveryNotes(delivery).map((note) => (
|
|
435
|
+
<p key={note} className="text-muted text-xs">
|
|
436
|
+
{note}
|
|
437
|
+
</p>
|
|
438
|
+
))}
|
|
310
439
|
{checkout.testPayment ? (
|
|
311
440
|
<p className="rounded-md bg-chip px-3 py-2 text-xs">
|
|
312
441
|
테스트 결제예요. 실제로 돈이 오가지 않아요.
|
|
@@ -318,6 +447,17 @@ function Checkout() {
|
|
|
318
447
|
);
|
|
319
448
|
}
|
|
320
449
|
|
|
450
|
+
/** 배송비를 몇 번, 왜 부과했는지 알려 주는 안내 문구 */
|
|
451
|
+
function deliveryNotes(delivery: CheckoutDelivery): string[] {
|
|
452
|
+
const notes: string[] = [];
|
|
453
|
+
if (delivery.bundleCount >= 2) {
|
|
454
|
+
notes.push(`배송 묶음 ${delivery.bundleCount}건 — 묶음마다 배송비가 붙어요.`);
|
|
455
|
+
}
|
|
456
|
+
if (delivery.freeByThreshold) notes.push("무료배송 기준 금액을 넘겨 기본 배송비가 빠졌어요.");
|
|
457
|
+
if (delivery.zipCode === null) notes.push("배송지를 입력하면 배송비가 확정돼요.");
|
|
458
|
+
return notes;
|
|
459
|
+
}
|
|
460
|
+
|
|
321
461
|
function Field({
|
|
322
462
|
label,
|
|
323
463
|
name,
|
|
@@ -326,6 +466,7 @@ function Field({
|
|
|
326
466
|
placeholder,
|
|
327
467
|
required,
|
|
328
468
|
hint,
|
|
469
|
+
onBlur,
|
|
329
470
|
}: {
|
|
330
471
|
label: string;
|
|
331
472
|
name: string;
|
|
@@ -334,6 +475,7 @@ function Field({
|
|
|
334
475
|
placeholder?: string;
|
|
335
476
|
required?: boolean;
|
|
336
477
|
hint?: string;
|
|
478
|
+
onBlur?: (value: string) => void;
|
|
337
479
|
}) {
|
|
338
480
|
return (
|
|
339
481
|
<label className="block space-y-1">
|
|
@@ -348,6 +490,7 @@ function Field({
|
|
|
348
490
|
defaultValue={defaultValue}
|
|
349
491
|
placeholder={placeholder}
|
|
350
492
|
required={required}
|
|
493
|
+
onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined}
|
|
351
494
|
className="h-11 w-full rounded-md border border-line px-3"
|
|
352
495
|
/>
|
|
353
496
|
{hint ? <span className="text-muted text-xs">{hint}</span> : null}
|
|
@@ -26,6 +26,10 @@ const getLoginMethods = createServerFn({ method: "GET" }).handler(async () => {
|
|
|
26
26
|
/**
|
|
27
27
|
* 소셜 로그인 시작 — state·PKCE verifier를 쿠키에 두고 공급자 로그인 화면 주소를 돌려준다. 돌아오는 곳은
|
|
28
28
|
* `/auth/callback`이고, 이 앱의 주소가 콘솔의 스토어프론트 도메인에 등록돼 있어야 한다.
|
|
29
|
+
*
|
|
30
|
+
* 가입 화면에서 약관 동의를 미리 받는다면 `agreements: { terms: true, privacy: true, marketing }`을 함께 넘긴다.
|
|
31
|
+
* 이 템플릿은 로그인과 가입을 한 화면에서 받아(기존 회원의 로그인까지 막지 않으려고) 넘기지 않고,
|
|
32
|
+
* 가입된 뒤 계정 화면(`/account`)에서 받는다.
|
|
29
33
|
*/
|
|
30
34
|
const startSocial = createServerFn({ method: "POST" })
|
|
31
35
|
.validator(z.object({ provider: z.enum(["kakao", "naver", "google"]), redirectTo: z.unknown() }))
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ApiError,
|
|
3
|
+
type ClaimReason,
|
|
4
|
+
type ClaimType,
|
|
5
|
+
type CreateClaimResult,
|
|
6
|
+
claimReasonSchema,
|
|
7
|
+
claimTypeSchema,
|
|
8
|
+
type MyOrderItem,
|
|
9
|
+
} from "@sayren/storefront-sdk";
|
|
10
|
+
import { createFileRoute, Link, notFound, redirect, useRouter } from "@tanstack/react-router";
|
|
3
11
|
import { createServerFn } from "@tanstack/react-start";
|
|
12
|
+
import { useState } from "react";
|
|
4
13
|
import { z } from "zod";
|
|
5
14
|
import { ProductThumb } from "../components/product-thumb";
|
|
15
|
+
import { SubmitButton } from "../components/submit-button";
|
|
6
16
|
import { apiFor } from "../lib/api.server";
|
|
7
17
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
8
18
|
import { readToken } from "../lib/session.server";
|
|
@@ -22,14 +32,108 @@ const getOrder = createServerFn({ method: "GET" })
|
|
|
22
32
|
}
|
|
23
33
|
});
|
|
24
34
|
|
|
35
|
+
/**
|
|
36
|
+
* 취소·반품 접수 — 수량을 실으면 그 수량만 접수된다(생략하면 남은 수량 전체).
|
|
37
|
+
* 상한은 주문 상품의 `activeQuantity`이고 넘기면 서버가 400 `INVALID_QUANTITY`로 거절한다.
|
|
38
|
+
* 구매자 토큰은 서버에만 두므로 접수도 서버 함수에서 한다.
|
|
39
|
+
*/
|
|
40
|
+
const createClaim = createServerFn({ method: "POST" })
|
|
41
|
+
.validator(
|
|
42
|
+
z.object({
|
|
43
|
+
orderItemId: z.string(),
|
|
44
|
+
type: claimTypeSchema,
|
|
45
|
+
reason: claimReasonSchema,
|
|
46
|
+
quantity: z.number().int().min(1),
|
|
47
|
+
reasonDetail: z.string().max(1000).optional(),
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
.handler(async ({ data }) => {
|
|
51
|
+
const accessToken = readToken();
|
|
52
|
+
if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
|
|
53
|
+
const { orderItemId, ...body } = data;
|
|
54
|
+
try {
|
|
55
|
+
return {
|
|
56
|
+
claim: await apiFor({ accessToken }).myClaims.create(orderItemId, body),
|
|
57
|
+
error: null,
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return { claim: null, error: claimErrorMessage(error) };
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function claimErrorMessage(error: unknown): string {
|
|
65
|
+
if (!(error instanceof ApiError)) return "접수하지 못했어요. 잠시 후 다시 시도해주세요.";
|
|
66
|
+
switch (error.code) {
|
|
67
|
+
case "INVALID_QUANTITY":
|
|
68
|
+
return "남은 수량보다 많이 신청했어요. 수량을 확인해주세요.";
|
|
69
|
+
case "CLAIM_IN_PROGRESS":
|
|
70
|
+
return "이미 진행 중인 신청이 있어요.";
|
|
71
|
+
case "INVALID_STATUS":
|
|
72
|
+
return "지금은 신청할 수 없는 상태예요. 주문 상태를 확인해주세요.";
|
|
73
|
+
case "PERIOD_EXPIRED":
|
|
74
|
+
return "반품·교환 가능 기간이 지났어요.";
|
|
75
|
+
default:
|
|
76
|
+
return error.message || "접수하지 못했어요. 잠시 후 다시 시도해주세요.";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
25
80
|
export const Route = createFileRoute("/orders/$orderId")({
|
|
26
81
|
loader: ({ params }) => getOrder({ data: { orderId: params.orderId } }),
|
|
27
82
|
head: ({ params }) => ({ meta: [{ title: `주문 ${params.orderId}` }] }),
|
|
28
83
|
component: OrderDetail,
|
|
29
84
|
});
|
|
30
85
|
|
|
86
|
+
/**
|
|
87
|
+
* 상태별로 접수할 수 있는 신청 종류 — **서버 규칙과 같게 둔다.**
|
|
88
|
+
*
|
|
89
|
+
* 취소는 발송 전(`PAID`·`CONFIRMED`), 반품은 배송완료(`DELIVERED`) 뒤에만 된다. 발송 중(`DISPATCHED`·
|
|
90
|
+
* `DELIVERING`)에는 둘 다 안 되므로 버튼을 띄우지 않는다 — 띄우면 구매자가 눌러 봐야 409 `INVALID_STATUS`를 본다.
|
|
91
|
+
* 교환은 희망 옵션(`exchangeOptionId`)이 필수라 이 화면에서 다루지 않는다.
|
|
92
|
+
*/
|
|
93
|
+
function claimableTypes(status: MyOrderItem["status"]): ClaimType[] {
|
|
94
|
+
if (status === "PAID" || status === "CONFIRMED") return ["CANCEL"];
|
|
95
|
+
if (status === "DELIVERED") return ["RETURN"];
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const CLAIM_TYPE_LABELS: Record<ClaimType, string> = {
|
|
100
|
+
CANCEL: "취소",
|
|
101
|
+
RETURN: "반품",
|
|
102
|
+
EXCHANGE: "교환",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const CLAIM_REASON_LABELS: Record<ClaimReason, string> = {
|
|
106
|
+
CHANGE_OF_MIND: "단순 변심",
|
|
107
|
+
WRONG_ORDER: "주문 실수",
|
|
108
|
+
DEFECTIVE: "상품 불량",
|
|
109
|
+
WRONG_DELIVERY: "오배송",
|
|
110
|
+
DIFFERENT_FROM_PAGE: "상품 정보와 다름",
|
|
111
|
+
DELAY: "배송 지연",
|
|
112
|
+
};
|
|
113
|
+
|
|
31
114
|
function OrderDetail() {
|
|
32
115
|
const order = Route.useLoaderData();
|
|
116
|
+
const router = useRouter();
|
|
117
|
+
const [openItemId, setOpenItemId] = useState<string | null>(null);
|
|
118
|
+
const [pending, setPending] = useState(false);
|
|
119
|
+
const [error, setError] = useState<string | null>(null);
|
|
120
|
+
const [result, setResult] = useState<CreateClaimResult | null>(null);
|
|
121
|
+
|
|
122
|
+
const submit = (data: Parameters<typeof createClaim>[0]["data"]) => {
|
|
123
|
+
setPending(true);
|
|
124
|
+
setError(null);
|
|
125
|
+
createClaim({ data })
|
|
126
|
+
.then((response) => {
|
|
127
|
+
if (response.error) {
|
|
128
|
+
setError(response.error);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setResult(response.claim);
|
|
132
|
+
setOpenItemId(null);
|
|
133
|
+
return router.invalidate();
|
|
134
|
+
})
|
|
135
|
+
.finally(() => setPending(false));
|
|
136
|
+
};
|
|
33
137
|
|
|
34
138
|
return (
|
|
35
139
|
<div className="space-y-6">
|
|
@@ -41,23 +145,74 @@ function OrderDetail() {
|
|
|
41
145
|
<p className="text-muted text-sm">{formatDateTime(order.orderedAt)}</p>
|
|
42
146
|
</div>
|
|
43
147
|
|
|
148
|
+
{result ? (
|
|
149
|
+
<div className="space-y-1 rounded-md bg-chip p-4 text-sm">
|
|
150
|
+
<p className="font-semibold">신청이 접수됐어요 (수량 {result.quantity}개)</p>
|
|
151
|
+
{/* 상품 금액 환불은 수량 비율이고, 반품 배송비는 이미 빠져 있다. 배송비 환불은 따로 온다 */}
|
|
152
|
+
{result.expectedRefundAmount === null ? null : (
|
|
153
|
+
<p className="text-muted">
|
|
154
|
+
예상 환불 금액 {formatPrice(result.expectedRefundAmount)}
|
|
155
|
+
{result.claimDeliveryFee > 0
|
|
156
|
+
? ` (반품 배송비 ${formatPrice(result.claimDeliveryFee)} 차감)`
|
|
157
|
+
: null}
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
{result.deliveryFeeRefundAmount > 0 ? (
|
|
161
|
+
<p className="text-muted">
|
|
162
|
+
배송비 환불 예상 {formatPrice(result.deliveryFeeRefundAmount)}
|
|
163
|
+
</p>
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
166
|
+
) : null}
|
|
167
|
+
|
|
44
168
|
<ul className="divide-y divide-line border-line border-y">
|
|
45
169
|
{order.items.map((item) => (
|
|
46
|
-
<li key={item.orderItemId} className="
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<span>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
170
|
+
<li key={item.orderItemId} className="space-y-3 py-4">
|
|
171
|
+
<div className="flex items-center gap-4">
|
|
172
|
+
<ProductThumb
|
|
173
|
+
src={item.thumbnailUrl}
|
|
174
|
+
className="size-16 rounded-md bg-chip object-cover"
|
|
175
|
+
/>
|
|
176
|
+
<span className="flex flex-1 flex-col gap-1 text-sm">
|
|
177
|
+
<span>{item.productName}</span>
|
|
178
|
+
{item.optionName ? (
|
|
179
|
+
<span className="text-muted text-xs">{item.optionName}</span>
|
|
180
|
+
) : null}
|
|
181
|
+
<span className="text-muted text-xs">
|
|
182
|
+
{item.status} · 수량 {item.quantity}개
|
|
183
|
+
{item.canceledQuantity > 0
|
|
184
|
+
? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
|
|
185
|
+
: null}
|
|
186
|
+
</span>
|
|
58
187
|
</span>
|
|
59
|
-
|
|
60
|
-
|
|
188
|
+
<span className="text-sm">{formatPrice(item.totalPrice)}</span>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{claimableTypes(item.status).length > 0 &&
|
|
192
|
+
!item.claimStatus &&
|
|
193
|
+
item.activeQuantity > 0 ? (
|
|
194
|
+
openItemId === item.orderItemId ? (
|
|
195
|
+
<ClaimForm
|
|
196
|
+
item={item}
|
|
197
|
+
pending={pending}
|
|
198
|
+
error={error}
|
|
199
|
+
onSubmit={submit}
|
|
200
|
+
onCancel={() => setOpenItemId(null)}
|
|
201
|
+
/>
|
|
202
|
+
) : (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
onClick={() => {
|
|
206
|
+
setResult(null);
|
|
207
|
+
setError(null);
|
|
208
|
+
setOpenItemId(item.orderItemId);
|
|
209
|
+
}}
|
|
210
|
+
className="text-muted text-xs underline"
|
|
211
|
+
>
|
|
212
|
+
취소·반품 신청
|
|
213
|
+
</button>
|
|
214
|
+
)
|
|
215
|
+
) : null}
|
|
61
216
|
</li>
|
|
62
217
|
))}
|
|
63
218
|
</ul>
|
|
@@ -86,3 +241,127 @@ function OrderDetail() {
|
|
|
86
241
|
</div>
|
|
87
242
|
);
|
|
88
243
|
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 취소·반품 접수 폼. 수량 기본값은 남은 수량 전체이고 입력값은 `activeQuantity`까지다.
|
|
247
|
+
* 서버 렌더한 마크업에 값이 그대로 남아야 하므로 프리필은 `defaultValue`로 준다.
|
|
248
|
+
*/
|
|
249
|
+
function ClaimForm({
|
|
250
|
+
item,
|
|
251
|
+
pending,
|
|
252
|
+
error,
|
|
253
|
+
onSubmit,
|
|
254
|
+
onCancel,
|
|
255
|
+
}: {
|
|
256
|
+
item: MyOrderItem;
|
|
257
|
+
pending: boolean;
|
|
258
|
+
error: string | null;
|
|
259
|
+
onSubmit: (data: {
|
|
260
|
+
orderItemId: string;
|
|
261
|
+
type: ClaimType;
|
|
262
|
+
reason: ClaimReason;
|
|
263
|
+
quantity: number;
|
|
264
|
+
reasonDetail?: string;
|
|
265
|
+
}) => void;
|
|
266
|
+
onCancel: () => void;
|
|
267
|
+
}) {
|
|
268
|
+
const types = claimableTypes(item.status);
|
|
269
|
+
// 수량은 빈 칸·범위 밖을 폼에서 먼저 잡는다 — `Number("")`는 0이라 그대로 보내면 서버 400을 본다
|
|
270
|
+
const [quantityText, setQuantityText] = useState(String(item.activeQuantity));
|
|
271
|
+
const quantity = Number(quantityText);
|
|
272
|
+
const quantityError =
|
|
273
|
+
quantityText.trim() === "" || !Number.isInteger(quantity)
|
|
274
|
+
? "수량을 입력해주세요."
|
|
275
|
+
: quantity < 1 || quantity > item.activeQuantity
|
|
276
|
+
? `수량은 1개 이상 ${item.activeQuantity}개 이하로 입력해주세요.`
|
|
277
|
+
: null;
|
|
278
|
+
return (
|
|
279
|
+
<form
|
|
280
|
+
className="space-y-3 rounded-md bg-chip p-4"
|
|
281
|
+
onSubmit={(event) => {
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
if (quantityError) return;
|
|
284
|
+
const form = new FormData(event.currentTarget);
|
|
285
|
+
const reasonDetail = String(form.get("reasonDetail") ?? "").trim();
|
|
286
|
+
onSubmit({
|
|
287
|
+
orderItemId: item.orderItemId,
|
|
288
|
+
type: form.get("type") as ClaimType,
|
|
289
|
+
reason: form.get("reason") as ClaimReason,
|
|
290
|
+
quantity,
|
|
291
|
+
reasonDetail: reasonDetail || undefined,
|
|
292
|
+
});
|
|
293
|
+
}}
|
|
294
|
+
>
|
|
295
|
+
<div className="flex flex-wrap gap-3">
|
|
296
|
+
<label className="flex flex-col gap-1 text-xs">
|
|
297
|
+
<span className="text-muted">신청 종류</span>
|
|
298
|
+
<select
|
|
299
|
+
name="type"
|
|
300
|
+
defaultValue={types[0]}
|
|
301
|
+
className="h-9 rounded-md border border-line bg-white px-2 text-sm"
|
|
302
|
+
>
|
|
303
|
+
{types.map((type) => (
|
|
304
|
+
<option key={type} value={type}>
|
|
305
|
+
{CLAIM_TYPE_LABELS[type]}
|
|
306
|
+
</option>
|
|
307
|
+
))}
|
|
308
|
+
</select>
|
|
309
|
+
</label>
|
|
310
|
+
<label className="flex flex-col gap-1 text-xs">
|
|
311
|
+
<span className="text-muted">사유</span>
|
|
312
|
+
<select
|
|
313
|
+
name="reason"
|
|
314
|
+
defaultValue={claimReasonSchema.options[0]}
|
|
315
|
+
className="h-9 rounded-md border border-line bg-white px-2 text-sm"
|
|
316
|
+
>
|
|
317
|
+
{claimReasonSchema.options.map((reason) => (
|
|
318
|
+
<option key={reason} value={reason}>
|
|
319
|
+
{CLAIM_REASON_LABELS[reason]}
|
|
320
|
+
</option>
|
|
321
|
+
))}
|
|
322
|
+
</select>
|
|
323
|
+
</label>
|
|
324
|
+
<label className="flex flex-col gap-1 text-xs">
|
|
325
|
+
<span className="text-muted">수량 (남은 수량 {item.activeQuantity}개)</span>
|
|
326
|
+
<input
|
|
327
|
+
type="number"
|
|
328
|
+
name="quantity"
|
|
329
|
+
min={1}
|
|
330
|
+
max={item.activeQuantity}
|
|
331
|
+
step={1}
|
|
332
|
+
value={quantityText}
|
|
333
|
+
onChange={(event) => setQuantityText(event.target.value)}
|
|
334
|
+
aria-invalid={quantityError !== null}
|
|
335
|
+
className="h-9 w-24 rounded-md border border-line bg-white px-2 text-sm"
|
|
336
|
+
/>
|
|
337
|
+
</label>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{quantityError ? <p className="text-point text-xs">{quantityError}</p> : null}
|
|
341
|
+
|
|
342
|
+
<label className="flex flex-col gap-1 text-xs">
|
|
343
|
+
<span className="text-muted">상세 사유 (선택)</span>
|
|
344
|
+
<textarea
|
|
345
|
+
name="reasonDetail"
|
|
346
|
+
rows={2}
|
|
347
|
+
maxLength={1000}
|
|
348
|
+
className="rounded-md border border-line bg-white px-2 py-1 text-sm"
|
|
349
|
+
/>
|
|
350
|
+
</label>
|
|
351
|
+
|
|
352
|
+
{error ? <p className="text-point text-xs">{error}</p> : null}
|
|
353
|
+
|
|
354
|
+
<div className="flex items-center gap-3">
|
|
355
|
+
<SubmitButton
|
|
356
|
+
disabled={pending || quantityError !== null}
|
|
357
|
+
className="h-9 rounded-md bg-ink px-4 font-semibold text-sm text-white disabled:opacity-50"
|
|
358
|
+
>
|
|
359
|
+
신청하기
|
|
360
|
+
</SubmitButton>
|
|
361
|
+
<button type="button" onClick={onCancel} className="text-muted text-xs underline">
|
|
362
|
+
닫기
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
</form>
|
|
366
|
+
);
|
|
367
|
+
}
|