@crediball/react 0.7.0 → 0.9.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,7 +84,36 @@ 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;
96
+ /**
97
+ * Automatic referral handling. On by default: any visit with `?ref=CODE` in
98
+ * the URL is captured (stored locally + click counted), and once a `userId`
99
+ * is present the stored code is attached to that user as a pending referral —
100
+ * so with referrals enabled in the dashboard there is zero extra wiring.
101
+ * Rewards are only ever granted server-side when the conversion condition is
102
+ * met. Set false to manage capture/completion yourself via @crediball/core.
103
+ */
104
+ captureReferrals?: boolean;
105
+ }
106
+ /**
107
+ * Internal: the provider's raw connection config, for hooks (useReferral) that
108
+ * talk to the API directly instead of reading the polled snapshot.
109
+ */
110
+ interface CrediballConfigValue {
111
+ publishableKey?: string;
112
+ userId?: string;
113
+ apiUrl?: string;
87
114
  }
115
+ /** Internal hook: the provider's publishableKey/userId/apiUrl. Throws outside a provider. */
116
+ export declare function useCrediballConfig(): CrediballConfigValue;
88
117
  interface CrediballContextValue {
89
118
  /**
90
119
  * Open the paywall modal. Always safe to call — never guard with "already shown"
@@ -116,6 +145,12 @@ interface CrediballContextValue {
116
145
  topUp: (amount: number) => Promise<void>;
117
146
  /** Refetch balance, usage, costs, and packages immediately. No-op without publishableKey/userId. */
118
147
  refresh: () => Promise<void>;
148
+ /** The live packages payload (top-up options, plans, custom-topup, currency, ui). null until loaded. */
149
+ packages: PackagesPayload | null;
150
+ /** Currency symbol derived from the dashboard's configured currency (e.g. "€", "$"). */
151
+ currencySymbol: string;
152
+ /** Developer-published default copy from the dashboard, used as a label fallback. null when unconfigured. */
153
+ content: CrediballUiContent | null;
119
154
  }
120
155
  /**
121
156
  * Pattern B, automated. Wrap your app once:
@@ -137,7 +172,7 @@ interface CrediballContextValue {
137
172
  * SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
138
173
  * the watcher scans each chunk for credit errors, so no manual wiring is needed.
139
174
  */
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;
175
+ 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, captureReferrals, }: CrediballProviderProps): import("react").JSX.Element;
141
176
  /**
142
177
  * Access the paywall + live data from anywhere inside <CrediballProvider>.
143
178
  *
@@ -1,10 +1,19 @@
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, loadRemoteFont, themeToCssVars, captureReferral, completeStoredReferral, } from "@crediball/core";
8
+ const CrediballConfigContext = createContext(null);
9
+ /** Internal hook: the provider's publishableKey/userId/apiUrl. Throws outside a provider. */
10
+ export function useCrediballConfig() {
11
+ const ctx = useContext(CrediballConfigContext);
12
+ if (!ctx) {
13
+ throw new Error("This hook must be used inside <CrediballProvider>.");
14
+ }
15
+ return ctx;
16
+ }
8
17
  const CrediballContext = createContext(null);
9
18
  const EMPTY_SNAPSHOT = {
10
19
  balance: null,
@@ -54,8 +63,25 @@ function symbolForCurrency(iso) {
54
63
  * SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
55
64
  * the watcher scans each chunk for credit errors, so no manual wiring is needed.
56
65
  */
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, }) {
66
+ 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, captureReferrals = true, }) {
58
67
  const [open, setOpen] = useState(false);
68
+ // Referral auto-capture: pick up a `?ref=CODE` visit into localStorage (and
69
+ // count the click) as soon as the provider mounts — the visitor usually has
70
+ // no account yet at this point.
71
+ useEffect(() => {
72
+ if (!captureReferrals || !publishableKey)
73
+ return;
74
+ captureReferral({ publishableKey, apiUrl });
75
+ }, [captureReferrals, publishableKey, apiUrl]);
76
+ // Referral auto-complete: the moment a userId is known (signup finished, or
77
+ // a signed-in user returns), attach any stored code to them as a pending
78
+ // referral. Best-effort — a failure leaves the code stored for a retry on
79
+ // the next mount.
80
+ useEffect(() => {
81
+ if (!captureReferrals || !publishableKey || !userId)
82
+ return;
83
+ void completeStoredReferral({ publishableKey, userId, apiUrl }).catch(() => { });
84
+ }, [captureReferrals, publishableKey, userId, apiUrl]);
59
85
  const showPaywall = useCallback(() => setOpen(true), []);
60
86
  const hidePaywall = useCallback(() => setOpen(false), []);
61
87
  // Keep a ref so the fetch-watcher closure always calls the latest version.
@@ -84,6 +110,20 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
84
110
  return () => client.stop();
85
111
  }, [client]);
