@crediball/react 0.8.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.
- package/dist/CrediballProvider.d.ts +21 -1
- package/dist/CrediballProvider.js +57 -107
- package/dist/InlineValue.d.ts +11 -0
- package/dist/InlineValue.js +10 -0
- package/dist/ReferralCard.d.ts +30 -0
- package/dist/ReferralCard.js +70 -0
- package/dist/format.d.ts +1 -4
- package/dist/format.js +1 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/useReferral.d.ts +42 -0
- package/dist/useReferral.js +92 -0
- package/dist/variables/CrediballBalance.js +2 -1
- package/dist/variables/CrediballLowCreditWarning.js +4 -2
- package/dist/variables/CrediballUsage.js +2 -1
- package/package.json +2 -2
|
@@ -93,7 +93,27 @@ export interface CrediballProviderProps {
|
|
|
93
93
|
* theme entirely from your own stylesheet.
|
|
94
94
|
*/
|
|
95
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;
|
|
96
114
|
}
|
|
115
|
+
/** Internal hook: the provider's publishableKey/userId/apiUrl. Throws outside a provider. */
|
|
116
|
+
export declare function useCrediballConfig(): CrediballConfigValue;
|
|
97
117
|
interface CrediballContextValue {
|
|
98
118
|
/**
|
|
99
119
|
* Open the paywall modal. Always safe to call — never guard with "already shown"
|
|
@@ -152,7 +172,7 @@ interface CrediballContextValue {
|
|
|
152
172
|
* SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
|
|
153
173
|
* the watcher scans each chunk for credit errors, so no manual wiring is needed.
|
|
154
174
|
*/
|
|
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;
|
|
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;
|
|
156
176
|
/**
|
|
157
177
|
* Access the paywall + live data from anywhere inside <CrediballProvider>.
|
|
158
178
|
*
|
|
@@ -4,7 +4,16 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|
|
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,
|
|
@@ -34,50 +43,6 @@ function symbolForCurrency(iso) {
|
|
|
34
43
|
return iso;
|
|
35
44
|
}
|
|
36
45
|
}
|
|
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
|
-
}
|
|
81
46
|
/**
|
|
82
47
|
* Pattern B, automated. Wrap your app once:
|
|
83
48
|
*
|
|
@@ -98,8 +63,25 @@ function loadRemoteFont(url) {
|
|
|
98
63
|
* SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
|
|
99
64
|
* the watcher scans each chunk for credit errors, so no manual wiring is needed.
|
|
100
65
|
*/
|
|
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, }) {
|
|
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, }) {
|
|
102
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]);
|
|
103
85
|
const showPaywall = useCallback(() => setOpen(true), []);
|
|
104
86
|
const hidePaywall = useCallback(() => setOpen(false), []);
|
|
105
87
|
// Keep a ref so the fetch-watcher closure always calls the latest version.
|
|
@@ -186,35 +168,14 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
|
|
|
186
168
|
if (typeof window !== "undefined")
|
|
187
169
|
window.location.assign(url);
|
|
188
170
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
finally {
|
|
196
|
-
setOpen(false);
|
|
197
|
-
}
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
// Otherwise start Stripe ourselves (custom amount). Leave the modal open on
|
|
201
|
-
// failure so the error is visible instead of the modal silently closing.
|
|
202
|
-
if (client) {
|
|
203
|
-
try {
|
|
204
|
-
await startBuiltInCheckout({ eurAmount: amount });
|
|
205
|
-
}
|
|
206
|
-
catch (err) {
|
|
207
|
-
console.error("[crediball] checkout failed:", err);
|
|
208
|
-
}
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
// Data-less mode with no handler: nothing to charge against.
|
|
212
|
-
setOpen(false);
|
|
213
|
-
}
|
|
214
|
-
async function handleSelectPackage(pkg) {
|
|
215
|
-
if (onSelectPackage) {
|
|
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) {
|
|
216
177
|
try {
|
|
217
|
-
await
|
|
178
|
+
await hostHandler(arg);
|
|
218
179
|
notifyBalanceChanged();
|
|
219
180
|
await client?.refresh();
|
|
220
181
|
}
|
|
@@ -225,45 +186,33 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
|
|
|
225
186
|
}
|
|
226
187
|
if (client) {
|
|
227
188
|
try {
|
|
228
|
-
await
|
|
189
|
+
await builtIn();
|
|
229
190
|
}
|
|
230
191
|
catch (err) {
|
|
231
|
-
console.error(
|
|
192
|
+
console.error(`[crediball] ${errorLabel} failed:`, err);
|
|
232
193
|
}
|
|
233
194
|
return;
|
|
234
195
|
}
|
|
196
|
+
// Data-less mode with no handler: nothing to charge against.
|
|
235
197
|
setOpen(false);
|
|
236
198
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const { url } = await client.createSubscriptionCheckout({
|
|
255
|
-
planId: plan.id,
|
|
256
|
-
returnPath: currentReturnPath(),
|
|
257
|
-
});
|
|
258
|
-
if (typeof window !== "undefined")
|
|
259
|
-
window.location.assign(url);
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
console.error("[crediball] subscription checkout failed:", err);
|
|
263
|
-
}
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
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");
|
|
267
216
|
}
|
|
268
217
|
async function handleCancelSubscription(subscriptionId) {
|
|
269
218
|
await onCancelSubscription?.(subscriptionId);
|
|
@@ -282,7 +231,8 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
|
|
|
282
231
|
const themeVars = applyRemoteTheme ? themeToCssVars(uiTheme) : {};
|
|
283
232
|
const hasThemeVars = Object.keys(themeVars).length > 0;
|
|
284
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 })] }));
|
|
285
|
-
|
|
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) }) }));
|
|
286
236
|
}
|
|
287
237
|
/**
|
|
288
238
|
* Access the paywall + live data from anywhere inside <CrediballProvider>.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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")
|
|
@@ -12,12 +14,12 @@ export function CrediballLowCreditWarning({ className, style, threshold, message
|
|
|
12
14
|
const { balance, isLow: contextIsLow, content } = useCrediball();
|
|
13
15
|
// Prefer the host prop, then the dashboard-published default, then the provider's.
|
|
14
16
|
const effectiveThreshold = threshold ?? content?.lowCreditThreshold;
|
|
15
|
-
const isLow =
|
|
17
|
+
const isLow = isBalanceLow(balance, effectiveThreshold, contextIsLow);
|
|
16
18
|
if (!isLow)
|
|
17
19
|
return null;
|
|
18
20
|
if (typeof children === "function") {
|
|
19
21
|
return _jsx(_Fragment, { children: children({ balance, isLow }) });
|
|
20
22
|
}
|
|
21
23
|
const resolvedMessage = message ?? content?.lowCreditMessage ?? "Low credit balance — top up to keep going.";
|
|
22
|
-
return (_jsx(
|
|
24
|
+
return (_jsx(InlineValue, { className: className, style: style, children: children ?? resolvedMessage }));
|
|
23
25
|
}
|
|
@@ -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(
|
|
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.
|
|
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.
|
|
54
|
+
"@crediball/core": "^0.4.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "^18.3.11",
|