@crediball/react 0.7.0 → 0.8.0

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.
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type PackageOption, type SubscriptionPlanOption } from "./PaywallModal";
3
- import { type UsagePeriod } from "@crediball/core";
3
+ import { type UsagePeriod, type PackagesPayload, type CrediballUiContent } from "@crediball/core";
4
4
  export interface CrediballProviderProps {
5
5
  children: ReactNode;
6
6
  /**
@@ -84,6 +84,15 @@ export interface CrediballProviderProps {
84
84
  description?: string;
85
85
  /** Override the accent color (defaults to Action Blue). */
86
86
  accentColor?: string;
87
+ /**
88
+ * Apply the theme (colors, radii, font) the developer published on the
89
+ * Crediball dashboard's UI-patterns page — served alongside packages — as
90
+ * `--crediball-*` CSS variables, so preview edits reflect here with no code
91
+ * change. On by default; the published theme takes precedence over any
92
+ * `--crediball-*` vars you set in your own CSS. Set false to opt out and
93
+ * theme entirely from your own stylesheet.
94
+ */
95
+ applyRemoteTheme?: boolean;
87
96
  }
88
97
  interface CrediballContextValue {
89
98
  /**
@@ -116,6 +125,12 @@ interface CrediballContextValue {
116
125
  topUp: (amount: number) => Promise<void>;
117
126
  /** Refetch balance, usage, costs, and packages immediately. No-op without publishableKey/userId. */
118
127
  refresh: () => Promise<void>;
128
+ /** The live packages payload (top-up options, plans, custom-topup, currency, ui). null until loaded. */
129
+ packages: PackagesPayload | null;
130
+ /** Currency symbol derived from the dashboard's configured currency (e.g. "€", "$"). */
131
+ currencySymbol: string;
132
+ /** Developer-published default copy from the dashboard, used as a label fallback. null when unconfigured. */
133
+ content: CrediballUiContent | null;
119
134
  }
120
135
  /**
121
136
  * Pattern B, automated. Wrap your app once:
@@ -137,7 +152,7 @@ interface CrediballContextValue {
137
152
  * SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
138
153
  * the watcher scans each chunk for credit errors, so no manual wiring is needed.
139
154
  */
140
- export declare function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts, onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, }: CrediballProviderProps): import("react").JSX.Element;
155
+ export declare function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts, onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme, }: CrediballProviderProps): import("react").JSX.Element;
141
156
  /**
142
157
  * Access the paywall + live data from anywhere inside <CrediballProvider>.
143
158
  *
@@ -1,10 +1,10 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
4
4
  import { PaywallModal } from "./PaywallModal";
5
5
  import { subscribeFetchWatcher } from "./fetchWatcher";
6
6
  import { subscribeInsufficientCredits } from "./creditEvent";
7
- import { getCrediballClient, notifyBalanceChanged } from "@crediball/core";
7
+ import { getCrediballClient, notifyBalanceChanged, } from "@crediball/core";
8
8
  const CrediballContext = createContext(null);
9
9
  const EMPTY_SNAPSHOT = {
10
10
  balance: null,
@@ -34,6 +34,50 @@ function symbolForCurrency(iso) {
34
34
  return iso;
35
35
  }
36
36
  }
37
+ /**
38
+ * Turn a published theme into the `--crediball-*` inline style the provider
39
+ * spreads onto a wrapping element. Only set fields are emitted, so an empty or
40
+ * partial theme leaves the built-in defaults (and any host CSS vars) in place.
41
+ */
42
+ function themeToCssVars(theme) {
43
+ if (!theme)
44
+ return {};
45
+ const vars = {};
46
+ if (theme.accent)
47
+ vars["--crediball-accent"] = theme.accent;
48
+ if (theme.onAccent)
49
+ vars["--crediball-on-accent"] = theme.onAccent;
50
+ if (theme.ink)
51
+ vars["--crediball-ink"] = theme.ink;
52
+ if (theme.canvas)
53
+ vars["--crediball-canvas"] = theme.canvas;
54
+ if (theme.hairline)
55
+ vars["--crediball-hairline"] = theme.hairline;
56
+ if (theme.radius != null)
57
+ vars["--crediball-radius"] = `${theme.radius}px`;
58
+ if (theme.radiusCard != null)
59
+ vars["--crediball-radius-card"] = `${theme.radiusCard}px`;
60
+ if (theme.fontStack)
61
+ vars["--crediball-font"] = theme.fontStack;
62
+ return vars;
63
+ }
64
+ /** Inject a webfont stylesheet <link> once (idempotent by href), for a published theme's fontUrl. */
65
+ function loadRemoteFont(url) {
66
+ if (typeof document === "undefined")
67
+ return;
68
+ const existing = document.querySelector(`link[data-crediball-font]`);
69
+ if (existing?.href === url)
70
+ return;
71
+ if (existing)
72
+ existing.href = url;
73
+ else {
74
+ const link = document.createElement("link");
75
+ link.rel = "stylesheet";
76
+ link.href = url;
77
+ link.setAttribute("data-crediball-font", "");
78
+ document.head.appendChild(link);
79
+ }
80
+ }
37
81
  /**
38
82
  * Pattern B, automated. Wrap your app once:
39
83
  *
@@ -54,7 +98,7 @@ function symbolForCurrency(iso) {
54
98
  * SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
55
99
  * the watcher scans each chunk for credit errors, so no manual wiring is needed.
56
100
  */