86
112
  const snapshot = useSyncExternalStore(client ? client.subscribe : noopSubscribe, client ? client.getSnapshot : getEmptySnapshot, client ? client.getSnapshot : getEmptySnapshot);
113
+ const pkgs = snapshot.packages;
114
+ const uiTheme = pkgs?.ui?.theme ?? null;
115
+ const uiContent = pkgs?.ui?.content ?? null;
116
+ // Follow the dashboard's configured currency once packages load, unless the
117
+ // host pins an explicit symbol. Falls back to "€" only until the first fetch
118
+ // resolves, so switching currencies on the Packages page never requires a
119
+ // matching code change/redeploy here.
120
+ const currencySymbol = currencySymbolProp ?? (pkgs?.currency ? symbolForCurrency(pkgs.currency) : "€");
121
+ // Load the published theme's webfont, if any, so --crediball-font renders.
122
+ useEffect(() => {
123
+ if (!applyRemoteTheme || !uiTheme?.fontUrl)
124
+ return;
125
+ loadRemoteFont(uiTheme.fontUrl);
126
+ }, [applyRemoteTheme, uiTheme?.fontUrl]);
87
127
  const costOf = useCallback((event) => client?.costOf(event), [client]);
88
128
  const refresh = useCallback(async () => {
89
129
  await client?.refresh();
@@ -107,7 +147,10 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
107
147
  costOf,
108
148
  topUp,
109
149
  refresh,
110
- }), [showPaywall, hidePaywall, open, snapshot, costOf, topUp, refresh]);
150
+ packages: pkgs,
151
+ currencySymbol,
152
+ content: uiContent,
153
+ }), [showPaywall, hidePaywall, open, snapshot, costOf, topUp, refresh, pkgs, currencySymbol, uiContent]);
111
154
  // The page to return to after Stripe checkout — the one the user is on now.
112
155
  function currentReturnPath() {
113
156
  if (typeof window === "undefined")
@@ -125,35 +168,14 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
125
168
  if (typeof window !== "undefined")
126
169
  window.location.assign(url);
127
170
  }
