@sayren/mcp 0.12.0 → 0.13.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 CHANGED
@@ -283,7 +283,7 @@ function describeWriteResult(result) {
283
283
 
284
284
  //#endregion
285
285
  //#region package.json
286
- var version = "0.12.0";
286
+ var version = "0.13.0";
287
287
 
288
288
  //#endregion
289
289
  //#region src/user-agent.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sayren-mcp": "./dist/index.mjs"
@@ -12,8 +12,8 @@
12
12
  "dependencies": {
13
13
  "@modelcontextprotocol/sdk": "^1.22.0",
14
14
  "zod": "^4.6.5",
15
- "@sayren/storefront-sdk": "^0.15.0",
16
- "@sayren/store-sdk": "^0.19.0"
15
+ "@sayren/store-sdk": "^0.20.0",
16
+ "@sayren/storefront-sdk": "^0.16.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "versions": {
10
10
  "@sayren/contracts": "0.1.0",
11
- "@sayren/store-sdk": "0.19.0",
12
- "@sayren/storefront-sdk": "0.15.0",
11
+ "@sayren/store-sdk": "0.20.0",
12
+ "@sayren/storefront-sdk": "0.16.0",
13
13
  "@sayren/ui": "0.1.0"
14
14
  }
15
15
  }
@@ -1,10 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import {
3
- checkoutFormSchema,
4
- guestOrderLookupSchema,
5
- signupFormSchema,
6
- WITHDRAWAL_AGREEMENT_MESSAGE,
7
- } from "./form-schemas";
2
+ import { checkoutFormSchema, guestOrderLookupSchema, signupFormSchema } from "./form-schemas";
8
3
 
9
4
  const messageOf = (result: { success: boolean; error?: { issues: { message: string }[] } }) =>
10
5
  result.error?.issues[0]?.message;
@@ -119,21 +114,6 @@ describe("주문서 폼", () => {
119
114
  expect(messageOf(result)).toBe("주문 조회 비밀번호는 6자 이상이어야 합니다");
120
115
  });
121
116
 