57
- export function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts = [5, 10, 20], onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch = true, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, }) {
101
+ export function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts = [5, 10, 20], onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, watchFetch = true, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme = true, }) {
58
102
  const [open, setOpen] = useState(false);
59
103
  const showPaywall = useCallback(() => setOpen(true), []);
60
104
  const hidePaywall = useCallback(() => setOpen(false), []);
@@ -84,6 +128,20 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
84
128
  return () => client.stop();
85
129
  }, [client]);
86
130
  const snapshot = useSyncExternalStore(client ? client.subscribe : noopSubscribe, client ? client.getSnapshot : getEmptySnapshot, client ? client.getSnapshot : getEmptySnapshot);
131
+ const pkgs = snapshot.packages;
132
+ const uiTheme = pkgs?.ui?.theme ?? null;
133
+ const uiContent = pkgs?.ui?.content ?? null;
134
+ // Follow the dashboard's configured currency once packages load, unless the
135
+ // host pins an explicit symbol. Falls back to "€" only until the first fetch
136
+ // resolves, so switching currencies on the Packages page never requires a
137
+ // matching code change/redeploy here.
138
+ const currencySymbol = currencySymbolProp ?? (pkgs?.currency ? symbolForCurrency(pkgs.currency) : "€");
139
+ // Load the published theme's webfont, if any, so --crediball-font renders.
140
+ useEffect(() => {
141
+ if (!applyRemoteTheme || !uiTheme?.fontUrl)
142
+ return;
143
+ loadRemoteFont(uiTheme.fontUrl);
144
+ }, [applyRemoteTheme, uiTheme?.fontUrl]);
87
145
  const costOf = useCallback((event) => client?.costOf(event), [client]);
88
146
  const refresh = useCallback(async () => {
89
147
  await client?.refresh();
@@ -107,7 +165,10 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
107
165
  costOf,
108
166
  topUp,
109
167
  refresh,
110
- }), [showPaywall, hidePaywall, open, snapshot, costOf, topUp, refresh]);
168
+ packages: pkgs,
169
+ currencySymbol,
170
+ content: uiContent,
171
+ }), [showPaywall, hidePaywall, open, snapshot, costOf, topUp, refresh, pkgs, currencySymbol, uiContent]);
111
172
  // The page to return to after Stripe checkout — the one the user is on now.