128
- async function handleAdd(amount) {
129
- // A host-supplied top-up flow always wins.
130
- if (onTopup) {
171
+ // Shared shape for handleAdd/handleSelectPackage/handleSelectPlan: a host-supplied
172
+ // handler always wins (and refreshes balance/usage on completion); otherwise fall
173
+ // back to the built-in Stripe checkout when a data client is available, leaving the
174
+ // modal open on failure so the error stays visible; otherwise just close the modal.
175
+ async function runPaywallAction(hostHandler, arg, builtIn, errorLabel) {
176
+ if (hostHandler) {
131
177
  try {
132
- await topUp(amount);
133
- }
134
- finally {
135
- setOpen(false);
136
- }
137
- return;
138
- }
139
- // Otherwise start Stripe ourselves (custom amount). Leave the modal open on
140
- // failure so the error is visible instead of the modal silently closing.
141
- if (client) {
142
- try {
143
- await startBuiltInCheckout({ eurAmount: amount });
144
- }
145
- catch (err) {
146
- console.error("[crediball] checkout failed:", err);
147
- }
148
- return;
149
- }
150
- // Data-less mode with no handler: nothing to charge against.
151
- setOpen(false);
152
- }
153
- async function handleSelectPackage(pkg) {
154
- if (onSelectPackage) {
155
- try {
156
- await onSelectPackage(pkg);
178
+ await hostHandler(arg);
157
179
  notifyBalanceChanged();
158
180
  await client?.refresh();
159
181
  }
@@ -164,56 +186,38 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
164
186
  }
165
187
  if (client) {
166
188
  try {
167
- await startBuiltInCheckout({ packageId: pkg.id });
189
+ await builtIn();
168
190
  }
169
191
  catch (err) {
170
- console.error("[crediball] checkout failed:", err);
192
+ console.error(`[crediball] ${errorLabel} failed:`, err);
171
193
  }
172
194
  return;
173
195
  }
196
+ // Data-less mode with no handler: nothing to charge against.
174
197
  setOpen(false);
175
198
  }
176
- async function handleSelectPlan(plan) {
177
- // Host-supplied subscription flow wins.
178
- if (onSelectPlan) {
179
- try {
180
- await onSelectPlan(plan);
181
- notifyBalanceChanged();
182
- await client?.refresh();
183
- }
184
- finally {
185
- setOpen(false);
186
- }
187
- return;
188
- }
189
- // Otherwise start the recurring Stripe Checkout ourselves. Leave the modal
190
- // open on failure so the error is visible.
191
- if (client) {
192
- try {
193
- const { url } = await client.createSubscriptionCheckout({
194
- planId: plan.id,
195
- returnPath: currentReturnPath(),
196
- });
197
- if (typeof window !== "undefined")
198
- window.location.assign(url);
199
- }
200
- catch (err) {
201
- console.error("[crediball] subscription checkout failed:", err);
202
- }
203
- return;
204
- }
205
- setOpen(false);
199
+ function handleAdd(amount) {
200
+ return runPaywallAction(onTopup, amount, () => startBuiltInCheckout({ eurAmount: amount }), "checkout");
201
+ }
202
+ function handleSelectPackage(pkg) {
203
+ return runPaywallAction(onSelectPackage, pkg, () => startBuiltInCheckout({ packageId: pkg.id }), "checkout");
204
+ }
205
+ function handleSelectPlan(plan) {
206
+ return runPaywallAction(onSelectPlan, plan, async () => {
207
+ if (!client)
208
+ return;
209
+ const { url } = await client.createSubscriptionCheckout({
210
+ planId: plan.id,
211
+ returnPath: currentReturnPath(),
212
+ });
213
+ if (typeof window !== "undefined")
214
+ window.location.assign(url);
215
+ }, "subscription checkout");
206
216
  }
207
217
  async function handleCancelSubscription(subscriptionId) {
208
218
  await onCancelSubscription?.(subscriptionId);
209
219
  await client?.refresh();
210
220
  }
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
221
  // Auto-derive the custom-amount field from the dashboard's custom-topup config,
218
222
  // so a developer who enabled it doesn't also have to pass allowCustom/min/max.
219
223
  // Explicit props still win (allowCustom={false} force-hides it).
@@ -221,7 +225,14 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
221
225
  const effectiveAllowCustom = allowCustom ?? customCfg?.enabled ?? false;
222
226
  const effectiveCustomMin = customMin ?? customCfg?.minEur;
223
227
  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 })] }));
228
+ // Published theme --crediball-* CSS vars, applied on a display:contents
229
+ // wrapper so it themes every descendant (and the paywall) without adding a
230
+ // layout box. Host props/CSS are overridden by design (dashboard wins).
231
+ const themeVars = applyRemoteTheme ? themeToCssVars(uiTheme) : {};
232
+ const hasThemeVars = Object.keys(themeVars).length > 0;
233
+ 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 })] }));
234
+ const configCtx = useMemo(() => ({ publishableKey, userId, apiUrl }), [publishableKey, userId, apiUrl]);
235
+ return (_jsx(CrediballContext.Provider, { value: ctx, children: _jsx(CrediballConfigContext.Provider, { value: configCtx, children: hasThemeVars ? (_jsx("div", { style: { display: "contents", ...themeVars }, children: inner })) : (inner) }) }));
225
236
  }
