@sayren/mcp 0.2.2 → 0.2.3
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.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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/
|
|
16
|
-
"@sayren/
|
|
15
|
+
"@sayren/storefront-sdk": "^0.11.0",
|
|
16
|
+
"@sayren/store-sdk": "^0.11.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -82,6 +82,12 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
82
82
|
- 주문서(`/checkout`)는 들어올 때마다 주문서 세션을 새로 만든다. 조회 API가 없고, 가격과 재고를
|
|
83
83
|
매번 다시 계산해야 하기 때문이다.
|
|
84
84
|
- 비회원 장바구니는 서버가 발급한 토큰으로만 찾는다. 쿠키를 지우면 담은 상품이 사라진다.
|
|
85
|
+
- 배송비는 배송 묶음마다 한 번 붙는다. 같은 출고지 + 같은 배송 정책인 상품이 한 묶음이고, 제주·도서산간 추가
|
|
86
|
+
배송비는 묶음마다 더하며 무료배송이어도 부과한다. 주문서를 만들 때는 배송지를 몰라 주문서의 `delivery.zipCode`가
|
|
87
|
+
null이고 추가 배송비가 0이다 — 주문서(`/checkout`)는 우편번호 입력을 마칠 때(blur) `quoteDelivery` 서버 함수로
|
|
88
|
+
`checkout.quoteDelivery(checkoutId, { zipCode })`를 불러 금액 요약을 갱신한다(주문서를 바꾸지 않는 읽기 계산이다).
|
|
89
|
+
**최종 금액은 결제 시작 응답의 `amounts`·`delivery`다** — 서버가 결제 시작이 보낸 배송지로 배송비를 다시 계산하므로
|
|
90
|
+
미리보기와 다를 수 있고, 화면은 그 값으로 덮어쓴다.
|
|
85
91
|
- 취소·반품은 수량 단위다. 주문 상세(`/orders/$orderId`)의 신청 수량 상한은 주문 상품의 `activeQuantity`
|
|
86
92
|
(남은 수량)이고, 접수 결과의 `expectedRefundAmount`는 수량 비율 상품 금액에서 반품 배송비를 뺀 값이다.
|
|
87
93
|
배송비 환불은 `deliveryFeeRefundAmount`로 따로 온다. 교환은 희망 옵션(`exchangeOptionId`)이 필요해 이 화면에
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ApiError,
|
|
3
|
+
type CheckoutDelivery,
|
|
4
|
+
cashReceiptAvailable,
|
|
5
|
+
cashReceiptRequestSchema,
|
|
6
|
+
type DeliveryQuoteResult,
|
|
3
7
|
paymentOptionKey,
|
|
4
8
|
paymentOptionSchema,
|
|
5
9
|
paymentStartSchema,
|
|
@@ -47,6 +51,23 @@ const createCheckout = createServerFn({ method: "POST" })
|
|
|
47
51
|
return { checkout, member, config };
|
|
48
52
|
});
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 배송비 미리보기 — 주문서를 만들 때는 배송지를 몰라 제주·도서산간 추가 배송비가 빠져 있다.
|
|
56
|
+
* 우편번호를 받으면 다시 계산해 보여 준다(주문서를 바꾸지 않는 읽기 계산이다).
|
|
57
|
+
* 결제 시작과 마찬가지로 서버 함수로 부른다 — 구매자 토큰이 브라우저 JS에 나가지 않는다.
|
|
58
|
+
*/
|
|
59
|
+
const quoteDelivery = createServerFn({ method: "POST" })
|
|
60
|
+
.validator(z.object({ checkoutId: z.string(), zipCode: z.string() }))
|
|
61
|
+
.handler(async ({ data }) => {
|
|
62
|
+
const api = apiFor({ accessToken: readToken(), cartToken: readCartToken() });
|
|
63
|
+
try {
|
|
64
|
+
const quote = await api.checkout.quoteDelivery(data.checkoutId, { zipCode: data.zipCode });
|
|
65
|
+
return { quote, error: null };
|
|
66
|
+
} catch {
|
|
67
|
+
return { quote: null, error: "배송비를 확인하지 못했어요. 우편번호를 다시 확인해주세요." };
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
50
71
|
const paymentForm = z.object({
|
|
51
72
|
checkoutId: z.string(),
|
|
52
73
|
option: paymentOptionSchema,
|
|
@@ -61,6 +82,8 @@ const paymentForm = z.object({
|
|
|
61
82
|
guestName: z.string(),
|
|
62
83
|
email: z.string(),
|
|
63
84
|
orderPassword: z.string(),
|
|
85
|
+
/** 현금영수증 신청 — 계좌이체·가상계좌에서 구매자가 켰을 때만 */
|
|
86
|
+
cashReceipt: cashReceiptRequestSchema.optional(),
|
|
64
87
|
});
|
|
65
88
|
|
|
66
89
|
/** 결제 시작은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다. 결제창 호출 값(공개 값)만 내려준다 */
|
|
@@ -92,6 +115,7 @@ const startPayment = createServerFn({ method: "POST" })
|
|
|
92
115
|
email: data.email,
|
|
93
116
|
orderPassword: data.orderPassword,
|
|
94
117
|
},
|
|
118
|
+
cashReceipt: data.cashReceipt,
|
|
95
119
|
});
|
|
96
120
|
// 결제 시작 값은 JSON으로 넘기고 브라우저에서 SDK 스키마로 다시 읽는다 — 서버 함수 직렬화 타입에 맞춘다
|
|
97
121
|
return { start: JSON.stringify(start), error: null };
|
|
@@ -109,6 +133,8 @@ function startErrorMessage(error: unknown): string {
|
|
|
109
133
|
return "결제창을 준비하지 못했어요. 다른 결제수단으로 시도해주세요.";
|
|
110
134
|
case "PAYMENT_NOT_CONFIGURED":
|
|
111
135
|
return "결제 수단을 준비하고 있어요. 잠시 후 다시 시도해주세요.";
|
|
136
|
+
case "CASH_RECEIPT_NOT_AVAILABLE":
|
|
137
|
+
return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있어요.";
|
|
112
138
|
case "RETURN_URL_NOT_ALLOWED":
|
|
113
139
|
return "이 쇼핑몰 주소에서는 결제할 수 없어요. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주세요.";
|
|
114
140
|
default:
|
|
@@ -131,6 +157,38 @@ function Checkout() {
|
|
|
131
157
|
const [submitting, setSubmitting] = useState(false);
|
|
132
158
|
const options = checkout.paymentOptions;
|
|
133
159
|
const [selected, setSelected] = useState(options[0] ? paymentOptionKey(options[0]) : "");
|
|
160
|
+
/**
|
|
161
|
+
* 배송지를 반영한 금액. 배송비 미리보기와 결제 시작 응답이 덮어쓴다 — 최종 금액은 결제 시작 응답의 `amounts`다
|
|
162
|
+
* (서버가 보낸 배송지로 배송비를 다시 계산한다).
|
|
163
|
+
*/
|
|
164
|
+
const [quote, setQuote] = useState<DeliveryQuoteResult | null>(null);
|
|
165
|
+
/** 같은 우편번호로 blur가 반복돼도 다시 부르지 않는다 */
|
|
166
|
+
const [quotedZip, setQuotedZip] = useState<string | null>(null);
|
|
167
|
+
const amounts = quote?.amounts ?? checkout.amounts;
|
|
168
|
+
const delivery = quote?.delivery ?? checkout.delivery;
|
|
169
|
+
|
|
170
|
+
// 디바운스 타이머 대신 blur에서 부른다 — 우편번호는 5자리를 한 번에 채우는 값이고, FormData 폼에
|
|
171
|
+
// 입력 상태를 따로 들지 않아도 된다
|
|
172
|
+
const handleZipBlur = (zipCode: string) => {
|
|
173
|
+
if (!/^[0-9]{5}$/.test(zipCode) || zipCode === quotedZip) return;
|
|
174
|
+
setQuotedZip(zipCode);
|
|
175
|
+
void quoteDelivery({ data: { checkoutId: checkout.checkoutId, zipCode } })
|
|
176
|
+
.then(({ quote: next, error }) => {
|
|
177
|
+
if (next) setQuote(next);
|
|
178
|
+
else {
|
|
179
|
+
setQuotedZip(null);
|
|
180
|
+
setNotice(error);
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
.catch(() => setQuotedZip(null));
|
|
184
|
+
};
|
|
185
|
+
const [cashReceiptOn, setCashReceiptOn] = useState(false);
|
|
186
|
+
const [cashReceiptType, setCashReceiptType] = useState<"INCOME_DEDUCTION" | "EXPENSE_PROOF">(
|
|
187
|
+
"INCOME_DEDUCTION",
|
|
188
|
+
);
|
|
189
|
+
const selectedOption = options.find((item) => paymentOptionKey(item) === selected);
|
|
190
|
+
// 현금영수증은 계좌이체·가상계좌에서만 받는다
|
|
191
|
+
const cashReceiptShown = !!selectedOption && cashReceiptAvailable(selectedOption.method);
|
|
134
192
|
|
|
135
193
|
const settle = (result: PaymentResult, back: string) => {
|
|
136
194
|
if (result.status === "COMPLETED") {
|
|
@@ -168,6 +226,18 @@ function Checkout() {
|
|
|
168
226
|
}
|
|
169
227
|
const form = new FormData(event.currentTarget);
|
|
170
228
|
const field = (name: string) => String(form.get(name) ?? "");
|
|
229
|
+
let cashReceipt: z.infer<typeof cashReceiptRequestSchema> | undefined;
|
|
230
|
+
if (cashReceiptShown && cashReceiptOn) {
|
|
231
|
+
const parsed = cashReceiptRequestSchema.safeParse({
|
|
232
|
+
type: cashReceiptType,
|
|
233
|
+
identityNumber: field("cashReceiptNumber"),
|
|
234
|
+
});
|
|
235
|
+
if (!parsed.success) {
|
|
236
|
+
setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해주세요.");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
cashReceipt = parsed.data;
|
|
240
|
+
}
|
|
171
241
|
const back = window.location.pathname + window.location.search;
|
|
172
242
|
const payments = paymentsFor(config);
|
|
173
243
|
// 결제창은 클릭 시점에 열어야 팝업 차단에 걸리지 않는다 — 서버 함수를 기다리기 전에 빈 창을 먼저 연다
|
|
@@ -188,6 +258,7 @@ function Checkout() {
|
|
|
188
258
|
guestName: field("guestName"),
|
|
189
259
|
email: field("email"),
|
|
190
260
|
orderPassword: field("orderPassword"),
|
|
261
|
+
cashReceipt,
|
|
191
262
|
},
|
|
192
263
|
})
|
|
193
264
|
.then(async ({ start, error }) => {
|
|
@@ -196,13 +267,11 @@ function Checkout() {
|
|
|
196
267
|
setNotice(error);
|
|
197
268
|
return;
|
|
198
269
|
}
|
|
270
|
+
const paymentStart = paymentStartSchema.parse(JSON.parse(start));
|
|
271
|
+
// 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다. 화면에 보여 준 금액과 다를 수 있다
|
|
272
|
+
setQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });
|
|
199
273
|
// 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
|
|
200
|
-
settle(
|
|
201
|
-
await payments.open(paymentStartSchema.parse(JSON.parse(start)), {
|
|
202
|
-
window: paymentWindow,
|
|
203
|
-
}),
|
|
204
|
-
back,
|
|
205
|
-
);
|
|
274
|
+
settle(await payments.open(paymentStart, { window: paymentWindow }), back);
|
|
206
275
|
})
|
|
207
276
|
.catch(() => {
|
|
208
277
|
paymentWindow.close();
|
|
@@ -218,7 +287,14 @@ function Checkout() {
|
|
|
218
287
|
<h2 className="font-bold text-lg">배송지</h2>
|
|
219
288
|
<Field label="수령인" name="receiverName" defaultValue={member?.name ?? ""} required />
|
|
220
289
|
<Field label="연락처" name="phone" placeholder="01012345678" required />
|
|
221
|
-
<Field
|
|
290
|
+
<Field
|
|
291
|
+
label="우편번호"
|
|
292
|
+
name="zipCode"
|
|
293
|
+
placeholder="04524"
|
|
294
|
+
required
|
|
295
|
+
onBlur={handleZipBlur}
|
|
296
|
+
hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줘요"
|
|
297
|
+
/>
|
|
222
298
|
<Field label="주소" name="address1" required />
|
|
223
299
|
<Field label="상세 주소" name="address2" />
|
|
224
300
|
<Field label="배송 메모" name="deliveryMemo" />
|
|
@@ -272,11 +348,52 @@ function Checkout() {
|
|
|
272
348
|
)}
|
|
273
349
|
</fieldset>
|
|
274
350
|
|
|
351
|
+
{cashReceiptShown ? (
|
|
352
|
+
<fieldset className="space-y-3">
|
|
353
|
+
<legend className="mb-3 font-bold text-lg">현금영수증</legend>
|
|
354
|
+
<label className="flex items-center gap-2 text-sm">
|
|
355
|
+
<input
|
|
356
|
+
type="checkbox"
|
|
357
|
+
checked={cashReceiptOn}
|
|
358
|
+
onChange={(event) => setCashReceiptOn(event.target.checked)}
|
|
359
|
+
/>
|
|
360
|
+
현금영수증 신청
|
|
361
|
+
</label>
|
|
362
|
+
{cashReceiptOn ? (
|
|
363
|
+
<>
|
|
364
|
+
<label className="block space-y-1">
|
|
365
|
+
<span className="text-sm">용도</span>
|
|
366
|
+
<select
|
|
367
|
+
name="cashReceiptType"
|
|
368
|
+
value={cashReceiptType}
|
|
369
|
+
onChange={(event) =>
|
|
370
|
+
setCashReceiptType(event.target.value as typeof cashReceiptType)
|
|
371
|
+
}
|
|
372
|
+
className="w-full rounded-md border border-line px-3 py-2"
|
|
373
|
+
>
|
|
374
|
+
<option value="INCOME_DEDUCTION">소득공제</option>
|
|
375
|
+
<option value="EXPENSE_PROOF">지출증빙</option>
|
|
376
|
+
</select>
|
|
377
|
+
</label>
|
|
378
|
+
<Field
|
|
379
|
+
label={
|
|
380
|
+
cashReceiptType === "EXPENSE_PROOF"
|
|
381
|
+
? "사업자등록번호"
|
|
382
|
+
: "휴대폰 번호 또는 현금영수증카드 번호"
|
|
383
|
+
}
|
|
384
|
+
name="cashReceiptNumber"
|
|
385
|
+
required
|
|
386
|
+
/>
|
|
387
|
+
</>
|
|
388
|
+
) : null}
|
|
389
|
+
</fieldset>
|
|
390
|
+
) : null}
|
|
391
|
+
|
|
275
392
|
<SubmitButton
|
|
276
393
|
disabled={submitting || options.length === 0}
|
|
277
394
|
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
278
395
|
>
|
|
279
|
-
{formatPrice(
|
|
396
|
+
{formatPrice(amounts.totalAmount)} 결제하기
|
|
280
397
|
</SubmitButton>
|
|
281
398
|
</form>
|
|
282
399
|
|
|
@@ -300,13 +417,25 @@ function Checkout() {
|
|
|
300
417
|
))}
|
|
301
418
|
</ul>
|
|
302
419
|
<dl className="space-y-1 border-line border-t pt-3 text-sm">
|
|
303
|
-
<Row label="상품 금액" value={formatPrice(
|
|
304
|
-
|
|
420
|
+
<Row label="상품 금액" value={formatPrice(amounts.productAmount)} />
|
|
421
|
+
{/* 배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙고, 지역 추가 배송비는 묶음마다 더한다 */}
|
|
422
|
+
<Row label="기본 배송비" value={formatPrice(delivery.baseFee)} />
|
|
423
|
+
{delivery.remoteSurcharge > 0 ? (
|
|
424
|
+
<Row
|
|
425
|
+
label={`${delivery.remoteAreaLabel ?? "지역"} 추가 배송비`}
|
|
426
|
+
value={formatPrice(delivery.remoteSurcharge)}
|
|
427
|
+
/>
|
|
428
|
+
) : null}
|
|
305
429
|
<div className="flex justify-between border-line border-t pt-2 font-bold">
|
|
306
430
|
<dt>총 결제금액</dt>
|
|
307
|
-
<dd>{formatPrice(
|
|
431
|
+
<dd>{formatPrice(amounts.totalAmount)}</dd>
|
|
308
432
|
</div>
|
|
309
433
|
</dl>
|
|
434
|
+
{deliveryNotes(delivery).map((note) => (
|
|
435
|
+
<p key={note} className="text-muted text-xs">
|
|
436
|
+
{note}
|
|
437
|
+
</p>
|
|
438
|
+
))}
|
|
310
439
|
{checkout.testPayment ? (
|
|
311
440
|
<p className="rounded-md bg-chip px-3 py-2 text-xs">
|
|
312
441
|
테스트 결제예요. 실제로 돈이 오가지 않아요.
|
|
@@ -318,6 +447,17 @@ function Checkout() {
|
|
|
318
447
|
);
|
|
319
448
|
}
|
|
320
449
|
|
|
450
|
+
/** 배송비를 몇 번, 왜 부과했는지 알려 주는 안내 문구 */
|
|
451
|
+
function deliveryNotes(delivery: CheckoutDelivery): string[] {
|
|
452
|
+
const notes: string[] = [];
|
|
453
|
+
if (delivery.bundleCount >= 2) {
|
|
454
|
+
notes.push(`배송 묶음 ${delivery.bundleCount}건 — 묶음마다 배송비가 붙어요.`);
|
|
455
|
+
}
|
|
456
|
+
if (delivery.freeByThreshold) notes.push("무료배송 기준 금액을 넘겨 기본 배송비가 빠졌어요.");
|
|
457
|
+
if (delivery.zipCode === null) notes.push("배송지를 입력하면 배송비가 확정돼요.");
|
|
458
|
+
return notes;
|
|
459
|
+
}
|
|
460
|
+
|
|
321
461
|
function Field({
|
|
322
462
|
label,
|
|
323
463
|
name,
|
|
@@ -326,6 +466,7 @@ function Field({
|
|
|
326
466
|
placeholder,
|
|
327
467
|
required,
|
|
328
468
|
hint,
|
|
469
|
+
onBlur,
|
|
329
470
|
}: {
|
|
330
471
|
label: string;
|
|
331
472
|
name: string;
|
|
@@ -334,6 +475,7 @@ function Field({
|
|
|
334
475
|
placeholder?: string;
|
|
335
476
|
required?: boolean;
|
|
336
477
|
hint?: string;
|
|
478
|
+
onBlur?: (value: string) => void;
|
|
337
479
|
}) {
|
|
338
480
|
return (
|
|
339
481
|
<label className="block space-y-1">
|
|
@@ -348,6 +490,7 @@ function Field({
|
|
|
348
490
|
defaultValue={defaultValue}
|
|
349
491
|
placeholder={placeholder}
|
|
350
492
|
required={required}
|
|
493
|
+
onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined}
|
|
351
494
|
className="h-11 w-full rounded-md border border-line px-3"
|
|
352
495
|
/>
|
|
353
496
|
{hint ? <span className="text-muted text-xs">{hint}</span> : null}
|