112
173
  function currentReturnPath() {
113
174
  if (typeof window === "undefined")
@@ -208,12 +269,6 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
208
269
  await onCancelSubscription?.(subscriptionId);
209
270
  await client?.refresh();
210
271
  }
211
- const pkgs = snapshot.packages;
212
- // Follow the dashboard's configured currency once packages load, unless the
213
- // host pins an explicit symbol. Falls back to "€" only until the first fetch
214
- // resolves, so switching currencies on the Packages page never requires a
215
- // matching code change/redeploy here.
216
- const currencySymbol = currencySymbolProp ?? (pkgs?.currency ? symbolForCurrency(pkgs.currency) : "€");
217
272
  // Auto-derive the custom-amount field from the dashboard's custom-topup config,
218
273
  // so a developer who enabled it doesn't also have to pass allowCustom/min/max.
219
274
  // Explicit props still win (allowCustom={false} force-hides it).
@@ -221,7 +276,13 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
221
276
  const effectiveAllowCustom = allowCustom ?? customCfg?.enabled ?? false;
222
277
  const effectiveCustomMin = customMin ?? customCfg?.minEur;
223
278
  const effectiveCustomMax = customMax ?? customCfg?.maxEur ?? undefined;
224
- return (_jsxs(CrediballContext.Provider, { value: ctx, children: [children, _jsx(PaywallModal, { open: open, packages: pkgs?.packages, onSelectPackage: handleSelectPackage, subscriptionPlans: pkgs?.subscriptionPlans, onSelectPlan: handleSelectPlan, activeSubscription: pkgs?.activeSubscription, onCancelSubscription: handleCancelSubscription, amounts: amounts, amountPrefix: `Add ${currencySymbol}`, onAdd: handleAdd, onClose: hidePaywall, allowCustom: effectiveAllowCustom, customMin: effectiveCustomMin, customMax: effectiveCustomMax, currencySymbol: currencySymbol, balance: snapshot.balance ?? undefined, balanceRate: pkgs?.customTopup.enabled ? pkgs.customTopup.rate : undefined, currency: pkgs?.currency, usage: snapshot.usage?.usedCredits ?? undefined, title: title, description: description, accentColor: accentColor })] }));
279
+ // Published theme --crediball-* CSS vars, applied on a display:contents
280
+ // wrapper so it themes every descendant (and the paywall) without adding a
281
+ // layout box. Host props/CSS are overridden by design (dashboard wins).
282
+ const themeVars = applyRemoteTheme ? themeToCssVars(uiTheme) : {};
283
+ const hasThemeVars = Object.keys(themeVars).length > 0;
284
+ const inner = (_jsxs(_Fragment, { children: [children, _jsx(PaywallModal, { open: open, packages: pkgs?.packages, onSelectPackage: handleSelectPackage, subscriptionPlans: pkgs?.subscriptionPlans, onSelectPlan: handleSelectPlan, activeSubscription: pkgs?.activeSubscription, onCancelSubscription: onCancelSubscription ? handleCancelSubscription : undefined, amounts: amounts, amountPrefix: `Add ${currencySymbol}`, onAdd: handleAdd, onClose: hidePaywall, allowCustom: effectiveAllowCustom, customMin: effectiveCustomMin, customMax: effectiveCustomMax, currencySymbol: currencySymbol, balance: snapshot.balance ?? undefined, balanceRate: pkgs?.customTopup.enabled ? pkgs.customTopup.rate : undefined, currency: pkgs?.currency, usage: snapshot.usage?.usedCredits ?? undefined, title: title ?? uiContent?.paywallTitle, description: description ?? uiContent?.paywallDescription, addButtonText: uiContent?.paywallButtonText, accentColor: accentColor })] }));
285
+ return (_jsx(CrediballContext.Provider, { value: ctx, children: hasThemeVars ? _jsx("div", { style: { display: "contents", ...themeVars }, children: inner }) : inner }));
225
286
  }
