Skip to content
149 changes: 147 additions & 2 deletions api-report/purchases-js.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions api-report/purchases-js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
54 changes: 54 additions & 0 deletions examples/webbilling-demo/src/components/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from "react";
import type { Toast } from "../hooks/useToast";

const backgroundColors: Record<Toast["type"], string> = {
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 (
<div
style={{
position: "fixed",
top: 16,
right: 16,
display: "flex",
flexDirection: "column",
gap: 8,
zIndex: 1000003,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This puts it on top of both the paywall and the purchase screens.

pointerEvents: "none",
}}
>
{toasts.map((toast) => (
<div
key={toast.id}
onClick={() => 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}
</div>
))}
</div>
);
};

export default ToastContainer;
32 changes: 32 additions & 0 deletions examples/webbilling-demo/src/hooks/useToast.ts
Original file line number Diff line number Diff line change
@@ -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<Toast[]>([]);

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 };
}
31 changes: 25 additions & 6 deletions examples/webbilling-demo/src/pages/rc_paywall_launcher/index.tsx
Original file line number Diff line number Diff line change
@@ -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] =
Expand All @@ -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);
Expand Down Expand Up @@ -167,6 +185,7 @@ const RCPaywallLauncherPage: React.FC = () => {
<p>No purchase result yet. Click the button to launch the paywall.</p>
) : null}
<SettingsGearButton onClick={openSettings} />
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</div>
);
};
Expand Down
23 changes: 23 additions & 0 deletions src/entities/paywall-listener.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions src/entities/present-express-purchase-button-params.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -65,4 +66,8 @@ export interface PresentExpressPurchaseButtonParams {
updater: ExpressPurchaseButtonUpdater,
walletsAvailable: boolean,
) => void;
/**
* Optional listener for purchase lifecycle events.
*/
listener?: PaywallListener;
}
Loading