@crediball/react 0.11.0 → 0.14.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.
@@ -32,14 +32,15 @@ export interface CrediballProviderProps {
32
32
  * userId) and redirects — no backend wiring needed, just connect payouts in the
33
33
  * dashboard. Provide this only to run your own top-up/checkout flow instead.
34
34
  */
35
- onTopup?: (amount: number) => void | Promise<void>;
35
+ onTopup?: (amount: number, code?: string) => void | Promise<void>;
36
36
  /**
37
37
  * Optional override for one-time packages. If omitted, the paywall starts a
38
38
  * Crediball-hosted Stripe Checkout itself and redirects (connect payouts in the
39
39
  * dashboard). Provide this only to run your own flow (e.g. `meter.topup({ userId,
40
- * packageId })` server-side).
40
+ * packageId })` server-side). The second argument is the promo code the user
41
+ * entered, if any (see `allowPromoCode`) — pass it through as `code`.
41
42
  */
42
- onSelectPackage?: (pkg: PackageOption) => void | Promise<void>;
43
+ onSelectPackage?: (pkg: PackageOption, code?: string) => void | Promise<void>;
43
44
  /**
44
45
  * Optional override for subscription plans. If omitted, the paywall starts a
45
46
  * recurring Stripe Checkout itself (connect payouts in the dashboard) and Stripe
@@ -85,6 +86,15 @@ export interface CrediballProviderProps {
85
86
  customMin?: number;
86
87
  /** Override the custom-amount maximum (defaults to the dashboard's configured maximum). */
87
88
  customMax?: number;
89
+ /**
90
+ * Show a "Have a promo code?" field in the paywall (Growth → Promotions'
91
+ * Checkout trigger). Off by default. The code is passed through to
92
+ * `onSelectPackage`/`onTopup`, or included automatically in the built-in
93
+ * checkout when neither is provided.
94
+ */
95
+ allowPromoCode?: boolean;
96
+ /** Label above the promo code toggle (default "Have a promo code?"). */
97
+ promoCodeLabel?: string;
88
98
  /**
89
99
  * Currency symbol shown in the paywall. Auto-derived (via Intl) from the ISO
90
100
  * currency you configured on the dashboard's Packages page once packages load,
@@ -153,8 +163,9 @@ interface CrediballContextValue {
153
163
  error: Error | null;
154
164
  /** Credit cost of a tracked event, from the live cost catalog. undefined if unknown. */
155
165
  costOf: (event: string) => number | undefined;
156
- /** Trigger a top-up (delegates to the `onTopup` prop) and refresh balance/usage on completion. */
157
- topUp: (amount: number) => Promise<void>;
166
+ /** Trigger a top-up (delegates to the `onTopup` prop) and refresh balance/usage on completion.
167
+ * `code` is a checkout promo code, if any. */
168
+ topUp: (amount: number, code?: string) => Promise<void>;
158
169
  /** Refetch balance, usage, costs, and packages immediately. No-op without publishableKey/userId. */
159
170
  refresh: () => Promise<void>;
160
171
  /** The live packages payload (top-up options, plans, custom-topup, currency, ui). null until loaded. */
@@ -184,7 +195,7 @@ interface CrediballContextValue {
184
195
  * SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
185
196
  * the watcher scans each chunk for credit errors, so no manual wiring is needed.
186
197
  */
187
- export declare function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts, onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, onEnableAutoTopup, watchFetch, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme, captureReferrals, }: CrediballProviderProps): import("react").JSX.Element;
198
+ export declare function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts, onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, onEnableAutoTopup, watchFetch, allowCustom, customMin, customMax, allowPromoCode, promoCodeLabel, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme, captureReferrals, }: CrediballProviderProps): import("react").JSX.Element;
188
199
  /**
189
200
  * Access the paywall + live data from anywhere inside <CrediballProvider>.
190
201
  *
@@ -63,7 +63,7 @@ function symbolForCurrency(iso) {
63
63
  * SSE/streaming routes (text/event-stream, ndjson) are also handled automatically —
64
64
  * the watcher scans each chunk for credit errors, so no manual wiring is needed.
65
65
  */
