@superwall/paywalls-react 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hooks.ts DELETED
@@ -1,368 +0,0 @@
1
- // React hooks — sugar over the public `Superwall` instance + `Readable<T>`.
2
- // Per API.md §9.3.
3
-
4
- import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
5
- import type {
6
- AllSuperwallEvents,
7
- CustomerInfo,
8
- CustomPaywallController,
9
- CustomPaywallState,
10
- Entitlement,
11
- IdentityOptions,
12
- IntegrationAttribute,
13
- PaywallInfo,
14
- PaywallResult,
15
- PaywallSkippedReason,
16
- PlacementParams,
17
- Readable,
18
- RegisterPlacementArgs,
19
- RegisterPlacementResult,
20
- Superwall,
21
- SuperwallCustomEvent,
22
- SuperwallDelegate,
23
- SubscriptionStatus,
24
- UserAttributes,
25
- } from "@superwall/paywalls-js";
26
- import { useSuperwallContext } from "./provider.tsx";
27
-
28
- // ---------------------------------------------------------------------------
29
- // useSuperwall — raw instance access
30
- // ---------------------------------------------------------------------------
31
-
32
- export const useSuperwall = (): Superwall => useSuperwallContext();
33
-
34
- // ---------------------------------------------------------------------------
35
- // useSignal — bridge `Readable<T>` to React via useSyncExternalStore
36
- // ---------------------------------------------------------------------------
37
-
38
- /**
39
- * Subscribe to a `Readable<T>` and re-render on change. Stores the signal
40
- * in a ref so an unstable `signal` identity per render doesn't cause
41
- * `useSyncExternalStore` to re-subscribe on every render (which would
42
- * spin into an infinite re-render loop). The ref always points at the
43
- * latest signal; subscribe + getSnapshot read through it.
44
- */
45
- export const useSignal = <T,>(signal: Readable<T>): T => {
46
- const ref = useRef(signal);
47
- ref.current = signal;
48
- const subscribe = useCallback(
49
- (onChange: () => void) => ref.current.subscribe(() => onChange()),
50
- [],
51
- );
52
- const getSnapshot = useCallback(() => ref.current.value, []);
53
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
54
- };
55
-
56
- // ---------------------------------------------------------------------------
57
- // useUser — flat view of the user namespace + bound methods
58
- // ---------------------------------------------------------------------------
59
-
60
- export interface UseUserResult {
61
- id: string;
62
- aliasId: string;
63
- effectiveId: string;
64
- isLoggedIn: boolean;
65
- attributes: UserAttributes;
66
- integrationAttributes: Partial<Record<IntegrationAttribute, string>>;
67
- subscriptionStatus: SubscriptionStatus;
68
- customerInfo: CustomerInfo | null;
69
- /** Active entitlements convenience — derived from subscriptionStatus. */
70
- entitlements: Entitlement[];
71
-
72
- identify: (userId: string, opts?: IdentityOptions) => Promise<void>;
73
- signOut: () => Promise<void>;
74
- setAttributes: (attrs: Partial<UserAttributes>) => void;
75
- setIntegrationAttribute: (
76
- attr: IntegrationAttribute,
77
- value: string | null,
78
- ) => void;
79
- setIntegrationAttributes: (
80
- attrs: Partial<Record<IntegrationAttribute, string | null>>,
81
- ) => void;
82
- }
83
-
84
- export const useUser = (): UseUserResult => {
85
- const sw = useSuperwall();
86
- return {
87
- id: useSignal(sw.user.id),
88
- aliasId: useSignal(sw.user.aliasId),
89
- effectiveId: useSignal(sw.user.effectiveId),
90
- isLoggedIn: useSignal(sw.user.isLoggedIn),
91
- attributes: useSignal(sw.user.attributes),
92
- integrationAttributes: useSignal(sw.user.integrationAttributes),
93
- subscriptionStatus: useSignal(sw.subscriptionStatus),
94
- customerInfo: useSignal(sw.customerInfo),
95
- entitlements: useSignal(sw.entitlements.active),
96
- identify: sw.user.identify,
97
- signOut: sw.user.signOut,
98
- setAttributes: sw.user.setAttributes,
99
- setIntegrationAttribute: sw.user.setIntegrationAttribute,
100
- setIntegrationAttributes: sw.user.setIntegrationAttributes,
101
- };
102
- };
103
-
104
- // ---------------------------------------------------------------------------
105
- // usePlacement — per-component register + lifecycle state
106
- // ---------------------------------------------------------------------------
107
-
108
- export interface PaywallPresentationHandlerHooks {
109
- onPresent?(info: PaywallInfo): void;
110
- onDismiss?(info: PaywallInfo, result: PaywallResult): void;
111
- onError?(error: Error): void;
112
- onSkip?(reason: PaywallSkippedReason): void;
113
- }
114
-
115
- export type PaywallState =
116
- | { type: "idle" }
117
- | { type: "presented"; info: PaywallInfo }
118
- | { type: "dismissed"; info: PaywallInfo; result: PaywallResult }
119
- | { type: "skipped"; reason: PaywallSkippedReason }
120
- | { type: "error"; error: Error };
121
-
122
- export interface UsePlacementResult {
123
- register: (args: RegisterPlacementArgs) => Promise<RegisterPlacementResult>;
124
- state: PaywallState;
125
- }
126
-
127
- /**
128
- * Returns a `register` function bound to the active Superwall + a `state`
129
- * reflecting the latest placement outcome from THIS hook's calls. Handler
130
- * callbacks fire alongside the global delegate. State is local to the
131
- * hook (one component's state, not the SDK's).
132
- */
133
- export const usePlacement = (
134
- handler?: PaywallPresentationHandlerHooks,
135
- ): UsePlacementResult => {
136
- const sw = useSuperwall();
137
- const [state, setState] = useState<PaywallState>({ type: "idle" });
138
-
139
- // Latest-handler ref so the user can rely on closure-captured values
140
- // without re-binding `register` on every render.
141
- const handlerRef = useRef(handler);
142
- handlerRef.current = handler;
143
-
144
- const register = useCallback(
145
- async (args: RegisterPlacementArgs): Promise<RegisterPlacementResult> => {
146
- const r = await sw.register({
147
- ...args,
148
- handler: {
149
- onPresent: (info) => {
150
- setState({ type: "presented", info });
151
- try {
152
- handlerRef.current?.onPresent?.(info);
153
- } catch {
154
- /* swallow */
155
- }
156
- },
157
- onDismiss: (info, result) => {
158
- setState({ type: "dismissed", info, result });
159
- try {
160
- handlerRef.current?.onDismiss?.(info, result);
161
- } catch {
162
- /* swallow */
163
- }
164
- },
165
- onError: (error) => {
166
- setState({ type: "error", error });
167
- try {
168
- handlerRef.current?.onError?.(error);
169
- } catch {
170
- /* swallow */
171
- }
172
- },
173
- onSkip: (reason) => {
174
- setState({ type: "skipped", reason });
175
- try {
176
- handlerRef.current?.onSkip?.(reason);
177
- } catch {
178
- /* swallow */
179
- }
180
- },
181
- },
182
- });
183
- // For cases where the SDK returned a non-presented result without
184
- // firing handler callbacks (e.g. `entitled`), reflect that in state.
185
- if (r.type === "entitled") {
186
- setState({ type: "idle" });
187
- }
188
- return r;
189
- },
190
- [sw],
191
- );
192
-
193
- return { register, state };
194
- };
195
-
196
- // ---------------------------------------------------------------------------
197
- // useCustomPaywall — render your own paywall UI through register()
198
- // ---------------------------------------------------------------------------
199
-
200
- export interface UseCustomPaywallOptions extends PaywallPresentationHandlerHooks {
201
- placement: string;
202
- params?: PlacementParams;
203
- /** Runs when the user is entitled OR a non-gated paywall is dismissed
204
- * without purchase. Same semantics as `register({ feature })`. */
205
- feature?: () => void | Promise<void>;
206
- }
207
-
208
- export interface CustomPaywallMountSnapshot {
209
- readonly state: CustomPaywallState;
210
- readonly controller: CustomPaywallController;
211
- }
212
-
213
- export interface UseCustomPaywallResult {
214
- /** Trigger the placement. Runs the full SDK pipeline (rules / holdout /
215
- * assignment / gating / analytics); if it decides to present, `paywall`
216
- * flips non-null and your UI should render. Resolves with the placement
217
- * outcome. */
218
- register: () => Promise<RegisterPlacementResult>;
219
- /** Active mount (state snapshot + controller) while presenting, else null.
220
- * Re-renders the calling component whenever the transaction / restoration
221
- * phase changes. */
222
- paywall: CustomPaywallMountSnapshot | null;
223
- }
224
-
225
- /**
226
- * React primitive for custom (developer-rendered) paywalls — the web analogue
227
- * of Android's `SuperwallCustomPaywall`. You own the UI; the SDK runs the
228
- * trigger pipeline, resolves products, fires identical lifecycle events, and
229
- * hands you a controller. Render `paywall` when it's non-null; drive
230
- * `paywall.controller.buy / restore / close` from your buttons.
231
- *
232
- * const { register, paywall } = useCustomPaywall({ placement: "home" });
233
- * return (
234
- * <>
235
- * <button onClick={register}>Go Pro</button>
236
- * {paywall && (
237
- * <MyPaywall
238
- * products={paywall.state.products}
239
- * busy={paywall.state.transaction.phase === "purchasing"}
240
- * onBuy={paywall.controller.buy}
241
- * onClose={paywall.controller.close}
242
- * />
243
- * )}
244
- * </>
245
- * );
246
- */
247
- export const useCustomPaywall = (
248
- opts: UseCustomPaywallOptions,
249
- ): UseCustomPaywallResult => {
250
- const sw = useSuperwall();
251
- const [paywall, setPaywall] = useState<CustomPaywallMountSnapshot | null>(null);
252
-
253
- // Latest-opts ref so `register` stays stable but reads fresh callbacks.
254
- const optsRef = useRef(opts);
255
- optsRef.current = opts;
256
-
257
- const register = useCallback((): Promise<RegisterPlacementResult> => {
258
- const o = optsRef.current;
259
- return sw.register({
260
- placement: o.placement,
261
- ...(o.params !== undefined && { params: o.params }),
262
- ...(o.feature !== undefined && { feature: o.feature }),
263
- handler: {
264
- onPresent: (info) => {
265
- try { optsRef.current.onPresent?.(info); } catch { /* swallow */ }
266
- },
267
- onDismiss: (info, result) => {
268
- try { optsRef.current.onDismiss?.(info, result); } catch { /* swallow */ }
269
- },
270
- onError: (error) => {
271
- try { optsRef.current.onError?.(error); } catch { /* swallow */ }
272
- },
273
- onSkip: (reason) => {
274
- try { optsRef.current.onSkip?.(reason); } catch { /* swallow */ }
275
- },
276
- },
277
- // The renderer bridges core's reactive state into React state. Core
278
- // calls it once on present; we subscribe (fires sync) and mirror each
279
- // update into component state. Teardown clears the mount on dismiss.
280
- paywall: ({ state, controller }) => {
281
- const unsubscribe = state.subscribe((s) => {
282
- setPaywall({ state: s, controller });
283
- });
284
- return () => {
285
- unsubscribe();
286
- setPaywall(null);
287
- };
288
- },
289
- });
290
- }, [sw]);
291
-
292
- return { register, paywall };
293
- };
294
-
295
- // ---------------------------------------------------------------------------
296
- // useSuperwallEvent — typed addEventListener with auto-cleanup
297
- // ---------------------------------------------------------------------------
298
-
299
- export const useSuperwallEvent = <K extends keyof AllSuperwallEvents>(
300
- type: K,
301
- listener: (event: SuperwallCustomEvent<K>) => void,
302
- ): void => {
303
- const sw = useSuperwall();
304
- // Latest-listener ref so the user can capture fresh closures without
305
- // re-attaching the listener every render.
306
- const listenerRef = useRef(listener);
307
- listenerRef.current = listener;
308
- useEffect(() => {
309
- const ac = new AbortController();
310
- sw.events.addEventListener(
311
- type,
312
- (e) => listenerRef.current(e),
313
- { signal: ac.signal },
314
- );
315
- return () => ac.abort();
316
- }, [sw, type]);
317
- };
318
-
319
- // ---------------------------------------------------------------------------
320
- // useDelegate — install a global SuperwallDelegate for the lifetime of the
321
- // component. Multiple hooks can mount concurrently: each pushes onto a
322
- // per-instance stack. The active delegate is always the top; unmount pops
323
- // only the owner that pushed and re-installs whatever was below.
324
- // ---------------------------------------------------------------------------
325
-
326
- interface DelegateEntry {
327
- readonly id: symbol;
328
- readonly delegate: SuperwallDelegate | null;
329
- }
330
-
331
- const delegateStacks = new WeakMap<Superwall, DelegateEntry[]>();
332
-
333
- const applyTop = (sw: Superwall): void => {
334
- const stack = delegateStacks.get(sw) ?? [];
335
- const top = stack.length === 0 ? null : (stack[stack.length - 1]!.delegate);
336
- sw.setDelegate(top);
337
- };
338
-
339
- export const useDelegate = (delegate: SuperwallDelegate | null): void => {
340
- const sw = useSuperwall();
341
- const ref = useRef(delegate);
342
- ref.current = delegate;
343
- useEffect(() => {
344
- const id = Symbol("useDelegate");
345
- // Always read latest delegate from the ref so callbacks see the current
346
- // closure values without re-mounting.
347
- const wrapped: SuperwallDelegate | null =
348
- ref.current === null
349
- ? null
350
- : new Proxy({} as SuperwallDelegate, {
351
- get(_t, prop: string) {
352
- const d = ref.current;
353
- return d ? (d as Record<string, unknown>)[prop] : undefined;
354
- },
355
- });
356
- const stack = delegateStacks.get(sw) ?? [];
357
- stack.push({ id, delegate: wrapped });
358
- delegateStacks.set(sw, stack);
359
- applyTop(sw);
360
- return () => {
361
- const current = delegateStacks.get(sw) ?? [];
362
- const next = current.filter((e) => e.id !== id);
363
- if (next.length === 0) delegateStacks.delete(sw);
364
- else delegateStacks.set(sw, next);
365
- applyTop(sw);
366
- };
367
- }, [sw]);
368
- };
package/src/index.test.ts DELETED
@@ -1,7 +0,0 @@
1
- import { test, expect } from "bun:test";
2
- import { SDK_VERSION } from "./index.ts";
3
-
4
- test("re-exports SDK_VERSION from @superwall/paywalls-js", () => {
5
- expect(typeof SDK_VERSION).toBe("string");
6
- expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+/);
7
- });
package/src/index.ts DELETED
@@ -1,31 +0,0 @@
1
- // @superwall/paywalls-react — React 19 bindings for the Superwall Web SDK.
2
- // See /Users/ianrumac/Workspace/Superwall/Superwall-Web/API.md §9.
3
-
4
- export { SDK_VERSION } from "@superwall/paywalls-js";
5
-
6
- export {
7
- SuperwallProvider,
8
- type SuperwallProviderProps,
9
- useSuperwallContext,
10
- } from "./provider.tsx";
11
-
12
- export {
13
- useSuperwall,
14
- useSignal,
15
- useUser,
16
- usePlacement,
17
- useCustomPaywall,
18
- useSuperwallEvent,
19
- useDelegate,
20
- type UseUserResult,
21
- type UsePlacementResult,
22
- type UseCustomPaywallOptions,
23
- type UseCustomPaywallResult,
24
- type CustomPaywallMountSnapshot,
25
- type PaywallState,
26
- type PaywallPresentationHandlerHooks,
27
- } from "./hooks.ts";
28
-
29
- // Re-export everything public from paywalls-js so React consumers don't
30
- // need to depend on both packages directly. Tree-shakeable per ESM rules.
31
- export * from "@superwall/paywalls-js";
@@ -1,102 +0,0 @@
1
- import { test, expect, beforeEach } from "bun:test";
2
- import { act, render } from "@testing-library/react";
3
- import { useSuperwall } from "./hooks.ts";
4
- import {
5
- _resetProviderRegistry,
6
- SuperwallProvider,
7
- } from "./provider.tsx";
8
-
9
- const noopFetch = (() =>
10
- Promise.resolve(new Response("", { status: 204 }))) as unknown as typeof fetch;
11
-
12
- beforeEach(() => {
13
- _resetProviderRegistry();
14
- });
15
-
16
- test("SuperwallProvider provides a Superwall instance via context", async () => {
17
- let captured: ReturnType<typeof useSuperwall> | null = null;
18
- const Probe = () => {
19
- captured = useSuperwall();
20
- return null;
21
- };
22
-
23
- await act(async () => {
24
- render(
25
- <SuperwallProvider apiKey="pk_test" fetch={noopFetch}>
26
- <Probe />
27
- </SuperwallProvider>,
28
- );
29
- });
30
-
31
- expect(captured).not.toBeNull();
32
- expect(captured!.apiKey).toBe("pk_test");
33
- });
34
-
35
- test("useSuperwall outside <SuperwallProvider> throws a clear error", () => {
36
- const Probe = () => {
37
- useSuperwall();
38
- return null;
39
- };
40
- expect(() => render(<Probe />)).toThrow(/SuperwallProvider/);
41
- });
42
-
43
- test("registry reuses the instance across re-mounts with the same apiKey (HMR)", async () => {
44
- let first: ReturnType<typeof useSuperwall> | null = null;
45
- let second: ReturnType<typeof useSuperwall> | null = null;
46
-
47
- const Probe = ({ slot }: { slot: 1 | 2 }) => {
48
- const sw = useSuperwall();
49
- if (slot === 1) first = sw;
50
- else second = sw;
51
- return null;
52
- };
53
-
54
- let view: ReturnType<typeof render>;
55
- await act(async () => {
56
- view = render(
57
- <SuperwallProvider apiKey="pk_hmr" fetch={noopFetch}>
58
- <Probe slot={1} />
59
- </SuperwallProvider>,
60
- );
61
- });
62
- // Unmount, re-mount with same key — registry should hand back the same instance.
63
- await act(async () => {
64
- view!.unmount();
65
- render(
66
- <SuperwallProvider apiKey="pk_hmr" fetch={noopFetch}>
67
- <Probe slot={2} />
68
- </SuperwallProvider>,
69
- );
70
- });
71
-
72
- expect(first).not.toBeNull();
73
- expect(second).toBe(first); // same instance
74
- });
75
-
76
- test("different apiKeys produce different instances", async () => {
77
- let a: ReturnType<typeof useSuperwall> | null = null;
78
- let b: ReturnType<typeof useSuperwall> | null = null;
79
- const Probe = ({ tag }: { tag: "a" | "b" }) => {
80
- const sw = useSuperwall();
81
- if (tag === "a") a = sw;
82
- else b = sw;
83
- return null;
84
- };
85
-
86
- await act(async () => {
87
- render(
88
- <>
89
- <SuperwallProvider apiKey="pk_a" fetch={noopFetch}>
90
- <Probe tag="a" />
91
- </SuperwallProvider>
92
- <SuperwallProvider apiKey="pk_b" fetch={noopFetch}>
93
- <Probe tag="b" />
94
- </SuperwallProvider>
95
- </>,
96
- );
97
- });
98
-
99
- expect(a).not.toBeNull();
100
- expect(b).not.toBeNull();
101
- expect(a).not.toBe(b);
102
- });
package/src/provider.tsx DELETED
@@ -1,99 +0,0 @@
1
- // `<SuperwallProvider>` — constructs a `Superwall` instance on mount and
2
- // exposes it via React Context. Per API.md §9.1.
3
- //
4
- // HMR / Fast Refresh resilient (normative §9.1): the Provider holds the
5
- // instance in a module-level registry keyed by `apiKey`. On unmount it
6
- // disposes; on re-mount with the same `apiKey` (e.g. dev-server hot
7
- // reload) it reuses the existing instance instead of building a new one,
8
- // so iframes don't leak and event listeners don't stack.
9
- //
10
- // SSR-safe: on the server, `createSuperwall` is called with whatever
11
- // identity seed the consumer passes; rendered children are eager (don't
12
- // gate with `use(sw.ready)` server-side per API.md §9.2).
13
-
14
- import {
15
- createContext,
16
- useContext,
17
- useMemo,
18
- type ReactNode,
19
- } from "react";
20
- import {
21
- createSuperwall,
22
- type CreateSuperwallOptions,
23
- type Superwall,
24
- } from "@superwall/paywalls-js";
25
-
26
- const SuperwallContext = createContext<Superwall | null>(null);
27
-
28
- // ---------------------------------------------------------------------------
29
- // HMR-resilient registry — instances live for the page's lifetime.
30
- //
31
- // Production: a host app has one Provider per apiKey, mounted once, never
32
- // unmounted until page navigation. Disposing on unmount is unnecessary
33
- // and fights HMR (Fast Refresh tears down + re-mounts; the iframe and
34
- // listeners would leak if we re-created the instance each time). Memory
35
- // "leak" of one Superwall instance per apiKey for the page's lifetime is
36
- // the intended trade-off.
37
- //
38
- // Tests use `_resetProviderRegistry()` between cases to avoid leakage.
39
- // ---------------------------------------------------------------------------
40
-
41
- const registry = new Map<string, Superwall>();
42
-
43
- const acquire = (apiKey: string, opts: CreateSuperwallOptions): Superwall => {
44
- const existing = registry.get(apiKey);
45
- if (existing) return existing;
46
- const sw = createSuperwall(opts);
47
- registry.set(apiKey, sw);
48
- return sw;
49
- };
50
-
51
- /** Test-only — dispose every registered instance and clear the registry. */
52
- export const _resetProviderRegistry = (): void => {
53
- for (const sw of registry.values()) {
54
- void sw.dispose();
55
- }
56
- registry.clear();
57
- };
58
-
59
- // ---------------------------------------------------------------------------
60
- // Provider
61
- // ---------------------------------------------------------------------------
62
-
63
- export interface SuperwallProviderProps extends CreateSuperwallOptions {
64
- children: ReactNode;
65
- }
66
-
67
- export const SuperwallProvider = ({
68
- children,
69
- ...opts
70
- }: SuperwallProviderProps) => {
71
- // Acquire the registry-cached instance for this apiKey (first call
72
- // creates; subsequent calls reuse — see registry comment above).
73
- // `useMemo` keyed by apiKey is sufficient: changing `apiKey` swaps the
74
- // context value to the next instance; other config props are ignored
75
- // post-mount (config changes don't reconfigure the SDK in v0).
76
- const sw = useMemo(
77
- () => acquire(opts.apiKey, opts),
78
- // eslint-disable-next-line react-hooks/exhaustive-deps
79
- [opts.apiKey],
80
- );
81
-
82
- return (
83
- <SuperwallContext.Provider value={sw}>{children}</SuperwallContext.Provider>
84
- );
85
- };
86
-
87
- // ---------------------------------------------------------------------------
88
- // Context consumer
89
- // ---------------------------------------------------------------------------
90
-
91
- export const useSuperwallContext = (): Superwall => {
92
- const sw = useContext(SuperwallContext);
93
- if (sw === null) {
94
- throw new Error(
95
- "useSuperwall (or its callers) must be used inside <SuperwallProvider>",
96
- );
97
- }
98
- return sw;
99
- };
package/test-setup-dom.ts DELETED
@@ -1,11 +0,0 @@
1
- // Bun preload — registers happy-dom so React 19 + @testing-library/react
2
- // run in a browser-shaped environment.
3
-
4
- import { GlobalRegistrator } from "@happy-dom/global-registrator";
5
-
6
- GlobalRegistrator.register({ url: "https://app.example.test" });
7
-
8
- // `IS_REACT_ACT_ENVIRONMENT` is the React 19 marker that quiets "act"
9
- // warnings during state updates from outside `act(...)`. Setting it
10
- // globally is the recommended pattern for non-React-Native test runners.
11
- (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "rootDir": "src",
5
- "jsx": "react-jsx",
6
- "lib": ["ESNext", "DOM"]
7
- },
8
- "include": ["src/**/*"]
9
- }