@sayren/storefront-sdk 0.6.0 → 0.8.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
@@ -1,6 +1,7 @@
1
1
  import { a as ANALYTICS_VISITOR_HEADER, c as analyticsClientEventSchema, i as ANALYTICS_VISITOR_COOKIE, l as analyticsIdSchema, n as ANALYTICS_SESSION_COOKIE, o as analyticsBatchSchema, r as ANALYTICS_SESSION_HEADER, s as analyticsClientEventNameSchema, t as ANALYTICS_MAX_BATCH_BYTES, u as analyticsServerEventNameSchema } from "./analytics-CgD70OqH.mjs";
2
2
  import { t as analyticsIdsFromCookie } from "./analytics-ids-COElZiI3.mjs";
3
- import { $ as idpAuthorizeRequestSchema, A as pageSchema, B as pgParamsSchema, C as claimTypeSchema, D as claimStatusSchema, E as myClaimSchema, F as createCheckoutRequestSchema, G as productDetailSchema, H as shippingAddressInputSchema, I as guestInfoSchema, J as addCartItemRequestSchema, K as productFacetsSchema, L as paymentMethodSchema, M as checkoutSessionSchema, N as confirmPaymentRequestSchema, O as orderItemStatusSchema, P as confirmPaymentResultSchema, Q as anonymousSessionSchema, R as paymentParamsSchema, S as claimReasonSchema, T as createClaimResultSchema, U as categoryNodeSchema, V as requestPaymentRequestSchema, W as productCardSchema, X as cartSchema, Y as cartItemSchema, Z as updateCartItemRequestSchema, _ as createInquiryRequestSchema, a as publicReviewSchema, at as tokenPairSchema, b as customerInquiryCategorySchema, c as writableReviewSchema, ct as ApiError, d as myOrderSchema, dt as unwrapData, et as idpAuthorizeResponseSchema, f as orderShippingAddressSchema, g as updateProfileRequestSchema, h as memberSchema, i as createReviewResultSchema, it as socialProviderSchema, j as PAYMENT_STATUS_VALUES, k as pageInfoSchema, l as deliveryTrackingSchema, lt as buildQuery, m as memberAddressSchema, n as storefrontStoreSchema, nt as loginRequestSchema, o as reviewSummarySchema, ot as withdrawRequestSchema, p as memberAddressRequestSchema, q as productSortSchema, r as createReviewRequestSchema, rt as signupRequestSchema, s as updateReviewRequestSchema, st as withdrawalBlockedDetailsSchema, t as createStorefrontClient, tt as idpTokenRequestSchema, u as myOrderItemSchema, ut as toApiError, v as publicInquirySchema, w as createClaimRequestSchema, x as customerInquirySchema, y as createCustomerInquiryRequestSchema, z as paymentStatusSchema } from "./client-Do9-LG5X.mjs";
3
+ import { $ as signupRequestSchema, A as createClaimResultSchema, B as pageInfoSchema, C as publicInquirySchema, D as claimReasonSchema, E as customerInquirySchema, F as productFacetsSchema, G as updateCartItemRequestSchema, H as addCartItemRequestSchema, I as productPageSchema, J as idpAuthorizeResponseSchema, K as anonymousSessionSchema, L as productSortSchema, M as categoryNodeSchema, N as productCardSchema, O as claimTypeSchema, P as productDetailSchema, Q as memberIdentitiesSchema, R as claimStatusSchema, S as createInquiryResultSchema, T as customerInquiryCategorySchema, U as cartItemSchema, V as pageSchema, W as cartSchema, X as linkIdentityRequestSchema, Y as idpTokenRequestSchema, Z as loginRequestSchema, _ as memberAddressSchema, a as publicReviewSchema, at as buildQuery, b as wishlistAddResultSchema, c as updateReviewRequestSchema, d as deliveryTrackingSchema, et as socialProviderSchema, f as myOrderItemSchema, g as memberAddressRequestSchema, h as purchaseDecisionResultSchema, i as createReviewResultSchema, it as ApiError, j as myClaimSchema, k as createClaimRequestSchema, l as updateReviewResultSchema, m as orderShippingAddressSchema, n as storefrontStoreSchema, nt as withdrawRequestSchema, o as reviewPageSchema, ot as toApiError, p as myOrderSchema, q as idpAuthorizeRequestSchema, r as createReviewRequestSchema, rt as withdrawalBlockedDetailsSchema, s as reviewSummarySchema, st as unwrapData, t as createStorefrontClient, tt as tokenPairSchema, u as writableReviewSchema, v as memberSchema, w as createCustomerInquiryRequestSchema, x as createInquiryRequestSchema, y as updateProfileRequestSchema, z as orderItemStatusSchema } from "./client-DwqExvZN.mjs";
4
+ import { _ as pgProviderSchema, a as createCheckoutRequestSchema, b as startPaymentRequestSchema, c as guestInfoSchema, d as paymentMethodSchema, f as paymentOptionKey, g as paymentStatusSchema, h as paymentStartSchema, i as checkoutSessionSchema, l as paymentFailureCategorySchema, m as paymentOptionSchema, n as PAYMENT_STATUS_VALUES, o as easyPayProviderResponseSchema, p as paymentOptionListSchema, r as availablePaymentOptionSchema, s as easyPayProviderSchema, t as PAYMENT_RESULT_VALUES, u as paymentMessageSchema, v as retryPaymentRequestSchema, y as shippingAddressInputSchema } from "./checkout-JFk3b8RG.mjs";
4
5
  //#region src/session.ts
