@guideify/react 0.1.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/LICENSE ADDED
@@ -0,0 +1,122 @@
1
+ Guideify SDK License
2
+
3
+ Copyright (c) 2026 Guideify. All rights reserved.
4
+
5
+ This software is proprietary. It is published to the public npm registry so a
6
+ build tool can install it, not because it is open source. The permission you
7
+ have to use it is written below, and there is no other.
8
+
9
+
10
+ 1. Definitions
11
+
12
+ "Software" means this package and every file it publishes.
13
+
14
+ "Service" means the Guideify hosted product at https://guideify.in.
15
+
16
+ "You" means the person or organisation installing the Software, and any
17
+ organisation You install it on behalf of.
18
+
19
+ "Your Application" means a product You develop and operate that integrates
20
+ the Service.
21
+
22
+
23
+ 2. Grant
24
+
25
+ Subject to Your compliance with this license, Guideify grants You a
26
+ non-exclusive, worldwide, royalty-free, non-transferable, revocable license
27
+ to:
28
+
29
+ (a) install, copy and use the Software in order to integrate the Service
30
+ into Your Application;
31
+
32
+ (b) incorporate the Software into Your Application, including compiling,
33
+ bundling, minifying and otherwise transforming it as an ordinary build
34
+ step does; and
35
+
36
+ (c) distribute the Software as an incorporated part of Your Application to
37
+ Your users, including by serving it from Your own origin or from a
38
+ content delivery network You use.
39
+
40
+ Paragraph (c) is the point of the whole document. A browser SDK reaches its
41
+ audience only by being served from Your pages, and a proprietary license that
42
+ omitted it would forbid the one thing every user of this package must do.
43
+
44
+
45
+ 3. Conditions
46
+
47
+ (a) You may not distribute the Software on its own, or inside anything whose
48
+ purpose is to make the Software available to others. It travels inside
49
+ Your Application or not at all.
50
+
51
+ (b) You may not remove or alter a copyright, license or attribution notice in
52
+ the Software. A build step that strips comments from bundled output is
53
+ not a breach of this paragraph.
54
+
55
+ (c) You may not sublicense the Software except as an inseparable part of Your
56
+ Application, on terms no more permissive than these.
57
+
58
+
59
+ 4. Restrictions
60
+
61
+ (a) You may not modify the Software, or use it, to connect to or interoperate
62
+ with any service other than the Service.
63
+
64
+ (b) You may not use the Software, or knowledge gained from studying it, to
65
+ build a product that competes with the Service.
66
+
67
+ (c) You may not reverse engineer, decompile or disassemble the Software,
68
+ except where that restriction is prohibited by applicable law, or where
69
+ it is necessary to exercise a right applicable law grants You that cannot
70
+ be waived by contract.
71
+
72
+ Nothing in this section restricts what You may do with the Software's public
73
+ API, with its documented behaviour, or with the data the Service returns to
74
+ You about Your own product.
75
+
76
+
77
+ 5. Reservation of rights
78
+
79
+ All rights not expressly granted are reserved. The Software is licensed,
80
+ never sold, and this license transfers no ownership.
81
+
82
+
83
+ 6. Term and termination
84
+
85
+ This license takes effect when You install the Software. It ends when You
86
+ stop using the Service, or if You breach a term and do not remedy the breach
87
+ within thirty days of being told about it.
88
+
89
+ On termination You must stop distributing new copies of the Software. Copies
90
+ already embedded in versions of Your Application that You have already
91
+ distributed are unaffected, and You do not have to recall them.
92
+
93
+
94
+ 7. No warranty
95
+
96
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
97
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
98
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
99
+
100
+
101
+ 8. Limitation of liability
102
+
103
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE
104
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
105
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
106
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
107
+
108
+ Nothing here excludes a liability that applicable law does not permit to be
109
+ excluded.
110
+
111
+
112
+ 9. Relationship to the Terms of Service
113
+
114
+ The Guideify Terms of Service at https://guideify.in/terms govern the
115
+ Service. This license governs the Software.
116
+
117
+ It stands on its own: it is granted by the copyright holder directly, and it
118
+ does not depend on those terms being in force. Copyright exists without
119
+ incorporation, and so does a license over it.
120
+
121
+
122
+ Questions about this license: hello@guideify.in
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @guideify/react
2
+
3
+ Tier 3 of the three ways to install Guideify — BUILD_REPORT §6.3. Tier 1 is a
4
+ script tag and Tier 2 is `identify()`; this is for teams who would rather say
5
+ both in the component tree, plus the one thing neither tier offers:
6
+ `<GuideifyTarget>`.
7
+
8
+ ```bash
9
+ npm i @guideify/react
10
+ ```
11
+
12
+ ```tsx
13
+ import { GuideifyProvider, GuideifyTarget, useGuideify } from '@guideify/react';
14
+
15
+ <GuideifyProvider apiKey={process.env.NEXT_PUBLIC_GUIDEIFY_KEY}>
16
+ <App />
17
+ </GuideifyProvider>;
18
+
19
+ // anywhere below it
20
+ const { start, track, identify } = useGuideify();
21
+ ```
22
+
23
+ ## `<GuideifyTarget>`
24
+
25
+ ```tsx
26
+ <GuideifyTarget id="create-project">
27
+ <button onClick={create}>New project</button>
28
+ </GuideifyTarget>
29
+ ```
30
+
31
+ The paved path from §3.6, as one import. `data-guideify-id` is the resolver's
32
+ first tier: a step aimed at one never scores candidates, never lands on the
33
+ wrong element, and never drifts when the class names around it change.
34
+
35
+ It renders no element of its own — it clones the single child and adds the
36
+ attribute — so it cannot change your layout. Given anything else (several
37
+ children, a bare string) it renders them untouched rather than throwing.
38
+
39
+ ## What it does not bundle
40
+
41
+ **The core is not in this package.** The provider injects the loader tag and the
42
+ runtime arrives from the CDN, which is what keeps a canary (R-03) and the kill
43
+ switch (R-04) able to reach you. Every import from `@guideify/js` here is
44
+ `import type`, and `test/boundary.test.mjs` fails the build if that changes.
45
+
46
+ Two consequences worth knowing:
47
+
48
+ - **Already have the script tag?** Keep it. The provider never injects over an
49
+ existing `window.guideify`, so having both is the documented upgrade path
50
+ rather than a mistake — and it is why you do not get two cores.
51
+ - **Calls before the core lands are queued, not lost.** `track()` in a mount
52
+ effect needs no guard. The one exception is `on()`, which owes you an
53
+ unsubscribe immediately; this package holds the subscription and attaches it
54
+ on arrival, and unsubscribing first really does mean it is never attached.
55
+
56
+ ## Props
57
+
58
+ `GuideifyProvider` takes exactly what a script tag can carry, because the tag is
59
+ what initialises the core:
60
+
61
+ | Prop | |
62
+ |---|---|
63
+ | `apiKey` | required — your `pk_live_…` |
64
+ | `cdnHost` | self-hosted or CNAME'd bundle origin |
65
+ | `ingestHost` | analytics origin |
66
+ | `publicKey` | turns on config signature verification |
67
+ | `debug` | |
68
+ | `nonce` | CSP `nonce` for the injected tag — §6.4 |
69
+
70
+ `manualOnly`, `apiHost`, `dashboardOrigin` and `config` have no script-tag
71
+ equivalent, so they are deliberately absent from the type rather than accepted
72
+ and ignored. Use `@guideify/js` directly if you need them.
73
+
74
+ Next.js App Router users: see `@guideify/next`, which reads the CSP nonce for you.
75
+
76
+ ## License
77
+
78
+ Proprietary — see [LICENSE](./LICENSE). You may bundle this into your own
79
+ application and serve it to your users; you may not redistribute it on its own.
package/dist/index.cjs ADDED
@@ -0,0 +1,141 @@
1
+ 'use client';
2
+ 'use strict';
3
+
4
+ var react = require('react');
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+
7
+ // ../snippet/src/index.ts
8
+ var DEFAULT_CDN = "https://cdn.guideify.in";
9
+ function isCore(candidate) {
10
+ return typeof candidate?.on === "function";
11
+ }
12
+ function current() {
13
+ if (typeof window === "undefined") return void 0;
14
+ return window.guideify;
15
+ }
16
+ function coreReady() {
17
+ return isCore(current());
18
+ }
19
+ function installLoader(options) {
20
+ if (typeof document === "undefined") return null;
21
+ const existing = document.querySelector("script[data-guideify-key]");
22
+ if (current() || existing) return existing;
23
+ const cdn = options.cdnHost ?? DEFAULT_CDN;
24
+ const tag = document.createElement("script");
25
+ tag.async = true;
26
+ tag.src = `${cdn}/v1/loader.js`;
27
+ tag.setAttribute("data-guideify-key", options.apiKey);
28
+ if (options.cdnHost) tag.setAttribute("data-cdn", options.cdnHost);
29
+ if (options.ingestHost) tag.setAttribute("data-ingest", options.ingestHost);
30
+ if (options.publicKey) tag.setAttribute("data-public-key", options.publicKey);
31
+ if (options.debug) tag.setAttribute("data-debug", "");
32
+ if (options.nonce) tag.setAttribute("nonce", options.nonce);
33
+ (document.head || document.documentElement).appendChild(tag);
34
+ return tag;
35
+ }
36
+ var POLL_MS = 50;
37
+ var LOAD_TIMEOUT_MS = 15e3;
38
+ function whenCoreReady(tag, then) {
39
+ if (coreReady()) {
40
+ then();
41
+ return () => void 0;
42
+ }
43
+ let done = false;
44
+ const finish = () => {
45
+ if (done) return;
46
+ done = true;
47
+ stop();
48
+ then();
49
+ };
50
+ const onLoad = () => {
51
+ if (coreReady()) finish();
52
+ };
53
+ const started = Date.now();
54
+ const timer = setInterval(() => {
55
+ if (coreReady()) return finish();
56
+ if (Date.now() - started > LOAD_TIMEOUT_MS) stop();
57
+ }, POLL_MS);
58
+ function stop() {
59
+ clearInterval(timer);
60
+ tag?.removeEventListener("load", onLoad);
61
+ }
62
+ tag?.addEventListener("load", onLoad);
63
+ return stop;
64
+ }
65
+ function callCore(method, args) {
66
+ const target = current();
67
+ const fn = target?.[method];
68
+ if (typeof fn !== "function") return void 0;
69
+ try {
70
+ return fn.apply(target, args);
71
+ } catch {
72
+ return void 0;
73
+ }
74
+ }
75
+ var GuideifyContext = react.createContext(null);
76
+ var pending = /* @__PURE__ */ new Set();
77
+ function attach(event, listener) {
78
+ const off = callCore("on", [event, listener]);
79
+ return typeof off === "function" ? off : () => void 0;
80
+ }
81
+ function subscribe(event, listener) {
82
+ if (coreReady()) return attach(event, listener);
83
+ const intent = { event, listener, live: null, cancelled: false };
84
+ pending.add(intent);
85
+ return () => {
86
+ intent.cancelled = true;
87
+ pending.delete(intent);
88
+ intent.live?.();
89
+ };
90
+ }
91
+ function drainPendingSubscriptions() {
92
+ if (!coreReady()) return;
93
+ for (const intent of [...pending]) {
94
+ pending.delete(intent);
95
+ if (!intent.cancelled) intent.live = attach(intent.event, intent.listener);
96
+ }
97
+ }
98
+ function useGuideify() {
99
+ const context = react.useContext(GuideifyContext);
100
+ if (!context) {
101
+ throw new Error("useGuideify must be called inside a <GuideifyProvider>");
102
+ }
103
+ const { ready } = context;
104
+ return react.useMemo(
105
+ () => ({
106
+ start: (flowId) => void callCore("start", [flowId]),
107
+ stop: () => void callCore("stop", []),
108
+ track: (name, props) => void callCore("track", props === void 0 ? [name] : [name, props]),
109
+ identify: (userId, traits, opts) => void callCore("identify", [userId, traits ?? {}, opts]),
110
+ reset: () => void callCore("reset", []),
111
+ on: subscribe,
112
+ ready
113
+ }),
114
+ [ready]
115
+ );
116
+ }
117
+ function GuideifyProvider({ children, ...options }) {
118
+ const [ready, setReady] = react.useState(false);
119
+ const { apiKey, cdnHost, ingestHost, publicKey, debug, nonce } = options;
120
+ react.useEffect(() => {
121
+ if (!apiKey) return;
122
+ const tag = installLoader({ apiKey, cdnHost, ingestHost, publicKey, debug, nonce });
123
+ return whenCoreReady(tag, () => {
124
+ drainPendingSubscriptions();
125
+ setReady(true);
126
+ });
127
+ }, [apiKey, cdnHost, ingestHost, publicKey, debug, nonce]);
128
+ const value = react.useMemo(() => ({ apiKey, ready }), [apiKey, ready]);
129
+ return /* @__PURE__ */ jsxRuntime.jsx(GuideifyContext.Provider, { value, children });
130
+ }
131
+ function GuideifyTarget({ id, children }) {
132
+ if (!react.isValidElement(children)) return children;
133
+ const child = children;
134
+ if (child.props["data-guideify-id"] !== void 0) return child;
135
+ return react.cloneElement(child, { "data-guideify-id": id });
136
+ }
137
+
138
+ exports.DEFAULT_CDN = DEFAULT_CDN;
139
+ exports.GuideifyProvider = GuideifyProvider;
140
+ exports.GuideifyTarget = GuideifyTarget;
141
+ exports.useGuideify = useGuideify;
@@ -0,0 +1,413 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The published config contract.
5
+ *
6
+ * These types are the wire format between the publisher (which compiles them
7
+ * into an immutable CDN artifact) and the SDK (which parses it in the end
8
+ * user's browser). ARCHITECTURE §14: it must be impossible for those two to
9
+ * drift, which is why they live in one package imported by both.
10
+ *
11
+ * Changes here are BREAKING unless purely additive with a safe default. Bump
12
+ * `GuideifyConfig.version` and keep the SDK tolerant of unknown fields.
13
+ */
14
+ interface AncestorHint {
15
+ tag: string;
16
+ id?: string;
17
+ role?: string;
18
+ classes?: string[];
19
+ landmark?: string;
20
+ }
21
+ /**
22
+ * Multi-signal element identity. Deliberately records far more than is needed
23
+ * so the resolver can degrade gracefully as the host app changes.
24
+ * See BUILD_REPORT §3.2.
25
+ */
26
+ interface ElementFingerprint {
27
+ /** Tier 1: the host app opted in with data-guideify-id. Always wins. */
28
+ explicitId?: string;
29
+ testId?: string;
30
+ domId?: string;
31
+ name?: string;
32
+ ariaLabel?: string;
33
+ role?: string;
34
+ tag: string;
35
+ cssPath?: string;
36
+ stableClasses?: string[];
37
+ ancestors?: AncestorHint[];
38
+ siblingIndex?: number;
39
+ siblingCount?: number;
40
+ text?: string;
41
+ href?: string;
42
+ rectRatio?: {
43
+ x: number;
44
+ y: number;
45
+ w: number;
46
+ h: number;
47
+ };
48
+ captureViewport?: {
49
+ w: number;
50
+ h: number;
51
+ };
52
+ }
53
+ /**
54
+ * Content is a structured document, never an HTML string. The renderer walks
55
+ * this tree with createElement/textContent, which removes the injection class
56
+ * rather than filtering it. See BUILD_REPORT §9.3.
57
+ */
58
+ type Mark = {
59
+ type: 'bold';
60
+ } | {
61
+ type: 'italic';
62
+ } | {
63
+ type: 'code';
64
+ } | {
65
+ type: 'link';
66
+ href: string;
67
+ };
68
+ type ContentNode = {
69
+ type: 'text';
70
+ text: string;
71
+ marks?: Mark[];
72
+ } | {
73
+ type: 'paragraph';
74
+ content?: ContentNode[];
75
+ } | {
76
+ type: 'heading';
77
+ level?: 1 | 2 | 3;
78
+ content?: ContentNode[];
79
+ } | {
80
+ type: 'bulletList';
81
+ content?: ContentNode[];
82
+ } | {
83
+ type: 'orderedList';
84
+ content?: ContentNode[];
85
+ } | {
86
+ type: 'listItem';
87
+ content?: ContentNode[];
88
+ } | {
89
+ type: 'code';
90
+ text: string;
91
+ } | {
92
+ type: 'image';
93
+ src: string;
94
+ alt?: string;
95
+ } | {
96
+ type: 'doc';
97
+ content?: ContentNode[];
98
+ };
99
+ interface StepButton {
100
+ label: string;
101
+ action: 'next' | 'prev' | 'dismiss' | 'complete' | 'url';
102
+ href?: string;
103
+ variant?: 'primary' | 'secondary' | 'ghost';
104
+ }
105
+ interface StepContent {
106
+ title?: string;
107
+ body?: ContentNode;
108
+ media?: {
109
+ type: 'image';
110
+ src: string;
111
+ alt?: string;
112
+ };
113
+ buttons?: StepButton[];
114
+ }
115
+ type StepType = 'tooltip' | 'modal';
116
+ type Placement = 'auto' | 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end';
117
+ type AdvanceOn = {
118
+ type: 'next_button';
119
+ } | {
120
+ type: 'target_click';
121
+ } | {
122
+ type: 'url_match';
123
+ pattern: string;
124
+ } | {
125
+ type: 'event';
126
+ name: string;
127
+ } | {
128
+ type: 'element_appears';
129
+ target: ElementFingerprint;
130
+ };
131
+ /** What to do when a step's target cannot be resolved. */
132
+ type StepFallback = 'skip' | 'modal' | 'abort';
133
+ interface Step {
134
+ id: string;
135
+ type: StepType;
136
+ /** Absent for modal steps, which are not anchored to anything. */
137
+ target?: ElementFingerprint;
138
+ content: StepContent;
139
+ placement?: Placement;
140
+ advanceOn?: AdvanceOn;
141
+ /** Only show once the URL matches; otherwise wait for navigation. */
142
+ urlPattern?: string;
143
+ /** Let clicks through to the highlighted element. */
144
+ interactive?: boolean;
145
+ fallback?: StepFallback;
146
+ /** ms to wait for the target before giving up. */
147
+ timeout?: number;
148
+ /** Extra px of breathing room around the spotlight hole. */
149
+ padding?: number;
150
+ }
151
+ type RuleOperator = 'eq' | 'neq' | 'in' | 'not_in' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'starts_with' | 'matches_regex' | 'within_last' | 'before' | 'after' | 'exists' | 'not_exists';
152
+ interface Condition {
153
+ field: string;
154
+ operator: RuleOperator;
155
+ value?: unknown;
156
+ }
157
+ interface RuleGroup {
158
+ op: 'and' | 'or';
159
+ rules: RuleNode[];
160
+ }
161
+ type RuleNode = Condition | RuleGroup;
162
+ type Trigger = {
163
+ type: 'auto';
164
+ } | {
165
+ type: 'manual';
166
+ } | {
167
+ type: 'url';
168
+ pattern: string;
169
+ } | {
170
+ type: 'event';
171
+ name: string;
172
+ } | {
173
+ type: 'element_appears';
174
+ target: ElementFingerprint;
175
+ };
176
+ type Frequency = 'once' | 'session' | 'until_complete' | 'always';
177
+ interface Flow {
178
+ id: string;
179
+ name: string;
180
+ version: number;
181
+ trigger: Trigger;
182
+ audience?: RuleNode;
183
+ frequency: Frequency;
184
+ priority?: number;
185
+ /** Can the end user close this flow? Use false sparingly. */
186
+ dismissible?: boolean;
187
+ steps: Step[];
188
+ }
189
+ interface ThemeTokens {
190
+ accent?: string;
191
+ scrim?: string;
192
+ radius?: string;
193
+ fontFamily?: string;
194
+ }
195
+ interface ConfigSettings {
196
+ /** Max flows auto-started per session. Stops customers spamming their users. */
197
+ maxFlowsPerSession?: number;
198
+ maxFlowsPerDay?: number;
199
+ theme?: ThemeTokens;
200
+ }
201
+ interface GuideifyConfig {
202
+ version: number;
203
+ envKey: string;
204
+ flows: Flow[];
205
+ settings?: ConfigSettings;
206
+ }
207
+ interface UserTraits {
208
+ [key: string]: unknown;
209
+ }
210
+ interface InitOptions {
211
+ apiKey: string;
212
+ /** Origin serving the hash-addressed config blobs. */
213
+ cdnHost?: string;
214
+ /** Origin receiving batched analytics. */
215
+ ingestHost?: string;
216
+ /**
217
+ * Origin of the Guideify API. Reached only by design mode, to spend a nonce
218
+ * for a token — the end-user hot path never touches this service (§0).
219
+ */
220
+ apiHost?: string;
221
+ /**
222
+ * Origin of the Guideify dashboard, pinned on both halves of the design-mode
223
+ * handshake. Overridden only for self-hosted deployments and for the demo,
224
+ * and it is a *security* parameter: it decides which window may vouch for a
225
+ * nonce, so it is compiled in rather than read from the page.
226
+ */
227
+ dashboardOrigin?: string;
228
+ /**
229
+ * Base64 Ed25519 public key for this environment, from `GET /v1/envs/:id`.
230
+ *
231
+ * Supplying it turns on config verification: the pointer must be signed by the
232
+ * matching key and the artifact must hash to what that signature covers, or no
233
+ * flows run at all. It belongs in the snippet rather than in the config
234
+ * because the point is that it does not come from the bucket. §5.1.
235
+ */
236
+ publicKey?: string;
237
+ /** Bypass the network entirely and run against a literal config (tests, demos). */
238
+ config?: GuideifyConfig;
239
+ debug?: boolean;
240
+ /** Disable automatic flow evaluation; only `start()` will run anything. */
241
+ manualOnly?: boolean;
242
+ }
243
+ interface IdentifyOptions {
244
+ /** HMAC of the user id, proving the host's backend vouches for it. §9.2 */
245
+ userHash?: string;
246
+ }
247
+
248
+ /**
249
+ * @guideify/snippet — how the core gets onto the page, for every framework
250
+ * wrapper that has to do it. F-06.
251
+ *
252
+ * Extracted rather than copied into each wrapper, on D-259's precedent: two
253
+ * packages owning two copies of one contract is how the two come to disagree
254
+ * about it, and everything here is a contract with something else. The attribute
255
+ * names are the loader's, the default origin is the loader's, and the readiness
256
+ * test is a fact about the core. None of it is framework-shaped, which is why
257
+ * there is nothing to specialise per wrapper.
258
+ *
259
+ * Private and source-only, like `@guideify/shared`: consumers inline it, because
260
+ * a bare `@guideify/snippet` import left in a published `dist` is one an npm
261
+ * consumer could never resolve.
262
+ *
263
+ * **The wrapper injects the loader; it does not bundle the core.** npm-installing
264
+ * `@guideify/js` beside this package would be one line shorter and would pin every
265
+ * customer to whatever core they built against. Two release-readiness items
266
+ * depend on that not being true: R-03 canaries 1% of environments onto a new core
267
+ * hash, and R-04 is a kill switch that pins a per-environment version in Workers
268
+ * KV and expects it live within 60 seconds. Neither reaches a copy sitting in
269
+ * somebody's webpack output. The loader exists precisely so the core is
270
+ * "versioned separately so we can ship SDK updates without customers touching
271
+ * this line ever again", and a framework wrapper is not a reason to give that up.
272
+ *
273
+ * So every import from `@guideify/js` in this package is `import type`, and
274
+ * `test/boundary.test.mjs` is what keeps it that way.
275
+ */
276
+ /**
277
+ * The subset of `InitOptions` the pasted snippet can actually carry.
278
+ *
279
+ * Deliberately not all of it. `standalone.ts` auto-initialises from the script
280
+ * tag's data attributes, and these five are the ones it reads — so these are the
281
+ * five a tag can express. `manualOnly`, `apiHost`, `dashboardOrigin` and `config`
282
+ * have no attribute and are not silently dropped here: they are absent from the
283
+ * type, so asking for one is a compile error rather than a setting that appears
284
+ * to be applied and is not.
285
+ */
286
+ interface SnippetOptions extends Pick<InitOptions, 'apiKey'> {
287
+ cdnHost?: string | undefined;
288
+ ingestHost?: string | undefined;
289
+ publicKey?: string | undefined;
290
+ debug?: boolean | undefined;
291
+ /**
292
+ * CSP `nonce` for the injected tag — BUILD_REPORT §6.4.
293
+ *
294
+ * A page served with `script-src 'nonce-…'` refuses an injected script that
295
+ * does not carry it, and the failure is silent from the host app's point of
296
+ * view: no tours, no error anybody connects to us.
297
+ */
298
+ nonce?: string | undefined;
299
+ }
300
+ /** Where the loader lives when nothing says otherwise. Mirrors `loader.ts`. */
301
+ declare const DEFAULT_CDN = "https://cdn.guideify.in";
302
+
303
+ interface GuideifyProviderProps extends SnippetOptions {
304
+ children: ReactNode;
305
+ }
306
+ /**
307
+ * Mounts Guideify for a React tree — BUILD_REPORT §6.3.
308
+ *
309
+ * ```tsx
310
+ * <GuideifyProvider apiKey={process.env.NEXT_PUBLIC_GUIDEIFY_KEY}>
311
+ * <App />
312
+ * </GuideifyProvider>
313
+ * ```
314
+ *
315
+ * **Nothing touches `window` during render**, which is the Next.js App Router
316
+ * pitfall §6.4 names: the tag goes in from an effect, so a server render
317
+ * produces the same markup as the client's first pass and nothing hydrates
318
+ * mismatched.
319
+ *
320
+ * **This does not call `init`.** The loader's tag carries `data-guideify-key` and
321
+ * `standalone.ts` initialises from it on arrival, so a provider that also called
322
+ * `init` would be the second call and the core would warn and ignore it. The
323
+ * props are therefore exactly what a tag can express (`SnippetOptions`) rather
324
+ * than all of `InitOptions`.
325
+ *
326
+ * Mounted twice, the second one adds nothing: `installLoader` refuses to inject
327
+ * over an existing `window.guideify`, so a nested provider is a no-op rather than
328
+ * a second core.
329
+ */
330
+ declare function GuideifyProvider({ children, ...options }: GuideifyProviderProps): ReactNode;
331
+
332
+ interface GuideifyTargetProps {
333
+ /** The stable name a flow step aims at. Yours to choose; never changes. */
334
+ id: string;
335
+ children: ReactNode;
336
+ }
337
+ /**
338
+ * The paved path from BUILD_REPORT §3.6, as one import.
339
+ *
340
+ * ```tsx
341
+ * <GuideifyTarget id="create-project">
342
+ * <button onClick={create}>New project</button>
343
+ * </GuideifyTarget>
344
+ * ```
345
+ *
346
+ * An element marked this way resolves exactly and for free: `data-guideify-id` is
347
+ * the resolver's first tier, so it never scores candidates, never lands on the
348
+ * wrong one, and never drifts when the class names around it change. §3.6's
349
+ * finding is that the customers who do this do not churn over broken tours —
350
+ * which makes the cost of doing it the thing worth attacking, and one import
351
+ * beats a code review about a magic attribute.
352
+ *
353
+ * **Renders no element of its own.** It clones the single child and adds the
354
+ * attribute, because a wrapper `<div>` would change the host app's layout to
355
+ * suit our targeting — exactly the "zero impact on host app" §6.5 promises. The
356
+ * cost is the constraint: one child, and it has to be an element that puts its
357
+ * props on a DOM node.
358
+ *
359
+ * Given anything else — several children, a bare string, a fragment — it renders
360
+ * them untouched rather than throwing. A missing attribute costs a step its
361
+ * exact match and the resolver falls back to fingerprinting; an exception costs
362
+ * the customer their page.
363
+ */
364
+ declare function GuideifyTarget({ id, children }: GuideifyTargetProps): ReactNode;
365
+
366
+ /**
367
+ * The four public events the core emits. `PublicEvent` is module-private in
368
+ * `core/guideify.ts`, so it is restated rather than imported — and
369
+ * `test/surface.test.mjs` reads that file to prove the two lists still agree.
370
+ */
371
+ type GuideifyEvent = 'flow_started' | 'flow_completed' | 'flow_dismissed' | 'step_viewed';
372
+ type Unsubscribe = () => void;
373
+ interface GuideifyApi {
374
+ /** Starts one flow by id, regardless of its trigger or frequency rules. */
375
+ start(flowId: string): void;
376
+ /** Ends whatever is running. Safe when nothing is. */
377
+ stop(): void;
378
+ track(name: string, props?: Record<string, unknown>): void;
379
+ identify(userId: string, traits?: UserTraits, opts?: IdentifyOptions): void;
380
+ /** Forgets this browser's identity and progress — call it on sign-out. */
381
+ reset(): void;
382
+ /**
383
+ * Subscribes to a flow lifecycle event.
384
+ *
385
+ * The one method that cannot be queued: the loader's stub hands back nothing,
386
+ * and `on` owes its caller an unsubscribe immediately. So before the core
387
+ * arrives this registers the intent and returns a canceller that honours it —
388
+ * unsubscribing between mount and arrival really does mean the listener is
389
+ * never attached.
390
+ */
391
+ on(event: GuideifyEvent, listener: (payload: unknown) => void): Unsubscribe;
392
+ /** Whether the core has arrived. Calls made before it do not need this. */
393
+ ready: boolean;
394
+ }
395
+ /**
396
+ * The hook — BUILD_REPORT §6.3.
397
+ *
398
+ * ```tsx
399
+ * const { start, track, identify } = useGuideify();
400
+ * ```
401
+ *
402
+ * **Every method is safe to call before the core arrives.** The loader installs
403
+ * a queue for exactly this, so `track()` in a mount effect is not a race the
404
+ * caller has to think about — which is most of what this package is for.
405
+ *
406
+ * Throws when there is no provider above it, and that is the one thing here that
407
+ * does throw: it is a mistake in the component tree, it is the same on every
408
+ * render, and the alternative is a hook that silently does nothing for the life
409
+ * of the application.
410
+ */
411
+ declare function useGuideify(): GuideifyApi;
412
+
413
+ export { DEFAULT_CDN, type GuideifyApi, type GuideifyEvent, GuideifyProvider, type GuideifyProviderProps, GuideifyTarget, type GuideifyTargetProps, type SnippetOptions, type Unsubscribe, useGuideify };
@@ -0,0 +1,413 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The published config contract.
5
+ *
6
+ * These types are the wire format between the publisher (which compiles them
7
+ * into an immutable CDN artifact) and the SDK (which parses it in the end
8
+ * user's browser). ARCHITECTURE §14: it must be impossible for those two to
9
+ * drift, which is why they live in one package imported by both.
10
+ *
11
+ * Changes here are BREAKING unless purely additive with a safe default. Bump
12
+ * `GuideifyConfig.version` and keep the SDK tolerant of unknown fields.
13
+ */
14
+ interface AncestorHint {
15
+ tag: string;
16
+ id?: string;
17
+ role?: string;
18
+ classes?: string[];
19
+ landmark?: string;
20
+ }
21
+ /**
22
+ * Multi-signal element identity. Deliberately records far more than is needed
23
+ * so the resolver can degrade gracefully as the host app changes.
24
+ * See BUILD_REPORT §3.2.
25
+ */
26
+ interface ElementFingerprint {
27
+ /** Tier 1: the host app opted in with data-guideify-id. Always wins. */
28
+ explicitId?: string;
29
+ testId?: string;
30
+ domId?: string;
31
+ name?: string;
32
+ ariaLabel?: string;
33
+ role?: string;
34
+ tag: string;
35
+ cssPath?: string;
36
+ stableClasses?: string[];
37
+ ancestors?: AncestorHint[];
38
+ siblingIndex?: number;
39
+ siblingCount?: number;
40
+ text?: string;
41
+ href?: string;
42
+ rectRatio?: {
43
+ x: number;
44
+ y: number;
45
+ w: number;
46
+ h: number;
47
+ };
48
+ captureViewport?: {
49
+ w: number;
50
+ h: number;
51
+ };
52
+ }
53
+ /**
54
+ * Content is a structured document, never an HTML string. The renderer walks
55
+ * this tree with createElement/textContent, which removes the injection class
56
+ * rather than filtering it. See BUILD_REPORT §9.3.
57
+ */
58
+ type Mark = {
59
+ type: 'bold';
60
+ } | {
61
+ type: 'italic';
62
+ } | {
63
+ type: 'code';
64
+ } | {
65
+ type: 'link';
66
+ href: string;
67
+ };
68
+ type ContentNode = {
69
+ type: 'text';
70
+ text: string;
71
+ marks?: Mark[];
72
+ } | {
73
+ type: 'paragraph';
74
+ content?: ContentNode[];
75
+ } | {
76
+ type: 'heading';
77
+ level?: 1 | 2 | 3;
78
+ content?: ContentNode[];
79
+ } | {
80
+ type: 'bulletList';
81
+ content?: ContentNode[];
82
+ } | {
83
+ type: 'orderedList';
84
+ content?: ContentNode[];
85
+ } | {
86
+ type: 'listItem';
87
+ content?: ContentNode[];
88
+ } | {
89
+ type: 'code';
90
+ text: string;
91
+ } | {
92
+ type: 'image';
93
+ src: string;
94
+ alt?: string;
95
+ } | {
96
+ type: 'doc';
97
+ content?: ContentNode[];
98
+ };
99
+ interface StepButton {
100
+ label: string;
101
+ action: 'next' | 'prev' | 'dismiss' | 'complete' | 'url';
102
+ href?: string;
103
+ variant?: 'primary' | 'secondary' | 'ghost';
104
+ }
105
+ interface StepContent {
106
+ title?: string;
107
+ body?: ContentNode;
108
+ media?: {
109
+ type: 'image';
110
+ src: string;
111
+ alt?: string;
112
+ };
113
+ buttons?: StepButton[];
114
+ }
115
+ type StepType = 'tooltip' | 'modal';
116
+ type Placement = 'auto' | 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end';
117
+ type AdvanceOn = {
118
+ type: 'next_button';
119
+ } | {
120
+ type: 'target_click';
121
+ } | {
122
+ type: 'url_match';
123
+ pattern: string;
124
+ } | {
125
+ type: 'event';
126
+ name: string;
127
+ } | {
128
+ type: 'element_appears';
129
+ target: ElementFingerprint;
130
+ };
131
+ /** What to do when a step's target cannot be resolved. */
132
+ type StepFallback = 'skip' | 'modal' | 'abort';
133
+ interface Step {
134
+ id: string;
135
+ type: StepType;
136
+ /** Absent for modal steps, which are not anchored to anything. */
137
+ target?: ElementFingerprint;
138
+ content: StepContent;
139
+ placement?: Placement;
140
+ advanceOn?: AdvanceOn;
141
+ /** Only show once the URL matches; otherwise wait for navigation. */
142
+ urlPattern?: string;
143
+ /** Let clicks through to the highlighted element. */
144
+ interactive?: boolean;
145
+ fallback?: StepFallback;
146
+ /** ms to wait for the target before giving up. */
147
+ timeout?: number;
148
+ /** Extra px of breathing room around the spotlight hole. */
149
+ padding?: number;
150
+ }
151
+ type RuleOperator = 'eq' | 'neq' | 'in' | 'not_in' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'starts_with' | 'matches_regex' | 'within_last' | 'before' | 'after' | 'exists' | 'not_exists';
152
+ interface Condition {
153
+ field: string;
154
+ operator: RuleOperator;
155
+ value?: unknown;
156
+ }
157
+ interface RuleGroup {
158
+ op: 'and' | 'or';
159
+ rules: RuleNode[];
160
+ }
161
+ type RuleNode = Condition | RuleGroup;
162
+ type Trigger = {
163
+ type: 'auto';
164
+ } | {
165
+ type: 'manual';
166
+ } | {
167
+ type: 'url';
168
+ pattern: string;
169
+ } | {
170
+ type: 'event';
171
+ name: string;
172
+ } | {
173
+ type: 'element_appears';
174
+ target: ElementFingerprint;
175
+ };
176
+ type Frequency = 'once' | 'session' | 'until_complete' | 'always';
177
+ interface Flow {
178
+ id: string;
179
+ name: string;
180
+ version: number;
181
+ trigger: Trigger;
182
+ audience?: RuleNode;
183
+ frequency: Frequency;
184
+ priority?: number;
185
+ /** Can the end user close this flow? Use false sparingly. */
186
+ dismissible?: boolean;
187
+ steps: Step[];
188
+ }
189
+ interface ThemeTokens {
190
+ accent?: string;
191
+ scrim?: string;
192
+ radius?: string;
193
+ fontFamily?: string;
194
+ }
195
+ interface ConfigSettings {
196
+ /** Max flows auto-started per session. Stops customers spamming their users. */
197
+ maxFlowsPerSession?: number;
198
+ maxFlowsPerDay?: number;
199
+ theme?: ThemeTokens;
200
+ }
201
+ interface GuideifyConfig {
202
+ version: number;
203
+ envKey: string;
204
+ flows: Flow[];
205
+ settings?: ConfigSettings;
206
+ }
207
+ interface UserTraits {
208
+ [key: string]: unknown;
209
+ }
210
+ interface InitOptions {
211
+ apiKey: string;
212
+ /** Origin serving the hash-addressed config blobs. */
213
+ cdnHost?: string;
214
+ /** Origin receiving batched analytics. */
215
+ ingestHost?: string;
216
+ /**
217
+ * Origin of the Guideify API. Reached only by design mode, to spend a nonce
218
+ * for a token — the end-user hot path never touches this service (§0).
219
+ */
220
+ apiHost?: string;
221
+ /**
222
+ * Origin of the Guideify dashboard, pinned on both halves of the design-mode
223
+ * handshake. Overridden only for self-hosted deployments and for the demo,
224
+ * and it is a *security* parameter: it decides which window may vouch for a
225
+ * nonce, so it is compiled in rather than read from the page.
226
+ */
227
+ dashboardOrigin?: string;
228
+ /**
229
+ * Base64 Ed25519 public key for this environment, from `GET /v1/envs/:id`.
230
+ *
231
+ * Supplying it turns on config verification: the pointer must be signed by the
232
+ * matching key and the artifact must hash to what that signature covers, or no
233
+ * flows run at all. It belongs in the snippet rather than in the config
234
+ * because the point is that it does not come from the bucket. §5.1.
235
+ */
236
+ publicKey?: string;
237
+ /** Bypass the network entirely and run against a literal config (tests, demos). */
238
+ config?: GuideifyConfig;
239
+ debug?: boolean;
240
+ /** Disable automatic flow evaluation; only `start()` will run anything. */
241
+ manualOnly?: boolean;
242
+ }
243
+ interface IdentifyOptions {
244
+ /** HMAC of the user id, proving the host's backend vouches for it. §9.2 */
245
+ userHash?: string;
246
+ }
247
+
248
+ /**
249
+ * @guideify/snippet — how the core gets onto the page, for every framework
250
+ * wrapper that has to do it. F-06.
251
+ *
252
+ * Extracted rather than copied into each wrapper, on D-259's precedent: two
253
+ * packages owning two copies of one contract is how the two come to disagree
254
+ * about it, and everything here is a contract with something else. The attribute
255
+ * names are the loader's, the default origin is the loader's, and the readiness
256
+ * test is a fact about the core. None of it is framework-shaped, which is why
257
+ * there is nothing to specialise per wrapper.
258
+ *
259
+ * Private and source-only, like `@guideify/shared`: consumers inline it, because
260
+ * a bare `@guideify/snippet` import left in a published `dist` is one an npm
261
+ * consumer could never resolve.
262
+ *
263
+ * **The wrapper injects the loader; it does not bundle the core.** npm-installing
264
+ * `@guideify/js` beside this package would be one line shorter and would pin every
265
+ * customer to whatever core they built against. Two release-readiness items
266
+ * depend on that not being true: R-03 canaries 1% of environments onto a new core
267
+ * hash, and R-04 is a kill switch that pins a per-environment version in Workers
268
+ * KV and expects it live within 60 seconds. Neither reaches a copy sitting in
269
+ * somebody's webpack output. The loader exists precisely so the core is
270
+ * "versioned separately so we can ship SDK updates without customers touching
271
+ * this line ever again", and a framework wrapper is not a reason to give that up.
272
+ *
273
+ * So every import from `@guideify/js` in this package is `import type`, and
274
+ * `test/boundary.test.mjs` is what keeps it that way.
275
+ */
276
+ /**
277
+ * The subset of `InitOptions` the pasted snippet can actually carry.
278
+ *
279
+ * Deliberately not all of it. `standalone.ts` auto-initialises from the script
280
+ * tag's data attributes, and these five are the ones it reads — so these are the
281
+ * five a tag can express. `manualOnly`, `apiHost`, `dashboardOrigin` and `config`
282
+ * have no attribute and are not silently dropped here: they are absent from the
283
+ * type, so asking for one is a compile error rather than a setting that appears
284
+ * to be applied and is not.
285
+ */
286
+ interface SnippetOptions extends Pick<InitOptions, 'apiKey'> {
287
+ cdnHost?: string | undefined;
288
+ ingestHost?: string | undefined;
289
+ publicKey?: string | undefined;
290
+ debug?: boolean | undefined;
291
+ /**
292
+ * CSP `nonce` for the injected tag — BUILD_REPORT §6.4.
293
+ *
294
+ * A page served with `script-src 'nonce-…'` refuses an injected script that
295
+ * does not carry it, and the failure is silent from the host app's point of
296
+ * view: no tours, no error anybody connects to us.
297
+ */
298
+ nonce?: string | undefined;
299
+ }
300
+ /** Where the loader lives when nothing says otherwise. Mirrors `loader.ts`. */
301
+ declare const DEFAULT_CDN = "https://cdn.guideify.in";
302
+
303
+ interface GuideifyProviderProps extends SnippetOptions {
304
+ children: ReactNode;
305
+ }
306
+ /**
307
+ * Mounts Guideify for a React tree — BUILD_REPORT §6.3.
308
+ *
309
+ * ```tsx
310
+ * <GuideifyProvider apiKey={process.env.NEXT_PUBLIC_GUIDEIFY_KEY}>
311
+ * <App />
312
+ * </GuideifyProvider>
313
+ * ```
314
+ *
315
+ * **Nothing touches `window` during render**, which is the Next.js App Router
316
+ * pitfall §6.4 names: the tag goes in from an effect, so a server render
317
+ * produces the same markup as the client's first pass and nothing hydrates
318
+ * mismatched.
319
+ *
320
+ * **This does not call `init`.** The loader's tag carries `data-guideify-key` and
321
+ * `standalone.ts` initialises from it on arrival, so a provider that also called
322
+ * `init` would be the second call and the core would warn and ignore it. The
323
+ * props are therefore exactly what a tag can express (`SnippetOptions`) rather
324
+ * than all of `InitOptions`.
325
+ *
326
+ * Mounted twice, the second one adds nothing: `installLoader` refuses to inject
327
+ * over an existing `window.guideify`, so a nested provider is a no-op rather than
328
+ * a second core.
329
+ */
330
+ declare function GuideifyProvider({ children, ...options }: GuideifyProviderProps): ReactNode;
331
+
332
+ interface GuideifyTargetProps {
333
+ /** The stable name a flow step aims at. Yours to choose; never changes. */
334
+ id: string;
335
+ children: ReactNode;
336
+ }
337
+ /**
338
+ * The paved path from BUILD_REPORT §3.6, as one import.
339
+ *
340
+ * ```tsx
341
+ * <GuideifyTarget id="create-project">
342
+ * <button onClick={create}>New project</button>
343
+ * </GuideifyTarget>
344
+ * ```
345
+ *
346
+ * An element marked this way resolves exactly and for free: `data-guideify-id` is
347
+ * the resolver's first tier, so it never scores candidates, never lands on the
348
+ * wrong one, and never drifts when the class names around it change. §3.6's
349
+ * finding is that the customers who do this do not churn over broken tours —
350
+ * which makes the cost of doing it the thing worth attacking, and one import
351
+ * beats a code review about a magic attribute.
352
+ *
353
+ * **Renders no element of its own.** It clones the single child and adds the
354
+ * attribute, because a wrapper `<div>` would change the host app's layout to
355
+ * suit our targeting — exactly the "zero impact on host app" §6.5 promises. The
356
+ * cost is the constraint: one child, and it has to be an element that puts its
357
+ * props on a DOM node.
358
+ *
359
+ * Given anything else — several children, a bare string, a fragment — it renders
360
+ * them untouched rather than throwing. A missing attribute costs a step its
361
+ * exact match and the resolver falls back to fingerprinting; an exception costs
362
+ * the customer their page.
363
+ */
364
+ declare function GuideifyTarget({ id, children }: GuideifyTargetProps): ReactNode;
365
+
366
+ /**
367
+ * The four public events the core emits. `PublicEvent` is module-private in
368
+ * `core/guideify.ts`, so it is restated rather than imported — and
369
+ * `test/surface.test.mjs` reads that file to prove the two lists still agree.
370
+ */
371
+ type GuideifyEvent = 'flow_started' | 'flow_completed' | 'flow_dismissed' | 'step_viewed';
372
+ type Unsubscribe = () => void;
373
+ interface GuideifyApi {
374
+ /** Starts one flow by id, regardless of its trigger or frequency rules. */
375
+ start(flowId: string): void;
376
+ /** Ends whatever is running. Safe when nothing is. */
377
+ stop(): void;
378
+ track(name: string, props?: Record<string, unknown>): void;
379
+ identify(userId: string, traits?: UserTraits, opts?: IdentifyOptions): void;
380
+ /** Forgets this browser's identity and progress — call it on sign-out. */
381
+ reset(): void;
382
+ /**
383
+ * Subscribes to a flow lifecycle event.
384
+ *
385
+ * The one method that cannot be queued: the loader's stub hands back nothing,
386
+ * and `on` owes its caller an unsubscribe immediately. So before the core
387
+ * arrives this registers the intent and returns a canceller that honours it —
388
+ * unsubscribing between mount and arrival really does mean the listener is
389
+ * never attached.
390
+ */
391
+ on(event: GuideifyEvent, listener: (payload: unknown) => void): Unsubscribe;
392
+ /** Whether the core has arrived. Calls made before it do not need this. */
393
+ ready: boolean;
394
+ }
395
+ /**
396
+ * The hook — BUILD_REPORT §6.3.
397
+ *
398
+ * ```tsx
399
+ * const { start, track, identify } = useGuideify();
400
+ * ```
401
+ *
402
+ * **Every method is safe to call before the core arrives.** The loader installs
403
+ * a queue for exactly this, so `track()` in a mount effect is not a race the
404
+ * caller has to think about — which is most of what this package is for.
405
+ *
406
+ * Throws when there is no provider above it, and that is the one thing here that
407
+ * does throw: it is a mistake in the component tree, it is the same on every
408
+ * render, and the alternative is a hook that silently does nothing for the life
409
+ * of the application.
410
+ */
411
+ declare function useGuideify(): GuideifyApi;
412
+
413
+ export { DEFAULT_CDN, type GuideifyApi, type GuideifyEvent, GuideifyProvider, type GuideifyProviderProps, GuideifyTarget, type GuideifyTargetProps, type SnippetOptions, type Unsubscribe, useGuideify };
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ 'use client';
2
+ import { createContext, useContext, useMemo, useState, useEffect, isValidElement, cloneElement } from 'react';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ // ../snippet/src/index.ts
6
+ var DEFAULT_CDN = "https://cdn.guideify.in";
7
+ function isCore(candidate) {
8
+ return typeof candidate?.on === "function";
9
+ }
10
+ function current() {
11
+ if (typeof window === "undefined") return void 0;
12
+ return window.guideify;
13
+ }
14
+ function coreReady() {
15
+ return isCore(current());
16
+ }
17
+ function installLoader(options) {
18
+ if (typeof document === "undefined") return null;
19
+ const existing = document.querySelector("script[data-guideify-key]");
20
+ if (current() || existing) return existing;
21
+ const cdn = options.cdnHost ?? DEFAULT_CDN;
22
+ const tag = document.createElement("script");
23
+ tag.async = true;
24
+ tag.src = `${cdn}/v1/loader.js`;
25
+ tag.setAttribute("data-guideify-key", options.apiKey);
26
+ if (options.cdnHost) tag.setAttribute("data-cdn", options.cdnHost);
27
+ if (options.ingestHost) tag.setAttribute("data-ingest", options.ingestHost);
28
+ if (options.publicKey) tag.setAttribute("data-public-key", options.publicKey);
29
+ if (options.debug) tag.setAttribute("data-debug", "");
30
+ if (options.nonce) tag.setAttribute("nonce", options.nonce);
31
+ (document.head || document.documentElement).appendChild(tag);
32
+ return tag;
33
+ }
34
+ var POLL_MS = 50;
35
+ var LOAD_TIMEOUT_MS = 15e3;
36
+ function whenCoreReady(tag, then) {
37
+ if (coreReady()) {
38
+ then();
39
+ return () => void 0;
40
+ }
41
+ let done = false;
42
+ const finish = () => {
43
+ if (done) return;
44
+ done = true;
45
+ stop();
46
+ then();
47
+ };
48
+ const onLoad = () => {
49
+ if (coreReady()) finish();
50
+ };
51
+ const started = Date.now();
52
+ const timer = setInterval(() => {
53
+ if (coreReady()) return finish();
54
+ if (Date.now() - started > LOAD_TIMEOUT_MS) stop();
55
+ }, POLL_MS);
56
+ function stop() {
57
+ clearInterval(timer);
58
+ tag?.removeEventListener("load", onLoad);
59
+ }
60
+ tag?.addEventListener("load", onLoad);
61
+ return stop;
62
+ }
63
+ function callCore(method, args) {
64
+ const target = current();
65
+ const fn = target?.[method];
66
+ if (typeof fn !== "function") return void 0;
67
+ try {
68
+ return fn.apply(target, args);
69
+ } catch {
70
+ return void 0;
71
+ }
72
+ }
73
+ var GuideifyContext = createContext(null);
74
+ var pending = /* @__PURE__ */ new Set();
75
+ function attach(event, listener) {
76
+ const off = callCore("on", [event, listener]);
77
+ return typeof off === "function" ? off : () => void 0;
78
+ }
79
+ function subscribe(event, listener) {
80
+ if (coreReady()) return attach(event, listener);
81
+ const intent = { event, listener, live: null, cancelled: false };
82
+ pending.add(intent);
83
+ return () => {
84
+ intent.cancelled = true;
85
+ pending.delete(intent);
86
+ intent.live?.();
87
+ };
88
+ }
89
+ function drainPendingSubscriptions() {
90
+ if (!coreReady()) return;
91
+ for (const intent of [...pending]) {
92
+ pending.delete(intent);
93
+ if (!intent.cancelled) intent.live = attach(intent.event, intent.listener);
94
+ }
95
+ }
96
+ function useGuideify() {
97
+ const context = useContext(GuideifyContext);
98
+ if (!context) {
99
+ throw new Error("useGuideify must be called inside a <GuideifyProvider>");
100
+ }
101
+ const { ready } = context;
102
+ return useMemo(
103
+ () => ({
104
+ start: (flowId) => void callCore("start", [flowId]),
105
+ stop: () => void callCore("stop", []),
106
+ track: (name, props) => void callCore("track", props === void 0 ? [name] : [name, props]),
107
+ identify: (userId, traits, opts) => void callCore("identify", [userId, traits ?? {}, opts]),
108
+ reset: () => void callCore("reset", []),
109
+ on: subscribe,
110
+ ready
111
+ }),
112
+ [ready]
113
+ );
114
+ }
115
+ function GuideifyProvider({ children, ...options }) {
116
+ const [ready, setReady] = useState(false);
117
+ const { apiKey, cdnHost, ingestHost, publicKey, debug, nonce } = options;
118
+ useEffect(() => {
119
+ if (!apiKey) return;
120
+ const tag = installLoader({ apiKey, cdnHost, ingestHost, publicKey, debug, nonce });
121
+ return whenCoreReady(tag, () => {
122
+ drainPendingSubscriptions();
123
+ setReady(true);
124
+ });
125
+ }, [apiKey, cdnHost, ingestHost, publicKey, debug, nonce]);
126
+ const value = useMemo(() => ({ apiKey, ready }), [apiKey, ready]);
127
+ return /* @__PURE__ */ jsx(GuideifyContext.Provider, { value, children });
128
+ }
129
+ function GuideifyTarget({ id, children }) {
130
+ if (!isValidElement(children)) return children;
131
+ const child = children;
132
+ if (child.props["data-guideify-id"] !== void 0) return child;
133
+ return cloneElement(child, { "data-guideify-id": id });
134
+ }
135
+
136
+ export { DEFAULT_CDN, GuideifyProvider, GuideifyTarget, useGuideify };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@guideify/react",
3
+ "version": "0.1.0",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "dev": "tsup --watch",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "node --test \"test/*.test.mjs\"",
24
+ "verify": "npm run typecheck && npm run build && npm run test",
25
+ "verify:package": "node ../../scripts/verify-package.mjs react",
26
+ "prepublishOnly": "npm run verify && npm run verify:package"
27
+ },
28
+ "peerDependencies": {
29
+ "react": ">=18"
30
+ },
31
+ "devDependencies": {
32
+ "@guideify/js": "*",
33
+ "@guideify/snippet": "*",
34
+ "@types/react": "^19.2.0",
35
+ "jsdom": "^26.1.0",
36
+ "react": "^19.2.8",
37
+ "react-dom": "^19.2.8",
38
+ "tsup": "^8.5.0",
39
+ "typescript": "^5.7.3"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/vikasOzark/guidely.git",
47
+ "directory": "packages/react"
48
+ }
49
+ }