66
- export function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts = [5, 10, 20], onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, onEnableAutoTopup, watchFetch = true, allowCustom, customMin, customMax, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme = true, captureReferrals = true, }) {
66
+ export function CrediballProvider({ children, publishableKey, userId, apiUrl, pollIntervalMs, lowCreditThreshold, amounts = [5, 10, 20], onTopup, onSelectPackage, onSelectPlan, onCancelSubscription, onEnableAutoTopup, watchFetch = true, allowCustom, customMin, customMax, allowPromoCode, promoCodeLabel, currencySymbol: currencySymbolProp, title, description, accentColor, applyRemoteTheme = true, captureReferrals = true, }) {
67
67
  const [open, setOpen] = useState(false);
68
68
  // Referral auto-capture: pick up a `?ref=CODE` visit into localStorage (and
69
69
  // count the click) as soon as the provider mounts — the visitor usually has
@@ -150,8 +150,8 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
150
150
  const refresh = useCallback(async () => {
151
151
  await client?.refresh();
152
152
  }, [client]);
153
- const topUp = useCallback(async (amount) => {
154
- await onTopup?.(amount);
153
+ const topUp = useCallback(async (amount, code) => {
154
+ await onTopup?.(amount, code);
155
155
  notifyBalanceChanged();
156
156
  await client?.refresh();
157
157
  }, [onTopup, client]);
@@ -194,10 +194,12 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
194
194
  // handler always wins (and refreshes balance/usage on completion); otherwise fall
195
195
  // back to the built-in Stripe checkout when a data client is available, leaving the
196
196
  // modal open on failure so the error stays visible; otherwise just close the modal.
197
- async function runPaywallAction(hostHandler, arg, builtIn, errorLabel) {
197
+ // `code` (a promo code from the paywall) is only meaningful for topup-shaped
198
+ // actions (handleAdd/handleSelectPackage) — other callers simply omit it.
199
+ async function runPaywallAction(hostHandler, arg, builtIn, errorLabel, code) {
198
200
  if (hostHandler) {
199
201
  try {
200
- await hostHandler(arg);
202
+ await hostHandler(arg, code);
201
203
  notifyBalanceChanged();
202
204
  await client?.refresh();
203
205
  }
@@ -218,11 +220,11 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
218
220
  // Data-less mode with no handler: nothing to charge against.
219
221
  setOpen(false);
220
222
  }
221
- function handleAdd(amount) {
222
- return runPaywallAction(onTopup, amount, () => startBuiltInCheckout({ eurAmount: amount }), "checkout");
223
+ function handleAdd(amount, code) {
224
+ return runPaywallAction(onTopup, amount, () => startBuiltInCheckout({ eurAmount: amount, code }), "checkout", code);
223
225
  }
224
- function handleSelectPackage(pkg) {
225
- return runPaywallAction(onSelectPackage, pkg, () => startBuiltInCheckout({ packageId: pkg.id }), "checkout");
226
+ function handleSelectPackage(pkg, code) {
227
+ return runPaywallAction(onSelectPackage, pkg, () => startBuiltInCheckout({ packageId: pkg.id, code }), "checkout", code);
226
228
  }
227
229
  function handleSelectPlan(plan) {
228
230
  return runPaywallAction(onSelectPlan, plan, async () => {
@@ -264,7 +266,7 @@ export function CrediballProvider({ children, publishableKey, userId, apiUrl, po
264
266
  // layout box. Host props/CSS are overridden by design (dashboard wins).
265
267
  const themeVars = applyRemoteTheme ? themeToCssVars(uiTheme) : {};
266
268
  const hasThemeVars = Object.keys(themeVars).length > 0;
267
- 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, autoTopup: pkgs?.autoTopup, onEnableAutoTopup: handleEnableAutoTopup, 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, referralLink: referral?.link ?? undefined, referralReward: referral?.rewardCredits, referralLabel: uiContent?.referralLabel, accentColor: accentColor })] }));
269
+ 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, autoTopup: pkgs?.autoTopup, onEnableAutoTopup: handleEnableAutoTopup, amounts: amounts, amountPrefix: `Add ${currencySymbol}`, onAdd: handleAdd, onClose: hidePaywall, allowCustom: effectiveAllowCustom, customMin: effectiveCustomMin, customMax: effectiveCustomMax, allowPromoCode: allowPromoCode, promoCodeLabel: promoCodeLabel, customTopupRate: pkgs?.customTopup.enabled ? pkgs.customTopup.rate : undefined, 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, referralLink: referral?.link ?? undefined, referralReward: referral?.rewardCredits, referralLabel: uiContent?.referralLabel, accentColor: accentColor })] }));
268
270
  const configCtx = useMemo(() => ({ publishableKey, userId, apiUrl }), [publishableKey, userId, apiUrl]);
269
271
  return (_jsx(CrediballContext.Provider, { value: ctx, children: _jsx(CrediballConfigContext.Provider, { value: configCtx, children: hasThemeVars ? (_jsx("div", { style: { display: "contents", ...themeVars }, children: inner })) : (inner) }) }));
270
272
  }