5
6
  /** 세션 키 상수 — SDK가 소유한다. store-sdk와 겹치지 않는 별도 네임스페이스. */
6
7
  const STOREFRONT_SESSION_KEYS = {
@@ -124,4 +125,4 @@ function createStorefrontSession(options) {
124
125
  };
125
126
  }
126
127
  //#endregion
127
- export { ANALYTICS_MAX_BATCH_BYTES, ANALYTICS_SESSION_COOKIE, ANALYTICS_SESSION_HEADER, ANALYTICS_VISITOR_COOKIE, ANALYTICS_VISITOR_HEADER, ApiError, PAYMENT_STATUS_VALUES, STOREFRONT_SESSION_KEYS, addCartItemRequestSchema, analyticsBatchSchema, analyticsClientEventNameSchema, analyticsClientEventSchema, analyticsIdSchema, analyticsIdsFromCookie, analyticsServerEventNameSchema, anonymousSessionSchema, buildQuery, cartItemSchema, cartSchema, categoryNodeSchema, checkoutSessionSchema, claimReasonSchema, claimStatusSchema, claimTypeSchema, confirmPaymentRequestSchema, confirmPaymentResultSchema, createCheckoutRequestSchema, createClaimRequestSchema, createClaimResultSchema, createCustomerInquiryRequestSchema, createInquiryRequestSchema, createReviewRequestSchema, createReviewResultSchema, createStorefrontClient, createStorefrontSession, customerInquiryCategorySchema, customerInquirySchema, deliveryTrackingSchema, guestInfoSchema, idpAuthorizeRequestSchema, idpAuthorizeResponseSchema, idpTokenRequestSchema, loginRequestSchema, memberAddressRequestSchema, memberAddressSchema, memberSchema, myClaimSchema, myOrderItemSchema, myOrderSchema, orderItemStatusSchema, orderShippingAddressSchema, pageInfoSchema, pageSchema, paymentMethodSchema, paymentParamsSchema, paymentStatusSchema, pgParamsSchema, productCardSchema, productDetailSchema, productFacetsSchema, productSortSchema, publicInquirySchema, publicReviewSchema, requestPaymentRequestSchema, reviewSummarySchema, shippingAddressInputSchema, signupRequestSchema, socialProviderSchema, storefrontStoreSchema, tokenPairSchema, updateCartItemRequestSchema, updateProfileRequestSchema, updateReviewRequestSchema, withdrawRequestSchema, withdrawalBlockedDetailsSchema, writableReviewSchema };
128
+ export { ANALYTICS_MAX_BATCH_BYTES, ANALYTICS_SESSION_COOKIE, ANALYTICS_SESSION_HEADER, ANALYTICS_VISITOR_COOKIE, ANALYTICS_VISITOR_HEADER, ApiError, PAYMENT_RESULT_VALUES, PAYMENT_STATUS_VALUES, STOREFRONT_SESSION_KEYS, addCartItemRequestSchema, analyticsBatchSchema, analyticsClientEventNameSchema, analyticsClientEventSchema, analyticsIdSchema, analyticsIdsFromCookie, analyticsServerEventNameSchema, anonymousSessionSchema, availablePaymentOptionSchema, buildQuery, cartItemSchema, cartSchema, categoryNodeSchema, checkoutSessionSchema, claimReasonSchema, claimStatusSchema, claimTypeSchema, createCheckoutRequestSchema, createClaimRequestSchema, createClaimResultSchema, createCustomerInquiryRequestSchema, createInquiryRequestSchema, createInquiryResultSchema, createReviewRequestSchema, createReviewResultSchema, createStorefrontClient, createStorefrontSession, customerInquiryCategorySchema, customerInquirySchema, deliveryTrackingSchema, easyPayProviderResponseSchema, easyPayProviderSchema, guestInfoSchema, idpAuthorizeRequestSchema, idpAuthorizeResponseSchema, idpTokenRequestSchema, linkIdentityRequestSchema, loginRequestSchema, memberAddressRequestSchema, memberAddressSchema, memberIdentitiesSchema, memberSchema, myClaimSchema, myOrderItemSchema, myOrderSchema, orderItemStatusSchema, orderShippingAddressSchema, pageInfoSchema, pageSchema, paymentFailureCategorySchema, paymentMessageSchema, paymentMethodSchema, paymentOptionKey, paymentOptionListSchema, paymentOptionSchema, paymentStartSchema, paymentStatusSchema, pgProviderSchema, productCardSchema, productDetailSchema, productFacetsSchema, productPageSchema, productSortSchema, publicInquirySchema, publicReviewSchema, purchaseDecisionResultSchema, retryPaymentRequestSchema, reviewPageSchema, reviewSummarySchema, shippingAddressInputSchema, signupRequestSchema, socialProviderSchema, startPaymentRequestSchema, storefrontStoreSchema, tokenPairSchema, updateCartItemRequestSchema, updateProfileRequestSchema, updateReviewRequestSchema, updateReviewResultSchema, wishlistAddResultSchema, withdrawRequestSchema, withdrawalBlockedDetailsSchema, writableReviewSchema };
@@ -0,0 +1,166 @@
1
+ import { A as createStorefrontClient, Ht as PaymentOption, Pt as GuestInfo, Ut as PaymentStart, Wt as PaymentStatus, qt as ShippingAddressInput } from "../index-BRXStiK2.mjs";
2
+ //#region src/payments/index.d.ts
3
+ /**
4
+ * 스토어프론트 결제 — 브라우저 전용(`@sayren/storefront-sdk/payments`).
5
+ *
6
+ * ```ts
7
+ * const payments = createPayments({ client: sdk, returnUrl: `${location.origin}/checkout/return` });
8
+ * const result = await payments.start(checkoutId, { option, shippingAddress }); // 결제창(팝업)
9
+ * if (result.status === "COMPLETED") location.assign(`/orders/${result.orderId}`);
10
+ * ```
11
+ *
12
+ * 결제창은 결제 서비스(`payUrl`)가 그린다. SDK는 그 창을 팝업으로 열고 결과 메시지를 받아 결제 상태 조회로 확정한다.
13
+ * 모바일(또는 `mode: "redirect"`)에서는 현재 탭을 `payUrl`로 보내고, 결제가 끝나면 구매자가 `returnUrl`로 돌아온다 —
14
+ * 복귀 화면에서 {@link Payments.result}를 부른다.
15
+ *
16
+ * **`returnUrl`은 결제를 시작한 페이지와 같은 origin이어야 한다.** 결제 서비스는 팝업 결과 메시지를 복귀 주소의
17
+ * origin으로 보내므로, origin이 다르면 메시지가 도달하지 않고 결제 상태 조회로만 결과가 정해진다(느려질 뿐 결과는 같다).
18
+ *
19
+ * 팝업 메시지는 "끝났다"는 신호일 뿐이다. 결과도 구매자에게 보여 줄 안내 문구도 결제 상태 조회의 `result`·`lastFailure`가
20
+ * 원천이다 — 메시지의 코드·메시지는 읽지 않는다(누구나 보낼 수 있는 값이다).
21
+ */
22
+ type StorefrontClient = Pick<ReturnType<typeof createStorefrontClient>, "checkout" | "payments">;
23
+ /** 결제창을 여는 방식 — `auto`는 모바일이면 리다이렉트, 아니면 팝업이다 */
24
+ export type PaymentMode = "auto" | "popup" | "redirect";
25
+ /** `auto` 판정에 쓰는 기기 정보 */
26
+ export interface PaymentDevice {
27
+ userAgent: string;
28
+ /** `matchMedia("(pointer: coarse)")` */
29
+ coarsePointer: boolean;
30
+ viewportWidth: number;
31
+ }
32
+ /** 결제 결과 */
33
+ export type PaymentResult = {
34
+ status: "COMPLETED";
35
+ paymentId: string;
36
+ /** 만들어진 주문 번호 — 주문 완료 화면으로 보낸다 */
37
+ orderId: string;
38
+ } | {
39
+ /** 구매자가 결제창을 닫았다. 같은 결제를 다른 옵션으로 `retry`할 수 있다 */
40
+ status: "CANCELED";
41
+ paymentId: string;
42
+ code: string;
43
+ message: string;
44
+ } | {
45
+ /** 카드 거절·결제 오류 등. 같은 결제를 다른 옵션으로 `retry`할 수 있다 */
46
+ status: "FAILED";
47
+ paymentId: string;
48
+ code: string;
49
+ message: string;
50
+ } | {
51
+ /** 결과 확인 중 — `getStatus(paymentId)`로 확정될 때까지 조회한다 */
52
+ status: "PROCESSING";
53
+ paymentId: string;
54
+ } | {
55
+ /** 복귀 주소에 결제 정보가 없다(직접 연 주소 등) */
56
+ status: "INVALID";
57
+ message: string;
58
+ };
59
+ /** 결제 서비스 창 — 브라우저에서는 `window.open`이 돌려준 창이다 */
60
+ export interface PaymentPopup {
61
+ readonly closed: boolean;
62
+ /** `event.source` 대조용 창 참조 */
63
+ readonly ref: unknown;
64
+ /** 결제 서비스 주소로 보낸다 */
65
+ navigate(url: string): void;
66
+ close(): void;
67
+ }
68
+ /** 결제 서비스가 보낸 postMessage — 브라우저 `MessageEvent`의 필요한 부분만 */
69
+ export interface PaymentPostMessage {
70
+ data: unknown;
71
+ origin: string;
72
+ source: unknown;
73
+ }
74
+ /** 브라우저 의존부 — 테스트는 가짜를 넣는다 */
75
+ export interface PaymentsEnv {
76
+ /** 클릭 시점에 빈 팝업을 연다. 팝업 차단이면 null */
77
+ openPopup(): PaymentPopup | null;
78
+ /** 현재 창을 이동시킨다(리다이렉트 결제) */
79
+ navigate(url: string): void;
80
+ /** 결제 서비스 메시지 구독 — 해제 함수를 돌려준다 */
81
+ subscribe(listener: (event: PaymentPostMessage) => void): () => void;
82
+ /** 복귀 화면의 현재 주소 — `result()`의 기본값 */
83
+ currentUrl(): string;
84
+ device(): PaymentDevice;
85
+ /** 만료 판정에 쓰는 시계. 브라우저 시계가 서버보다 앞서면 팝업 대기가 일찍 끝난다(결과는 PROCESSING) */
86
+ now(): number;
87
+ setInterval(handler: () => void, ms: number): unknown;
88
+ clearInterval(handle: unknown): void;
89
+ }
90
+ /**
91
+ * 결제창을 팝업으로 열지 리다이렉트로 열지 정한다 — `auto` 판정 한 곳(순수 함수).
92
+ * 모바일은 팝업이 탭 전환으로 열리고 간편결제 앱을 거치며 `opener`를 잃으므로 리다이렉트를 쓴다.
93
+ */
94
+ export declare function resolvePaymentMode(mode: PaymentMode | undefined, device: PaymentDevice): "popup" | "redirect";
95
+ /**
96
+ * 결제 상태를 화면 결과로 옮긴다 — 결과의 원천은 언제나 `GET /payments/{id}`다(메시지는 신호일 뿐).
97
+ * 모르는 `result` 값은 확인 중으로 다룬다.
98
+ */
99
+ export declare function paymentResultOf(status: PaymentStatus, options?: {
100
+ popupClosed?: boolean;
101
+ }): PaymentResult;
102
+ export interface PaymentsOptions {
103
+ client: StorefrontClient;
104
+ /** 결제를 마치면 돌아올 주소(스토어 결제 도메인 안). `start`·`retry`에서 바꿀 수 있다 */
105
+ returnUrl?: string;
106
+ env?: PaymentsEnv;
107
+ }
108
+ export interface StartOptions {
109
+ option: PaymentOption;
110
+ shippingAddress: ShippingAddressInput;
111
+ guest?: GuestInfo;
112
+ returnUrl?: string;
113
+ mode?: PaymentMode;
114
+ /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창. 폼 검증이 비동기라 클릭 태스크를 넘길 때 쓴다 */
115
+ window?: PreparedPaymentWindow;
116
+ }
117
+ export interface RetryOptions {
118
+ option: PaymentOption;
119
+ /** 생략하면 결제 시작 때의 복귀 주소를 쓴다 */
120
+ returnUrl?: string;
121
+ mode?: PaymentMode;
122
+ /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창 */
123
+ window?: PreparedPaymentWindow;
124
+ }
125
+ /** {@link Payments.prepareWindow}가 돌려준 핸들임을 나타내는 표식 — 직접 만든 객체는 받지 않는다 */
126
+ export declare const PREPARED_WINDOW: unique symbol;
127
+ /**
128
+ * 클릭 시점에 미리 연 결제창. 결제 시작 API가 서버 함수를 거쳐 느리게 끝나도 팝업 차단에 걸리지 않게
129
+ * {@link Payments.prepareWindow}로 먼저 열어 두고 {@link Payments.open}(또는 `start`·`retry`의 `window`)에 넘긴다.
130
+ */
131
+ export interface PreparedPaymentWindow {
132
+ /** `auto` 판정이 끝난 실제 모드 */
133
+ readonly mode: "popup" | "redirect";
134
+ /** 결제 시작에 실패했을 때 미리 연 팝업을 닫는다 */
135
+ close(): void;
136
+ /** 이 결제 인스턴스가 연 창이라는 표식. 값은 내부용이다 */
137
+ readonly [PREPARED_WINDOW]: object;
138
+ }
139
+ export interface Payments {
140
+ /**
141
+ * 결제 시작 — 결제 세션을 만들고 결제창을 연다. 팝업 모드는 결과가 나올 때까지 기다리고,
142
+ * 리다이렉트 모드는 페이지가 떠나므로 **끝나지 않는 Promise**를 돌려준다.
143
+ * 결제 시작 API가 실패하면 미리 연 팝업을 닫고 `ApiError`를 그대로 던진다.
144
+ */
145
+ start(checkoutId: string, options: StartOptions): Promise<PaymentResult>;
146
+ /** 같은 결제를 다른 결제 옵션으로 다시 시도한다 */
147
+ retry(paymentId: string, options: RetryOptions): Promise<PaymentResult>;
148
+ /**
149
+ * 이미 받은 결제 시작 값으로 결제창만 연다 — 결제 시작을 서버 함수로 하는 SSR 앱용.
150
+ * 클릭 핸들러에서 {@link Payments.prepareWindow}로 창을 먼저 열고 그 핸들을 `window`로 넘긴다.
151
+ */
152
+ open(start: PaymentStart, options?: {
153
+ mode?: PaymentMode;
154
+ window?: PreparedPaymentWindow;
155
+ }): Promise<PaymentResult>;
156
+ /** 클릭 시점에 빈 팝업을 연다(팝업 차단 회피). 반환 핸들을 {@link Payments.open}에 넘긴다 */
157
+ prepareWindow(options?: {
158
+ mode?: PaymentMode;
159
+ }): PreparedPaymentWindow;
160
+ /** 복귀 화면 — 주소의 `sayrenPaymentId`로 결제 상태를 조회해 결과를 만든다 */
161
+ result(url?: string): Promise<PaymentResult>;
162
+ /** 결제 상태 조회 — 결과가 `PROCESSING`이면 확정될 때까지 조회한다 */
163
+ getStatus(paymentId: string): Promise<PaymentStatus>;
164
+ }
165
+ export declare function createPayments(options: PaymentsOptions): Payments;
166
+ //#endregion
@@ -0,0 +1,310 @@
1
+ import { u as paymentMessageSchema } from "../checkout-JFk3b8RG.mjs";
2
+ //#region src/payments/index.ts
3
+ /** 복귀 주소에 결제 서비스가 붙이는 쿼리 */
4
+ const RETURN_PAYMENT_ID = "sayrenPaymentId";
5
+ /** 팝업 이름 — 같은 이름으로 열면 결제창이 하나만 뜬다 */
6
+ const POPUP_NAME = "sayren-payment";
7
+ const POPUP_FEATURES = "width=500,height=720,resizable=yes,scrollbars=yes";
8
+ /** 팝업이 메시지 없이 닫혔는지 보는 간격 */
9
+ const POPUP_POLL_MS = 500;
10
+ /** opener를 잃은 팝업 대비 — 결제 상태를 직접 조회하기 시작하는 시점(5초)과 그 뒤 간격(3초) */
11
+ const STATUS_POLL_FIRST_TICKS = 10;
12
+ const STATUS_POLL_EVERY_TICKS = 6;
13
+ /** 좁은 터치 화면은 팝업이 아니라 리다이렉트다 */
14
+ const POPUP_MIN_WIDTH = 820;
15
+ const MOBILE_UA = /Android|iPhone|iPad|iPod|IEMobile|Opera Mini|Mobile/i;
16
+ /**
17
+ * 결제창을 팝업으로 열지 리다이렉트로 열지 정한다 — `auto` 판정 한 곳(순수 함수).
18
+ * 모바일은 팝업이 탭 전환으로 열리고 간편결제 앱을 거치며 `opener`를 잃으므로 리다이렉트를 쓴다.
19
+ */
20
+ function resolvePaymentMode(mode, device) {
21
+ if (mode === "popup" || mode === "redirect") return mode;
22
+ if (MOBILE_UA.test(device.userAgent)) return "redirect";
23
+ if (device.coarsePointer && device.viewportWidth > 0 && device.viewportWidth < POPUP_MIN_WIDTH) return "redirect";
24
+ return "popup";
25
+ }
26
+ /**
27
+ * 결제 상태를 화면 결과로 옮긴다 — 결과의 원천은 언제나 `GET /payments/{id}`다(메시지는 신호일 뿐).
28
+ * 모르는 `result` 값은 확인 중으로 다룬다.
29
+ */
30
+ function paymentResultOf(status, options = {}) {
31
+ const { paymentId, result, lastFailure } = status;
32
+ if (result === "COMPLETED") return status.orderId ? {
33
+ status: "COMPLETED",
34
+ paymentId,
35
+ orderId: status.orderId
36
+ } : {
37
+ status: "PROCESSING",
38
+ paymentId
39
+ };
40
+ if (result === "CANCELED") return {
41
+ status: "CANCELED",
42
+ paymentId,
43
+ code: lastFailure?.code ?? "USER_CANCELED",
44
+ message: lastFailure?.message ?? "결제를 취소했어요"
45
+ };
46
+ if (result === "FAILED" || result === "EXPIRED") return {
47
+ status: "FAILED",
48
+ paymentId,
49
+ code: lastFailure?.code ?? result,
50
+ message: lastFailure?.message ?? (result === "EXPIRED" ? "결제 요청이 만료됐어요" : "결제에 실패했어요")
51
+ };
52
+ if (result === "PENDING") return options.popupClosed ? {
53
+ status: "CANCELED",
54
+ paymentId,
55
+ code: "POPUP_CLOSED",
56
+ message: "결제창을 닫았어요"
57
+ } : {
58
+ status: "PROCESSING",
59
+ paymentId
60
+ };
61
+ return {
62
+ status: "PROCESSING",
63
+ paymentId
64
+ };
65
+ }
66
+ function browserEnv() {
67
+ return {
68
+ openPopup() {
69
+ const popup = window.open("about:blank", POPUP_NAME, POPUP_FEATURES);
70
+ if (!popup) return null;
71
+ return {
72
+ get closed() {
73
+ return popup.closed;
74
+ },
75
+ ref: popup,
76
+ navigate: (url) => {
77
+ popup.location.href = url;
78
+ },
79
+ close: () => popup.close()
80
+ };
81
+ },
82
+ navigate: (url) => window.location.assign(url),
83
+ subscribe(listener) {
84
+ const handler = (event) => listener({
85
+ data: event.data,
86
+ origin: event.origin,
87
+ source: event.source
88
+ });
89
+ window.addEventListener("message", handler);
90
+ return () => window.removeEventListener("message", handler);
91
+ },
92
+ currentUrl: () => window.location.href,
93
+ device: () => ({
94
+ userAgent: typeof navigator === "undefined" ? "" : navigator.userAgent,
95
+ coarsePointer: typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
96
+ viewportWidth: window.innerWidth
97
+ }),
98
+ now: () => Date.now(),
99
+ setInterval: (handler, ms) => setInterval(handler, ms),
100
+ clearInterval: (handle) => clearInterval(handle)
101
+ };
102
+ }
103
+ /** {@link Payments.prepareWindow}가 돌려준 핸들임을 나타내는 표식 — 직접 만든 객체는 받지 않는다 */
104
+ const PREPARED_WINDOW = Symbol("sayren.payments.preparedWindow");
105
+ function popupUrl(payUrl) {
106
+ try {
107
+ const url = new URL(payUrl);
108
+ url.searchParams.set("mode", "popup");
109
+ return url.toString();
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+ function createPayments(options) {
115
+ const { client } = options;
116
+ const env = options.env ?? browserEnv();
117
+ /**
118
+ * 진행 중인 팝업 감시. 팝업은 이름이 같아 다시 열면 같은 창을 쓰므로 감시도 하나만 둔다 —
119
+ * 새 결제를 시작하면 이전 감시를 끝내고(그 Promise는 `PROCESSING`) 리스너·타이머를 거둔다.
120
+ */
121
+ let cancelWatch = null;
122
+ /** 이 인스턴스가 연 창인지 가리는 표식 */
123
+ const brand = {};
124
+ function stopWatch() {
125
+ const cancel = cancelWatch;
126
+ cancelWatch = null;
127
+ cancel?.();
128
+ }
129
+ /**
130
+ * 결제 서비스는 팝업 결과 메시지를 **복귀 주소(returnUrl)의 origin**으로 보낸다.
131
+ * 결제를 시작한 페이지와 origin이 다르면 메시지가 도달하지 않아 상태 조회로만 결과가 정해진다.
132
+ */
133
+ function warnReturnOrigin(returnUrl, mode) {
134
+ if (!returnUrl || mode !== "popup") return;
135
+ try {
136
+ if (new URL(returnUrl).origin === new URL(env.currentUrl()).origin) return;
137
+ } catch {
138
+ return;
139
+ }
140
+ console.warn(`[sayren] returnUrl(${returnUrl})의 origin이 지금 페이지와 달라요 — 결제 서비스는 그 origin으로 결과 메시지를 보내므로 팝업 결과가 도달하지 않고 결제 상태 조회로만 끝나요`);
141
+ }
142
+ function prepareWindow(prepareOptions = {}) {
143
+ stopWatch();
144
+ const popup = resolvePaymentMode(prepareOptions.mode, env.device()) === "popup" ? env.openPopup() : null;
145
+ return {
146
+ mode: popup ? "popup" : "redirect",
147
+ popup,
148
+ close: () => popup?.close(),
149
+ [PREPARED_WINDOW]: brand
150
+ };
151
+ }
152
+ /**
153
+ * 넘겨받은 창 핸들을 확인한다 — 이 인스턴스의 `prepareWindow()`가 돌려준 것만 받는다.
154
+ * 다른 인스턴스의 것이나 손으로 만든 객체는 창도 감시도 우리 것이 아니라 결제가 조용히 어긋난다.
155
+ */
156
+ function resolvePrepared(given, mode) {
157
+ if (!given) return prepareWindow({ mode });
158
+ if (given[PREPARED_WINDOW] !== brand) throw new TypeError("window는 같은 createPayments의 prepareWindow()가 돌려준 핸들이어야 해요");
159
+ return given;
160
+ }
161
+ /**
162
+ * 팝업에서 결제가 끝날 때까지 기다린다 — 메시지는 신호, 결과는 상태 조회다.
163
+ *
164
+ * 감시는 셋이다. (1) 결제 서비스의 postMessage, (2) 창이 닫혔는지, (3) 결제 상태 직접 조회.
165
+ * (3)이 있어야 팝업이 `opener`를 잃어(간편결제 앱 전환, 팝업 안에서 복귀 주소로 리다이렉트) 메시지도
166
+ * 닫힘도 오지 않는 경우에 멈추지 않는다. 어느 경로든 결과는 한 번만 돌려준다.
167
+ */
168
+ function awaitPopup(paymentId, popup, origin, expiresAt) {
169
+ return new Promise((resolve) => {
170
+ let done = false;
171
+ let ticks = 0;
172
+ let polling = false;
173
+ let timer;
174
+ let unsubscribe = () => {};
175
+ let cancelThis = null;
176
+ const deadline = Date.parse(expiresAt);
177
+ const teardown = () => {
178
+ unsubscribe();
179
+ env.clearInterval(timer);
180
+ if (cancelWatch === cancelThis) cancelWatch = null;
181
+ };
182
+ const finish = (result) => {
183
+ if (done) return;
184
+ done = true;
185
+ teardown();
186
+ resolve(result);
187
+ };
188
+ /** 메시지·닫힘 신호를 받았다 — 결과는 상태 조회로 정한다 */
189
+ const settle = async (popupClosed) => {
190
+ if (done) return;
191
+ done = true;
192
+ teardown();
193
+ try {
194
+ resolve(paymentResultOf(await client.payments.getStatus(paymentId), { popupClosed }));
195
+ } catch {
196
+ resolve({
197
+ status: "PROCESSING",
198
+ paymentId
199
+ });
200
+ }
201
+ };
202
+ unsubscribe = env.subscribe((event) => {
203
+ if (event.origin !== origin || event.source !== popup.ref) return;
204
+ const message = paymentMessageSchema.safeParse(event.data);
205
+ if (!message.success || message.data.paymentId !== paymentId) return;
206
+ settle(false);
207
+ });
208
+ timer = env.setInterval(() => {
209
+ if (done) return;
210
+ if (popup.closed) {
211
+ settle(true);
212
+ return;
213
+ }
214
+ if (Number.isFinite(deadline) && env.now() >= deadline) {
215
+ finish({
216
+ status: "PROCESSING",
217
+ paymentId
218
+ });
219
+ return;
220
+ }
221
+ ticks += 1;
222
+ const since = ticks - STATUS_POLL_FIRST_TICKS;
223
+ if (polling || since < 0 || since % STATUS_POLL_EVERY_TICKS !== 0) return;
224
+ polling = true;
225
+ client.payments.getStatus(paymentId).then((status) => {
226
+ if (done) return;
227
+ const result = paymentResultOf(status);
228
+ if (result.status === "PROCESSING") return;
229
+ try {
230
+ popup.close();
231
+ } catch {}
232
+ finish(result);
233
+ }).catch(() => {}).finally(() => {
234
+ polling = false;
235
+ });
236
+ }, POPUP_POLL_MS);
237
+ cancelThis = () => finish({
238
+ status: "PROCESSING",
239
+ paymentId
240
+ });
241
+ cancelWatch = cancelThis;
242
+ });
243
+ }
244
+ function open(start, openOptions = {}) {
245
+ stopWatch();
246
+ const popup = resolvePrepared(openOptions.window, openOptions.mode).popup;
247
+ const url = popup ? popupUrl(start.payUrl) : null;
248
+ if (!popup || !url) {
249
+ popup?.close();
250
+ env.navigate(start.payUrl);
251
+ return new Promise(() => {});
252
+ }
253
+ popup.navigate(url);
254
+ return awaitPopup(start.paymentId, popup, new URL(url).origin, start.expiresAt);
255
+ }
256
+ async function startWith(prepared, request) {
257
+ let start;
258
+ try {
259
+ start = await request();
260
+ } catch (error) {
261
+ prepared.close();
262
+ throw error;
263
+ }
264
+ return open(start, { window: prepared });
265
+ }
266
+ return {
267
+ prepareWindow,
268
+ open,
269
+ start(checkoutId, startOptions) {
270
+ const prepared = resolvePrepared(startOptions.window, startOptions.mode);
271
+ const returnUrl = startOptions.returnUrl ?? options.returnUrl;
272
+ if (!returnUrl) {
273
+ prepared.close();
274
+ return Promise.reject(/* @__PURE__ */ new Error("returnUrl이 필요해요 — createPayments 또는 start에 넘겨 주세요"));
275
+ }
276
+ warnReturnOrigin(returnUrl, prepared.mode);
277
+ return startWith(prepared, () => client.checkout.startPayment(checkoutId, {
278
+ option: startOptions.option,
279
+ shippingAddress: startOptions.shippingAddress,
280
+ guest: startOptions.guest,
281
+ returnUrl
282
+ }));
283
+ },
284
+ retry(paymentId, retryOptions) {
285
+ const prepared = resolvePrepared(retryOptions.window, retryOptions.mode);
286
+ const returnUrl = retryOptions.returnUrl ?? options.returnUrl;
287
+ warnReturnOrigin(returnUrl, prepared.mode);
288
+ return startWith(prepared, () => client.payments.retry(paymentId, {
289
+ option: retryOptions.option,
290
+ returnUrl
291
+ }));
292
+ },
293
+ getStatus: (paymentId) => client.payments.getStatus(paymentId),
294
+ async result(url) {
295
+ let paymentId = null;
296
+ try {
297
+ paymentId = new URL(url ?? env.currentUrl()).searchParams.get(RETURN_PAYMENT_ID);
298
+ } catch {
299
+ paymentId = null;
300
+ }
301
+ if (!paymentId) return {
302
+ status: "INVALID",
303
+ message: "결제 정보가 없는 주소예요"
304
+ };
305
+ return paymentResultOf(await client.payments.getStatus(paymentId));
306
+ }
307
+ };
308
+ }
309
+ //#endregion
310
+ export { PREPARED_WINDOW, createPayments, paymentResultOf, resolvePaymentMode };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/storefront-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
@@ -18,6 +18,11 @@
18
18
  "development": "./src/auth/index.ts",
19
19
  "types": "./dist/auth/index.d.mts",
20
20
  "default": "./dist/auth/index.mjs"
21
+ },
22
+ "./payments": {
23
+ "development": "./src/payments/index.ts",
24
+ "types": "./dist/payments/index.d.mts",
25
+ "default": "./dist/payments/index.mjs"
21
26
  }
22
27
  },