122
- it("청약철회 제한 상품이 있으면 동의를 요구하고, 없으면 묻지 않는다", () => {
123
- const restricted = checkoutFormSchema({
124
- guest: false,
125
- cashReceiptOptionKeys: cashKeys,
126
- withdrawal: true,
127
- });
128
- expect(messageOf(restricted.safeParse({ ...shipping, withdrawalAgreed: false }))).toBe(
129
- WITHDRAWAL_AGREEMENT_MESSAGE,
130
- );
131
- expect(restricted.safeParse({ ...shipping }).success).toBe(false);
132
- expect(restricted.safeParse({ ...shipping, withdrawalAgreed: true }).success).toBe(true);
133
- const plain = checkoutFormSchema({ guest: false, cashReceiptOptionKeys: cashKeys });
134
- expect(plain.safeParse({ ...shipping, withdrawalAgreed: false }).success).toBe(true);
135
- });
136
-
137
117
  it("현금영수증은 받을 수 있는 결제 옵션에서 신청했을 때만 번호를 검사한다", () => {
138
118
  const schema = checkoutFormSchema({ guest: false, cashReceiptOptionKeys: cashKeys });
139
119
  const on = { ...shipping, cashReceiptOn: true, cashReceiptNumber: "12" };
@@ -63,24 +63,17 @@ export type GuestOrderLookup = z.output<typeof guestOrderLookupSchema>;
63
63
 
64
64
  // ── 주문서 ───────────────────────────────────────────────────────────────
65
65
 
66
- /** 청약철회 제한 동의 누락 — 폼 검사와 서버 400 `WITHDRAWAL_AGREEMENT_REQUIRED`가 같은 문구를 쓴다 */
67
- export const WITHDRAWAL_AGREEMENT_MESSAGE = "청약철회 제한 안내에 동의해 주십시오";
68
-
69
66
  /**
70
67
  * 주문서 폼. 비회원 주문 정보는 비회원일 때만 받고, 현금영수증은 계좌이체·가상계좌 옵션을 골랐고 신청을 켰을 때만
71
68
  * 검사한다(`cashReceiptOptionKeys`는 그런 결제 옵션의 키).
72
69
  *
73
- * 배송지는 주문서의 `requiresShipping`이 true일 때만 받는다(`shipping`, 생략하면 true). 배송이 필요 없는 주문(이용권·방문
74
- * 수령 등)은 배송지 칸이 없고, 연락처는 비회원 주문자 연락처로만 받는다.
75
- *
76
- * 청약철회 제한 동의는 주문서의 `withdrawal.required`가 true일 때만 요구한다(`withdrawal`, 생략하면 false). 결제 시작이
77
- * `withdrawalAgreement: true`로 보낸다.
70
+ * 배송지는 주문서의 `requiresShipping`이 true일 때만 받는다(`shipping`, 생략하면 true). 배송이 필요 없는 주문(배송 없는
71
+ * 상품만 담은 주문)은 배송지 칸이 없고, 연락처는 비회원 주문자 연락처로만 받는다.
78
72
  */
79
73
  export function checkoutFormSchema(options: {
80
74
  guest: boolean;
81
75
  cashReceiptOptionKeys: ReadonlySet<string>;
82
76
  shipping?: boolean;
83
- withdrawal?: boolean;
84
77
  }) {
85
78
  const shipping = shippingAddressInputSchema.shape;
86
79
  const address = options.shipping ?? true;
@@ -105,10 +98,6 @@ export function checkoutFormSchema(options: {
105
98
  cashReceiptOn: z.boolean(),
106
99
  cashReceiptType: cashReceiptTypeSchema,
107
100
  cashReceiptNumber: z.string().optional(),
108
- // 체크박스는 체크 전 false다 — 제한 상품이 있는 주문만 true를 요구한다
109
- withdrawalAgreed: options.withdrawal
110
- ? z.boolean().refine((agreed) => agreed, WITHDRAWAL_AGREEMENT_MESSAGE)
111
- : z.boolean().optional(),
112
101
  })
113
102
  .superRefine((value, ctx) => {
114
103
  if (!value.cashReceiptOn || !options.cashReceiptOptionKeys.has(value.paymentOption)) return;
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { guestLookupLockedMessage } from "./guest-order";
2
+ import { guestLookupLockedMessage, normalizePhone } from "./guest-order";
3
3
 
4
4
  describe("guestLookupLockedMessage", () => {
5
5
  it("남은 시간을 분 단위로 올려 알린다", () => {
@@ -16,3 +16,9 @@ describe("guestLookupLockedMessage", () => {
16
16
  expect(guestLookupLockedMessage("30")).toContain("잠시 후");
17
17
  });
18
18
  });
19
+
20
+ describe("normalizePhone", () => {
21
+ it("숫자만 남긴다", () => {
22
+ expect(normalizePhone("010-1234 5678")).toBe("01012345678");
23
+ });
24
+ });
@@ -11,3 +11,8 @@ export function guestLookupLockedMessage(retryAfterSeconds: unknown): string {
11
11
  ? `조회 시도가 너무 많습니다. ${minutes}분 뒤에 다시 시도해 주십시오`
12
12
  : "조회 시도가 너무 많습니다. 잠시 후 다시 시도해 주십시오";
13
13
  }
14
+
15
+ /** 비회원 조회에 싣는 연락처 — 숫자만 남긴다 */
16
+ export function normalizePhone(phone: string): string {
17
+ return phone.replace(/[^0-9]/g, "");
18
+ }
@@ -1,9 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { orderItemStatusLabel, withdrawalRestrictedLabel } from "./order-status";
2
+ import { orderItemStatusLabel } from "./order-status";
3
3
 
4
4
  const SHIPPING = { type: "SHIPPING", requiresShipping: true } as const;
5
- const SERVICE = { type: "SERVICE", requiresShipping: false } as const;
6
- const PICKUP = { type: "PICKUP", requiresShipping: false } as const;
5
+ const MANUAL = { type: "MANUAL", requiresShipping: false } as const;
7
6
 
8
7
  describe("주문 상품 상태 라벨", () => {
9
8
  it("배송 상품은 배송 단계로 보인다", () => {
@@ -12,11 +11,10 @@ describe("주문 상품 상태 라벨", () => {
12
11
  expect(orderItemStatusLabel("CONFIRMED", SHIPPING)).toBe("발송 준비");
13
12
  });
14
13
 
15
- it("배송이 없는 상품의 DELIVERED는 제공 완료, 방문 수령은 수령 완료다", () => {
16
- expect(orderItemStatusLabel("DELIVERED", SERVICE)).toBe("제공 완료");
17
- expect(orderItemStatusLabel("DELIVERED", PICKUP)).toBe("수령 완료");
18
- expect(orderItemStatusLabel("CONFIRMED", SERVICE)).toBe("준비 중");
19
- expect(orderItemStatusLabel("PAID", PICKUP)).toBe("결제 완료");
14
+ it("배송이 없는 상품의 DELIVERED는 제공 완료다", () => {
15
+ expect(orderItemStatusLabel("DELIVERED", MANUAL)).toBe("제공 완료");
16
+ expect(orderItemStatusLabel("CONFIRMED", MANUAL)).toBe("준비 중");
17
+ expect(orderItemStatusLabel("PAID", MANUAL)).toBe("결제 완료");
20
18
  // 모르는 유형도 배송 여부로 가른다
21
19
  expect(orderItemStatusLabel("DELIVERED", { type: "OTHER", requiresShipping: false })).toBe(
22
20
  "제공 완료",
@@ -24,21 +22,6 @@ describe("주문 상품 상태 라벨", () => {
24
22
  });
25
23
 
26
24
  it("모르는 상태는 코드 그대로다", () => {
27
- expect(orderItemStatusLabel("ON_HOLD", SERVICE)).toBe("ON_HOLD");
28
- });
29
- });
30
-
31
- describe("청약철회 제한 표시", () => {
32
- it("제한 주문상품에만 라벨을 붙인다", () => {
33
- const download = (withdrawalRestricted: boolean) => ({
34
- fulfillmentSnapshot: {
35
- type: "DOWNLOAD" as const,
36
- requiresShipping: false as const,
37
- download: { withdrawalRestricted },
38
- },
39
- });
40
- expect(withdrawalRestrictedLabel(download(true))).toBe("청약철회 제한 상품");
41
- expect(withdrawalRestrictedLabel(download(false))).toBeNull();
42
- expect(withdrawalRestrictedLabel({ fulfillmentSnapshot: SERVICE })).toBeNull();
25
+ expect(orderItemStatusLabel("ON_HOLD", MANUAL)).toBe("ON_HOLD");
43
26
  });
44
27
  });
@@ -1,8 +1,4 @@
1
- import {
2
- type MyOrderItemFulfillmentSnapshot,
3
- type OrderItemStatus,
4
- withdrawalRestrictedOfView,
5
- } from "@sayren/storefront-sdk";
1
+ import type { MyOrderItemFulfillmentSnapshot, OrderItemStatus } from "@sayren/storefront-sdk";
6
2
 
7
3
  /** 주문 상품 상태 라벨 — 화면에 코드 대신 보인다. 서버가 상태를 더하면 코드 그대로 보인다 */
8
4
  const ORDER_ITEM_STATUS_LABELS: Record<OrderItemStatus, string> = {
@@ -17,12 +13,11 @@ const ORDER_ITEM_STATUS_LABELS: Record<OrderItemStatus, string> = {
17
13
  };
18
14
 
19
15
  /**
20
- * 배송이 없는 상품(이용권·방문 수령 등)의 상태 라벨. 제공 완료는 `DELIVERED`를 그대로 쓰므로 표시만 바꾼다 —
21
- * 방문 수령은 「수령 완료」, 그 밖의 유형은 「제공 완료」다.
16
+ * 배송이 없는 상품의 상태 라벨. 판매자가 제공 처리하면 `DELIVERED`가 되므로 표시만 「제공 완료」로 바꾼다.
22
17
  */
23
- function nonShippingLabel(status: string, fulfillmentType: string): string | null {
18
+ function nonShippingLabel(status: string): string | null {
24
19
  if (status === "CONFIRMED") return "준비 중";
25
- if (status === "DELIVERED") return fulfillmentType === "PICKUP" ? "수령 완료" : "제공 완료";
20
+ if (status === "DELIVERED") return "제공 완료";
26
21
  return null;
27
22
  }
28
23
 
@@ -35,22 +30,8 @@ export function orderItemStatusLabel(
35
30
  fulfillment?: Pick<MyOrderItemFulfillmentSnapshot, "type" | "requiresShipping"> | null,
36
31
  ): string {
37
32
  if (fulfillment && !fulfillment.requiresShipping) {
38
- const label = nonShippingLabel(status, fulfillment.type);
33
+ const label = nonShippingLabel(status);
39
34
  if (label) return label;
40
35
  }
41
36
  return ORDER_ITEM_STATUS_LABELS[status as OrderItemStatus] ?? status;
42
37
  }
43
-
44
- /**
45
- * 청약철회 제한 주문상품(이슈 #48) — 구매자가 결제 전에 동의했고, 제공이 시작되면(첫 다운로드·열람) 단순 변심 취소·반품을
46
- * 받지 않는다. 주문 상세·비회원 주문 조회가 상품 옆에 붙인다.
47
- */
48
- export function withdrawalRestrictedLabel(item: {
49
- fulfillmentSnapshot: MyOrderItemFulfillmentSnapshot;
50
- }): string | null {
51
- return withdrawalRestrictedOfView(item.fulfillmentSnapshot) ? "청약철회 제한 상품" : null;
52
- }
53
-
54
- /** 제공이 시작된 청약철회 제한 상품의 취소·반품 접수 거절(409 `WITHDRAWAL_RESTRICTED`) 안내 */
55
- export const WITHDRAWAL_RESTRICTED_CLAIM_MESSAGE =
56
- "제공이 시작된 상품이라 취소·반품을 접수할 수 없습니다.";
@@ -168,7 +168,7 @@ function CartPage() {
168
168
  <dt className="text-muted">상품 금액</dt>
169
169
  <dd>{formatPrice(cart.summary.productAmount)}</dd>
170
170
  </div>
171
- {/* 배송이 필요 없는 상품(이용권·방문 수령 등)만 담았으면 배송비가 없다 */}
171
+ {/* 배송이 필요 없는 상품만 담았으면 배송비가 없다 */}
172
172
  {cart.summary.requiresShipping ? (
173
173
  <div className="flex justify-between">
174
174
  <dt className="text-muted">배송비</dt>
@@ -29,12 +29,7 @@ import {
29
29
  couponErrorMessage,
30
30
  couponRejectMessage,
31
31
  } from "../lib/coupon-code";
32
- import {
33
- type CheckoutForm,
34
- type CheckoutFormInput,
35
- checkoutFormSchema,
36
- WITHDRAWAL_AGREEMENT_MESSAGE,
37
- } from "../lib/form-schemas";
32
+ import { type CheckoutForm, type CheckoutFormInput, checkoutFormSchema } from "../lib/form-schemas";
38
33
  import { formatPrice } from "../lib/format";
39
34
  import { pageTitle } from "../lib/page-title";
40
35
  import { forgetPaymentOptions, rememberPaymentOptions } from "../lib/payment-options";
@@ -132,7 +127,7 @@ const paymentForm = z.object({
132
127
  option: paymentOptionSchema,
133
128
  /** 결제에 실패하면 돌아올 주문서 주소 */
134
129
  back: z.string(),
135
- /** 배송지를 보내는가 — 주문서의 `requiresShipping`. false면(이용권·방문 수령 등) 배송지를 싣지 않는다 */
130
+ /** 배송지를 보내는가 — 주문서의 `requiresShipping`. false면(배송 없는 상품만 담은 주문) 배송지를 싣지 않는다 */
136
131
  requiresShipping: z.boolean(),
137
132
  receiverName: z.string(),
138
133
  phone: z.string(),
@@ -147,8 +142,6 @@ const paymentForm = z.object({
147
142
  cashReceipt: cashReceiptRequestSchema.optional(),
148
143
  /** 적용한 쿠폰 코드 — 미리보기로 확인한 값 */
149
144
  couponCode: z.string().regex(COUPON_CODE_PATTERN).optional(),
150
- /** 청약철회 제한 동의 — 주문서의 `withdrawal.required`가 true일 때 구매자가 체크한 값 */
151
- withdrawalAgreement: z.boolean().optional(),
152
145
  });
153
146
 
154
147
  /** 결제 시작은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다. 결제창 호출 값(공개 값)만 내려준다 */
@@ -184,8 +177,6 @@ const startPayment = createServerFn({ method: "POST" })
184
177
  },
185
178
  cashReceipt: data.cashReceipt,
186
179
  ...(data.couponCode ? { coupons: [{ code: data.couponCode }] } : {}),
187
- // 제한 상품이 없는 주문은 보내지 않는다 — 빠지면 서버가 400 WITHDRAWAL_AGREEMENT_REQUIRED다
188
- ...(data.withdrawalAgreement ? { withdrawalAgreement: true } : {}),
189
180
  });
190
181
  // 결제 시작 값은 JSON으로 넘기고 브라우저에서 SDK 스키마로 다시 읽는다 — 서버 함수 직렬화 타입에 맞춘다
191
182
  return { start: JSON.stringify(start), error: null, couponFailed: false };
@@ -220,8 +211,6 @@ function startErrorMessage(error: unknown): string {
220
211
  return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해 주십시오.";
221
212
  case "PAYMENT_NOT_CONFIGURED":
222
213
  return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해 주십시오.";
223
- case "WITHDRAWAL_AGREEMENT_REQUIRED":
224
- return WITHDRAWAL_AGREEMENT_MESSAGE;
225
214
  case "CASH_RECEIPT_NOT_AVAILABLE":
226
215
  return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있습니다.";
227
216
  case "COUPON_NOT_APPLICABLE": {
@@ -259,10 +248,8 @@ function Checkout() {
259
248
  const [notice, setNotice] = useState<string | null>(null);
260
249
  const [submitting, setSubmitting] = useState(false);
261
250
  const options = checkout.paymentOptions;
262
- /** 배송이 필요한 주문인가 — false면(이용권·방문 수령 등만 담은 주문) 배송지·배송비를 보이지 않는다 */
251
+ /** 배송이 필요한 주문인가 — false면(배송 없는 상품만 담은 주문) 배송지·배송비를 보이지 않는다 */
263
252
  const shipping = checkout.requiresShipping;
264
- /** 청약철회 제한 상품(제공이 시작되면 취소·반품할 수 없는 파일·코드·이용권)이 있는가 — 결제 전에 안내하고 동의를 받는다 */
265
- const withdrawal = checkout.withdrawal;
266
253
  const defaultOptionKey = options[0] ? paymentOptionKey(options[0]) : "";
267
254
  /**
268
255
  * 폼 스키마는 주문서마다 한 번 만든다 — 비회원 여부와 현금영수증을 받을 수 있는 결제 옵션이 주문서에서 정해진다.
@@ -273,12 +260,11 @@ function Checkout() {
273
260
  checkoutFormSchema({
274
261
  guest: !member,
275
262
  shipping,
276
- withdrawal: withdrawal.required,
277
263
  cashReceiptOptionKeys: new Set(
278
264
  options.filter((option) => cashReceiptAvailable(option.method)).map(paymentOptionKey),
279
265
  ),
280
266
  }),
281
- [member, options, shipping, withdrawal.required],
267
+ [member, options, shipping],
282
268
  );
283
269
  const form = useForm<CheckoutFormInput, unknown, CheckoutForm>({
284
270
  resolver: zodResolver(schema),
@@ -287,7 +273,6 @@ function Checkout() {
287
273
  paymentOption: defaultOptionKey,
288
274
  cashReceiptOn: false,
289
275
  cashReceiptType: "INCOME_DEDUCTION",
290
- withdrawalAgreed: false,
291
276
  },
292
277
  });
293
278
  const { errors } = form.formState;
@@ -482,7 +467,6 @@ function Checkout() {
482
467
  orderPassword: values.orderPassword ?? "",
483
468
  cashReceipt,
484
469
  couponCode: coupon?.code,
485
- withdrawalAgreement: withdrawal.required ? values.withdrawalAgreed === true : undefined,
486
470
  },
487
471
  })
488
472
  .then(async ({ start, error, couponFailed }) => {
@@ -515,7 +499,7 @@ function Checkout() {
515
499
  return (
516
500
  <div className="grid gap-8 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:items-start">
517
501
  <form onSubmit={handleSubmit} className="space-y-6">
518
- {/* 배송이 필요 없는 주문(이용권·방문 수령 등)은 배송지를 받지 않는다 */}
502
+ {/* 배송이 필요 없는 주문(배송 없는 상품만 담은 주문)은 배송지를 받지 않는다 */}
519
503
  {shipping ? (
520
504
  <section className="space-y-3">
521
505
  <h2 className="font-bold text-lg">배송지</h2>
@@ -723,24 +707,6 @@ function Checkout() {
723
707
  ) : null}
724
708
  </section>
725
709
 
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
-
744
710
  <SubmitButton
745
711
  disabled={submitting || couponBusy || options.length === 0}
746
712
  className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
@@ -5,22 +5,18 @@ import { createServerFn } from "@tanstack/react-start";
5
5
  import { useState } from "react";
6
6
  import { useForm } from "react-hook-form";
7
7
  import { z } from "zod";
8
- import { FulfillmentCodes } from "../components/fulfillment-codes";
9
- import { FulfillmentDownloads } from "../components/fulfillment-downloads";
10
8
  import { ProductThumb } from "../components/product-thumb";
11
9
  import { SubmitButton } from "../components/submit-button";
12
10
  import { TextField } from "../components/text-field";
13
11
  import { apiFor } from "../lib/api.server";
14
- import { type CodeRevealResult, codeRevealErrorMessage, normalizePhone } from "../lib/code-reveal";
15
- import { type DownloadResult, downloadErrorMessage } from "../lib/download";
16
12
  import {
17
13
  type GuestOrderLookup,
18
14
  type GuestOrderLookupInput,
19
15
  guestOrderLookupSchema,
20
16
  } from "../lib/form-schemas";
21
17
  import { formatDateTime, formatPrice } from "../lib/format";
22
- import { guestLookupLockedMessage } from "../lib/guest-order";
23
- import { orderItemStatusLabel, withdrawalRestrictedLabel } from "../lib/order-status";
18
+ import { guestLookupLockedMessage, normalizePhone } from "../lib/guest-order";
19
+ import { orderItemStatusLabel } from "../lib/order-status";
24
20
  import { pageTitle } from "../lib/page-title";
25
21
 
26
22
  const guestOrderSearch = z.object({
@@ -58,58 +54,6 @@ const lookupGuestOrder = createServerFn({ method: "POST" })
58
54
  }
59
55
  });
60
56
 
61
- /**
62
- * 비회원 코드 열람 — 조회에 쓴 연락처·주문 조회 비밀번호로 다시 확인한다(주문 조회와 같은 시도 한도).
63
- * 비밀번호는 서버 함수 본문(POST)으로만 보내고 주소에 남기지 않는다.
64
- */
65
- const revealGuestCode = createServerFn({ method: "POST" })
66
- .validator(
67
- z.object({
68
- orderId: z.string(),
69
- orderItemId: z.string(),
70
- phone: z.string(),
71
- orderPassword: z.string(),
72
- }),
73
- )
74
- .handler(async ({ data }): Promise<CodeRevealResult> => {
75
- try {
76
- const result = await apiFor().myOrders.revealGuestCode(data.orderId, data.orderItemId, {
77
- phone: normalizePhone(data.phone),
78
- orderPassword: data.orderPassword,
79
- });
80
- return { codes: result.codes, error: null };
81
- } catch (error) {
82
- return { codes: null, error: codeRevealErrorMessage(error) };
83
- }
84
- });
85
-
86
- /**
87
- * 비회원 파일 받기 — 조회에 쓴 연락처·주문 조회 비밀번호로 다시 확인하고 서명 주소(수명 300초)를 받는다. 받을 때마다
88
- * 횟수를 하나 쓴다. 비밀번호는 서버 함수 본문(POST)으로만 보내고 주소에 남기지 않는다.
89
- */
90
- const createGuestDownload = createServerFn({ method: "POST" })
91
- .validator(
92
- z.object({
93
- orderId: z.string(),
94
- orderItemId: z.string(),
95
- assetId: z.string(),
96
- phone: z.string(),
97
- orderPassword: z.string(),
98
- }),
99
- )
100
- .handler(async ({ data }): Promise<DownloadResult> => {
101
- try {
102
- const link = await apiFor().myOrders.createGuestDownload(data.orderId, data.orderItemId, {
103
- phone: normalizePhone(data.phone),
104
- orderPassword: data.orderPassword,
105
- assetId: data.assetId,
106
- });
107
- return { url: link.url, fileName: link.fileName, remaining: link.remaining, error: null };
108
- } catch (error) {
109
- return { url: null, error: downloadErrorMessage(error) };
110
- }
111
- });
112
-
113
57
  export const Route = createFileRoute("/guest-order")({
114
58
  validateSearch: guestOrderSearch,
115
59
  head: ({ matches }) => ({
@@ -125,8 +69,6 @@ export const Route = createFileRoute("/guest-order")({
125
69
  function GuestOrder() {
126
70
  const { orderId } = Route.useSearch();
127
71
  const [order, setOrder] = useState<MyOrder | null>(null);
128
- // 코드 열람에 다시 쓰는 조회 값 — 이 컴포넌트 상태에만 둔다(주소·스토리지에 남기지 않는다)
129
- const [lookup, setLookup] = useState<GuestOrderLookup | null>(null);
130
72
  const [error, setError] = useState<string | null>(null);
131
73
  const form = useForm<GuestOrderLookupInput, unknown, GuestOrderLookup>({
132
74
  resolver: zodResolver(guestOrderLookupSchema),
@@ -138,11 +80,9 @@ function GuestOrder() {
138
80
  try {
139
81
  const result = await lookupGuestOrder({ data: values });
140
82
  setOrder(result.order);
141
- setLookup(result.order ? values : null);
142
83
  setError(result.error);
143
84
  } catch {
144
85
  setOrder(null);
145
- setLookup(null);
146
86
  setError("조회하지 못했습니다. 잠시 후 다시 시도해 주십시오");
147
87
  }
148
88
  });
@@ -218,42 +158,14 @@ function GuestOrder() {
218
158
  {orderItemStatusLabel(item.status, item.fulfillmentSnapshot)} · 수량{" "}
219
159
  {item.quantity}개
220
160
  </span>
221
- {withdrawalRestrictedLabel(item) ? (
222
- <span className="text-muted text-xs">{withdrawalRestrictedLabel(item)}</span>
223
- ) : null}
224
161
  </span>
225
162
  <span>{formatPrice(item.totalPrice)}</span>
226
163
  </div>
227
- {lookup ? (
228
- <FulfillmentCodes
229
- item={item}
230
- reveal={() =>
231
- revealGuestCode({
232
- data: {
233
- orderId: order.orderId,
234
- orderItemId: item.orderItemId,
235
- phone: lookup.phone,
236
- orderPassword: lookup.orderPassword,
237
- },
238
- })
239
- }
240
- />
241
- ) : null}
242
- {lookup ? (
243
- <FulfillmentDownloads
244
- item={item}
245
- download={(assetId) =>
246
- createGuestDownload({
247
- data: {
248
- orderId: order.orderId,
249
- orderItemId: item.orderItemId,
250
- assetId,
251
- phone: lookup.phone,
252
- orderPassword: lookup.orderPassword,
253
- },
254
- })
255
- }
256
- />
164
+ {item.fulfillment?.status === "FULFILLED" && item.fulfillment.note ? (
165
+ <dl className="space-y-0.5 rounded-md bg-chip p-3 text-xs">
166
+ <dt className="font-semibold">이용 안내</dt>
167
+ <dd className="whitespace-pre-line">{item.fulfillment.note}</dd>
168
+ </dl>
257
169
  ) : null}
258
170
  </li>
259
171
  ))}
@@ -11,19 +11,11 @@ import { createFileRoute, Link, notFound, redirect, useRouter } from "@tanstack/
11
11
  import { createServerFn } from "@tanstack/react-start";
12
12
  import { useState } from "react";
13
13
  import { z } from "zod";
14
- import { FulfillmentCodes } from "../components/fulfillment-codes";
15
- import { FulfillmentDownloads } from "../components/fulfillment-downloads";
16
14
  import { ProductThumb } from "../components/product-thumb";
17
15
  import { SubmitButton } from "../components/submit-button";
18
16
  import { apiFor } from "../lib/api.server";
19
- import { type CodeRevealResult, codeRevealErrorMessage } from "../lib/code-reveal";
20
- import { type DownloadResult, downloadErrorMessage } from "../lib/download";
21
17
  import { formatDateTime, formatPrice } from "../lib/format";
22
- import {
23
- orderItemStatusLabel,
24
- WITHDRAWAL_RESTRICTED_CLAIM_MESSAGE,
25
- withdrawalRestrictedLabel,
26
- } from "../lib/order-status";
18
+ import { orderItemStatusLabel } from "../lib/order-status";
27
19
  import { pageTitle } from "../lib/page-title";
28
20
  import { readToken } from "../lib/session.server";
29
21
 
@@ -42,42 +34,6 @@ const getOrder = createServerFn({ method: "GET" })
42
34
  }
43
35
  });
44
36
 
45
- /**
46
- * 코드 열람 — 코드 발급 상품의 평문 코드를 받는다. 처음 연 시각이 기록된다. 구매자 토큰은 서버에만 두므로 서버
47
- * 함수에서 부르고, 평문은 응답으로만 돌려준다(저장하지 않는다).
48
- */
49
- const revealCode = createServerFn({ method: "POST" })
50
- .validator(z.object({ orderItemId: z.string() }))
51
- .handler(async ({ data }): Promise<CodeRevealResult> => {
52
- const accessToken = readToken();
53
- if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
54
- try {
55
- const result = await apiFor({ accessToken }).myOrders.revealCode(data.orderItemId);
56
- return { codes: result.codes, error: null };
57
- } catch (error) {
58
- return { codes: null, error: codeRevealErrorMessage(error) };
59
- }
60
- });
61
-
62
- /**
63
- * 파일 받기 — 파일 다운로드 상품의 서명 주소(수명 300초)를 받는다. 받을 때마다 횟수를 하나 쓴다. 구매자 토큰은 서버에만
64
- * 두므로 서버 함수에서 부르고, 주소는 응답으로만 돌려준다(저장하지 않는다).
65
- */
66
- const createDownload = createServerFn({ method: "POST" })
67
- .validator(z.object({ orderItemId: z.string(), assetId: z.string() }))
68
- .handler(async ({ data }): Promise<DownloadResult> => {
69
- const accessToken = readToken();
70
- if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/orders" } });
71
- try {
72
- const link = await apiFor({ accessToken }).myOrders.createDownload(data.orderItemId, {
73
- assetId: data.assetId,
74
- });
75
- return { url: link.url, fileName: link.fileName, remaining: link.remaining, error: null };
76
- } catch (error) {
77
- return { url: null, error: downloadErrorMessage(error) };
78
- }
79
- });
80
-
81
37
  /**
82
38
  * 취소·반품 접수 — 수량을 실으면 그 수량만 접수된다(생략하면 남은 수량 전체).
83
39
  * 상한은 주문 상품의 `activeQuantity`이고 넘기면 서버가 400 `INVALID_QUANTITY`로 거절한다.
@@ -118,8 +74,6 @@ function claimErrorMessage(error: unknown): string {
118
74
  return "지금은 신청할 수 없는 상태입니다. 주문 상태를 확인해 주십시오.";
119
75
  case "PERIOD_EXPIRED":
120
76
  return "반품·교환 가능 기간이 지났습니다.";
121
- case "WITHDRAWAL_RESTRICTED":
122
- return WITHDRAWAL_RESTRICTED_CLAIM_MESSAGE;
123
77
  default:
124
78
  return error.message || "접수하지 못했습니다. 잠시 후 다시 시도해 주십시오.";
125
79
  }
@@ -236,24 +190,11 @@ function OrderDetail() {
236
190
  ? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
237
191
  : null}
238
192
  </span>
239
- {withdrawalRestrictedLabel(item) ? (
240
- <span className="text-muted text-xs">{withdrawalRestrictedLabel(item)}</span>
241
- ) : null}
242
193
  </span>
243
194
  <span className="text-sm">{formatPrice(item.totalPrice)}</span>
244
195
  </div>
245
196
 
246
197
  <FulfillmentInfo item={item} />
247
- <FulfillmentCodes
248
- item={item}
249
- reveal={() => revealCode({ data: { orderItemId: item.orderItemId } })}
250
- />
251
- <FulfillmentDownloads
252
- item={item}
253
- download={(assetId) =>
254
- createDownload({ data: { orderItemId: item.orderItemId, assetId } })
255
- }
256
- />
257
198
 
258
199
  {claimableTypes(item.status).length > 0 &&
259
200
  !item.claimStatus &&
@@ -310,34 +251,16 @@ function OrderDetail() {
310
251
  }
311
252
 
312
253
  /**
313
- * 배송이 없는 상품의 받는 방법 — 방문 수령 장소와 셀러가 제공 처리하며 남긴 이용 안내(이슈 #48).
314
- * 배송 상품이거나 보여 줄 것이 없으면 그리지 않는다. 배송 상품이 아니므로 배송 조회 링크가 없다.
254
+ * 배송이 없는 상품의 이용 안내 — 판매자가 제공 처리하며 남긴 안내(이슈 #48). 배송 상품이거나 안내가 없으면 그리지 않는다.
255
+ * 배송 상품이 아니므로 배송 조회 링크가 없다.
315
256
  */
316
257
  function FulfillmentInfo({ item }: { item: MyOrderItem }) {
317
- const snapshot = item.fulfillmentSnapshot;
318
- // 수령 장소는 주문 시점 스냅샷이다 — 셀러가 나중에 수령지를 바꿔도 이 주문의 안내는 그대로다
319
- const pickup = snapshot.type === "PICKUP" ? snapshot.pickup.location : null;
320
258
  const note = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.note : null;
321
- if (!pickup && !note) return null;
259
+ if (!note) return null;
322
260
  return (
323
- <dl className="space-y-2 rounded-md bg-chip p-3 text-xs">
324
- {pickup ? (
325
- <div className="space-y-0.5">
326
- <dt className="font-semibold">수령 장소</dt>
327
- <dd>{pickup.name}</dd>
328
- <dd className="text-muted">
329
- ({pickup.zipCode}) {pickup.address1}
330
- {pickup.address2 ? ` ${pickup.address2}` : ""}
331
- </dd>
332
- <dd className="text-muted">{pickup.phone}</dd>
333
- </div>
334
- ) : null}
335
- {note ? (
336
- <div className="space-y-0.5">
337
- <dt className="font-semibold">이용 안내</dt>
338
- <dd className="whitespace-pre-line">{note}</dd>
339
- </div>
340
- ) : null}
261
+ <dl className="space-y-0.5 rounded-md bg-chip p-3 text-xs">
262
+ <dt className="font-semibold">이용 안내</dt>
263
+ <dd className="whitespace-pre-line">{note}</dd>
341
264
  </dl>
342
265
  );
343
266
  }
@@ -59,7 +59,7 @@ export const Route = createFileRoute("/products/$productId")({
59
59
  component: ProductDetail,
60
60
  });
61
61
 
62
- /** 상품 상세의 받는 방법 한 줄 — 배송 상품은 배송비·예상 기간, 그 밖의 유형은 배송 없이 받는 방법이다 */
62
+ /** 상품 상세의 받는 방법 한 줄 — 배송 상품은 배송비·예상 기간, 배송 없는 상품은 판매자가 직접 제공한다 */
63
63
  function deliveryNoteOf(fulfillment: ProductFulfillmentView): string {
64
64
  switch (fulfillment.type) {
65
65
  case "SHIPPING": {
@@ -67,14 +67,8 @@ function deliveryNoteOf(fulfillment: ProductFulfillmentView): string {
67
67
  const fee = deliveryType === "FREE" ? "무료배송" : `배송비 ${formatPrice(deliveryFee)}`;
68
68
  return estimatedDays ? `${fee} · ${estimatedDays}` : fee;
69
69
  }
70
- case "DOWNLOAD":
71
- return "결제 후 바로 내려받기";
72
- case "CODE":
73
- return "결제 후 코드 제공";
74
- case "PICKUP":
75
- return fulfillment.pickup.location
76
- ? `방문 수령 · ${fulfillment.pickup.location.name}`
77
- : "방문 수령";
70
+ case "MANUAL":
71
+ return "배송 없이 판매자가 제공";
78
72
  default:
79
73
  return fulfillment.requiresShipping ? "택배 배송" : "배송 없이 제공";
80
74
  }
@@ -1,110 +0,0 @@
1
- import type { FulfillmentReveal, MyOrderItem } from "@sayren/storefront-sdk";
2
- import { useHydrated } from "@tanstack/react-router";
3
- import { useState } from "react";
4
- import type { CodeRevealResult } from "../lib/code-reveal";
5
- import { formatDateTime } from "../lib/format";
6
-
7
- type RevealedCode = FulfillmentReveal["codes"][number];
8
-
9
- /**
10
- * 코드 발급 상품의 코드 — 가린 값을 보이고 「코드 보기」를 누르면 평문을 받아 이 컴포넌트 상태에만 둔다.
11
- * 평문은 주소·스토리지에 남기지 않는다. 코드 발급 상품이 아니거나 받은 코드가 없으면 그리지 않는다.
12
- *
13
- * `reveal`은 서버 함수를 부르는 호출부 몫이다 — 회원은 액세스 토큰, 비회원은 조회에 쓴 연락처·비밀번호로 확인한다.
14
- */
15
- export function FulfillmentCodes({
16
- item,
17
- reveal,
18
- }: {
19
- item: MyOrderItem;
20
- reveal: () => Promise<CodeRevealResult>;
21
- }) {
22
- const hydrated = useHydrated();
23
- const [revealed, setRevealed] = useState<RevealedCode[] | null>(null);
24
- const [pending, setPending] = useState(false);
25
- const [error, setError] = useState<string | null>(null);
26
- const codes = item.fulfillment?.status === "FULFILLED" ? (item.fulfillment.codes ?? []) : [];
27
- if (item.fulfillmentSnapshot.type !== "CODE" || codes.length === 0) return null;
28
-
29
- const open = async () => {
30
- setPending(true);
31
- setError(null);
32
- try {
33
- const result = await reveal();
34
- if (result.error !== null) setError(result.error);
35
- else setRevealed(result.codes);
36
- } catch {
37
- setError("코드를 불러오지 못했습니다. 잠시 후 다시 시도해 주십시오");
38
- } finally {
39
- setPending(false);
40
- }
41
- };
42
-
43
- return (
44
- <section aria-label="코드" className="space-y-2 rounded-md bg-chip p-3 text-xs">
45
- <p className="font-semibold">코드</p>
46
- {revealed ? (
47
- <ul className="space-y-1.5">
48
- {revealed.map((code) => (
49
- <li key={code.codeId} className="flex flex-wrap items-center gap-2">
50
- <code className="break-all rounded bg-white px-2 py-1 font-mono text-sm">
51
- {code.code}
52
- </code>
53
- <CopyButton value={code.code} />
54
- </li>
55
- ))}
56
- </ul>
57
- ) : (
58
- <>
59
- <ul className="space-y-1">
60
- {codes.map((code) => (
61
- <li key={code.codeId} className="flex flex-wrap items-center gap-2">
62
- <code className="font-mono text-sm">{code.hint}</code>
63
- {code.revealedAt ? (
64
- <span className="text-muted">처음 연 시각 {formatDateTime(code.revealedAt)}</span>
65
- ) : null}
66
- </li>
67
- ))}
68
- </ul>
69
- <div className="flex flex-wrap items-center gap-3">
70
- <button
71
- type="button"
72
- disabled={!hydrated || pending}
73
- onClick={() => void open()}
74
- className="h-8 rounded-md bg-ink px-3 font-semibold text-white disabled:opacity-50"
75
- >
76
- 코드 보기
77
- </button>
78
- <span className="text-muted">코드를 처음 연 시각이 기록됩니다.</span>
79
- </div>
80
- </>
81
- )}
82
- {error ? (
83
- <p role="alert" className="text-point">
84
- {error}
85
- </p>
86
- ) : null}
87
- </section>
88
- );
89
- }
90
-
91
- function CopyButton({ value }: { value: string }) {
92
- const [copied, setCopied] = useState(false);
93
- return (
94
- <button
95
- type="button"
96
- onClick={() => {
97
- navigator.clipboard
98
- .writeText(value)
99
- .then(() => {
100
- setCopied(true);
101
- setTimeout(() => setCopied(false), 1500);
102
- })
103
- .catch(() => undefined);
104
- }}
105
- className="h-7 rounded-md border border-line bg-white px-2 text-xs"
106
- >
107
- {copied ? "복사함" : "복사"}
108
- </button>
109
- );
110
- }
@@ -1,88 +0,0 @@
1
- import type { MyOrderItem } from "@sayren/storefront-sdk";
2
- import { useHydrated } from "@tanstack/react-router";
3
- import { useState } from "react";
4
- import { type DownloadResult, formatFileSize } from "../lib/download";
5
- import { formatDateTime } from "../lib/format";
6
-
7
- /**
8
- * 파일 다운로드 상품의 파일 — 남은 횟수·기한과 파일마다 「다운로드」 버튼을 보인다. 누르면 서명 주소(수명 300초)를 받아
9
- * 브라우저가 바로 그 주소로 간다. 받을 때마다 횟수를 하나 쓴다. 파일 다운로드 상품이 아니거나 제공 전이면 그리지 않는다.
10
- *
11
- * `download`는 서버 함수를 부르는 호출부 몫이다 — 회원은 액세스 토큰, 비회원은 조회에 쓴 연락처·비밀번호로 확인한다.
12
- */
13
- export function FulfillmentDownloads({
14
- item,
15
- download,
16
- }: {
17
- item: MyOrderItem;
18
- download: (assetId: string) => Promise<DownloadResult>;
19
- }) {
20
- const hydrated = useHydrated();
21
- const [pendingAssetId, setPendingAssetId] = useState<string | null>(null);
22
- const [remaining, setRemaining] = useState<number | null>(null);
23
- const [error, setError] = useState<string | null>(null);
24
- const info = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.download : null;
25
- if (item.fulfillmentSnapshot.type !== "DOWNLOAD" || !info) return null;
26
-
27
- const left = remaining ?? Math.max(info.limit - info.count, 0);
28
- const expired = info.expiresAt !== null && new Date(info.expiresAt).getTime() <= Date.now();
29
-
30
- const start = async (assetId: string) => {
31
- setPendingAssetId(assetId);
32
- setError(null);
33
- try {
34
- const result = await download(assetId);
35
- if (result.error !== null) {
36
- setError(result.error);
37
- return;
38
- }
39
- setRemaining(result.remaining);
40
- // 서명 주소는 곧 만료된다 — 저장하지 않고 바로 받는다
41
- window.location.assign(result.url);
42
- } catch {
43
- setError("파일을 받지 못했습니다. 잠시 후 다시 시도해 주십시오");
44
- } finally {
45
- setPendingAssetId(null);
46
- }
47
- };
48
-
49
- return (
50
- <section aria-label="다운로드" className="space-y-2 rounded-md bg-chip p-3 text-xs">
51
- <p className="font-semibold">다운로드</p>
52
- <p className="text-muted">
53
- 남은 횟수 {left}/{info.limit}회
54
- {info.expiresAt ? ` · 기한 ${formatDateTime(info.expiresAt)}` : ""}
55
- </p>
56
- {info.files.length === 0 ? (
57
- <p className="text-muted">받을 파일이 없습니다.</p>
58
- ) : (
59
- <ul className="space-y-1.5">
60
- {info.files.map((file) => (
61
- <li key={file.assetId} className="flex flex-wrap items-center gap-2">
62
- <span className="break-all text-sm">{file.fileName}</span>
63
- {file.sizeBytes !== null ? (
64
- <span className="text-muted">{formatFileSize(file.sizeBytes)}</span>
65
- ) : null}
66
- <button
67
- type="button"
68
- aria-label={`${file.fileName} 다운로드`}
69
- disabled={!hydrated || pendingAssetId !== null || left <= 0 || expired}
70
- onClick={() => void start(file.assetId)}
71
- className="ml-auto h-8 rounded-md bg-ink px-3 font-semibold text-white disabled:opacity-50"
72
- >
73
- 다운로드
74
- </button>
75
- </li>
76
- ))}
77
- </ul>
78
- )}
79
- {left <= 0 ? <p className="text-muted">다운로드 횟수를 모두 사용했습니다.</p> : null}
80
- {expired && left > 0 ? <p className="text-muted">다운로드 기간이 지났습니다.</p> : null}
81
- {error ? (
82
- <p role="alert" className="text-point">
83
- {error}
84
- </p>
85
- ) : null}
86
- </section>
87
- );
88
- }
@@ -1,30 +0,0 @@
1
- import { ApiError } from "@sayren/storefront-sdk";
2
- import { describe, expect, it } from "vitest";
3
- import { codeRevealErrorMessage, normalizePhone } from "./code-reveal";
4
-
5
- describe("codeRevealErrorMessage", () => {
6
- it("열람할 코드가 없으면(404) 주문 상태를 확인하게 한다", () => {
7
- expect(codeRevealErrorMessage(new ApiError(404, "FULFILLMENT_NOT_FOUND", "x"))).toBe(
8
- "열람할 코드가 없습니다. 주문 상태를 확인해 주십시오",
9
- );
10
- });
11
-
12
- it("시도 한도(429)는 남은 시간을 알린다", () => {
13
- const error = new ApiError(429, "TOO_MANY_REQUESTS", "x", undefined, undefined, {
14
- retryAfterSeconds: 600,
15
- });
16
- expect(codeRevealErrorMessage(error)).toContain("10분 뒤");
17
- });
18
-
19
- it("그 밖의 실패는 다시 시도하게 한다", () => {
20
- expect(codeRevealErrorMessage(new Error("network"))).toBe(
21
- "코드를 불러오지 못했습니다. 잠시 후 다시 시도해 주십시오",
22
- );
23
- });
24
- });
25
-
26
- describe("normalizePhone", () => {
27
- it("숫자만 남긴다", () => {
28
- expect(normalizePhone("010-1234 5678")).toBe("01012345678");
29
- });
30
- });
@@ -1,32 +0,0 @@
1
- import { ApiError, type FulfillmentReveal } from "@sayren/storefront-sdk";
2
- import { guestLookupLockedMessage } from "./guest-order";
3
-
4
- /**
5
- * 코드 발급 상품의 코드 열람 (sayren 이슈 #48).
6
- *
7
- * 주문 상세의 코드는 가린 값(`hint`)만 온다. 평문은 구매자가 「코드 보기」를 누를 때 서버 함수가
8
- * `myOrders.revealCode`(회원)·`myOrders.revealGuestCode`(비회원)로 받아 그 화면에만 보인다. 평문은
9
- * 주소·스토리지·쿠키에 두지 않는다. 처음 연 시각이 기록되고 셀러가 청약철회를 판단하는 근거가 된다.
10
- */
11
-
12
- /** 서버 함수 결과 — ApiError는 서버 함수 경계를 넘지 못하므로 문구로 바꿔 돌려준다 */
13
- export type CodeRevealResult =
14
- | { codes: FulfillmentReveal["codes"]; error: null }
15
- | { codes: null; error: string };
16
-
17
- /** 코드 열람 실패 → 구매자에게 보일 문구 */
18
- export function codeRevealErrorMessage(error: unknown): string {
19
- if (error instanceof ApiError) {
20
- if (error.status === 404) {
21
- return "열람할 코드가 없습니다. 주문 상태를 확인해 주십시오";
22
- }
23
- // 비회원 열람은 비회원 주문 조회와 같은 시도 한도를 쓴다
24
- if (error.status === 429) return guestLookupLockedMessage(error.details?.retryAfterSeconds);
25
- }
26
- return "코드를 불러오지 못했습니다. 잠시 후 다시 시도해 주십시오";
27
- }
28
-
29
- /** 비회원 열람에 싣는 연락처 — 조회 화면과 같게 숫자만 남긴다 */
30
- export function normalizePhone(phone: string): string {
31
- return phone.replace(/[^0-9]/g, "");
32
- }
@@ -1,41 +0,0 @@
1
- import { ApiError } from "@sayren/storefront-sdk";
2
- import { describe, expect, it } from "vitest";
3
- import { downloadErrorMessage, formatFileSize } from "./download";
4
-
5
- describe("downloadErrorMessage", () => {
6
- it("횟수를 다 썼거나 기간이 지나면 그대로 알린다", () => {
7
- expect(downloadErrorMessage(new ApiError(409, "DOWNLOAD_LIMIT_REACHED", "x"))).toBe(
8
- "다운로드 횟수를 모두 사용했습니다",
9
- );
10
- expect(downloadErrorMessage(new ApiError(409, "DOWNLOAD_EXPIRED", "x"))).toBe(
11
- "다운로드 기간이 지났습니다",
12
- );
13
- });
14
-
15
- it("받을 파일이 없으면(404) 주문 상태를 확인하게 한다", () => {
16
- expect(downloadErrorMessage(new ApiError(404, "FULFILLMENT_NOT_FOUND", "x"))).toBe(
17
- "받을 파일이 없습니다. 주문 상태를 확인해 주십시오",
18
- );
19
- });
20
-
21
- it("시도 한도(429)는 남은 시간을 알린다", () => {
22
- const error = new ApiError(429, "TOO_MANY_REQUESTS", "x", undefined, undefined, {
23
- retryAfterSeconds: 600,
24
- });
25
- expect(downloadErrorMessage(error)).toContain("10분 뒤");
26
- });
27
-
28
- it("그 밖의 실패는 다시 시도하게 한다", () => {
29
- expect(downloadErrorMessage(new Error("network"))).toBe(
30
- "파일을 받지 못했습니다. 잠시 후 다시 시도해 주십시오",
31
- );
32
- });
33
- });
34
-
35
- describe("formatFileSize", () => {
36
- it("1024 단위로 줄이고 모르면 빈 문자열이다", () => {
37
- expect(formatFileSize(512)).toBe("512B");
38
- expect(formatFileSize(1536)).toBe("1.5KB");
39
- expect(formatFileSize(null)).toBe("");
40
- });
41
- });
@@ -1,41 +0,0 @@
1
- import { ApiError } from "@sayren/storefront-sdk";
2
- import { guestLookupLockedMessage } from "./guest-order";
3
-
4
- /**
5
- * 파일 다운로드 상품의 파일 받기 (sayren 이슈 #48).
6
- *
7
- * 주문 상세에는 횟수·기한·파일 이름만 온다. 「다운로드」를 누르면 서버 함수가 `myOrders.createDownload`(회원)·
8
- * `myOrders.createGuestDownload`(비회원)로 수명 300초의 서명 주소를 받아 브라우저가 바로 그 주소로 간다.
9
- * 주소는 받을 때마다 횟수를 하나 쓰므로 저장하거나 공유하지 않는다.
10
- */
11
-
12
- /** 서버 함수 결과 — ApiError는 서버 함수 경계를 넘지 못하므로 문구로 바꿔 돌려준다 */
13
- export type DownloadResult =
14
- | { url: string; fileName: string; remaining: number; error: null }
15
- | { url: null; error: string };
16
-
17
- /** 다운로드 실패 → 구매자에게 보일 문구 */
18
- export function downloadErrorMessage(error: unknown): string {
19
- if (error instanceof ApiError) {
20
- if (error.code === "DOWNLOAD_LIMIT_REACHED") return "다운로드 횟수를 모두 사용했습니다";
21
- if (error.code === "DOWNLOAD_EXPIRED") return "다운로드 기간이 지났습니다";
22
- if (error.status === 404) return "받을 파일이 없습니다. 주문 상태를 확인해 주십시오";
23
- // 비회원 다운로드는 비회원 주문 조회와 같은 시도 한도를 쓴다
24
- if (error.status === 429) return guestLookupLockedMessage(error.details?.retryAfterSeconds);
25
- }
26
- return "파일을 받지 못했습니다. 잠시 후 다시 시도해 주십시오";
27
- }
28
-
29
- /** 파일 크기 — 1024 단위, 소수 한 자리. 모르면 빈 문자열 */
30
- export function formatFileSize(bytes: number | null | undefined): string {
31
- if (bytes === null || bytes === undefined || bytes < 0) return "";
32
- if (bytes < 1024) return `${bytes}B`;
33
- const units = ["KB", "MB", "GB"];
34
- let size = bytes / 1024;
35
- let unit = 0;
36
- while (size >= 1024 && unit < units.length - 1) {
37
- size /= 1024;
38
- unit += 1;
39
- }
40
- return `${size.toFixed(1)}${units[unit]}`;
41
- }