@@ -13,7 +13,7 @@ export interface SubscriptionPlanOption {
13
13
  name: string;
14
14
  price: number;
15
15
  credits: number;
16
- period: "monthly" | "yearly";
16
+ period: "weekly" | "monthly" | "yearly";
17
17
  }
18
18
  /** The end user's current active subscription (returned by listPackages() with userId). */
19
19
  export interface ActiveSubscriptionInfo {
@@ -40,9 +40,11 @@ export interface PaywallModalProps extends CrediballThemeOverrides {
40
40
  /**
41
41
  * Called when the user selects a package. Use this with `packages` — call
42
42
  * `meter.topup({ userId, packageId: pkg.id })` in your backend. No `amount`
43
- * calculation needed; Crediball derives it from the package.
43
+ * calculation needed; Crediball derives it from the package. The second
44
+ * argument is the promo code the user entered (if the "Have a promo code?"
45
+ * field is shown and filled in) — pass it through as `code` to `topup()`.
44
46
  */
45
- onSelectPackage?: (pkg: PackageOption) => void;
47
+ onSelectPackage?: (pkg: PackageOption, code?: string) => void;
46
48
  /**
47
49
  * Subscription plans from `meter.listPackages()`. When provided (and the user
48
50
  * has no active subscription), the modal shows a "Subscribe" section above
@@ -67,17 +69,19 @@ export interface PaywallModalProps extends CrediballThemeOverrides {
67
69
  onCancelSubscription?: (subscriptionId: string) => void | Promise<void>;
68
70
  /**
69
71
  * The end user's current auto-topup rule (from `meter.listPackages({ userId })`
70
- * or `meter.getAutoTopupStatus(userId)`). When absent/null and `onEnableAutoTopup`
71
- * is provided, an opt-in row is shown below the packages so the user can turn
72
- * on "keep me topped up automatically".
72
+ * or `meter.getAutoTopupStatus(userId)`). When absent/null, an opt-in row is
73
+ * shown below the packages so the user can turn on "keep me topped up
74
+ * automatically".
73
75
  */
74
76
  autoTopup?: AutoTopupInfo | null;
75
77
  /**
76
- * Called when the user opts into auto top-up (only shown when `packages` is
77
- * non-empty). Call `meter.setupAutoTopupCheckout({ userId, packageId,
78
- * thresholdCredits, successUrl, cancelUrl })` in your backend it saves a
79
- * payment method, no money changes hands in this step — then redirect the
80
- * browser to the returned `url`.
78
+ * Called when the user opts into auto top-up. Shown whenever `packages` is
79
+ * non-empty same as `onSelectPackage`, this is optional to invoke (a no-op
80
+ * if you don't wire it, e.g. in a preview with no backend). Call
81
+ * `meter.setupAutoTopupCheckout({ userId, packageId, thresholdCredits,
82
+ * successUrl, cancelUrl })` in your backend — it saves a payment method, no
83
+ * money changes hands in this step — then redirect the browser to the
84
+ * returned `url`.
81
85
  */
82
86
  onEnableAutoTopup?: (input: {
83
87
  packageId: string;
@@ -92,17 +96,36 @@ export interface PaywallModalProps extends CrediballThemeOverrides {
92
96
  amountPrefix?: string;
93
97
  title?: string;
94
98
  description?: string;
95
- /** Called when the user clicks a legacy amount button. */
96
- onAdd?: (amount: number) => void;
99
+ /** Called when the user clicks a legacy amount button. The second argument is
100
+ * the promo code the user entered, same as `onSelectPackage`'s. */
101
+ onAdd?: (amount: number, code?: string) => void;
97
102
  onClose?: () => void;
98
103
  /** When true, show a free-form amount field in addition to the preset buttons. */
99
104
  allowCustom?: boolean;
105
+ /**
106
+ * When true, show a "Have a promo code?" field above the top-up buttons. The
107
+ * code is passed as the second argument to `onSelectPackage`/`onAdd`/the
108
+ * built-in checkout — pass it through as `code` to `topup()` or
109
+ * `createCheckout()`. Not validated client-side; an invalid code is rejected
110
+ * when the top-up is actually processed.
111
+ */
112
+ allowPromoCode?: boolean;
113
+ /** Label above the promo code toggle (default "Have a promo code?"). */
114
+ promoCodeLabel?: string;
100
115
  /** Button label for the free-form amount field (default "Add"). */
101
116
  addButtonText?: string;
102
117
  /** Minimum custom amount (default 1). */
103
118
  customMin?: number;
104
119
  /** Optional maximum custom amount. */
105
120
  customMax?: number;
121
+ /**
122
+ * Credits granted per 1 currency unit for the custom-amount field (your
123
+ * dashboard's custom top-up rate). When provided, a live "≈ N credits"
124
+ * line appears under the field as the user types, computed the same way
125
+ * the actual charge is (Math.round(amount * rate)) — so it never promises
126
+ * a different number than what gets granted.
127
+ */
128
+ customTopupRate?: number;
106
129
  /** Currency symbol shown beside the custom field (default "€"). */
107
130
  currencySymbol?: string;
108
131
  /**
@@ -143,4 +166,4 @@ export interface PaywallModalProps extends CrediballThemeOverrides {
143
166
  * continue. The host controls when it appears; shows a small "Powered by
144
167
  * Crediball" line by default (set `hideBranding` to remove it).
145
168
  */
146
- export declare function PaywallModal({ open, packages, onSelectPackage, subscriptionPlans, onSelectPlan, activeSubscription, onCancelSubscription, autoTopup, onEnableAutoTopup, amounts, amountPrefix, title, description, onAdd, onClose, allowCustom, addButtonText, customMin, customMax, currencySymbol, balance, balanceRate, currency, usage, usageLabel, hideBranding, referralLink, referralReward, referralLabel, accentColor, style, }: PaywallModalProps): import("react").JSX.Element | null;
169
+ export declare function PaywallModal({ open, packages, onSelectPackage, subscriptionPlans, onSelectPlan, activeSubscription, onCancelSubscription, autoTopup, onEnableAutoTopup, amounts, amountPrefix, title, description, onAdd, onClose, allowCustom, allowPromoCode, promoCodeLabel, addButtonText, customMin, customMax, customTopupRate, currencySymbol, balance, balanceRate, currency, usage, usageLabel, hideBranding, referralLink, referralReward, referralLabel, accentColor, style, }: PaywallModalProps): import("react").JSX.Element | null;
@@ -3,6 +3,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { theme, accentStyle } from "./theme";
5
5
  import { formatCredits, formatMoney } from "./format";
6
+ /** Shorthand suffix for a billing period, used in plan price labels (e.g. "€9.99/mo"). */
7
+ const PERIOD_SUFFIX = {
8
+ weekly: "wk",
9
+ monthly: "mo",
10
+ yearly: "yr",
11
+ };
6
12
  /** Shared uppercase eyebrow-label style used above the subscribe/buy-credits sections. */
7
13
  function sectionLabelStyle(marginBottom) {
8
14
  return {
@@ -19,7 +25,7 @@ function sectionLabelStyle(marginBottom) {
19
25
  * continue. The host controls when it appears; shows a small "Powered by
20
26
  * Crediball" line by default (set `hideBranding` to remove it).
21
27
  */
22
- export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlans, onSelectPlan, activeSubscription, onCancelSubscription, autoTopup, onEnableAutoTopup, amounts = [5, 10], amountPrefix = "Add €", title = "You need more credits to continue.", description = "Top up to keep using this feature.", onAdd, onClose, allowCustom = false, addButtonText = "Add", customMin = 1, customMax, currencySymbol = "€", balance, balanceRate, currency = "EUR", usage, usageLabel = "Usage", hideBranding = false, referralLink, referralReward, referralLabel = "Refer a friend · earn {credits} credits", accentColor, style, }) {
28
+ export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlans, onSelectPlan, activeSubscription, onCancelSubscription, autoTopup, onEnableAutoTopup, amounts = [5, 10], amountPrefix = "Add €", title = "You need more credits to continue.", description = "Top up to keep using this feature.", onAdd, onClose, allowCustom = false, allowPromoCode = false, promoCodeLabel = "Have a promo code?", addButtonText = "Add", customMin = 1, customMax, customTopupRate, currencySymbol = "€", balance, balanceRate, currency = "EUR", usage, usageLabel = "Usage", hideBranding = false, referralLink, referralReward, referralLabel = "Refer a friend · earn {credits} credits", accentColor, style, }) {
23
29
  // Stack the top-up buttons one-per-line when the dialog itself is narrow.
24
30
  // Measured off the dialog's own box (via ResizeObserver) rather than the
25
31
  // window — in normal use the two are the same width, but this also makes
@@ -35,6 +41,8 @@ export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlan
35
41
  const [autoTopupOpen, setAutoTopupOpen] = useState(false);
36
42
  const [autoTopupPackageId, setAutoTopupPackageId] = useState("");
37
43
  const [autoTopupThreshold, setAutoTopupThreshold] = useState("");
44
+ const [promoCodeOpen, setPromoCodeOpen] = useState(false);
45
+ const [promoCode, setPromoCode] = useState("");
38
46
  useEffect(() => {
39
47
  const el = overlayRef.current;
40
48
  if (!el || typeof ResizeObserver === "undefined")
@@ -76,6 +84,12 @@ export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlan
76
84
  setReferralCopied(true);
77
85
  setTimeout(() => setReferralCopied(false), 2000);
78
86
  }
87
+ // Undefined (not "") when empty, so onAdd/onSelectPackage receive `code`
88
+ // exactly as the SDK expects (an absent field, not an empty string).
89
+ function enteredCode() {
90
+ const trimmed = promoCode.trim();
91
+ return trimmed || undefined;
92
+ }
79
93
  function submitCustom() {
80
94
  const amt = Number(customValue);
81
95
  if (!Number.isFinite(amt) || amt <= 0) {
@@ -91,7 +105,7 @@ export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlan
91
105
  return;
92
106
  }
93
107
  setCustomError(null);
94
- onAdd?.(amt);
108
+ onAdd?.(amt, enteredCode());
95
109
  }
96
110
  function submitAutoTopup() {
97
111
  const threshold = Number(autoTopupThreshold);
@@ -114,6 +128,12 @@ export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlan
114
128
  borderRadius: theme.radiusPill,
115
129
  };
116
130
  const resolvedReferralLabel = referralLabel.replace("{credits}", formatCredits(referralReward ?? 0));
131
+ // Same rounding the actual charge uses (see /api/public/topup/checkout),
132
+ // so this preview never promises a different number than what's granted.
133
+ const customAmountParsed = Number(customValue);
134
+ const customCreditsPreview = customTopupRate && customTopupRate > 0 && Number.isFinite(customAmountParsed) && customAmountParsed > 0
135
+ ? Math.round(customAmountParsed * customTopupRate)
136
+ : null;
117
137
  return (_jsx("div", { ref: overlayRef, role: "dialog", "aria-modal": "true", style: {
118
138
  position: "fixed",
119
139
  inset: 0,
@@ -174,9 +194,32 @@ export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlan
174
194
  display: "flex",
175
195
  justifyContent: "space-between",
176
196
  alignItems: "center",
177
- }, children: [_jsx("span", { children: plan.name }), _jsxs("span", { style: { opacity: 0.85, fontSize: 14 }, children: [formatMoney(plan.price, currency), "/", plan.period === "monthly" ? "mo" : "yr", " \u00B7 ", formatCredits(plan.credits), " cr"] })] }, plan.id))) })] })), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10, marginTop: 24 }, children: [(!activeSubscription && subscriptionPlans && subscriptionPlans.length > 0) && (_jsx("div", { style: sectionLabelStyle(-2), children: "Or buy credits" })), activeSubscription && packages && packages.length > 0 && (_jsx("div", { style: sectionLabelStyle(-2), children: "Add extra credits" })), packages && packages.length > 0
178
- ? packages.map((pkg) => (_jsxs("button", { onClick: () => onSelectPackage?.(pkg), style: { ...buttonStyle, flex: "none", display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [_jsx("span", { children: pkg.name }), _jsxs("span", { style: { opacity: 0.85, fontSize: 14 }, children: [formatMoney(pkg.price, currency), " \u00B7 ", formatCredits(pkg.credits), " cr"] })] }, pkg.id)))
179
- : (!activeSubscription && (_jsx("div", { style: { display: "flex", flexDirection: narrow ? "column" : "row", gap: 12 }, children: amounts.map((amount) => (_jsxs("button", { onClick: () => onAdd?.(amount), style: buttonStyle, children: [amountPrefix, amount] }, amount))) })))] }), allowCustom ? (_jsxs("div", { style: { marginTop: 12 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [_jsx("span", { style: { fontSize: 14, color: theme.inkMuted }, children: currencySymbol }), _jsx("input", { value: customValue, onChange: (e) => setCustomValue(e.target.value), inputMode: "decimal", placeholder: String(customMin), style: {
197
+ }, children: [_jsx("span", { children: plan.name }), _jsxs("span", { style: { opacity: 0.85, fontSize: 14 }, children: [formatMoney(plan.price, currency), "/", PERIOD_SUFFIX[plan.period], " \u00B7 ", formatCredits(plan.credits), " cr"] })] }, plan.id))) })] })), allowPromoCode && !activeSubscription && (_jsx("div", { style: { marginTop: 16 }, children: promoCodeOpen ? (_jsxs("div", { children: [_jsx("div", { style: { ...sectionLabelStyle(6) }, children: promoCodeLabel }), _jsx("input", { value: promoCode, onChange: (e) => setPromoCode(e.target.value.toUpperCase()), placeholder: "PROMOCODE", style: {
198
+ width: "100%",
199
+ boxSizing: "border-box",
200
+ appearance: "none",
201
+ fontFamily: theme.font,
202
+ fontSize: 14,
203
+ lineHeight: "20px",
204
+ padding: "8px 12px",
205
+ borderRadius: theme.radiusPill,
206
+ border: `1px solid ${theme.hairline}`,
207
+ color: theme.ink,
208
+ background: theme.canvas,
209
+ outline: "none",
210
+ } })] })) : (_jsx("button", { onClick: () => setPromoCodeOpen(true), style: {
211
+ appearance: "none",
212
+ border: "none",
213
+ background: "transparent",
214
+ cursor: "pointer",
215
+ padding: 0,
216
+ fontSize: 12,
217
+ color: theme.inkMuted,
218
+ fontFamily: theme.font,
219
+ textDecoration: "underline",
220
+ }, children: promoCodeLabel })) })), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10, marginTop: 24 }, children: [(!activeSubscription && subscriptionPlans && subscriptionPlans.length > 0) && (_jsx("div", { style: sectionLabelStyle(-2), children: "Or buy credits" })), activeSubscription && packages && packages.length > 0 && (_jsx("div", { style: sectionLabelStyle(-2), children: "Add extra credits" })), packages && packages.length > 0
221
+ ? packages.map((pkg) => (_jsxs("button", { onClick: () => onSelectPackage?.(pkg, enteredCode()), style: { ...buttonStyle, flex: "none", display: "flex", justifyContent: "space-between", alignItems: "center" }, children: [_jsx("span", { children: pkg.name }), _jsxs("span", { style: { opacity: 0.85, fontSize: 14 }, children: [formatMoney(pkg.price, currency), " \u00B7 ", formatCredits(pkg.credits), " cr"] })] }, pkg.id)))
222
+ : (!activeSubscription && (_jsx("div", { style: { display: "flex", flexDirection: narrow ? "column" : "row", gap: 12 }, children: amounts.map((amount) => (_jsxs("button", { onClick: () => onAdd?.(amount, enteredCode()), style: buttonStyle, children: [amountPrefix, amount] }, amount))) })))] }), allowCustom ? (_jsxs("div", { style: { marginTop: 12 }, children: [_jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [_jsx("span", { style: { fontSize: 14, color: theme.inkMuted }, children: currencySymbol }), _jsx("input", { value: customValue, onChange: (e) => setCustomValue(e.target.value), inputMode: "decimal", placeholder: String(customMin), style: {
180
223
  flex: 1,
181
224
  minWidth: 0,
182
225
  appearance: "none",
@@ -189,9 +232,9 @@ export function PaywallModal({ open, packages, onSelectPackage, subscriptionPlan
189
232
  color: theme.ink,
190
233
  background: theme.canvas,
191
234
  outline: "none",
192
- } }), _jsx("button", { onClick: submitCustom, style: { ...buttonStyle, flex: "none" }, children: addButtonText })] }), customError ? (_jsx("p", { style: { margin: "8px 0 0", fontSize: 13, color: theme.accent }, children: customError })) : null] })) : null, onEnableAutoTopup && packages && packages.length > 0 && (_jsx("div", { style: { marginTop: 16 }, children: autoTopup?.enabled && autoTopup.status === "active" ? (_jsxs("p", { style: { margin: 0, fontSize: 12, color: theme.inkMuted }, children: ["Auto top-up is on \u2014 buying", " ", packages.find((p) => p.id === autoTopup.packageId)?.name ?? "your package", " whenever your balance drops below ", formatCredits(autoTopup.thresholdCredits), " credits."] })) : autoTopup?.enabled && (autoTopup.status === "needs_auth" || autoTopup.status === "failed") ? (_jsxs("div", { children: [_jsx("p", { style: { margin: 0, fontSize: 12, color: theme.accent }, children: autoTopup.status === "needs_auth"
235
+ } }), _jsx("button", { onClick: submitCustom, style: { ...buttonStyle, flex: "none" }, children: addButtonText })] }), customCreditsPreview != null ? (_jsxs("p", { style: { margin: "6px 0 0", fontSize: 12, color: theme.inkMuted }, children: ["\u2248 ", formatCredits(customCreditsPreview), " credits"] })) : null, customError ? (_jsx("p", { style: { margin: "8px 0 0", fontSize: 13, color: theme.accent }, children: customError })) : null] })) : null, packages && packages.length > 0 && (_jsx("div", { style: { marginTop: 16 }, children: autoTopup?.enabled && autoTopup.status === "active" ? (_jsxs("p", { style: { margin: 0, fontSize: 12, color: theme.inkMuted }, children: ["Auto top-up is on \u2014 buying", " ", packages.find((p) => p.id === autoTopup.packageId)?.name ?? "your package", " whenever your balance drops below ", formatCredits(autoTopup.thresholdCredits), " credits."] })) : autoTopup?.enabled && (autoTopup.status === "needs_auth" || autoTopup.status === "failed") ? (_jsxs("div", { children: [_jsx("p", { style: { margin: 0, fontSize: 12, color: theme.accent }, children: autoTopup.status === "needs_auth"
193
236
  ? "Auto top-up needs you to re-confirm your payment method."
194
- : "Auto top-up's last charge failed." }), _jsx("button", { onClick: () => onEnableAutoTopup({
237
+ : "Auto top-up's last charge failed." }), _jsx("button", { onClick: () => onEnableAutoTopup?.({
195
238
  packageId: autoTopup.packageId,
196
239
  thresholdCredits: autoTopup.thresholdCredits,
197
240
  }), style: {
@@ -7,10 +7,12 @@ import { type PaywallModalProps, type PackageOption, type SubscriptionPlanOption
7
7
  * accept async handlers. Add watchFetch to auto-open on detected credit errors.
8
8
  */
9
9
  export type UsePaywallProps = Omit<PaywallModalProps, "open" | "onClose" | "onAdd" | "style"> & {
10
- /** Called when the user picks a legacy preset/custom top-up amount. */
11
- onTopup?: (amount: number) => void | Promise<void>;
12
- /** Called when the user picks a one-time package (async override). */
13
- onSelectPackage?: (pkg: PackageOption) => void | Promise<void>;
10
+ /** Called when the user picks a legacy preset/custom top-up amount. The
11
+ * second argument is the promo code entered, if any (see `allowPromoCode`). */
12
+ onTopup?: (amount: number, code?: string) => void | Promise<void>;
13
+ /** Called when the user picks a one-time package (async override). The second
14
+ * argument is the promo code entered, if any (see `allowPromoCode`). */
15
+ onSelectPackage?: (pkg: PackageOption, code?: string) => void | Promise<void>;
14
16
  /** Called when the user picks a subscription plan (async override). */
15
17
  onSelectPlan?: (plan: SubscriptionPlanOption) => void | Promise<void>;
16
18
  /**
@@ -36,13 +36,13 @@ export function usePaywall({ onTopup, onSelectPackage, onSelectPlan, watchFetch
36
36
  return;
37
37
  return subscribeFetchWatcher(() => triggerRef.current());
38
38
  }, [watchFetch]);
39
- const handleTopup = useCallback(async (amount) => {
39
+ const handleTopup = useCallback(async (amount, code) => {
40
40
  setIsOpen(false);
41
- await onTopup?.(amount);
41
+ await onTopup?.(amount, code);
42
42
  }, [onTopup]);
43
- const handleSelectPackage = useCallback(async (pkg) => {
43
+ const handleSelectPackage = useCallback(async (pkg, code) => {
44
44
  setIsOpen(false);
45
- await onSelectPackage?.(pkg);
45
+ await onSelectPackage?.(pkg, code);
46
46
  }, [onSelectPackage]);
47
47
  const handleSelectPlan = useCallback(async (plan) => {
48
48
  setIsOpen(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediball/react",
3
- "version": "0.11.0",
3
+ "version": "0.14.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.6.0"
54
+ "@crediball/core": "^0.8.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "^18.3.11",