@sayren/mcp 0.6.1 → 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 +27 -0
- 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/order-status.test.ts +29 -0
- package/template/src/lib/order-status.ts +28 -2
- 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 +9 -4
- package/template/src/routes/auth.callback.ts +13 -9
- package/template/src/routes/cart.tsx +11 -5
- package/template/src/routes/checkout.index.tsx +439 -146
- package/template/src/routes/guest-order.tsx +161 -71
- package/template/src/routes/orders.$orderId.tsx +94 -2
- package/template/src/routes/products.index.tsx +44 -46
- package/template/src/routes/signup.tsx +59 -89
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
1
2
|
import {
|
|
2
3
|
ApiError,
|
|
3
4
|
type CheckoutDelivery,
|
|
@@ -12,14 +13,28 @@ 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";
|
|
24
39
|
import { pageTitle } from "../lib/page-title";
|
|
25
40
|
import { forgetPaymentOptions, rememberPaymentOptions } from "../lib/payment-options";
|
|
@@ -73,11 +88,52 @@ const quoteDelivery = createServerFn({ method: "POST" })
|
|
|
73
88
|
}
|
|
74
89
|
});
|
|
75
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
|
+
|
|
76
130
|
const paymentForm = z.object({
|
|
77
131
|
checkoutId: z.string(),
|
|
78
132
|
option: paymentOptionSchema,
|
|
79
133
|
/** 결제에 실패하면 돌아올 주문서 주소 */
|
|
80
134
|
back: z.string(),
|
|
135
|
+
/** 배송지를 보내는가 — 주문서의 `requiresShipping`. false면(이용권·방문 수령 등) 배송지를 싣지 않는다 */
|
|
136
|
+
requiresShipping: z.boolean(),
|
|
81
137
|
receiverName: z.string(),
|
|
82
138
|
phone: z.string(),
|
|
83
139
|
zipCode: z.string(),
|
|
@@ -89,6 +145,10 @@ const paymentForm = z.object({
|
|
|
89
145
|
orderPassword: z.string(),
|
|
90
146
|
/** 현금영수증 신청 — 계좌이체·가상계좌에서 구매자가 켰을 때만 */
|
|
91
147
|
cashReceipt: cashReceiptRequestSchema.optional(),
|
|
148
|
+
/** 적용한 쿠폰 코드 — 미리보기로 확인한 값 */
|
|
149
|
+
couponCode: z.string().regex(COUPON_CODE_PATTERN).optional(),
|
|
150
|
+
/** 청약철회 제한 동의 — 주문서의 `withdrawal.required`가 true일 때 구매자가 체크한 값 */
|
|
151
|
+
withdrawalAgreement: z.boolean().optional(),
|
|
92
152
|
});
|
|
93
153
|
|
|
94
154
|
/** 결제 시작은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다. 결제창 호출 값(공개 값)만 내려준다 */
|
|
@@ -104,14 +164,16 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
104
164
|
const start = await api.checkout.startPayment(data.checkoutId, {
|
|
105
165
|
option: data.option,
|
|
106
166
|
returnUrl: returnUrl.toString(),
|
|
107
|
-
shippingAddress:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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,
|
|
115
177
|
guest: accessToken
|
|
116
178
|
? undefined
|
|
117
179
|
: {
|
|
@@ -121,14 +183,33 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
121
183
|
orderPassword: data.orderPassword,
|
|
122
184
|
},
|
|
123
185
|
cashReceipt: data.cashReceipt,
|
|
186
|
+
...(data.couponCode ? { coupons: [{ code: data.couponCode }] } : {}),
|
|
187
|
+
// 제한 상품이 없는 주문은 보내지 않는다 — 빠지면 서버가 400 WITHDRAWAL_AGREEMENT_REQUIRED다
|
|
188
|
+
...(data.withdrawalAgreement ? { withdrawalAgreement: true } : {}),
|
|
124
189
|
});
|
|
125
190
|
// 결제 시작 값은 JSON으로 넘기고 브라우저에서 SDK 스키마로 다시 읽는다 — 서버 함수 직렬화 타입에 맞춘다
|
|
126
|
-
return { start: JSON.stringify(start), error: null };
|
|
191
|
+
return { start: JSON.stringify(start), error: null, couponFailed: false };
|
|
127
192
|
} catch (error) {
|
|
128
|
-
|
|
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 };
|
|
129
198
|
}
|
|
130
199
|
});
|
|
131
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
|
+
|
|
132
213
|
function startErrorMessage(error: unknown): string {
|
|
133
214
|
if (!(error instanceof ApiError))
|
|
134
215
|
return "결제를 시작하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
|
|
@@ -139,8 +220,24 @@ function startErrorMessage(error: unknown): string {
|
|
|
139
220
|
return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해 주십시오.";
|
|
140
221
|
case "PAYMENT_NOT_CONFIGURED":
|
|
141
222
|
return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해 주십시오.";
|
|
223
|
+
case "WITHDRAWAL_AGREEMENT_REQUIRED":
|
|
224
|
+
return WITHDRAWAL_AGREEMENT_MESSAGE;
|
|
142
225
|
case "CASH_RECEIPT_NOT_AVAILABLE":
|
|
143
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);
|
|
144
241
|
case "RETURN_URL_NOT_ALLOWED":
|
|
145
242
|
return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해 주십시오.";
|
|
146
243
|
default:
|
|
@@ -162,41 +259,124 @@ function Checkout() {
|
|
|
162
259
|
const [notice, setNotice] = useState<string | null>(null);
|
|
163
260
|
const [submitting, setSubmitting] = useState(false);
|
|
164
261
|
const options = checkout.paymentOptions;
|
|
165
|
-
|
|
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");
|
|
166
297
|
/**
|
|
167
298
|
* 배송지를 반영한 금액. 배송비 미리보기와 결제 시작 응답이 덮어쓴다 — 최종 금액은 결제 시작 응답의 `amounts`다
|
|
168
299
|
* (서버가 보낸 배송지로 배송비를 다시 계산한다).
|
|
169
300
|
*/
|
|
170
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);
|
|
171
315
|
/** 같은 우편번호로 blur가 반복돼도 다시 부르지 않는다 */
|
|
172
316
|
const [quotedZip, setQuotedZip] = useState<string | null>(null);
|
|
173
|
-
const
|
|
317
|
+
const baseAmounts = quote?.amounts ?? checkout.amounts;
|
|
174
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
|
+
};
|
|
175
358
|
|
|
176
|
-
// 디바운스 타이머 대신 blur에서 부른다 — 우편번호는 5자리를 한 번에 채우는
|
|
177
|
-
// 입력 상태를 따로 들지 않아도 된다
|
|
359
|
+
// 디바운스 타이머 대신 blur에서 부른다 — 우편번호는 5자리를 한 번에 채우는 값이다
|
|
178
360
|
const handleZipBlur = (zipCode: string) => {
|
|
179
361
|
if (!/^[0-9]{5}$/.test(zipCode) || zipCode === quotedZip) return;
|
|
180
362
|
setQuotedZip(zipCode);
|
|
181
363
|
void quoteDelivery({ data: { checkoutId: checkout.checkoutId, zipCode } })
|
|
182
364
|
.then(({ quote: next, error }) => {
|
|
183
|
-
if (next)
|
|
184
|
-
|
|
365
|
+
if (next) {
|
|
366
|
+
setQuote(next);
|
|
367
|
+
setQuoteConfirmed(false);
|
|
368
|
+
} else {
|
|
185
369
|
setQuotedZip(null);
|
|
186
370
|
setNotice(error);
|
|
187
371
|
}
|
|
188
372
|
})
|
|
189
373
|
.catch(() => setQuotedZip(null));
|
|
190
374
|
};
|
|
191
|
-
const [cashReceiptOn, setCashReceiptOn] = useState(false);
|
|
192
|
-
const [cashReceiptType, setCashReceiptType] = useState<"INCOME_DEDUCTION" | "EXPENSE_PROOF">(
|
|
193
|
-
"INCOME_DEDUCTION",
|
|
194
|
-
);
|
|
195
375
|
const selectedOption = options.find((item) => paymentOptionKey(item) === selected);
|
|
196
376
|
// 현금영수증은 계좌이체·가상계좌에서만 받는다
|
|
197
377
|
const cashReceiptShown = !!selectedOption && cashReceiptAvailable(selectedOption.method);
|
|
198
378
|
|
|
199
|
-
const settle = (result: PaymentResult, back: string) => {
|
|
379
|
+
const settle = (result: PaymentResult, back: string, signature: string) => {
|
|
200
380
|
if (result.status === "COMPLETED") {
|
|
201
381
|
forgetPaymentOptions(result.paymentId);
|
|
202
382
|
void navigate({
|
|
@@ -215,6 +395,8 @@ function Checkout() {
|
|
|
215
395
|
});
|
|
216
396
|
return;
|
|
217
397
|
}
|
|
398
|
+
// 같은 입력으로 다시 누르면 이 결제를 다른 옵션으로 이어 간다
|
|
399
|
+
if (result.status !== "INVALID") setPending({ paymentId: result.paymentId, signature });
|
|
218
400
|
setNotice(
|
|
219
401
|
result.status === "CANCELED"
|
|
220
402
|
? "결제를 취소했습니다. 다른 결제수단으로 다시 시도해 주십시오."
|
|
@@ -224,63 +406,104 @@ function Checkout() {
|
|
|
224
406
|
);
|
|
225
407
|
};
|
|
226
408
|
|
|
409
|
+
/**
|
|
410
|
+
* 결제창은 클릭에서 동기로 열어야 팝업 차단에 걸리지 않는다. `form.handleSubmit`은 검증을 기다린 뒤 부르므로
|
|
411
|
+
* 여기서는 같은 스키마로 먼저 동기 검사하고, 통과하면 바로 빈 창을 연다. 실패하면 `handleSubmit`으로 칸마다
|
|
412
|
+
* 오류를 보이고 첫 오류 칸으로 옮긴다.
|
|
413
|
+
*/
|
|
227
414
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
228
415
|
event.preventDefault();
|
|
229
|
-
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);
|
|
230
423
|
if (!option) {
|
|
231
424
|
setNotice("결제수단을 골라 주십시오.");
|
|
232
425
|
return;
|
|
233
426
|
}
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const parsed = cashReceiptRequestSchema.safeParse({
|
|
239
|
-
type: cashReceiptType,
|
|
240
|
-
identityNumber: field("cashReceiptNumber"),
|
|
241
|
-
});
|
|
242
|
-
if (!parsed.success) {
|
|
243
|
-
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해 주십시오.");
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
cashReceipt = parsed.data;
|
|
247
|
-
}
|
|
427
|
+
const cashReceipt =
|
|
428
|
+
cashReceiptAvailable(option.method) && values.cashReceiptOn
|
|
429
|
+
? { type: values.cashReceiptType, identityNumber: values.cashReceiptNumber ?? "" }
|
|
430
|
+
: undefined;
|
|
248
431
|
const back = window.location.pathname + window.location.search;
|
|
249
432
|
const payments = paymentsFor(config);
|
|
250
433
|
// 결제창은 클릭 시점에 열어야 팝업 차단에 걸리지 않는다 — 서버 함수를 기다리기 전에 빈 창을 먼저 연다
|
|
251
434
|
const paymentWindow = payments.prepareWindow();
|
|
252
435
|
setSubmitting(true);
|
|
253
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);
|
|
254
468
|
void startPayment({
|
|
255
469
|
data: {
|
|
256
470
|
checkoutId: checkout.checkoutId,
|
|
257
471
|
option,
|
|
258
472
|
back,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 ?? "",
|
|
268
483
|
cashReceipt,
|
|
484
|
+
couponCode: coupon?.code,
|
|
485
|
+
withdrawalAgreement: withdrawal.required ? values.withdrawalAgreed === true : undefined,
|
|
269
486
|
},
|
|
270
487
|
})
|
|
271
|
-
.then(async ({ start, error }) => {
|
|
488
|
+
.then(async ({ start, error, couponFailed }) => {
|
|
272
489
|
if (!start) {
|
|
273
490
|
paymentWindow.close();
|
|
274
|
-
|
|
491
|
+
if (couponFailed && coupon) {
|
|
492
|
+
releaseCoupon(coupon.code);
|
|
493
|
+
setCouponError(error);
|
|
494
|
+
} else {
|
|
495
|
+
setNotice(error);
|
|
496
|
+
}
|
|
275
497
|
return;
|
|
276
498
|
}
|
|
277
499
|
const paymentStart = paymentStartSchema.parse(JSON.parse(start));
|
|
278
500
|
// 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다. 화면에 보여 준 금액과 다를 수 있다
|
|
279
501
|
setQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });
|
|
502
|
+
setQuoteConfirmed(true);
|
|
280
503
|
// 리다이렉트 결제가 실패해 복귀 화면으로 오면 같은 결제를 다른 옵션으로 다시 시도한다 — 그때 쓸 목록
|
|
281
504
|
rememberPaymentOptions(paymentStart.paymentId, options);
|
|
282
505
|
// 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
|
|
283
|
-
settle(await payments.open(paymentStart, { window: paymentWindow }), back);
|
|
506
|
+
settle(await payments.open(paymentStart, { window: paymentWindow }), back, signature);
|
|
284
507
|
})
|
|
285
508
|
.catch(() => {
|
|
286
509
|
paymentWindow.close();
|
|
@@ -292,31 +515,86 @@ function Checkout() {
|
|
|
292
515
|
return (
|
|
293
516
|
<div className="grid gap-8 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:items-start">
|
|
294
517
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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}
|
|
311
566
|
|
|
312
567
|
{member ? null : (
|
|
313
568
|
<section className="space-y-3">
|
|
314
569
|
<h2 className="font-bold text-lg">비회원 주문</h2>
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
318
595
|
label="주문 조회 비밀번호"
|
|
319
|
-
|
|
596
|
+
registration={form.register("orderPassword")}
|
|
597
|
+
error={errors.orderPassword?.message}
|
|
320
598
|
type="password"
|
|
321
599
|
required
|
|
322
600
|
hint="주문 내역을 찾을 때 쓸 6자 이상 비밀번호입니다"
|
|
@@ -340,12 +618,12 @@ function Checkout() {
|
|
|
340
618
|
}`}
|
|
341
619
|
>
|
|
342
620
|
<span className="flex items-center gap-2">
|
|
621
|
+
{/* 서버 HTML에도 첫 옵션이 선택돼 있도록 defaultChecked를 함께 준다 */}
|
|
343
622
|
<input
|
|
344
623
|
type="radio"
|
|
345
|
-
|
|
624
|
+
{...form.register("paymentOption")}
|
|
346
625
|
value={key}
|
|
347
|
-
|
|
348
|
-
onChange={() => setSelected(key)}
|
|
626
|
+
defaultChecked={key === defaultOptionKey}
|
|
349
627
|
/>
|
|
350
628
|
<span className="font-medium">{option.label}</span>
|
|
351
629
|
</span>
|
|
@@ -355,17 +633,16 @@ function Checkout() {
|
|
|
355
633
|
})}
|
|
356
634
|
</div>
|
|
357
635
|
)}
|
|
636
|
+
{errors.paymentOption ? (
|
|
637
|
+
<p className="text-point text-xs">{errors.paymentOption.message}</p>
|
|
638
|
+
) : null}
|
|
358
639
|
</fieldset>
|
|
359
640
|
|
|
360
641
|
{cashReceiptShown ? (
|
|
361
642
|
<fieldset className="space-y-3">
|
|
362
643
|
<legend className="mb-3 font-bold text-lg">현금영수증</legend>
|
|
363
644
|
<label className="flex items-center gap-2 text-sm">
|
|
364
|
-
<input
|
|
365
|
-
type="checkbox"
|
|
366
|
-
checked={cashReceiptOn}
|
|
367
|
-
onChange={(event) => setCashReceiptOn(event.target.checked)}
|
|
368
|
-
/>
|
|
645
|
+
<input type="checkbox" {...form.register("cashReceiptOn")} />
|
|
369
646
|
현금영수증 신청
|
|
370
647
|
</label>
|
|
371
648
|
{cashReceiptOn ? (
|
|
@@ -373,24 +650,21 @@ function Checkout() {
|
|
|
373
650
|
<label className="block space-y-1">
|
|
374
651
|
<span className="text-sm">용도</span>
|
|
375
652
|
<select
|
|
376
|
-
|
|
377
|
-
value={cashReceiptType}
|
|
378
|
-
onChange={(event) =>
|
|
379
|
-
setCashReceiptType(event.target.value as typeof cashReceiptType)
|
|
380
|
-
}
|
|
653
|
+
{...form.register("cashReceiptType")}
|
|
381
654
|
className="w-full rounded-md border border-line px-3 py-2"
|
|
382
655
|
>
|
|
383
656
|
<option value="INCOME_DEDUCTION">소득공제</option>
|
|
384
657
|
<option value="EXPENSE_PROOF">지출증빙</option>
|
|
385
658
|
</select>
|
|
386
659
|
</label>
|
|
387
|
-
<
|
|
660
|
+
<TextField
|
|
388
661
|
label={
|
|
389
662
|
cashReceiptType === "EXPENSE_PROOF"
|
|
390
663
|
? "사업자등록번호"
|
|
391
664
|
: "휴대폰 번호 또는 현금영수증카드 번호"
|
|
392
665
|
}
|
|
393
|
-
|
|
666
|
+
registration={form.register("cashReceiptNumber")}
|
|
667
|
+
error={errors.cashReceiptNumber?.message}
|
|
394
668
|
required
|
|
395
669
|
/>
|
|
396
670
|
</>
|
|
@@ -398,11 +672,80 @@ function Checkout() {
|
|
|
398
672
|
</fieldset>
|
|
399
673
|
) : null}
|
|
400
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
|
+
|
|
401
744
|
<SubmitButton
|
|
402
|
-
disabled={submitting || options.length === 0}
|
|
745
|
+
disabled={submitting || couponBusy || options.length === 0}
|
|
403
746
|
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
404
747
|
>
|
|
405
|
-
{formatPrice(
|
|
748
|
+
{formatPrice(totalAmount)} 결제하기
|
|
406
749
|
</SubmitButton>
|
|
407
750
|
</form>
|
|
408
751
|
|
|
@@ -426,21 +769,25 @@ function Checkout() {
|
|
|
426
769
|
))}
|
|
427
770
|
</ul>
|
|
428
771
|
<dl className="space-y-1 border-line border-t pt-3 text-sm">
|
|
429
|
-
<Row label="상품 금액" value={formatPrice(
|
|
430
|
-
{/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다
|
|
431
|
-
|
|
432
|
-
{delivery.
|
|
772
|
+
<Row label="상품 금액" value={formatPrice(baseAmounts.productAmount)} />
|
|
773
|
+
{/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다 더한다.
|
|
774
|
+
배송이 필요 없는 주문은 배송비가 없다 */}
|
|
775
|
+
{shipping ? <Row label="기본 배송비" value={formatPrice(delivery.baseFee)} /> : null}
|
|
776
|
+
{shipping && delivery.remoteSurcharge > 0 ? (
|
|
433
777
|
<Row
|
|
434
778
|
label={`${delivery.remoteAreaLabel ?? "지역"} 추가 배송비`}
|
|
435
779
|
value={formatPrice(delivery.remoteSurcharge)}
|
|
436
780
|
/>
|
|
437
781
|
) : null}
|
|
782
|
+
{couponDiscount > 0 ? (
|
|
783
|
+
<Row label="쿠폰 할인" value={`−${formatPrice(couponDiscount)}`} />
|
|
784
|
+
) : null}
|
|
438
785
|
<div className="flex justify-between border-line border-t pt-2 font-bold">
|
|
439
786
|
<dt>총 결제금액</dt>
|
|
440
|
-
<dd>{formatPrice(
|
|
787
|
+
<dd>{formatPrice(totalAmount)}</dd>
|
|
441
788
|
</div>
|
|
442
789
|
</dl>
|
|
443
|
-
{deliveryNotes(delivery).map((note) => (
|
|
790
|
+
{(shipping ? deliveryNotes(delivery) : []).map((note) => (
|
|
444
791
|
<p key={note} className="text-muted text-xs">
|
|
445
792
|
{note}
|
|
446
793
|
</p>
|
|
@@ -471,60 +818,6 @@ function deliveryNotes(delivery: CheckoutDelivery): string[] {
|
|
|
471
818
|
return notes;
|
|
472
819
|
}
|
|
473
820
|
|
|
474
|
-
function Field({
|
|
475
|
-
label,
|
|
476
|
-
name,
|
|
477
|
-
type = "text",
|
|
478
|
-
defaultValue,
|
|
479
|
-
placeholder,
|
|
480
|
-
required,
|
|
481
|
-
hint,
|
|
482
|
-
onBlur,
|
|
483
|
-
}: {
|
|
484
|
-
label: string;
|
|
485
|
-
name: string;
|
|
486
|
-
type?: string;
|
|
487
|
-
defaultValue?: string;
|
|
488
|
-
placeholder?: string;
|
|
489
|
-
required?: boolean;
|
|
490
|
-
hint?: string;
|
|
491
|
-
onBlur?: (value: string) => void;
|
|
492
|
-
}) {
|
|
493
|
-
const hintId = useId();
|
|
494
|
-
return (
|
|
495
|
-
<label className="block space-y-1">
|
|
496
|
-
<span className="text-sm">
|
|
497
|
-
{label}
|
|
498
|
-
{/* 필수 표시는 눈으로만 본다 — 입력의 required가 보조 기술에 필수임을 알린다 */}
|
|
499
|
-
{required ? (
|
|
500
|
-
<span aria-hidden="true" className="text-point">
|
|
501
|
-
{" "}
|
|
502
|
-
*
|
|
503
|
-
</span>
|
|
504
|
-
) : null}
|
|
505
|
-
</span>
|
|
506
|
-
{/* 프리필은 defaultValue로 준다 — SSR HTML에 값이 실린다 */}
|
|
507
|
-
<input
|
|
508
|
-
type={type}
|
|
509
|
-
name={name}
|
|
510
|
-
defaultValue={defaultValue}
|
|
511
|
-
placeholder={placeholder}
|
|
512
|
-
required={required}
|
|
513
|
-
// 이름은 라벨 글자 그대로다(필수 표시·안내 문구는 빼고) — 안내는 aria-describedby로 잇는다
|
|
514
|
-
aria-label={label}
|
|
515
|
-
aria-describedby={hint ? hintId : undefined}
|
|
516
|
-
onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined}
|
|
517
|
-
className="h-11 w-full rounded-md border border-line px-3"
|
|
518
|
-
/>
|
|
519
|
-
{hint ? (
|
|
520
|
-
<span id={hintId} className="block text-muted text-xs">
|
|
521
|
-
{hint}
|
|
522
|
-
</span>
|
|
523
|
-
) : null}
|
|
524
|
-
</label>
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
821
|
function Row({ label, value }: { label: string; value: string }) {
|
|
529
822
|
return (
|
|
530
823
|
<div className="flex justify-between">
|