@crediball/react 0.2.0 → 0.3.1

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.
@@ -16,6 +16,10 @@ export interface CrediballProviderProps {
16
16
  * `{ code: "insufficient_credits" }`) from any fetch() call and open the paywall
17
17
  * automatically — no per-call wiring. Defaults to true. Set false to only open
18
18
  * the paywall manually via useCrediball().showPaywall().
19
+ *
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
23
  */
20
24
  watchFetch?: boolean;
21
25
  /** Allow a free-form amount entry in the paywall. */
@@ -30,7 +34,11 @@ export interface CrediballProviderProps {
30
34
  accentColor?: string;
31
35
  }
32
36
  interface CrediballContextValue {
33
- /** Open the paywall modal. */
37
+ /**
38
+ * Open the paywall modal. Always safe to call — never guard with "already shown"
39
+ * state. Calling while already open is a no-op; dismissing via "Not now" does not
40
+ * prevent the next showPaywall() call from reopening it.
41
+ */
34
42
  showPaywall: () => void;
35
43
  /** Close the paywall modal. */
36
44
  hidePaywall: () => void;
@@ -47,8 +55,17 @@ interface CrediballContextValue {
47
55
  * Whenever any request returns 402 `insufficient_credits` (your server forwarding
48
56
  * Crediball's error), the paywall appears automatically — no extra code per call.
49
57
  * You can also open it yourself with useCrediball().showPaywall().
58
+ *
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.
50
61
  */
51
62
  export declare function CrediballProvider({ children, amounts, onTopup, watchFetch, allowCustom, customMin, customMax, currencySymbol, title, description, accentColor, }: CrediballProviderProps): import("react").JSX.Element;
52
- /** Access the paywall controls from anywhere inside <CrediballProvider>. */
63
+ /**
64
+ * Access the paywall controls from anywhere inside <CrediballProvider>.
65
+ *
66
+ * Important: showPaywall() is always safe to call — never add a "paywallAlreadyShown"
67
+ * guard around it. Dismissing the modal ("Not now") does not prevent future calls
68
+ * from reopening it. Call showPaywall() unconditionally on every InsufficientCreditsError.
69
+ */
53
70
  export declare function useCrediball(): CrediballContextValue;
54
71
  export {};
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
4
  import { PaywallModal } from "./PaywallModal";
5
+ import { subscribeFetchWatcher } from "./fetchWatcher";
5
6
  const CrediballContext = createContext(null);
6
7
  /**
7
8
  * Pattern B, automated. Wrap your app once:
@@ -13,36 +14,21 @@ const CrediballContext = createContext(null);
13
14
  * Whenever any request returns 402 `insufficient_credits` (your server forwarding
14
15
  * Crediball's error), the paywall appears automatically — no extra code per call.
15
16
  * You can also open it yourself with useCrediball().showPaywall().
17
+ *
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.
16
20
  */
17
21
  export function CrediballProvider({ children, amounts = [5, 10, 20], onTopup, watchFetch = true, allowCustom = false, customMin, customMax, currencySymbol = "€", title, description, accentColor, }) {
18
22
  const [open, setOpen] = useState(false);
19
23
  const showPaywall = useCallback(() => setOpen(true), []);
20
24
  const hidePaywall = useCallback(() => setOpen(false), []);
21
- // Auto-open on any 402 insufficient_credits response, app-wide. We patch
22
- // window.fetch and inspect a *clone* so the original response is untouched.
25
+ // Keep a ref so the fetch-watcher closure always calls the latest version.
26
+ const showPaywallRef = useRef(showPaywall);
27
+ showPaywallRef.current = showPaywall;
23
28
  useEffect(() => {
24
- if (!watchFetch || typeof window === "undefined")
29
+ if (!watchFetch)
25
30
  return;
26
- const original = window.fetch;
27
- window.fetch = async (...args) => {
28
- const res = await original(...args);
29
- if (res.status === 402) {
30
- res
31
- .clone()
32
- .json()
33
- .then((body) => {
34
- if (body && body.code === "insufficient_credits")
35
- setOpen(true);
36
- })
37
- .catch(() => {
38
- /* non-JSON 402: ignore */
39
- });
40
- }
41
- return res;
42
- };
43
- return () => {
44
- window.fetch = original;
45
- };
31
+ return subscribeFetchWatcher(() => showPaywallRef.current());
46
32
  }, [watchFetch]);
47
33
  const ctx = useMemo(() => ({ showPaywall, hidePaywall, open }), [showPaywall, hidePaywall, open]);
48
34
  async function handleAdd(amount) {
@@ -55,7 +41,13 @@ export function CrediballProvider({ children, amounts = [5, 10, 20], onTopup, wa
55
41
  }
56
42
  return (_jsxs(CrediballContext.Provider, { value: ctx, children: [children, _jsx(PaywallModal, { open: open, amounts: amounts, amountPrefix: `Add ${currencySymbol}`, onAdd: handleAdd, onClose: hidePaywall, allowCustom: allowCustom, customMin: customMin, customMax: customMax, currencySymbol: currencySymbol, title: title, description: description, accentColor: accentColor })] }));
57
43
  }
58
- /** Access the paywall controls from anywhere inside <CrediballProvider>. */
44
+ /**
45
+ * Access the paywall controls from anywhere inside <CrediballProvider>.
46
+ *
47
+ * Important: showPaywall() is always safe to call — never add a "paywallAlreadyShown"
48
+ * guard around it. Dismissing the modal ("Not now") does not prevent future calls
49
+ * from reopening it. Call showPaywall() unconditionally on every InsufficientCreditsError.
50
+ */
59
51
  export function useCrediball() {
60
52
  const ctx = useContext(CrediballContext);
61
53
  if (!ctx) {
@@ -65,7 +65,7 @@ export function PaywallModal({ open, packages, onSelectPackage, amounts = [5, 10
65
65
  return (_jsx("div", { role: "dialog", "aria-modal": "true", style: {
66
66
  position: "fixed",
67
67
  inset: 0,
68
- zIndex: 1000,
68
+ zIndex: 2147483647,
69
69
  display: "flex",
70
70
  alignItems: "center",
71
71
  justifyContent: "center",
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Module-level singleton that patches window.fetch once and fans out to all
3
+ * registered listeners (from CrediballProvider and/or usePaywall). Only one
4
+ * fetch patch exists at a time regardless of how many components subscribe.
5
+ * Reference-counted: the patch is removed when the last listener unsubscribes.
6
+ */
7
+ type Listener = () => void;
8
+ /**
9
+ * Subscribe to insufficient-credits 402 events detected in window.fetch.
10
+ * Returns an unsubscribe function — call it in your useEffect cleanup.
11
+ */
12
+ export declare function subscribeFetchWatcher(listener: Listener): () => void;
13
+ export {};
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Module-level singleton that patches window.fetch once and fans out to all
3
+ * registered listeners (from CrediballProvider and/or usePaywall). Only one
4
+ * fetch patch exists at a time regardless of how many components subscribe.
5
+ * Reference-counted: the patch is removed when the last listener unsubscribes.
6
+ */
7
+ let _original = null;
8
+ const _listeners = new Set();
9
+ function install() {
10
+ if (typeof window === "undefined" || _original !== null)
11
+ return;
12
+ _original = window.fetch;
13
+ window.fetch = async (...args) => {
14
+ const res = await _original(...args);
15
+ if (res.status === 402) {
16
+ res
17
+ .clone()
18
+ .json()
19
+ .then((body) => {
20
+ if (body != null &&
21
+ typeof body === "object" &&
22
+ body.code === "insufficient_credits") {
23
+ _listeners.forEach((fn) => fn());
24
+ }
25
+ })
26
+ .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());
31
+ });
32
+ }
33
+ return res;
34
+ };
35
+ }
36
+ function uninstall() {
37
+ if (typeof window === "undefined" || _original === null)
38
+ return;
39
+ window.fetch = _original;
40
+ _original = null;
41
+ }
42
+ /**
43
+ * Subscribe to insufficient-credits 402 events detected in window.fetch.
44
+ * Returns an unsubscribe function — call it in your useEffect cleanup.
45
+ */
46
+ export function subscribeFetchWatcher(listener) {
47
+ if (typeof window === "undefined")
48
+ return () => { };
49
+ if (_listeners.size === 0)
50
+ install();
51
+ _listeners.add(listener);
52
+ return () => {
53
+ _listeners.delete(listener);
54
+ if (_listeners.size === 0)
55
+ uninstall();
56
+ };
57
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { CreditsBadge, type CreditsBadgeProps } from "./CreditsBadge";
2
- export { PaywallModal, type PaywallModalProps } from "./PaywallModal";
2
+ export { PaywallModal, type PaywallModalProps, type PackageOption } 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
6
  export { tokens, type CrediballThemeOverrides } from "./theme";
package/dist/index.js CHANGED
@@ -4,4 +4,5 @@ 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
8
  export { tokens } from "./theme";
@@ -0,0 +1,71 @@
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. */
5
+ onTopup?: (amount: number) => void | Promise<void>;
6
+ /** Called when the user picks a package from `packages`. */
7
+ onSelectPackage?: (pkg: PackageOption) => void | Promise<void>;
8
+ /**
9
+ * Auto-detect HTTP 402 `insufficient_credits` responses from any fetch() call
10
+ * and open the paywall automatically. Defaults to true.
11
+ *
12
+ * Set to false if your app uses <CrediballProvider> (it already watches fetch)
13
+ * or if you want to trigger the paywall exclusively via trigger().
14
+ *
15
+ * Note: SSE/streaming routes surface credit errors as stream events (HTTP 200),
16
+ * not HTTP 402 — call trigger() manually from your stream error handler.
17
+ */
18
+ watchFetch?: boolean;
19
+ };
20
+ export interface UsePaywallResult {
21
+ /**
22
+ * Open the paywall modal. Calling this is ALWAYS safe — it opens the modal
23
+ * whether or not it was previously dismissed. Never add a "paywallAlreadyShown"
24
+ * guard around this: it is idempotent (calling while already open is a no-op)
25
+ * and every InsufficientCreditsError should call it unconditionally.
26
+ */
27
+ trigger: () => void;
28
+ /** Close the paywall without a purchase. The next trigger() call will re-open it. */
29
+ dismiss: () => void;
30
+ /** Whether the paywall is currently open. */
31
+ isOpen: boolean;
32
+ /**
33
+ * Drop this into your JSX tree — it renders the modal when open.
34
+ * It must be rendered for the paywall to appear; place it near the root of
35
+ * the component where you call `trigger()`.
36
+ *
37
+ * return (
38
+ * <div>
39
+ * <button onClick={generate}>Generate</button>
40
+ * {paywall}
41
+ * </div>
42
+ * );
43
+ */
44
+ paywall: ReactElement;
45
+ }
46
+ /**
47
+ * Manages a Crediball paywall modal with a simple, misuse-proof API.
48
+ *
49
+ * The key rule: call `trigger()` unconditionally whenever you catch an
50
+ * InsufficientCreditsError — never guard it with "already shown" state.
51
+ * Dismissing the modal ("Not now") does NOT prevent the next trigger() from
52
+ * reopening it. This is what makes the paywall reliably reappear on retry.
53
+ *
54
+ * const { trigger, paywall } = usePaywall({
55
+ * amounts: [5, 10, 20],
56
+ * onTopup: async (eur) => checkout(eur),
57
+ * });
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
+ */
71
+ export declare function usePaywall({ amounts, amountPrefix, packages, onTopup, onSelectPackage, allowCustom, customMin, customMax, currencySymbol, title, description, accentColor, balance, balanceRate, currency, watchFetch, }?: UsePaywallProps): UsePaywallResult;
@@ -0,0 +1,53 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { PaywallModal } from "./PaywallModal";
5
+ import { subscribeFetchWatcher } from "./fetchWatcher";
6
+ /**
7
+ * Manages a Crediball paywall modal with a simple, misuse-proof API.
8
+ *
9
+ * The key rule: call `trigger()` unconditionally whenever you catch an
10
+ * InsufficientCreditsError — never guard it with "already shown" state.
11
+ * Dismissing the modal ("Not now") does NOT prevent the next trigger() from
12
+ * reopening it. This is what makes the paywall reliably reappear on retry.
13
+ *
14
+ * const { trigger, paywall } = usePaywall({
15
+ * amounts: [5, 10, 20],
16
+ * onTopup: async (eur) => checkout(eur),
17
+ * });
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
+ */
31
+ export function usePaywall({ amounts, amountPrefix, packages, onTopup, onSelectPackage, allowCustom, customMin, customMax, currencySymbol, title, description, accentColor, balance, balanceRate, currency, watchFetch = true, } = {}) {
32
+ const [isOpen, setIsOpen] = useState(false);
33
+ const trigger = useCallback(() => setIsOpen(true), []);
34
+ const dismiss = useCallback(() => setIsOpen(false), []);
35
+ // Keep a ref so the fetch-watcher closure always calls the latest trigger.
36
+ const triggerRef = useRef(trigger);
37
+ triggerRef.current = trigger;
38
+ useEffect(() => {
39
+ if (!watchFetch)
40
+ return;
41
+ return subscribeFetchWatcher(() => triggerRef.current());
42
+ }, [watchFetch]);
43
+ const handleTopup = useCallback(async (amount) => {
44
+ setIsOpen(false);
45
+ await onTopup?.(amount);
46
+ }, [onTopup]);
47
+ const handleSelectPackage = useCallback(async (pkg) => {
48
+ setIsOpen(false);
49
+ await onSelectPackage?.(pkg);
50
+ }, [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 };
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediball/react",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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>",