diff --git a/api-report/purchases-js.api.json b/api-report/purchases-js.api.json index 2d089710c..2c742bcd4 100644 --- a/api-report/purchases-js.api.json +++ b/api-report/purchases-js.api.json @@ -3526,6 +3526,123 @@ } ] }, + { + "kind": "Interface", + "canonicalReference": "@revenuecat/purchases-js!PaywallListener:interface", + "docComment": "/**\n * Listener for paywall purchase lifecycle events.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export declare interface PaywallListener " + } + ], + "fileUrlPath": "dist/Purchases.es.d.ts", + "releaseTag": "Public", + "name": "PaywallListener", + "preserveMemberOrder": false, + "members": [ + { + "kind": "PropertySignature", + "canonicalReference": "@revenuecat/purchases-js!PaywallListener#onPurchaseCancelled:member", + "docComment": "/**\n * Called when the user cancels the purchase flow.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "onPurchaseCancelled?: " + }, + { + "kind": "Content", + "text": "() => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "onPurchaseCancelled", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, + { + "kind": "PropertySignature", + "canonicalReference": "@revenuecat/purchases-js!PaywallListener#onPurchaseError:member", + "docComment": "/**\n * Callback called when an error that won't close the paywall occurs. For example, a retryable error during the purchase process.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "onPurchaseError?: " + }, + { + "kind": "Content", + "text": "(error: " + }, + { + "kind": "Reference", + "text": "Error", + "canonicalReference": "!Error:interface" + }, + { + "kind": "Content", + "text": ") => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "onPurchaseError", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + } + }, + { + "kind": "PropertySignature", + "canonicalReference": "@revenuecat/purchases-js!PaywallListener#onPurchaseStarted:member", + "docComment": "/**\n * Called when a purchase flow is about to start for the given package.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "onPurchaseStarted?: " + }, + { + "kind": "Content", + "text": "(rcPackage: " + }, + { + "kind": "Reference", + "text": "Package", + "canonicalReference": "@revenuecat/purchases-js!Package:interface" + }, + { + "kind": "Content", + "text": ") => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "onPurchaseStarted", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + } + } + ], + "extendsTokenRanges": [] + }, { "kind": "Interface", "canonicalReference": "@revenuecat/purchases-js!PaywallPurchaseResult:interface", @@ -4007,7 +4124,7 @@ { "kind": "PropertySignature", "canonicalReference": "@revenuecat/purchases-js!PresentPaywallParams#customVariables:member", - "docComment": "/**\n * Custom variables to pass to the paywall at runtime, overriding defaults set in the RevenueCat dashboard.\n *\n * Variables must be defined in the dashboard first. Reference them in paywall text using the `custom.` prefix (e.g. `{{ custom.player_name }}`).\n *\n * @example\n * ```ts\n * presentPaywall({\n * customVariables: {\n * player_name: CustomVariableValue.string('Ada'),\n * level: CustomVariableValue.string('42'),\n * },\n * });\n * ```\n *\n */\n", + "docComment": "/**\n * Custom variables to pass to the paywall at runtime, overriding defaults set in the RevenueCat dashboard.\n *\n * Variables must be defined in the dashboard first. Reference them in paywall text using the `custom.` prefix (e.g. `{{ custom.player_name }}`).\n *\n * @example\n * ```ts\n * presentPaywall({\n * customVariables: {\n * player_name: CustomVariableValue.string('Ada'),\n * level: CustomVariableValue.number(42),\n * is_premium: CustomVariableValue.boolean(true),\n * },\n * });\n * ```\n *\n */\n", "excerptTokens": [ { "kind": "Content", @@ -4087,6 +4204,34 @@ "endIndex": 2 } }, + { + "kind": "PropertySignature", + "canonicalReference": "@revenuecat/purchases-js!PresentPaywallParams#listener:member", + "docComment": "/**\n * Optional listener for paywall purchase lifecycle events.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "readonly listener?: " + }, + { + "kind": "Reference", + "text": "PaywallListener", + "canonicalReference": "@revenuecat/purchases-js!PaywallListener:interface" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": true, + "isOptional": true, + "releaseTag": "Public", + "name": "listener", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, { "kind": "PropertySignature", "canonicalReference": "@revenuecat/purchases-js!PresentPaywallParams#offering:member", @@ -4172,7 +4317,7 @@ { "kind": "PropertySignature", "canonicalReference": "@revenuecat/purchases-js!PresentPaywallParams#onPurchaseError:member", - "docComment": "/**\n * Callback called when an error that won't close the paywall occurs. For example, a retryable error during the purchase process.\n */\n", + "docComment": "/**\n * Callback called when an error that won't close the paywall occurs. For example, a retryable error during the purchase process.\n *\n * @deprecated\n *\n * Use `listener.onPurchaseError` instead.\n */\n", "excerptTokens": [ { "kind": "Content", diff --git a/api-report/purchases-js.api.md b/api-report/purchases-js.api.md index 6b21a99f7..331cf5fea 100644 --- a/api-report/purchases-js.api.md +++ b/api-report/purchases-js.api.md @@ -259,6 +259,13 @@ export enum PackageType { Weekly = "$rc_weekly" } +// @public +export interface PaywallListener { + onPurchaseCancelled?: () => void; + onPurchaseError?: (error: Error) => void; + onPurchaseStarted?: (rcPackage: Package) => void; +} + // @public export interface PaywallPurchaseResult extends PurchaseResult { selectedPackage: Package; @@ -304,9 +311,11 @@ export interface PresentPaywallParams { readonly customVariables?: CustomVariables; readonly hideBackButtons?: boolean; readonly htmlTarget?: HTMLElement; + readonly listener?: PaywallListener; readonly offering?: Offering; readonly onBack?: (closePaywall: () => void) => void; readonly onNavigateToUrl?: (url: string) => void; + // @deprecated readonly onPurchaseError?: (error: Error) => void; readonly onVisitCustomerCenter?: () => void; readonly purchaseHtmlTarget?: HTMLElement; diff --git a/examples/webbilling-demo/src/components/ToastContainer.tsx b/examples/webbilling-demo/src/components/ToastContainer.tsx new file mode 100644 index 000000000..35cac8ef5 --- /dev/null +++ b/examples/webbilling-demo/src/components/ToastContainer.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import type { Toast } from "../hooks/useToast"; + +const backgroundColors: Record = { + info: "#0d6efd", + success: "#198754", + error: "#dc3545", +}; + +const ToastContainer: React.FC<{ + toasts: Toast[]; + onDismiss: (id: number) => void; +}> = ({ toasts, onDismiss }) => { + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
onDismiss(toast.id)} + style={{ + pointerEvents: "auto", + cursor: "pointer", + backgroundColor: backgroundColors[toast.type], + color: "#fff", + padding: "12px 20px", + borderRadius: 8, + boxShadow: "0 4px 12px rgba(0,0,0,0.2)", + fontSize: 14, + maxWidth: 360, + wordBreak: "break-word", + animation: "fadeIn 0.2s ease-out", + }} + > + {toast.message} +
+ ))} +
+ ); +}; + +export default ToastContainer; diff --git a/examples/webbilling-demo/src/hooks/useToast.ts b/examples/webbilling-demo/src/hooks/useToast.ts new file mode 100644 index 000000000..4bf5dbf0e --- /dev/null +++ b/examples/webbilling-demo/src/hooks/useToast.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from "react"; + +export type ToastType = "info" | "success" | "error"; + +export interface Toast { + id: number; + message: string; + type: ToastType; +} + +let nextId = 0; + +export function useToast(duration = 4000) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback( + (message: string, type: ToastType = "info") => { + const id = nextId++; + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, duration); + }, + [duration], + ); + + const removeToast = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return { toasts, showToast, removeToast }; +} diff --git a/examples/webbilling-demo/src/pages/rc_paywall_launcher/index.tsx b/examples/webbilling-demo/src/pages/rc_paywall_launcher/index.tsx index 8a0cf35c7..7cb2fd4ef 100644 --- a/examples/webbilling-demo/src/pages/rc_paywall_launcher/index.tsx +++ b/examples/webbilling-demo/src/pages/rc_paywall_launcher/index.tsx @@ -1,8 +1,13 @@ -import type { PaywallPurchaseResult } from "@revenuecat/purchases-js"; +import type { + PaywallListener, + PaywallPurchaseResult, +} from "@revenuecat/purchases-js"; import { Purchases, PurchasesError } from "@revenuecat/purchases-js"; import React from "react"; import { usePaywallSettings } from "../../hooks/usePaywallSettings"; +import { useToast } from "../../hooks/useToast"; import SettingsGearButton from "../../components/SettingsGearButton"; +import ToastContainer from "../../components/ToastContainer"; const RCPaywallLauncherPage: React.FC = () => { const [purchaseResult, setPurchaseResult] = @@ -12,17 +17,30 @@ const RCPaywallLauncherPage: React.FC = () => { openSettings, settings: { customVariables }, } = usePaywallSettings(); + const { toasts, showToast, removeToast } = useToast(); const onLaunchPaywallClicked = () => { const purchases = Purchases.getSharedInstance(); + + const listener: PaywallListener = { + onPurchaseStarted: (rcPackage) => { + showToast( + `Purchase started: ${rcPackage.webBillingProduct.title} (${rcPackage.identifier})`, + "info", + ); + }, + onPurchaseError: (error) => { + showToast(`Purchase error: ${error.message}`, "error"); + }, + onPurchaseCancelled: () => { + showToast("Purchase cancelled by user", "error"); + }, + }; + purchases .presentPaywall({ customVariables, - onPurchaseError: (error) => { - console.error( - `There was a purchase error inside the paywall: ${error}`, - ); - }, + listener, }) .then((purchaseResult: PaywallPurchaseResult) => { setPurchaseResult(purchaseResult); @@ -167,6 +185,7 @@ const RCPaywallLauncherPage: React.FC = () => {

No purchase result yet. Click the button to launch the paywall.

) : null} + ); }; diff --git a/src/entities/paywall-listener.ts b/src/entities/paywall-listener.ts new file mode 100644 index 000000000..24fbc9c3f --- /dev/null +++ b/src/entities/paywall-listener.ts @@ -0,0 +1,23 @@ +import type { Package } from "./offerings"; + +/** + * Listener for paywall purchase lifecycle events. + * @public + */ +export interface PaywallListener { + /** + * Called when a purchase flow is about to start for the given package. + */ + onPurchaseStarted?: (rcPackage: Package) => void; + + /** + * Callback called when an error that won't close the paywall occurs. + * For example, a retryable error during the purchase process. + */ + onPurchaseError?: (error: Error) => void; + + /** + * Called when the user cancels the purchase flow. + */ + onPurchaseCancelled?: () => void; +} diff --git a/src/entities/present-express-purchase-button-params.ts b/src/entities/present-express-purchase-button-params.ts index 19d4cd52a..a824108cc 100644 --- a/src/entities/present-express-purchase-button-params.ts +++ b/src/entities/present-express-purchase-button-params.ts @@ -1,5 +1,6 @@ import type { Package, PurchaseMetadata, PurchaseOption } from "./offerings"; import type { CustomTranslations } from "../ui/localization/translator"; +import type { PaywallListener } from "./paywall-listener"; /** * Callback to be called when the express purchase button is ready to be updated. @@ -65,4 +66,8 @@ export interface PresentExpressPurchaseButtonParams { updater: ExpressPurchaseButtonUpdater, walletsAvailable: boolean, ) => void; + /** + * Optional listener for purchase lifecycle events. + */ + listener?: PaywallListener; } diff --git a/src/entities/present-paywall-params.ts b/src/entities/present-paywall-params.ts index c628f74e1..6660cb62c 100644 --- a/src/entities/present-paywall-params.ts +++ b/src/entities/present-paywall-params.ts @@ -1,5 +1,6 @@ import type { Offering } from "./offerings"; import type { CustomVariables } from "@revenuecat/purchases-ui-js"; +import type { PaywallListener } from "./paywall-listener"; /** * Parameters for the {@link Purchases.presentPaywall} method. @@ -59,9 +60,15 @@ export interface PresentPaywallParams { /** * Callback called when an error that won't close the paywall occurs. * For example, a retryable error during the purchase process. + * @deprecated Use `listener.onPurchaseError` instead. */ readonly onPurchaseError?: (error: Error) => void; + /** + * Optional listener for paywall purchase lifecycle events. + */ + readonly listener?: PaywallListener; + /** * The locale to use for the paywall and the checkout flow. */ diff --git a/src/main.ts b/src/main.ts index 5ee0832f2..9894c6755 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,6 +62,7 @@ import { } from "./entities/purchase-result"; import { mount, unmount } from "svelte"; import { type PresentPaywallParams } from "./entities/present-paywall-params"; +import { type PaywallListener } from "./entities/paywall-listener"; import type { WalletButtonRender } from "@revenuecat/purchases-ui-js"; import { Paywall, type PaywallData } from "@revenuecat/purchases-ui-js"; import { PaywallDefaultContainerZIndex } from "./ui/theme/constants"; @@ -166,6 +167,7 @@ export type { PurchasesConfig } from "./entities/purchases-config"; export type { VirtualCurrencies } from "./entities/virtual-currencies"; export type { VirtualCurrency } from "./entities/virtual-currency"; export type { PresentPaywallParams } from "./entities/present-paywall-params"; +export type { PaywallListener } from "./entities/paywall-listener"; export { CustomVariableValue, type CustomVariables, @@ -703,6 +705,44 @@ export class Purchases { }); }; + const listener = paywallParams.listener; + + const notifyPurchaseStarted = (pkg: Package) => { + if (listener?.onPurchaseStarted) { + try { + listener.onPurchaseStarted(pkg); + } catch (e) { + Logger.errorLog(`Error in listener.onPurchaseStarted: ${e}`); + } + } + }; + + const notifyPurchaseError = (err: Error) => { + if ( + err instanceof PurchasesError && + err.errorCode === ErrorCode.UserCancelledError + ) { + if (listener?.onPurchaseCancelled) { + try { + listener.onPurchaseCancelled(); + } catch (e) { + Logger.errorLog(`Error in listener.onPurchaseCancelled: ${e}`); + } + } + } else { + if (listener?.onPurchaseError) { + try { + listener.onPurchaseError(err); + } catch (e) { + Logger.errorLog(`Error in listener.onPurchaseError: ${e}`); + } + } + if (paywallParams.onPurchaseError) { + paywallParams.onPurchaseError(err); + } + } + }; + const onSuccess = (result: PaywallPurchaseResult) => { unmountPaywall(); resolve(result); @@ -722,7 +762,7 @@ export class Purchases { } Logger.errorLog(`${message}: ${error}`); - paywallParams.onPurchaseError?.(error); + notifyPurchaseError(error); }; const walletButtonRender = this.getWalletButtonRender( @@ -730,6 +770,7 @@ export class Purchases { onSuccess, paywallParams.customerEmail, onError("Error presenting express purchase button"), + listener, ); certainHTMLTarget.innerHTML = ""; @@ -753,6 +794,10 @@ export class Purchases { }, onRestorePurchasesClicked: onRestorePurchasesClicked, onPurchaseClicked: (selectedPackageId: string) => { + const pkg = offering.packagesById[selectedPackageId]; + if (pkg) { + notifyPurchaseStarted(pkg); + } startPurchaseFlow(selectedPackageId) .then(onSuccess) .catch(onError("Error performing purchase")); @@ -983,6 +1028,7 @@ export class Purchases { translator, onFinished, onError, + listener: params.listener, }); }); } @@ -1003,6 +1049,7 @@ export class Purchases { onSuccess: (purchaseResult: PaywallPurchaseResult) => void, customerEmail?: string, onError?: (error: Error) => void, + listener?: PaywallListener, ): WalletButtonRender | undefined { if (!isWebBillingApiKey(this._API_KEY)) { return undefined; @@ -1023,6 +1070,7 @@ export class Purchases { buttonUpdater = updater; onReady?.(walletsAvailable); }, + listener, }) .then((purchaseResult) => { onSuccess({ ...purchaseResult, selectedPackage: pkg }); diff --git a/src/ui/express-purchase-button/express-purchase-button-props.ts b/src/ui/express-purchase-button/express-purchase-button-props.ts index 7f99a1ef0..6fb799f2f 100644 --- a/src/ui/express-purchase-button/express-purchase-button-props.ts +++ b/src/ui/express-purchase-button/express-purchase-button-props.ts @@ -15,6 +15,7 @@ import type { CustomTranslations, Translator, } from "../localization/translator"; +import type { PaywallListener } from "../../entities/paywall-listener"; export interface ExpressPurchaseButtonProps { customerEmail: string | undefined; @@ -31,4 +32,5 @@ export interface ExpressPurchaseButtonProps { onFinished: (operationResult: OperationSessionSuccessfulResult) => void; onError: (error: PurchaseFlowError) => void; onReady?: (walletsAvailable: boolean) => void; + listener?: PaywallListener; } diff --git a/src/ui/express-purchase-button/express-purchase-button.svelte b/src/ui/express-purchase-button/express-purchase-button.svelte index 224d16d0e..37ebea5f4 100644 --- a/src/ui/express-purchase-button/express-purchase-button.svelte +++ b/src/ui/express-purchase-button/express-purchase-button.svelte @@ -52,6 +52,7 @@ onFinished, onError, onReady, + listener, }: ExpressPurchaseButtonProps = $props(); const mode: SDKEventPurchaseMode = "express_purchase_button"; @@ -320,6 +321,7 @@ }; const onExpressClicked = (event: StripeExpressCheckoutElementClickEvent) => { + listener?.onPurchaseStarted?.(rcPackage); startCheckout().then((options) => event.resolve(options)); }; @@ -327,6 +329,7 @@ eventsTracker.trackSDKEvent( createCheckoutSessionEndClosedEvent({ mode: "express_purchase_button" }), ); + listener?.onPurchaseCancelled?.(); }; function onExpressCheckoutElementReady(