@sayren/mcp 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.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/store-sdk": "^0.17.0",
16
- "@sayren/storefront-sdk": "^0.14.0"
15
+ "@sayren/store-sdk": "^0.18.0",
16
+ "@sayren/storefront-sdk": "^0.15.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.17.0",
12
- "@sayren/storefront-sdk": "0.14.0",
11
+ "@sayren/store-sdk": "0.18.0",
12
+ "@sayren/storefront-sdk": "0.15.0",
13
13
  "@sayren/ui": "0.1.0"
14
14
  }
15
15
  }
@@ -24,7 +24,7 @@ export function FulfillmentCodes({
24
24
  const [pending, setPending] = useState(false);
25
25
  const [error, setError] = useState<string | null>(null);
26
26
  const codes = item.fulfillment?.status === "FULFILLED" ? (item.fulfillment.codes ?? []) : [];
27
- if (item.fulfillmentType !== "CODE" || codes.length === 0) return null;
27
+ if (item.fulfillmentSnapshot.type !== "CODE" || codes.length === 0) return null;
28
28
 
29
29
  const open = async () => {
30
30
  setPending(true);
@@ -22,7 +22,7 @@ export function FulfillmentDownloads({
22
22
  const [remaining, setRemaining] = useState<number | null>(null);
23
23
  const [error, setError] = useState<string | null>(null);
24
24
  const info = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.download : null;
25
- if (item.fulfillmentType !== "DOWNLOAD" || !info) return null;
25
+ if (item.fulfillmentSnapshot.type !== "DOWNLOAD" || !info) return null;
26
26
 
27
27
  const left = remaining ?? Math.max(info.limit - info.count, 0);
28
28
  const expired = info.expiresAt !== null && new Date(info.expiresAt).getTime() <= Date.now();
@@ -1,29 +1,44 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { orderItemStatusLabel, withdrawalRestrictedLabel } from "./order-status";
3
3
 
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;
7
+
4
8
  describe("주문 상품 상태 라벨", () => {
5
9
  it("배송 상품은 배송 단계로 보인다", () => {
6
10
  expect(orderItemStatusLabel("DELIVERED")).toBe("배송 완료");
7
- expect(orderItemStatusLabel("DELIVERED", "SHIPPING")).toBe("배송 완료");
8
- expect(orderItemStatusLabel("CONFIRMED", "SHIPPING")).toBe("발송 준비");
11
+ expect(orderItemStatusLabel("DELIVERED", SHIPPING)).toBe("배송 완료");
12
+ expect(orderItemStatusLabel("CONFIRMED", SHIPPING)).toBe("발송 준비");
9
13
  });
10
14
 
11
15
  it("배송이 없는 상품의 DELIVERED는 제공 완료, 방문 수령은 수령 완료다", () => {
12
- expect(orderItemStatusLabel("DELIVERED", "SERVICE")).toBe("제공 완료");
13
- expect(orderItemStatusLabel("DELIVERED", "PICKUP")).toBe("수령 완료");
14
- expect(orderItemStatusLabel("CONFIRMED", "SERVICE")).toBe("준비 중");
15
- expect(orderItemStatusLabel("PAID", "PICKUP")).toBe("결제 완료");
16
+ expect(orderItemStatusLabel("DELIVERED", SERVICE)).toBe("제공 완료");
17
+ expect(orderItemStatusLabel("DELIVERED", PICKUP)).toBe("수령 완료");
18
+ expect(orderItemStatusLabel("CONFIRMED", SERVICE)).toBe("준비 중");
19
+ expect(orderItemStatusLabel("PAID", PICKUP)).toBe("결제 완료");
20
+ // 모르는 유형도 배송 여부로 가른다
21
+ expect(orderItemStatusLabel("DELIVERED", { type: "OTHER", requiresShipping: false })).toBe(
22
+ "제공 완료",
23
+ );
16
24
  });
17
25
 
18
26
  it("모르는 상태는 코드 그대로다", () => {
19
- expect(orderItemStatusLabel("ON_HOLD", "SERVICE")).toBe("ON_HOLD");
27
+ expect(orderItemStatusLabel("ON_HOLD", SERVICE)).toBe("ON_HOLD");
20
28
  });
21
29
  });
22
30
 
23
31
  describe("청약철회 제한 표시", () => {
24
32
  it("제한 주문상품에만 라벨을 붙인다", () => {
25
- expect(withdrawalRestrictedLabel({ withdrawalRestricted: true })).toBe("청약철회 제한 상품");
26
- expect(withdrawalRestrictedLabel({ withdrawalRestricted: false })).toBeNull();
27
- expect(withdrawalRestrictedLabel({})).toBeNull();
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();
28
43
  });
29
44
  });
@@ -1,4 +1,8 @@
1
- import { isShippingFulfillment, type OrderItemStatus } from "@sayren/storefront-sdk";
1
+ import {
2
+ type MyOrderItemFulfillmentSnapshot,
3
+ type OrderItemStatus,
4
+ withdrawalRestrictedOfView,
5
+ } from "@sayren/storefront-sdk";
2
6
 
3
7
  /** 주문 상품 상태 라벨 — 화면에 코드 대신 보인다. 서버가 상태를 더하면 코드 그대로 보인다 */
4
8
  const ORDER_ITEM_STATUS_LABELS: Record<OrderItemStatus, string> = {
@@ -22,9 +26,16 @@ function nonShippingLabel(status: string, fulfillmentType: string): string | nul
22
26
  return null;
23
27
  }
24
28
 
25
- export function orderItemStatusLabel(status: string, fulfillmentType?: string | null): string {
26
- if (fulfillmentType && !isShippingFulfillment(fulfillmentType)) {
27
- const label = nonShippingLabel(status, fulfillmentType);
29
+ /**
30
+ * 주문 상품 상태 라벨. `fulfillment`는 주문상품의 `fulfillmentSnapshot`(주문 시점 이행)이다 — 배송이 없는 상품은 제공 단계로
31
+ * 보인다. 모르는 유형도 `requiresShipping`으로 가른다
32
+ */
33
+ export function orderItemStatusLabel(
34
+ status: string,
35
+ fulfillment?: Pick<MyOrderItemFulfillmentSnapshot, "type" | "requiresShipping"> | null,
36
+ ): string {
37
+ if (fulfillment && !fulfillment.requiresShipping) {
38
+ const label = nonShippingLabel(status, fulfillment.type);
28
39
  if (label) return label;
29
40
  }
30
41
  return ORDER_ITEM_STATUS_LABELS[status as OrderItemStatus] ?? status;
@@ -34,8 +45,10 @@ export function orderItemStatusLabel(status: string, fulfillmentType?: string |
34
45
  * 청약철회 제한 주문상품(이슈 #48) — 구매자가 결제 전에 동의했고, 제공이 시작되면(첫 다운로드·열람) 단순 변심 취소·반품을
35
46
  * 받지 않는다. 주문 상세·비회원 주문 조회가 상품 옆에 붙인다.
36
47
  */
37
- export function withdrawalRestrictedLabel(item: { withdrawalRestricted?: boolean }): string | null {
38
- return item.withdrawalRestricted ? "청약철회 제한 상품" : null;
48
+ export function withdrawalRestrictedLabel(item: {
49
+ fulfillmentSnapshot: MyOrderItemFulfillmentSnapshot;
50
+ }): string | null {
51
+ return withdrawalRestrictedOfView(item.fulfillmentSnapshot) ? "청약철회 제한 상품" : null;
39
52
  }
40
53
 
41
54
  /** 제공이 시작된 청약철회 제한 상품의 취소·반품 접수 거절(409 `WITHDRAWAL_RESTRICTED`) 안내 */
@@ -215,7 +215,7 @@ function GuestOrder() {
215
215
  <span className="text-muted text-xs">{item.optionName}</span>
216
216
  ) : null}
217
217
  <span className="text-muted text-xs">
218
- {orderItemStatusLabel(item.status, item.fulfillmentType)} · 수량{" "}
218
+ {orderItemStatusLabel(item.status, item.fulfillmentSnapshot)} · 수량{" "}
219
219
  {item.quantity}개
220
220
  </span>
221
221
  {withdrawalRestrictedLabel(item) ? (
@@ -230,7 +230,8 @@ function OrderDetail() {
230
230
  <span className="text-muted text-xs">{item.optionName}</span>
231
231
  ) : null}
232
232
  <span className="text-muted text-xs">
233
- {orderItemStatusLabel(item.status, item.fulfillmentType)} · 수량 {item.quantity}개
233
+ {orderItemStatusLabel(item.status, item.fulfillmentSnapshot)} · 수량{" "}
234
+ {item.quantity}개
234
235
  {item.canceledQuantity > 0
235
236
  ? ` · 취소·반품 ${item.canceledQuantity}개 · 남은 수량 ${item.activeQuantity}개`
236
237
  : null}
@@ -313,7 +314,9 @@ function OrderDetail() {
313
314
  * 배송 상품이거나 보여 줄 것이 없으면 그리지 않는다. 배송 상품이 아니므로 배송 조회 링크가 없다.
314
315
  */
315
316
  function FulfillmentInfo({ item }: { item: MyOrderItem }) {
316
- const pickup = item.fulfillmentType === "PICKUP" ? item.pickupLocation : null;
317
+ const snapshot = item.fulfillmentSnapshot;
318
+ // 수령 장소는 주문 시점 스냅샷이다 — 셀러가 나중에 수령지를 바꿔도 이 주문의 안내는 그대로다
319
+ const pickup = snapshot.type === "PICKUP" ? snapshot.pickup.location : null;
317
320
  const note = item.fulfillment?.status === "FULFILLED" ? item.fulfillment.note : null;
318
321
  if (!pickup && !note) return null;
319
322
  return (
@@ -1,4 +1,4 @@
1
- import { ApiError } from "@sayren/storefront-sdk";
1
+ import { ApiError, type ProductFulfillmentView } from "@sayren/storefront-sdk";
2
2
  import { createFileRoute, Link, notFound, useNavigate } from "@tanstack/react-router";
3
3
  import { createServerFn } from "@tanstack/react-start";
4
4
  import { useState } from "react";
@@ -59,6 +59,27 @@ export const Route = createFileRoute("/products/$productId")({
59
59
  component: ProductDetail,
60
60
  });
61
61
 
62
+ /** 상품 상세의 받는 방법 한 줄 — 배송 상품은 배송비·예상 기간, 그 밖의 유형은 배송 없이 받는 방법이다 */
63
+ function deliveryNoteOf(fulfillment: ProductFulfillmentView): string {
64
+ switch (fulfillment.type) {
65
+ case "SHIPPING": {
66
+ const { deliveryType, deliveryFee, estimatedDays } = fulfillment.shipping;
67
+ const fee = deliveryType === "FREE" ? "무료배송" : `배송비 ${formatPrice(deliveryFee)}`;
68
+ return estimatedDays ? `${fee} · ${estimatedDays}` : fee;
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
+ : "방문 수령";
78
+ default:
79
+ return fulfillment.requiresShipping ? "택배 배송" : "배송 없이 제공";
80
+ }
81
+ }
82
+
62
83
  function ProductDetail() {
63
84
  const product = Route.useLoaderData();
64
85
  useTrack({ name: "product_view", productId: product.productId });
@@ -98,12 +119,7 @@ function ProductDetail() {
98
119
  <div className="space-y-2">
99
120
  <h1 className="font-bold text-2xl">{product.name}</h1>
100
121
  <p className="font-bold text-xl">{formatPrice(price)}</p>
101
- <p className="text-muted text-sm">
102
- {product.delivery.type === "FREE"
103
- ? "무료배송"
104
- : `배송비 ${formatPrice(product.delivery.fee)}`}
105
- {product.delivery.estimatedDays ? ` · ${product.delivery.estimatedDays}` : ""}
106
- </p>
122
+ <p className="text-muted text-sm">{deliveryNoteOf(product.fulfillment)}</p>
107
123
  </div>
108
124
 
109
125
  <form