@silverassist/leadcapture-form 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/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - Unreleased
6
+
7
+ ### Added
8
+
9
+ - Initial extraction of `LeadCaptureForm` from the fleet's `ScriptManager`
10
+ implementations, ported onto `@silverassist/next-script-loader`.
11
+
12
+ ### Changed
13
+
14
+ - The per-variant `ScriptManager` (independent state per variant) was
15
+ replaced by `ScriptLoader`'s single-active-variant model. Ownership
16
+ arbitration and ref-counted load/unload now come from `ScriptLoader`
17
+ directly; only the generation-based stale-unmount guard (needed because
18
+ `reload()` doesn't change the reference count) stays as package-local
19
+ logic on top of it. See the README's "Known behavioral difference" note.
package/LICENSE ADDED
@@ -0,0 +1,131 @@
1
+ # Polyform Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ ## Acceptance
6
+
7
+ In order to get any license under these terms, you must agree
8
+ to them as both strict obligations and conditions to all
9
+ your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the
14
+ software to do everything you might do with the software
15
+ that would otherwise infringe the licensor's copyright
16
+ in it for any permitted purpose. However, you may
17
+ only distribute the software according to [Distribution
18
+ License](#distribution-license) and make changes or new works
19
+ based on the software according to [Changes and New Works
20
+ License](#changes-and-new-works-license).
21
+
22
+ ## Distribution License
23
+
24
+ The licensor grants you an additional copyright license
25
+ to distribute copies of the software. Your license
26
+ to distribute covers distributing the software with
27
+ changes and new works permitted by [Changes and New Works
28
+ License](#changes-and-new-works-license).
29
+
30
+ ## Notices
31
+
32
+ You must ensure that anyone who gets a copy of any part of
33
+ the software from you also gets a copy of these terms or the
34
+ URL for them above, as well as copies of any plain-text lines
35
+ beginning with `Required Notice:` that the licensor provided
36
+ with the software. For example:
37
+
38
+ > Required Notice: Copyright Silver Assist Team
39
+
40
+ ## Changes and New Works License
41
+
42
+ The licensor grants you an additional copyright license to
43
+ make changes and new works based on the software for any
44
+ permitted purpose.
45
+
46
+ ## Patent License
47
+
48
+ The licensor grants you a patent license for the software that
49
+ covers patent claims the licensor can license, or becomes able
50
+ to license, that you would infringe by using the software.
51
+
52
+ ## Noncommercial Purposes
53
+
54
+ Any noncommercial purpose is a permitted purpose.
55
+
56
+ ## Personal Uses
57
+
58
+ Personal use for research, experiment, and testing for
59
+ the benefit of public knowledge, personal study, private
60
+ entertainment, hobby projects, amateur pursuits, or religious
61
+ observance, without any anticipated commercial application,
62
+ is use for a permitted purpose.
63
+
64
+ ## Noncommercial Organizations
65
+
66
+ Use by any charitable organization, educational institution,
67
+ public research organization, public safety or health
68
+ organization, environmental protection organization,
69
+ or government institution is use for a permitted purpose
70
+ regardless of the source of funding or obligations resulting
71
+ from the funding.
72
+
73
+ ## Fair Use
74
+
75
+ You may have "fair use" rights for the software under the
76
+ law. These terms do not limit them.
77
+
78
+ ## No Other Rights
79
+
80
+ These terms do not allow you to sublicense or transfer any of
81
+ your licenses to anyone else, or prevent the licensor from
82
+ granting licenses to anyone else. These terms do not imply
83
+ any other licenses.
84
+
85
+ ## Patent Defense
86
+
87
+ If you make any written claim that the software infringes or
88
+ contributes to infringement of any patent, your patent license
89
+ for the software granted under these terms ends immediately. If
90
+ your company makes such a claim, your patent license ends
91
+ immediately for work on behalf of your company.
92
+
93
+ ## Violations
94
+
95
+ The first time you are notified in writing that you have
96
+ violated any of these terms, or done anything with the software
97
+ not covered by your licenses, your licenses can nonetheless
98
+ continue if you come into full compliance with these terms,
99
+ and take practical steps to correct past violations, within
100
+ 32 days of receiving notice. Otherwise, all your licenses
101
+ end immediately.
102
+
103
+ ## No Liability
104
+
105
+ ***As far as the law allows, the software comes as is, without
106
+ any warranty or condition, and the licensor will not be liable
107
+ to you for any damages arising out of these terms or the use
108
+ or nature of the software, under any kind of legal claim.***
109
+
110
+ ## Definitions
111
+
112
+ The **licensor** is the individual or entity offering these
113
+ terms, and the **software** is the software the licensor makes
114
+ available under these terms.
115
+
116
+ **You** refers to the individual or entity agreeing to these
117
+ terms.
118
+
119
+ **Your company** is any legal entity, sole proprietorship,
120
+ or other kind of organization that you work for, plus all
121
+ organizations that have control over, are under the control of,
122
+ or are under common control with that organization. **Control**
123
+ means ownership of substantially all the assets of an entity,
124
+ or the power to direct its management and policies by vote,
125
+ contract, or otherwise. Control can be direct or indirect.
126
+
127
+ **Your licenses** are all the licenses granted to you for the
128
+ software under these terms.
129
+
130
+ **Use** means anything you do with the software requiring one
131
+ of your licenses.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @silverassist/leadcapture-form
2
+
3
+ LeadCapture IO form integration for Next.js apps. Variant switching,
4
+ ownership arbitration between competing components (e.g. a modal and an
5
+ on-page form), and ref-counted script lifecycle — built on
6
+ `@silverassist/next-script-loader`.
7
+
8
+ ## Status
9
+
10
+ Extracted from the fleet's `ScriptManager` implementations — the pattern
11
+ `@silverassist/next-script-loader` itself was generalized from (see that
12
+ package's own doc comment). Not yet published. Part of the fleet-wide
13
+ third-party-integration package effort described in
14
+ `nextjs-boilerplate/docs/NEXTJS_CORE_PACKAGE_PLAN.md`.
15
+
16
+ **Known behavioral difference from the site implementations this was ported
17
+ from:** the shared `leadCaptureLoader` tracks one active variant at a time —
18
+ loading a different variant tears down the previous one, the same way
19
+ `ScriptLoader.load()` always has. The original per-site `ScriptManager`
20
+ tracked state per variant independently, so two different variants could in
21
+ principle stay loaded simultaneously (e.g. a modal on `"desktop"` and an
22
+ on-page form on `"mobile"` on the same page at once). If your site actually
23
+ needs that, instantiate a second `ScriptLoader` yourself rather than sharing
24
+ `leadCaptureLoader` — see [Advanced: two independent
25
+ loaders](#advanced-two-independent-loaders).
26
+
27
+ LeadCapture IO serves one shared script for every variant — it reads which
28
+ form to render from a global `window.form_token` this component sets from
29
+ `formTokens[formVariant]` right before loading, rather than varying the
30
+ script URL per variant.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ npm install @silverassist/leadcapture-form
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```tsx
41
+ import LeadCaptureForm from "@silverassist/leadcapture-form";
42
+
43
+ const FORM_TOKENS = { desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" };
44
+
45
+ // Modal usage
46
+ <LeadCaptureForm
47
+ formVariant="desktop"
48
+ formTokens={FORM_TOKENS}
49
+ usageContext="modal"
50
+ isModalOpen={isOpen}
51
+ />;
52
+
53
+ // On-page usage
54
+ <LeadCaptureForm
55
+ formVariant="mobile"
56
+ formTokens={FORM_TOKENS}
57
+ usageContext="onPage"
58
+ isModalOpen={true}
59
+ />;
60
+ ```
61
+
62
+ A single form can render in multiple DOM locations via the
63
+ `.leadforms-embd-form` class inside the component's container — LeadCapture
64
+ IO's script populates every matching div once it loads, it doesn't re-run
65
+ per div.
66
+
67
+ ### Props
68
+
69
+ | Prop | Type | Default | Description |
70
+ | --------------- | ------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------ |
71
+ | `formVariant` | `string` | — | Required. Must match a key in `formTokens` (e.g. `"desktop"`, `"itt"`). |
72
+ | `formTokens` | `Record<string, string>` | — | Required. Maps variant names to LeadCapture IO form tokens. |
73
+ | `scriptUrl` | `string` | LeadCapture IO's default CDN URL | Override the embed script URL. |
74
+ | `usageContext` | `"modal" \| "onPage"` | — | Required. Controls when the script loads. |
75
+ | `isModalOpen` | `boolean` | — | Required. Only meaningful for `usageContext="modal"`. |
76
+ | `className` | `string` | `""` | CSS classes on the container. |
77
+ | `embedTargetId` | `string` | — | Id for the inner `.leadforms-embd-form` div (e.g. matching a WordPress `embed_target_id`). |
78
+
79
+ ## Advanced: two independent loaders
80
+
81
+ If a page genuinely needs two different variants active at once, don't rely
82
+ on the module-level `leadCaptureLoader` singleton for both — the underlying
83
+ `@silverassist/next-script-loader` `ScriptLoader` only tracks one variant per
84
+ instance. Fork this component (or file an issue) rather than sharing the
85
+ export across two forms that need independent variants simultaneously; this
86
+ is a real gap, not a documented-and-solved case, in v0.1.0.
87
+
88
+ ## License
89
+
90
+ [PolyForm Noncommercial 1.0.0](./LICENSE)
91
+
92
+ ---
93
+
94
+ Made with ❤️ by Silver Assist
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ import { ScriptLoader } from "@silverassist/next-script-loader";
3
+ //#region src/index.d.ts
4
+ type UsageContext = "modal" | "onPage";
5
+ /**
6
+ * Module-level singleton: every `LeadCaptureForm` instance on the page
7
+ * shares one loader. `ScriptLoader` tracks a single active variant at a
8
+ * time — switching variants tears down the previous one — which matches
9
+ * how this form is actually used in the fleet (one device/territory
10
+ * variant active per page). A page that genuinely needs two different
11
+ * variants loaded simultaneously (e.g. a modal on "desktop" and an on-page
12
+ * form on "mobile" at once) isn't supported by this shared instance; see
13
+ * the README for the workaround.
14
+ */
15
+ declare const leadCaptureLoader: ScriptLoader;
16
+ interface LeadCaptureFormProps {
17
+ /**
18
+ * Form variant to render (e.g. "desktop", "mobile", "itt", "oot"). Must
19
+ * match a key configured in `formTokens`.
20
+ */
21
+ formVariant: string;
22
+ /** Map of variant names to LeadCapture IO form tokens. */
23
+ formTokens: Record<string, string>;
24
+ /** Script URL override, if not using LeadCapture IO's default CDN. */
25
+ scriptUrl?: string;
26
+ /**
27
+ * Usage context — determines loading behavior.
28
+ * - `modal`: loads when the modal opens
29
+ * - `onPage`: loads when in viewport, after minimal interaction
30
+ */
31
+ usageContext: UsageContext;
32
+ /** Controls whether the modal is open (only relevant for `usageContext="modal"`). */
33
+ isModalOpen: boolean;
34
+ /** Optional additional CSS classes. */
35
+ className?: string;
36
+ /**
37
+ * Optional embed target id for the inner `.leadforms-embd-form` div. Must
38
+ * match the WordPress `embed_target_id` when the site sources form
39
+ * placement from WordPress.
40
+ */
41
+ embedTargetId?: string;
42
+ }
43
+ /**
44
+ * LeadCaptureForm — renders a LeadCapture IO form with support for
45
+ * configurable variants, built on `@silverassist/next-script-loader`'s
46
+ * singleton, reference-counted, ownership-arbitrated script lifecycle.
47
+ *
48
+ * A single form can render in multiple DOM locations via the
49
+ * `.leadforms-embd-form` class — LeadCapture IO's script populates every
50
+ * matching div once it loads, it doesn't re-run per div.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * // Modal usage
55
+ * <LeadCaptureForm
56
+ * formVariant="desktop"
57
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
58
+ * usageContext="modal"
59
+ * isModalOpen={isOpen}
60
+ * />
61
+ *
62
+ * // On-page usage
63
+ * <LeadCaptureForm
64
+ * formVariant="mobile"
65
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
66
+ * usageContext="onPage"
67
+ * isModalOpen={true}
68
+ * />
69
+ * ```
70
+ */
71
+ declare function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isModalOpen, className, embedTargetId }: LeadCaptureFormProps): import("react").JSX.Element;
72
+ //#endregion
73
+ export { LeadCaptureFormProps, UsageContext, LeadCaptureForm as default, leadCaptureLoader };
74
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ import { ScriptLoader } from "@silverassist/next-script-loader";
3
+ //#region src/index.d.ts
4
+ type UsageContext = "modal" | "onPage";
5
+ /**
6
+ * Module-level singleton: every `LeadCaptureForm` instance on the page
7
+ * shares one loader. `ScriptLoader` tracks a single active variant at a
8
+ * time — switching variants tears down the previous one — which matches
9
+ * how this form is actually used in the fleet (one device/territory
10
+ * variant active per page). A page that genuinely needs two different
11
+ * variants loaded simultaneously (e.g. a modal on "desktop" and an on-page
12
+ * form on "mobile" at once) isn't supported by this shared instance; see
13
+ * the README for the workaround.
14
+ */
15
+ declare const leadCaptureLoader: ScriptLoader;
16
+ interface LeadCaptureFormProps {
17
+ /**
18
+ * Form variant to render (e.g. "desktop", "mobile", "itt", "oot"). Must
19
+ * match a key configured in `formTokens`.
20
+ */
21
+ formVariant: string;
22
+ /** Map of variant names to LeadCapture IO form tokens. */
23
+ formTokens: Record<string, string>;
24
+ /** Script URL override, if not using LeadCapture IO's default CDN. */
25
+ scriptUrl?: string;
26
+ /**
27
+ * Usage context — determines loading behavior.
28
+ * - `modal`: loads when the modal opens
29
+ * - `onPage`: loads when in viewport, after minimal interaction
30
+ */
31
+ usageContext: UsageContext;
32
+ /** Controls whether the modal is open (only relevant for `usageContext="modal"`). */
33
+ isModalOpen: boolean;
34
+ /** Optional additional CSS classes. */
35
+ className?: string;
36
+ /**
37
+ * Optional embed target id for the inner `.leadforms-embd-form` div. Must
38
+ * match the WordPress `embed_target_id` when the site sources form
39
+ * placement from WordPress.
40
+ */
41
+ embedTargetId?: string;
42
+ }
43
+ /**
44
+ * LeadCaptureForm — renders a LeadCapture IO form with support for
45
+ * configurable variants, built on `@silverassist/next-script-loader`'s
46
+ * singleton, reference-counted, ownership-arbitrated script lifecycle.
47
+ *
48
+ * A single form can render in multiple DOM locations via the
49
+ * `.leadforms-embd-form` class — LeadCapture IO's script populates every
50
+ * matching div once it loads, it doesn't re-run per div.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * // Modal usage
55
+ * <LeadCaptureForm
56
+ * formVariant="desktop"
57
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
58
+ * usageContext="modal"
59
+ * isModalOpen={isOpen}
60
+ * />
61
+ *
62
+ * // On-page usage
63
+ * <LeadCaptureForm
64
+ * formVariant="mobile"
65
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
66
+ * usageContext="onPage"
67
+ * isModalOpen={true}
68
+ * />
69
+ * ```
70
+ */
71
+ declare function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isModalOpen, className, embedTargetId }: LeadCaptureFormProps): import("react").JSX.Element;
72
+ //#endregion
73
+ export { LeadCaptureFormProps, UsageContext, LeadCaptureForm as default, leadCaptureLoader };
74
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,194 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: 'Module' } });
5
+ let _silverassist_next_script_loader = require("@silverassist/next-script-loader");
6
+ let react = require("react");
7
+ let react_jsx_runtime = require("react/jsx-runtime");
8
+
9
+ //#region src/index.tsx
10
+ /**
11
+ * @packageDocumentation
12
+ * LeadCapture IO form integration for Next.js — a variant-switching,
13
+ * ownership-arbitrated `LeadCaptureForm` component built on
14
+ * `@silverassist/next-script-loader`.
15
+ */
16
+ /**
17
+ * Module-level singleton: every `LeadCaptureForm` instance on the page
18
+ * shares one loader. `ScriptLoader` tracks a single active variant at a
19
+ * time — switching variants tears down the previous one — which matches
20
+ * how this form is actually used in the fleet (one device/territory
21
+ * variant active per page). A page that genuinely needs two different
22
+ * variants loaded simultaneously (e.g. a modal on "desktop" and an on-page
23
+ * form on "mobile" at once) isn't supported by this shared instance; see
24
+ * the README for the workaround.
25
+ */
26
+ const leadCaptureLoader = new _silverassist_next_script_loader.ScriptLoader();
27
+ /**
28
+ * Generation counter per variant, incremented on every {@link ScriptLoader.load}/
29
+ * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the
30
+ * generation at mount time and skips its `unload()` call if the generation has
31
+ * since advanced — i.e. a new mount already reloaded the script before the old
32
+ * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).
33
+ * `ScriptLoader`'s own ref-counting handles the common case; this guards the
34
+ * one case it doesn't: `reload()` doesn't change the reference count, so a
35
+ * stale `unload()` after a `reload()` could drop the count to zero and tear
36
+ * down a script a fresh mount is now depending on.
37
+ */
38
+ const generationByVariant = /* @__PURE__ */ new Map();
39
+ function bumpGeneration(variant) {
40
+ const next = (generationByVariant.get(variant) ?? 0) + 1;
41
+ generationByVariant.set(variant, next);
42
+ return next;
43
+ }
44
+ function currentGeneration(variant) {
45
+ return generationByVariant.get(variant) ?? 0;
46
+ }
47
+ const DEFAULT_LEADCAPTURE_SCRIPT_URL = "https://api.useleadbot.com/lead-bots/get-pixel-script.js";
48
+ /**
49
+ * LeadCaptureForm — renders a LeadCapture IO form with support for
50
+ * configurable variants, built on `@silverassist/next-script-loader`'s
51
+ * singleton, reference-counted, ownership-arbitrated script lifecycle.
52
+ *
53
+ * A single form can render in multiple DOM locations via the
54
+ * `.leadforms-embd-form` class — LeadCapture IO's script populates every
55
+ * matching div once it loads, it doesn't re-run per div.
56
+ *
57
+ * @example
58
+ * ```tsx
59
+ * // Modal usage
60
+ * <LeadCaptureForm
61
+ * formVariant="desktop"
62
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
63
+ * usageContext="modal"
64
+ * isModalOpen={isOpen}
65
+ * />
66
+ *
67
+ * // On-page usage
68
+ * <LeadCaptureForm
69
+ * formVariant="mobile"
70
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
71
+ * usageContext="onPage"
72
+ * isModalOpen={true}
73
+ * />
74
+ * ```
75
+ */
76
+ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isModalOpen, className = "", embedTargetId }) {
77
+ const [isInViewport, setIsInViewport] = (0, react.useState)(false);
78
+ const formRef = (0, react.useRef)(null);
79
+ const mountGenRef = (0, react.useRef)(0);
80
+ const isMountedRef = (0, react.useRef)(true);
81
+ const containerId = `leadcapture-container-${formVariant}-${usageContext}`;
82
+ (0, react.useEffect)(() => {
83
+ isMountedRef.current = true;
84
+ return () => {
85
+ isMountedRef.current = false;
86
+ };
87
+ }, []);
88
+ (0, react.useEffect)(() => {
89
+ leadCaptureLoader.configure({ urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL } });
90
+ }, [formVariant, scriptUrl]);
91
+ /**
92
+ * Intersection Observer for onPage forms — loads when near viewport.
93
+ */
94
+ (0, react.useEffect)(() => {
95
+ if (usageContext !== "onPage" || !formRef.current) return;
96
+ const observer = new IntersectionObserver((entries) => {
97
+ entries.forEach((entry) => {
98
+ if (entry.isIntersecting) {
99
+ setIsInViewport(true);
100
+ observer.disconnect();
101
+ }
102
+ });
103
+ }, {
104
+ rootMargin: "100px",
105
+ threshold: .1
106
+ });
107
+ observer.observe(formRef.current);
108
+ return () => observer.disconnect();
109
+ }, [usageContext]);
110
+ /**
111
+ * Script loading with minimal interaction pattern.
112
+ * - Modal: loads immediately when the modal opens.
113
+ * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once
114
+ * near viewport.
115
+ */
116
+ (0, react.useEffect)(() => {
117
+ if (!(usageContext === "modal" ? isModalOpen === true : isInViewport)) return;
118
+ const load = () => {
119
+ window.form_token = formTokens[formVariant];
120
+ bumpGeneration(formVariant);
121
+ leadCaptureLoader.load(formVariant).then(() => {
122
+ if (!isMountedRef.current) return;
123
+ leadCaptureLoader.forceSetOwner(containerId);
124
+ }).catch(() => {});
125
+ };
126
+ if (usageContext === "modal") {
127
+ load();
128
+ return;
129
+ }
130
+ const events = [
131
+ "focus",
132
+ "mousemove",
133
+ "scroll",
134
+ "touchstart"
135
+ ];
136
+ const loadOnce = () => {
137
+ load();
138
+ events.forEach((event) => document.removeEventListener(event, loadOnce));
139
+ };
140
+ events.forEach((event) => {
141
+ document.addEventListener(event, loadOnce, { once: true });
142
+ });
143
+ return () => {
144
+ events.forEach((event) => document.removeEventListener(event, loadOnce));
145
+ };
146
+ }, [
147
+ isModalOpen,
148
+ isInViewport,
149
+ formVariant,
150
+ usageContext,
151
+ containerId,
152
+ formTokens
153
+ ]);
154
+ /**
155
+ * Capture the current generation at mount time — passed to the delayed
156
+ * unload on cleanup so a stale unmount (superseded by a fresh mount that
157
+ * already reloaded) is a no-op.
158
+ */
159
+ (0, react.useEffect)(() => {
160
+ mountGenRef.current = currentGeneration(formVariant);
161
+ }, [formVariant]);
162
+ /**
163
+ * Cleanup on unmount only.
164
+ * Modal: only releases ownership, doesn't unload the script.
165
+ * OnPage: releases ownership and unloads after a short delay, skipped if
166
+ * a newer mount has already reloaded the script in the meantime.
167
+ */
168
+ (0, react.useEffect)(() => {
169
+ return () => {
170
+ leadCaptureLoader.releaseOwnership(containerId);
171
+ if (usageContext === "onPage") {
172
+ const gen = mountGenRef.current;
173
+ setTimeout(() => {
174
+ if (gen < currentGeneration(formVariant)) return;
175
+ leadCaptureLoader.unload();
176
+ }, 100);
177
+ }
178
+ };
179
+ }, []);
180
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
181
+ ref: formRef,
182
+ id: containerId,
183
+ className,
184
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
185
+ className: "leadforms-embd-form",
186
+ ...embedTargetId ? { id: embedTargetId } : {}
187
+ })
188
+ });
189
+ }
190
+
191
+ //#endregion
192
+ exports.default = LeadCaptureForm;
193
+ exports.leadCaptureLoader = leadCaptureLoader;
194
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["ScriptLoader","useState","useRef"],"sources":["../src/index.tsx"],"sourcesContent":["/**\n * @packageDocumentation\n * LeadCapture IO form integration for Next.js — a variant-switching,\n * ownership-arbitrated `LeadCaptureForm` component built on\n * `@silverassist/next-script-loader`.\n */\n\n\"use client\";\n\nimport { ScriptLoader } from \"@silverassist/next-script-loader\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type UsageContext = \"modal\" | \"onPage\";\n\n/**\n * Module-level singleton: every `LeadCaptureForm` instance on the page\n * shares one loader. `ScriptLoader` tracks a single active variant at a\n * time — switching variants tears down the previous one — which matches\n * how this form is actually used in the fleet (one device/territory\n * variant active per page). A page that genuinely needs two different\n * variants loaded simultaneously (e.g. a modal on \"desktop\" and an on-page\n * form on \"mobile\" at once) isn't supported by this shared instance; see\n * the README for the workaround.\n */\nexport const leadCaptureLoader = new ScriptLoader();\n\n/**\n * Generation counter per variant, incremented on every {@link ScriptLoader.load}/\n * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the\n * generation at mount time and skips its `unload()` call if the generation has\n * since advanced — i.e. a new mount already reloaded the script before the old\n * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).\n * `ScriptLoader`'s own ref-counting handles the common case; this guards the\n * one case it doesn't: `reload()` doesn't change the reference count, so a\n * stale `unload()` after a `reload()` could drop the count to zero and tear\n * down a script a fresh mount is now depending on.\n */\nconst generationByVariant = new Map<string, number>();\n\nfunction bumpGeneration(variant: string): number {\n const next = (generationByVariant.get(variant) ?? 0) + 1;\n generationByVariant.set(variant, next);\n return next;\n}\n\nfunction currentGeneration(variant: string): number {\n return generationByVariant.get(variant) ?? 0;\n}\n\nexport interface LeadCaptureFormProps {\n /**\n * Form variant to render (e.g. \"desktop\", \"mobile\", \"itt\", \"oot\"). Must\n * match a key configured in `formTokens`.\n */\n formVariant: string;\n\n /** Map of variant names to LeadCapture IO form tokens. */\n formTokens: Record<string, string>;\n\n /** Script URL override, if not using LeadCapture IO's default CDN. */\n scriptUrl?: string;\n\n /**\n * Usage context — determines loading behavior.\n * - `modal`: loads when the modal opens\n * - `onPage`: loads when in viewport, after minimal interaction\n */\n usageContext: UsageContext;\n\n /** Controls whether the modal is open (only relevant for `usageContext=\"modal\"`). */\n isModalOpen: boolean;\n\n /** Optional additional CSS classes. */\n className?: string;\n\n /**\n * Optional embed target id for the inner `.leadforms-embd-form` div. Must\n * match the WordPress `embed_target_id` when the site sources form\n * placement from WordPress.\n */\n embedTargetId?: string;\n}\n\nconst DEFAULT_LEADCAPTURE_SCRIPT_URL = \"https://api.useleadbot.com/lead-bots/get-pixel-script.js\";\n\n/**\n * LeadCaptureForm — renders a LeadCapture IO form with support for\n * configurable variants, built on `@silverassist/next-script-loader`'s\n * singleton, reference-counted, ownership-arbitrated script lifecycle.\n *\n * A single form can render in multiple DOM locations via the\n * `.leadforms-embd-form` class — LeadCapture IO's script populates every\n * matching div once it loads, it doesn't re-run per div.\n *\n * @example\n * ```tsx\n * // Modal usage\n * <LeadCaptureForm\n * formVariant=\"desktop\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"modal\"\n * isModalOpen={isOpen}\n * />\n *\n * // On-page usage\n * <LeadCaptureForm\n * formVariant=\"mobile\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"onPage\"\n * isModalOpen={true}\n * />\n * ```\n */\nexport default function LeadCaptureForm({\n formVariant,\n formTokens,\n scriptUrl,\n usageContext,\n isModalOpen,\n className = \"\",\n embedTargetId,\n}: LeadCaptureFormProps) {\n const [isInViewport, setIsInViewport] = useState(false);\n const formRef = useRef<HTMLDivElement>(null);\n const mountGenRef = useRef<number>(0);\n const isMountedRef = useRef<boolean>(true);\n const containerId = `leadcapture-container-${formVariant}-${usageContext}`;\n\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n useEffect(() => {\n leadCaptureLoader.configure({\n urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL },\n });\n }, [formVariant, scriptUrl]);\n\n /**\n * Intersection Observer for onPage forms — loads when near viewport.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\" || !formRef.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting) {\n setIsInViewport(true);\n observer.disconnect();\n }\n });\n },\n { rootMargin: \"100px\", threshold: 0.1 },\n );\n\n observer.observe(formRef.current);\n return () => observer.disconnect();\n }, [usageContext]);\n\n /**\n * Script loading with minimal interaction pattern.\n * - Modal: loads immediately when the modal opens.\n * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once\n * near viewport.\n */\n useEffect(() => {\n const shouldLoad = usageContext === \"modal\" ? isModalOpen === true : isInViewport;\n\n if (!shouldLoad) return;\n\n const load = () => {\n // LeadCapture IO serves one shared script for every variant and reads\n // which form to render from a global set just before the script\n // loads, rather than varying the script URL itself per variant.\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n\n bumpGeneration(formVariant);\n leadCaptureLoader\n .load(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade — the surrounding page stays usable without\n // the embed.\n });\n };\n\n if (usageContext === \"modal\") {\n load();\n return;\n }\n\n const events = [\"focus\", \"mousemove\", \"scroll\", \"touchstart\"] as const;\n const loadOnce = () => {\n load();\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n events.forEach((event) => {\n document.addEventListener(event, loadOnce, { once: true });\n });\n\n return () => {\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n }, [isModalOpen, isInViewport, formVariant, usageContext, containerId, formTokens]);\n\n /**\n * Capture the current generation at mount time — passed to the delayed\n * unload on cleanup so a stale unmount (superseded by a fresh mount that\n * already reloaded) is a no-op.\n */\n useEffect(() => {\n mountGenRef.current = currentGeneration(formVariant);\n }, [formVariant]);\n\n /**\n * Cleanup on unmount only.\n * Modal: only releases ownership, doesn't unload the script.\n * OnPage: releases ownership and unloads after a short delay, skipped if\n * a newer mount has already reloaded the script in the meantime.\n */\n useEffect(() => {\n return () => {\n leadCaptureLoader.releaseOwnership(containerId);\n\n if (usageContext === \"onPage\") {\n const gen = mountGenRef.current;\n setTimeout(() => {\n if (gen < currentGeneration(formVariant)) return;\n leadCaptureLoader.unload();\n }, 100);\n }\n };\n }, []);\n\n return (\n <div ref={formRef} id={containerId} className={className}>\n {/*\n LeadCapture IO embed container.\n\n CRITICAL: this div must exist BEFORE the script loads -- LeadCapture IO\n only populates `.leadforms-embd-form` divs present at load time, it\n doesn't detect ones added later.\n */}\n <div className=\"leadforms-embd-form\" {...(embedTargetId ? { id: embedTargetId } : {})}>\n {/* Form renders here */}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
package/dist/index.mjs ADDED
@@ -0,0 +1,192 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ import { ScriptLoader } from "@silverassist/next-script-loader";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { jsx } from "react/jsx-runtime";
7
+
8
+ //#region src/index.tsx
9
+ /**
10
+ * @packageDocumentation
11
+ * LeadCapture IO form integration for Next.js — a variant-switching,
12
+ * ownership-arbitrated `LeadCaptureForm` component built on
13
+ * `@silverassist/next-script-loader`.
14
+ */
15
+ /**
16
+ * Module-level singleton: every `LeadCaptureForm` instance on the page
17
+ * shares one loader. `ScriptLoader` tracks a single active variant at a
18
+ * time — switching variants tears down the previous one — which matches
19
+ * how this form is actually used in the fleet (one device/territory
20
+ * variant active per page). A page that genuinely needs two different
21
+ * variants loaded simultaneously (e.g. a modal on "desktop" and an on-page
22
+ * form on "mobile" at once) isn't supported by this shared instance; see
23
+ * the README for the workaround.
24
+ */
25
+ const leadCaptureLoader = new ScriptLoader();
26
+ /**
27
+ * Generation counter per variant, incremented on every {@link ScriptLoader.load}/
28
+ * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the
29
+ * generation at mount time and skips its `unload()` call if the generation has
30
+ * since advanced — i.e. a new mount already reloaded the script before the old
31
+ * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).
32
+ * `ScriptLoader`'s own ref-counting handles the common case; this guards the
33
+ * one case it doesn't: `reload()` doesn't change the reference count, so a
34
+ * stale `unload()` after a `reload()` could drop the count to zero and tear
35
+ * down a script a fresh mount is now depending on.
36
+ */
37
+ const generationByVariant = /* @__PURE__ */ new Map();
38
+ function bumpGeneration(variant) {
39
+ const next = (generationByVariant.get(variant) ?? 0) + 1;
40
+ generationByVariant.set(variant, next);
41
+ return next;
42
+ }
43
+ function currentGeneration(variant) {
44
+ return generationByVariant.get(variant) ?? 0;
45
+ }
46
+ const DEFAULT_LEADCAPTURE_SCRIPT_URL = "https://api.useleadbot.com/lead-bots/get-pixel-script.js";
47
+ /**
48
+ * LeadCaptureForm — renders a LeadCapture IO form with support for
49
+ * configurable variants, built on `@silverassist/next-script-loader`'s
50
+ * singleton, reference-counted, ownership-arbitrated script lifecycle.
51
+ *
52
+ * A single form can render in multiple DOM locations via the
53
+ * `.leadforms-embd-form` class — LeadCapture IO's script populates every
54
+ * matching div once it loads, it doesn't re-run per div.
55
+ *
56
+ * @example
57
+ * ```tsx
58
+ * // Modal usage
59
+ * <LeadCaptureForm
60
+ * formVariant="desktop"
61
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
62
+ * usageContext="modal"
63
+ * isModalOpen={isOpen}
64
+ * />
65
+ *
66
+ * // On-page usage
67
+ * <LeadCaptureForm
68
+ * formVariant="mobile"
69
+ * formTokens={{ desktop: "GLFT-XXXX", mobile: "GLFT-YYYY" }}
70
+ * usageContext="onPage"
71
+ * isModalOpen={true}
72
+ * />
73
+ * ```
74
+ */
75
+ function LeadCaptureForm({ formVariant, formTokens, scriptUrl, usageContext, isModalOpen, className = "", embedTargetId }) {
76
+ const [isInViewport, setIsInViewport] = useState(false);
77
+ const formRef = useRef(null);
78
+ const mountGenRef = useRef(0);
79
+ const isMountedRef = useRef(true);
80
+ const containerId = `leadcapture-container-${formVariant}-${usageContext}`;
81
+ useEffect(() => {
82
+ isMountedRef.current = true;
83
+ return () => {
84
+ isMountedRef.current = false;
85
+ };
86
+ }, []);
87
+ useEffect(() => {
88
+ leadCaptureLoader.configure({ urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL } });
89
+ }, [formVariant, scriptUrl]);
90
+ /**
91
+ * Intersection Observer for onPage forms — loads when near viewport.
92
+ */
93
+ useEffect(() => {
94
+ if (usageContext !== "onPage" || !formRef.current) return;
95
+ const observer = new IntersectionObserver((entries) => {
96
+ entries.forEach((entry) => {
97
+ if (entry.isIntersecting) {
98
+ setIsInViewport(true);
99
+ observer.disconnect();
100
+ }
101
+ });
102
+ }, {
103
+ rootMargin: "100px",
104
+ threshold: .1
105
+ });
106
+ observer.observe(formRef.current);
107
+ return () => observer.disconnect();
108
+ }, [usageContext]);
109
+ /**
110
+ * Script loading with minimal interaction pattern.
111
+ * - Modal: loads immediately when the modal opens.
112
+ * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once
113
+ * near viewport.
114
+ */
115
+ useEffect(() => {
116
+ if (!(usageContext === "modal" ? isModalOpen === true : isInViewport)) return;
117
+ const load = () => {
118
+ window.form_token = formTokens[formVariant];
119
+ bumpGeneration(formVariant);
120
+ leadCaptureLoader.load(formVariant).then(() => {
121
+ if (!isMountedRef.current) return;
122
+ leadCaptureLoader.forceSetOwner(containerId);
123
+ }).catch(() => {});
124
+ };
125
+ if (usageContext === "modal") {
126
+ load();
127
+ return;
128
+ }
129
+ const events = [
130
+ "focus",
131
+ "mousemove",
132
+ "scroll",
133
+ "touchstart"
134
+ ];
135
+ const loadOnce = () => {
136
+ load();
137
+ events.forEach((event) => document.removeEventListener(event, loadOnce));
138
+ };
139
+ events.forEach((event) => {
140
+ document.addEventListener(event, loadOnce, { once: true });
141
+ });
142
+ return () => {
143
+ events.forEach((event) => document.removeEventListener(event, loadOnce));
144
+ };
145
+ }, [
146
+ isModalOpen,
147
+ isInViewport,
148
+ formVariant,
149
+ usageContext,
150
+ containerId,
151
+ formTokens
152
+ ]);
153
+ /**
154
+ * Capture the current generation at mount time — passed to the delayed
155
+ * unload on cleanup so a stale unmount (superseded by a fresh mount that
156
+ * already reloaded) is a no-op.
157
+ */
158
+ useEffect(() => {
159
+ mountGenRef.current = currentGeneration(formVariant);
160
+ }, [formVariant]);
161
+ /**
162
+ * Cleanup on unmount only.
163
+ * Modal: only releases ownership, doesn't unload the script.
164
+ * OnPage: releases ownership and unloads after a short delay, skipped if
165
+ * a newer mount has already reloaded the script in the meantime.
166
+ */
167
+ useEffect(() => {
168
+ return () => {
169
+ leadCaptureLoader.releaseOwnership(containerId);
170
+ if (usageContext === "onPage") {
171
+ const gen = mountGenRef.current;
172
+ setTimeout(() => {
173
+ if (gen < currentGeneration(formVariant)) return;
174
+ leadCaptureLoader.unload();
175
+ }, 100);
176
+ }
177
+ };
178
+ }, []);
179
+ return /* @__PURE__ */ jsx("div", {
180
+ ref: formRef,
181
+ id: containerId,
182
+ className,
183
+ children: /* @__PURE__ */ jsx("div", {
184
+ className: "leadforms-embd-form",
185
+ ...embedTargetId ? { id: embedTargetId } : {}
186
+ })
187
+ });
188
+ }
189
+
190
+ //#endregion
191
+ export { LeadCaptureForm as default, leadCaptureLoader };
192
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["/**\n * @packageDocumentation\n * LeadCapture IO form integration for Next.js — a variant-switching,\n * ownership-arbitrated `LeadCaptureForm` component built on\n * `@silverassist/next-script-loader`.\n */\n\n\"use client\";\n\nimport { ScriptLoader } from \"@silverassist/next-script-loader\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport type UsageContext = \"modal\" | \"onPage\";\n\n/**\n * Module-level singleton: every `LeadCaptureForm` instance on the page\n * shares one loader. `ScriptLoader` tracks a single active variant at a\n * time — switching variants tears down the previous one — which matches\n * how this form is actually used in the fleet (one device/territory\n * variant active per page). A page that genuinely needs two different\n * variants loaded simultaneously (e.g. a modal on \"desktop\" and an on-page\n * form on \"mobile\" at once) isn't supported by this shared instance; see\n * the README for the workaround.\n */\nexport const leadCaptureLoader = new ScriptLoader();\n\n/**\n * Generation counter per variant, incremented on every {@link ScriptLoader.load}/\n * {@link ScriptLoader.reload} call. A delayed on-unmount cleanup captures the\n * generation at mount time and skips its `unload()` call if the generation has\n * since advanced — i.e. a new mount already reloaded the script before the old\n * mount's delayed cleanup ran (a page-navigation remount, not a real teardown).\n * `ScriptLoader`'s own ref-counting handles the common case; this guards the\n * one case it doesn't: `reload()` doesn't change the reference count, so a\n * stale `unload()` after a `reload()` could drop the count to zero and tear\n * down a script a fresh mount is now depending on.\n */\nconst generationByVariant = new Map<string, number>();\n\nfunction bumpGeneration(variant: string): number {\n const next = (generationByVariant.get(variant) ?? 0) + 1;\n generationByVariant.set(variant, next);\n return next;\n}\n\nfunction currentGeneration(variant: string): number {\n return generationByVariant.get(variant) ?? 0;\n}\n\nexport interface LeadCaptureFormProps {\n /**\n * Form variant to render (e.g. \"desktop\", \"mobile\", \"itt\", \"oot\"). Must\n * match a key configured in `formTokens`.\n */\n formVariant: string;\n\n /** Map of variant names to LeadCapture IO form tokens. */\n formTokens: Record<string, string>;\n\n /** Script URL override, if not using LeadCapture IO's default CDN. */\n scriptUrl?: string;\n\n /**\n * Usage context — determines loading behavior.\n * - `modal`: loads when the modal opens\n * - `onPage`: loads when in viewport, after minimal interaction\n */\n usageContext: UsageContext;\n\n /** Controls whether the modal is open (only relevant for `usageContext=\"modal\"`). */\n isModalOpen: boolean;\n\n /** Optional additional CSS classes. */\n className?: string;\n\n /**\n * Optional embed target id for the inner `.leadforms-embd-form` div. Must\n * match the WordPress `embed_target_id` when the site sources form\n * placement from WordPress.\n */\n embedTargetId?: string;\n}\n\nconst DEFAULT_LEADCAPTURE_SCRIPT_URL = \"https://api.useleadbot.com/lead-bots/get-pixel-script.js\";\n\n/**\n * LeadCaptureForm — renders a LeadCapture IO form with support for\n * configurable variants, built on `@silverassist/next-script-loader`'s\n * singleton, reference-counted, ownership-arbitrated script lifecycle.\n *\n * A single form can render in multiple DOM locations via the\n * `.leadforms-embd-form` class — LeadCapture IO's script populates every\n * matching div once it loads, it doesn't re-run per div.\n *\n * @example\n * ```tsx\n * // Modal usage\n * <LeadCaptureForm\n * formVariant=\"desktop\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"modal\"\n * isModalOpen={isOpen}\n * />\n *\n * // On-page usage\n * <LeadCaptureForm\n * formVariant=\"mobile\"\n * formTokens={{ desktop: \"GLFT-XXXX\", mobile: \"GLFT-YYYY\" }}\n * usageContext=\"onPage\"\n * isModalOpen={true}\n * />\n * ```\n */\nexport default function LeadCaptureForm({\n formVariant,\n formTokens,\n scriptUrl,\n usageContext,\n isModalOpen,\n className = \"\",\n embedTargetId,\n}: LeadCaptureFormProps) {\n const [isInViewport, setIsInViewport] = useState(false);\n const formRef = useRef<HTMLDivElement>(null);\n const mountGenRef = useRef<number>(0);\n const isMountedRef = useRef<boolean>(true);\n const containerId = `leadcapture-container-${formVariant}-${usageContext}`;\n\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n useEffect(() => {\n leadCaptureLoader.configure({\n urls: { [formVariant]: scriptUrl ?? DEFAULT_LEADCAPTURE_SCRIPT_URL },\n });\n }, [formVariant, scriptUrl]);\n\n /**\n * Intersection Observer for onPage forms — loads when near viewport.\n */\n useEffect(() => {\n if (usageContext !== \"onPage\" || !formRef.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting) {\n setIsInViewport(true);\n observer.disconnect();\n }\n });\n },\n { rootMargin: \"100px\", threshold: 0.1 },\n );\n\n observer.observe(formRef.current);\n return () => observer.disconnect();\n }, [usageContext]);\n\n /**\n * Script loading with minimal interaction pattern.\n * - Modal: loads immediately when the modal opens.\n * - OnPage: loads on the first of focus/mousemove/scroll/touchstart, once\n * near viewport.\n */\n useEffect(() => {\n const shouldLoad = usageContext === \"modal\" ? isModalOpen === true : isInViewport;\n\n if (!shouldLoad) return;\n\n const load = () => {\n // LeadCapture IO serves one shared script for every variant and reads\n // which form to render from a global set just before the script\n // loads, rather than varying the script URL itself per variant.\n (window as Window & { form_token?: string }).form_token = formTokens[formVariant];\n\n bumpGeneration(formVariant);\n leadCaptureLoader\n .load(formVariant)\n .then(() => {\n if (!isMountedRef.current) return;\n leadCaptureLoader.forceSetOwner(containerId);\n })\n .catch(() => {\n // Silently degrade — the surrounding page stays usable without\n // the embed.\n });\n };\n\n if (usageContext === \"modal\") {\n load();\n return;\n }\n\n const events = [\"focus\", \"mousemove\", \"scroll\", \"touchstart\"] as const;\n const loadOnce = () => {\n load();\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n events.forEach((event) => {\n document.addEventListener(event, loadOnce, { once: true });\n });\n\n return () => {\n events.forEach((event) => document.removeEventListener(event, loadOnce));\n };\n }, [isModalOpen, isInViewport, formVariant, usageContext, containerId, formTokens]);\n\n /**\n * Capture the current generation at mount time — passed to the delayed\n * unload on cleanup so a stale unmount (superseded by a fresh mount that\n * already reloaded) is a no-op.\n */\n useEffect(() => {\n mountGenRef.current = currentGeneration(formVariant);\n }, [formVariant]);\n\n /**\n * Cleanup on unmount only.\n * Modal: only releases ownership, doesn't unload the script.\n * OnPage: releases ownership and unloads after a short delay, skipped if\n * a newer mount has already reloaded the script in the meantime.\n */\n useEffect(() => {\n return () => {\n leadCaptureLoader.releaseOwnership(containerId);\n\n if (usageContext === \"onPage\") {\n const gen = mountGenRef.current;\n setTimeout(() => {\n if (gen < currentGeneration(formVariant)) return;\n leadCaptureLoader.unload();\n }, 100);\n }\n };\n }, []);\n\n return (\n <div ref={formRef} id={containerId} className={className}>\n {/*\n LeadCapture IO embed container.\n\n CRITICAL: this div must exist BEFORE the script loads -- LeadCapture IO\n only populates `.leadforms-embd-form` divs present at load time, it\n doesn't detect ones added later.\n */}\n <div className=\"leadforms-embd-form\" {...(embedTargetId ? { id: embedTargetId } : {})}>\n {/* Form renders here */}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,114 @@
1
+ {
2
+ "name": "@silverassist/leadcapture-form",
3
+ "version": "0.1.0",
4
+ "description": "LeadCapture IO form integration for Next.js — variant switching, ownership arbitration, and ref-counted script lifecycle via @silverassist/next-script-loader",
5
+ "author": "Miguel Colmenares <me@miguelcolmenares.com>",
6
+ "license": "PolyForm-Noncommercial-1.0.0",
7
+ "prettier": "@silverassist/npm-package-standards/prettier",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/SilverAssist/next-leadcapture-form.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/SilverAssist/next-leadcapture-form/issues"
14
+ },
15
+ "homepage": "https://github.com/SilverAssist/next-leadcapture-form#readme",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "leadcapture",
21
+ "lead-forms",
22
+ "nextjs",
23
+ "next",
24
+ "react",
25
+ "script-loader"
26
+ ],
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.mjs",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "import": {
33
+ "types": "./dist/index.d.mts",
34
+ "default": "./dist/index.mjs"
35
+ },
36
+ "require": {
37
+ "types": "./dist/index.d.ts",
38
+ "default": "./dist/index.js"
39
+ }
40
+ }
41
+ },
42
+ "sideEffects": false,
43
+ "files": [
44
+ "dist",
45
+ "README.md",
46
+ "LICENSE",
47
+ "CHANGELOG.md"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsdown",
51
+ "dev": "tsdown --watch",
52
+ "test": "jest",
53
+ "test:watch": "jest --watch",
54
+ "test:coverage": "jest --coverage",
55
+ "typecheck": "tsc --noEmit",
56
+ "lint": "eslint .",
57
+ "lint:fix": "eslint . --fix",
58
+ "format": "prettier --write \"**/*.{js,mjs,ts,tsx,json,md}\"",
59
+ "format:check": "prettier --check \"**/*.{js,mjs,ts,tsx,json,md}\"",
60
+ "clean": "rm -rf dist",
61
+ "prepublishOnly": "npm run clean && npm run check && npm run build",
62
+ "release": "npm publish --access public",
63
+ "check": "npm run format:check && npm run typecheck && npm run lint && npm run test",
64
+ "prepare": "husky || true",
65
+ "e2e:setup": "next-testing-toolkit build-fixture --port 3218",
66
+ "e2e": "npm run e2e:setup && playwright test -c e2e/playwright.config.ts"
67
+ },
68
+ "peerDependencies": {
69
+ "next": ">=16.0.0",
70
+ "react": ">=18.0.0",
71
+ "react-dom": ">=18.0.0"
72
+ },
73
+ "dependencies": {
74
+ "@silverassist/next-script-loader": "^0.1.0"
75
+ },
76
+ "devDependencies": {
77
+ "@playwright/test": "^1.62.1",
78
+ "@silverassist/next-testing-toolkit": "^0.2.1",
79
+ "@silverassist/npm-package-standards": "^0.1.0",
80
+ "@swc/core": "^1.16.1",
81
+ "@swc/jest": "^0.2.39",
82
+ "@testing-library/jest-dom": "^7.0.0",
83
+ "@testing-library/react": "^16.3.2",
84
+ "@types/jest": "^30.0.0",
85
+ "@types/node": "^26.0.0",
86
+ "@types/react": "^19.2.9",
87
+ "@types/react-dom": "^19.2.3",
88
+ "eslint": "^9.39.1",
89
+ "eslint-plugin-react": "^7.37.5",
90
+ "globals": "^17.11.0",
91
+ "husky": "^9.1.7",
92
+ "jest": "^30.2.0",
93
+ "jest-environment-jsdom": "^30.2.0",
94
+ "lint-staged": "^17.3.0",
95
+ "next": "^16.1.5",
96
+ "prettier": "^3.6.2",
97
+ "react": "^19.2.4",
98
+ "react-dom": "^19.2.4",
99
+ "tsdown": "^0.22.14",
100
+ "typescript": "^6.0.3",
101
+ "typescript-eslint": "^8.68.0"
102
+ },
103
+ "engines": {
104
+ "node": ">=22.0.0"
105
+ },
106
+ "lint-staged": {
107
+ "*.{ts,tsx,js,mjs,cjs,json,md}": [
108
+ "prettier --write"
109
+ ],
110
+ "*.{ts,tsx}": [
111
+ "eslint --fix"
112
+ ]
113
+ }
114
+ }