@sayren/storefront-sdk 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,551 @@
1
+ import type { createStorefrontClient } from "../client";
2
+ import {
3
+ type GuestInfo,
4
+ type PaymentOption,
5
+ type PaymentStart,
6
+ type PaymentStatus,
7
+ paymentMessageSchema,
8
+ type ShippingAddressInput,
9
+ } from "../schemas/checkout";
10
+
11
+ /**
12
+ * 스토어프론트 결제 — 브라우저 전용(`@sayren/storefront-sdk/payments`).
13
+ *
14
+ * ```ts
15
+ * const payments = createPayments({ client: sdk, returnUrl: `${location.origin}/checkout/return` });
16
+ * const result = await payments.start(checkoutId, { option, shippingAddress }); // 결제창(팝업)
17
+ * if (result.status === "COMPLETED") location.assign(`/orders/${result.orderId}`);
18
+ * ```
19
+ *
20
+ * 결제창은 결제 서비스(`payUrl`)가 그린다. SDK는 그 창을 팝업으로 열고 결과 메시지를 받아 결제 상태 조회로 확정한다.
21
+ * 모바일(또는 `mode: "redirect"`)에서는 현재 탭을 `payUrl`로 보내고, 결제가 끝나면 구매자가 `returnUrl`로 돌아온다 —
22
+ * 복귀 화면에서 {@link Payments.result}를 부른다.
23
+ *
24
+ * **`returnUrl`은 결제를 시작한 페이지와 같은 origin이어야 한다.** 결제 서비스는 팝업 결과 메시지를 복귀 주소의
25
+ * origin으로 보내므로, origin이 다르면 메시지가 도달하지 않고 결제 상태 조회로만 결과가 정해진다(느려질 뿐 결과는 같다).
26
+ *
27
+ * 팝업 메시지는 "끝났다"는 신호일 뿐이다. 결과도 구매자에게 보여 줄 안내 문구도 결제 상태 조회의 `result`·`lastFailure`가
28
+ * 원천이다 — 메시지의 코드·메시지는 읽지 않는다(누구나 보낼 수 있는 값이다).
29
+ */
30
+
31
+ type StorefrontClient = Pick<ReturnType<typeof createStorefrontClient>, "checkout" | "payments">;
32
+
33
+ /** 결제창을 여는 방식 — `auto`는 모바일이면 리다이렉트, 아니면 팝업이다 */
34
+ export type PaymentMode = "auto" | "popup" | "redirect";
35
+
36
+ /** `auto` 판정에 쓰는 기기 정보 */
37
+ export interface PaymentDevice {
38
+ userAgent: string;
39
+ /** `matchMedia("(pointer: coarse)")` */
40
+ coarsePointer: boolean;
41
+ viewportWidth: number;
42
+ }
43
+
44
+ /** 결제 결과 */
45
+ export type PaymentResult =
46
+ | {
47
+ status: "COMPLETED";
48
+ paymentId: string;
49
+ /** 만들어진 주문 번호 — 주문 완료 화면으로 보낸다 */
50
+ orderId: string;
51
+ }
52
+ | {
53
+ /** 구매자가 결제창을 닫았다. 같은 결제를 다른 옵션으로 `retry`할 수 있다 */
54
+ status: "CANCELED";
55
+ paymentId: string;
56
+ code: string;
57
+ message: string;
58
+ }
59
+ | {
60
+ /** 카드 거절·결제 오류 등. 같은 결제를 다른 옵션으로 `retry`할 수 있다 */
61
+ status: "FAILED";
62
+ paymentId: string;
63
+ code: string;
64
+ message: string;
65
+ }
66
+ | {
67
+ /** 결과 확인 중 — `getStatus(paymentId)`로 확정될 때까지 조회한다 */
68
+ status: "PROCESSING";
69
+ paymentId: string;
70
+ }
71
+ | {
72
+ /** 복귀 주소에 결제 정보가 없다(직접 연 주소 등) */
73
+ status: "INVALID";
74
+ message: string;
75
+ };
76
+
77
+ /** 결제 서비스 창 — 브라우저에서는 `window.open`이 돌려준 창이다 */
78
+ export interface PaymentPopup {
79
+ readonly closed: boolean;
80
+ /** `event.source` 대조용 창 참조 */
81
+ readonly ref: unknown;
82
+ /** 결제 서비스 주소로 보낸다 */
83
+ navigate(url: string): void;
84
+ close(): void;
85
+ }
86
+
87
+ /** 결제 서비스가 보낸 postMessage — 브라우저 `MessageEvent`의 필요한 부분만 */
88
+ export interface PaymentPostMessage {
89
+ data: unknown;
90
+ origin: string;
91
+ source: unknown;
92
+ }
93
+
94
+ /** 브라우저 의존부 — 테스트는 가짜를 넣는다 */
95
+ export interface PaymentsEnv {
96
+ /** 클릭 시점에 빈 팝업을 연다. 팝업 차단이면 null */
97
+ openPopup(): PaymentPopup | null;
98
+ /** 현재 창을 이동시킨다(리다이렉트 결제) */
99
+ navigate(url: string): void;
100
+ /** 결제 서비스 메시지 구독 — 해제 함수를 돌려준다 */
101
+ subscribe(listener: (event: PaymentPostMessage) => void): () => void;
102
+ /** 복귀 화면의 현재 주소 — `result()`의 기본값 */
103
+ currentUrl(): string;
104
+ device(): PaymentDevice;
105
+ /** 만료 판정에 쓰는 시계. 브라우저 시계가 서버보다 앞서면 팝업 대기가 일찍 끝난다(결과는 PROCESSING) */
106
+ now(): number;
107
+ setInterval(handler: () => void, ms: number): unknown;
108
+ clearInterval(handle: unknown): void;
109
+ }
110
+
111
+ /** 복귀 주소에 결제 서비스가 붙이는 쿼리 */
112
+ const RETURN_PAYMENT_ID = "sayrenPaymentId";
113
+ /** 팝업 이름 — 같은 이름으로 열면 결제창이 하나만 뜬다 */
114
+ const POPUP_NAME = "sayren-payment";
115
+ /**
116
+ * 결제 팝업 크기 — PG 결제창이 안에서 그대로 뜬다. KG이니시스 PC 결제창이 폭 800px 안팎이라 그보다 넓게 연다
117
+ * (포트원 샌드박스 확인). 화면이 더 작으면 화면에 맞춘다.
118
+ */
119
+ const POPUP_SIZE = { width: 860, height: 760 };
120
+
121
+ function popupFeatures(): string {
122
+ const width = Math.min(POPUP_SIZE.width, window.screen?.availWidth || POPUP_SIZE.width);
123
+ const height = Math.min(POPUP_SIZE.height, window.screen?.availHeight || POPUP_SIZE.height);
124
+ return `width=${width},height=${height},resizable=yes,scrollbars=yes`;
125
+ }
126
+ /** 팝업이 메시지 없이 닫혔는지 보는 간격 */
127
+ const POPUP_POLL_MS = 500;
128
+ /** opener를 잃은 팝업 대비 — 결제 상태를 직접 조회하기 시작하는 시점(5초)과 그 뒤 간격(3초) */
129
+ const STATUS_POLL_FIRST_TICKS = 10;
130
+ const STATUS_POLL_EVERY_TICKS = 6;
131
+ /** 좁은 터치 화면은 팝업이 아니라 리다이렉트다 */
132
+ const POPUP_MIN_WIDTH = 820;
133
+ const MOBILE_UA = /Android|iPhone|iPad|iPod|IEMobile|Opera Mini|Mobile/i;
134
+
135
+ /**
136
+ * 결제창을 팝업으로 열지 리다이렉트로 열지 정한다 — `auto` 판정 한 곳(순수 함수).
137
+ * 모바일은 팝업이 탭 전환으로 열리고 간편결제 앱을 거치며 `opener`를 잃으므로 리다이렉트를 쓴다.
138
+ */
139
+ export function resolvePaymentMode(
140
+ mode: PaymentMode | undefined,
141
+ device: PaymentDevice,
142
+ ): "popup" | "redirect" {
143
+ if (mode === "popup" || mode === "redirect") return mode;
144
+ if (MOBILE_UA.test(device.userAgent)) return "redirect";
145
+ if (device.coarsePointer && device.viewportWidth > 0 && device.viewportWidth < POPUP_MIN_WIDTH) {
146
+ return "redirect";
147
+ }
148
+ return "popup";
149
+ }
150
+
151
+ /**
152
+ * 결제 상태를 화면 결과로 옮긴다 — 결과의 원천은 언제나 `GET /payments/{id}`다(메시지는 신호일 뿐).
153
+ * 모르는 `result` 값은 확인 중으로 다룬다.
154
+ */
155
+ export function paymentResultOf(
156
+ status: PaymentStatus,
157
+ options: { popupClosed?: boolean } = {},
158
+ ): PaymentResult {
159
+ const { paymentId, result, lastFailure } = status;
160
+ if (result === "COMPLETED") {
161
+ return status.orderId
162
+ ? { status: "COMPLETED", paymentId, orderId: status.orderId }
163
+ : { status: "PROCESSING", paymentId };
164
+ }
165
+ if (result === "CANCELED") {
166
+ return {
167
+ status: "CANCELED",
168
+ paymentId,
169
+ code: lastFailure?.code ?? "USER_CANCELED",
170
+ message: lastFailure?.message ?? "결제를 취소했어요",
171
+ };
172
+ }
173
+ if (result === "FAILED" || result === "EXPIRED") {
174
+ return {
175
+ status: "FAILED",
176
+ paymentId,
177
+ code: lastFailure?.code ?? result,
178
+ message:
179
+ lastFailure?.message ??
180
+ (result === "EXPIRED" ? "결제 요청이 만료됐어요" : "결제에 실패했어요"),
181
+ };
182
+ }
183
+ if (result === "PENDING") {
184
+ // 결제창이 닫혔는데 아직 결제 전이면 구매자가 닫은 것이다
185
+ return options.popupClosed
186
+ ? { status: "CANCELED", paymentId, code: "POPUP_CLOSED", message: "결제창을 닫았어요" }
187
+ : { status: "PROCESSING", paymentId };
188
+ }
189
+ return { status: "PROCESSING", paymentId };
190
+ }
191
+
192
+ function browserEnv(): PaymentsEnv {
193
+ return {
194
+ openPopup() {
195
+ const popup = window.open("about:blank", POPUP_NAME, popupFeatures());
196
+ if (!popup) return null;
197
+ return {
198
+ get closed() {
199
+ return popup.closed;
200
+ },
201
+ ref: popup,
202
+ navigate: (url) => {
203
+ popup.location.href = url;
204
+ },
205
+ close: () => popup.close(),
206
+ };
207
+ },
208
+ navigate: (url) => window.location.assign(url),
209
+ subscribe(listener) {
210
+ const handler = (event: MessageEvent) =>
211
+ listener({ data: event.data, origin: event.origin, source: event.source });
212
+ window.addEventListener("message", handler);
213
+ return () => window.removeEventListener("message", handler);
214
+ },
215
+ currentUrl: () => window.location.href,
216
+ device: () => ({
217
+ userAgent: typeof navigator === "undefined" ? "" : navigator.userAgent,
218
+ coarsePointer:
219
+ typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches,
220
+ viewportWidth: window.innerWidth,
221
+ }),
222
+ now: () => Date.now(),
223
+ setInterval: (handler, ms) => setInterval(handler, ms),
224
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
225
+ };
226
+ }
227
+
228
+ export interface PaymentsOptions {
229
+ client: StorefrontClient;
230
+ /** 결제를 마치면 돌아올 주소(스토어 결제 도메인 안). `start`·`retry`에서 바꿀 수 있다 */
231
+ returnUrl?: string;
232
+ env?: PaymentsEnv;
233
+ }
234
+
235
+ export interface StartOptions {
236
+ option: PaymentOption;
237
+ shippingAddress: ShippingAddressInput;
238
+ guest?: GuestInfo;
239
+ returnUrl?: string;
240
+ mode?: PaymentMode;
241
+ /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창. 폼 검증이 비동기라 클릭 태스크를 넘길 때 쓴다 */
242
+ window?: PreparedPaymentWindow;
243
+ }
244
+
245
+ export interface RetryOptions {
246
+ option: PaymentOption;
247
+ /** 생략하면 결제 시작 때의 복귀 주소를 쓴다 */
248
+ returnUrl?: string;
249
+ mode?: PaymentMode;
250
+ /** 클릭 시점에 {@link Payments.prepareWindow}로 미리 연 창 */
251
+ window?: PreparedPaymentWindow;
252
+ }
253
+
254
+ /** {@link Payments.prepareWindow}가 돌려준 핸들임을 나타내는 표식 — 직접 만든 객체는 받지 않는다 */
255
+ export const PREPARED_WINDOW: unique symbol = Symbol("sayren.payments.preparedWindow");
256
+
257
+ /**
258
+ * 클릭 시점에 미리 연 결제창. 결제 시작 API가 서버 함수를 거쳐 느리게 끝나도 팝업 차단에 걸리지 않게
259
+ * {@link Payments.prepareWindow}로 먼저 열어 두고 {@link Payments.open}(또는 `start`·`retry`의 `window`)에 넘긴다.
260
+ */
261
+ export interface PreparedPaymentWindow {
262
+ /** `auto` 판정이 끝난 실제 모드 */
263
+ readonly mode: "popup" | "redirect";
264
+ /** 결제 시작에 실패했을 때 미리 연 팝업을 닫는다 */
265
+ close(): void;
266
+ /** 이 결제 인스턴스가 연 창이라는 표식. 값은 내부용이다 */
267
+ readonly [PREPARED_WINDOW]: object;
268
+ }
269
+
270
+ export interface Payments {
271
+ /**
272
+ * 결제 시작 — 결제 세션을 만들고 결제창을 연다. 팝업 모드는 결과가 나올 때까지 기다리고,
273
+ * 리다이렉트 모드는 페이지가 떠나므로 **끝나지 않는 Promise**를 돌려준다.
274
+ * 결제 시작 API가 실패하면 미리 연 팝업을 닫고 `ApiError`를 그대로 던진다.
275
+ */
276
+ start(checkoutId: string, options: StartOptions): Promise<PaymentResult>;
277
+ /** 같은 결제를 다른 결제 옵션으로 다시 시도한다 */
278
+ retry(paymentId: string, options: RetryOptions): Promise<PaymentResult>;
279
+ /**
280
+ * 이미 받은 결제 시작 값으로 결제창만 연다 — 결제 시작을 서버 함수로 하는 SSR 앱용.
281
+ * 클릭 핸들러에서 {@link Payments.prepareWindow}로 창을 먼저 열고 그 핸들을 `window`로 넘긴다.
282
+ */
283
+ open(
284
+ start: PaymentStart,
285
+ options?: { mode?: PaymentMode; window?: PreparedPaymentWindow },
286
+ ): Promise<PaymentResult>;
287
+ /** 클릭 시점에 빈 팝업을 연다(팝업 차단 회피). 반환 핸들을 {@link Payments.open}에 넘긴다 */
288
+ prepareWindow(options?: { mode?: PaymentMode }): PreparedPaymentWindow;
289
+ /** 복귀 화면 — 주소의 `sayrenPaymentId`로 결제 상태를 조회해 결과를 만든다 */
290
+ result(url?: string): Promise<PaymentResult>;
291
+ /** 결제 상태 조회 — 결과가 `PROCESSING`이면 확정될 때까지 조회한다 */
292
+ getStatus(paymentId: string): Promise<PaymentStatus>;
293
+ }
294
+
295
+ interface PreparedWindowInternal extends PreparedPaymentWindow {
296
+ readonly popup: PaymentPopup | null;
297
+ }
298
+
299
+ function popupUrl(payUrl: string): string | null {
300
+ try {
301
+ const url = new URL(payUrl);
302
+ url.searchParams.set("mode", "popup");
303
+ return url.toString();
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ export function createPayments(options: PaymentsOptions): Payments {
310
+ const { client } = options;
311
+ const env = options.env ?? browserEnv();
312
+ /**
313
+ * 진행 중인 팝업 감시. 팝업은 이름이 같아 다시 열면 같은 창을 쓰므로 감시도 하나만 둔다 —
314
+ * 새 결제를 시작하면 이전 감시를 끝내고(그 Promise는 `PROCESSING`) 리스너·타이머를 거둔다.
315
+ */
316
+ let cancelWatch: (() => void) | null = null;
317
+ /** 이 인스턴스가 연 창인지 가리는 표식 */
318
+ const brand: object = {};
319
+
320
+ function stopWatch(): void {
321
+ const cancel = cancelWatch;
322
+ cancelWatch = null;
323
+ cancel?.();
324
+ }
325
+
326
+ /**
327
+ * 결제 서비스는 팝업 결과 메시지를 **복귀 주소(returnUrl)의 origin**으로 보낸다.
328
+ * 결제를 시작한 페이지와 origin이 다르면 메시지가 도달하지 않아 상태 조회로만 결과가 정해진다.
329
+ */
330
+ function warnReturnOrigin(returnUrl: string | undefined, mode: "popup" | "redirect"): void {
331
+ if (!returnUrl || mode !== "popup") return;
332
+ try {
333
+ if (new URL(returnUrl).origin === new URL(env.currentUrl()).origin) return;
334
+ } catch {
335
+ return; // 주소를 읽지 못하면 검사하지 않는다
336
+ }
337
+ console.warn(
338
+ `[sayren] returnUrl(${returnUrl})의 origin이 지금 페이지와 달라요 — 결제 서비스는 그 origin으로 결과 메시지를 보내므로 팝업 결과가 도달하지 않고 결제 상태 조회로만 끝나요`,
339
+ );
340
+ }
341
+
342
+ function prepareWindow(prepareOptions: { mode?: PaymentMode } = {}): PreparedWindowInternal {
343
+ // 같은 창을 다시 쓰므로 이전 결제의 감시부터 끝낸다
344
+ stopWatch();
345
+ const mode = resolvePaymentMode(prepareOptions.mode, env.device());
346
+ // 팝업 차단이면 창이 null이다 — 리다이렉트로 떨어진다
347
+ const popup = mode === "popup" ? env.openPopup() : null;
348
+ return {
349
+ mode: popup ? "popup" : "redirect",
350
+ popup,
351
+ close: () => popup?.close(),
352
+ [PREPARED_WINDOW]: brand,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * 넘겨받은 창 핸들을 확인한다 — 이 인스턴스의 `prepareWindow()`가 돌려준 것만 받는다.
358
+ * 다른 인스턴스의 것이나 손으로 만든 객체는 창도 감시도 우리 것이 아니라 결제가 조용히 어긋난다.
359
+ */
360
+ function resolvePrepared(
361
+ given: PreparedPaymentWindow | undefined,
362
+ mode: PaymentMode | undefined,
363
+ ): PreparedWindowInternal {
364
+ if (!given) return prepareWindow({ mode });
365
+ if ((given as Partial<PreparedWindowInternal>)[PREPARED_WINDOW] !== brand) {
366
+ throw new TypeError(
367
+ "window는 같은 createPayments의 prepareWindow()가 돌려준 핸들이어야 해요",
368
+ );
369
+ }
370
+ return given as PreparedWindowInternal;
371
+ }
372
+
373
+ /**
374
+ * 팝업에서 결제가 끝날 때까지 기다린다 — 메시지는 신호, 결과는 상태 조회다.
375
+ *
376
+ * 감시는 셋이다. (1) 결제 서비스의 postMessage, (2) 창이 닫혔는지, (3) 결제 상태 직접 조회.
377
+ * (3)이 있어야 팝업이 `opener`를 잃어(간편결제 앱 전환, 팝업 안에서 복귀 주소로 리다이렉트) 메시지도
378
+ * 닫힘도 오지 않는 경우에 멈추지 않는다. 어느 경로든 결과는 한 번만 돌려준다.
379
+ */
380
+ function awaitPopup(
381
+ paymentId: string,
382
+ popup: PaymentPopup,
383
+ origin: string,
384
+ expiresAt: string,
385
+ ): Promise<PaymentResult> {
386
+ return new Promise<PaymentResult>((resolve) => {
387
+ let done = false;
388
+ let ticks = 0;
389
+ let polling = false;
390
+ let timer: unknown;
391
+ let unsubscribe = () => {};
392
+ let cancelThis: (() => void) | null = null;
393
+ const deadline = Date.parse(expiresAt);
394
+
395
+ const teardown = () => {
396
+ unsubscribe();
397
+ env.clearInterval(timer);
398
+ if (cancelWatch === cancelThis) cancelWatch = null;
399
+ };
400
+
401
+ const finish = (result: PaymentResult) => {
402
+ if (done) return;
403
+ done = true;
404
+ teardown();
405
+ resolve(result);
406
+ };
407
+
408
+ /** 메시지·닫힘 신호를 받았다 — 결과는 상태 조회로 정한다 */
409
+ const settle = async (popupClosed: boolean) => {
410
+ if (done) return;
411
+ done = true;
412
+ teardown();
413
+ try {
414
+ resolve(paymentResultOf(await client.payments.getStatus(paymentId), { popupClosed }));
415
+ } catch {
416
+ resolve({ status: "PROCESSING", paymentId });
417
+ }
418
+ };
419
+
420
+ // 메시지에서는 `paymentId`만 읽는다 — 결과도 안내 문구(코드·메시지)도 상태 조회의 `result`·`lastFailure`가 원천이다
421
+ unsubscribe = env.subscribe((event) => {
422
+ if (event.origin !== origin || event.source !== popup.ref) return;
423
+ const message = paymentMessageSchema.safeParse(event.data);
424
+ if (!message.success || message.data.paymentId !== paymentId) return;
425
+ void settle(false);
426
+ });
427
+
428
+ timer = env.setInterval(() => {
429
+ if (done) return;
430
+ if (popup.closed) {
431
+ void settle(true);
432
+ return;
433
+ }
434
+ if (Number.isFinite(deadline) && env.now() >= deadline) {
435
+ // 결제 요청이 만료될 때까지 결과를 받지 못했다 — 호출자가 상태를 계속 조회한다
436
+ finish({ status: "PROCESSING", paymentId });
437
+ return;
438
+ }
439
+ ticks += 1;
440
+ const since = ticks - STATUS_POLL_FIRST_TICKS;
441
+ if (polling || since < 0 || since % STATUS_POLL_EVERY_TICKS !== 0) return;
442
+ polling = true;
443
+ void client.payments
444
+ .getStatus(paymentId)
445
+ .then((status) => {
446
+ if (done) return;
447
+ const result = paymentResultOf(status);
448
+ // 아직 결제 전(PENDING)이거나 확인 중이면 계속 기다린다
449
+ if (result.status === "PROCESSING") return;
450
+ // 결과가 확정됐는데 창이 남아 있다 — 닫아 주고 끝낸다
451
+ try {
452
+ popup.close();
453
+ } catch {
454
+ // 창을 닫지 못해도 결과는 같다
455
+ }
456
+ finish(result);
457
+ })
458
+ .catch(() => {
459
+ // 일시적인 조회 실패는 다음 주기에 다시 본다
460
+ })
461
+ .finally(() => {
462
+ polling = false;
463
+ });
464
+ }, POPUP_POLL_MS);
465
+
466
+ // 다음 결제가 같은 창을 다시 쓰면 이 감시는 끝난다 — 결과는 호출자가 상태 조회로 확인한다
467
+ cancelThis = () => finish({ status: "PROCESSING", paymentId });
468
+ cancelWatch = cancelThis;
469
+ });
470
+ }
471
+
472
+ function open(
473
+ start: PaymentStart,
474
+ openOptions: { mode?: PaymentMode; window?: PreparedPaymentWindow } = {},
475
+ ): Promise<PaymentResult> {
476
+ // 미리 연 창을 받았으면 prepareWindow가 이미 거뒀다 — 아니면 여기서 이전 감시를 끝낸다
477
+ stopWatch();
478
+ const prepared = resolvePrepared(openOptions.window, openOptions.mode);
479
+ const popup = prepared.popup;
480
+ const url = popup ? popupUrl(start.payUrl) : null;
481
+ if (!popup || !url) {
482
+ popup?.close();
483
+ env.navigate(start.payUrl);
484
+ // 페이지가 떠난다 — 결과는 복귀 화면의 result()가 읽는다
485
+ return new Promise<PaymentResult>(() => {});
486
+ }
487
+ popup.navigate(url);
488
+ return awaitPopup(start.paymentId, popup, new URL(url).origin, start.expiresAt);
489
+ }
490
+
491
+ async function startWith(
492
+ prepared: PreparedWindowInternal,
493
+ request: () => Promise<PaymentStart>,
494
+ ): Promise<PaymentResult> {
495
+ let start: PaymentStart;
496
+ try {
497
+ start = await request();
498
+ } catch (error) {
499
+ prepared.close();
500
+ throw error;
501
+ }
502
+ return open(start, { window: prepared });
503
+ }
504
+
505
+ return {
506
+ prepareWindow,
507
+ open,
508
+
509
+ start(checkoutId, startOptions) {
510
+ const prepared = resolvePrepared(startOptions.window, startOptions.mode);
511
+ const returnUrl = startOptions.returnUrl ?? options.returnUrl;
512
+ if (!returnUrl) {
513
+ prepared.close();
514
+ return Promise.reject(
515
+ new Error("returnUrl이 필요해요 — createPayments 또는 start에 넘겨 주세요"),
516
+ );
517
+ }
518
+ warnReturnOrigin(returnUrl, prepared.mode);
519
+ return startWith(prepared, () =>
520
+ client.checkout.startPayment(checkoutId, {
521
+ option: startOptions.option,
522
+ shippingAddress: startOptions.shippingAddress,
523
+ guest: startOptions.guest,
524
+ returnUrl,
525
+ }),
526
+ );
527
+ },
528
+
529
+ retry(paymentId, retryOptions) {
530
+ const prepared = resolvePrepared(retryOptions.window, retryOptions.mode);
531
+ const returnUrl = retryOptions.returnUrl ?? options.returnUrl;
532
+ warnReturnOrigin(returnUrl, prepared.mode);
533
+ return startWith(prepared, () =>
534
+ client.payments.retry(paymentId, { option: retryOptions.option, returnUrl }),
535
+ );
536
+ },
537
+
538
+ getStatus: (paymentId) => client.payments.getStatus(paymentId),
539
+
540
+ async result(url) {
541
+ let paymentId: string | null = null;
542
+ try {
543
+ paymentId = new URL(url ?? env.currentUrl()).searchParams.get(RETURN_PAYMENT_ID);
544
+ } catch {
545
+ paymentId = null;
546
+ }
547
+ if (!paymentId) return { status: "INVALID", message: "결제 정보가 없는 주소예요" };
548
+ return paymentResultOf(await client.payments.getStatus(paymentId));
549
+ },
550
+ };
551
+ }
@@ -4,7 +4,7 @@ export const cartItemSchema = z.object({
4
4
  cartItemId: z.string(),
5
5
  productId: z.string(),
6
6
  productName: z.string(),
7
- thumbnailUrl: z.url(),
7
+ thumbnailUrl: z.url().nullable().describe("담은 상품의 대표 이미지. 없으면 null"),
8
8
  optionId: z.string().nullable(),
9
9
  optionName: z.string().nullable(),
10
10
  quantity: z.number().int(),
@@ -20,7 +20,7 @@ export const categoryNodeSchema: z.ZodType<CategoryNode> = z.lazy(() =>
20
20
  export const productCardSchema = z.object({
21
21
  productId: z.string(),
22
22
  name: z.string(),
23
- thumbnailUrl: z.url(),
23
+ thumbnailUrl: z.url().nullable().describe("대표 이미지 주소. 등록된 이미지가 없으면 null"),
24
24
  salePrice: z.number().int().describe("판매가. 원(KRW) 단위 정수"),
25
25
  discountedPrice: z
26
26
  .number()