@sayren/mcp 0.1.11 → 0.2.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
@@ -510,24 +510,29 @@ const RULES = [
510
510
  why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다"
511
511
  },
512
512
  {
513
- id: "popup-sync-open",
514
- title: "결제 팝업은 클릭 콜스택에서 동기적으로 연다",
515
- why: "API 응답을 기다린 뒤 열면 브라우저가 팝업을 막는다. 빈 창을 먼저 열고 주소를 넣는다"
513
+ id: "payment-browser-launch",
514
+ title: "결제창은 브라우저에서 `@sayren/storefront-sdk/payments`로 연다(`start` 또는 `open`)",
515
+ why: "결제창은 결제 서비스 주소를 브라우저 창으로 여는 일이다. 서버 파일에서 부르면 아무것도 열리지 않고 결제가 시작되지 않는다. 결제 시작(`startPayment`)은 서버 함수, 결제창은 브라우저다"
516
516
  },
517
517
  {
518
- id: "popup-client-submit",
519
- title: "주문서 제출은 클라이언트 제출(`onSubmit` + 서버 함수)로 한다",
520
- why: "네이티브 폼은 문서를 다시 그려 팝업 제어 상태가 사라진다. 결제창이 빈 창으로 남는다"
518
+ id: "payment-popup-prepare",
519
+ title: "결제 시작이 서버 함수를 거치면 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 연다",
520
+ why: "팝업은 사용자 클릭에서 동기로 열어야 차단되지 않는다. 서버 응답을 기다린 뒤 열면 브라우저가 막아 결제가 시작되지 않는다"
521
521
  },
522
522
  {
523
- id: "parent-origin",
524
- title: "결제 요청에 우리 화면의 origin을 실어 보낸다",
525
- why: "결제 팝업은 그 값으로만 부모 창에 결과를 알린다. 빠지면 결제는 되는데 주문서가 완료를 못 받는다"
523
+ id: "payment-return-route",
524
+ title: "결제 복귀 화면에서 `payments.result()`로 결과를 읽는다",
525
+ why: "모바일·팝업 차단은 리다이렉트 결제다. 결제 서비스가 복귀 주소로 돌려보내므로 복귀 화면이 결제 상태를 확인하지 않으면 결제한 구매자가 결과를 볼 수 없다"
526
526
  },
527
527
  {
528
- id: "popup-origin-check",
529
- title: "팝업 메시지는 origin과 source를 모두 검증한다",
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 (/window\.open\(/.test(file.content) && /await .*\n?.*window\.open\(/.test(file.content)) findings.push({
586
- ruleId: "popup-sync-open",
584
+ if (isServer && /@sayren\/storefront-sdk\/payments/.test(file.content)) findings.push({
585
+ ruleId: "payment-browser-launch",
587
586
  file: file.path,
588
- detail: "await 뒤에 팝업을 연다. 브라우저가 막는다"
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("requestPayment(") && !has("Origin")) findings.push({
619
- ruleId: "parent-origin",
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: "결제 요청에 Origin 헤더를 싣는 코드가 없다"
620
+ detail: "결제 복귀 화면에서 `payments.result()`를 부르는 코드가 없다"
622
621
  });
623
- if (has("requestPayment(") && !has("getStatus(")) findings.push({
624
- ruleId: "closed-not-failed",
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/payment-popup.ts — 결제 팝업 규칙",
705
- "src/lib/api.server.ts — 테넌트 헤더·토큰 전달·부모 origin·방문 식별 쿠키",
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, 팝업 origin 미검증, 부모 origin 누락 등)를 잡는다.",
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.11",
3
+ "version": "0.2.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.7.0",
16
- "@sayren/storefront-sdk": "^0.7.0"
15
+ "@sayren/storefront-sdk": "^0.8.0",
16
+ "@sayren/store-sdk": "^0.8.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -7,8 +7,8 @@
7
7
  "zod": "^4.6.5"
8
8
  },
9
9
  "versions": {
10
- "@sayren/store-sdk": "0.7.0",
11
- "@sayren/storefront-sdk": "0.7.0",
10
+ "@sayren/store-sdk": "0.8.0",
11
+ "@sayren/storefront-sdk": "0.8.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -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/payment-popup.ts` — 결제 팝업 규칙이다. 팝업은 클릭 콜스택에서 동기적으로 열고,
58
- postMessage는 origin과 source를 모두 검증하며, 결과 없이 닫히면 서버에 상태를 물어본다.
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
- - 주문서 제출 방식 — `onSubmit`에서 `preventDefault` 후 서버 함수를 부른다. 네이티브 폼 제출은 문서를 다시 불러
66
- 열어 둔 결제 팝업의 제어를 잃는다.
68
+ - 결제 도메인 — 실결제는 셀러 콘솔 설정 › 결제의 **결제 도메인**에 등록한 도메인에서만 된다. 이 쇼핑몰을 배포한 주소를
69
+ 등록하고, 같은 주소를 PG(토스페이먼츠·포트원) 가맹 정보에도 등록한다. 테스트 결제는 localhost에서도 된다.
67
70
 
68
71
  ## 주의
69
72
 
@@ -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
 
@@ -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,
@@ -1,17 +1,23 @@
1
- import { ApiError } from "@sayren/storefront-sdk";
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 { useRef, useState } from "react";
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 { createPaymentPopup, type PaymentPopup } from "../lib/payment-popup";
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
- /** 결제 요청은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다 */
58
- const requestPayment = createServerFn({ method: "POST" })
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
- const payment = await api.checkout.requestPayment(data.checkoutId, {
64
- shippingAddress: {
65
- receiverName: data.receiverName,
66
- phone: data.phone,
67
- zipCode: data.zipCode,
68
- address1: data.address1,
69
- address2: data.address2 || undefined,
70
- deliveryMemo: data.deliveryMemo || undefined,
71
- },
72
- paymentMethod: "CARD",
73
- guest: accessToken
74
- ? undefined
75
- : {
76
- name: data.guestName,
77
- phone: data.phone,
78
- email: data.email,
79
- orderPassword: data.orderPassword,
80
- },
81
- });
82
- // 브라우저에는 팝업 주소와 상태 조회 키만 내려준다
83
- return { paymentId: payment.paymentId, popupUrl: payment.pgParams.popupUrl };
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
- let paymentId: string | null = null;
108
-
109
- // 클릭 콜스택에서 동기적으로 연다 — 응답을 기다린 뒤 열면 차단된다
110
- const popup = createPaymentPopup({
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
- requestPayment({
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((payment) => {
157
- paymentId = payment.paymentId;
158
- if (!popup.navigate(payment.popupUrl)) {
159
- popup.close();
160
- setNotice("결제창이 열리지 않았어요. 팝업 차단을 풀고 다시 시도해주세요.");
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
- popup.close();
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
+ }
@@ -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
- }