226
287
  /**
227
288
  * Access the paywall + live data from anywhere inside <CrediballProvider>.
@@ -7,9 +7,11 @@ import { useCrediballOptional } from "./CrediballProvider";
7
7
  * Pattern C — Subtle indicator. A quiet balance chip for a host app's top bar;
8
8
  * clickable to open a top-up flow. Minimal and non-intrusive.
9
9
  */
10
- export function CreditIndicator({ balanceLabel, label = "Credits:", onClick, accentColor, style, }) {
10
+ export function CreditIndicator({ balanceLabel, label, onClick, accentColor, style, }) {
11
11
  const ctx = useCrediballOptional();
12
12
  const resolvedLabel = balanceLabel ?? (ctx?.balance != null ? formatCredits(ctx.balance) : "—");
13
+ // Prefer the host prop, then the dashboard-published default, then the built-in.
14
+ const resolvedText = label ?? ctx?.content?.indicatorLabel ?? "Credits:";
13
15
  const resolvedOnClick = onClick ?? ctx?.openPaywall;
14
16
  return (_jsxs("button", { onClick: resolvedOnClick, style: {
15
17
  display: "inline-flex",
@@ -26,5 +28,5 @@ export function CreditIndicator({ balanceLabel, label = "Credits:", onClick, acc
26
28
  padding: "4px 12px",
27
29
  ...accentStyle(accentColor),
28
30
  ...style,
29
- }, children: [_jsx("span", { style: { color: theme.inkMuted }, children: label }), _jsx("span", { style: { fontWeight: 600 }, children: resolvedLabel })] }));
31
+ }, children: [_jsx("span", { style: { color: theme.inkMuted }, children: resolvedText }), _jsx("span", { style: { fontWeight: 600 }, children: resolvedLabel })] }));
30
32
  }
@@ -9,9 +9,12 @@ import { useCrediballOptional } from "./CrediballProvider";
9
9
  * as the primary figure and an approximate money value beneath it. No Crediball
10
10
  * branding.
11
11
  */
12
- export function CreditsBadge({ credits, rate, currency = "EUR", label = "Credit balance", onAddCredits, addLabel = "Add credits", accentColor, style, }) {
12
+ export function CreditsBadge({ credits, rate, currency = "EUR", label, onAddCredits, addLabel, accentColor, style, }) {
13
13
  const ctx = useCrediballOptional();
14
14
  const resolvedCredits = credits ?? ctx?.balance ?? 0;
15
+ // Prefer the host prop, then the dashboard-published default, then the built-in.
16
+ const resolvedLabel = label ?? ctx?.content?.badgeLabel ?? "Credit balance";
17
+ const resolvedAddLabel = addLabel ?? ctx?.content?.badgeAddLabel ?? "Add credits";
15
18
  const showMoney = typeof rate === "number" && rate > 0;
16
19
  return (_jsxs("div", { style: {
17
20
  display: "flex",
@@ -25,7 +28,7 @@ export function CreditsBadge({ credits, rate, currency = "EUR", label = "Credit
25
28
  fontFamily: theme.font,
26
29
  ...accentStyle(accentColor),
27
30
  ...style,
28
- }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 14, color: theme.inkMuted }, children: label }), _jsxs("div", { style: { fontSize: 28, fontWeight: 600, color: theme.ink, letterSpacing: "-0.01em" }, children: [formatCredits(resolvedCredits), _jsx("span", { style: { fontSize: 16, fontWeight: 400, color: theme.inkMuted, marginLeft: 6 }, children: "credits" })] }), showMoney ? (_jsxs("div", { style: { fontSize: 13, color: theme.inkMuted, marginTop: 2 }, children: ["\u2248 ", formatMoney(resolvedCredits / rate, currency)] })) : null] }), _jsx("button", { onClick: onAddCredits ?? ctx?.openPaywall, style: {
31
+ }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 14, color: theme.inkMuted }, children: resolvedLabel }), _jsxs("div", { style: { fontSize: 28, fontWeight: 600, color: theme.ink, letterSpacing: "-0.01em" }, children: [formatCredits(resolvedCredits), _jsx("span", { style: { fontSize: 16, fontWeight: 400, color: theme.inkMuted, marginLeft: 6 }, children: "credits" })] }), showMoney ? (_jsxs("div", { style: { fontSize: 13, color: theme.inkMuted, marginTop: 2 }, children: ["\u2248 ", formatMoney(resolvedCredits / rate, currency)] })) : null] }), _jsx("button", { onClick: onAddCredits ?? ctx?.openPaywall, style: {
29
32
  appearance: "none",
30
33
  border: "none",
31
34
  cursor: "pointer",
@@ -35,5 +38,5 @@ export function CreditsBadge({ credits, rate, currency = "EUR", label = "Credit
35
38
  fontSize: 14,
36
39
  padding: "8px 16px",
37
40
  borderRadius: theme.radiusPill,
38
- }, children: addLabel })] }));
41
+ }, children: resolvedAddLabel })] }));
39
42
  }
