@crediball/react 0.3.1 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CrediballProvider.d.ts +7 -9
- package/dist/CrediballProvider.js +7 -2
- package/dist/PaywallModal.d.ts +38 -1
- package/dist/PaywallModal.js +66 -9
- package/dist/creditEvent.d.ts +21 -0
- package/dist/creditEvent.js +31 -0
- package/dist/fetchWatcher.d.ts +6 -1
- package/dist/fetchWatcher.js +63 -6
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/usePaywall.d.ts +43 -25
- package/dist/usePaywall.js +36 -19
- package/package.json +1 -1
|
@@ -12,14 +12,12 @@ export interface CrediballProviderProps {
|
|
|
12
12
|
*/
|
|
13
13
|
onTopup?: (amount: number) => void | Promise<void>;
|
|
14
14
|
/**
|
|
15
|
-
* Auto-detect insufficient-credit responses (
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* the paywall manually via useCrediball().showPaywall().
|
|
15
|
+
* Auto-detect insufficient-credit responses from any fetch() call and open the
|
|
16
|
+
* paywall automatically — no per-call wiring. Defaults to true. Set false to only
|
|
17
|
+
* open the paywall manually via useCrediball().showPaywall().
|
|
19
18
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* showPaywall() manually from your SSE error handler in those cases.
|
|
19
|
+
* Handles both HTTP 402 (regular JSON routes) and HTTP 200 text/event-stream /
|
|
20
|
+
* ndjson (streaming/SSE routes where the error arrives as a stream chunk).
|
|
23
21
|
*/
|
|
24
22
|
watchFetch?: boolean;
|
|
25
23
|
/** Allow a free-form amount entry in the paywall. */
|
|
@@ -56,8 +54,8 @@ interface CrediballContextValue {
|
|
|
56
54
|
* Crediball's error), the paywall appears automatically — no extra code per call.
|
|
57
55
|
* You can also open it yourself with useCrediball().showPaywall().
|
|
58
56
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
57
|
+
* SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
|
|
58
|
+
* the watcher scans each chunk for credit errors, so no manual wiring is needed.
|
|
61
59
|
*/
|
|
62
60
|
export declare function CrediballProvider({ children, amounts, onTopup, watchFetch, allowCustom, customMin, customMax, currencySymbol, title, description, accentColor, }: CrediballProviderProps): import("react").JSX.Element;
|
|
63
61
|
/**
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
4
|
import { PaywallModal } from "./PaywallModal";
|
|
5
5
|
import { subscribeFetchWatcher } from "./fetchWatcher";
|
|
6
|
+
import { subscribeInsufficientCredits } from "./creditEvent";
|
|
6
7
|
const CrediballContext = createContext(null);
|
|
7
8
|
/**
|
|
8
9
|
* Pattern B, automated. Wrap your app once:
|
|
@@ -15,8 +16,8 @@ const CrediballContext = createContext(null);
|
|
|
15
16
|
* Crediball's error), the paywall appears automatically — no extra code per call.
|
|
16
17
|
* You can also open it yourself with useCrediball().showPaywall().
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
|
|
20
|
+
* the watcher scans each chunk for credit errors, so no manual wiring is needed.
|
|
20
21
|
*/
|
|
21
22
|
export function CrediballProvider({ children, amounts = [5, 10, 20], onTopup, watchFetch = true, allowCustom = false, customMin, customMax, currencySymbol = "€", title, description, accentColor, }) {
|
|
22
23
|
const [open, setOpen] = useState(false);
|
|
@@ -25,6 +26,10 @@ export function CrediballProvider({ children, amounts = [5, 10, 20], onTopup, wa
|
|
|
25
26
|
// Keep a ref so the fetch-watcher closure always calls the latest version.
|
|
26
27
|
const showPaywallRef = useRef(showPaywall);
|
|
27
28
|
showPaywallRef.current = showPaywall;
|
|
29
|
+
// Listen for the global credit-error event (fired by notifyInsufficientCredits()
|
|
30
|
+
// or the fetch watcher). Always active — works regardless of how the request
|
|
31
|
+
// was made (fetch, XHR, AI SDK internals, etc.).
|
|
32
|
+
useEffect(() => subscribeInsufficientCredits(() => showPaywallRef.current()), []);
|
|
28
33
|
useEffect(() => {
|
|
29
34
|
if (!watchFetch)
|
|
30
35
|
return;
|
package/dist/PaywallModal.d.ts
CHANGED
|
@@ -7,6 +7,21 @@ export interface PackageOption {
|
|
|
7
7
|
price: number;
|
|
8
8
|
credits: number;
|
|
9
9
|
}
|
|
10
|
+
/** A recurring subscription plan (returned by listPackages()). */
|
|
11
|
+
export interface SubscriptionPlanOption {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
price: number;
|
|
15
|
+
credits: number;
|
|
16
|
+
period: "monthly" | "yearly";
|
|
17
|
+
}
|
|
18
|
+
/** The end user's current active subscription (returned by listPackages() with userId). */
|
|
19
|
+
export interface ActiveSubscriptionInfo {
|
|
20
|
+
subscriptionId: string;
|
|
21
|
+
planName: string;
|
|
22
|
+
planPeriod: string;
|
|
23
|
+
currentPeriodEnd: string;
|
|
24
|
+
}
|
|
10
25
|
export interface PaywallModalProps extends CrediballThemeOverrides {
|
|
11
26
|
open: boolean;
|
|
12
27
|
/**
|
|
@@ -21,6 +36,28 @@ export interface PaywallModalProps extends CrediballThemeOverrides {
|
|
|
21
36
|
* calculation needed; Crediball derives it from the package.
|
|
22
37
|
*/
|
|
23
38
|
onSelectPackage?: (pkg: PackageOption) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Subscription plans from `meter.listPackages()`. When provided (and the user
|
|
41
|
+
* has no active subscription), the modal shows a "Subscribe" section above
|
|
42
|
+
* one-time packages.
|
|
43
|
+
*/
|
|
44
|
+
subscriptionPlans?: SubscriptionPlanOption[];
|
|
45
|
+
/**
|
|
46
|
+
* Called when the user selects a subscription plan. Call
|
|
47
|
+
* `meter.subscribe({ userId, planId: plan.id })` in your backend.
|
|
48
|
+
*/
|
|
49
|
+
onSelectPlan?: (plan: SubscriptionPlanOption) => void;
|
|
50
|
+
/**
|
|
51
|
+
* The end user's active subscription (from `meter.listPackages({ userId })`).
|
|
52
|
+
* When provided, the subscription section is replaced by a subscription-status
|
|
53
|
+
* banner and a cancel option.
|
|
54
|
+
*/
|
|
55
|
+
activeSubscription?: ActiveSubscriptionInfo | null;
|
|
56
|
+
/**
|
|
57
|
+
* Called when the user clicks "Cancel subscription" in the paywall.
|
|
58
|
+
* Call `meter.cancelSubscription({ userId, subscriptionId })` in your backend.
|
|
59
|
+
*/
|
|
60
|
+
onCancelSubscription?: (subscriptionId: string) => void | Promise<void>;
|
|
24
61
|
/**
|
|
25
62
|
* Legacy: flat list of euro amounts. Prefer `packages` + `onSelectPackage`.
|
|
26
63
|
* When both are provided, `packages` takes precedence.
|
|
@@ -52,4 +89,4 @@ export interface PaywallModalProps extends CrediballThemeOverrides {
|
|
|
52
89
|
currency?: string;
|
|
53
90
|
style?: CSSProperties;
|
|
54
91
|
}
|
|
55
|
-
export declare function PaywallModal({ open, packages, onSelectPackage, amounts, amountPrefix, title, description, onAdd, onClose, allowCustom, customMin, customMax, currencySymbol, balance, balanceRate, currency, accentColor, style, }: PaywallModalProps): import("react").JSX.Element | null;
|
|
92
|
+
export declare function PaywallModal({ open, packages, onSelectPackage, subscriptionPlans, onSelectPlan, activeSubscription, onCancelSubscription, amounts, amountPrefix, title, description, onAdd, onClose, allowCustom, customMin, customMax, currencySymbol, balance, balanceRate, currency, accentColor, style, }: PaywallModalProps): import("react").JSX.Element | null;
|
package/dist/PaywallModal.js
CHANGED
|
@@ -17,11 +17,13 @@ function fmtMoney(n, currency) {
|
|
|
17
17
|
return `${n.toFixed(2)} ${currency}`;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
export function PaywallModal({ open, packages, onSelectPackage, amounts = [5, 10], amountPrefix = "Add €", title = "You need more credits to continue.", description = "Top up to keep using this feature.", onAdd, onClose, allowCustom = false, customMin = 1, customMax, currencySymbol = "€", balance, balanceRate, currency = "EUR", accentColor = tokens.primary, style, }) {
|
|
20
|
+
export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlans, onSelectPlan, activeSubscription, onCancelSubscription, amounts = [5, 10], amountPrefix = "Add €", title = "You need more credits to continue.", description = "Top up to keep using this feature.", onAdd, onClose, allowCustom = false, customMin = 1, customMax, currencySymbol = "€", balance, balanceRate, currency = "EUR", accentColor = tokens.primary, style, }) {
|
|
21
21
|
// Stack the top-up buttons one-per-line on narrow (mobile) screens.
|
|
22
22
|
const [narrow, setNarrow] = useState(false);
|
|
23
23
|
const [customValue, setCustomValue] = useState("");
|
|
24
24
|
const [customError, setCustomError] = useState(null);
|
|
25
|
+
const [cancelPending, setCancelPending] = useState(false);
|
|
26
|
+
const [cancelDone, setCancelDone] = useState(false);
|
|
25
27
|
useEffect(() => {
|
|
26
28
|
if (typeof window === "undefined" || !window.matchMedia)
|
|
27
29
|
return;
|
|
@@ -33,6 +35,18 @@ export function PaywallModal({ open, packages, onSelectPackage, amounts = [5, 10
|
|
|
33
35
|
}, []);
|
|
34
36
|
if (!open)
|
|
35
37
|
return null;
|
|
38
|
+
async function handleCancelSubscription() {
|
|
39
|
+
if (!activeSubscription || cancelPending)
|
|
40
|
+
return;
|
|
41
|
+
setCancelPending(true);
|
|
42
|
+
try {
|
|
43
|
+
await onCancelSubscription?.(activeSubscription.subscriptionId);
|
|
44
|
+
setCancelDone(true);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
setCancelPending(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
36
50
|
function submitCustom() {
|
|
37
51
|
const amt = Number(customValue);
|
|
38
52
|
if (!Number.isFinite(amt) || amt <= 0) {
|
|
@@ -90,14 +104,57 @@ export function PaywallModal({ open, packages, onSelectPackage, amounts = [5, 10
|
|
|
90
104
|
textTransform: "uppercase",
|
|
91
105
|
color: tokens.inkMuted,
|
|
92
106
|
marginBottom: 4,
|
|
93
|
-
}, children: "Your balance" }), _jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8, flexWrap: "wrap" }, children: [_jsxs("span", { style: { fontSize: 22, fontWeight: 600, color: tokens.ink }, children: [fmtCredits(balance), " credits"] }), balanceRate && balanceRate > 0 ? (_jsxs("span", { style: { fontSize: 13, color: tokens.inkMuted }, children: ["\u2248 ", fmtMoney(balance / balanceRate, currency)] })) : null] })] })), _jsx("h3", { style: { margin: 0, fontSize: 24, fontWeight: 600, color: tokens.ink, letterSpacing: "-0.01em" }, children:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
:
|
|
107
|
+
}, children: "Your balance" }), _jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8, flexWrap: "wrap" }, children: [_jsxs("span", { style: { fontSize: 22, fontWeight: 600, color: tokens.ink }, children: [fmtCredits(balance), " credits"] }), balanceRate && balanceRate > 0 ? (_jsxs("span", { style: { fontSize: 13, color: tokens.inkMuted }, children: ["\u2248 ", fmtMoney(balance / balanceRate, currency)] })) : null] })] })), _jsx("h3", { style: { margin: 0, fontSize: 24, fontWeight: 600, color: tokens.ink, letterSpacing: "-0.01em" }, children: activeSubscription
|
|
108
|
+
? "You've used all your credits for this period."
|
|
109
|
+
: title }), _jsx("p", { style: { marginTop: 8, marginBottom: 0, fontSize: 14, color: tokens.inkMuted }, children: activeSubscription
|
|
110
|
+
? `Your ${activeSubscription.planName} credits renew on ${new Date(activeSubscription.currentPeriodEnd).toLocaleDateString()}.`
|
|
111
|
+
: description }), activeSubscription && (_jsxs("div", { style: {
|
|
112
|
+
marginTop: 16,
|
|
113
|
+
padding: "12px 16px",
|
|
114
|
+
borderRadius: tokens.radiusCard,
|
|
115
|
+
background: "#f5f5f7",
|
|
116
|
+
border: `1px solid ${tokens.hairline}`,
|
|
117
|
+
}, children: [_jsx("div", { style: { fontSize: 13, fontWeight: 600, color: tokens.ink }, children: cancelDone ? "Subscription canceled" : `${activeSubscription.planName} · ${activeSubscription.planPeriod}` }), !cancelDone && (_jsx("button", { onClick: handleCancelSubscription, disabled: cancelPending, style: {
|
|
118
|
+
marginTop: 8,
|
|
119
|
+
appearance: "none",
|
|
120
|
+
border: "none",
|
|
121
|
+
background: "transparent",
|
|
122
|
+
cursor: cancelPending ? "default" : "pointer",
|
|
123
|
+
padding: 0,
|
|
124
|
+
fontSize: 13,
|
|
125
|
+
color: tokens.inkMuted,
|
|
126
|
+
fontFamily: tokens.fontStack,
|
|
127
|
+
textDecoration: "underline",
|
|
128
|
+
}, children: cancelPending ? "Canceling…" : "Cancel subscription" }))] })), !activeSubscription && subscriptionPlans && subscriptionPlans.length > 0 && (_jsxs("div", { style: { marginTop: 24 }, children: [_jsx("div", { style: {
|
|
129
|
+
fontSize: 11,
|
|
130
|
+
fontWeight: 600,
|
|
131
|
+
letterSpacing: "0.06em",
|
|
132
|
+
textTransform: "uppercase",
|
|
133
|
+
color: tokens.inkMuted,
|
|
134
|
+
marginBottom: 8,
|
|
135
|
+
}, children: "Subscribe" }), _jsx("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: subscriptionPlans.map((plan) => (_jsxs("button", { onClick: () => onSelectPlan?.(plan), style: {
|
|
136
|
+
...buttonStyle,
|
|
137
|
+
flex: "none",
|
|
138
|
+
display: "flex",
|
|
139
|
+
justifyContent: "space-between",
|
|
140
|
+
alignItems: "center",
|
|
141
|
+
}, children: [_jsx("span", { children: plan.name }), _jsxs("span", { style: { opacity: 0.85, fontSize: 14 }, children: [fmtMoney(plan.price, currency), "/", plan.period === "monthly" ? "mo" : "yr", " \u00B7 ", fmtCredits(plan.credits), " cr"] })] }, plan.id))) })] })), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10, marginTop: 24 }, children: [(!activeSubscription && subscriptionPlans && subscriptionPlans.length > 0) && (_jsx("div", { style: {
|
|
142
|
+
fontSize: 11,
|
|
143
|
+
fontWeight: 600,
|
|
144
|
+
letterSpacing: "0.06em",
|
|
145
|
+
textTransform: "uppercase",
|
|
146
|
+
color: tokens.inkMuted,
|
|
147
|
+
marginBottom: -2,
|
|
148
|
+
}, children: activeSubscription ? "Add extra credits" : "Or buy credits" })), activeSubscription && packages && packages.length > 0 && (_jsx("div", { style: {
|
|
149
|
+
fontSize: 11,
|
|
150
|
+
fontWeight: 600,
|
|
151
|
+
letterSpacing: "0.06em",
|
|
152
|
+
textTransform: "uppercase",
|
|
153
|
+
color: tokens.inkMuted,
|
|
154
|
+
marginBottom: -2,
|
|
155
|
+
}, children: "Add extra credits" })), packages && packages.length > 0
|
|
156
|
+
? packages.map((pkg) => (_jsxs("button", { onClick: () => onSelectPackage?.(pkg), style: { ...buttonStyle, flex: "none", display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [_jsx("span", { children: pkg.name }), _jsxs("span", { style: { opacity: 0.85, fontSize: 14 }, children: [fmtMoney(pkg.price, currency), " \u00B7 ", fmtCredits(pkg.credits), " cr"] })] }, pkg.id)))
|
|
157
|
+
: (!activeSubscription && (_jsx("div", { style: { display: "flex", flexDirection: narrow ? "column" : "row", gap: 12 }, children: amounts.map((amount) => (_jsxs("button", { onClick: () => onAdd?.(amount), style: buttonStyle, children: [amountPrefix, amount] }, amount))) })))] }), allowCustom ? (_jsxs("div", { style: { marginTop: 12 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [_jsx("span", { style: { fontSize: 17, color: tokens.inkMuted }, children: currencySymbol }), _jsx("input", { value: customValue, onChange: (e) => setCustomValue(e.target.value), inputMode: "decimal", placeholder: String(customMin), style: {
|
|
101
158
|
flex: 1,
|
|
102
159
|
minWidth: 0,
|
|
103
160
|
appearance: "none",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global custom-event bus for insufficient-credits signals.
|
|
3
|
+
*
|
|
4
|
+
* Any code — a useChat onError, an XHR error handler, a Redux middleware, a
|
|
5
|
+
* server action catch block — can call notifyInsufficientCredits() and every
|
|
6
|
+
* mounted CrediballProvider / usePaywall instance will open the paywall.
|
|
7
|
+
*
|
|
8
|
+
* This decouples the signal source from the UI, so the paywall works regardless
|
|
9
|
+
* of how the HTTP request was made (fetch, XHR, EventSource, AI SDK internals…).
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Fire the global insufficient-credits signal. Safe to call from anywhere in
|
|
13
|
+
* the app — server components excluded (DOM only). Every mounted paywall
|
|
14
|
+
* component will react to it automatically.
|
|
15
|
+
*/
|
|
16
|
+
export declare function notifyInsufficientCredits(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to the global insufficient-credits signal.
|
|
19
|
+
* Returns an unsubscribe function for use in useEffect cleanup.
|
|
20
|
+
*/
|
|
21
|
+
export declare function subscribeInsufficientCredits(listener: () => void): () => void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global custom-event bus for insufficient-credits signals.
|
|
3
|
+
*
|
|
4
|
+
* Any code — a useChat onError, an XHR error handler, a Redux middleware, a
|
|
5
|
+
* server action catch block — can call notifyInsufficientCredits() and every
|
|
6
|
+
* mounted CrediballProvider / usePaywall instance will open the paywall.
|
|
7
|
+
*
|
|
8
|
+
* This decouples the signal source from the UI, so the paywall works regardless
|
|
9
|
+
* of how the HTTP request was made (fetch, XHR, EventSource, AI SDK internals…).
|
|
10
|
+
*/
|
|
11
|
+
const EVENT = "crediball:insufficient-credits";
|
|
12
|
+
/**
|
|
13
|
+
* Fire the global insufficient-credits signal. Safe to call from anywhere in
|
|
14
|
+
* the app — server components excluded (DOM only). Every mounted paywall
|
|
15
|
+
* component will react to it automatically.
|
|
16
|
+
*/
|
|
17
|
+
export function notifyInsufficientCredits() {
|
|
18
|
+
if (typeof window === "undefined")
|
|
19
|
+
return;
|
|
20
|
+
window.dispatchEvent(new CustomEvent(EVENT));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Subscribe to the global insufficient-credits signal.
|
|
24
|
+
* Returns an unsubscribe function for use in useEffect cleanup.
|
|
25
|
+
*/
|
|
26
|
+
export function subscribeInsufficientCredits(listener) {
|
|
27
|
+
if (typeof window === "undefined")
|
|
28
|
+
return () => { };
|
|
29
|
+
window.addEventListener(EVENT, listener);
|
|
30
|
+
return () => window.removeEventListener(EVENT, listener);
|
|
31
|
+
}
|
package/dist/fetchWatcher.d.ts
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
* registered listeners (from CrediballProvider and/or usePaywall). Only one
|
|
4
4
|
* fetch patch exists at a time regardless of how many components subscribe.
|
|
5
5
|
* Reference-counted: the patch is removed when the last listener unsubscribes.
|
|
6
|
+
*
|
|
7
|
+
* Handles two credit-error surfaces:
|
|
8
|
+
* 1. HTTP 402 — regular JSON responses (non-streaming routes).
|
|
9
|
+
* 2. HTTP 200 text/event-stream or ndjson — streaming routes where the error
|
|
10
|
+
* arrives as a chunk in the stream body rather than an HTTP status code.
|
|
6
11
|
*/
|
|
7
12
|
type Listener = () => void;
|
|
8
13
|
/**
|
|
9
|
-
* Subscribe to insufficient-credits
|
|
14
|
+
* Subscribe to insufficient-credits events detected in window.fetch.
|
|
10
15
|
* Returns an unsubscribe function — call it in your useEffect cleanup.
|
|
11
16
|
*/
|
|
12
17
|
export declare function subscribeFetchWatcher(listener: Listener): () => void;
|
package/dist/fetchWatcher.js
CHANGED
|
@@ -3,9 +3,57 @@
|
|
|
3
3
|
* registered listeners (from CrediballProvider and/or usePaywall). Only one
|
|
4
4
|
* fetch patch exists at a time regardless of how many components subscribe.
|
|
5
5
|
* Reference-counted: the patch is removed when the last listener unsubscribes.
|
|
6
|
+
*
|
|
7
|
+
* Handles two credit-error surfaces:
|
|
8
|
+
* 1. HTTP 402 — regular JSON responses (non-streaming routes).
|
|
9
|
+
* 2. HTTP 200 text/event-stream or ndjson — streaming routes where the error
|
|
10
|
+
* arrives as a chunk in the stream body rather than an HTTP status code.
|
|
6
11
|
*/
|
|
12
|
+
import { notifyInsufficientCredits } from "./creditEvent";
|
|
7
13
|
let _original = null;
|
|
8
14
|
const _listeners = new Set();
|
|
15
|
+
function fire() {
|
|
16
|
+
// Dispatch the global event so ALL mounted paywall instances react,
|
|
17
|
+
// plus any manual subscribeInsufficientCredits() listeners.
|
|
18
|
+
notifyInsufficientCredits();
|
|
19
|
+
// Also call per-instance listeners registered via subscribeFetchWatcher
|
|
20
|
+
// (kept for backwards compatibility with direct watcher subscribers).
|
|
21
|
+
_listeners.forEach((fn) => fn());
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Tee the streaming response body and scan each chunk for the
|
|
25
|
+
* `insufficient_credits` signal. Returns a new Response whose body is the
|
|
26
|
+
* pass-through branch so the calling code receives the full, unmodified stream.
|
|
27
|
+
*/
|
|
28
|
+
function watchStream(res) {
|
|
29
|
+
if (!res.body)
|
|
30
|
+
return res;
|
|
31
|
+
const [scanBranch, passBranch] = res.body.tee();
|
|
32
|
+
(async () => {
|
|
33
|
+
const reader = scanBranch.getReader();
|
|
34
|
+
const decoder = new TextDecoder();
|
|
35
|
+
try {
|
|
36
|
+
for (;;) {
|
|
37
|
+
const { done, value } = await reader.read();
|
|
38
|
+
if (done)
|
|
39
|
+
break;
|
|
40
|
+
if (decoder.decode(value, { stream: true }).includes("insufficient_credits")) {
|
|
41
|
+
fire();
|
|
42
|
+
reader.cancel().catch(() => { });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// stream error — ignore
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
return new Response(passBranch, {
|
|
52
|
+
status: res.status,
|
|
53
|
+
statusText: res.statusText,
|
|
54
|
+
headers: res.headers,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
9
57
|
function install() {
|
|
10
58
|
if (typeof window === "undefined" || _original !== null)
|
|
11
59
|
return;
|
|
@@ -20,15 +68,24 @@ function install() {
|
|
|
20
68
|
if (body != null &&
|
|
21
69
|
typeof body === "object" &&
|
|
22
70
|
body.code === "insufficient_credits") {
|
|
23
|
-
|
|
71
|
+
fire();
|
|
24
72
|
}
|
|
25
73
|
})
|
|
26
74
|
.catch(() => {
|
|
27
|
-
// Non-JSON 402
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
75
|
+
// Non-JSON 402 — fire anyway; a 402 from the app's own API route is
|
|
76
|
+
// almost certainly a credit shortage. Set watchFetch=false and call
|
|
77
|
+
// trigger() manually if your app uses 402 for other purposes.
|
|
78
|
+
fire();
|
|
31
79
|
});
|
|
80
|
+
return res;
|
|
81
|
+
}
|
|
82
|
+
// Watch streaming responses for embedded credit errors (HTTP 200 with
|
|
83
|
+
// text/event-stream or ndjson — the common AI streaming pattern).
|
|
84
|
+
if (res.body) {
|
|
85
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
86
|
+
if (ct.includes("text/event-stream") || ct.includes("ndjson")) {
|
|
87
|
+
return watchStream(res);
|
|
88
|
+
}
|
|
32
89
|
}
|
|
33
90
|
return res;
|
|
34
91
|
};
|
|
@@ -40,7 +97,7 @@ function uninstall() {
|
|
|
40
97
|
_original = null;
|
|
41
98
|
}
|
|
42
99
|
/**
|
|
43
|
-
* Subscribe to insufficient-credits
|
|
100
|
+
* Subscribe to insufficient-credits events detected in window.fetch.
|
|
44
101
|
* Returns an unsubscribe function — call it in your useEffect cleanup.
|
|
45
102
|
*/
|
|
46
103
|
export function subscribeFetchWatcher(listener) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { CreditsBadge, type CreditsBadgeProps } from "./CreditsBadge";
|
|
2
|
-
export { PaywallModal, type PaywallModalProps, type PackageOption } from "./PaywallModal";
|
|
2
|
+
export { PaywallModal, type PaywallModalProps, type PackageOption, type SubscriptionPlanOption, type ActiveSubscriptionInfo, } from "./PaywallModal";
|
|
3
3
|
export { CreditIndicator, type CreditIndicatorProps } from "./CreditIndicator";
|
|
4
4
|
export { CrediballProvider, useCrediball, type CrediballProviderProps, } from "./CrediballProvider";
|
|
5
|
-
export { usePaywall, type UsePaywallProps, type UsePaywallResult, } from "./usePaywall";
|
|
5
|
+
export { usePaywall, isInsufficientCreditsError, type UsePaywallProps, type UsePaywallResult, } from "./usePaywall";
|
|
6
6
|
export { tokens, type CrediballThemeOverrides } from "./theme";
|
|
7
|
+
export { notifyInsufficientCredits } from "./creditEvent";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// @crediball/react — drop-in, self-contained components for showing credits
|
|
2
2
|
// inside a host AI app. No Crediball branding, no end-user login, no Tailwind.
|
|
3
3
|
export { CreditsBadge } from "./CreditsBadge";
|
|
4
|
-
export { PaywallModal } from "./PaywallModal";
|
|
4
|
+
export { PaywallModal, } from "./PaywallModal";
|
|
5
5
|
export { CreditIndicator } from "./CreditIndicator";
|
|
6
6
|
export { CrediballProvider, useCrediball, } from "./CrediballProvider";
|
|
7
|
-
export { usePaywall, } from "./usePaywall";
|
|
7
|
+
export { usePaywall, isInsufficientCreditsError, } from "./usePaywall";
|
|
8
8
|
export { tokens } from "./theme";
|
|
9
|
+
export { notifyInsufficientCredits } from "./creditEvent";
|
package/dist/usePaywall.d.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
|
-
import { type PaywallModalProps, type PackageOption } from "./PaywallModal";
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { type PaywallModalProps, type PackageOption, type SubscriptionPlanOption } from "./PaywallModal";
|
|
3
|
+
/**
|
|
4
|
+
* Props for usePaywall. Accepts everything PaywallModal accepts, except the
|
|
5
|
+
* three props the hook manages itself (open, onClose, onAdd). Callback props
|
|
6
|
+
* that the hook wraps (onSelectPackage, onSelectPlan) are overridden to also
|
|
7
|
+
* accept async handlers. Add watchFetch to auto-open on detected credit errors.
|
|
8
|
+
*/
|
|
9
|
+
export type UsePaywallProps = Omit<PaywallModalProps, "open" | "onClose" | "onAdd" | "style"> & {
|
|
10
|
+
/** Called when the user picks a legacy preset/custom top-up amount. */
|
|
5
11
|
onTopup?: (amount: number) => void | Promise<void>;
|
|
6
|
-
/** Called when the user picks a package
|
|
12
|
+
/** Called when the user picks a one-time package (async override). */
|
|
7
13
|
onSelectPackage?: (pkg: PackageOption) => void | Promise<void>;
|
|
14
|
+
/** Called when the user picks a subscription plan (async override). */
|
|
15
|
+
onSelectPlan?: (plan: SubscriptionPlanOption) => void | Promise<void>;
|
|
8
16
|
/**
|
|
9
|
-
* Auto-detect
|
|
10
|
-
*
|
|
17
|
+
* Auto-detect credit errors from any fetch() call and open the paywall
|
|
18
|
+
* automatically. Defaults to false.
|
|
11
19
|
*
|
|
12
|
-
* Set to
|
|
13
|
-
*
|
|
20
|
+
* Set to true only if you are NOT using <CrediballProvider> and want automatic
|
|
21
|
+
* interception. If your app already wraps with <CrediballProvider watchFetch>
|
|
22
|
+
* (the recommended pattern), leave this false — double-patching is harmless
|
|
23
|
+
* but unnecessary.
|
|
14
24
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
25
|
+
* Both HTTP 402 (regular routes) and text/event-stream / ndjson streaming
|
|
26
|
+
* responses are handled automatically.
|
|
17
27
|
*/
|
|
18
28
|
watchFetch?: boolean;
|
|
19
29
|
};
|
|
@@ -42,6 +52,16 @@ export interface UsePaywallResult {
|
|
|
42
52
|
* );
|
|
43
53
|
*/
|
|
44
54
|
paywall: ReactElement;
|
|
55
|
+
/**
|
|
56
|
+
* Pass directly to useChat / useCompletion as the `onError` prop. Triggers
|
|
57
|
+
* the paywall automatically when the Vercel AI SDK surfaces a credit error
|
|
58
|
+
* from a streaming route (the AI SDK captures `fetch` before window.fetch
|
|
59
|
+
* patching applies, so watchFetch cannot intercept streaming responses).
|
|
60
|
+
*
|
|
61
|
+
* const { paywall, onError } = usePaywall({ ... });
|
|
62
|
+
* const { messages } = useChat({ api: '/api/generate', onError });
|
|
63
|
+
*/
|
|
64
|
+
onError: (error: Error) => void;
|
|
45
65
|
}
|
|
46
66
|
/**
|
|
47
67
|
* Manages a Crediball paywall modal with a simple, misuse-proof API.
|
|
@@ -52,20 +72,18 @@ export interface UsePaywallResult {
|
|
|
52
72
|
* reopening it. This is what makes the paywall reliably reappear on retry.
|
|
53
73
|
*
|
|
54
74
|
* const { trigger, paywall } = usePaywall({
|
|
55
|
-
*
|
|
56
|
-
*
|
|
75
|
+
* packages: pkgs,
|
|
76
|
+
* subscriptionPlans: plans,
|
|
77
|
+
* activeSubscription: activeSub,
|
|
78
|
+
* onSelectPackage: async (pkg) => checkout(pkg),
|
|
79
|
+
* onSelectPlan: async (plan) => subscribe(plan),
|
|
80
|
+
* onCancelSubscription: async (id) => cancel(id),
|
|
57
81
|
* });
|
|
58
|
-
*
|
|
59
|
-
* try {
|
|
60
|
-
* const page = await meter.wrapMeteredAction(userId, "generate_page", async () => {
|
|
61
|
-
* const res = await streamAI(prompt);
|
|
62
|
-
* return { result: res, tokens: res.usage.totalTokens };
|
|
63
|
-
* });
|
|
64
|
-
* } catch (e) {
|
|
65
|
-
* if (e instanceof InsufficientCreditsError) trigger(); // always call — no guard
|
|
66
|
-
* else throw e;
|
|
67
|
-
* }
|
|
68
|
-
*
|
|
69
|
-
* return <div>...<button onClick={generate}>Generate</button>...{paywall}</div>;
|
|
70
82
|
*/
|
|
71
|
-
export declare function usePaywall({
|
|
83
|
+
export declare function usePaywall({ onTopup, onSelectPackage, onSelectPlan, watchFetch, ...rest }?: UsePaywallProps): UsePaywallResult;
|
|
84
|
+
/**
|
|
85
|
+
* Returns true when an error from the Vercel AI SDK (or any other source)
|
|
86
|
+
* represents an insufficient-credits rejection from Crediball. Useful when
|
|
87
|
+
* you need to check the error yourself before calling trigger().
|
|
88
|
+
*/
|
|
89
|
+
export declare function isInsufficientCreditsError(error: unknown): boolean;
|
package/dist/usePaywall.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import { PaywallModal } from "./PaywallModal";
|
|
4
|
+
import { PaywallModal, } from "./PaywallModal";
|
|
5
5
|
import { subscribeFetchWatcher } from "./fetchWatcher";
|
|
6
|
+
import { subscribeInsufficientCredits, notifyInsufficientCredits } from "./creditEvent";
|
|
6
7
|
/**
|
|
7
8
|
* Manages a Crediball paywall modal with a simple, misuse-proof API.
|
|
8
9
|
*
|
|
@@ -12,29 +13,24 @@ import { subscribeFetchWatcher } from "./fetchWatcher";
|
|
|
12
13
|
* reopening it. This is what makes the paywall reliably reappear on retry.
|
|
13
14
|
*
|
|
14
15
|
* const { trigger, paywall } = usePaywall({
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* packages: pkgs,
|
|
17
|
+
* subscriptionPlans: plans,
|
|
18
|
+
* activeSubscription: activeSub,
|
|
19
|
+
* onSelectPackage: async (pkg) => checkout(pkg),
|
|
20
|
+
* onSelectPlan: async (plan) => subscribe(plan),
|
|
21
|
+
* onCancelSubscription: async (id) => cancel(id),
|
|
17
22
|
* });
|
|
18
|
-
*
|
|
19
|
-
* try {
|
|
20
|
-
* const page = await meter.wrapMeteredAction(userId, "generate_page", async () => {
|
|
21
|
-
* const res = await streamAI(prompt);
|
|
22
|
-
* return { result: res, tokens: res.usage.totalTokens };
|
|
23
|
-
* });
|
|
24
|
-
* } catch (e) {
|
|
25
|
-
* if (e instanceof InsufficientCreditsError) trigger(); // always call — no guard
|
|
26
|
-
* else throw e;
|
|
27
|
-
* }
|
|
28
|
-
*
|
|
29
|
-
* return <div>...<button onClick={generate}>Generate</button>...{paywall}</div>;
|
|
30
23
|
*/
|
|
31
|
-
export function usePaywall({
|
|
24
|
+
export function usePaywall({ onTopup, onSelectPackage, onSelectPlan, watchFetch = false, ...rest } = {}) {
|
|
32
25
|
const [isOpen, setIsOpen] = useState(false);
|
|
33
26
|
const trigger = useCallback(() => setIsOpen(true), []);
|
|
34
27
|
const dismiss = useCallback(() => setIsOpen(false), []);
|
|
35
|
-
// Keep a ref so
|
|
28
|
+
// Keep a ref so closures always call the latest trigger.
|
|
36
29
|
const triggerRef = useRef(trigger);
|
|
37
30
|
triggerRef.current = trigger;
|
|
31
|
+
// Always listen for the global credit-error event — works regardless of how
|
|
32
|
+
// the request was made (fetch, XHR, AI SDK internals, etc.).
|
|
33
|
+
useEffect(() => subscribeInsufficientCredits(() => triggerRef.current()), []);
|
|
38
34
|
useEffect(() => {
|
|
39
35
|
if (!watchFetch)
|
|
40
36
|
return;
|
|
@@ -48,6 +44,27 @@ export function usePaywall({ amounts, amountPrefix, packages, onTopup, onSelectP
|
|
|
48
44
|
setIsOpen(false);
|
|
49
45
|
await onSelectPackage?.(pkg);
|
|
50
46
|
}, [onSelectPackage]);
|
|
51
|
-
const
|
|
52
|
-
|
|
47
|
+
const handleSelectPlan = useCallback(async (plan) => {
|
|
48
|
+
setIsOpen(false);
|
|
49
|
+
await onSelectPlan?.(plan);
|
|
50
|
+
}, [onSelectPlan]);
|
|
51
|
+
// onError fires the global event so ALL mounted paywall instances react,
|
|
52
|
+
// not just this hook's local state.
|
|
53
|
+
const onError = useCallback((error) => {
|
|
54
|
+
if (isInsufficientCreditsError(error))
|
|
55
|
+
notifyInsufficientCredits();
|
|
56
|
+
}, []);
|
|
57
|
+
const paywall = (_jsx(PaywallModal, { ...rest, open: isOpen, onAdd: handleTopup, onSelectPackage: handleSelectPackage, onSelectPlan: handleSelectPlan, onClose: dismiss }));
|
|
58
|
+
return { trigger, dismiss, isOpen, paywall, onError };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Returns true when an error from the Vercel AI SDK (or any other source)
|
|
62
|
+
* represents an insufficient-credits rejection from Crediball. Useful when
|
|
63
|
+
* you need to check the error yourself before calling trigger().
|
|
64
|
+
*/
|
|
65
|
+
export function isInsufficientCreditsError(error) {
|
|
66
|
+
if (!(error instanceof Error))
|
|
67
|
+
return false;
|
|
68
|
+
const msg = error.message.toLowerCase();
|
|
69
|
+
return msg.includes("insufficient_credits") || msg.includes("insufficient credits");
|
|
53
70
|
}
|
package/package.json
CHANGED