@userz-ai/react 1.0.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/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @userz-ai/react
2
+
3
+ Idiomatic React bindings for the [Userz](https://userz.ai) feedback widget. A provider, a hook, and a component-targeting wrapper around [`@userz-ai/browser`](https://www.npmjs.com/package/@userz-ai/browser).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @userz-ai/react @userz-ai/browser
9
+ # Optional peer for richer screenshots
10
+ pnpm add @zumer/snapdom
11
+ ```
12
+
13
+ React 18 and 19 are both supported.
14
+
15
+ ## Quick start
16
+
17
+ ```tsx
18
+ // app/Userz.tsx — client component
19
+ 'use client';
20
+ import { UserzProvider } from '@userz-ai/react';
21
+
22
+ export function Userz({ children }: { children: React.ReactNode }) {
23
+ return (
24
+ <UserzProvider
25
+ publicKey="pub_..."
26
+ apiUrl="https://api.userz.ai"
27
+ // Private mode: return a JWT minted by your backend (see @userz-ai/node).
28
+ getUserToken={async () => sessionStore.getUserzToken()}
29
+ >
30
+ {children}
31
+ </UserzProvider>
32
+ );
33
+ }
34
+ ```
35
+
36
+ ```tsx
37
+ // Anywhere underneath the provider:
38
+ import { useUserz } from '@userz-ai/react';
39
+
40
+ function Header() {
41
+ const userz = useUserz();
42
+ return <button onClick={() => userz.open()}>Send feedback</button>;
43
+ }
44
+ ```
45
+
46
+ The provider accepts every `UserzConfig` field from `@userz-ai/browser` (`publicKey`, `apiUrl`, `getUserToken`, `bubble`, `showEmailField`, `consoleCapacity`, `captureErrors`, `targetingChord`, `initialUser`). It owns one `Userz` instance for the lifetime of the component and tears it down on unmount.
47
+
48
+ > Like Sentry / Datadog SDKs, **config changes after the first render are ignored**. Use `setUser()` and `setMetadata()` via the hook for runtime updates.
49
+
50
+ ## `useUserz()`
51
+
52
+ Access the instance imperatively:
53
+
54
+ ```tsx
55
+ function IdentityBridge({ user }: { user: User | null }) {
56
+ const userz = useUserz();
57
+ useEffect(() => {
58
+ userz.setUser(
59
+ user ? { externalUserId: user.id, email: user.email } : null,
60
+ );
61
+ }, [user, userz]);
62
+ return null;
63
+ }
64
+ ```
65
+
66
+ ## `<UserzTarget>`
67
+
68
+ Mark a child as a "feedback target" the end-user can click while the targeting overlay is active (`Ctrl+Shift+U` by default). On click, the panel opens pre-filled with the target's `name`, your `meta` payload, and a cropped screenshot of just that element.
69
+
70
+ ```tsx
71
+ import { UserzTarget } from '@userz-ai/react';
72
+
73
+ <UserzTarget name="CheckoutButton" meta={{ variant, plan: 'pro' }}>
74
+ <button onClick={onCheckout}>Checkout</button>
75
+ </UserzTarget>
76
+ ```
77
+
78
+ | Prop | Required | Notes |
79
+ |---|---|---|
80
+ | `name` | yes | Display name surfaced as the targeted-component label in feedback. |
81
+ | `meta` | no | Opt-in metadata shipped with the report. Keep it small + non-sensitive — it lands in our backend in plaintext. |
82
+ | `children` | yes | A **single** child element. We clone it and attach a ref — no wrapper div, layout is unchanged. |
83
+
84
+ The child must accept a ref. DOM elements (`<button>`, `<div>`) and `forwardRef` components are fine. Plain function components without `forwardRef` get a `display: contents` wrapper — your layout still works, but `forwardRef` is preferred.
85
+
86
+ ## SSR / Next.js
87
+
88
+ The widget is browser-only. Mount the provider in a client component and render it from a server component:
89
+
90
+ ```tsx
91
+ // app/layout.tsx — server component
92
+ import { Userz } from './Userz';
93
+
94
+ export default function Layout({ children }: { children: React.ReactNode }) {
95
+ return (
96
+ <html>
97
+ <body>
98
+ <Userz>{children}</Userz>
99
+ </body>
100
+ </html>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ## Docs
106
+
107
+ Full reference at [userz.ai/docs/sdk/react](https://userz.ai/docs/sdk/react). Source on [GitHub](https://github.com/UserzFeedback/userz).
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,89 @@
1
+ import { Userz, UserzConfig } from "@userz-ai/browser";
2
+ import { CSSProperties, ReactNode } from "react";
3
+ import * as _$react_jsx_runtime0 from "react/jsx-runtime";
4
+
5
+ //#region src/provider.d.ts
6
+ interface UserzProviderProps extends UserzConfig {
7
+ children: ReactNode;
8
+ }
9
+ /**
10
+ * React provider that owns one Userz instance for the lifetime of the
11
+ * component tree. Re-renders DON'T tear down the widget — config changes
12
+ * after the first render are ignored (same as Sentry/Datadog SDKs). Use
13
+ * `useUserz()` to call methods (setUser, submit, …) imperatively.
14
+ */
15
+ declare function UserzProvider({
16
+ children,
17
+ ...config
18
+ }: UserzProviderProps): _$react_jsx_runtime0.JSX.Element;
19
+ declare function useUserz(): Userz;
20
+ //#endregion
21
+ //#region src/ScreenshotEditor.d.ts
22
+ /**
23
+ * Annotated screenshot editor.
24
+ *
25
+ * Wraps tldraw to give the end-user a quick "circle the broken thing, type
26
+ * an arrow, save" workflow before submitting feedback. The tldraw bundle
27
+ * (~600KB) is dynamic-imported on first mount so apps that never open the
28
+ * editor don't pay for it; the component shows a loading state until ready.
29
+ *
30
+ * tldraw is declared as an OPTIONAL peer dep in package.json. Apps that
31
+ * want this component MUST install `tldraw` themselves; without it the
32
+ * component renders an instructive fallback instead of throwing.
33
+ *
34
+ * Usage:
35
+ * <ScreenshotEditor
36
+ * blob={blob}
37
+ * onSave={(annotated) => userz.submit({ ..., attachments: [annotated] })}
38
+ * onCancel={() => setOpen(false)}
39
+ * />
40
+ */
41
+ interface ScreenshotEditorProps {
42
+ /** PNG/JPEG blob to seed the canvas with. */
43
+ blob: Blob;
44
+ /** Optional dimensions override; default is the blob's natural size. */
45
+ width?: number;
46
+ height?: number;
47
+ /** Receives the annotated PNG. */
48
+ onSave: (annotated: Blob) => void | Promise<void>;
49
+ onCancel?: () => void;
50
+ /** Inline style override on the editor container. */
51
+ style?: CSSProperties;
52
+ className?: string;
53
+ /** Save-button color. Defaults to the Userz brand mint. */
54
+ brandColor?: string;
55
+ /** Save-button text color. Defaults to brand foreground. */
56
+ brandForeground?: string;
57
+ }
58
+ declare function ScreenshotEditor(props: ScreenshotEditorProps): ReactNode;
59
+ //#endregion
60
+ //#region src/UserzTarget.d.ts
61
+ interface UserzTargetProps {
62
+ /** Stable display name for this target. Surfaces in feedback as the
63
+ * "component the user clicked on" label. */
64
+ name: string;
65
+ /** Opt-in metadata you want forwarded with feedback. Keep small + non-sensitive. */
66
+ meta?: Record<string, unknown>;
67
+ /** A single child element (no fragments). We attach a ref to it without
68
+ * a wrapper div so layout is unchanged. */
69
+ children: ReactNode;
70
+ }
71
+ /**
72
+ * Wraps a child element to mark it as a "feedback target" the end-user can
73
+ * click on while the targeting overlay is active (Ctrl+Shift+U by default).
74
+ *
75
+ * Implementation: clones the single child, attaches a ref, and registers/
76
+ * unregisters with the Userz instance over the child's lifecycle. We do NOT
77
+ * inject a wrapper div — that would break flex/grid layouts. As a result,
78
+ * the child must accept a `ref`; for most DOM elements and `forwardRef`
79
+ * components this is fine. For function components without forwardRef, we
80
+ * fall back to a `display: contents` wrapper.
81
+ */
82
+ declare function UserzTarget({
83
+ name,
84
+ meta,
85
+ children
86
+ }: UserzTargetProps): _$react_jsx_runtime0.JSX.Element;
87
+ //#endregion
88
+ export { ScreenshotEditor, type ScreenshotEditorProps, UserzProvider, type UserzProviderProps, UserzTarget, type UserzTargetProps, useUserz };
89
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/provider.tsx","../src/ScreenshotEditor.tsx","../src/UserzTarget.tsx"],"mappings":";;;;;UAKiB,kBAAA,SAA2B,WAAA;EAC1C,QAAA,EAAU,SAAA;AAAA;AADZ;;;;;;AAAA,iBAUgB,aAAA,CAAA;EAAgB,QAAA;EAAA,GAAa;AAAA,GAAU,kBAAA,GAAkB,oBAAA,CAAA,GAAA,CAAA,OAAA;AAAA,iBAgCzD,QAAA,CAAA,GAAY,KAAA;;;;;;;AA1C5B;;;;;;;;;AAUA;;;;;;UCeiB,qBAAA;EDfwD;ECiBvE,IAAA,EAAM,IAAA;EDjBwB;ECmB9B,KAAA;EACA,MAAA;EDpBuE;ECsBvE,MAAA,GAAS,SAAA,EAAW,IAAA,YAAgB,OAAA;EACpC,QAAA;EDvBuE;ECyBvE,KAAA,GAAQ,aAAA;EACR,SAAA;;EAEA,UAAA;EDI+B;ECF/B,eAAA;AAAA;AAAA,iBAGc,gBAAA,CAAiB,KAAA,EAAO,qBAAA,GAAwB,SAAA;;;UCpC/C,gBAAA;;;EAGf,IAAA;EFVe;EEYf,IAAA,GAAO,MAAA;;;EAGP,QAAA,EAAU,SAAA;AAAA;;;;AFLZ;;;;;;;;iBEmBgB,WAAA,CAAA;EAAc,IAAA;EAAM,IAAA;EAAM;AAAA,GAAY,gBAAA,GAAgB,oBAAA,CAAA,GAAA,CAAA,OAAA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,317 @@
1
+ import { UZ_DEFAULT_BRAND, UZ_DEFAULT_BRAND_FG, createUserz } from "@userz-ai/browser";
2
+ import { Children, Suspense, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+
5
+ //#region src/provider.tsx
6
+ const UserzCtx = createContext(null);
7
+ /**
8
+ * React provider that owns one Userz instance for the lifetime of the
9
+ * component tree. Re-renders DON'T tear down the widget — config changes
10
+ * after the first render are ignored (same as Sentry/Datadog SDKs). Use
11
+ * `useUserz()` to call methods (setUser, submit, …) imperatively.
12
+ */
13
+ function UserzProvider({ children, ...config }) {
14
+ const instance = useRef(null);
15
+ if (instance.current === null && typeof document !== "undefined") instance.current = createUserz(config);
16
+ const initialUser = config.initialUser;
17
+ useEffect(() => {
18
+ if (initialUser !== void 0) instance.current?.setUser(initialUser);
19
+ }, [initialUser]);
20
+ useEffect(() => () => {
21
+ instance.current?.destroy();
22
+ instance.current = null;
23
+ }, []);
24
+ const value = useMemo(() => instance.current, []);
25
+ return /* @__PURE__ */ jsx(UserzCtx.Provider, {
26
+ value,
27
+ children
28
+ });
29
+ }
30
+ function useUserz() {
31
+ const u = useContext(UserzCtx);
32
+ if (!u) throw new Error("useUserz must be used inside <UserzProvider>");
33
+ return u;
34
+ }
35
+
36
+ //#endregion
37
+ //#region src/ScreenshotEditor.tsx
38
+ function ScreenshotEditor(props) {
39
+ return /* @__PURE__ */ jsx(Suspense, {
40
+ fallback: /* @__PURE__ */ jsx(EditorLoadingState, {}),
41
+ children: /* @__PURE__ */ jsx(LazyEditor, { ...props })
42
+ });
43
+ }
44
+ function LazyEditor(props) {
45
+ const [tldraw, setTldraw] = useState(null);
46
+ useEffect(() => {
47
+ let cancelled = false;
48
+ loadTldraw().then((mod) => {
49
+ if (!cancelled) setTldraw(mod ?? "missing");
50
+ }, () => {
51
+ if (!cancelled) setTldraw("missing");
52
+ });
53
+ return () => {
54
+ cancelled = true;
55
+ };
56
+ }, []);
57
+ if (tldraw === null) return /* @__PURE__ */ jsx(EditorLoadingState, {});
58
+ if (tldraw === "missing") return /* @__PURE__ */ jsx(MissingPeerFallback, {});
59
+ return /* @__PURE__ */ jsx(TldrawEditor, {
60
+ tldraw,
61
+ ...props
62
+ });
63
+ }
64
+ async function loadTldraw() {
65
+ try {
66
+ const mod = await import(["tld", "raw"].join(""));
67
+ if (!mod.Tldraw) return null;
68
+ return mod;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function TldrawEditor({ tldraw, blob, width, height, onSave, onCancel, style, className, brandColor = UZ_DEFAULT_BRAND, brandForeground = UZ_DEFAULT_BRAND_FG }) {
74
+ const [editor, setEditor] = useState(null);
75
+ const [saving, setSaving] = useState(false);
76
+ const onMount = useCallback(async (ed) => {
77
+ setEditor(ed);
78
+ const url = URL.createObjectURL(blob);
79
+ try {
80
+ const dims = width && height ? {
81
+ w: width,
82
+ h: height
83
+ } : await measureBlob(blob);
84
+ const assetId = tldraw.AssetRecordType.createId();
85
+ const shapeId = tldraw.createShapeId();
86
+ const editorAny = ed;
87
+ editorAny.createAssets([{
88
+ id: assetId,
89
+ type: "image",
90
+ typeName: "asset",
91
+ props: {
92
+ name: "screenshot.png",
93
+ src: url,
94
+ w: dims.w,
95
+ h: dims.h,
96
+ mimeType: blob.type || "image/png",
97
+ isAnimated: false
98
+ },
99
+ meta: {}
100
+ }]);
101
+ editorAny.createShape({
102
+ id: shapeId,
103
+ type: "image",
104
+ x: 0,
105
+ y: 0,
106
+ props: {
107
+ assetId,
108
+ w: dims.w,
109
+ h: dims.h
110
+ }
111
+ });
112
+ } catch {}
113
+ }, [
114
+ tldraw,
115
+ blob,
116
+ width,
117
+ height
118
+ ]);
119
+ const handleSave = useCallback(async () => {
120
+ if (!editor || saving) return;
121
+ setSaving(true);
122
+ try {
123
+ const editorAny = editor;
124
+ const ids = Array.from(editorAny.getCurrentPageShapeIds());
125
+ await onSave(await tldraw.exportAs(editor, ids, "png", { background: false }));
126
+ } finally {
127
+ setSaving(false);
128
+ }
129
+ }, [
130
+ editor,
131
+ saving,
132
+ tldraw,
133
+ onSave
134
+ ]);
135
+ const TldrawCmp = tldraw.Tldraw;
136
+ return /* @__PURE__ */ jsxs("div", {
137
+ className,
138
+ style: {
139
+ position: "relative",
140
+ width: "100%",
141
+ height: "60vh",
142
+ minHeight: 360,
143
+ ...style
144
+ },
145
+ children: [/* @__PURE__ */ jsx(TldrawCmp, { onMount }), /* @__PURE__ */ jsxs("div", {
146
+ style: {
147
+ position: "absolute",
148
+ right: 12,
149
+ bottom: 12,
150
+ display: "flex",
151
+ gap: 8,
152
+ zIndex: 1e3
153
+ },
154
+ children: [onCancel && /* @__PURE__ */ jsx("button", {
155
+ type: "button",
156
+ onClick: onCancel,
157
+ style: editorButtonStyle("ghost", brandColor, brandForeground),
158
+ disabled: saving,
159
+ children: "Cancel"
160
+ }), /* @__PURE__ */ jsx("button", {
161
+ type: "button",
162
+ onClick: handleSave,
163
+ style: editorButtonStyle("primary", brandColor, brandForeground),
164
+ disabled: saving || !editor,
165
+ children: saving ? "Saving…" : "Save annotation"
166
+ })]
167
+ })]
168
+ });
169
+ }
170
+ function EditorLoadingState() {
171
+ return /* @__PURE__ */ jsx("div", {
172
+ style: loadingStyle,
173
+ children: /* @__PURE__ */ jsx("span", { children: "Loading editor…" })
174
+ });
175
+ }
176
+ function MissingPeerFallback() {
177
+ return /* @__PURE__ */ jsx("div", {
178
+ style: loadingStyle,
179
+ children: /* @__PURE__ */ jsxs("div", {
180
+ style: {
181
+ maxWidth: 360,
182
+ textAlign: "center",
183
+ lineHeight: 1.45
184
+ },
185
+ children: [/* @__PURE__ */ jsx("strong", { children: "Screenshot annotation unavailable." }), /* @__PURE__ */ jsxs("div", {
186
+ style: {
187
+ marginTop: 8,
188
+ fontSize: 13,
189
+ opacity: .8
190
+ },
191
+ children: [
192
+ "Install ",
193
+ /* @__PURE__ */ jsx("code", { children: "tldraw" }),
194
+ " in your app to enable the in-widget editor:",
195
+ /* @__PURE__ */ jsx("pre", {
196
+ style: {
197
+ marginTop: 8,
198
+ padding: 8,
199
+ background: "rgba(0,0,0,0.05)",
200
+ borderRadius: 6,
201
+ fontSize: 12
202
+ },
203
+ children: "pnpm add tldraw"
204
+ })
205
+ ]
206
+ })]
207
+ })
208
+ });
209
+ }
210
+ const loadingStyle = {
211
+ display: "flex",
212
+ alignItems: "center",
213
+ justifyContent: "center",
214
+ width: "100%",
215
+ height: "60vh",
216
+ minHeight: 360,
217
+ border: "1px dashed rgba(0,0,0,0.15)",
218
+ borderRadius: 8,
219
+ fontSize: 14,
220
+ color: "#555"
221
+ };
222
+ function editorButtonStyle(kind, brandColor, brandForeground) {
223
+ if (kind === "primary") return {
224
+ padding: "8px 14px",
225
+ borderRadius: 6,
226
+ border: "none",
227
+ background: brandColor,
228
+ color: brandForeground,
229
+ fontWeight: 600,
230
+ cursor: "pointer",
231
+ boxShadow: "0 4px 12px rgba(0,0,0,0.18)"
232
+ };
233
+ return {
234
+ padding: "8px 14px",
235
+ borderRadius: 6,
236
+ border: "1px solid rgba(0,0,0,0.18)",
237
+ background: "white",
238
+ color: "#111",
239
+ cursor: "pointer"
240
+ };
241
+ }
242
+ async function measureBlob(blob) {
243
+ return new Promise((resolve, reject) => {
244
+ const img = new Image();
245
+ const url = URL.createObjectURL(blob);
246
+ img.onload = () => {
247
+ URL.revokeObjectURL(url);
248
+ resolve({
249
+ w: img.naturalWidth,
250
+ h: img.naturalHeight
251
+ });
252
+ };
253
+ img.onerror = (err) => {
254
+ URL.revokeObjectURL(url);
255
+ reject(err);
256
+ };
257
+ img.src = url;
258
+ });
259
+ }
260
+
261
+ //#endregion
262
+ //#region src/UserzTarget.tsx
263
+ /**
264
+ * Wraps a child element to mark it as a "feedback target" the end-user can
265
+ * click on while the targeting overlay is active (Ctrl+Shift+U by default).
266
+ *
267
+ * Implementation: clones the single child, attaches a ref, and registers/
268
+ * unregisters with the Userz instance over the child's lifecycle. We do NOT
269
+ * inject a wrapper div — that would break flex/grid layouts. As a result,
270
+ * the child must accept a `ref`; for most DOM elements and `forwardRef`
271
+ * components this is fine. For function components without forwardRef, we
272
+ * fall back to a `display: contents` wrapper.
273
+ */
274
+ function UserzTarget({ name, meta, children }) {
275
+ const u = useUserz();
276
+ const ref = useRef(null);
277
+ const stableMeta = useMemo(() => meta, [JSON.stringify(meta ?? null)]);
278
+ useEffect(() => {
279
+ const el = ref.current;
280
+ if (!el) return;
281
+ return u.registerTarget({
282
+ el,
283
+ name,
284
+ meta: stableMeta
285
+ });
286
+ }, [
287
+ u,
288
+ name,
289
+ stableMeta
290
+ ]);
291
+ const only = Children.only(children);
292
+ if (isValidElement(only) && (typeof only.type === "string" || hasForwardedRef(only))) {
293
+ const original = only.props.ref;
294
+ return cloneElement(only, { ref: composeRefs(ref, original) });
295
+ }
296
+ return /* @__PURE__ */ jsx("span", {
297
+ ref: (el) => {
298
+ ref.current = el;
299
+ },
300
+ style: { display: "contents" },
301
+ children: only
302
+ });
303
+ }
304
+ function hasForwardedRef(el) {
305
+ const t = el.type;
306
+ return typeof t === "object" && typeof t.$$typeof?.toString === "function";
307
+ }
308
+ function composeRefs(...refs) {
309
+ return (node) => {
310
+ for (const r of refs) if (typeof r === "function") r(node);
311
+ else if (r && typeof r === "object" && "current" in r) r.current = node;
312
+ };
313
+ }
314
+
315
+ //#endregion
316
+ export { ScreenshotEditor, UserzProvider, UserzTarget, useUserz };
317
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/provider.tsx","../src/ScreenshotEditor.tsx","../src/UserzTarget.tsx"],"sourcesContent":["import { createUserz, type Userz, type UserzConfig } from '@userz-ai/browser';\nimport { createContext, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react';\n\nconst UserzCtx = createContext<Userz | null>(null);\n\nexport interface UserzProviderProps extends UserzConfig {\n children: ReactNode;\n}\n\n/**\n * React provider that owns one Userz instance for the lifetime of the\n * component tree. Re-renders DON'T tear down the widget — config changes\n * after the first render are ignored (same as Sentry/Datadog SDKs). Use\n * `useUserz()` to call methods (setUser, submit, …) imperatively.\n */\nexport function UserzProvider({ children, ...config }: UserzProviderProps) {\n const instance = useRef<Userz | null>(null);\n\n // Initialize once, lazily — and only in the browser. Next.js, Remix, and\n // any other RSC/SSR framework will render this provider on the server to\n // emit the initial HTML; `createUserz` touches `document` immediately so\n // running it server-side crashes the render. The widget has nothing to do\n // during SSR anyway (no bubble to paint, no console to capture), so we\n // defer construction to the client pass.\n if (instance.current === null && typeof document !== 'undefined') {\n instance.current = createUserz(config);\n }\n\n // Pass through identity/metadata updates if the parent passes them via prop\n // changes after mount.\n const initialUser = config.initialUser;\n useEffect(() => {\n if (initialUser !== undefined) instance.current?.setUser(initialUser);\n }, [initialUser]);\n\n useEffect(\n () => () => {\n instance.current?.destroy();\n instance.current = null;\n },\n [],\n );\n\n const value = useMemo(() => instance.current, []);\n return <UserzCtx.Provider value={value}>{children}</UserzCtx.Provider>;\n}\n\nexport function useUserz(): Userz {\n const u = useContext(UserzCtx);\n if (!u) throw new Error('useUserz must be used inside <UserzProvider>');\n return u;\n}\n","import { UZ_DEFAULT_BRAND, UZ_DEFAULT_BRAND_FG } from '@userz-ai/browser';\nimport {\n type CSSProperties,\n type ReactNode,\n Suspense,\n useCallback,\n useEffect,\n useState,\n} from 'react';\n\n/**\n * Annotated screenshot editor.\n *\n * Wraps tldraw to give the end-user a quick \"circle the broken thing, type\n * an arrow, save\" workflow before submitting feedback. The tldraw bundle\n * (~600KB) is dynamic-imported on first mount so apps that never open the\n * editor don't pay for it; the component shows a loading state until ready.\n *\n * tldraw is declared as an OPTIONAL peer dep in package.json. Apps that\n * want this component MUST install `tldraw` themselves; without it the\n * component renders an instructive fallback instead of throwing.\n *\n * Usage:\n * <ScreenshotEditor\n * blob={blob}\n * onSave={(annotated) => userz.submit({ ..., attachments: [annotated] })}\n * onCancel={() => setOpen(false)}\n * />\n */\n\nexport interface ScreenshotEditorProps {\n /** PNG/JPEG blob to seed the canvas with. */\n blob: Blob;\n /** Optional dimensions override; default is the blob's natural size. */\n width?: number;\n height?: number;\n /** Receives the annotated PNG. */\n onSave: (annotated: Blob) => void | Promise<void>;\n onCancel?: () => void;\n /** Inline style override on the editor container. */\n style?: CSSProperties;\n className?: string;\n /** Save-button color. Defaults to the Userz brand mint. */\n brandColor?: string;\n /** Save-button text color. Defaults to brand foreground. */\n brandForeground?: string;\n}\n\nexport function ScreenshotEditor(props: ScreenshotEditorProps): ReactNode {\n return (\n <Suspense fallback={<EditorLoadingState />}>\n <LazyEditor {...props} />\n </Suspense>\n );\n}\n\nfunction LazyEditor(props: ScreenshotEditorProps) {\n const [tldraw, setTldraw] = useState<TldrawModule | null | 'missing'>(null);\n\n useEffect(() => {\n let cancelled = false;\n loadTldraw().then(\n (mod) => {\n if (!cancelled) setTldraw(mod ?? 'missing');\n },\n () => {\n if (!cancelled) setTldraw('missing');\n },\n );\n return () => {\n cancelled = true;\n };\n }, []);\n\n if (tldraw === null) return <EditorLoadingState />;\n if (tldraw === 'missing') return <MissingPeerFallback />;\n\n return <TldrawEditor tldraw={tldraw} {...props} />;\n}\n\ninterface TldrawModule {\n Tldraw: (props: Record<string, unknown>) => ReactNode;\n exportAs: (\n editor: unknown,\n shapeIds: unknown[],\n format: string,\n opts?: Record<string, unknown>,\n ) => Promise<Blob>;\n AssetRecordType: {\n createId(): string;\n };\n createShapeId(): string;\n}\n\nasync function loadTldraw(): Promise<TldrawModule | null> {\n try {\n // The string is split with a runtime expression so bundlers don't try\n // to follow the import; tldraw stays out of the build graph entirely\n // for apps that don't install it.\n const name = ['tld', 'raw'].join('');\n const mod = (await import(/* @vite-ignore */ name)) as Partial<TldrawModule>;\n if (!mod.Tldraw) return null;\n return mod as TldrawModule;\n } catch {\n return null;\n }\n}\n\ninterface TldrawEditorProps extends ScreenshotEditorProps {\n tldraw: TldrawModule;\n}\n\nfunction TldrawEditor({\n tldraw,\n blob,\n width,\n height,\n onSave,\n onCancel,\n style,\n className,\n brandColor = UZ_DEFAULT_BRAND,\n brandForeground = UZ_DEFAULT_BRAND_FG,\n}: TldrawEditorProps) {\n const [editor, setEditor] = useState<unknown>(null);\n const [saving, setSaving] = useState(false);\n\n const onMount = useCallback(\n async (ed: unknown) => {\n setEditor(ed);\n const url = URL.createObjectURL(blob);\n try {\n const dims = width && height ? { w: width, h: height } : await measureBlob(blob);\n const assetId = tldraw.AssetRecordType.createId();\n const shapeId = tldraw.createShapeId();\n const editorAny = ed as {\n createAssets: (assets: unknown[]) => void;\n createShape: (s: unknown) => void;\n };\n editorAny.createAssets([\n {\n id: assetId,\n type: 'image',\n typeName: 'asset',\n props: {\n name: 'screenshot.png',\n src: url,\n w: dims.w,\n h: dims.h,\n mimeType: blob.type || 'image/png',\n isAnimated: false,\n },\n meta: {},\n },\n ]);\n editorAny.createShape({\n id: shapeId,\n type: 'image',\n x: 0,\n y: 0,\n props: { assetId, w: dims.w, h: dims.h },\n });\n } catch {\n // best-effort seeding; user can still draw on a blank canvas\n }\n },\n [tldraw, blob, width, height],\n );\n\n const handleSave = useCallback(async () => {\n if (!editor || saving) return;\n setSaving(true);\n try {\n const editorAny = editor as { getCurrentPageShapeIds(): Set<unknown> };\n const ids = Array.from(editorAny.getCurrentPageShapeIds());\n const png = await tldraw.exportAs(editor, ids, 'png', { background: false });\n await onSave(png);\n } finally {\n setSaving(false);\n }\n }, [editor, saving, tldraw, onSave]);\n\n const TldrawCmp = tldraw.Tldraw as unknown as (p: {\n onMount: (ed: unknown) => void;\n persistenceKey?: string;\n }) => ReactNode;\n\n return (\n <div\n className={className}\n style={{\n position: 'relative',\n width: '100%',\n height: '60vh',\n minHeight: 360,\n ...style,\n }}\n >\n <TldrawCmp onMount={onMount} />\n <div\n style={{\n position: 'absolute',\n right: 12,\n bottom: 12,\n display: 'flex',\n gap: 8,\n zIndex: 1000,\n }}\n >\n {onCancel && (\n <button\n type=\"button\"\n onClick={onCancel}\n style={editorButtonStyle('ghost', brandColor, brandForeground)}\n disabled={saving}\n >\n Cancel\n </button>\n )}\n <button\n type=\"button\"\n onClick={handleSave}\n style={editorButtonStyle('primary', brandColor, brandForeground)}\n disabled={saving || !editor}\n >\n {saving ? 'Saving…' : 'Save annotation'}\n </button>\n </div>\n </div>\n );\n}\n\nfunction EditorLoadingState() {\n return (\n <div style={loadingStyle}>\n <span>Loading editor…</span>\n </div>\n );\n}\n\nfunction MissingPeerFallback() {\n return (\n <div style={loadingStyle}>\n <div style={{ maxWidth: 360, textAlign: 'center', lineHeight: 1.45 }}>\n <strong>Screenshot annotation unavailable.</strong>\n <div style={{ marginTop: 8, fontSize: 13, opacity: 0.8 }}>\n Install <code>tldraw</code> in your app to enable the in-widget editor:\n <pre\n style={{\n marginTop: 8,\n padding: 8,\n background: 'rgba(0,0,0,0.05)',\n borderRadius: 6,\n fontSize: 12,\n }}\n >\n pnpm add tldraw\n </pre>\n </div>\n </div>\n </div>\n );\n}\n\nconst loadingStyle: CSSProperties = {\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n width: '100%',\n height: '60vh',\n minHeight: 360,\n border: '1px dashed rgba(0,0,0,0.15)',\n borderRadius: 8,\n fontSize: 14,\n color: '#555',\n};\n\nfunction editorButtonStyle(\n kind: 'primary' | 'ghost',\n brandColor: string,\n brandForeground: string,\n): CSSProperties {\n if (kind === 'primary') {\n return {\n padding: '8px 14px',\n borderRadius: 6,\n border: 'none',\n background: brandColor,\n color: brandForeground,\n fontWeight: 600,\n cursor: 'pointer',\n boxShadow: '0 4px 12px rgba(0,0,0,0.18)',\n };\n }\n return {\n padding: '8px 14px',\n borderRadius: 6,\n border: '1px solid rgba(0,0,0,0.18)',\n background: 'white',\n color: '#111',\n cursor: 'pointer',\n };\n}\n\nasync function measureBlob(blob: Blob): Promise<{ w: number; h: number }> {\n return new Promise((resolve, reject) => {\n const img = new Image();\n const url = URL.createObjectURL(blob);\n img.onload = () => {\n URL.revokeObjectURL(url);\n resolve({ w: img.naturalWidth, h: img.naturalHeight });\n };\n img.onerror = (err) => {\n URL.revokeObjectURL(url);\n reject(err);\n };\n img.src = url;\n });\n}\n","import {\n Children,\n cloneElement,\n isValidElement,\n type ReactElement,\n type ReactNode,\n useEffect,\n useMemo,\n useRef,\n} from 'react';\nimport { useUserz } from './provider';\n\nexport interface UserzTargetProps {\n /** Stable display name for this target. Surfaces in feedback as the\n * \"component the user clicked on\" label. */\n name: string;\n /** Opt-in metadata you want forwarded with feedback. Keep small + non-sensitive. */\n meta?: Record<string, unknown>;\n /** A single child element (no fragments). We attach a ref to it without\n * a wrapper div so layout is unchanged. */\n children: ReactNode;\n}\n\n/**\n * Wraps a child element to mark it as a \"feedback target\" the end-user can\n * click on while the targeting overlay is active (Ctrl+Shift+U by default).\n *\n * Implementation: clones the single child, attaches a ref, and registers/\n * unregisters with the Userz instance over the child's lifecycle. We do NOT\n * inject a wrapper div — that would break flex/grid layouts. As a result,\n * the child must accept a `ref`; for most DOM elements and `forwardRef`\n * components this is fine. For function components without forwardRef, we\n * fall back to a `display: contents` wrapper.\n */\nexport function UserzTarget({ name, meta, children }: UserzTargetProps) {\n const u = useUserz();\n const ref = useRef<HTMLElement | null>(null);\n // Stable meta identity so the effect only re-runs when shape actually changes.\n // The dep is intentionally the JSON string, not `meta`, so callers passing a\n // fresh object literal each render don't thrash the registration.\n const metaJson = JSON.stringify(meta ?? null);\n // biome-ignore lint/correctness/useExhaustiveDependencies: see above\n const stableMeta = useMemo(() => meta, [metaJson]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n return u.registerTarget({ el, name, meta: stableMeta });\n }, [u, name, stableMeta]);\n\n const only = Children.only(children);\n if (isValidElement(only) && (typeof only.type === 'string' || hasForwardedRef(only))) {\n const original = (only.props as { ref?: unknown }).ref;\n return cloneElement(only as ReactElement<{ ref?: unknown }>, {\n ref: composeRefs(ref, original),\n });\n }\n // Function component without forwardRef — use a `display: contents` span\n // so we can attach a ref without affecting layout.\n return (\n <span\n ref={(el) => {\n ref.current = el;\n }}\n style={{ display: 'contents' }}\n >\n {only}\n </span>\n );\n}\n\nfunction hasForwardedRef(el: ReactElement): boolean {\n // Conservative heuristic — React.forwardRef components have a \"$$typeof\"\n // marker on their type. We can't import it portably across React versions,\n // so we check the symbol indirectly.\n const t = el.type as unknown as { $$typeof?: symbol };\n return typeof t === 'object' && typeof t.$$typeof?.toString === 'function';\n}\n\nfunction composeRefs<T>(...refs: unknown[]): (node: T) => void {\n return (node: T) => {\n for (const r of refs) {\n if (typeof r === 'function') (r as (n: T) => void)(node);\n else if (r && typeof r === 'object' && 'current' in r) {\n (r as { current: T }).current = node;\n }\n }\n };\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@userz-ai/react",
3
+ "version": "1.0.1",
4
+ "description": "React bindings for the Userz feedback widget.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.mjs",
8
+ "types": "./dist/index.d.mts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.mts",
12
+ "import": "./dist/index.mjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "sideEffects": false,
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "peerDependencies": {
24
+ "react": "^18.0.0 || ^19.0.0",
25
+ "tldraw": "^4.0.0",
26
+ "@userz-ai/browser": "0.2.1"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "tldraw": {
30
+ "optional": true
31
+ }
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^25.6.0",
35
+ "@types/react": "^19.2.14",
36
+ "react": "^19.2.5",
37
+ "tldraw": "^4.5.9",
38
+ "tsdown": "^0.21.9",
39
+ "typescript": "^6.0.3",
40
+ "@userz-ai/browser": "0.2.1"
41
+ },
42
+ "scripts": {
43
+ "build": "tsdown",
44
+ "dev": "tsdown --watch",
45
+ "typecheck": "tsc --noEmit",
46
+ "clean": "rm -rf dist *.tsbuildinfo"
47
+ }
48
+ }