23
28
  "dependencies": {
@@ -42,7 +47,7 @@
42
47
  "dist",
43
48
  "src"
44
49
  ],
45
- "description": "sayren 스토어프론트 API SDK — zod 스키마 + fetch 클라이언트",
50
+ "description": "sayren 스토어프론트 API SDK — zod 스키마 + fetch 클라이언트 + 브라우저 결제·분석·인증 모듈",
46
51
  "scripts": {
47
52
  "build": "tsdown",
48
53
  "dev": "tsdown --watch",
package/src/auth/index.ts CHANGED
@@ -3,6 +3,7 @@ import { ApiError } from "../http";
3
3
  import type {
4
4
  AnonymousSession,
5
5
  LoginRequest,
6
+ MemberIdentities,
6
7
  SignupRequest,
7
8
  SocialProvider,
8
9
  TokenPair,
@@ -14,6 +15,7 @@ export { ApiError } from "../http";
14
15
  export type {
15
16
  AnonymousSession,
16
17
  LoginRequest,
18
+ MemberIdentities,
17
19
  SignupRequest,
18
20
  SocialProvider,
19
21
  TokenPair,
@@ -78,7 +80,7 @@ export interface StorefrontAuthOptions {
78
80
  }
79
81
 
80
82
  /**
81
- * 구매자 인증 — 화면은 스토어프런트가 그리고, 계정 저장과 인증 처리는 sayren이 한다.
83
+ * 구매자 인증 — 화면은 스토어프론트가 그리고, 계정 저장과 인증 처리는 sayren이 한다.
82
84
  * 어느 방식으로 로그인해도 결과는 같은 구매자 토큰(`TokenPair`)이고 `/me/*`·장바구니·주문 API에 그대로 쓴다.
83
85
  *
84
86
  * 토큰 보관은 앱 몫이다. 서버(loader·action)에서 호출하고 httpOnly 쿠키에 두기를 권한다.
@@ -118,7 +120,7 @@ export function createStorefrontAuth(options: StorefrontAuthOptions) {
118
120
  /**
119
121
  * 소셜 로그인 시작 — PKCE(S256)와 state를 만들고 공급자 로그인 주소를 받는다. 반환한 `state`·`codeVerifier`를
120
122
  * 서버 세션에 보관하고 구매자를 `url`로 보낸다. 로그인을 마치면 `redirectUri`로 `?code=&state=`가 붙어 돌아온다.
121
- * `redirectUri`는 스토어프런트 도메인 목록 안이어야 한다
123
+ * `redirectUri`는 스토어프론트 도메인 목록 안이어야 한다
122
124
  */
123
125
  async idp(provider: SocialProvider, options: { redirectUri: string }): Promise<IdpStart> {
124
126
  const state = randomToken(24);
@@ -146,6 +148,51 @@ export function createStorefrontAuth(options: StorefrontAuthOptions) {
146
148
  cartToken: input.cartToken,
147
149
  });
148
150
  },
151
+ /** 로그인 수단 — 비밀번호가 있는지, 어떤 소셜 계정이 연결됐는지 */
152
+ identities(accessToken: string): Promise<MemberIdentities> {
153
+ return client(accessToken).auth.identities();
154
+ },
155
+ /**
156
+ * 소셜 계정 연결 시작 — 로그인한 구매자에 공급자 계정을 붙인다. `idp()`처럼 `state`·`codeVerifier`를 보관하고
157
+ * 구매자를 `url`로 보낸다. 돌아오면 `linkIdpCallback()`으로 마무리한다. 연결은 흐름을 시작한 구매자에게만 된다
158
+ */
159
+ async linkIdp(
160
+ accessToken: string,
161
+ provider: SocialProvider,
162
+ options: { redirectUri: string },
163
+ ): Promise<IdpStart> {
164
+ const state = randomToken(24);
165
+ const codeVerifier = randomToken(32);
166
+ const { url } = await client(accessToken).auth.linkIdentityAuthorize(provider, {
167
+ redirectUri: options.redirectUri,
168
+ codeChallenge: await s256(codeVerifier),
169
+ state,
170
+ });
171
+ return { url, state, codeVerifier };
172
+ },
173
+ /**
174
+ * 소셜 계정 연결 마무리 — state를 비교하고 code를 바꿔 연결한다. 다른 구매자에 이미 연결된 계정이면
175
+ * 409 `IDENTITY_IN_USE`, 같은 공급자의 다른 계정을 이미 연결했으면 409 `PROVIDER_ALREADY_LINKED`
176
+ */
177
+ linkIdpCallback(
178
+ accessToken: string,
179
+ provider: SocialProvider,
180
+ input: Omit<IdpCallbackInput, "cartToken"> & { error?: string | null },
181
+ ): Promise<MemberIdentities> {
182
+ if (input.error) return Promise.reject(new IdpCallbackError(input.error));
183
+ if (!input.state || input.state !== input.expectedState) {
184
+ return Promise.reject(new IdpCallbackError("state_mismatch"));
185
+ }
186
+ if (!input.code) return Promise.reject(new IdpCallbackError("missing_code"));
187
+ return client(accessToken).auth.linkIdentity(provider, {
188
+ code: input.code,
189
+ codeVerifier: input.codeVerifier,
190
+ });
191
+ },
192
+ /** 소셜 계정 연결 해제 — 마지막 로그인 수단이면 409 `LAST_LOGIN_METHOD` */
193
+ unlinkIdp(accessToken: string, provider: SocialProvider): Promise<MemberIdentities> {
194
+ return client(accessToken).auth.unlinkIdentity(provider);
195
+ },
149
196
  /** 토큰 갱신 — refreshToken은 한 번 쓰면 폐기되고 새 쌍이 온다(회전) */
150
197
  refresh(refreshToken: string): Promise<TokenPair> {
151
198
  return client().auth.refresh(refreshToken);