@crediball/react 0.3.2 → 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.
@@ -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 (HTTP 402 with
16
- * `{ code: "insufficient_credits" }`) from any fetch() call and open the paywall
17
- * automatically no per-call wiring. Defaults to true. Set false to only open
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
- * Note: this only intercepts responses with HTTP 402. Streaming (SSE) routes that
21
- * surface credit errors as stream events will not be caught automatically — call
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
- * For SSE/streaming routes: the 402 is surfaced as a stream event, not an HTTP status,
60
- * so watchFetch won't catch it. Call showPaywall() manually from your SSE error handler.
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
- * For SSE/streaming routes: the 402 is surfaced as a stream event, not an HTTP status,
19
- * so watchFetch won't catch it. Call showPaywall() manually from your SSE error handler.
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;
@@ -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
+ }
@@ -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 402 events detected in window.fetch.
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;
@@ -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
- _listeners.forEach((fn) => fn());
71
+ fire();
24
72
  }
25
73
  })
26
74
  .catch(() => {
27
- // Non-JSON 402. Fire listeners anyway a 402 from the app's own API
28
- // route is almost certainly a credit shortage. Set watchFetch=false
29
- // and call trigger() manually if your app uses 402 for other purposes.
30
- _listeners.forEach((fn) => fn());
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 402 events detected in window.fetch.
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
@@ -2,5 +2,6 @@ export { CreditsBadge, type CreditsBadgeProps } from "./CreditsBadge";
2
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
@@ -4,5 +4,6 @@ export { CreditsBadge } from "./CreditsBadge";
4
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";
@@ -1,21 +1,29 @@
1
1
  import { type ReactElement } from "react";
2
- import { type PaywallModalProps, type PackageOption } from "./PaywallModal";
3
- export type UsePaywallProps = Pick<PaywallModalProps, "amounts" | "amountPrefix" | "packages" | "allowCustom" | "customMin" | "customMax" | "currencySymbol" | "title" | "description" | "accentColor" | "balance" | "balanceRate" | "currency"> & {
4
- /** Called when the user picks a preset or custom top-up amount. */
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 from `packages`. */
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 HTTP 402 `insufficient_credits` responses from any fetch() call
10
- * and open the paywall automatically. Defaults to false.
17
+ * Auto-detect credit errors from any fetch() call and open the paywall
18
+ * automatically. Defaults to false.
11
19
  *
12
20
  * Set to true only if you are NOT using <CrediballProvider> and want automatic
13
21
  * interception. If your app already wraps with <CrediballProvider watchFetch>
14
22
  * (the recommended pattern), leave this false — double-patching is harmless
15
23
  * but unnecessary.
16
24
  *
17
- * Note: SSE/streaming routes surface credit errors as stream events (HTTP 200),
18
- * not HTTP 402 — call trigger() manually from your stream error handler.
25
+ * Both HTTP 402 (regular routes) and text/event-stream / ndjson streaming
26
+ * responses are handled automatically.
19
27
  */
20
28
  watchFetch?: boolean;
21
29
  };
@@ -44,6 +52,16 @@ export interface UsePaywallResult {
44
52
  * );
45
53
  */
46
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;
47
65
  }
48
66
  /**
49
67
  * Manages a Crediball paywall modal with a simple, misuse-proof API.
@@ -54,20 +72,18 @@ export interface UsePaywallResult {
54
72
  * reopening it. This is what makes the paywall reliably reappear on retry.
55
73
  *
56
74
  * const { trigger, paywall } = usePaywall({
57
- * amounts: [5, 10, 20],
58
- * onTopup: async (eur) => checkout(eur),
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),
59
81
  * });
60
- *
61
- * try {
62
- * const page = await meter.wrapMeteredAction(userId, "generate_page", async () => {
63
- * const res = await streamAI(prompt);
64
- * return { result: res, tokens: res.usage.totalTokens };
65
- * });
66
- * } catch (e) {
67
- * if (e instanceof InsufficientCreditsError) trigger(); // always call — no guard
68
- * else throw e;
69
- * }
70
- *
71
- * return <div>...<button onClick={generate}>Generate</button>...{paywall}</div>;
72
82
  */
73
- export declare function usePaywall({ amounts, amountPrefix, packages, onTopup, onSelectPackage, allowCustom, customMin, customMax, currencySymbol, title, description, accentColor, balance, balanceRate, currency, watchFetch, }?: UsePaywallProps): UsePaywallResult;
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;
@@ -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
- * amounts: [5, 10, 20],
16
- * onTopup: async (eur) => checkout(eur),
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({ amounts, amountPrefix, packages, onTopup, onSelectPackage, allowCustom, customMin, customMax, currencySymbol, title, description, accentColor, balance, balanceRate, currency, watchFetch = false, } = {}) {
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 the fetch-watcher closure always calls the latest trigger.
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 paywall = (_jsx(PaywallModal, { open: isOpen, amounts: amounts, amountPrefix: amountPrefix, packages: packages, onAdd: handleTopup, onSelectPackage: handleSelectPackage, onClose: dismiss, allowCustom: allowCustom, customMin: customMin, customMax: customMax, currencySymbol: currencySymbol, title: title, description: description, accentColor: accentColor, balance: balance, balanceRate: balanceRate, currency: currency }));
52
- return { trigger, dismiss, isOpen, paywall };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediball/react",
3
- "version": "0.3.2",
3
+ "version": "0.3.6",
4
4
  "description": "Drop-in React components for showing Crediball credits inside your AI app.",
5
5
  "license": "MIT",
6
6
  "author": "Filippo Rezzadore <filipporezzadore@gmail.com>",