@sayren/mcp 0.6.0 → 0.8.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 +6 -4
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +32 -2
- package/template/package.json +6 -0
- package/template/src/components/fulfillment-codes.tsx +110 -0
- package/template/src/components/fulfillment-downloads.tsx +88 -0
- package/template/src/components/text-field.tsx +71 -0
- package/template/src/lib/catalog-queries.ts +47 -0
- package/template/src/lib/code-reveal.test.ts +30 -0
- package/template/src/lib/code-reveal.ts +32 -0
- package/template/src/lib/coupon-code.test.ts +19 -0
- package/template/src/lib/coupon-code.ts +60 -0
- package/template/src/lib/download.test.ts +41 -0
- package/template/src/lib/download.ts +41 -0
- package/template/src/lib/form-schemas.test.ts +144 -0
- package/template/src/lib/form-schemas.ts +131 -0
- package/template/src/lib/guest-order.test.ts +18 -0
- package/template/src/lib/guest-order.ts +13 -0
- package/template/src/lib/order-status.test.ts +29 -0
- package/template/src/lib/order-status.ts +28 -2
- package/template/src/lib/page-title.test.ts +19 -0
- package/template/src/lib/page-title.ts +23 -0
- package/template/src/lib/products-search.test.ts +25 -0
- package/template/src/lib/products-search.ts +49 -0
- package/template/src/router.tsx +26 -1
- package/template/src/routes/__root.tsx +13 -6
- package/template/src/routes/account.tsx +2 -1
- package/template/src/routes/auth.callback.ts +13 -9
- package/template/src/routes/cart.tsx +13 -6
- package/template/src/routes/checkout.complete.tsx +2 -1
- package/template/src/routes/checkout.index.tsx +441 -147
- package/template/src/routes/checkout.return.tsx +7 -1
- package/template/src/routes/guest-order.tsx +174 -72
- package/template/src/routes/login.tsx +2 -1
- package/template/src/routes/orders.$orderId.tsx +98 -3
- package/template/src/routes/orders.index.tsx +2 -1
- package/template/src/routes/products.$productId.tsx +4 -1
- package/template/src/routes/products.index.tsx +46 -47
- package/template/src/routes/signup.tsx +61 -90
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
1
2
|
import {
|
|
2
3
|
ApiError,
|
|
3
4
|
type CheckoutDelivery,
|
|
@@ -12,15 +13,30 @@ import type { PaymentResult } from "@sayren/storefront-sdk/payments";
|
|
|
12
13
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
|
13
14
|
import { createServerFn } from "@tanstack/react-start";
|
|
14
15
|
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
15
|
-
import {
|
|
16
|
+
import { useMemo, useRef, useState } from "react";
|
|
17
|
+
import { useForm } from "react-hook-form";
|
|
16
18
|
import { z } from "zod";
|
|
17
19
|
import { SubmitButton } from "../components/submit-button";
|
|
20
|
+
import { TextField } from "../components/text-field";
|
|
18
21
|
import { apiFor } from "../lib/api.server";
|
|
19
22
|
import { readCartToken } from "../lib/cart-session.server";
|
|
20
23
|
import { checkoutIntentFrom, checkoutSearch } from "../lib/checkout-intent";
|
|
21
24
|
import type { PublicConfig } from "../lib/config";
|
|
22
25
|
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
26
|
+
import {
|
|
27
|
+
type AppliedCoupon,
|
|
28
|
+
COUPON_CODE_PATTERN,
|
|
29
|
+
couponErrorMessage,
|
|
30
|
+
couponRejectMessage,
|
|
31
|
+
} from "../lib/coupon-code";
|
|
32
|
+
import {
|
|
33
|
+
type CheckoutForm,
|
|
34
|
+
type CheckoutFormInput,
|
|
35
|
+
checkoutFormSchema,
|
|
36
|
+
WITHDRAWAL_AGREEMENT_MESSAGE,
|
|
37
|
+
} from "../lib/form-schemas";
|
|
23
38
|
import { formatPrice } from "../lib/format";
|
|
39
|
+
import { pageTitle } from "../lib/page-title";
|
|
24
40
|
import { forgetPaymentOptions, rememberPaymentOptions } from "../lib/payment-options";
|
|
25
41
|
import { PAYMENT_RETURN_PATH, paymentsFor } from "../lib/payments";
|
|
26
42
|
import { readToken } from "../lib/session.server";
|
|
@@ -72,11 +88,52 @@ const quoteDelivery = createServerFn({ method: "POST" })
|
|
|
72
88
|
}
|
|
73
89
|
});
|
|
74
90
|
|
|
91
|
+
/**
|
|
92
|
+
* 쿠폰 코드 적용 — 금액 미리보기(`checkout.pricing`)로 적용 여부와 할인액을 확인한다. 주문서를 바꾸지 않고 쿠폰을 예약하지 않는다.
|
|
93
|
+
* 확정은 결제 시작이 같은 코드를 `coupons`로 보낼 때다. 구매자 토큰을 브라우저에 두지 않으려고 서버 함수로 부른다.
|
|
94
|
+
*/
|
|
95
|
+
const previewCoupon = createServerFn({ method: "POST" })
|
|
96
|
+
.validator(
|
|
97
|
+
z.object({
|
|
98
|
+
checkoutId: z.string(),
|
|
99
|
+
code: z.string().regex(COUPON_CODE_PATTERN),
|
|
100
|
+
zipCode: z.string(),
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
.handler(async ({ data }) => {
|
|
104
|
+
const api = apiFor({ accessToken: readToken(), cartToken: readCartToken() });
|
|
105
|
+
try {
|
|
106
|
+
const pricing = await api.checkout.pricing(data.checkoutId, {
|
|
107
|
+
coupons: [{ code: data.code }],
|
|
108
|
+
zipCode: /^[0-9]{5}$/.test(data.zipCode) ? data.zipCode : undefined,
|
|
109
|
+
});
|
|
110
|
+
const result = pricing.coupons[0];
|
|
111
|
+
if (!result?.applied) {
|
|
112
|
+
return { coupon: null, error: couponRejectMessage(result?.rejectReason ?? null) };
|
|
113
|
+
}
|
|
114
|
+
if (!pricing.payable)
|
|
115
|
+
return { coupon: null, error: couponErrorMessage("PAYMENT_AMOUNT_TOO_LOW") };
|
|
116
|
+
const coupon: AppliedCoupon = {
|
|
117
|
+
code: result.code ?? data.code,
|
|
118
|
+
name: result.name,
|
|
119
|
+
discountAmount: result.discountAmount,
|
|
120
|
+
};
|
|
121
|
+
return { coupon, error: null };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
coupon: null,
|
|
125
|
+
error: couponErrorMessage(error instanceof ApiError ? error.code : null),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
75
130
|
const paymentForm = z.object({
|
|
76
131
|
checkoutId: z.string(),
|
|
77
132
|
option: paymentOptionSchema,
|
|
78
133
|
/** 결제에 실패하면 돌아올 주문서 주소 */
|
|
79
134
|
back: z.string(),
|
|
135
|
+
/** 배송지를 보내는가 — 주문서의 `requiresShipping`. false면(이용권·방문 수령 등) 배송지를 싣지 않는다 */
|
|
136
|
+
requiresShipping: z.boolean(),
|
|
80
137
|
receiverName: z.string(),
|
|
81
138
|
phone: z.string(),
|
|
82
139
|
zipCode: z.string(),
|
|
@@ -88,6 +145,10 @@ const paymentForm = z.object({
|
|
|
88
145
|
orderPassword: z.string(),
|
|
89
146
|
/** 현금영수증 신청 — 계좌이체·가상계좌에서 구매자가 켰을 때만 */
|
|
90
147
|
cashReceipt: cashReceiptRequestSchema.optional(),
|
|
148
|
+
/** 적용한 쿠폰 코드 — 미리보기로 확인한 값 */
|
|
149
|
+
couponCode: z.string().regex(COUPON_CODE_PATTERN).optional(),
|
|
150
|
+
/** 청약철회 제한 동의 — 주문서의 `withdrawal.required`가 true일 때 구매자가 체크한 값 */
|
|
151
|
+
withdrawalAgreement: z.boolean().optional(),
|
|
91
152
|
});
|
|
92
153
|
|
|
93
154
|
/** 결제 시작은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다. 결제창 호출 값(공개 값)만 내려준다 */
|
|
@@ -103,14 +164,16 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
103
164
|
const start = await api.checkout.startPayment(data.checkoutId, {
|
|
104
165
|
option: data.option,
|
|
105
166
|
returnUrl: returnUrl.toString(),
|
|
106
|
-
shippingAddress:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
167
|
+
shippingAddress: data.requiresShipping
|
|
168
|
+
? {
|
|
169
|
+
receiverName: data.receiverName,
|
|
170
|
+
phone: data.phone,
|
|
171
|
+
zipCode: data.zipCode,
|
|
172
|
+
address1: data.address1,
|
|
173
|
+
address2: data.address2 || undefined,
|
|
174
|
+
deliveryMemo: data.deliveryMemo || undefined,
|
|
175
|
+
}
|
|
176
|
+
: undefined,
|
|
114
177
|
guest: accessToken
|
|
115
178
|
? undefined
|
|
116
179
|
: {
|
|
@@ -120,14 +183,33 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
120
183
|
orderPassword: data.orderPassword,
|
|
121
184
|
},
|
|
122
185
|
cashReceipt: data.cashReceipt,
|
|
186
|
+
...(data.couponCode ? { coupons: [{ code: data.couponCode }] } : {}),
|
|
187
|
+
// 제한 상품이 없는 주문은 보내지 않는다 — 빠지면 서버가 400 WITHDRAWAL_AGREEMENT_REQUIRED다
|
|
188
|
+
...(data.withdrawalAgreement ? { withdrawalAgreement: true } : {}),
|
|
123
189
|
});
|
|
124
190
|
// 결제 시작 값은 JSON으로 넘기고 브라우저에서 SDK 스키마로 다시 읽는다 — 서버 함수 직렬화 타입에 맞춘다
|
|
125
|
-
return { start: JSON.stringify(start), error: null };
|
|
191
|
+
return { start: JSON.stringify(start), error: null, couponFailed: false };
|
|
126
192
|
} catch (error) {
|
|
127
|
-
|
|
193
|
+
// 쿠폰 때문에 시작하지 못했으면 화면이 쿠폰을 풀고 쿠폰 칸에 안내한다 — 같은 쿠폰으로 다시 눌러도 같은 오류다
|
|
194
|
+
const couponFailed =
|
|
195
|
+
error instanceof ApiError &&
|
|
196
|
+
(COUPON_START_ERRORS as readonly string[]).includes(error.code);
|
|
197
|
+
return { start: null, error: startErrorMessage(error), couponFailed };
|
|
128
198
|
}
|
|
129
199
|
});
|
|
130
200
|
|
|
201
|
+
/** 결제 시작이 쿠폰 때문에 거절된 오류 코드 */
|
|
202
|
+
const COUPON_START_ERRORS = [
|
|
203
|
+
"COUPON_NOT_FOUND",
|
|
204
|
+
"COUPON_NOT_APPLICABLE",
|
|
205
|
+
"COUPON_CHANGED",
|
|
206
|
+
"COUPON_IN_USE",
|
|
207
|
+
"COUPON_LIMIT_REACHED",
|
|
208
|
+
"COUPON_EXHAUSTED",
|
|
209
|
+
"COUPONS_UNAVAILABLE",
|
|
210
|
+
"PAYMENT_AMOUNT_TOO_LOW",
|
|
211
|
+
] as const;
|
|
212
|
+
|
|
131
213
|
function startErrorMessage(error: unknown): string {
|
|
132
214
|
if (!(error instanceof ApiError))
|
|
133
215
|
return "결제를 시작하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
@@ -138,8 +220,24 @@ function startErrorMessage(error: unknown): string {
|
|
|
138
220
|
return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해 주십시오.";
|
|
139
221
|
case "PAYMENT_NOT_CONFIGURED":
|
|
140
222
|
return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해 주십시오.";
|
|
223
|
+
case "WITHDRAWAL_AGREEMENT_REQUIRED":
|
|
224
|
+
return WITHDRAWAL_AGREEMENT_MESSAGE;
|
|
141
225
|
case "CASH_RECEIPT_NOT_AVAILABLE":
|
|
142
226
|
return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있습니다.";
|
|
227
|
+
case "COUPON_NOT_APPLICABLE": {
|
|
228
|
+
// 적용하지 못한 이유가 오류에 실려 온다(`details.coupons[].reason`)
|
|
229
|
+
const rejected = (error.details?.coupons as Array<{ reason?: string }> | undefined)?.[0];
|
|
230
|
+
return couponRejectMessage(rejected?.reason ?? null);
|
|
231
|
+
}
|
|
232
|
+
case "TOO_MANY_REQUESTS":
|
|
233
|
+
case "COUPON_NOT_FOUND":
|
|
234
|
+
case "COUPON_CHANGED":
|
|
235
|
+
case "COUPON_IN_USE":
|
|
236
|
+
case "COUPON_LIMIT_REACHED":
|
|
237
|
+
case "COUPON_EXHAUSTED":
|
|
238
|
+
case "COUPONS_UNAVAILABLE":
|
|
239
|
+
case "PAYMENT_AMOUNT_TOO_LOW":
|
|
240
|
+
return couponErrorMessage(error.code);
|
|
143
241
|
case "RETURN_URL_NOT_ALLOWED":
|
|
144
242
|
return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해 주십시오.";
|
|
145
243
|
default:
|
|
@@ -151,7 +249,7 @@ export const Route = createFileRoute("/checkout/")({
|
|
|
151
249
|
validateSearch: checkoutSearch,
|
|
152
250
|
loaderDeps: ({ search }) => search,
|
|
153
251
|
loader: ({ deps, location }) => createCheckout({ data: { search: deps, back: location.href } }),
|
|
154
|
-
head: () => ({ meta: [{ title: "주문서" }] }),
|
|
252
|
+
head: ({ matches }) => ({ meta: [{ title: pageTitle(matches, "주문서") }] }),
|
|
155
253
|
component: Checkout,
|
|
156
254
|
});
|
|
157
255
|
|
|
@@ -161,41 +259,124 @@ function Checkout() {
|
|
|
161
259
|
const [notice, setNotice] = useState<string | null>(null);
|
|
162
260
|
const [submitting, setSubmitting] = useState(false);
|
|
163
261
|
const options = checkout.paymentOptions;
|
|
164
|
-
|
|
262
|
+
/** 배송이 필요한 주문인가 — false면(이용권·방문 수령 등만 담은 주문) 배송지·배송비를 보이지 않는다 */
|
|
263
|
+
const shipping = checkout.requiresShipping;
|
|
264
|
+
/** 청약철회 제한 상품(제공이 시작되면 취소·반품할 수 없는 파일·코드·이용권)이 있는가 — 결제 전에 안내하고 동의를 받는다 */
|
|
265
|
+
const withdrawal = checkout.withdrawal;
|
|
266
|
+
const defaultOptionKey = options[0] ? paymentOptionKey(options[0]) : "";
|
|
267
|
+
/**
|
|
268
|
+
* 폼 스키마는 주문서마다 한 번 만든다 — 비회원 여부와 현금영수증을 받을 수 있는 결제 옵션이 주문서에서 정해진다.
|
|
269
|
+
* 결제 시작 직전의 동기 검사와 `zodResolver`가 같은 스키마를 쓴다.
|
|
270
|
+
*/
|
|
271
|
+
const schema = useMemo(
|
|
272
|
+
() =>
|
|
273
|
+
checkoutFormSchema({
|
|
274
|
+
guest: !member,
|
|
275
|
+
shipping,
|
|
276
|
+
withdrawal: withdrawal.required,
|
|
277
|
+
cashReceiptOptionKeys: new Set(
|
|
278
|
+
options.filter((option) => cashReceiptAvailable(option.method)).map(paymentOptionKey),
|
|
279
|
+
),
|
|
280
|
+
}),
|
|
281
|
+
[member, options, shipping, withdrawal.required],
|
|
282
|
+
);
|
|
283
|
+
const form = useForm<CheckoutFormInput, unknown, CheckoutForm>({
|
|
284
|
+
resolver: zodResolver(schema),
|
|
285
|
+
// 입력 칸의 기본값은 비워 둔다 — 하이드레이션 전에 입력한 값을 그대로 읽는다
|
|
286
|
+
defaultValues: {
|
|
287
|
+
paymentOption: defaultOptionKey,
|
|
288
|
+
cashReceiptOn: false,
|
|
289
|
+
cashReceiptType: "INCOME_DEDUCTION",
|
|
290
|
+
withdrawalAgreed: false,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const { errors } = form.formState;
|
|
294
|
+
const selected = form.watch("paymentOption");
|
|
295
|
+
const cashReceiptOn = form.watch("cashReceiptOn");
|
|
296
|
+
const cashReceiptType = form.watch("cashReceiptType");
|
|
165
297
|
/**
|
|
166
298
|
* 배송지를 반영한 금액. 배송비 미리보기와 결제 시작 응답이 덮어쓴다 — 최종 금액은 결제 시작 응답의 `amounts`다
|
|
167
299
|
* (서버가 보낸 배송지로 배송비를 다시 계산한다).
|
|
168
300
|
*/
|
|
169
301
|
const [quote, setQuote] = useState<DeliveryQuoteResult | null>(null);
|
|
302
|
+
/** 결제 시작 응답이 준 확정 금액인가 — 이미 쿠폰 할인이 들어 있어 화면에서 다시 빼지 않는다 */
|
|
303
|
+
const [quoteConfirmed, setQuoteConfirmed] = useState(false);
|
|
304
|
+
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
|
|
305
|
+
/** 쿠폰 칸은 비제어 입력이다 — 하이드레이션 전에 입력한 값도 남는다. 쿠폰을 풀면 이 값으로 칸을 다시 채운다 */
|
|
306
|
+
const couponInputRef = useRef<HTMLInputElement>(null);
|
|
307
|
+
const [couponDraft, setCouponDraft] = useState("");
|
|
308
|
+
const [couponError, setCouponError] = useState<string | null>(null);
|
|
309
|
+
const [couponBusy, setCouponBusy] = useState(false);
|
|
310
|
+
/**
|
|
311
|
+
* 팝업에서 취소·실패한 결제. 입력이 그대로면 새 결제를 만들지 않고 이 결제를 다른 옵션으로 다시 시도한다(`payments.retry`) —
|
|
312
|
+
* 새 결제를 만들면 앞 결제가 결제 시간(10분) 동안 쿠폰을 잡고 있어 쿠폰 한도에 걸린다.
|
|
313
|
+
*/
|
|
314
|
+
const [pending, setPending] = useState<{ paymentId: string; signature: string } | null>(null);
|
|
170
315
|
/** 같은 우편번호로 blur가 반복돼도 다시 부르지 않는다 */
|
|
171
316
|
const [quotedZip, setQuotedZip] = useState<string | null>(null);
|
|
172
|
-
const
|
|
317
|
+
const baseAmounts = quote?.amounts ?? checkout.amounts;
|
|
173
318
|
const delivery = quote?.delivery ?? checkout.delivery;
|
|
319
|
+
// 총액은 할인 전 금액에서 매번 계산한다. 결제 시작 응답(확정)이면 그 할인을, 아니면 미리보기 할인을 뺀다 —
|
|
320
|
+
// 쿠폰 할인은 배송지와 무관하다(배송비 쿠폰 없음)
|
|
321
|
+
const couponDiscount = coupon
|
|
322
|
+
? quoteConfirmed
|
|
323
|
+
? baseAmounts.couponDiscountAmount
|
|
324
|
+
: coupon.discountAmount
|
|
325
|
+
: 0;
|
|
326
|
+
const deliveryDiscount = quoteConfirmed ? baseAmounts.deliveryDiscountAmount : 0;
|
|
327
|
+
const totalAmount =
|
|
328
|
+
baseAmounts.productAmount + baseAmounts.deliveryFee - couponDiscount - deliveryDiscount;
|
|
329
|
+
|
|
330
|
+
/** 쿠폰을 푼다 — 코드는 칸에 되돌린다 */
|
|
331
|
+
const releaseCoupon = (code: string | null) => {
|
|
332
|
+
setCouponDraft(code ?? "");
|
|
333
|
+
setCoupon(null);
|
|
334
|
+
setQuoteConfirmed(false);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const applyCoupon = () => {
|
|
338
|
+
if (couponBusy) return;
|
|
339
|
+
const code = (couponInputRef.current?.value ?? "").trim();
|
|
340
|
+
if (!COUPON_CODE_PATTERN.test(code)) {
|
|
341
|
+
setCouponError("쿠폰 코드는 영문·숫자·-·_ 6~30자입니다.");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
setCouponBusy(true);
|
|
345
|
+
setCouponError(null);
|
|
346
|
+
void previewCoupon({
|
|
347
|
+
data: { checkoutId: checkout.checkoutId, code, zipCode: form.getValues("zipCode") ?? "" },
|
|
348
|
+
})
|
|
349
|
+
.then(({ coupon: next, error }) => {
|
|
350
|
+
setCouponDraft(code);
|
|
351
|
+
setCoupon(next);
|
|
352
|
+
setQuoteConfirmed(false);
|
|
353
|
+
setCouponError(error);
|
|
354
|
+
})
|
|
355
|
+
.catch(() => setCouponError(couponErrorMessage(null)))
|
|
356
|
+
.finally(() => setCouponBusy(false));
|
|
357
|
+
};
|
|
174
358
|
|
|
175
|
-
// 디바운스 타이머 대신 blur에서 부른다 — 우편번호는 5자리를 한 번에 채우는
|
|
176
|
-
// 입력 상태를 따로 들지 않아도 된다
|
|
359
|
+
// 디바운스 타이머 대신 blur에서 부른다 — 우편번호는 5자리를 한 번에 채우는 값이다
|
|
177
360
|
const handleZipBlur = (zipCode: string) => {
|
|
178
361
|
if (!/^[0-9]{5}$/.test(zipCode) || zipCode === quotedZip) return;
|
|
179
362
|
setQuotedZip(zipCode);
|
|
180
363
|
void quoteDelivery({ data: { checkoutId: checkout.checkoutId, zipCode } })
|
|
181
364
|
.then(({ quote: next, error }) => {
|
|
182
|
-
if (next)
|
|
183
|
-
|
|
365
|
+
if (next) {
|
|
366
|
+
setQuote(next);
|
|
367
|
+
setQuoteConfirmed(false);
|
|
368
|
+
} else {
|
|
184
369
|
setQuotedZip(null);
|
|
185
370
|
setNotice(error);
|
|
186
371
|
}
|
|
187
372
|
})
|
|
188
373
|
.catch(() => setQuotedZip(null));
|
|
189
374
|
};
|
|
190
|
-
const [cashReceiptOn, setCashReceiptOn] = useState(false);
|
|
191
|
-
const [cashReceiptType, setCashReceiptType] = useState<"INCOME_DEDUCTION" | "EXPENSE_PROOF">(
|
|
192
|
-
"INCOME_DEDUCTION",
|
|
193
|
-
);
|
|
194
375
|
const selectedOption = options.find((item) => paymentOptionKey(item) === selected);
|
|
195
376
|
// 현금영수증은 계좌이체·가상계좌에서만 받는다
|
|
196
377
|
const cashReceiptShown = !!selectedOption && cashReceiptAvailable(selectedOption.method);
|
|
197
378
|
|
|
198
|
-
const settle = (result: PaymentResult, back: string) => {
|
|
379
|
+
const settle = (result: PaymentResult, back: string, signature: string) => {
|
|
199
380
|
if (result.status === "COMPLETED") {
|
|
200
381
|
forgetPaymentOptions(result.paymentId);
|
|
201
382
|
void navigate({
|
|
@@ -214,6 +395,8 @@ function Checkout() {
|
|
|
214
395
|
});
|
|
215
396
|
return;
|
|
216
397
|
}
|
|
398
|
+
// 같은 입력으로 다시 누르면 이 결제를 다른 옵션으로 이어 간다
|
|
399
|
+
if (result.status !== "INVALID") setPending({ paymentId: result.paymentId, signature });
|
|
217
400
|
setNotice(
|
|
218
401
|
result.status === "CANCELED"
|
|
219
402
|
? "결제를 취소했습니다. 다른 결제수단으로 다시 시도해 주십시오."
|
|
@@ -223,63 +406,104 @@ function Checkout() {
|
|
|
223
406
|
);
|
|
224
407
|
};
|
|
225
408
|
|
|
409
|
+
/**
|
|
410
|
+
* 결제창은 클릭에서 동기로 열어야 팝업 차단에 걸리지 않는다. `form.handleSubmit`은 검증을 기다린 뒤 부르므로
|
|
411
|
+
* 여기서는 같은 스키마로 먼저 동기 검사하고, 통과하면 바로 빈 창을 연다. 실패하면 `handleSubmit`으로 칸마다
|
|
412
|
+
* 오류를 보이고 첫 오류 칸으로 옮긴다.
|
|
413
|
+
*/
|
|
226
414
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
227
415
|
event.preventDefault();
|
|
228
|
-
const
|
|
416
|
+
const parsed = schema.safeParse(form.getValues());
|
|
417
|
+
if (!parsed.success) {
|
|
418
|
+
void form.handleSubmit(() => undefined)();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const values = parsed.data;
|
|
422
|
+
const option = options.find((item) => paymentOptionKey(item) === values.paymentOption);
|
|
229
423
|
if (!option) {
|
|
230
424
|
setNotice("결제수단을 골라 주십시오.");
|
|
231
425
|
return;
|
|
232
426
|
}
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const parsed = cashReceiptRequestSchema.safeParse({
|
|
238
|
-
type: cashReceiptType,
|
|
239
|
-
identityNumber: field("cashReceiptNumber"),
|
|
240
|
-
});
|
|
241
|
-
if (!parsed.success) {
|
|
242
|
-
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해 주십시오.");
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
cashReceipt = parsed.data;
|
|
246
|
-
}
|
|
427
|
+
const cashReceipt =
|
|
428
|
+
cashReceiptAvailable(option.method) && values.cashReceiptOn
|
|
429
|
+
? { type: values.cashReceiptType, identityNumber: values.cashReceiptNumber ?? "" }
|
|
430
|
+
: undefined;
|
|
247
431
|
const back = window.location.pathname + window.location.search;
|
|
248
432
|
const payments = paymentsFor(config);
|
|
249
433
|
// 결제창은 클릭 시점에 열어야 팝업 차단에 걸리지 않는다 — 서버 함수를 기다리기 전에 빈 창을 먼저 연다
|
|
250
434
|
const paymentWindow = payments.prepareWindow();
|
|
251
435
|
setSubmitting(true);
|
|
252
436
|
setNotice(null);
|
|
437
|
+
// 결제 금액을 정하는 입력 — 앞 결제와 같으면 그 결제를 이어 간다
|
|
438
|
+
const signature = JSON.stringify([
|
|
439
|
+
values.receiverName ?? "",
|
|
440
|
+
values.phone ?? "",
|
|
441
|
+
values.zipCode ?? "",
|
|
442
|
+
values.address1 ?? "",
|
|
443
|
+
values.address2 ?? "",
|
|
444
|
+
values.deliveryMemo ?? "",
|
|
445
|
+
values.guestName ?? "",
|
|
446
|
+
values.email ?? "",
|
|
447
|
+
values.orderPassword ?? "",
|
|
448
|
+
coupon?.code ?? null,
|
|
449
|
+
]);
|
|
450
|
+
if (pending && pending.signature === signature) {
|
|
451
|
+
void payments
|
|
452
|
+
.retry(pending.paymentId, { option, cashReceipt, window: paymentWindow })
|
|
453
|
+
.then((result) => settle(result, back, signature))
|
|
454
|
+
.catch((error: unknown) => {
|
|
455
|
+
paymentWindow.close();
|
|
456
|
+
// 이어 갈 수 없는 결제다(시간 지남 등) — 다음 클릭은 새 결제를 만든다
|
|
457
|
+
setPending(null);
|
|
458
|
+
setNotice(
|
|
459
|
+
error instanceof ApiError && error.code === "PAYMENT_EXPIRED"
|
|
460
|
+
? "결제 시간이 지났습니다. 결제하기를 다시 눌러 주십시오."
|
|
461
|
+
: "결제를 다시 시작하지 못했습니다. 결제하기를 다시 눌러 주십시오.",
|
|
462
|
+
);
|
|
463
|
+
})
|
|
464
|
+
.finally(() => setSubmitting(false));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
setPending(null);
|
|
253
468
|
void startPayment({
|
|
254
469
|
data: {
|
|
255
470
|
checkoutId: checkout.checkoutId,
|
|
256
471
|
option,
|
|
257
472
|
back,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
473
|
+
requiresShipping: shipping,
|
|
474
|
+
receiverName: values.receiverName ?? "",
|
|
475
|
+
phone: values.phone ?? "",
|
|
476
|
+
zipCode: values.zipCode ?? "",
|
|
477
|
+
address1: values.address1 ?? "",
|
|
478
|
+
address2: values.address2 ?? "",
|
|
479
|
+
deliveryMemo: values.deliveryMemo ?? "",
|
|
480
|
+
guestName: values.guestName ?? "",
|
|
481
|
+
email: values.email ?? "",
|
|
482
|
+
orderPassword: values.orderPassword ?? "",
|
|
267
483
|
cashReceipt,
|
|
484
|
+
couponCode: coupon?.code,
|
|
485
|
+
withdrawalAgreement: withdrawal.required ? values.withdrawalAgreed === true : undefined,
|
|
268
486
|
},
|
|
269
487
|
})
|
|
270
|
-
.then(async ({ start, error }) => {
|
|
488
|
+
.then(async ({ start, error, couponFailed }) => {
|
|
271
489
|
if (!start) {
|
|
272
490
|
paymentWindow.close();
|
|
273
|
-
|
|
491
|
+
if (couponFailed && coupon) {
|
|
492
|
+
releaseCoupon(coupon.code);
|
|
493
|
+
setCouponError(error);
|
|
494
|
+
} else {
|
|
495
|
+
setNotice(error);
|
|
496
|
+
}
|
|
274
497
|
return;
|
|
275
498
|
}
|
|
276
499
|
const paymentStart = paymentStartSchema.parse(JSON.parse(start));
|
|
277
500
|
// 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다. 화면에 보여 준 금액과 다를 수 있다
|
|
278
501
|
setQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });
|
|
502
|
+
setQuoteConfirmed(true);
|
|
279
503
|
// 리다이렉트 결제가 실패해 복귀 화면으로 오면 같은 결제를 다른 옵션으로 다시 시도한다 — 그때 쓸 목록
|
|
280
504
|
rememberPaymentOptions(paymentStart.paymentId, options);
|
|
281
505
|
// 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
|
|
282
|
-
settle(await payments.open(paymentStart, { window: paymentWindow }), back);
|
|
506
|
+
settle(await payments.open(paymentStart, { window: paymentWindow }), back, signature);
|
|
283
507
|
})
|
|
284
508
|
.catch(() => {
|
|
285
509
|
paymentWindow.close();
|
|
@@ -291,31 +515,86 @@ function Checkout() {
|
|
|
291
515
|
return (
|
|
292
516
|
<div className="grid gap-8 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:items-start">
|
|
293
517
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
<
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
518
|
+
{/* 배송이 필요 없는 주문(이용권·방문 수령 등)은 배송지를 받지 않는다 */}
|
|
519
|
+
{shipping ? (
|
|
520
|
+
<section className="space-y-3">
|
|
521
|
+
<h2 className="font-bold text-lg">배송지</h2>
|
|
522
|
+
<TextField
|
|
523
|
+
label="수령인"
|
|
524
|
+
registration={form.register("receiverName")}
|
|
525
|
+
error={errors.receiverName?.message}
|
|
526
|
+
defaultValue={member?.name ?? ""}
|
|
527
|
+
required
|
|
528
|
+
/>
|
|
529
|
+
<TextField
|
|
530
|
+
label="연락처"
|
|
531
|
+
registration={form.register("phone")}
|
|
532
|
+
error={errors.phone?.message}
|
|
533
|
+
type="tel"
|
|
534
|
+
placeholder="01012345678"
|
|
535
|
+
required
|
|
536
|
+
/>
|
|
537
|
+
<TextField
|
|
538
|
+
label="우편번호"
|
|
539
|
+
registration={form.register("zipCode", {
|
|
540
|
+
onBlur: (event: React.FocusEvent<HTMLInputElement>) =>
|
|
541
|
+
handleZipBlur(event.target.value.trim()),
|
|
542
|
+
})}
|
|
543
|
+
error={errors.zipCode?.message}
|
|
544
|
+
placeholder="04524"
|
|
545
|
+
required
|
|
546
|
+
hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줍니다"
|
|
547
|
+
/>
|
|
548
|
+
<TextField
|
|
549
|
+
label="주소"
|
|
550
|
+
registration={form.register("address1")}
|
|
551
|
+
error={errors.address1?.message}
|
|
552
|
+
required
|
|
553
|
+
/>
|
|
554
|
+
<TextField
|
|
555
|
+
label="상세 주소"
|
|
556
|
+
registration={form.register("address2")}
|
|
557
|
+
error={errors.address2?.message}
|
|
558
|
+
/>
|
|
559
|
+
<TextField
|
|
560
|
+
label="배송 메모"
|
|
561
|
+
registration={form.register("deliveryMemo")}
|
|
562
|
+
error={errors.deliveryMemo?.message}
|
|
563
|
+
/>
|
|
564
|
+
</section>
|
|
565
|
+
) : null}
|
|
310
566
|
|
|
311
567
|
{member ? null : (
|
|
312
568
|
<section className="space-y-3">
|
|
313
569
|
<h2 className="font-bold text-lg">비회원 주문</h2>
|
|
314
|
-
<
|
|
315
|
-
|
|
316
|
-
|
|
570
|
+
<TextField
|
|
571
|
+
label="주문자명"
|
|
572
|
+
registration={form.register("guestName")}
|
|
573
|
+
error={errors.guestName?.message}
|
|
574
|
+
required
|
|
575
|
+
/>
|
|
576
|
+
{/* 배송지가 없는 주문은 주문자 연락처를 여기서 받는다(배송지 칸의 연락처가 없다) */}
|
|
577
|
+
{shipping ? null : (
|
|
578
|
+
<TextField
|
|
579
|
+
label="연락처"
|
|
580
|
+
registration={form.register("phone")}
|
|
581
|
+
error={errors.phone?.message}
|
|
582
|
+
type="tel"
|
|
583
|
+
placeholder="01012345678"
|
|
584
|
+
required
|
|
585
|
+
/>
|
|
586
|
+
)}
|
|
587
|
+
<TextField
|
|
588
|
+
label="이메일"
|
|
589
|
+
registration={form.register("email")}
|
|
590
|
+
error={errors.email?.message}
|
|
591
|
+
type="email"
|
|
592
|
+
required
|
|
593
|
+
/>
|
|
594
|
+
<TextField
|
|
317
595
|
label="주문 조회 비밀번호"
|
|
318
|
-
|
|
596
|
+
registration={form.register("orderPassword")}
|
|
597
|
+
error={errors.orderPassword?.message}
|
|
319
598
|
type="password"
|
|
320
599
|
required
|
|
321
600
|
hint="주문 내역을 찾을 때 쓸 6자 이상 비밀번호입니다"
|
|
@@ -339,12 +618,12 @@ function Checkout() {
|
|
|
339
618
|
}`}
|
|
340
619
|
>
|
|
341
620
|
<span className="flex items-center gap-2">
|
|
621
|
+
{/* 서버 HTML에도 첫 옵션이 선택돼 있도록 defaultChecked를 함께 준다 */}
|
|
342
622
|
<input
|
|
343
623
|
type="radio"
|
|
344
|
-
|
|
624
|
+
{...form.register("paymentOption")}
|
|
345
625
|
value={key}
|
|
346
|
-
|
|
347
|
-
onChange={() => setSelected(key)}
|
|
626
|
+
defaultChecked={key === defaultOptionKey}
|
|
348
627
|
/>
|
|
349
628
|
<span className="font-medium">{option.label}</span>
|
|
350
629
|
</span>
|
|
@@ -354,17 +633,16 @@ function Checkout() {
|
|
|
354
633
|
})}
|
|
355
634
|
</div>
|
|
356
635
|
)}
|
|
636
|
+
{errors.paymentOption ? (
|
|
637
|
+
<p className="text-point text-xs">{errors.paymentOption.message}</p>
|
|
638
|
+
) : null}
|
|
357
639
|
</fieldset>
|
|
358
640
|
|
|
359
641
|
{cashReceiptShown ? (
|
|
360
642
|
<fieldset className="space-y-3">
|
|
361
643
|
<legend className="mb-3 font-bold text-lg">현금영수증</legend>
|
|
362
644
|
<label className="flex items-center gap-2 text-sm">
|
|
363
|
-
<input
|
|
364
|
-
type="checkbox"
|
|
365
|
-
checked={cashReceiptOn}
|
|
366
|
-
onChange={(event) => setCashReceiptOn(event.target.checked)}
|
|
367
|
-
/>
|
|
645
|
+
<input type="checkbox" {...form.register("cashReceiptOn")} />
|
|
368
646
|
현금영수증 신청
|
|
369
647
|
</label>
|
|
370
648
|
{cashReceiptOn ? (
|
|
@@ -372,24 +650,21 @@ function Checkout() {
|
|
|
372
650
|
<label className="block space-y-1">
|
|
373
651
|
<span className="text-sm">용도</span>
|
|
374
652
|
<select
|
|
375
|
-
|
|
376
|
-
value={cashReceiptType}
|
|
377
|
-
onChange={(event) =>
|
|
378
|
-
setCashReceiptType(event.target.value as typeof cashReceiptType)
|
|
379
|
-
}
|
|
653
|
+
{...form.register("cashReceiptType")}
|
|
380
654
|
className="w-full rounded-md border border-line px-3 py-2"
|
|
381
655
|
>
|
|
382
656
|
<option value="INCOME_DEDUCTION">소득공제</option>
|
|
383
657
|
<option value="EXPENSE_PROOF">지출증빙</option>
|
|
384
658
|
</select>
|
|
385
659
|
</label>
|
|
386
|
-
<
|
|
660
|
+
<TextField
|
|
387
661
|
label={
|
|
388
662
|
cashReceiptType === "EXPENSE_PROOF"
|
|
389
663
|
? "사업자등록번호"
|
|
390
664
|
: "휴대폰 번호 또는 현금영수증카드 번호"
|
|
391
665
|
}
|
|
392
|
-
|
|
666
|
+
registration={form.register("cashReceiptNumber")}
|
|
667
|
+
error={errors.cashReceiptNumber?.message}
|
|
393
668
|
required
|
|
394
669
|
/>
|
|
395
670
|
</>
|
|
@@ -397,11 +672,80 @@ function Checkout() {
|
|
|
397
672
|
</fieldset>
|
|
398
673
|
) : null}
|
|
399
674
|
|
|
675
|
+
<section className="space-y-3">
|
|
676
|
+
<h2 className="font-bold text-lg">쿠폰</h2>
|
|
677
|
+
{coupon ? (
|
|
678
|
+
<div className="flex items-center justify-between gap-3 rounded-md border border-line px-3 py-2 text-sm">
|
|
679
|
+
<span className="flex flex-col">
|
|
680
|
+
<span className="font-medium">{coupon.name}</span>
|
|
681
|
+
<span className="text-muted text-xs">
|
|
682
|
+
{coupon.code} · {formatPrice(coupon.discountAmount)} 할인
|
|
683
|
+
</span>
|
|
684
|
+
</span>
|
|
685
|
+
<button
|
|
686
|
+
type="button"
|
|
687
|
+
className="text-muted text-sm underline"
|
|
688
|
+
onClick={() => releaseCoupon(coupon.code)}
|
|
689
|
+
>
|
|
690
|
+
쿠폰 빼기
|
|
691
|
+
</button>
|
|
692
|
+
</div>
|
|
693
|
+
) : (
|
|
694
|
+
<div className="flex gap-2">
|
|
695
|
+
{/* 결제 폼 안이라 Enter가 결제를 시작하지 않게 막고 쿠폰만 적용한다 */}
|
|
696
|
+
<input
|
|
697
|
+
ref={couponInputRef}
|
|
698
|
+
defaultValue={couponDraft}
|
|
699
|
+
onKeyDown={(event) => {
|
|
700
|
+
if (event.key !== "Enter") return;
|
|
701
|
+
event.preventDefault();
|
|
702
|
+
applyCoupon();
|
|
703
|
+
}}
|
|
704
|
+
aria-label="쿠폰 코드"
|
|
705
|
+
placeholder="쿠폰 코드"
|
|
706
|
+
autoComplete="off"
|
|
707
|
+
className="h-11 min-w-0 flex-1 rounded-md border border-line px-3 uppercase"
|
|
708
|
+
/>
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
disabled={couponBusy}
|
|
712
|
+
onClick={applyCoupon}
|
|
713
|
+
className="h-11 rounded-md border border-ink px-4 font-medium text-sm disabled:opacity-50"
|
|
714
|
+
>
|
|
715
|
+
적용
|
|
716
|
+
</button>
|
|
717
|
+
</div>
|
|
718
|
+
)}
|
|
719
|
+
{couponError ? (
|
|
720
|
+
<p role="alert" className="text-point text-xs">
|
|
721
|
+
{couponError}
|
|
722
|
+
</p>
|
|
723
|
+
) : null}
|
|
724
|
+
</section>
|
|
725
|
+
|
|
726
|
+
{withdrawal.required ? (
|
|
727
|
+
<section className="space-y-3">
|
|
728
|
+
<h2 className="font-bold text-lg">청약철회 제한 안내</h2>
|
|
729
|
+
<p className="whitespace-pre-line rounded-md bg-chip px-3 py-2 text-sm">
|
|
730
|
+
{withdrawal.notice}
|
|
731
|
+
</p>
|
|
732
|
+
<label className="flex items-start gap-2 text-sm">
|
|
733
|
+
<input type="checkbox" className="mt-1" {...form.register("withdrawalAgreed")} />
|
|
734
|
+
<span>위 청약철회 제한 안내를 확인했으며 동의합니다</span>
|
|
735
|
+
</label>
|
|
736
|
+
{errors.withdrawalAgreed ? (
|
|
737
|
+
<p role="alert" className="text-point text-xs">
|
|
738
|
+
{errors.withdrawalAgreed.message}
|
|
739
|
+
</p>
|
|
740
|
+
) : null}
|
|
741
|
+
</section>
|
|
742
|
+
) : null}
|
|
743
|
+
|
|
400
744
|
<SubmitButton
|
|
401
|
-
disabled={submitting || options.length === 0}
|
|
745
|
+
disabled={submitting || couponBusy || options.length === 0}
|
|
402
746
|
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
403
747
|
>
|
|
404
|
-
{formatPrice(
|
|
748
|
+
{formatPrice(totalAmount)} 결제하기
|
|
405
749
|
</SubmitButton>
|
|
406
750
|
</form>
|
|
407
751
|
|
|
@@ -425,21 +769,25 @@ function Checkout() {
|
|
|
425
769
|
))}
|
|
426
770
|
</ul>
|
|
427
771
|
<dl className="space-y-1 border-line border-t pt-3 text-sm">
|
|
428
|
-
<Row label="상품 금액" value={formatPrice(
|
|
429
|
-
{/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다
|
|
430
|
-
|
|
431
|
-
{delivery.
|
|
772
|
+
<Row label="상품 금액" value={formatPrice(baseAmounts.productAmount)} />
|
|
773
|
+
{/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다 더한다.
|
|
774
|
+
배송이 필요 없는 주문은 배송비가 없다 */}
|
|
775
|
+
{shipping ? <Row label="기본 배송비" value={formatPrice(delivery.baseFee)} /> : null}
|
|
776
|
+
{shipping && delivery.remoteSurcharge > 0 ? (
|
|
432
777
|
<Row
|
|
433
778
|
label={`${delivery.remoteAreaLabel ?? "지역"} 추가 배송비`}
|
|
434
779
|
value={formatPrice(delivery.remoteSurcharge)}
|
|
435
780
|
/>
|
|
436
781
|
) : null}
|
|
782
|
+
{couponDiscount > 0 ? (
|
|
783
|
+
<Row label="쿠폰 할인" value={`−${formatPrice(couponDiscount)}`} />
|
|
784
|
+
) : null}
|
|
437
785
|
<div className="flex justify-between border-line border-t pt-2 font-bold">
|
|
438
786
|
<dt>총 결제금액</dt>
|
|
439
|
-
<dd>{formatPrice(
|
|
787
|
+
<dd>{formatPrice(totalAmount)}</dd>
|
|
440
788
|
</div>
|
|
441
789
|
</dl>
|
|
442
|
-
{deliveryNotes(delivery).map((note) => (
|
|
790
|
+
{(shipping ? deliveryNotes(delivery) : []).map((note) => (
|
|
443
791
|
<p key={note} className="text-muted text-xs">
|
|
444
792
|
{note}
|
|
445
793
|
</p>
|
|
@@ -470,60 +818,6 @@ function deliveryNotes(delivery: CheckoutDelivery): string[] {
|
|
|
470
818
|
return notes;
|
|
471
819
|
}
|
|
472
820
|
|
|
473
|
-
function Field({
|
|
474
|
-
label,
|
|
475
|
-
name,
|
|
476
|
-
type = "text",
|
|
477
|
-
defaultValue,
|
|
478
|
-
placeholder,
|
|
479
|
-
required,
|
|
480
|
-
hint,
|
|
481
|
-
onBlur,
|
|
482
|
-
}: {
|
|
483
|
-
label: string;
|
|
484
|
-
name: string;
|
|
485
|
-
type?: string;
|
|
486
|
-
defaultValue?: string;
|
|
487
|
-
placeholder?: string;
|
|
488
|
-
required?: boolean;
|
|
489
|
-
hint?: string;
|
|
490
|
-
onBlur?: (value: string) => void;
|
|
491
|
-
}) {
|
|
492
|
-
const hintId = useId();
|
|
493
|
-
return (
|
|
494
|
-
<label className="block space-y-1">
|
|
495
|
-
<span className="text-sm">
|
|
496
|
-
{label}
|
|
497
|
-
{/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
|
|
498
|
-
{required ? (
|
|
499
|
-
<span aria-hidden="true" className="text-point">
|
|
500
|
-
{" "}
|
|
501
|
-
*
|
|
502
|
-
</span>
|
|
503
|
-
) : null}
|
|
504
|
-
</span>
|
|
505
|
-
{/* 프리필은 defaultValue로 준다 — SSR HTML에 값이 실린다 */}
|
|
506
|
-
<input
|
|
507
|
-
type={type}
|
|
508
|
-
name={name}
|
|
509
|
-
defaultValue={defaultValue}
|
|
510
|
-
placeholder={placeholder}
|
|
511
|
-
required={required}
|
|
512
|
-
// 이름은 라벨 글자 그대로다(필수 표시·안내 문구는 빼고) — 안내는 aria-describedby로 잇는다
|
|
513
|
-
aria-label={label}
|
|
514
|
-
aria-describedby={hint ? hintId : undefined}
|
|
515
|
-
onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined}
|
|
516
|
-
className="h-11 w-full rounded-md border border-line px-3"
|
|
517
|
-
/>
|
|
518
|
-
{hint ? (
|
|
519
|
-
<span id={hintId} className="block text-muted text-xs">
|
|
520
|
-
{hint}
|
|
521
|
-
</span>
|
|
522
|
-
) : null}
|
|
523
|
-
</label>
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
821
|
function Row({ label, value }: { label: string; value: string }) {
|
|
528
822
|
return (
|
|
529
823
|
<div className="flex justify-between">
|