diff --git a/biome.json b/biome.json index de10f4f8c..e1d196275 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/docs/docs/api-reference/use-checkout.md b/docs/docs/api-reference/use-checkout.md new file mode 100644 index 000000000..d1d09c333 --- /dev/null +++ b/docs/docs/api-reference/use-checkout.md @@ -0,0 +1,374 @@ +--- +sidebar_position: 2 +--- + +# useCheckout + +The `useCheckout` hook enables [Instant Checkout](https://platform.openai.com/docs/actions/monetization) in ChatGPT apps, allowing users to complete purchases directly within ChatGPT. It wraps [`window.openai.requestCheckout`](https://developers.openai.com/apps-sdk/build/chatgpt-ui#instant-checkout) with type-safe state management following the [ACP (Agentic Commerce Protocol) specification](https://developers.openai.com/commerce/specs/checkout). + +:::note +Instant Checkout is currently in private beta and limited to select marketplace partners. +::: + +## Basic usage + +```tsx +import { useCheckout, CheckoutSessionRequest } from "skybridge/web"; + +function CheckoutButton({ session }: { session: CheckoutSessionRequest }) { + const { requestCheckout, isPending, isSuccess, order } = useCheckout(); + + return ( +
+ + {isSuccess && order &&

Order completed: {order.id}

} +
+ ); +} +``` + +## Parameters + +```tsx +const { + data, + error, + isError, + isIdle, + isPending, + isSuccess, + status, + order, + sessionId, + requestCheckout, + requestCheckoutAsync, +} = useCheckout(options); + +requestCheckout(session, { + onError, + onSettled, + onSuccess, +}); + +await requestCheckoutAsync(session); +``` + +### `options` + +Optional configuration object: + +```tsx +type UseCheckoutOptions = { + checkoutSessionIdGenerator?: () => string; +}; +``` + +- `checkoutSessionIdGenerator` - Function to generate unique checkout session IDs. Defaults to `crypto.randomUUID()`. Useful for custom ID formats or testing. + +## Returns + +### `requestCheckout` + +```tsx +requestCheckout: (session: CheckoutSessionRequest, sideEffects?: CheckoutSideEffects) => void +``` + +Initiates the checkout flow. Opens the ChatGPT Instant Checkout UI with the provided session data. + +- `session: CheckoutSessionRequest` + - **Required** + - The checkout session configuration (see [Checkout Session](#checkout-session) below) +- `sideEffects: CheckoutSideEffects` + - Optional callbacks: + - `onSuccess: (data: CheckoutSuccessResponse) => void` - Fires on successful checkout + - `onError: (error: CheckoutErrorResponse | Error) => void` - Fires on checkout failure + - `onSettled: (data, error) => void` - Fires when checkout completes (success or error) + +### `requestCheckoutAsync` + +```tsx +requestCheckoutAsync: (session: CheckoutSessionRequest) => Promise +``` + +Same as `requestCheckout` but returns a promise for async/await usage. + +### `status` + +```tsx +status: "idle" | "pending" | "success" | "error" +``` + +- `idle` - Initial state, no checkout initiated +- `pending` - Checkout UI is open, awaiting user action +- `success` - Checkout completed successfully +- `error` - Checkout failed or was cancelled + +### `isIdle`, `isPending`, `isSuccess`, `isError` + +```tsx +isIdle: boolean; +isPending: boolean; +isSuccess: boolean; +isError: boolean; +``` + +Boolean flags derived from `status` for convenience. + +### `data` + +```tsx +data: CheckoutSuccessResponse | undefined +``` + +The successful checkout response. Only available when `status` is `"success"`. + +```tsx +type CheckoutSuccessResponse = { + id: string; // Checkout session ID + status: "completed"; + currency: string; + order: { + id: string; // Order ID + checkout_session_id: string; + permalink_url?: string; // Link to order confirmation + created_at?: string; + status?: CheckoutOrderStatus; + }; +}; +``` + +### `error` + +```tsx +error: CheckoutErrorResponse | Error | undefined +``` + +The error if checkout failed. Only available when `status` is `"error"`. + +```tsx +type CheckoutErrorResponse = { + code: CheckoutErrorCode; // "payment_declined" | "requires_3ds" | "cancelled" | ... + message: string; +}; +``` + +### `order` + +```tsx +order: CheckoutOrder | undefined +``` + +Convenience accessor for the order details from the successful checkout. Equivalent to `data?.order`. Only available when `status` is `"success"`. + +```tsx +type CheckoutOrder = { + id: string; // Order ID + checkout_session_id: string; + permalink_url?: string; // Link to order confirmation + created_at?: string; + status?: CheckoutOrderStatus; +}; +``` + +### `sessionId` + +```tsx +sessionId: string | undefined +``` + +The checkout session ID used for the current or most recent checkout operation. This is either the ID provided in the `CheckoutSessionRequest` or auto-generated using the `checkoutSessionIdGenerator`. + +## Checkout Session + +The `CheckoutSessionRequest` defines what the user is purchasing: + +```tsx +type CheckoutSessionRequest = { + id: string; // Unique session ID + payment_provider: { + provider: string; // PSP name (e.g., "stripe", "adyen") + merchant_id: string; // Your merchant ID from PSP + supported_payment_methods?: SupportedPaymentMethod[]; + }; + status: CheckoutSessionStatus; // Usually "ready_for_payment" + currency: string; // ISO 4217 code (e.g., "USD") + totals: CheckoutTotal[]; // Price breakdown + links?: CheckoutLink[]; // Legal/policy links + payment_mode?: "live" | "test"; // Use "test" for test cards + line_items?: CheckoutLineItem[]; // Items being purchased + merchant_name?: string; + order_reference?: string; +}; +``` + +## Examples + +### With Side Effects + +```tsx +import { useCheckout, CheckoutSessionRequest } from "skybridge/web"; + +function CheckoutWidget({ session }: { session: CheckoutSessionRequest }) { + const { requestCheckout, isPending, isError, error } = useCheckout(); + + const handleCheckout = () => { + requestCheckout(session, { + onSuccess: (data) => { + console.log("Order completed:", data.order.id); + }, + onError: (error) => { + if ("code" in error) { + console.error("Checkout error:", error.code, error.message); + } else { + console.error("Unexpected error:", error.message); + } + }, + }); + }; + + return ( +
+ + {isError &&

Error: {"message" in error ? error.message : String(error)}

} +
+ ); +} +``` + +### Async/Await Pattern + +```tsx +import { useCheckout, CheckoutSessionRequest } from "skybridge/web"; + +function AsyncCheckout({ session }: { session: CheckoutSessionRequest }) { + const { requestCheckoutAsync, isPending } = useCheckout(); + + const handleCheckout = async () => { + try { + const result = await requestCheckoutAsync(session); + console.log("Order ID:", result.order.id); + // Redirect to confirmation or update UI + } catch (error) { + console.error("Checkout failed:", error); + } + }; + + return ( + + ); +} +``` + +### Building a Checkout Session + +```tsx +import { useCheckout, CheckoutSessionRequest } from "skybridge/web"; + +function ProductCheckout({ productId, price }: { productId: string; price: number }) { + const { requestCheckout, isPending } = useCheckout(); + + const handleCheckout = () => { + const session: CheckoutSessionRequest = { + id: `checkout_${Date.now()}`, + payment_provider: { + provider: "stripe", + merchant_id: "your_merchant_id", + supported_payment_methods: ["card", "apple_pay", "google_pay"], + }, + status: "ready_for_payment", + currency: "USD", + totals: [ + { type: "subtotal", display_text: "Subtotal", amount: price }, + { type: "tax", display_text: "Tax", amount: Math.round(price * 0.08) }, + { type: "total", display_text: "Total", amount: Math.round(price * 1.08) }, + ], + links: [ + { type: "terms_of_use", url: "https://example.com/terms" }, + { type: "privacy_policy", url: "https://example.com/privacy" }, + ], + payment_mode: "live", + }; + + requestCheckout(session); + }; + + return ( + + ); +} +``` + +### Test Mode + +Use `payment_mode: "test"` to test with test cards (e.g., 4242 4242 4242 4242): + +```tsx +const testSession: CheckoutSessionRequest = { + // ...other fields + payment_mode: "test", +}; +``` + +### Using Order and Session ID + +```tsx +import { useCheckout, CheckoutSessionRequest } from "skybridge/web"; + +function CheckoutStatus({ session }: { session: CheckoutSessionRequest }) { + const { requestCheckout, isPending, isSuccess, order, sessionId } = useCheckout(); + + return ( +
+ + {sessionId &&

Session ID: {sessionId}

} + {isSuccess && order && ( +
+

Order completed: {order.id}

+ {order.permalink_url && ( + View order details + )} +
+ )} +
+ ); +} +``` + +### Custom Session ID Generator + +```tsx +import { useCheckout } from "skybridge/web"; + +function CheckoutWithCustomId() { + const { requestCheckout, sessionId } = useCheckout({ + checkoutSessionIdGenerator: () => `session_${Date.now()}_${Math.random().toString(36).slice(2)}`, + }); + + // sessionId will use your custom format + return ( + + ); +} +``` + +## Related + +- [OpenAI Monetization Docs](https://platform.openai.com/docs/actions/monetization) +- [ACP Checkout Specification](https://developers.openai.com/commerce/specs/checkout) +- [Stripe Agentic Commerce](https://docs.stripe.com/agentic-commerce/apps) +- [Adyen Agentic Commerce](https://docs.adyen.com/online-payments/agentic-commerce) diff --git a/packages/core/src/server/widgetsDevServer.ts b/packages/core/src/server/widgetsDevServer.ts index da22fb7c7..a4f8d3f47 100644 --- a/packages/core/src/server/widgetsDevServer.ts +++ b/packages/core/src/server/widgetsDevServer.ts @@ -26,7 +26,7 @@ export const widgetsDevServer = async (): Promise => { webAppRoot, ); - const { build, preview, ...devConfig } = configResult?.config || {}; + const { ...devConfig } = configResult?.config || {}; const vite = await createServer({ ...devConfig, diff --git a/packages/core/src/web/hooks/index.ts b/packages/core/src/web/hooks/index.ts index 8893889d1..7868592c4 100644 --- a/packages/core/src/web/hooks/index.ts +++ b/packages/core/src/web/hooks/index.ts @@ -1,3 +1,4 @@ +export type { AsyncOperationState } from "./use-async-operation.js"; export { type CallToolAsyncFn, type CallToolFn, @@ -5,6 +6,30 @@ export { type SideEffects, useCallTool, } from "./use-call-tool.js"; +export { + type CheckoutErrorCode, + type CheckoutErrorResponse, + type CheckoutLineItem, + type CheckoutLink, + type CheckoutLinkType, + type CheckoutOrder, + type CheckoutOrderStatus, + type CheckoutPaymentMode, + type CheckoutPaymentProvider, + type CheckoutResponse, + type CheckoutSessionRequest, + type CheckoutSessionStatus, + type CheckoutSideEffects, + type CheckoutState, + type CheckoutSuccessResponse, + type CheckoutTotal, + type CheckoutTotalType, + type RequestCheckoutAsyncFn, + type RequestCheckoutFn, + type SupportedPaymentMethod, + type UseCheckoutOptions, + useCheckout, +} from "./use-checkout.js"; export { useDisplayMode } from "./use-display-mode.js"; export { useFiles } from "./use-files.js"; export { type LayoutState, useLayout } from "./use-layout.js"; diff --git a/packages/core/src/web/hooks/use-async-operation.test.ts b/packages/core/src/web/hooks/use-async-operation.test.ts new file mode 100644 index 000000000..9ade22deb --- /dev/null +++ b/packages/core/src/web/hooks/use-async-operation.test.ts @@ -0,0 +1,411 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAsyncOperation } from "./use-async-operation.js"; + +describe("useAsyncOperation", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("initial state", () => { + it("should start in idle state", () => { + const { result } = renderHook(() => useAsyncOperation()); + + expect(result.current.state.status).toBe("idle"); + expect(result.current.state.isIdle).toBe(true); + expect(result.current.state.isPending).toBe(false); + expect(result.current.state.isSuccess).toBe(false); + expect(result.current.state.isError).toBe(false); + expect(result.current.state.data).toBeUndefined(); + expect(result.current.state.error).toBeUndefined(); + }); + }); + + describe("successful execution", () => { + it("should transition from idle -> pending -> success", async () => { + const { result } = renderHook(() => useAsyncOperation()); + const mockData = "success data"; + + let resolveOperation: (value: string) => void; + const operationPromise = new Promise((resolve) => { + resolveOperation = resolve; + }); + + // Start execution + act(() => { + result.current.execute(() => operationPromise); + }); + + // Should be in pending state + expect(result.current.state.status).toBe("pending"); + expect(result.current.state.isPending).toBe(true); + expect(result.current.state.data).toBeUndefined(); + + // Resolve the operation + await act(async () => { + resolveOperation(mockData); + await operationPromise; + }); + + // Should be in success state + await waitFor(() => { + expect(result.current.state.status).toBe("success"); + expect(result.current.state.isSuccess).toBe(true); + expect(result.current.state.data).toBe(mockData); + expect(result.current.state.error).toBeUndefined(); + }); + }); + + it("should return the result from execute", async () => { + const { result } = renderHook(() => useAsyncOperation()); + const expectedValue = 42; + + let actualValue: number | undefined; + await act(async () => { + actualValue = await result.current.execute(async () => expectedValue); + }); + + expect(actualValue).toBe(expectedValue); + }); + }); + + describe("failed execution", () => { + it("should transition from idle -> pending -> error", async () => { + const { result } = renderHook(() => useAsyncOperation()); + const mockError = new Error("operation failed"); + + let rejectOperation: (error: Error) => void; + const operationPromise = new Promise((_resolve, reject) => { + rejectOperation = reject; + }); + + // Start execution + act(() => { + result.current + .execute(() => operationPromise) + .catch(() => { + // Prevent unhandled rejection + }); + }); + + // Should be in pending state + expect(result.current.state.status).toBe("pending"); + expect(result.current.state.isPending).toBe(true); + + // Reject the operation + await act(async () => { + rejectOperation(mockError); + try { + await operationPromise; + } catch { + // Expected + } + }); + + // Should be in error state + await waitFor(() => { + expect(result.current.state.status).toBe("error"); + expect(result.current.state.isError).toBe(true); + expect(result.current.state.error).toBe(mockError); + expect(result.current.state.data).toBeUndefined(); + }); + }); + + it("should throw the error from execute", async () => { + const { result } = renderHook(() => useAsyncOperation()); + const expectedError = new Error("test error"); + + await act(async () => { + await expect( + result.current.execute(async () => { + throw expectedError; + }), + ).rejects.toThrow(expectedError); + }); + }); + }); + + describe("deduplication", () => { + it("should not enable deduplication by default", async () => { + const { result } = renderHook(() => useAsyncOperation()); + + // Execute first operation + await act(async () => { + await result.current.execute(async () => "first"); + }); + expect(result.current.state.data).toBe("first"); + + // Execute second operation + await act(async () => { + await result.current.execute(async () => "second"); + }); + expect(result.current.state.data).toBe("second"); + }); + + it("should deduplicate rapid calls when enabled", async () => { + const { result } = renderHook(() => + useAsyncOperation({ enableDeduplication: true }), + ); + + let resolveFirst: (value: string) => void; + let resolveSecond: (value: string) => void; + + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve; + }); + + // Start first operation + act(() => { + result.current.execute(() => firstPromise); + }); + + // Start second operation (should supersede first) + act(() => { + result.current.execute(() => secondPromise); + }); + + expect(result.current.state.status).toBe("pending"); + + // Resolve first operation (should be ignored) + await act(async () => { + resolveFirst("first"); + await firstPromise; + }); + + // State should still be pending because second is newer + expect(result.current.state.status).toBe("pending"); + expect(result.current.state.data).toBeUndefined(); + + // Resolve second operation (should update state) + await act(async () => { + resolveSecond("second"); + await secondPromise; + }); + + await waitFor(() => { + expect(result.current.state.status).toBe("success"); + expect(result.current.state.data).toBe("second"); + }); + }); + + it("should ignore errors from superseded operations", async () => { + const { result } = renderHook(() => + useAsyncOperation({ enableDeduplication: true }), + ); + + let rejectFirst: (error: Error) => void; + let resolveSecond: (value: string) => void; + + const firstPromise = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve; + }); + + // Start first operation + act(() => { + result.current + .execute(() => firstPromise) + .catch(() => { + // Prevent unhandled rejection + }); + }); + + // Start second operation (should supersede first) + act(() => { + result.current.execute(() => secondPromise); + }); + + // Reject first operation (should be ignored) + await act(async () => { + rejectFirst(new Error("first error")); + try { + await firstPromise; + } catch { + // Expected + } + }); + + // State should still be pending (error was ignored) + expect(result.current.state.status).toBe("pending"); + expect(result.current.state.error).toBeUndefined(); + + // Resolve second operation + await act(async () => { + resolveSecond("second"); + await secondPromise; + }); + + await waitFor(() => { + expect(result.current.state.status).toBe("success"); + expect(result.current.state.data).toBe("second"); + }); + }); + + it("should handle multiple rapid calls with deduplication", async () => { + const { result } = renderHook(() => + useAsyncOperation({ enableDeduplication: true }), + ); + + const resolvers: Array<() => void> = []; + const promises = [1, 2, 3, 4, 5].map( + (n) => + new Promise((resolve) => { + resolvers.push(() => resolve(n)); + }), + ); + + // Start all operations rapidly + act(() => { + promises.forEach((promise) => { + result.current.execute(() => promise); + }); + }); + + expect(result.current.state.status).toBe("pending"); + + // Resolve all operations + await act(async () => { + for (const resolver of resolvers) { + resolver(); + } + await Promise.all(promises); + }); + + // Only the last operation should update state + await waitFor(() => { + expect(result.current.state.status).toBe("success"); + expect(result.current.state.data).toBe(5); + }); + }); + }); + + describe("state transitions", () => { + it("should clear previous data when starting new operation", async () => { + const { result } = renderHook(() => useAsyncOperation()); + + // First successful operation + await act(async () => { + await result.current.execute(async () => "first data"); + }); + expect(result.current.state.data).toBe("first data"); + + // Start second operation + let resolveSecond: (value: string) => void; + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve; + }); + + act(() => { + result.current.execute(() => secondPromise); + }); + + // Data should be cleared in pending state + expect(result.current.state.status).toBe("pending"); + expect(result.current.state.data).toBeUndefined(); + + // Complete second operation + await act(async () => { + resolveSecond("second data"); + await secondPromise; + }); + + await waitFor(() => { + expect(result.current.state.data).toBe("second data"); + }); + }); + + it("should clear previous error when starting new operation", async () => { + const { result } = renderHook(() => useAsyncOperation()); + + // First failed operation + await act(async () => { + try { + await result.current.execute(async () => { + throw new Error("first error"); + }); + } catch { + // Expected + } + }); + expect(result.current.state.error).toBeDefined(); + + // Start second operation + let resolveSecond: (value: string) => void; + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve; + }); + + act(() => { + result.current.execute(() => secondPromise); + }); + + // Error should be cleared in pending state + expect(result.current.state.status).toBe("pending"); + expect(result.current.state.error).toBeUndefined(); + + // Complete second operation + await act(async () => { + resolveSecond("success"); + await secondPromise; + }); + + await waitFor(() => { + expect(result.current.state.data).toBe("success"); + }); + }); + }); + + describe("type safety", () => { + it("should maintain correct types for data", async () => { + type CustomData = { id: string; value: number }; + const { result } = renderHook(() => useAsyncOperation()); + + const testData: CustomData = { id: "test", value: 42 }; + + await act(async () => { + await result.current.execute(async () => testData); + }); + + // Type should be CustomData | undefined in success state + expect(result.current.state.data).toEqual(testData); + if (result.current.state.isSuccess) { + expect(result.current.state.data.id).toBe("test"); + expect(result.current.state.data.value).toBe(42); + } + }); + + it("should maintain correct types for error", async () => { + type CustomError = { code: string; message: string }; + const { result } = renderHook(() => + useAsyncOperation(), + ); + + const testError: CustomError = { + code: "TEST_ERROR", + message: "Test error message", + }; + + await act(async () => { + try { + await result.current.execute(async () => { + throw testError; + }); + } catch { + // Expected + } + }); + + // Type should be CustomError | undefined in error state + expect(result.current.state.error).toEqual(testError); + if (result.current.state.isError) { + expect(result.current.state.error.code).toBe("TEST_ERROR"); + expect(result.current.state.error.message).toBe("Test error message"); + } + }); + }); +}); diff --git a/packages/core/src/web/hooks/use-async-operation.ts b/packages/core/src/web/hooks/use-async-operation.ts new file mode 100644 index 000000000..db5e4ffe0 --- /dev/null +++ b/packages/core/src/web/hooks/use-async-operation.ts @@ -0,0 +1,144 @@ +import { useRef, useState } from "react"; + +type AsyncOperationIdleState<_TData, _TError> = { + status: "idle"; + isIdle: true; + isPending: false; + isSuccess: false; + isError: false; + data: undefined; + error: undefined; +}; + +type AsyncOperationPendingState<_TData, _TError> = { + status: "pending"; + isIdle: false; + isPending: true; + isSuccess: false; + isError: false; + data: undefined; + error: undefined; +}; + +type AsyncOperationSuccessState = { + status: "success"; + isIdle: false; + isPending: false; + isSuccess: true; + isError: false; + data: TData; + error: undefined; +}; + +type AsyncOperationErrorState<_TData, TError> = { + status: "error"; + isIdle: false; + isPending: false; + isSuccess: false; + isError: true; + data: undefined; + error: TError; +}; + +export type AsyncOperationState = + | AsyncOperationIdleState + | AsyncOperationPendingState + | AsyncOperationSuccessState + | AsyncOperationErrorState; + +export type UseAsyncOperationConfig = { + enableDeduplication?: boolean; +}; + +const createState = ( + status: "idle" | "pending" | "success" | "error", + data: TData | undefined, + error: TError | undefined, +): AsyncOperationState => { + switch (status) { + case "idle": + return { + status: "idle", + data: undefined, + error: undefined, + isIdle: true, + isPending: false, + isSuccess: false, + isError: false, + }; + case "pending": + return { + status: "pending", + data: undefined, + error: undefined, + isIdle: false, + isPending: true, + isSuccess: false, + isError: false, + }; + case "success": + return { + status: "success", + data: data as TData, + error: undefined, + isIdle: false, + isPending: false, + isSuccess: true, + isError: false, + }; + case "error": + return { + status: "error", + data: undefined, + error: error as TError, + isIdle: false, + isPending: false, + isSuccess: false, + isError: true, + }; + } +}; + +export const useAsyncOperation = ( + config?: UseAsyncOperationConfig, +) => { + const [{ status, data, error }, setState] = useState< + Omit< + AsyncOperationState, + "isIdle" | "isPending" | "isSuccess" | "isError" + > + >({ status: "idle", data: undefined, error: undefined }); + + const callIdRef = useRef(0); + + const execute = async (executeFn: () => Promise): Promise => { + const callId = config?.enableDeduplication ? ++callIdRef.current : 0; + setState({ status: "pending", data: undefined, error: undefined }); + + try { + const result = await executeFn(); + + if (!config?.enableDeduplication || callId === callIdRef.current) { + setState({ status: "success", data: result, error: undefined }); + } + + return result; + } catch (error) { + if (!config?.enableDeduplication || callId === callIdRef.current) { + setState({ + status: "error", + data: undefined, + error: error as TError, + }); + } + throw error; + } + }; + + const state = createState(status, data, error); + + return { + state, + execute, + }; +}; diff --git a/packages/core/src/web/hooks/use-call-tool.ts b/packages/core/src/web/hooks/use-call-tool.ts index 9e6d1aaca..c0a254de4 100644 --- a/packages/core/src/web/hooks/use-call-tool.ts +++ b/packages/core/src/web/hooks/use-call-tool.ts @@ -1,57 +1,14 @@ -import { useRef, useState } from "react"; - import { useAdaptor } from "../bridges/hooks/use-adaptor.js"; import type { CallToolArgs, CallToolResponse, HasRequiredKeys, } from "../types.js"; - -type CallToolIdleState = { - status: "idle"; - isIdle: true; - isPending: false; - isSuccess: false; - isError: false; - data: undefined; - error: undefined; -}; - -type CallToolPendingState = { - status: "pending"; - isIdle: false; - isPending: true; - isSuccess: false; - isError: false; - data: undefined; - error: undefined; -}; - -type CallToolSuccessState = { - status: "success"; - isIdle: false; - isPending: false; - isSuccess: true; - isError: false; - data: TData; - error: undefined; -}; - -type CallToolErrorState = { - status: "error"; - isIdle: false; - isPending: false; - isSuccess: false; - isError: true; - data: undefined; - error: unknown; -}; +import type { AsyncOperationState } from "./use-async-operation.js"; +import { useAsyncOperation } from "./use-async-operation.js"; export type CallToolState = - | CallToolIdleState - | CallToolPendingState - | CallToolSuccessState - | CallToolErrorState; + AsyncOperationState; export type SideEffects = { onSuccess?: (data: ToolResponse, toolArgs: ToolArgs) => void; @@ -103,38 +60,22 @@ export const useCallTool = < ) => { type CombinedCallToolResponse = CallToolResponse & ToolResponse; - const [{ status, data, error }, setCallToolState] = useState< - Omit< - CallToolState, - "isIdle" | "isPending" | "isSuccess" | "isError" - > - >({ status: "idle", data: undefined, error: undefined }); + const { state, execute: executeAsync } = useAsyncOperation< + CombinedCallToolResponse, + unknown + >({ enableDeduplication: true }); - const callIdRef = useRef(0); const adaptor = useAdaptor(); const execute = async ( toolArgs: ToolArgs, ): Promise => { - const callId = ++callIdRef.current; - setCallToolState({ status: "pending", data: undefined, error: undefined }); - - try { - const data = await adaptor.callTool( + return executeAsync(async () => { + return adaptor.callTool( name, toolArgs, ); - if (callId === callIdRef.current) { - setCallToolState({ status: "success", data, error: undefined }); - } - - return data; - } catch (error) { - if (callId === callIdRef.current) { - setCallToolState({ status: "error", data: undefined, error }); - } - throw error; - } + }); }; const callToolAsync = ((toolArgs?: ToolArgs) => { @@ -173,18 +114,8 @@ export const useCallTool = < }); }) as CallToolFn; - const callToolState = { - status, - data, - error, - isIdle: status === "idle", - isPending: status === "pending", - isSuccess: status === "success", - isError: status === "error", - } as CallToolState; - return { - ...callToolState, + ...state, callTool, callToolAsync, }; diff --git a/packages/core/src/web/hooks/use-checkout.test.ts b/packages/core/src/web/hooks/use-checkout.test.ts new file mode 100644 index 000000000..723b48eec --- /dev/null +++ b/packages/core/src/web/hooks/use-checkout.test.ts @@ -0,0 +1,443 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import type { + CheckoutErrorResponse, + CheckoutSessionRequest, + CheckoutSuccessResponse, +} from "../types.js"; +import { useCheckout } from "./use-checkout.js"; + +describe("useCheckout", () => { + let requestCheckoutMock: Mock; + + const mockSession: CheckoutSessionRequest = { + id: "checkout_session_123", + payment_provider: { + provider: "stripe", + merchant_id: "merchant_456", + supported_payment_methods: ["card", "apple_pay"], + }, + status: "ready_for_payment", + currency: "USD", + totals: [ + { type: "subtotal", display_text: "Subtotal", amount: 1000 }, + { type: "tax", display_text: "Tax", amount: 80 }, + { type: "total", display_text: "Total", amount: 1080 }, + ], + links: [ + { type: "terms_of_use", url: "https://example.com/terms" }, + { type: "privacy_policy", url: "https://example.com/privacy" }, + ], + payment_mode: "live", + }; + + const mockSuccessResponse: CheckoutSuccessResponse = { + id: "checkout_session_123", + status: "completed", + currency: "USD", + order: { + id: "order_789", + checkout_session_id: "checkout_session_123", + permalink_url: "https://example.com/orders/789", + }, + }; + + const mockErrorResponse: CheckoutErrorResponse = { + code: "payment_declined", + message: "Your card was declined.", + }; + + beforeEach(() => { + requestCheckoutMock = vi.fn(); + vi.stubGlobal("openai", { + requestCheckout: requestCheckoutMock, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); + }); + + describe("initial state", () => { + it("should start in idle state", () => { + const { result } = renderHook(() => useCheckout()); + + expect(result.current.status).toBe("idle"); + expect(result.current.isIdle).toBe(true); + expect(result.current.isPending).toBe(false); + expect(result.current.isSuccess).toBe(false); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + }); + }); + + describe("requestCheckout", () => { + it("should call window.openai.requestCheckout with correct session data", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + expect(requestCheckoutMock).toHaveBeenCalledWith(mockSession); + }); + + it("should transition to pending state while checkout is in progress", async () => { + let resolveCheckout: (value: CheckoutSuccessResponse) => void; + requestCheckoutMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveCheckout = resolve; + }), + ); + + const { result } = renderHook(() => useCheckout()); + + act(() => { + result.current.requestCheckout(mockSession); + }); + + expect(result.current.status).toBe("pending"); + expect(result.current.isPending).toBe(true); + expect(result.current.isIdle).toBe(false); + + await act(async () => { + resolveCheckout?.(mockSuccessResponse); + }); + }); + + it("should transition to success state on successful checkout", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + await waitFor(() => { + expect(result.current.status).toBe("success"); + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toEqual(mockSuccessResponse); + expect(result.current.error).toBeUndefined(); + }); + }); + + it("should transition to error state on checkout error response", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse); + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + await waitFor(() => { + expect(result.current.status).toBe("error"); + expect(result.current.isError).toBe(true); + expect(result.current.error).toEqual(mockErrorResponse); + expect(result.current.data).toBeUndefined(); + }); + }); + + it("should transition to error state on rejected promise", async () => { + const error = new Error("Network error"); + requestCheckoutMock.mockRejectedValueOnce(error); + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + await waitFor(() => { + expect(result.current.status).toBe("error"); + expect(result.current.isError).toBe(true); + expect(result.current.error).toEqual(error); + }); + }); + }); + + describe("requestCheckout with side effects", () => { + it("should call onSuccess callback on successful checkout", async () => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + const onSettled = vi.fn(); + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + + const { result } = renderHook(() => useCheckout()); + + act(() => { + result.current.requestCheckout(mockSession, { + onSuccess, + onError, + onSettled, + }); + }); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith(mockSuccessResponse); + expect(onError).not.toHaveBeenCalled(); + expect(onSettled).toHaveBeenCalledWith(mockSuccessResponse, undefined); + }); + }); + + it("should call onError callback on checkout error", async () => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + const onSettled = vi.fn(); + requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse); + + const { result } = renderHook(() => useCheckout()); + + act(() => { + result.current.requestCheckout(mockSession, { + onSuccess, + onError, + onSettled, + }); + }); + + await waitFor(() => { + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(mockErrorResponse); + expect(onSettled).toHaveBeenCalledWith(undefined, mockErrorResponse); + }); + }); + + it("should call onError callback on rejected promise", async () => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + const onSettled = vi.fn(); + const error = new Error("Network error"); + requestCheckoutMock.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useCheckout()); + + act(() => { + result.current.requestCheckout(mockSession, { + onSuccess, + onError, + onSettled, + }); + }); + + await waitFor(() => { + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(error); + expect(onSettled).toHaveBeenCalledWith(undefined, error); + }); + }); + }); + + describe("requestCheckoutAsync", () => { + it("should return the successful response", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + const { result } = renderHook(() => useCheckout()); + + const response = await act(async () => { + return result.current.requestCheckoutAsync(mockSession); + }); + + expect(response).toEqual(mockSuccessResponse); + }); + + it("should throw on checkout error response", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse); + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + await expect( + result.current.requestCheckoutAsync(mockSession), + ).rejects.toEqual(mockErrorResponse); + }); + }); + + it("should throw on rejected promise", async () => { + const error = new Error("Network error"); + requestCheckoutMock.mockRejectedValueOnce(error); + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + await expect( + result.current.requestCheckoutAsync(mockSession), + ).rejects.toEqual(error); + }); + }); + }); + + describe("when requestCheckout is not available", () => { + it("should throw an error if window.openai.requestCheckout is undefined", async () => { + vi.stubGlobal("openai", {}); + + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + await expect( + result.current.requestCheckoutAsync(mockSession), + ).rejects.toThrow("requestCheckout is not available in this host"); + }); + }); + + it("should call onError callback if requestCheckout is unavailable", async () => { + vi.stubGlobal("openai", {}); + const onError = vi.fn(); + + const { result } = renderHook(() => useCheckout()); + + act(() => { + result.current.requestCheckout(mockSession, { onError }); + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalled(); + const errorArg = onError.mock.calls[0]?.[0]; + expect(errorArg).toBeInstanceOf(Error); + expect((errorArg as Error).message).toBe( + "requestCheckout is not available in this host", + ); + }); + }); + }); + + describe("test payment mode", () => { + it("should support test payment mode in session", async () => { + const testSession: CheckoutSessionRequest = { + ...mockSession, + payment_mode: "test", + }; + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(testSession); + }); + + expect(requestCheckoutMock).toHaveBeenCalledWith(testSession); + }); + }); + + describe("session ID generation", () => { + it("should generate session ID automatically if not provided", async () => { + const sessionWithoutId: CheckoutSessionRequest = { + ...mockSession, + id: undefined as unknown as string, + }; + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(sessionWithoutId); + }); + + // Verify that requestCheckout was called with a session that has an ID + expect(requestCheckoutMock).toHaveBeenCalled(); + const calledSession = requestCheckoutMock.mock.calls[0]?.[0]; + expect(calledSession).toBeDefined(); + expect(calledSession.id).toBeDefined(); + expect(typeof calledSession.id).toBe("string"); + }); + + it("should preserve provided session ID", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + expect(requestCheckoutMock).toHaveBeenCalledWith(mockSession); + const calledSession = requestCheckoutMock.mock.calls[0]?.[0]; + expect(calledSession.id).toBe("checkout_session_123"); + }); + + it("should use custom session ID generator", async () => { + const customIdGenerator = vi.fn(() => "custom_id_999"); + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + + const sessionWithoutId: CheckoutSessionRequest = { + ...mockSession, + id: undefined as unknown as string, + }; + + const { result } = renderHook(() => + useCheckout({ checkoutSessionIdGenerator: customIdGenerator }), + ); + + await act(async () => { + result.current.requestCheckout(sessionWithoutId); + }); + + expect(customIdGenerator).toHaveBeenCalled(); + const calledSession = requestCheckoutMock.mock.calls[0]?.[0]; + expect(calledSession.id).toBe("custom_id_999"); + }); + + it("should expose sessionId in return value", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + + const { result } = renderHook(() => useCheckout()); + + expect(result.current.sessionId).toBeUndefined(); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + await waitFor(() => { + expect(result.current.sessionId).toBe("checkout_session_123"); + }); + }); + }); + + describe("direct order access", () => { + it("should expose order directly in return value", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockSuccessResponse); + + const { result } = renderHook(() => useCheckout()); + + expect(result.current.order).toBeUndefined(); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + await waitFor(() => { + expect(result.current.order).toEqual(mockSuccessResponse.order); + expect(result.current.order?.id).toBe("order_789"); + }); + }); + + it("should have order as undefined when status is not success", async () => { + const { result } = renderHook(() => useCheckout()); + + expect(result.current.order).toBeUndefined(); + expect(result.current.status).toBe("idle"); + }); + + it("should have order as undefined on error", async () => { + requestCheckoutMock.mockResolvedValueOnce(mockErrorResponse); + + const { result } = renderHook(() => useCheckout()); + + await act(async () => { + result.current.requestCheckout(mockSession); + }); + + await waitFor(() => { + expect(result.current.status).toBe("error"); + expect(result.current.order).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/core/src/web/hooks/use-checkout.ts b/packages/core/src/web/hooks/use-checkout.ts new file mode 100644 index 000000000..bf48df5cc --- /dev/null +++ b/packages/core/src/web/hooks/use-checkout.ts @@ -0,0 +1,127 @@ +import { useState } from "react"; +import type { + CheckoutErrorResponse, + CheckoutResponse, + CheckoutSessionRequest, + CheckoutSuccessResponse, +} from "../types.js"; +import type { AsyncOperationState } from "./use-async-operation.js"; +import { useAsyncOperation } from "./use-async-operation.js"; + +export type { + CheckoutErrorCode, + CheckoutErrorResponse, + CheckoutLineItem, + CheckoutLink, + CheckoutLinkType, + CheckoutOrder, + CheckoutOrderStatus, + CheckoutPaymentMode, + CheckoutPaymentProvider, + CheckoutResponse, + CheckoutSessionRequest, + CheckoutSessionStatus, + CheckoutSuccessResponse, + CheckoutTotal, + CheckoutTotalType, + SupportedPaymentMethod, +} from "../types.js"; + +export type CheckoutState = AsyncOperationState< + CheckoutSuccessResponse, + CheckoutErrorResponse | Error +>; + +export type CheckoutSideEffects = { + onSuccess?: (data: CheckoutSuccessResponse) => void; + onError?: (error: CheckoutErrorResponse | Error) => void; + onSettled?: ( + data: CheckoutSuccessResponse | undefined, + error: CheckoutErrorResponse | Error | undefined, + ) => void; +}; + +export type RequestCheckoutFn = { + (session: CheckoutSessionRequest): void; + (session: CheckoutSessionRequest, sideEffects: CheckoutSideEffects): void; +}; + +export type RequestCheckoutAsyncFn = ( + session: CheckoutSessionRequest, +) => Promise; + +export type UseCheckoutOptions = { + checkoutSessionIdGenerator?: () => string; +}; + +function isCheckoutErrorResponse( + response: CheckoutResponse, +): response is CheckoutErrorResponse { + return "code" in response && "message" in response; +} + +export const useCheckout = (options?: UseCheckoutOptions) => { + const { state, execute: executeAsync } = useAsyncOperation< + CheckoutSuccessResponse, + CheckoutErrorResponse | Error + >({ enableDeduplication: true }); + + const [sessionId, setSessionId] = useState(); + + const generateSessionId = + options?.checkoutSessionIdGenerator ?? (() => crypto.randomUUID()); + + const execute = async ( + session: CheckoutSessionRequest, + ): Promise => { + return executeAsync(async () => { + if (!window.openai?.requestCheckout) { + throw new Error("requestCheckout is not available in this host"); + } + + // Auto-inject session ID if not provided + const sessionWithId = session.id + ? session + : { ...session, id: generateSessionId() }; + + setSessionId(sessionWithId.id); + + const response = await window.openai.requestCheckout(sessionWithId); + + if (isCheckoutErrorResponse(response)) { + throw response; + } + + return response; + }); + }; + + const requestCheckoutAsync: RequestCheckoutAsyncFn = ( + session: CheckoutSessionRequest, + ) => { + return execute(session); + }; + + const requestCheckout: RequestCheckoutFn = (( + session: CheckoutSessionRequest, + sideEffects?: CheckoutSideEffects, + ) => { + execute(session) + .then((data) => { + sideEffects?.onSuccess?.(data); + sideEffects?.onSettled?.(data, undefined); + }) + .catch((error: CheckoutErrorResponse | Error) => { + sideEffects?.onError?.(error); + sideEffects?.onSettled?.(undefined, error); + }); + }) as RequestCheckoutFn; + + return { + ...state, + order: state.data?.order, + sessionId, + requestCheckout, + requestCheckoutAsync, + }; +}; diff --git a/packages/core/src/web/types.ts b/packages/core/src/web/types.ts index 32d091f88..9595003fd 100644 --- a/packages/core/src/web/types.ts +++ b/packages/core/src/web/types.ts @@ -129,8 +129,175 @@ export type OpenAiMethods = { * Only files uploaded by the same connector instance can be downloaded. */ downloadFile: (file: FileMetadata) => Promise<{ downloadUrl: string }>; + + /** + * Opens the Instant Checkout UI for payment processing. + * This is part of the ChatGPT Apps Monetization (private beta). + * + * The promise resolves with the order result after successful payment, + * or rejects on error/cancellation. + * + * @see https://developers.openai.com/commerce/specs/checkout + */ + requestCheckout?: < + TSession extends CheckoutSessionRequest = CheckoutSessionRequest, + TResponse extends CheckoutResponse = CheckoutResponse, + >( + session: TSession, + ) => Promise; +}; + +/** + * Supported payment methods for instant checkout. + */ +export type SupportedPaymentMethod = "card" | "apple_pay" | "google_pay"; + +/** + * Payment provider configuration for instant checkout. + */ +export type CheckoutPaymentProvider = { + provider: string; + merchant_id: string; + supported_payment_methods?: SupportedPaymentMethod[]; }; +/** + * Line item in the checkout session. + */ +export type CheckoutLineItem = { + id?: string; + name: string; + description?: string; + quantity: number; + unit_amount: number; + image_url?: string; +}; + +/** + * Type of total line in the checkout summary. + */ +export type CheckoutTotalType = + | "subtotal" + | "tax" + | "discount" + | "fee" + | "shipping" + | "total"; + +/** + * Total line in the checkout summary. + */ +export type CheckoutTotal = { + type: CheckoutTotalType; + display_text: string; + amount: number; +}; + +/** + * Type of legal/policy link for checkout. + */ +export type CheckoutLinkType = + | "terms_of_use" + | "privacy_policy" + | "refund_policy" + | "shipping_policy" + | "merchant_terms"; + +/** + * Legal/policy link for checkout. + */ +export type CheckoutLink = { + type: CheckoutLinkType; + url: string; +}; + +/** + * Checkout session status. + */ +export type CheckoutSessionStatus = + | "pending" + | "ready_for_payment" + | "processing" + | "completed" + | "cancelled" + | "failed"; + +/** + * Payment mode for the checkout session. + */ +export type CheckoutPaymentMode = "live" | "test"; + +/** + * Checkout session request payload following the ACP specification. + */ +export type CheckoutSessionRequest = { + id: string; + payment_provider: CheckoutPaymentProvider; + status: CheckoutSessionStatus; + currency: string; + totals: CheckoutTotal[]; + links?: CheckoutLink[]; + payment_mode?: CheckoutPaymentMode; + line_items?: CheckoutLineItem[]; + merchant_name?: string; + order_reference?: string; +}; + +/** + * Order status after checkout completion. + */ +export type CheckoutOrderStatus = + | "pending" + | "confirmed" + | "shipped" + | "delivered" + | "cancelled"; + +/** + * Order details returned after successful checkout. + */ +export type CheckoutOrder = { + id: string; + checkout_session_id: string; + permalink_url?: string; + created_at?: string; + status?: CheckoutOrderStatus; +}; + +/** + * Successful checkout response. + */ +export type CheckoutSuccessResponse = { + id: string; + status: "completed"; + currency: string; + order: CheckoutOrder; +}; + +/** + * Error code returned from checkout. + */ +export type CheckoutErrorCode = + | "payment_declined" + | "requires_3ds" + | "invalid_session" + | "expired_session" + | "cancelled" + | "unknown_error"; + +/** + * Error response from checkout. + */ +export type CheckoutErrorResponse = { + code: CheckoutErrorCode; + message: string; +}; + +/** + * Checkout response - either success or error. + */ +export type CheckoutResponse = CheckoutSuccessResponse | CheckoutErrorResponse; + // Dispatched when any global changes in the host page export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals"; export class SetGlobalsEvent extends CustomEvent<{ diff --git a/packages/create-skybridge/biome.json b/packages/create-skybridge/biome.json index b733015e1..67d8ef7cd 100644 --- a/packages/create-skybridge/biome.json +++ b/packages/create-skybridge/biome.json @@ -1,6 +1,6 @@ { "root": false, - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "extends": "//", "linter": { "rules": {