@sayren/mcp 0.1.11 → 0.2.1
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 +47 -48
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/README.md +9 -6
- package/template/src/components/product-card.tsx +2 -2
- package/template/src/components/product-thumb.tsx +27 -0
- package/template/src/lib/api.server.ts +0 -7
- package/template/src/lib/api.ts +1 -1
- package/template/src/lib/payments.ts +31 -0
- package/template/src/routeTree.gen.ts +21 -0
- package/template/src/routes/cart.tsx +2 -2
- package/template/src/routes/checkout.index.tsx +149 -85
- package/template/src/routes/checkout.return.tsx +143 -0
- package/template/src/routes/orders.$orderId.tsx +2 -2
- package/template/src/routes/products.$productId.tsx +2 -1
- package/template/src/lib/payment-popup.ts +0 -98
package/dist/index.mjs
CHANGED
|
@@ -510,24 +510,29 @@ const RULES = [
|
|
|
510
510
|
why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다"
|
|
511
511
|
},
|
|
512
512
|
{
|
|
513
|
-
id: "
|
|
514
|
-
title: "
|
|
515
|
-
why: "
|
|
513
|
+
id: "payment-browser-launch",
|
|
514
|
+
title: "결제창은 브라우저에서 `@sayren/storefront-sdk/payments`로 연다(`start` 또는 `open`)",
|
|
515
|
+
why: "결제창은 결제 서비스 주소를 브라우저 창으로 여는 일이다. 서버 파일에서 부르면 아무것도 열리지 않고 결제가 시작되지 않는다. 결제 시작(`startPayment`)은 서버 함수, 결제창은 브라우저다"
|
|
516
516
|
},
|
|
517
517
|
{
|
|
518
|
-
id: "popup-
|
|
519
|
-
title: "
|
|
520
|
-
why: "
|
|
518
|
+
id: "payment-popup-prepare",
|
|
519
|
+
title: "결제 시작이 서버 함수를 거치면 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 연다",
|
|
520
|
+
why: "팝업은 사용자 클릭에서 동기로 열어야 차단되지 않는다. 서버 응답을 기다린 뒤 열면 브라우저가 막아 결제가 시작되지 않는다"
|
|
521
521
|
},
|
|
522
522
|
{
|
|
523
|
-
id: "
|
|
524
|
-
title: "결제
|
|
525
|
-
why: "결제
|
|
523
|
+
id: "payment-return-route",
|
|
524
|
+
title: "결제 복귀 화면에서 `payments.result()`로 결과를 읽는다",
|
|
525
|
+
why: "모바일·팝업 차단은 리다이렉트 결제다. 결제 서비스가 복귀 주소로 돌려보내므로 복귀 화면이 결제 상태를 확인하지 않으면 결제한 구매자가 결과를 볼 수 없다"
|
|
526
526
|
},
|
|
527
527
|
{
|
|
528
|
-
id: "
|
|
529
|
-
title: "
|
|
530
|
-
why: "
|
|
528
|
+
id: "payment-processing-status",
|
|
529
|
+
title: "결과 확인 중(`PROCESSING`)이면 결제 상태를 다시 조회한다",
|
|
530
|
+
why: "결과를 아직 모를 때 실패로 안내하면 구매자가 다시 결제해 이중 결제가 난다. 확정될 때까지 `payments.result()`·`payments.getStatus()`로 확인한다"
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
id: "checkout-origin",
|
|
534
|
+
title: "복귀 주소는 배포된 주소(요청 origin)에서 만들고 그 도메인을 셀러 콘솔 설정 › 결제에 등록한다",
|
|
535
|
+
why: "결제 도메인 밖의 복귀 주소는 서버가 거절한다. 주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 된다"
|
|
531
536
|
},
|
|
532
537
|
{
|
|
533
538
|
id: "analytics-start",
|
|
@@ -538,11 +543,6 @@ const RULES = [
|
|
|
538
543
|
id: "analytics-ids",
|
|
539
544
|
title: "서버의 API 클라이언트에 방문 식별 쿠키를 싣는다(`analyticsIdsFromCookie`)",
|
|
540
545
|
why: "빠지면 장바구니·결제 시작·구매가 방문과 이어지지 않아 퍼널과 유입별 매출에서 빠진다"
|
|
541
|
-
},
|
|
542
|
-
{
|
|
543
|
-
id: "closed-not-failed",
|
|
544
|
-
title: "팝업이 결과 없이 닫히면 실패로 단정하지 않는다",
|
|
545
|
-
why: "이미 승인됐을 수 있다. 서버에 결제 상태를 물어봐야 이중 결제와 누락 주문을 막는다"
|
|
546
546
|
}
|
|
547
547
|
];
|
|
548
548
|
/** 주석을 걷어낸다 — 규칙을 설명하는 주석이 위반으로 잡히지 않게 한다 */
|
|
@@ -551,8 +551,6 @@ function stripComments(source) {
|
|
|
551
551
|
}
|
|
552
552
|
const CLIENT_FILE = /\.(ts|tsx)$/;
|
|
553
553
|
const SERVER_FILE = /\.server\.(ts|tsx)$/;
|
|
554
|
-
/** 문서를 다시 부르는 폼 — method나 action을 단 <form> */
|
|
555
|
-
const NATIVE_SUBMIT_FORM = /<form\s[^>]*\b(method|action)=/;
|
|
556
554
|
/**
|
|
557
555
|
* 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
|
|
558
556
|
* 여기 있는 것들은 실제로 반복해서 깨지는 것들이다.
|
|
@@ -564,6 +562,7 @@ function verifySources(files) {
|
|
|
564
562
|
content: stripComments(file.content)
|
|
565
563
|
}));
|
|
566
564
|
const has = (needle) => cleaned.some((file) => file.content.includes(needle));
|
|
565
|
+
const matches = (pattern) => cleaned.find((file) => pattern.test(file.content));
|
|
567
566
|
for (const file of cleaned) {
|
|
568
567
|
if (!CLIENT_FILE.test(file.path)) continue;
|
|
569
568
|
const isServer = SERVER_FILE.test(file.path) || /\/(server|\.server)\//.test(file.path);
|
|
@@ -582,23 +581,11 @@ function verifySources(files) {
|
|
|
582
581
|
file: file.path,
|
|
583
582
|
detail: "토큰처럼 보이는 값을 모듈 전역에 두었다"
|
|
584
583
|
});
|
|
585
|
-
if (
|
|
586
|
-
ruleId: "
|
|
584
|
+
if (isServer && /@sayren\/storefront-sdk\/payments/.test(file.content)) findings.push({
|
|
585
|
+
ruleId: "payment-browser-launch",
|
|
587
586
|
file: file.path,
|
|
588
|
-
detail: "
|
|
587
|
+
detail: "서버 파일에서 결제 모듈을 쓴다. 결제창(`start`·`open`)과 복귀 처리(`result`)는 브라우저(컴포넌트·effect)에서 부른다"
|
|
589
588
|
});
|
|
590
|
-
if (/addEventListener\("message"/.test(file.content)) {
|
|
591
|
-
if (!/event\.origin|\.origin\s*!==/.test(file.content)) findings.push({
|
|
592
|
-
ruleId: "popup-origin-check",
|
|
593
|
-
file: file.path,
|
|
594
|
-
detail: "message 리스너가 origin을 검증하지 않는다"
|
|
595
|
-
});
|
|
596
|
-
if (!/event\.source|\.source\s*!==/.test(file.content)) findings.push({
|
|
597
|
-
ruleId: "popup-origin-check",
|
|
598
|
-
file: file.path,
|
|
599
|
-
detail: "message 리스너가 source(우리가 연 창)를 검증하지 않는다"
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
589
|
if (isServer && /createAnalytics\(/.test(file.content)) findings.push({
|
|
603
590
|
ruleId: "analytics-start",
|
|
604
591
|
file: file.path,
|
|
@@ -609,21 +596,33 @@ function verifySources(files) {
|
|
|
609
596
|
file: file.path,
|
|
610
597
|
detail: "서버 API 클라이언트에 방문 식별자가 없다. `...analyticsIdsFromCookie(request.headers.get(\"cookie\"))`를 넘긴다"
|
|
611
598
|
});
|
|
612
|
-
if (/requestPayment\(/.test(file.content) && NATIVE_SUBMIT_FORM.test(file.content)) findings.push({
|
|
613
|
-
ruleId: "popup-client-submit",
|
|
614
|
-
file: file.path,
|
|
615
|
-
detail: "결제 화면이 네이티브 폼 제출(method·action)을 쓴다. onSubmit에서 preventDefault 후 서버 함수를 부른다"
|
|
616
|
-
});
|
|
617
599
|
}
|
|
618
|
-
if (has("
|
|
619
|
-
ruleId: "
|
|
600
|
+
if (has("startPayment(") && !has("@sayren/storefront-sdk/payments")) findings.push({
|
|
601
|
+
ruleId: "payment-browser-launch",
|
|
602
|
+
file: "(프로젝트 전체)",
|
|
603
|
+
detail: "결제를 시작하지만 `@sayren/storefront-sdk/payments`로 결제창을 여는 코드가 없다"
|
|
604
|
+
});
|
|
605
|
+
const opensPayment = matches(/\bpayments\.open\(|\.open\(\s*(start|paymentStart)\b/);
|
|
606
|
+
if (opensPayment && !has("prepareWindow(")) findings.push({
|
|
607
|
+
ruleId: "payment-popup-prepare",
|
|
608
|
+
file: opensPayment.path,
|
|
609
|
+
detail: "받아 둔 결제 시작 값으로 결제창을 열지만 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 여는 코드가 없다"
|
|
610
|
+
});
|
|
611
|
+
const fixedReturnUrl = matches(/returnUrl\s*[:=]\s*["'`]https?:\/\//);
|
|
612
|
+
if (fixedReturnUrl) findings.push({
|
|
613
|
+
ruleId: "checkout-origin",
|
|
614
|
+
file: fixedReturnUrl.path,
|
|
615
|
+
detail: "복귀 주소를 고정 문자열로 두었다. 배포된 주소(요청 origin)에서 만들고 그 도메인을 결제 도메인에 등록한다"
|
|
616
|
+
});
|
|
617
|
+
if (has("startPayment(") && !has(".result(")) findings.push({
|
|
618
|
+
ruleId: "payment-return-route",
|
|
620
619
|
file: "(프로젝트 전체)",
|
|
621
|
-
detail: "결제
|
|
620
|
+
detail: "결제 복귀 화면에서 `payments.result()`를 부르는 코드가 없다"
|
|
622
621
|
});
|
|
623
|
-
if (has("
|
|
624
|
-
ruleId: "
|
|
622
|
+
if (has(".result(") && !has("PROCESSING")) findings.push({
|
|
623
|
+
ruleId: "payment-processing-status",
|
|
625
624
|
file: "(프로젝트 전체)",
|
|
626
|
-
detail: "
|
|
625
|
+
detail: "결과 확인 중(`PROCESSING`)일 때 결제 상태를 다시 조회하는 코드가 없다"
|
|
627
626
|
});
|
|
628
627
|
if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) findings.push({
|
|
629
628
|
ruleId: "analytics-start",
|
|
@@ -701,8 +700,8 @@ server.registerTool("get_scaffold_plan", {
|
|
|
701
700
|
}
|
|
702
701
|
],
|
|
703
702
|
doNotTouch: [
|
|
704
|
-
"src/lib/
|
|
705
|
-
"src/lib/api.server.ts — 테넌트 헤더·토큰
|
|
703
|
+
"src/lib/payments.ts·src/routes/checkout.return.tsx — 결제창 열기와 결제 복귀 결과 확인(브라우저)",
|
|
704
|
+
"src/lib/api.server.ts — 테넌트 헤더·토큰 전달·방문 식별 쿠키",
|
|
706
705
|
"src/lib/session.server.ts·src/start.ts — 구매자 세션 쿠키와 요청마다 한 번 하는 토큰 갱신",
|
|
707
706
|
"src/lib/analytics.ts — 방문 분석 시작(브라우저 한 번)과 시작 전 이벤트 큐",
|
|
708
707
|
"라우트 loader가 서버 함수(createServerFn)로 데이터를 받는 구조"
|
|
@@ -823,7 +822,7 @@ server.registerTool("call_api_write", {
|
|
|
823
822
|
}, async (input) => invoke("write", input));
|
|
824
823
|
server.registerTool("verify_storefront", {
|
|
825
824
|
title: "생성 결과 검증",
|
|
826
|
-
description: "만들어진 프로젝트를 규칙과 대조한다. 결제가 조용히 실패하는 실수(브라우저 번들의 process.env, 팝업
|
|
825
|
+
description: "만들어진 프로젝트를 규칙과 대조한다. 결제가 조용히 실패하는 실수(브라우저 번들의 process.env, 클릭 시점 팝업 선오픈 누락, 결제 복귀 화면의 결과 확인 누락, 결과 확인 중 상태 조회 누락 등)를 잡는다.",
|
|
827
826
|
inputSchema: { projectDir: z.string().describe("검사할 프로젝트 경로(절대 경로)") }
|
|
828
827
|
}, async ({ projectDir }) => {
|
|
829
828
|
const files = await collectSources(projectDir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.
|
|
16
|
-
"@sayren/storefront-sdk": "^0.
|
|
15
|
+
"@sayren/store-sdk": "^0.9.0",
|
|
16
|
+
"@sayren/storefront-sdk": "^0.9.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
package/template/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 스토어프론트 템플릿 (TanStack Router · TanStack Start SSR)
|
|
2
2
|
|
|
3
3
|
sayren MCP가 내려주는 스토어프론트 시작점이다. 구매 흐름 전체가 이미 붙어 있다.
|
|
4
|
-
홈, 목록·검색, 상세, 장바구니, 주문서·결제
|
|
4
|
+
홈, 목록·검색, 상세, 장바구니, 주문서·결제(결제수단 선택 → 결제창 → 결제 복귀), 완료, 로그인, 주문 내역이다.
|
|
5
5
|
|
|
6
6
|
## 시작하기
|
|
7
7
|
|
|
@@ -54,16 +54,19 @@ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는
|
|
|
54
54
|
|
|
55
55
|
## 건드리지 않는 편이 좋은 것
|
|
56
56
|
|
|
57
|
-
- `src/lib/
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
- `src/lib/payments.ts`·`src/routes/checkout.return.tsx` — 결제 흐름이다. 결제 시작은 서버 함수로 하고
|
|
58
|
+
(구매자 토큰이 브라우저 JS에 나가지 않는다), 결제창은 결제 서비스가 그린다. 주문서는 버튼 클릭 시점에
|
|
59
|
+
`payments.prepareWindow()`로 빈 팝업을 먼저 열고(팝업 차단 회피) `payments.open(start, { window })`으로 결제창을
|
|
60
|
+
띄운다. 모바일·팝업 차단은 같은 탭이 결제 서비스로 이동하고 복귀 화면으로 돌아온다. 복귀 화면은
|
|
61
|
+
`payments.result()`로 결과를 읽는다. 결과를 모르면(`PROCESSING`) 실패로 단정하지 않고 결제 상태를 계속 조회한다 —
|
|
62
|
+
다시 결제하게 하면 이중 결제가 난다.
|
|
60
63
|
- `src/lib/api.server.ts` — 테넌트 헤더와 토큰 전달 방식이다. 토큰을 모듈 전역에 담으면 서버 렌더에서
|
|
61
64
|
다른 사용자의 요청에 섞인다.
|
|
62
65
|
- `src/lib/session.server.ts`·`src/start.ts` — 구매자 세션 쿠키와 갱신이다. 갱신은 요청마다 한 번이고, 같은
|
|
63
66
|
리프레시 토큰의 동시 갱신은 한 번으로 모은다. 리프레시 토큰은 한 번 쓰면 폐기되므로 각자 갱신하면 로그아웃된다.
|
|
64
67
|
- loader가 서버 함수로 데이터를 받아 그리는 구조 — 컴포넌트에서 다시 받으면 검색 노출과 첫 화면이 죽는다.
|
|
65
|
-
-
|
|
66
|
-
|
|
68
|
+
- 결제 도메인 — 실결제는 셀러 콘솔 설정 › 결제의 **결제 도메인**에 등록한 도메인에서만 된다. 이 쇼핑몰을 배포한 주소를
|
|
69
|
+
등록하고, 같은 주소를 PG(토스페이먼츠·포트원) 가맹 정보에도 등록한다. 테스트 결제는 localhost에서도 된다.
|
|
67
70
|
|
|
68
71
|
## 주의
|
|
69
72
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ProductCard as ProductCardData } from "@sayren/storefront-sdk";
|
|
2
2
|
import { Link } from "@tanstack/react-router";
|
|
3
3
|
import { formatPrice } from "../lib/format";
|
|
4
|
+
import { ProductThumb } from "./product-thumb";
|
|
4
5
|
|
|
5
6
|
/** 상품 카드 — 확장 지점: 찜 버튼·리뷰 별점 같은 요소는 여기에 붙인다 */
|
|
6
7
|
export function ProductCard({ product }: { product: ProductCardData }) {
|
|
@@ -13,9 +14,8 @@ export function ProductCard({ product }: { product: ProductCardData }) {
|
|
|
13
14
|
className="group flex flex-col gap-2"
|
|
14
15
|
>
|
|
15
16
|
<div className="relative aspect-square overflow-hidden rounded-lg bg-chip">
|
|
16
|
-
<
|
|
17
|
+
<ProductThumb
|
|
17
18
|
src={product.thumbnailUrl}
|
|
18
|
-
alt=""
|
|
19
19
|
className="h-full w-full object-cover transition group-hover:scale-105"
|
|
20
20
|
loading="lazy"
|
|
21
21
|
/>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface ProductThumbProps {
|
|
2
|
+
/** 대표 이미지 주소. 등록된 이미지가 없으면 null이다 */
|
|
3
|
+
src: string | null;
|
|
4
|
+
alt?: string;
|
|
5
|
+
/** 크기·모서리 등 자리 클래스 — 이미지와 자리표시가 같은 박스를 쓴다 */
|
|
6
|
+
className: string;
|
|
7
|
+
loading?: "lazy" | "eager";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 상품 대표 이미지. `thumbnailUrl`이 null이면(이미지 없는 상품) 빈 자리를 그린다.
|
|
12
|
+
* 상품 이미지는 셀러가 콘솔에서 올리므로 아직 안 올린 상품이 섞일 수 있다.
|
|
13
|
+
*/
|
|
14
|
+
export function ProductThumb({ src, alt = "", className, loading }: ProductThumbProps) {
|
|
15
|
+
if (!src) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={`${className} flex items-center justify-center bg-chip text-muted text-xs`}
|
|
19
|
+
role="img"
|
|
20
|
+
aria-label="이미지 없음"
|
|
21
|
+
>
|
|
22
|
+
이미지 없음
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return <img src={src} alt={alt} loading={loading} className={className} />;
|
|
27
|
+
}
|
|
@@ -28,13 +28,6 @@ export function apiFor(
|
|
|
28
28
|
cartToken: options.cartToken ?? undefined,
|
|
29
29
|
onCartToken: options.onCartToken,
|
|
30
30
|
...analyticsIdsFromCookie(request.headers.get("cookie")),
|
|
31
|
-
// 결제 요청에 우리 화면의 origin을 실어 보낸다. 결제 팝업은 이 값으로만 부모 창에
|
|
32
|
-
// 결과를 알린다 — 빠지면 결제는 되지만 주문서가 완료를 못 받는다.
|
|
33
|
-
fetch: (input, init) => {
|
|
34
|
-
const headers = new Headers(init?.headers);
|
|
35
|
-
headers.set("Origin", new URL(request.url).origin);
|
|
36
|
-
return fetch(input, { ...init, headers });
|
|
37
|
-
},
|
|
38
31
|
});
|
|
39
32
|
}
|
|
40
33
|
|
package/template/src/lib/api.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createStorefrontClient } from "@sayren/storefront-sdk";
|
|
2
2
|
import type { PublicConfig } from "./config";
|
|
3
3
|
|
|
4
|
-
/** 브라우저용 — 서버 함수가 내려준 설정으로 만든다(
|
|
4
|
+
/** 브라우저용 — 서버 함수가 내려준 설정으로 만든다(결제창·결제 승인·결제 상태 조회에 쓴다) */
|
|
5
5
|
export function browserApi(config: PublicConfig) {
|
|
6
6
|
return createStorefrontClient({ baseUrl: config.apiBaseUrl, storeCode: config.storeCode });
|
|
7
7
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createPayments, type Payments } from "@sayren/storefront-sdk/payments";
|
|
2
|
+
import { browserApi } from "./api";
|
|
3
|
+
import type { PublicConfig } from "./config";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 결제 — 브라우저 전용. 이 파일은 그대로 두는 편이 좋다.
|
|
7
|
+
*
|
|
8
|
+
* 1. 결제 시작(`startPayment`)은 서버 함수에서 한다 — 구매자 토큰이 브라우저 JS에 노출되지 않는다.
|
|
9
|
+
* 2. 결제창은 결제 서비스가 그린다. 버튼 클릭 시점에 `payments.prepareWindow()`로 빈 팝업을 먼저 열고
|
|
10
|
+
* (팝업 차단 회피), 서버 함수가 돌려준 결제 시작 값으로 `payments.open(start, { window })`을 부른다.
|
|
11
|
+
* 3. 모바일이나 팝업 차단이면 같은 탭이 결제 서비스로 이동하고, 결제가 끝나면 복귀 화면(`/checkout/return`)으로
|
|
12
|
+
* 돌아온다. 복귀 화면은 `payments.result()` 한 줄로 결과를 읽는다.
|
|
13
|
+
* 4. 결과를 모르면(`PROCESSING`) 실패로 단정하지 말고 계속 조회한다 — 다시 결제하게 하면 이중 결제가 난다.
|
|
14
|
+
* 5. 복귀 주소의 도메인을 셀러 콘솔 설정 › 결제의 결제 도메인에 등록한다(테스트 결제는 localhost 허용).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* 설정마다 하나만 만든다. `prepareWindow()`로 연 창과 그 결과를 기다리는 감시가 같은 인스턴스에 묶여 있어서,
|
|
18
|
+
* 화면마다 새로 만들면 미리 연 창을 `open()`이 받지 못하고 이전 결제의 감시도 정리되지 않는다.
|
|
19
|
+
*/
|
|
20
|
+
let cached: { key: string; payments: Payments } | null = null;
|
|
21
|
+
|
|
22
|
+
export function paymentsFor(config: PublicConfig): Payments {
|
|
23
|
+
const key = `${config.apiBaseUrl}\u0000${config.storeCode}`;
|
|
24
|
+
if (cached?.key !== key) {
|
|
25
|
+
cached = { key, payments: createPayments({ client: browserApi(config) }) };
|
|
26
|
+
}
|
|
27
|
+
return cached.payments;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 결제 복귀 경로 — 주문서가 결제 시작 때 복귀 주소로 보낸다 */
|
|
31
|
+
export const PAYMENT_RETURN_PATH = "/checkout/return";
|
|
@@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
|
|
16
16
|
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
|
17
17
|
import { Route as CheckoutIndexRouteImport } from './routes/checkout.index'
|
|
18
18
|
import { Route as CheckoutCompleteRouteImport } from './routes/checkout.complete'
|
|
19
|
+
import { Route as CheckoutReturnRouteImport } from './routes/checkout.return'
|
|
19
20
|
import { Route as OrdersIndexRouteImport } from './routes/orders.index'
|
|
20
21
|
import { Route as OrdersOrderIdRouteImport } from './routes/orders.$orderId'
|
|
21
22
|
import { Route as ProductsIndexRouteImport } from './routes/products.index'
|
|
@@ -56,6 +57,11 @@ const CheckoutCompleteRoute = CheckoutCompleteRouteImport.update({
|
|
|
56
57
|
path: '/checkout/complete',
|
|
57
58
|
getParentRoute: () => rootRouteImport,
|
|
58
59
|
} as any)
|
|
60
|
+
const CheckoutReturnRoute = CheckoutReturnRouteImport.update({
|
|
61
|
+
id: '/checkout/return',
|
|
62
|
+
path: '/checkout/return',
|
|
63
|
+
getParentRoute: () => rootRouteImport,
|
|
64
|
+
} as any)
|
|
59
65
|
const OrdersIndexRoute = OrdersIndexRouteImport.update({
|
|
60
66
|
id: '/orders/',
|
|
61
67
|
path: '/orders/',
|
|
@@ -84,6 +90,7 @@ export interface FileRoutesByFullPath {
|
|
|
84
90
|
'/login': typeof LoginRoute
|
|
85
91
|
'/auth/callback': typeof AuthCallbackRoute
|
|
86
92
|
'/checkout/complete': typeof CheckoutCompleteRoute
|
|
93
|
+
'/checkout/return': typeof CheckoutReturnRoute
|
|
87
94
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
|
88
95
|
'/products/$productId': typeof ProductsProductIdRoute
|
|
89
96
|
'/checkout/': typeof CheckoutIndexRoute
|
|
@@ -97,6 +104,7 @@ export interface FileRoutesByTo {
|
|
|
97
104
|
'/login': typeof LoginRoute
|
|
98
105
|
'/auth/callback': typeof AuthCallbackRoute
|
|
99
106
|
'/checkout/complete': typeof CheckoutCompleteRoute
|
|
107
|
+
'/checkout/return': typeof CheckoutReturnRoute
|
|
100
108
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
|
101
109
|
'/products/$productId': typeof ProductsProductIdRoute
|
|
102
110
|
'/checkout': typeof CheckoutIndexRoute
|
|
@@ -111,6 +119,7 @@ export interface FileRoutesById {
|
|
|
111
119
|
'/login': typeof LoginRoute
|
|
112
120
|
'/auth/callback': typeof AuthCallbackRoute
|
|
113
121
|
'/checkout/complete': typeof CheckoutCompleteRoute
|
|
122
|
+
'/checkout/return': typeof CheckoutReturnRoute
|
|
114
123
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
|
115
124
|
'/products/$productId': typeof ProductsProductIdRoute
|
|
116
125
|
'/checkout/': typeof CheckoutIndexRoute
|
|
@@ -126,6 +135,7 @@ export interface FileRouteTypes {
|
|
|
126
135
|
| '/login'
|
|
127
136
|
| '/auth/callback'
|
|
128
137
|
| '/checkout/complete'
|
|
138
|
+
| '/checkout/return'
|
|
129
139
|
| '/orders/$orderId'
|
|
130
140
|
| '/products/$productId'
|
|
131
141
|
| '/checkout/'
|
|
@@ -139,6 +149,7 @@ export interface FileRouteTypes {
|
|
|
139
149
|
| '/login'
|
|
140
150
|
| '/auth/callback'
|
|
141
151
|
| '/checkout/complete'
|
|
152
|
+
| '/checkout/return'
|
|
142
153
|
| '/orders/$orderId'
|
|
143
154
|
| '/products/$productId'
|
|
144
155
|
| '/checkout'
|
|
@@ -152,6 +163,7 @@ export interface FileRouteTypes {
|
|
|
152
163
|
| '/login'
|
|
153
164
|
| '/auth/callback'
|
|
154
165
|
| '/checkout/complete'
|
|
166
|
+
| '/checkout/return'
|
|
155
167
|
| '/orders/$orderId'
|
|
156
168
|
| '/products/$productId'
|
|
157
169
|
| '/checkout/'
|
|
@@ -166,6 +178,7 @@ export interface RootRouteChildren {
|
|
|
166
178
|
LoginRoute: typeof LoginRoute
|
|
167
179
|
AuthCallbackRoute: typeof AuthCallbackRoute
|
|
168
180
|
CheckoutCompleteRoute: typeof CheckoutCompleteRoute
|
|
181
|
+
CheckoutReturnRoute: typeof CheckoutReturnRoute
|
|
169
182
|
OrdersOrderIdRoute: typeof OrdersOrderIdRoute
|
|
170
183
|
ProductsProductIdRoute: typeof ProductsProductIdRoute
|
|
171
184
|
CheckoutIndexRoute: typeof CheckoutIndexRoute
|
|
@@ -224,6 +237,13 @@ declare module '@tanstack/react-router' {
|
|
|
224
237
|
preLoaderRoute: typeof CheckoutCompleteRouteImport
|
|
225
238
|
parentRoute: typeof rootRouteImport
|
|
226
239
|
}
|
|
240
|
+
'/checkout/return': {
|
|
241
|
+
id: '/checkout/return'
|
|
242
|
+
path: '/checkout/return'
|
|
243
|
+
fullPath: '/checkout/return'
|
|
244
|
+
preLoaderRoute: typeof CheckoutReturnRouteImport
|
|
245
|
+
parentRoute: typeof rootRouteImport
|
|
246
|
+
}
|
|
227
247
|
'/orders/': {
|
|
228
248
|
id: '/orders/'
|
|
229
249
|
path: '/orders'
|
|
@@ -262,6 +282,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|
|
262
282
|
LoginRoute: LoginRoute,
|
|
263
283
|
AuthCallbackRoute: AuthCallbackRoute,
|
|
264
284
|
CheckoutCompleteRoute: CheckoutCompleteRoute,
|
|
285
|
+
CheckoutReturnRoute: CheckoutReturnRoute,
|
|
265
286
|
OrdersOrderIdRoute: OrdersOrderIdRoute,
|
|
266
287
|
ProductsProductIdRoute: ProductsProductIdRoute,
|
|
267
288
|
CheckoutIndexRoute: CheckoutIndexRoute,
|
|
@@ -2,6 +2,7 @@ import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
|
|
2
2
|
import { createServerFn } from "@tanstack/react-start";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { ProductThumb } from "../components/product-thumb";
|
|
5
6
|
import { SubmitButton } from "../components/submit-button";
|
|
6
7
|
import { apiFor } from "../lib/api.server";
|
|
7
8
|
import { readCartToken, writeCartToken } from "../lib/cart-session.server";
|
|
@@ -89,9 +90,8 @@ function CartPage() {
|
|
|
89
90
|
<ul className="divide-y divide-line border-line border-y">
|
|
90
91
|
{cart.items.map((item) => (
|
|
91
92
|
<li key={item.cartItemId} className="flex gap-4 py-4">
|
|
92
|
-
<
|
|
93
|
+
<ProductThumb
|
|
93
94
|
src={item.thumbnailUrl}
|
|
94
|
-
alt=""
|
|
95
95
|
className="size-20 rounded-md bg-chip object-cover"
|
|
96
96
|
/>
|
|
97
97
|
<div className="flex flex-1 flex-col gap-1">
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ApiError,
|
|
3
|
+
paymentOptionKey,
|
|
4
|
+
paymentOptionSchema,
|
|
5
|
+
paymentStartSchema,
|
|
6
|
+
} from "@sayren/storefront-sdk";
|
|
7
|
+
import type { PaymentResult } from "@sayren/storefront-sdk/payments";
|
|
2
8
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
|
3
9
|
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
-
import {
|
|
10
|
+
import { getRequestUrl } from "@tanstack/react-start/server";
|
|
11
|
+
import { useState } from "react";
|
|
5
12
|
import { z } from "zod";
|
|
6
13
|
import { SubmitButton } from "../components/submit-button";
|
|
7
|
-
import { browserApi } from "../lib/api";
|
|
8
14
|
import { apiFor } from "../lib/api.server";
|
|
9
15
|
import { readCartToken } from "../lib/cart-session.server";
|
|
10
16
|
import { checkoutIntentFrom, checkoutSearch } from "../lib/checkout-intent";
|
|
11
17
|
import type { PublicConfig } from "../lib/config";
|
|
12
18
|
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
13
19
|
import { formatPrice } from "../lib/format";
|
|
14
|
-
import {
|
|
20
|
+
import { PAYMENT_RETURN_PATH, paymentsFor } from "../lib/payments";
|
|
15
21
|
import { readToken } from "../lib/session.server";
|
|
16
22
|
|
|
17
23
|
const createCheckout = createServerFn({ method: "POST" })
|
|
@@ -43,6 +49,9 @@ const createCheckout = createServerFn({ method: "POST" })
|
|
|
43
49
|
|
|
44
50
|
const paymentForm = z.object({
|
|
45
51
|
checkoutId: z.string(),
|
|
52
|
+
option: paymentOptionSchema,
|
|
53
|
+
/** 결제에 실패하면 돌아올 주문서 주소 */
|
|
54
|
+
back: z.string(),
|
|
46
55
|
receiverName: z.string(),
|
|
47
56
|
phone: z.string(),
|
|
48
57
|
zipCode: z.string(),
|
|
@@ -54,35 +63,59 @@ const paymentForm = z.object({
|
|
|
54
63
|
orderPassword: z.string(),
|
|
55
64
|
});
|
|
56
65
|
|
|
57
|
-
/** 결제
|
|
58
|
-
const
|
|
66
|
+
/** 결제 시작은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다. 결제창 호출 값(공개 값)만 내려준다 */
|
|
67
|
+
const startPayment = createServerFn({ method: "POST" })
|
|
59
68
|
.validator(paymentForm)
|
|
60
69
|
.handler(async ({ data }) => {
|
|
61
70
|
const accessToken = readToken();
|
|
62
71
|
const api = apiFor({ accessToken, cartToken: readCartToken() });
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
// 복귀 주소 — 이 도메인이 셀러 콘솔 결제 도메인에 있어야 한다(테스트 결제는 localhost 허용)
|
|
73
|
+
const returnUrl = new URL(PAYMENT_RETURN_PATH, new URL(getRequestUrl()).origin);
|
|
74
|
+
returnUrl.searchParams.set("back", data.back);
|
|
75
|
+
try {
|
|
76
|
+
const start = await api.checkout.startPayment(data.checkoutId, {
|
|
77
|
+
option: data.option,
|
|
78
|
+
returnUrl: returnUrl.toString(),
|
|
79
|
+
shippingAddress: {
|
|
80
|
+
receiverName: data.receiverName,
|
|
81
|
+
phone: data.phone,
|
|
82
|
+
zipCode: data.zipCode,
|
|
83
|
+
address1: data.address1,
|
|
84
|
+
address2: data.address2 || undefined,
|
|
85
|
+
deliveryMemo: data.deliveryMemo || undefined,
|
|
86
|
+
},
|
|
87
|
+
guest: accessToken
|
|
88
|
+
? undefined
|
|
89
|
+
: {
|
|
90
|
+
name: data.guestName,
|
|
91
|
+
phone: data.phone,
|
|
92
|
+
email: data.email,
|
|
93
|
+
orderPassword: data.orderPassword,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
// 결제 시작 값은 JSON으로 넘기고 브라우저에서 SDK 스키마로 다시 읽는다 — 서버 함수 직렬화 타입에 맞춘다
|
|
97
|
+
return { start: JSON.stringify(start), error: null };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { start: null, error: startErrorMessage(error) };
|
|
100
|
+
}
|
|
84
101
|
});
|
|
85
102
|
|
|
103
|
+
function startErrorMessage(error: unknown): string {
|
|
104
|
+
if (!(error instanceof ApiError)) return "결제를 시작하지 못했어요. 잠시 후 다시 시도해주세요.";
|
|
105
|
+
switch (error.code) {
|
|
106
|
+
case "PAYMENT_OPTION_UNAVAILABLE":
|
|
107
|
+
return "지금 쓸 수 없는 결제수단이에요. 다른 결제수단을 골라주세요.";
|
|
108
|
+
case "PAYMENT_PROVIDER_UNAVAILABLE":
|
|
109
|
+
return "결제창을 준비하지 못했어요. 다른 결제수단으로 시도해주세요.";
|
|
110
|
+
case "PAYMENT_NOT_CONFIGURED":
|
|
111
|
+
return "결제 수단을 준비하고 있어요. 잠시 후 다시 시도해주세요.";
|
|
112
|
+
case "RETURN_URL_NOT_ALLOWED":
|
|
113
|
+
return "이 쇼핑몰 주소에서는 결제할 수 없어요. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주세요.";
|
|
114
|
+
default:
|
|
115
|
+
return "결제를 시작하지 못했어요. 입력한 내용을 확인하고 다시 시도해주세요.";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
86
119
|
export const Route = createFileRoute("/checkout/")({
|
|
87
120
|
validateSearch: checkoutSearch,
|
|
88
121
|
loaderDeps: ({ search }) => search,
|
|
@@ -94,54 +127,58 @@ export const Route = createFileRoute("/checkout/")({
|
|
|
94
127
|
function Checkout() {
|
|
95
128
|
const { checkout, member, config } = Route.useLoaderData();
|
|
96
129
|
const navigate = useNavigate();
|
|
97
|
-
const popupRef = useRef<PaymentPopup | null>(null);
|
|
98
130
|
const [notice, setNotice] = useState<string | null>(null);
|
|
99
|
-
const [checking, setChecking] = useState(false);
|
|
100
131
|
const [submitting, setSubmitting] = useState(false);
|
|
132
|
+
const options = checkout.paymentOptions;
|
|
133
|
+
const [selected, setSelected] = useState(options[0] ? paymentOptionKey(options[0]) : "");
|
|
134
|
+
|
|
135
|
+
const settle = (result: PaymentResult, back: string) => {
|
|
136
|
+
if (result.status === "COMPLETED") {
|
|
137
|
+
void navigate({
|
|
138
|
+
to: "/checkout/complete",
|
|
139
|
+
search: { orderId: result.orderId },
|
|
140
|
+
replace: true,
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (result.status === "PROCESSING") {
|
|
145
|
+
// 결과 확인 중 — 복귀 화면이 결제 상태를 확정할 때까지 조회한다
|
|
146
|
+
void navigate({
|
|
147
|
+
to: "/checkout/return",
|
|
148
|
+
search: { back, sayrenPaymentId: result.paymentId },
|
|
149
|
+
replace: true,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setNotice(
|
|
154
|
+
result.status === "CANCELED"
|
|
155
|
+
? "결제를 취소했어요. 다른 결제수단으로 다시 시도해주세요."
|
|
156
|
+
: result.status === "FAILED"
|
|
157
|
+
? result.message
|
|
158
|
+
: "결제 결과를 확인하지 못했어요. 주문 내역을 확인해주세요.",
|
|
159
|
+
);
|
|
160
|
+
};
|
|
101
161
|
|
|
102
162
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
103
|
-
// 문서를 다시 부르지 않는다 — 팝업 제어 상태가 사라진다
|
|
104
163
|
event.preventDefault();
|
|
164
|
+
const option = options.find((item) => paymentOptionKey(item) === selected);
|
|
165
|
+
if (!option) {
|
|
166
|
+
setNotice("결제수단을 골라주세요.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
105
169
|
const form = new FormData(event.currentTarget);
|
|
106
170
|
const field = (name: string) => String(form.get(name) ?? "");
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// 클릭
|
|
110
|
-
const
|
|
111
|
-
onCompleted: (message) => {
|
|
112
|
-
if (message.orderId) {
|
|
113
|
-
void navigate({ to: "/checkout/complete", search: { orderId: message.orderId } });
|
|
114
|
-
} else {
|
|
115
|
-
setNotice("결제가 완료됐어요. 주문 내역에서 확인해주세요.");
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
onFailed: (message) => {
|
|
119
|
-
setNotice(message.message ?? "결제가 실패했어요. 다시 시도해주세요.");
|
|
120
|
-
},
|
|
121
|
-
onClosed: () => {
|
|
122
|
-
// 닫혔다고 실패로 단정하지 않는다 — 서버에 결제 상태를 물어본다
|
|
123
|
-
if (!paymentId) return;
|
|
124
|
-
setChecking(true);
|
|
125
|
-
void pollPaymentStatus(config, paymentId).then((status) => {
|
|
126
|
-
setChecking(false);
|
|
127
|
-
if (status?.status === "completed" && status.orderId) {
|
|
128
|
-
void navigate({ to: "/checkout/complete", search: { orderId: status.orderId } });
|
|
129
|
-
} else {
|
|
130
|
-
setNotice("결제창이 닫혔어요. 결제를 마쳤다면 잠시 뒤 주문 내역에서 확인해주세요.");
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
popupRef.current?.close();
|
|
136
|
-
popupRef.current = popup;
|
|
137
|
-
popup.open();
|
|
138
|
-
|
|
139
|
-
// 결제 요청 응답이 오면 이미 열어 둔 팝업을 결제 주소로 보낸다
|
|
171
|
+
const back = window.location.pathname + window.location.search;
|
|
172
|
+
const payments = paymentsFor(config);
|
|
173
|
+
// 결제창은 클릭 시점에 열어야 팝업 차단에 걸리지 않는다 — 서버 함수를 기다리기 전에 빈 창을 먼저 연다
|
|
174
|
+
const paymentWindow = payments.prepareWindow();
|
|
140
175
|
setSubmitting(true);
|
|
141
176
|
setNotice(null);
|
|
142
|
-
|
|
177
|
+
void startPayment({
|
|
143
178
|
data: {
|
|
144
179
|
checkoutId: checkout.checkoutId,
|
|
180
|
+
option,
|
|
181
|
+
back,
|
|
145
182
|
receiverName: field("receiverName"),
|
|
146
183
|
phone: field("phone"),
|
|
147
184
|
zipCode: field("zipCode"),
|
|
@@ -153,16 +190,23 @@ function Checkout() {
|
|
|
153
190
|
orderPassword: field("orderPassword"),
|
|
154
191
|
},
|
|
155
192
|
})
|
|
156
|
-
.then((
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
193
|
+
.then(async ({ start, error }) => {
|
|
194
|
+
if (!start) {
|
|
195
|
+
paymentWindow.close();
|
|
196
|
+
setNotice(error);
|
|
197
|
+
return;
|
|
161
198
|
}
|
|
199
|
+
// 결제 서비스가 결제창을 그린다. 팝업이면 결과를 여기서 받고, 리다이렉트면 이 탭이 떠난다
|
|
200
|
+
settle(
|
|
201
|
+
await payments.open(paymentStartSchema.parse(JSON.parse(start)), {
|
|
202
|
+
window: paymentWindow,
|
|
203
|
+
}),
|
|
204
|
+
back,
|
|
205
|
+
);
|
|
162
206
|
})
|
|
163
207
|
.catch(() => {
|
|
164
|
-
|
|
165
|
-
setNotice("결제를 시작하지 못했어요.
|
|
208
|
+
paymentWindow.close();
|
|
209
|
+
setNotice("결제를 시작하지 못했어요. 잠시 후 다시 시도해주세요.");
|
|
166
210
|
})
|
|
167
211
|
.finally(() => setSubmitting(false));
|
|
168
212
|
};
|
|
@@ -195,13 +239,45 @@ function Checkout() {
|
|
|
195
239
|
</section>
|
|
196
240
|
)}
|
|
197
241
|
|
|
242
|
+
<fieldset className="space-y-2">
|
|
243
|
+
<legend className="mb-3 font-bold text-lg">결제수단</legend>
|
|
244
|
+
{options.length === 0 ? (
|
|
245
|
+
<p className="text-muted text-sm">지금 결제할 수 있는 결제수단이 없어요.</p>
|
|
246
|
+
) : (
|
|
247
|
+
<div className="grid grid-cols-2 gap-2">
|
|
248
|
+
{options.map((option) => {
|
|
249
|
+
const key = paymentOptionKey(option);
|
|
250
|
+
return (
|
|
251
|
+
<label
|
|
252
|
+
key={key}
|
|
253
|
+
className={`flex cursor-pointer flex-col rounded-md border px-3 py-2 text-sm ${
|
|
254
|
+
selected === key ? "border-ink" : "border-line"
|
|
255
|
+
}`}
|
|
256
|
+
>
|
|
257
|
+
<span className="flex items-center gap-2">
|
|
258
|
+
<input
|
|
259
|
+
type="radio"
|
|
260
|
+
name="paymentOption"
|
|
261
|
+
value={key}
|
|
262
|
+
checked={selected === key}
|
|
263
|
+
onChange={() => setSelected(key)}
|
|
264
|
+
/>
|
|
265
|
+
<span className="font-medium">{option.label}</span>
|
|
266
|
+
</span>
|
|
267
|
+
<span className="pl-6 text-muted text-xs">{option.pgName}</span>
|
|
268
|
+
</label>
|
|
269
|
+
);
|
|
270
|
+
})}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</fieldset>
|
|
274
|
+
|
|
198
275
|
<SubmitButton
|
|
199
|
-
disabled={submitting}
|
|
276
|
+
disabled={submitting || options.length === 0}
|
|
200
277
|
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
201
278
|
>
|
|
202
279
|
{formatPrice(checkout.amounts.totalAmount)} 결제하기
|
|
203
280
|
</SubmitButton>
|
|
204
|
-
<p className="text-center text-muted text-xs">결제창은 새 팝업에서 열려요</p>
|
|
205
281
|
</form>
|
|
206
282
|
|
|
207
283
|
<aside className="space-y-4 rounded-lg border border-line p-5">
|
|
@@ -236,24 +312,12 @@ function Checkout() {
|
|
|
236
312
|
테스트 결제예요. 실제로 돈이 오가지 않아요.
|
|
237
313
|
</p>
|
|
238
314
|
) : null}
|
|
239
|
-
{checking ? <p className="text-muted text-sm">결제 상태를 확인하고 있어요…</p> : null}
|
|
240
315
|
{notice ? <p className="rounded-md bg-chip px-3 py-2 text-sm">{notice}</p> : null}
|
|
241
316
|
</aside>
|
|
242
317
|
</div>
|
|
243
318
|
);
|
|
244
319
|
}
|
|
245
320
|
|
|
246
|
-
/** 팝업이 결과 없이 닫힌 경우 — 서버 상태가 진실이다 */
|
|
247
|
-
async function pollPaymentStatus(config: PublicConfig, paymentId: string) {
|
|
248
|
-
const api = browserApi(config);
|
|
249
|
-
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
250
|
-
const status = await api.payments.getStatus(paymentId).catch(() => null);
|
|
251
|
-
if (status && status.status !== "pending" && status.status !== "processing") return status;
|
|
252
|
-
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
253
|
-
}
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
321
|
function Field({
|
|
258
322
|
label,
|
|
259
323
|
name,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PaymentResult } from "@sayren/storefront-sdk/payments";
|
|
2
|
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { PublicConfig } from "../lib/config";
|
|
7
|
+
import { API_BASE_URL, resolveStoreCode } from "../lib/config.server";
|
|
8
|
+
import { paymentsFor } from "../lib/payments";
|
|
9
|
+
import { safeRedirect } from "../lib/safe-redirect";
|
|
10
|
+
|
|
11
|
+
// `back`은 결제 서비스를 거쳐 돌아오는 값이라 누구나 바꿀 수 있다 — 같은 사이트 경로만 쓴다(열린 리다이렉트·javascript: 방지)
|
|
12
|
+
const returnSearch = z.object({
|
|
13
|
+
back: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.catch(undefined)
|
|
17
|
+
.transform((value) =>
|
|
18
|
+
value !== undefined && safeRedirect(value) === value ? value : undefined,
|
|
19
|
+
),
|
|
20
|
+
/** 결제 서비스가 붙이는 결제 id — SDK가 주소에서 읽으므로 주소에 그대로 남긴다 */
|
|
21
|
+
sayrenPaymentId: z.string().optional().catch(undefined),
|
|
22
|
+
/** 참고용 결과 — 결과의 원천은 결제 상태 조회다 */
|
|
23
|
+
sayrenResult: z.string().optional().catch(undefined),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const getConfig = createServerFn({ method: "GET" }).handler(
|
|
27
|
+
(): PublicConfig => ({ apiBaseUrl: API_BASE_URL, storeCode: resolveStoreCode() }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** 결과 확인 중일 때 상태를 다시 보는 간격과 횟수 */
|
|
31
|
+
const POLL_MS = 2000;
|
|
32
|
+
const POLL_TIMES = 15;
|
|
33
|
+
|
|
34
|
+
type View =
|
|
35
|
+
| { kind: "working" }
|
|
36
|
+
| { kind: "processing" }
|
|
37
|
+
| { kind: "failed"; canceled: boolean; message: string }
|
|
38
|
+
| { kind: "unknown" };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 결제 복귀 화면 — 결제 서비스가 리다이렉트 결제를 마치고 돌려보내는 곳이다. 결과는 주소의 결제 id로
|
|
42
|
+
* 결제 상태를 조회해 정한다(`payments.result`). 같은 주소로 다시 열어도(뒤로 가기·새로고침) 안전하다.
|
|
43
|
+
*/
|
|
44
|
+
export const Route = createFileRoute("/checkout/return")({
|
|
45
|
+
validateSearch: returnSearch,
|
|
46
|
+
loader: () => getConfig(),
|
|
47
|
+
// 복귀 주소의 결제 id가 외부 리소스 요청의 Referer로 새지 않게 한다
|
|
48
|
+
head: () => ({ meta: [{ title: "결제 확인" }, { name: "referrer", content: "no-referrer" }] }),
|
|
49
|
+
component: PaymentReturn,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function PaymentReturn() {
|
|
53
|
+
const config = Route.useLoaderData();
|
|
54
|
+
const { back } = Route.useSearch();
|
|
55
|
+
const navigate = useNavigate();
|
|
56
|
+
const [view, setView] = useState<View>({ kind: "working" });
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let active = true;
|
|
60
|
+
const payments = paymentsFor(config);
|
|
61
|
+
const done = (orderId: string) =>
|
|
62
|
+
void navigate({ to: "/checkout/complete", search: { orderId }, replace: true });
|
|
63
|
+
|
|
64
|
+
const show = (result: PaymentResult): boolean => {
|
|
65
|
+
if (result.status === "COMPLETED") {
|
|
66
|
+
done(result.orderId);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (result.status === "CANCELED" || result.status === "FAILED") {
|
|
70
|
+
setView({
|
|
71
|
+
kind: "failed",
|
|
72
|
+
canceled: result.status === "CANCELED",
|
|
73
|
+
message: result.message,
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (result.status === "INVALID") {
|
|
78
|
+
setView({ kind: "unknown" });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const run = async () => {
|
|
85
|
+
const first = await payments.result(window.location.href).catch(() => null);
|
|
86
|
+
if (!active) return;
|
|
87
|
+
if (first && show(first)) return;
|
|
88
|
+
// 결과 확인 중 — 실패로 단정하지 않고 결제 상태를 다시 조회한다
|
|
89
|
+
setView({ kind: "processing" });
|
|
90
|
+
for (let i = 0; i < POLL_TIMES && active; i += 1) {
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
92
|
+
if (!active) return;
|
|
93
|
+
const next = await payments.result(window.location.href).catch(() => null);
|
|
94
|
+
if (!active) return;
|
|
95
|
+
if (next && show(next)) return;
|
|
96
|
+
}
|
|
97
|
+
if (active) setView({ kind: "unknown" });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
void run();
|
|
101
|
+
return () => {
|
|
102
|
+
active = false;
|
|
103
|
+
};
|
|
104
|
+
}, [config, navigate]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="mx-auto max-w-md space-y-6 py-16 text-center">
|
|
108
|
+
{view.kind === "working" || view.kind === "processing" ? (
|
|
109
|
+
<>
|
|
110
|
+
<h1 className="font-bold text-2xl">결제를 확인하고 있어요</h1>
|
|
111
|
+
<p className="text-muted text-sm">
|
|
112
|
+
{view.kind === "processing"
|
|
113
|
+
? "결제사에서 결과를 확인하는 중이에요. 이 화면을 닫지 말고 잠시만 기다려주세요."
|
|
114
|
+
: "잠시만 기다려주세요."}
|
|
115
|
+
</p>
|
|
116
|
+
</>
|
|
117
|
+
) : view.kind === "failed" ? (
|
|
118
|
+
<>
|
|
119
|
+
<h1 className="font-bold text-2xl">
|
|
120
|
+
{view.canceled ? "결제를 취소했어요" : "결제하지 못했어요"}
|
|
121
|
+
</h1>
|
|
122
|
+
{view.canceled ? null : <p className="text-muted text-sm">{view.message}</p>}
|
|
123
|
+
<div className="flex justify-center gap-2">
|
|
124
|
+
<a href={back ?? "/cart"} className="rounded-md bg-ink px-5 py-3 text-white">
|
|
125
|
+
다른 결제수단으로 다시 결제
|
|
126
|
+
</a>
|
|
127
|
+
</div>
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
<h1 className="font-bold text-2xl">결제 결과를 확인하지 못했어요</h1>
|
|
132
|
+
<p className="text-muted text-sm">
|
|
133
|
+
결제를 마쳤다면 잠시 뒤 주문 내역에 나타나요. 다시 결제하기 전에 주문 내역을 먼저
|
|
134
|
+
확인해주세요.
|
|
135
|
+
</p>
|
|
136
|
+
<Link to="/orders" className="inline-block rounded-md border border-ink px-5 py-3">
|
|
137
|
+
주문 내역
|
|
138
|
+
</Link>
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -2,6 +2,7 @@ import { ApiError } from "@sayren/storefront-sdk";
|
|
|
2
2
|
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
|
|
3
3
|
import { createServerFn } from "@tanstack/react-start";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { ProductThumb } from "../components/product-thumb";
|
|
5
6
|
import { apiFor } from "../lib/api.server";
|
|
6
7
|
import { formatDateTime, formatPrice } from "../lib/format";
|
|
7
8
|
import { readToken } from "../lib/session.server";
|
|
@@ -43,9 +44,8 @@ function OrderDetail() {
|
|
|
43
44
|
<ul className="divide-y divide-line border-line border-y">
|
|
44
45
|
{order.items.map((item) => (
|
|
45
46
|
<li key={item.orderItemId} className="flex items-center gap-4 py-4">
|
|
46
|
-
<
|
|
47
|
+
<ProductThumb
|
|
47
48
|
src={item.thumbnailUrl}
|
|
48
|
-
alt=""
|
|
49
49
|
className="size-16 rounded-md bg-chip object-cover"
|
|
50
50
|
/>
|
|
51
51
|
<span className="flex flex-1 flex-col gap-1 text-sm">
|
|
@@ -4,6 +4,7 @@ import { createServerFn } from "@tanstack/react-start";
|
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { ProductDescription } from "../components/product-description";
|
|
7
|
+
import { ProductThumb } from "../components/product-thumb";
|
|
7
8
|
import { SubmitButton } from "../components/submit-button";
|
|
8
9
|
import { useTrack } from "../lib/analytics";
|
|
9
10
|
import { apiFor } from "../lib/api.server";
|
|
@@ -74,7 +75,7 @@ function ProductDetail() {
|
|
|
74
75
|
return (
|
|
75
76
|
<div className="grid gap-8 md:grid-cols-2">
|
|
76
77
|
<div className="space-y-3">
|
|
77
|
-
<
|
|
78
|
+
<ProductThumb src={product.thumbnailUrl} className="w-full rounded-lg bg-chip" />
|
|
78
79
|
{product.images.slice(0, 4).map((image) => (
|
|
79
80
|
<img
|
|
80
81
|
key={image}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hosted Checkout 결제 팝업 — 이 파일은 그대로 두는 편이 좋다. 규칙을 하나라도 어기면
|
|
3
|
-
* 결제가 조용히 실패한다.
|
|
4
|
-
*
|
|
5
|
-
* 1. 팝업은 **클릭 콜스택에서 동기적으로** 연다. API 응답을 기다린 뒤에 열면 브라우저가 막는다.
|
|
6
|
-
* 그래서 빈 창을 먼저 열고 `navigate`로 결제 주소를 넣는다.
|
|
7
|
-
* 2. 팝업이 보내는 postMessage는 `event.origin`과 `event.source`를 **모두** 검증한다.
|
|
8
|
-
* 3. 결과 메시지 없이 창이 닫히면 결제가 끝났는지 알 수 없다. 이때는 실패로 단정하지 말고
|
|
9
|
-
* 서버에 결제 상태를 물어본다(주문서 화면이 폴링한다).
|
|
10
|
-
*/
|
|
11
|
-
export const PAYMENT_POPUP_NAME = "sayren-checkout";
|
|
12
|
-
const PAYMENT_POPUP_FEATURES = "width=460,height=680";
|
|
13
|
-
const CLOSE_POLL_MS = 500;
|
|
14
|
-
|
|
15
|
-
export interface PaymentCompletedMessage {
|
|
16
|
-
type: "payment:completed";
|
|
17
|
-
paymentId: string;
|
|
18
|
-
/** 결제는 끝났지만 팝업이 주문번호를 모를 수 있다 — 이때는 주문 내역을 안내한다 */
|
|
19
|
-
orderId: string | null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface PaymentFailedMessage {
|
|
23
|
-
type: "payment:failed";
|
|
24
|
-
paymentId?: string;
|
|
25
|
-
code?: string;
|
|
26
|
-
message?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface PaymentPopupHandlers {
|
|
30
|
-
onCompleted: (message: PaymentCompletedMessage) => void;
|
|
31
|
-
onFailed: (message: PaymentFailedMessage) => void;
|
|
32
|
-
/** 결과 메시지 없이 닫혔다 — 실패가 아니라 "모른다"는 뜻이다 */
|
|
33
|
-
onClosed: () => void;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface PaymentPopup {
|
|
37
|
-
/** 클릭 즉시 부른다. 차단되면 null */
|
|
38
|
-
open: () => Window | null;
|
|
39
|
-
/** 결제 주소를 받은 뒤 부른다. 팝업이 없거나 닫혔으면 false */
|
|
40
|
-
navigate: (popupUrl: string) => boolean;
|
|
41
|
-
close: () => void;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function createPaymentPopup(handlers: PaymentPopupHandlers): PaymentPopup {
|
|
45
|
-
let popup: Window | null = null;
|
|
46
|
-
let listener: ((event: MessageEvent) => void) | null = null;
|
|
47
|
-
let timer: ReturnType<typeof setInterval> | null = null;
|
|
48
|
-
|
|
49
|
-
const stop = () => {
|
|
50
|
-
if (listener) window.removeEventListener("message", listener);
|
|
51
|
-
listener = null;
|
|
52
|
-
if (timer) clearInterval(timer);
|
|
53
|
-
timer = null;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const watch = (origin: string) => {
|
|
57
|
-
stop();
|
|
58
|
-
listener = (event: MessageEvent) => {
|
|
59
|
-
if (event.origin !== origin) return;
|
|
60
|
-
if (event.source !== popup) return;
|
|
61
|
-
const data = event.data as PaymentCompletedMessage | PaymentFailedMessage | null;
|
|
62
|
-
if (!data || typeof data !== "object") return;
|
|
63
|
-
if (data.type === "payment:completed") {
|
|
64
|
-
stop();
|
|
65
|
-
handlers.onCompleted(data);
|
|
66
|
-
} else if (data.type === "payment:failed") {
|
|
67
|
-
stop();
|
|
68
|
-
handlers.onFailed(data);
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
window.addEventListener("message", listener);
|
|
72
|
-
|
|
73
|
-
timer = setInterval(() => {
|
|
74
|
-
if (popup?.closed) {
|
|
75
|
-
stop();
|
|
76
|
-
handlers.onClosed();
|
|
77
|
-
}
|
|
78
|
-
}, CLOSE_POLL_MS);
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
open: () => {
|
|
83
|
-
popup = window.open("", PAYMENT_POPUP_NAME, PAYMENT_POPUP_FEATURES);
|
|
84
|
-
return popup;
|
|
85
|
-
},
|
|
86
|
-
navigate: (popupUrl) => {
|
|
87
|
-
if (!popup || popup.closed) return false;
|
|
88
|
-
popup.location.href = popupUrl;
|
|
89
|
-
watch(new URL(popupUrl).origin);
|
|
90
|
-
return true;
|
|
91
|
-
},
|
|
92
|
-
close: () => {
|
|
93
|
-
stop();
|
|
94
|
-
popup?.close();
|
|
95
|
-
popup = null;
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
}
|