226
237
  /**
227
238
  * 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
  }
@@ -0,0 +1,11 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ /**
3
+ * Shared rendering shell for the Tier-2 "variable" components (CrediballBalance,
4
+ * CrediballUsage, CrediballLowCreditWarning): an inline <span> that inherits the
5
+ * surrounding font/color so it reads naturally wherever it's placed.
6
+ */
7
+ export declare function InlineValue({ className, style, children, }: {
8
+ className?: string;
9
+ style?: CSSProperties;
10
+ children: ReactNode;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * Shared rendering shell for the Tier-2 "variable" components (CrediballBalance,
5
+ * CrediballUsage, CrediballLowCreditWarning): an inline <span> that inherits the
6
+ * surrounding font/color so it reads naturally wherever it's placed.
7
+ */
8
+ export function InlineValue({ className, style, children, }) {
9
+ return (_jsx("span", { className: className, style: { font: "inherit", color: "currentColor", ...style }, children: children }));
10
+ }
@@ -0,0 +1,30 @@
1
+ import type { CSSProperties } from "react";
2
+ import { type CrediballThemeOverrides } from "./theme";
3
+ export interface ReferralCardProps extends CrediballThemeOverrides {
4
+ /** Heading. Default "Invite friends". */
5
+ title?: string;
6
+ /**
7
+ * Subtitle. Defaults to a reward blurb built from the dashboard config
8
+ * (e.g. "Earn 100 credits for every friend who joins.").
9
+ */
10
+ description?: string;
11
+ /** Copy-button label. Default "Copy". */
12
+ copyLabel?: string;
13
+ /** Share-button label. Default "Share". */
14
+ shareLabel?: string;
15
+ /** Hide the clicks / converted counters row. */
16
+ hideStats?: boolean;
17
+ style?: CSSProperties;
18
+ }
19
+ /**
20
+ * Drop-in invite card built on useReferral(). Place anywhere inside
21
+ * <CrediballProvider publishableKey userId>:
22
+ *
23
+ * <ReferralCard />
24
+ *
25
+ * Shows the user's invite link with Copy/Share actions and their referral
26
+ * stats. Lazily creates the code on first render, renders nothing while
27
+ * loading or when the referral program is disabled in the dashboard, and is
28
+ * themed by the same `--crediball-*` variables as every other component.
29
+ */
30
+ export declare function ReferralCard({ title, description, copyLabel, shareLabel, hideStats, accentColor, style, }: ReferralCardProps): import("react").JSX.Element | null;
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { theme, accentStyle } from "./theme";
4
+ import { formatCredits } from "./format";
5
+ import { useReferral } from "./useReferral";
6
+ /**
7
+ * Drop-in invite card built on useReferral(). Place anywhere inside
8
+ * <CrediballProvider publishableKey userId>:
9
+ *
10
+ * <ReferralCard />
11
+ *
12
+ * Shows the user's invite link with Copy/Share actions and their referral
13
+ * stats. Lazily creates the code on first render, renders nothing while
14
+ * loading or when the referral program is disabled in the dashboard, and is
15
+ * themed by the same `--crediball-*` variables as every other component.
16
+ */
17
+ export function ReferralCard({ title = "Invite friends", description, copyLabel = "Copy", shareLabel = "Share", hideStats = false, accentColor, style, }) {
18
+ const { enabled, link, code, clicks, rewards, rewardCredits, referredRewardCredits, loading, copy, copied, share, } = useReferral();
19
+ if (loading || !enabled || !code)
20
+ return null;
21
+ const resolvedDescription = description ??
22
+ (rewardCredits > 0
23
+ ? referredRewardCredits > 0
24
+ ? `Earn ${formatCredits(rewardCredits)} credits for every friend who joins — they get ${formatCredits(referredRewardCredits)} credits too.`
25
+ : `Earn ${formatCredits(rewardCredits)} credits for every friend who joins.`
26
+ : undefined);
27
+ const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";
28
+ const buttonBase = {
29
+ appearance: "none",
30
+ cursor: "pointer",
31
+ fontFamily: theme.font,
32
+ fontSize: 14,
33
+ padding: "8px 16px",
34
+ borderRadius: theme.radiusPill,
35
+ whiteSpace: "nowrap",
36
+ };
37
+ return (_jsxs("div", { style: {
38
+ display: "flex",
39
+ flexDirection: "column",
40
+ gap: 14,
41
+ padding: 24,
42
+ border: `1px solid ${theme.hairline}`,
43
+ borderRadius: theme.radiusCard,
44
+ background: theme.canvas,
45
+ fontFamily: theme.font,
46
+ ...accentStyle(accentColor),
47
+ ...style,
48
+ }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontSize: 16, fontWeight: 600, color: theme.ink }, children: title }), resolvedDescription ? (_jsx("div", { style: { fontSize: 13, color: theme.inkMuted, marginTop: 2 }, children: resolvedDescription })) : null] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }, children: [_jsx("div", { style: {
49
+ flex: "1 1 180px",
50
+ minWidth: 0,
51
+ overflow: "hidden",
52
+ textOverflow: "ellipsis",
53
+ whiteSpace: "nowrap",
54
+ fontSize: 14,
55
+ color: theme.ink,
56
+ border: `1px solid ${theme.hairline}`,
57
+ borderRadius: theme.radiusPill,
58
+ padding: "8px 14px",
59
+ }, title: link ?? code, children: link ?? code }), _jsx("button", { onClick: () => void copy(), style: {
60
+ ...buttonBase,
61
+ border: `1px solid ${theme.hairline}`,
62
+ background: "transparent",
63
+ color: theme.ink,
64
+ }, children: copied ? "Copied!" : copyLabel }), canShare ? (_jsx("button", { onClick: () => void share(), style: {
65
+ ...buttonBase,
66
+ border: "none",
67
+ background: theme.accent,
68
+ color: theme.onAccent,
69
+ }, children: shareLabel })) : null] }), hideStats ? null : (_jsxs("div", { style: { display: "flex", gap: 16, fontSize: 13, color: theme.inkMuted }, children: [_jsxs("span", { children: [_jsx("strong", { style: { color: theme.ink, fontWeight: 600 }, children: clicks }), " clicks"] }), _jsxs("span", { children: [_jsx("strong", { style: { color: theme.ink, fontWeight: 600 }, children: rewards.converted }), " ", "joined"] }), rewards.creditsEarned > 0 ? (_jsxs("span", { children: [_jsx("strong", { style: { color: theme.ink, fontWeight: 600 }, children: formatCredits(rewards.creditsEarned) }), " ", "credits earned"] })) : null] }))] }));
70
+ }
package/dist/format.d.ts CHANGED
@@ -1,4 +1 @@
1
- /** Formats a raw credit balance as a locale-formatted whole number (e.g. 840 → "840"). */
2
- export declare function formatCredits(credits: number): string;
3
- /** Formats a monetary amount in the given ISO 4217 currency, falling back to a plain number + code for unknown currencies. */
4
- export declare function formatMoney(amount: number, currency: string): string;
1
+ export { formatCredits, formatMoney } from "@crediball/core";
package/dist/format.js CHANGED
@@ -1,13 +1 @@
1
- /** Formats a raw credit balance as a locale-formatted whole number (e.g. 840 → "840"). */
2
- export function formatCredits(credits) {
3
- return new Intl.NumberFormat(undefined).format(Math.round(credits));
4
- }
5
- /** Formats a monetary amount in the given ISO 4217 currency, falling back to a plain number + code for unknown currencies. */
6
- export function formatMoney(amount, currency) {
7
- try {
8
- return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amount);
9
- }
10
- catch {
11
- return `${amount.toFixed(2)} ${currency}`;
12
- }
13
- }
1
+ export { formatCredits, formatMoney } from "@crediball/core";
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export { PaywallModal, type PaywallModalProps, type PackageOption, type Subscrip
3
3
  export { CreditIndicator, type CreditIndicatorProps } from "./CreditIndicator";
4
4
  export { CrediballProvider, useCrediball, useCrediballOptional, type CrediballProviderProps, } from "./CrediballProvider";
5
5
  export { usePaywall, isInsufficientCreditsError, type UsePaywallProps, type UsePaywallResult, } from "./usePaywall";
6
+ export { useReferral, type UseReferralResult } from "./useReferral";
7
+ export { ReferralCard, type ReferralCardProps } from "./ReferralCard";
6
8
  export { tokens, theme, cssVars, accentStyle, type CrediballThemeOverrides } from "./theme";
7
9
  export { notifyInsufficientCredits } from "./creditEvent";
8
10
  export { CrediballBalance, type CrediballBalanceProps } from "./variables/CrediballBalance";
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export { PaywallModal, } from "./PaywallModal";
5
5
  export { CreditIndicator } from "./CreditIndicator";
6
6
  export { CrediballProvider, useCrediball, useCrediballOptional, } from "./CrediballProvider";
7
7
  export { usePaywall, isInsufficientCreditsError, } from "./usePaywall";
8
+ export { useReferral } from "./useReferral";
9
+ export { ReferralCard } from "./ReferralCard";
8
10
  export { tokens, theme, cssVars, accentStyle } from "./theme";
9
11
  export { notifyInsufficientCredits } from "./creditEvent";
10
12
  // Tier 2 — "variables" you place anywhere inside <CrediballProvider>.
@@ -0,0 +1,42 @@
1
+ export interface UseReferralResult {
2
+ /** False while loading, or when the referral program is off in the dashboard. */
3
+ enabled: boolean;
4
+ /** The user's referral code (lazily created server-side on first read). */
5
+ code: string | null;
6
+ /** Full invite link: the dashboard's link template, or `origin/?ref=CODE` when unset. */
7
+ link: string | null;
8
+ /** How many times the invite link was opened. */
9
+ clicks: number;
10
+ /** Referral outcomes: pending / converted counts and credits earned so far. */
11
+ rewards: {
12
+ pending: number;
13
+ converted: number;
14
+ creditsEarned: number;
15
+ };
16
+ /** Credits the referrer earns per converted referral (dashboard config). */
17
+ rewardCredits: number;
18
+ /** Credits the invited friend gets on conversion (dashboard config, 0 = off). */
19
+ referredRewardCredits: number;
20
+ loading: boolean;
21
+ error: Error | null;
22
+ /** Copy the invite link (falls back to the code) to the clipboard. */
23
+ copy: () => Promise<void>;
24
+ /** True for 2s after a successful copy — for "Copied!" feedback. */
25
+ copied: boolean;
26
+ /** Open the native share sheet with the invite link; falls back to copy. */
27
+ share: () => Promise<void>;
28
+ /** Refetch code/stats. */
29
+ refresh: () => Promise<void>;
30
+ }
31
+ /**
32
+ * Headless referral hook — render any invite UI on top of it:
33
+ *
34
+ * const { link, copy, share, rewards, loading } = useReferral();
35
+ *
36
+ * Must be used inside <CrediballProvider publishableKey userId>. The referral
37
+ * code is created lazily by the server on the first call and stays stable.
38
+ * When the dashboard's referral program is disabled, `enabled` is false and
39
+ * everything else stays empty — render nothing in that case (as
40
+ * <ReferralCard/> does).
41
+ */
42
+ export declare function useReferral(): UseReferralResult;
@@ -0,0 +1,92 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { fetchReferral } from "@crediball/core";
4
+ import { useCrediballConfig } from "./CrediballProvider";
5
+ const EMPTY_REWARDS = { pending: 0, converted: 0, creditsEarned: 0 };
6
+ /**
7
+ * Headless referral hook — render any invite UI on top of it:
8
+ *
9
+ * const { link, copy, share, rewards, loading } = useReferral();
10
+ *
11
+ * Must be used inside <CrediballProvider publishableKey userId>. The referral
12
+ * code is created lazily by the server on the first call and stays stable.
13
+ * When the dashboard's referral program is disabled, `enabled` is false and
14
+ * everything else stays empty — render nothing in that case (as
15
+ * <ReferralCard/> does).
16
+ */
17
+ export function useReferral() {
18
+ const { publishableKey, userId, apiUrl } = useCrediballConfig();
19
+ const [data, setData] = useState(null);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState(null);
22
+ const [copied, setCopied] = useState(false);
23
+ const copiedTimer = useRef(null);
24
+ const refresh = useCallback(async () => {
25
+ if (!publishableKey || !userId) {
26
+ setLoading(false);
27
+ return;
28
+ }
29
+ try {
30
+ const next = await fetchReferral({ publishableKey, userId, apiUrl });
31
+ setData(next);
32
+ setError(null);
33
+ }
34
+ catch (err) {
35
+ setError(err instanceof Error ? err : new Error(String(err)));
36
+ }
37
+ finally {
38
+ setLoading(false);
39
+ }
40
+ }, [publishableKey, userId, apiUrl]);
41
+ useEffect(() => {
42
+ setLoading(true);
43
+ void refresh();
44
+ }, [refresh]);
45
+ useEffect(() => {
46
+ return () => {
47
+ if (copiedTimer.current)
48
+ clearTimeout(copiedTimer.current);
49
+ };
50
+ }, []);
51
+ const link = data?.link ?? null;
52
+ const code = data?.code ?? null;
53
+ const copy = useCallback(async () => {
54
+ const text = link ?? code;
55
+ if (!text || typeof navigator === "undefined" || !navigator.clipboard)
56
+ return;
57
+ await navigator.clipboard.writeText(text);
58
+ setCopied(true);
59
+ if (copiedTimer.current)
60
+ clearTimeout(copiedTimer.current);
61
+ copiedTimer.current = setTimeout(() => setCopied(false), 2000);
62
+ }, [link, code]);
63
+ const share = useCallback(async () => {
64
+ const url = link ?? undefined;
65
+ const nav = typeof navigator === "undefined" ? undefined : navigator;
66
+ if (url && nav?.share) {
67
+ try {
68
+ await nav.share({ url });
69
+ return;
70
+ }
71
+ catch {
72
+ // dismissed or unsupported payload — fall through to copy
73
+ }
74
+ }
75
+ await copy();
76
+ }, [link, copy]);
77
+ return {
78
+ enabled: data?.enabled ?? false,
79
+ code,
80
+ link,
81
+ clicks: data?.clicks ?? 0,
82
+ rewards: data?.rewards ?? EMPTY_REWARDS,
83
+ rewardCredits: data?.rewardCredits ?? 0,
84
+ referredRewardCredits: data?.referredRewardCredits ?? 0,
85
+ loading,
86
+ error,
87
+ copy,
88
+ copied,
89
+ share,
90
+ refresh,
91
+ };
92
+ }
@@ -2,6 +2,7 @@
2
2
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useCrediball } from "../CrediballProvider";
4
4
  import { formatCredits } from "../format";