@@ -8,13 +8,16 @@ import { useCrediball } from "../CrediballProvider";
8
8
  *
9
9
  * <CrediballLowCreditWarning threshold={20} message="Not enough for one more image." />
10
10
  */
11
- export function CrediballLowCreditWarning({ className, style, threshold, message = "Low credit balance — top up to keep going.", children, }) {
12
- const { balance, isLow: contextIsLow } = useCrediball();
13
- const isLow = threshold != null && balance != null ? balance < threshold : contextIsLow;
11
+ export function CrediballLowCreditWarning({ className, style, threshold, message, children, }) {
12
+ const { balance, isLow: contextIsLow, content } = useCrediball();
13
+ // Prefer the host prop, then the dashboard-published default, then the provider's.
14
+ const effectiveThreshold = threshold ?? content?.lowCreditThreshold;
15
+ const isLow = effectiveThreshold != null && balance != null ? balance < effectiveThreshold : contextIsLow;
14
16
  if (!isLow)
15
17
  return null;
16
18
  if (typeof children === "function") {
17
19
  return _jsx(_Fragment, { children: children({ balance, isLow }) });
18
20
  }
19
- return (_jsx("span", { className: className, style: { font: "inherit", color: "currentColor", ...style }, children: children ?? message }));
21
+ const resolvedMessage = message ?? content?.lowCreditMessage ?? "Low credit balance top up to keep going.";
22
+ return (_jsx("span", { className: className, style: { font: "inherit", color: "currentColor", ...style }, children: children ?? resolvedMessage }));
20
23
  }
@@ -11,7 +11,7 @@ import { theme, accentStyle } from "../theme";
11
11
  * <CrediballTopUpButton>Get more credits</CrediballTopUpButton>
12
12
  */
13
13
  export function CrediballTopUpButton({ className, style, children, onClick, accentColor }) {
14
- const { openPaywall } = useCrediball();
14
+ const { openPaywall, content } = useCrediball();
15
15
  return (_jsx("button", { type: "button", className: className, onClick: onClick ?? openPaywall, style: {
16
16
  font: "inherit",
17
17
  appearance: "none",
@@ -23,5 +23,5 @@ export function CrediballTopUpButton({ className, style, children, onClick, acce
23
23
  padding: "0.5em 1.1em",
24
24
  ...accentStyle(accentColor),
25
25
  ...style,
26
- }, children: children ?? "Add credits" }));
26
+ }, children: children ?? content?.topUpButtonText ?? "Add credits" }));
27
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediball/react",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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>",
@@ -51,7 +51,7 @@
51
51
  "react": ">=18"
52
52
  },
53
53
  "dependencies": {
54
- "@crediball/core": "^0.2.0"
54
+ "@crediball/core": "^0.3.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "^18.3.11",