@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.
@@ -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 { useId, useState } from "react";
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
- receiverName: data.receiverName,
109
- phone: data.phone,
110
- zipCode: data.zipCode,
111
- address1: data.address1,
112
- address2: data.address2 || undefined,
113
- deliveryMemo: data.deliveryMemo || undefined,
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
- return { start: null, error: startErrorMessage(error) };
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
- const [selected, setSelected] = useState(options[0] ? paymentOptionKey(options[0]) : "");
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 amounts = quote?.amounts ?? checkout.amounts;
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자리를 한 번에 채우는 값이고, FormData 폼에
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) setQuote(next);
184
- else {
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 option = options.find((item) => paymentOptionKey(item) === selected);
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 form = new FormData(event.currentTarget);
235
- const field = (name: string) => String(form.get(name) ?? "");
236
- let cashReceipt: z.infer<typeof cashReceiptRequestSchema> | undefined;
237
- if (cashReceiptShown && cashReceiptOn) {
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
- receiverName: field("receiverName"),
260
- phone: field("phone"),
261
- zipCode: field("zipCode"),
262
- address1: field("address1"),
263
- address2: field("address2"),
264
- deliveryMemo: field("deliveryMemo"),
265
- guestName: field("guestName"),
266
- email: field("email"),
267
- orderPassword: field("orderPassword"),
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
- setNotice(error);
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
- <section className="space-y-3">
296
- <h2 className="font-bold text-lg">배송지</h2>
297
- <Field label="수령인" name="receiverName" defaultValue={member?.name ?? ""} required />
298
- <Field label="연락처" name="phone" placeholder="01012345678" required />
299
- <Field
300
- label="우편번호"
301
- name="zipCode"
302
- placeholder="04524"
303
- required
304
- onBlur={handleZipBlur}
305
- hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줍니다"
306
- />
307
- <Field label="주소" name="address1" required />
308
- <Field label="상세 주소" name="address2" />
309
- <Field label="배송 메모" name="deliveryMemo" />
310
- </section>
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
- <Field label="주문자명" name="guestName" required />
316
- <Field label="이메일" name="email" type="email" required />
317
- <Field
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
- name="orderPassword"
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
- name="paymentOption"
624
+ {...form.register("paymentOption")}
346
625
  value={key}
347
- checked={selected === key}
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
- name="cashReceiptType"
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
- <Field
660
+ <TextField
388
661
  label={
389
662
  cashReceiptType === "EXPENSE_PROOF"
390
663
  ? "사업자등록번호"
391
664
  : "휴대폰 번호 또는 현금영수증카드 번호"
392
665
  }
393
- name="cashReceiptNumber"
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(amounts.totalAmount)} 결제하기
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(amounts.productAmount)} />
430
- {/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다 더한다 */}
431
- <Row label="기본 배송비" value={formatPrice(delivery.baseFee)} />
432
- {delivery.remoteSurcharge > 0 ? (
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(amounts.totalAmount)}</dd>
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">