5
+ import { InlineValue } from "../InlineValue";
5
6
  /**
6
7
  * Tier 2 — a "variable" you drop anywhere inside <CrediballProvider>. Renders
7
8
  * as an inline <span> that inherits the surrounding font, size, and color
@@ -15,5 +16,5 @@ export function CrediballBalance({ className, style, format = formatCredits, chi
15
16
  if (typeof children === "function") {
16
17
  return _jsx(_Fragment, { children: children({ balance, loading }) });
17
18
  }
18
- return (_jsx("span", { className: className, style: { font: "inherit", color: "currentColor", ...style }, children: children ?? (balance == null ? "—" : format(balance)) }));
19
+ return (_jsx(InlineValue, { className: className, style: style, children: children ?? (balance == null ? "—" : format(balance)) }));
19
20
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
+ import { isBalanceLow } from "@crediball/core";
3
4
  import { useCrediball } from "../CrediballProvider";
5
+ import { InlineValue } from "../InlineValue";
4
6
  /**
5
7
  * Tier 2 — renders nothing until the user's balance is low, then shows a
6
8
  * message inline. Place it next to a specific action ("this costs 20 credits")
@@ -8,13 +10,16 @@ import { useCrediball } from "../CrediballProvider";
8
10
  *
9
11
  * <CrediballLowCreditWarning threshold={20} message="Not enough for one more image." />
10
12
  */
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;
13
+ export function CrediballLowCreditWarning({ className, style, threshold, message, children, }) {
14
+ const { balance, isLow: contextIsLow, content } = useCrediball();
15
+ // Prefer the host prop, then the dashboard-published default, then the provider's.
16
+ const effectiveThreshold = threshold ?? content?.lowCreditThreshold;
17
+ const isLow = isBalanceLow(balance, effectiveThreshold, contextIsLow);
14
18
  if (!isLow)
15
19
  return null;
16
20
  if (typeof children === "function") {
17
21
  return _jsx(_Fragment, { children: children({ balance, isLow }) });
18
22
  }
19
- return (_jsx("span", { className: className, style: { font: "inherit", color: "currentColor", ...style }, children: children ?? message }));
23
+ const resolvedMessage = message ?? content?.lowCreditMessage ?? "Low credit balance top up to keep going.";
24
+ return (_jsx(InlineValue, { className: className, style: style, children: children ?? resolvedMessage }));
20
25
  }
@@ -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
  }
@@ -2,6 +2,7 @@
2
2
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useCrediball } from "../CrediballProvider";
4
4
  import { formatCredits } from "../format";
5
+ import { InlineValue } from "../InlineValue";
5
6
  /**
6
7
  * Tier 2 — credits consumed in the user's current period (their subscription's
7
8
  * billing cycle, or the calendar month for non-subscribers). Inline, inherits
@@ -15,5 +16,5 @@ export function CrediballUsage({ className, style, show = "used", format = forma
15
16
  if (typeof children === "function") {
16
17
  return _jsx(_Fragment, { children: children({ usage, value, loading }) });
17
18
  }
18
- return (_jsx("span", { className: className, style: { font: "inherit", color: "currentColor", ...style }, children: children ?? (value == null ? "—" : format(value)) }));
19
+ return (_jsx(InlineValue, { className: className, style: style, children: children ?? (value == null ? "—" : format(value)) }));
19
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediball/react",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.4.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "^18.3.11",