@fleegital/web 0.0.0-stage → 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,20 @@
1
+ # Changelog
2
+
3
+ This package runs on carriers' own websites, so a breaking change here breaks
4
+ someone else's site. Versions follow semver strictly, and anything that changes
5
+ what an existing call does gets a major.
6
+
7
+ ## Unreleased
8
+
9
+ ### Added
10
+ - `@fleegital/web/form`: `ApplicationForm` for a form that works out of the box,
11
+ and `useApplicationForm` for your own markup. Deduplicates applicants across
12
+ the whole company, records consent against the exact wording shown, carries an
13
+ anonymous first-visit id, and catches bots with a honeypot field.
14
+
15
+ - `@fleegital/web/analytics`: `createAnalytics` for website analytics —
16
+ automatic page views including SPA route changes, batching, a send when the
17
+ tab is hidden, device class, and the arriving campaign carried across the
18
+ whole visit. No cookie, no third-party script, nothing a person typed.
19
+ - `ApplicationForm` and `useApplicationForm` take an optional `analytics`, and
20
+ report `form_view`, `form_start` and `form_submit` into it.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fleegital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,178 @@
1
- # Temporary Holding Version
1
+ # @fleegital/web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The driver application form for carriers running Fleegital, as a package your
4
+ developers install instead of a script tag.
5
+
6
+ ```bash
7
+ npm install @fleegital/web
8
+ ```
9
+
10
+ Your publishable key is in Fleegital under **Settings → Data management →
11
+ Recruiting configuration → Website applications**, together with the list of
12
+ websites allowed to submit. A key that is not used from an allowed origin is
13
+ refused, so the key being public is not a way in.
14
+
15
+ ## The form
16
+
17
+ ```tsx
18
+ import { ApplicationForm } from "@fleegital/web/form";
19
+
20
+ <ApplicationForm
21
+ publishableKey="pk_live_…"
22
+ onSuccess={(message) => router.push("/thanks")}
23
+ onError={(message) => toast(message)}
24
+ className="my-form"
25
+ />
26
+ ```
27
+
28
+ The package ships no stylesheet. The markup is plain elements with your
29
+ `className` on the form, so your own CSS owns it.
30
+
31
+ ### Your own markup
32
+
33
+ ```tsx
34
+ import { useApplicationForm } from "@fleegital/web/form";
35
+
36
+ const f = useApplicationForm({ publishableKey: "pk_live_…" });
37
+
38
+ if (f.state === "loading") return <Spinner />;
39
+ if (f.state === "sent") return <p>{f.message}</p>;
40
+
41
+ <form onSubmit={async (e) => {
42
+ e.preventDefault();
43
+ const res = await f.submit();
44
+ if (!res.ok) showError(res.error);
45
+ }}>
46
+ <input value={f.values.first_name} onChange={(e) => f.setValue("first_name", e.target.value)} />
47
+ {f.errors.first_name?.[0] && <small>{f.errors.first_name[0]}</small>}
48
+
49
+ <label>
50
+ <input type="checkbox" checked={f.consentGranted}
51
+ onChange={(e) => f.setConsentGranted(e.target.checked)} />
52
+ {f.config?.consentText}
53
+ </label>
54
+
55
+ {/* A field no person sees. Leave it in: it is how bots are caught. */}
56
+ <input {...f.honeypotProps} tabIndex={-1} aria-hidden style={{ position: "absolute", left: -10000 }} />
57
+ </form>
58
+ ```
59
+
60
+ `f.config` carries the company name, the consent wording and version, the brand
61
+ colour and the logo — all from your Fleegital settings, so changing them there
62
+ changes the form without a deploy.
63
+
64
+ ## Website analytics
65
+
66
+ ```bash
67
+ npm install @fleegital/web
68
+ ```
69
+
70
+ ```ts
71
+ import { createAnalytics } from "@fleegital/web/analytics";
72
+
73
+ const analytics = createAnalytics({ publishableKey: "pk_live_…" });
74
+ ```
75
+
76
+ That is the whole setup. It records a page view on load and on every SPA route
77
+ change, batches events, and sends what is queued when the tab is hidden.
78
+
79
+ | | |
80
+ |---|---|
81
+ | `analytics.pageView(path?)` | Record a page view yourself (use with `autoPageViews: false`). |
82
+ | `analytics.track(event, path?)` | `page_view`, `form_view`, `form_start`, `form_submit`. |
83
+ | `analytics.flush()` | Send what is queued now. |
84
+ | `analytics.stop()` | Stop listening, unwrap `history`, send what is left. |
85
+
86
+ Options: `autoPageViews` (default `true`) and `flushInterval` in milliseconds
87
+ (default `3000`), plus `publishableKey` and `origin`.
88
+
89
+ ### The application funnel
90
+
91
+ Hand the analytics object to the form and the three funnel steps are recorded
92
+ for you — the form became usable, the applicant typed something, the
93
+ application was accepted:
94
+
95
+ ```tsx
96
+ <ApplicationForm publishableKey="pk_live_…" analytics={analytics} />
97
+ ```
98
+
99
+ Or with your own markup:
100
+
101
+ ```ts
102
+ const f = useApplicationForm({ publishableKey: "pk_live_…", analytics });
103
+ ```
104
+
105
+ Without it the page view is still recorded, but the funnel columns in Fleegital
106
+ stay empty — and a page view is not an attempt.
107
+
108
+ ### What it collects
109
+
110
+ An anonymous id for the browser, an id for the visit, the path, the referring
111
+ host when it is somebody else's, a device class of `mobile`, `tablet` or
112
+ `desktop`, and the `utm_*` tags the visit arrived on.
113
+
114
+ It sets **no cookie**, loads **no third-party script**, sends nothing to anyone
115
+ but your own Fleegital endpoint, and collects nothing a person typed. The ids
116
+ live in `localStorage` and `sessionStorage` and never leave the browser except
117
+ in these events. Where storage is blocked, nothing is recorded and nothing
118
+ breaks.
119
+
120
+ The campaign is remembered for the whole visit, not read fresh on each page: a
121
+ visitor who arrives from an ad and reads three pages counts as three pages for
122
+ that campaign.
123
+
124
+ ### It will not break your site
125
+
126
+ No call throws, no failure is retried, and nothing is logged to the console. A
127
+ page view is not worth an error on a carrier's careers page. On a browser
128
+ without `fetch` `keepalive` (Firefox before 133) a last unsent batch can be lost
129
+ when the tab closes; everything before it is already gone.
130
+
131
+ ## What it does that a plain `fetch` would not
132
+
133
+ - **Deduplicates across your whole company.** A driver who already applied, in
134
+ any phone format, updates their record instead of becoming a second lead.
135
+ - **Records consent** against the exact wording and version shown, which is what
136
+ the text-message gate reads before anything is sent.
137
+ - **Carries a first-visit id** so the lead can be traced to the visit it came
138
+ from. Anonymous, `localStorage` only, no cookie, nothing to a third party, and
139
+ silently skipped where storage is blocked.
140
+ - **Catches bots** with the honeypot field, without a CAPTCHA.
141
+
142
+ ## Outcomes
143
+
144
+ `submit()` resolves to `{ ok: true, message }` or
145
+ `{ ok: false, error, fields?, stale }`.
146
+
147
+ `stale` means your consent wording changed in Fleegital while the page was open:
148
+ reload it. `fields` carries per-field messages, keyed by field name.
149
+
150
+ Accepted applications, ones held for review and ones that tripped the spam trap
151
+ all get the same answer, deliberately — a submitter cannot learn which happened.
152
+ What a recruiter needs is in Fleegital.
153
+
154
+ ## Privacy
155
+
156
+ The package sets no cookie, loads no third-party script, and sends nothing
157
+ anywhere but your Fleegital intake endpoint on `www.fleegital.app`. The only
158
+ thing it stores is the anonymous visit id above.
159
+
160
+ ## Versions
161
+
162
+ This runs on your website, so a change here can break your site. Versions
163
+ follow semver strictly: anything that changes what an existing call does gets a
164
+ major. Pin the major and read [CHANGELOG.md](CHANGELOG.md) before moving it.
165
+
166
+ Published builds carry [npm provenance](https://docs.npmjs.com/generating-provenance-statements),
167
+ so you can verify the tarball you installed was built from a commit in this
168
+ repository.
169
+
170
+ ## Support
171
+
172
+ Bugs and documentation problems: [open an issue](https://github.com/fleegital/fleegital-web/issues).
173
+ Anything about your own company's keys, leads or settings belongs in the app,
174
+ not in a public issue. Security reports: see [SECURITY.md](SECURITY.md).
175
+
176
+ ## License
177
+
178
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,28 @@
1
+ import { type AnalyticsEventName, type FleegitalOptions } from "../shared/client.js";
2
+ export interface AnalyticsOptions extends FleegitalOptions {
3
+ /**
4
+ * Record a page view on load and on every SPA route change. Default true.
5
+ * Turn it off to call `pageView()` yourself from your router.
6
+ */
7
+ autoPageViews?: boolean;
8
+ /** How long events wait to be batched, in milliseconds. Default 3000. */
9
+ flushInterval?: number;
10
+ }
11
+ export interface Analytics {
12
+ /** Records a page view. Defaults to the current path. */
13
+ pageView: (path?: string) => void;
14
+ /** Records one of the funnel events. */
15
+ track: (event: AnalyticsEventName, path?: string) => void;
16
+ /** Sends whatever is queued now, rather than waiting for the timer. */
17
+ flush: () => void;
18
+ /** Stops listening and sends what is left. */
19
+ stop: () => void;
20
+ }
21
+ /**
22
+ * Starts collecting. Returns a no-op on the server, so importing this in a
23
+ * framework that renders on the server costs nothing and breaks nothing.
24
+ *
25
+ * Nothing here throws into the host page. A carrier's careers site must not
26
+ * go down because a page view could not be counted.
27
+ */
28
+ export declare function createAnalytics(options: AnalyticsOptions): Analytics;
@@ -0,0 +1,162 @@
1
+ import { sendEvents, MAX_BATCH, } from "../shared/client.js";
2
+ import { visitorId } from "../shared/visitor.js";
3
+ import { sessionId, visitUtm } from "../shared/session.js";
4
+ import { device } from "../shared/device.js";
5
+ /**
6
+ * The queue is bounded. A tab left open for a day with no network should cost
7
+ * a fixed amount of memory, not a growing one. Past the cap the oldest events
8
+ * go, because what someone is doing now is worth more than what they did an
9
+ * hour ago on a page that never reported.
10
+ */
11
+ const MAX_QUEUED = 200;
12
+ const noop = {
13
+ pageView: () => { },
14
+ track: () => { },
15
+ flush: () => { },
16
+ stop: () => { },
17
+ };
18
+ function currentPath() {
19
+ try {
20
+ return window.location.pathname + window.location.search;
21
+ }
22
+ catch {
23
+ return "/";
24
+ }
25
+ }
26
+ /** The referring host, but only when it is somebody else's. */
27
+ function referrerHost() {
28
+ try {
29
+ if (!document.referrer)
30
+ return undefined;
31
+ const u = new URL(document.referrer);
32
+ if (u.host === window.location.host)
33
+ return undefined;
34
+ return u.host;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /**
41
+ * Starts collecting. Returns a no-op on the server, so importing this in a
42
+ * framework that renders on the server costs nothing and breaks nothing.
43
+ *
44
+ * Nothing here throws into the host page. A carrier's careers site must not
45
+ * go down because a page view could not be counted.
46
+ */
47
+ export function createAnalytics(options) {
48
+ if (typeof window === "undefined" || typeof document === "undefined")
49
+ return noop;
50
+ const { publishableKey, origin } = options;
51
+ const interval = Math.max(500, options.flushInterval ?? 3000);
52
+ const auto = options.autoPageViews ?? true;
53
+ let queue = [];
54
+ let timer;
55
+ let stopped = false;
56
+ const send = (keepalive) => {
57
+ if (queue.length === 0)
58
+ return;
59
+ const batch = queue.slice(0, MAX_BATCH);
60
+ queue = queue.slice(MAX_BATCH);
61
+ // Deliberately not awaited: the caller is a click or a navigation.
62
+ void sendEvents({ publishableKey, origin }, batch, keepalive);
63
+ };
64
+ const schedule = () => {
65
+ if (timer !== undefined || stopped)
66
+ return;
67
+ timer = setTimeout(() => {
68
+ timer = undefined;
69
+ send(false);
70
+ }, interval);
71
+ };
72
+ const push = (event, path) => {
73
+ if (stopped)
74
+ return;
75
+ const vid = visitorId();
76
+ // No id, no event: the server requires one, and an event that cannot be
77
+ // tied to a visit cannot be counted as a visitor either.
78
+ if (!vid)
79
+ return;
80
+ const utm = visitUtm();
81
+ queue.push({
82
+ event_name: event,
83
+ path: (path ?? currentPath()).slice(0, 400),
84
+ visitor_id: vid,
85
+ session_id: sessionId(),
86
+ referrer_host: referrerHost(),
87
+ device: device(),
88
+ utm: Object.keys(utm).length > 0 ? utm : undefined,
89
+ occurred_at: new Date().toISOString(),
90
+ });
91
+ if (queue.length > MAX_QUEUED)
92
+ queue = queue.slice(queue.length - MAX_QUEUED);
93
+ schedule();
94
+ };
95
+ // A tab being hidden is the last reliable moment to send. pagehide is the
96
+ // backstop for browsers that go straight there, and both can fire, which is
97
+ // harmless: the queue is emptied as it is read.
98
+ const onHide = () => {
99
+ if (document.visibilityState === "hidden")
100
+ send(true);
101
+ };
102
+ const onPageHide = () => send(true);
103
+ document.addEventListener("visibilitychange", onHide);
104
+ window.addEventListener("pagehide", onPageHide);
105
+ // SPA navigation. pushState and replaceState fire no event of their own, so
106
+ // they are wrapped; popstate covers the back button. Both are put back as
107
+ // they were on stop(), so an app that mounts and unmounts does not stack
108
+ // wrappers.
109
+ const origPush = window.history.pushState;
110
+ const origReplace = window.history.replaceState;
111
+ const onPop = () => push("page_view");
112
+ if (auto) {
113
+ const wrap = (fn) => function (...args) {
114
+ const r = fn.apply(this, args);
115
+ // After the URL has changed, not before.
116
+ push("page_view");
117
+ return r;
118
+ };
119
+ try {
120
+ window.history.pushState = wrap(origPush);
121
+ window.history.replaceState = wrap(origReplace);
122
+ window.addEventListener("popstate", onPop);
123
+ }
124
+ catch {
125
+ /* a page that froze history keeps manual pageView() */
126
+ }
127
+ push("page_view");
128
+ }
129
+ return {
130
+ pageView: (path) => push("page_view", path),
131
+ track: (event, path) => push(event, path),
132
+ flush: () => {
133
+ if (timer !== undefined) {
134
+ clearTimeout(timer);
135
+ timer = undefined;
136
+ }
137
+ send(false);
138
+ },
139
+ stop: () => {
140
+ if (stopped)
141
+ return;
142
+ stopped = true;
143
+ if (timer !== undefined) {
144
+ clearTimeout(timer);
145
+ timer = undefined;
146
+ }
147
+ document.removeEventListener("visibilitychange", onHide);
148
+ window.removeEventListener("pagehide", onPageHide);
149
+ if (auto) {
150
+ try {
151
+ window.history.pushState = origPush;
152
+ window.history.replaceState = origReplace;
153
+ window.removeEventListener("popstate", onPop);
154
+ }
155
+ catch {
156
+ /* nothing to put back */
157
+ }
158
+ }
159
+ send(true);
160
+ },
161
+ };
162
+ }
@@ -0,0 +1,6 @@
1
+ export { createAnalytics } from "./analytics.js";
2
+ export type { Analytics, AnalyticsOptions } from "./analytics.js";
3
+ export type { AnalyticsEventName } from "../shared/client.js";
4
+ export { ANALYTICS_EVENTS } from "../shared/client.js";
5
+ export { visitorId } from "../shared/visitor.js";
6
+ export { sessionId } from "../shared/session.js";
@@ -0,0 +1,4 @@
1
+ export { createAnalytics } from "./analytics.js";
2
+ export { ANALYTICS_EVENTS } from "../shared/client.js";
3
+ export { visitorId } from "../shared/visitor.js";
4
+ export { sessionId } from "../shared/session.js";
@@ -0,0 +1,16 @@
1
+ import type { ApplicationValues, EventSink, FleegitalOptions } from "../shared/client.js";
2
+ /** Fields a carrier can ask for, in the order they are shown. */
3
+ export type FieldName = keyof ApplicationValues;
4
+ export interface ApplicationFormProps extends FleegitalOptions {
5
+ /** Pass what `createAnalytics()` returned to record the funnel steps. */
6
+ analytics?: EventSink;
7
+ fields?: FieldName[];
8
+ labels?: Partial<Record<FieldName, string>>;
9
+ /** Called once the server has accepted the submission. */
10
+ onSuccess?: (message: string) => void;
11
+ onError?: (message: string) => void;
12
+ /** Put your own class on the <form>; the package ships no stylesheet. */
13
+ className?: string;
14
+ submitLabel?: string;
15
+ }
16
+ export declare function ApplicationForm(props: ApplicationFormProps): import("react").JSX.Element;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useApplicationForm } from "./useApplicationForm.js";
3
+ const DEFAULT_FIELDS = ["first_name", "last_name", "phone", "email", "cdl_class", "experience_years"];
4
+ const DEFAULT_LABELS = {
5
+ first_name: "First name",
6
+ last_name: "Last name",
7
+ phone: "Mobile phone",
8
+ email: "Email",
9
+ cdl_class: "CDL class",
10
+ experience_years: "Years driving",
11
+ position: "Position",
12
+ location: "Location",
13
+ notes: "Anything else",
14
+ };
15
+ const REQUIRED = ["first_name", "phone"];
16
+ /*
17
+ * A plain, unstyled form. It carries no CSS of its own beyond what is needed to
18
+ * hide the spam trap, because a carrier's site has its own design and a package
19
+ * that arrives with opinions about colour is a package they fight. Anyone who
20
+ * wants a different layout uses `useApplicationForm` and writes their own.
21
+ */
22
+ const HIDDEN = {
23
+ position: "absolute",
24
+ left: "-10000px",
25
+ width: 1,
26
+ height: 1,
27
+ overflow: "hidden",
28
+ };
29
+ export function ApplicationForm(props) {
30
+ const { publishableKey, origin, analytics, fields = DEFAULT_FIELDS, labels, onSuccess, onError, className, submitLabel } = props;
31
+ const f = useApplicationForm({ publishableKey, origin, analytics });
32
+ if (f.state === "loading")
33
+ return _jsx("p", { children: "Loading\u2026" });
34
+ if (f.state === "error" && !f.config)
35
+ return _jsx("p", { children: f.error ?? "This form is unavailable." });
36
+ if (f.state === "sent")
37
+ return _jsx("p", { children: f.message });
38
+ const label = (name) => labels?.[name] ?? DEFAULT_LABELS[name];
39
+ return (_jsxs("form", { className: className, noValidate: true, onSubmit: (e) => {
40
+ e.preventDefault();
41
+ void f.submit().then((res) => {
42
+ // From the returned value, not from `f`: the state read here would be
43
+ // the one this render closed over, before the submission ran — and it
44
+ // carries the real reason, so a caller can tell a rejected field from
45
+ // a refused origin.
46
+ if (res.ok)
47
+ onSuccess?.(res.message);
48
+ else
49
+ onError?.(res.error);
50
+ });
51
+ }, children: [f.config?.companyName && _jsxs("h3", { children: ["Drive for ", f.config.companyName] }), fields.map((name) => {
52
+ const id = `fga-${name}`;
53
+ const fieldErrors = f.errors[name] ?? [];
54
+ return (_jsxs("div", { children: [_jsxs("label", { htmlFor: id, children: [label(name), REQUIRED.includes(name) ? " *" : ""] }), _jsx("input", { id: id, name: name, type: name === "email" ? "email" : name === "phone" ? "tel" : "text", value: f.values[name] ?? "", required: REQUIRED.includes(name), "aria-invalid": fieldErrors.length > 0 || undefined, "aria-describedby": fieldErrors.length > 0 ? `${id}-error` : undefined, onChange: (e) => f.setValue(name, e.target.value) }), fieldErrors.length > 0 && _jsx("p", { id: `${id}-error`, children: fieldErrors[0] })] }, name));
55
+ }), _jsx("input", { ...f.honeypotProps, tabIndex: -1, autoComplete: "off", "aria-hidden": "true", style: HIDDEN }), _jsxs("div", { children: [_jsx("input", { id: "fga-consent", type: "checkbox", checked: f.consentGranted, onChange: (e) => f.setConsentGranted(e.target.checked) }), _jsx("label", { htmlFor: "fga-consent", children: f.config?.consentText })] }), f.error && _jsx("p", { role: "alert", children: f.stale ? "This form has been updated. Please reload the page and try again." : f.error }), _jsx("button", { type: "submit", disabled: f.state === "sending", children: f.state === "sending" ? "Sending…" : (submitLabel ?? "Apply") })] }));
56
+ }
@@ -0,0 +1,7 @@
1
+ export { ApplicationForm } from "./ApplicationForm.js";
2
+ export type { ApplicationFormProps, FieldName } from "./ApplicationForm.js";
3
+ export { useApplicationForm } from "./useApplicationForm.js";
4
+ export type { UseApplicationForm, UseApplicationFormOptions, FormState, SubmitOutcome } from "./useApplicationForm.js";
5
+ export { IntakeError } from "../shared/client.js";
6
+ export type { ApplicationValues, FieldErrors, FleegitalOptions, IntakeConfig, SubmitResult, EventSink, } from "../shared/client.js";
7
+ export { visitorId } from "../shared/visitor.js";
@@ -0,0 +1,4 @@
1
+ export { ApplicationForm } from "./ApplicationForm.js";
2
+ export { useApplicationForm } from "./useApplicationForm.js";
3
+ export { IntakeError } from "../shared/client.js";
4
+ export { visitorId } from "../shared/visitor.js";
@@ -0,0 +1,60 @@
1
+ import { type ApplicationValues, type FieldErrors, type EventSink, type FleegitalOptions, type IntakeConfig } from "../shared/client.js";
2
+ export type FormState = "loading" | "ready" | "sending" | "sent" | "error";
3
+ export interface UseApplicationFormOptions extends FleegitalOptions {
4
+ /**
5
+ * Where to report the three funnel steps: the form became usable, the
6
+ * applicant typed something, the application was accepted. Pass the object
7
+ * `createAnalytics()` returned. Without it the form reports nothing, and
8
+ * the funnel columns in Fleegital stay empty — the page view is recorded
9
+ * either way, by the analytics package, but a page view is not an attempt.
10
+ */
11
+ analytics?: EventSink;
12
+ }
13
+ /** The result of one submission, carrying the reason when it failed. */
14
+ export type SubmitOutcome = {
15
+ ok: true;
16
+ message: string;
17
+ } | {
18
+ ok: false;
19
+ error: string;
20
+ fields?: FieldErrors;
21
+ stale: boolean;
22
+ };
23
+ export interface UseApplicationForm {
24
+ /** Null until the company's wording and branding have loaded. */
25
+ config: IntakeConfig | null;
26
+ state: FormState;
27
+ values: ApplicationValues;
28
+ setValue: <K extends keyof ApplicationValues>(field: K, value: ApplicationValues[K]) => void;
29
+ consentGranted: boolean;
30
+ setConsentGranted: (granted: boolean) => void;
31
+ /** Per-field messages from the server, cleared as soon as the field changes. */
32
+ errors: FieldErrors;
33
+ /** Whole-form message: a network failure, or the wording having changed. */
34
+ error: string | null;
35
+ /** True when the consent wording changed and the page must be reloaded. */
36
+ stale: boolean;
37
+ /** The server's own thank-you wording, once sent. */
38
+ message: string | null;
39
+ /**
40
+ * Returns what happened. The outcome is returned rather than only put in
41
+ * state because a caller that awaits this and then reads `state` reads the
42
+ * render it started from, not the one the submission produced.
43
+ */
44
+ submit: () => Promise<SubmitOutcome>;
45
+ /** Bind to a field no person can see; a value in it means a bot filled it. */
46
+ honeypotProps: {
47
+ name: string;
48
+ value: string;
49
+ onChange: (e: {
50
+ target: {
51
+ value: string;
52
+ };
53
+ }) => void;
54
+ };
55
+ }
56
+ /**
57
+ * The form without any markup, for carriers who want their own.
58
+ * `ApplicationForm` is this hook plus a plain, unstyled layout.
59
+ */
60
+ export declare function useApplicationForm(options: UseApplicationFormOptions): UseApplicationForm;
@@ -0,0 +1,110 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { fetchIntakeConfig, submitApplication, IntakeError, } from "../shared/client.js";
3
+ import { visitorId } from "../shared/visitor.js";
4
+ /**
5
+ * The form without any markup, for carriers who want their own.
6
+ * `ApplicationForm` is this hook plus a plain, unstyled layout.
7
+ */
8
+ export function useApplicationForm(options) {
9
+ const { publishableKey, origin, analytics } = options;
10
+ const [config, setConfig] = useState(null);
11
+ const [state, setState] = useState("loading");
12
+ const [values, setValues] = useState({ first_name: "" });
13
+ const [consentGranted, setConsentGranted] = useState(false);
14
+ const [errors, setErrors] = useState({});
15
+ const [error, setError] = useState(null);
16
+ const [stale, setStale] = useState(false);
17
+ const [message, setMessage] = useState(null);
18
+ const honeypot = useRef("");
19
+ // Each step is reported once per mounted form. A re-render is not a new
20
+ // attempt, and a second keystroke is not a second start.
21
+ const reported = useRef({ view: false, start: false });
22
+ // Held in a ref so that passing a fresh object every render — which a
23
+ // caller doing `analytics={createAnalytics(...)}` inline would — does not
24
+ // re-run the effect below and report the form as viewed again.
25
+ const sink = useRef(analytics);
26
+ sink.current = analytics;
27
+ useEffect(() => {
28
+ const ac = new AbortController();
29
+ setState("loading");
30
+ fetchIntakeConfig({ publishableKey, origin }, ac.signal)
31
+ .then((cfg) => {
32
+ setConfig(cfg);
33
+ setState("ready");
34
+ // Reported here rather than on mount: until the company's wording has
35
+ // loaded there is no form to have seen, only a spinner.
36
+ if (!reported.current.view) {
37
+ reported.current.view = true;
38
+ sink.current?.track("form_view");
39
+ }
40
+ })
41
+ .catch((e) => {
42
+ if (ac.signal.aborted)
43
+ return;
44
+ setError(e instanceof Error ? e.message : "This form is unavailable.");
45
+ setState("error");
46
+ });
47
+ return () => ac.abort();
48
+ }, [publishableKey, origin]);
49
+ const setValue = useCallback((field, value) => {
50
+ if (!reported.current.start) {
51
+ reported.current.start = true;
52
+ sink.current?.track("form_start");
53
+ }
54
+ setValues((v) => ({ ...v, [field]: value }));
55
+ // A message about what the applicant just changed is already out of date.
56
+ setErrors((e) => (e[field] ? { ...e, [field]: [] } : e));
57
+ }, []);
58
+ const submit = useCallback(async () => {
59
+ if (!config)
60
+ return { ok: false, error: "This form is not ready yet.", stale: false };
61
+ if (state === "sending")
62
+ return { ok: false, error: "Already sending.", stale: false };
63
+ setState("sending");
64
+ setError(null);
65
+ setErrors({});
66
+ try {
67
+ const res = await submitApplication({
68
+ publishableKey,
69
+ origin,
70
+ values,
71
+ consentGranted,
72
+ consentVersion: config.consentVersion,
73
+ visitorId: visitorId(),
74
+ honeypot: honeypot.current,
75
+ });
76
+ setMessage(res.message);
77
+ setState("sent");
78
+ // Only on acceptance. A rejected submission is not a submission, and
79
+ // counting it would make the funnel wider at the bottom than the top.
80
+ sink.current?.track("form_submit");
81
+ return { ok: true, message: res.message };
82
+ }
83
+ catch (e) {
84
+ setState("ready");
85
+ if (e instanceof IntakeError) {
86
+ if (e.fields)
87
+ setErrors(e.fields);
88
+ setStale(e.stale);
89
+ setError(e.message);
90
+ return e.fields
91
+ ? { ok: false, error: e.message, fields: e.fields, stale: e.stale }
92
+ : { ok: false, error: e.message, stale: e.stale };
93
+ }
94
+ const msg = "Something went wrong. Please try again.";
95
+ setError(msg);
96
+ return { ok: false, error: msg, stale: false };
97
+ }
98
+ }, [config, state, publishableKey, origin, values, consentGranted]);
99
+ const honeypotProps = useMemo(() => ({
100
+ name: "company_website",
101
+ value: honeypot.current,
102
+ onChange: (e) => {
103
+ honeypot.current = e.target.value;
104
+ },
105
+ }), []);
106
+ return {
107
+ config, state, values, setValue, consentGranted, setConsentGranted,
108
+ errors, error, stale, message, submit, honeypotProps,
109
+ };
110
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Everything the package knows about reaching Fleegital.
3
+ *
4
+ * One rule shapes this file: a carrier's developer only ever sees our own
5
+ * domain. The backend host is never printed, never configurable by accident,
6
+ * and never reachable from a carrier page — `origin` exists for local
7
+ * development and for carriers we put behind their own apply subdomain.
8
+ */
9
+ /** Where the intake endpoint lives unless the caller says otherwise. */
10
+ export declare const DEFAULT_ORIGIN = "https://www.fleegital.app";
11
+ /**
12
+ * The door this package submits through. It is checked exactly like the script
13
+ * tag — same publishable key, same origin allowlist — and named apart so the
14
+ * Website applications screen can show how many carriers installed the package.
15
+ */
16
+ export declare const DOOR: "npm";
17
+ export interface FleegitalOptions {
18
+ /** `pk_…`, from Settings → Recruiting configuration → Website applications. */
19
+ publishableKey: string;
20
+ /** Override only for local development or a carrier-specific apply domain. */
21
+ origin?: string;
22
+ }
23
+ /** What the form needs from the server before it can render. */
24
+ export interface IntakeConfig {
25
+ companyName: string | null;
26
+ consentText: string;
27
+ /** Echoed back on submit; a mismatch means the wording changed under us. */
28
+ consentVersion: string;
29
+ primaryColor: string | null;
30
+ /** Already rewritten to our own domain by the server. */
31
+ logoUrl: string | null;
32
+ }
33
+ /** A field-level rejection, as the endpoint reports it. */
34
+ export type FieldErrors = Record<string, string[]>;
35
+ export declare class IntakeError extends Error {
36
+ readonly status: number;
37
+ readonly fields: FieldErrors | undefined;
38
+ /** True when the form must be reloaded: the consent wording changed. */
39
+ readonly stale: boolean;
40
+ constructor(message: string, status: number, fields?: FieldErrors);
41
+ }
42
+ /** Reads the company name, consent wording and brand colour for a key. */
43
+ export declare function fetchIntakeConfig(options: FleegitalOptions, signal?: AbortSignal): Promise<IntakeConfig>;
44
+ /** The answers one applicant gave. Every field is optional but first name. */
45
+ export interface ApplicationValues {
46
+ first_name: string;
47
+ last_name?: string;
48
+ phone?: string;
49
+ email?: string;
50
+ cdl_class?: string;
51
+ experience_years?: string;
52
+ position?: string;
53
+ location?: string;
54
+ notes?: string;
55
+ }
56
+ /**
57
+ * What the endpoint tells a carrier's page, which is deliberately very little.
58
+ *
59
+ * There is no lead id and no outcome here, and that is on purpose: an accepted
60
+ * application, one held for review, and one that tripped the spam trap all get
61
+ * the same answer, so a submitter cannot learn which happened. Anything a
62
+ * recruiter needs is in the app.
63
+ */
64
+ export interface SubmitResult {
65
+ /** The wording to show the applicant, as the server phrased it. */
66
+ message: string;
67
+ }
68
+ export interface SubmitArgs extends FleegitalOptions {
69
+ values: ApplicationValues;
70
+ consentGranted: boolean;
71
+ consentVersion: string;
72
+ /** Anonymous first-party id, so the lead can be traced to the first visit. */
73
+ visitorId?: string | undefined;
74
+ /** Spam trap: the value of a field a person never sees. Must stay empty. */
75
+ honeypot?: string;
76
+ pageUrl?: string;
77
+ signal?: AbortSignal;
78
+ }
79
+ export declare function submitApplication(args: SubmitArgs): Promise<SubmitResult>;
80
+ /** The four names the server counts. A fifth would be stored and never read. */
81
+ export declare const ANALYTICS_EVENTS: readonly ["page_view", "form_view", "form_start", "form_submit"];
82
+ export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[number];
83
+ /**
84
+ * Somewhere to report a funnel event. `Analytics` satisfies it structurally,
85
+ * so the form can report into it without importing the analytics module —
86
+ * a carrier who only installs the form ships none of the analytics code.
87
+ */
88
+ export interface EventSink {
89
+ track: (event: AnalyticsEventName, path?: string) => void;
90
+ }
91
+ /** One event, in the shape the endpoint reads. */
92
+ export interface AnalyticsEvent {
93
+ event_name: AnalyticsEventName;
94
+ path: string;
95
+ visitor_id: string;
96
+ session_id?: string | undefined;
97
+ referrer_host?: string | undefined;
98
+ device?: string | undefined;
99
+ utm?: Record<string, string> | undefined;
100
+ /** ISO 8601. The server clamps it into the last two days. */
101
+ occurred_at: string;
102
+ }
103
+ /** The server's answer, which says nothing a caller can act on. */
104
+ export interface SendEventsResult {
105
+ accepted: number;
106
+ dropped: number;
107
+ }
108
+ /** The endpoint accepts at most this many events in one request. */
109
+ export declare const MAX_BATCH = 50;
110
+ /**
111
+ * Sends a batch. Never throws and never rejects: a page view is not worth
112
+ * breaking, retrying or logging on someone else's website, and the endpoint
113
+ * answers 202 for every outcome anyway, so there is nothing to branch on.
114
+ *
115
+ * `keepalive` lets the request outlive the page, which is the whole point
116
+ * when a tab is closing. sendBeacon would be the other way, but it cannot
117
+ * set the key header, and moving the key into the URL to suit it would put
118
+ * it in every proxy log for no gain.
119
+ */
120
+ export declare function sendEvents(options: FleegitalOptions, events: AnalyticsEvent[], keepalive?: boolean): Promise<SendEventsResult | undefined>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Everything the package knows about reaching Fleegital.
3
+ *
4
+ * One rule shapes this file: a carrier's developer only ever sees our own
5
+ * domain. The backend host is never printed, never configurable by accident,
6
+ * and never reachable from a carrier page — `origin` exists for local
7
+ * development and for carriers we put behind their own apply subdomain.
8
+ */
9
+ /** Where the intake endpoint lives unless the caller says otherwise. */
10
+ export const DEFAULT_ORIGIN = "https://www.fleegital.app";
11
+ /**
12
+ * The door this package submits through. It is checked exactly like the script
13
+ * tag — same publishable key, same origin allowlist — and named apart so the
14
+ * Website applications screen can show how many carriers installed the package.
15
+ */
16
+ export const DOOR = "npm";
17
+ export class IntakeError extends Error {
18
+ constructor(message, status, fields) {
19
+ super(message);
20
+ this.name = "IntakeError";
21
+ this.status = status;
22
+ this.fields = fields;
23
+ this.stale = status === 409;
24
+ }
25
+ }
26
+ const endpoint = (origin) => `${(origin ?? DEFAULT_ORIGIN).replace(/\/+$/, "")}/api/intake`;
27
+ async function readError(res) {
28
+ let body = {};
29
+ try {
30
+ body = (await res.json());
31
+ }
32
+ catch {
33
+ /* a proxy or a network device answered instead of the API */
34
+ }
35
+ const first = body.fields && Object.values(body.fields)[0]?.[0];
36
+ return new IntakeError(first ?? body.error ?? "The application could not be sent.", res.status, body.fields);
37
+ }
38
+ /** Reads the company name, consent wording and brand colour for a key. */
39
+ export async function fetchIntakeConfig(options, signal) {
40
+ const res = await fetch(`${endpoint(options.origin)}?key=${encodeURIComponent(options.publishableKey)}`, {
41
+ signal,
42
+ });
43
+ if (!res.ok)
44
+ throw await readError(res);
45
+ const cfg = (await res.json());
46
+ return {
47
+ companyName: cfg.company_name ?? null,
48
+ consentText: String(cfg.consent_text ?? ""),
49
+ consentVersion: String(cfg.consent_version ?? ""),
50
+ primaryColor: cfg.primary_color ?? null,
51
+ logoUrl: cfg.logo_url ?? null,
52
+ };
53
+ }
54
+ export async function submitApplication(args) {
55
+ const res = await fetch(endpoint(args.origin), {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json", "x-fleegital-key": args.publishableKey },
58
+ signal: args.signal,
59
+ body: JSON.stringify({
60
+ door: DOOR,
61
+ page_url: args.pageUrl ?? (typeof location === "undefined" ? undefined : location.href),
62
+ company_website: args.honeypot ?? "",
63
+ visitor_id: args.visitorId,
64
+ consent: { granted: args.consentGranted, version: args.consentVersion },
65
+ ...args.values,
66
+ }),
67
+ });
68
+ if (!res.ok)
69
+ throw await readError(res);
70
+ const body = (await res.json());
71
+ return { message: body.message ?? "Thank you. Your application was received." };
72
+ }
73
+ /* ------------------------------------------------------------------ *
74
+ * Analytics transport
75
+ * ------------------------------------------------------------------ */
76
+ /** The four names the server counts. A fifth would be stored and never read. */
77
+ export const ANALYTICS_EVENTS = ["page_view", "form_view", "form_start", "form_submit"];
78
+ /** The endpoint accepts at most this many events in one request. */
79
+ export const MAX_BATCH = 50;
80
+ const analyticsEndpoint = (origin) => `${(origin ?? DEFAULT_ORIGIN).replace(/\/+$/, "")}/api/analytics`;
81
+ /**
82
+ * Sends a batch. Never throws and never rejects: a page view is not worth
83
+ * breaking, retrying or logging on someone else's website, and the endpoint
84
+ * answers 202 for every outcome anyway, so there is nothing to branch on.
85
+ *
86
+ * `keepalive` lets the request outlive the page, which is the whole point
87
+ * when a tab is closing. sendBeacon would be the other way, but it cannot
88
+ * set the key header, and moving the key into the URL to suit it would put
89
+ * it in every proxy log for no gain.
90
+ */
91
+ export async function sendEvents(options, events, keepalive = false) {
92
+ if (events.length === 0)
93
+ return { accepted: 0, dropped: 0 };
94
+ try {
95
+ const res = await fetch(analyticsEndpoint(options.origin), {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json", "x-fleegital-key": options.publishableKey },
98
+ body: JSON.stringify({ events: events.slice(0, MAX_BATCH) }),
99
+ keepalive,
100
+ });
101
+ const body = (await res.json());
102
+ return { accepted: body.accepted ?? 0, dropped: body.dropped ?? 0 };
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Which of the three kinds of device this is, or nothing.
3
+ *
4
+ * The server accepts only `mobile`, `tablet` and `desktop` and drops anything
5
+ * else, so guessing wrongly costs the field rather than the event. Returning
6
+ * undefined is a fine answer and better than a confident wrong one.
7
+ *
8
+ * Deliberately not a user-agent parsing library: those are large, need
9
+ * constant updating, and are a fingerprinting surface. This asks the browser
10
+ * where the browser will say, and falls back to two coarse patterns.
11
+ */
12
+ export type Device = "mobile" | "tablet" | "desktop";
13
+ export declare function device(): Device | undefined;
@@ -0,0 +1,33 @@
1
+ export function device() {
2
+ if (typeof navigator === "undefined")
3
+ return undefined;
4
+ try {
5
+ const ua = navigator.userAgent || "";
6
+ // iPadOS reports itself as a Mac; the touch points give it away.
7
+ const iPad = /iPad/.test(ua) ||
8
+ (/Macintosh/.test(ua) && typeof navigator.maxTouchPoints === "number" && navigator.maxTouchPoints > 1);
9
+ if (iPad)
10
+ return "tablet";
11
+ // Android puts "Mobile" in the string for phones and leaves it out for tablets.
12
+ if (/Android/.test(ua))
13
+ return /Mobile/.test(ua) ? "mobile" : "tablet";
14
+ // Named devices before the coarse signal. `userAgentData.mobile` is a
15
+ // single boolean that Chromium answers from its own build, not from the
16
+ // user-agent string, so it reports a spoofed or overridden phone as a
17
+ // desktop — which is how this function first got an iPhone wrong.
18
+ if (/iPhone|iPod|Windows Phone/.test(ua))
19
+ return "mobile";
20
+ if (/Tablet|PlayBook|Silk|Kindle/.test(ua))
21
+ return "tablet";
22
+ // Then let the browser answer where it will.
23
+ const uaData = navigator.userAgentData;
24
+ if (uaData && typeof uaData.mobile === "boolean")
25
+ return uaData.mobile ? "mobile" : "desktop";
26
+ if (/Mobi/.test(ua))
27
+ return "mobile";
28
+ return "desktop";
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
@@ -0,0 +1,10 @@
1
+ /** The five tags the server stores. Anything else in the URL is ignored. */
2
+ export declare const UTM_PARAMS: readonly ["source", "medium", "campaign", "content", "term"];
3
+ export type Utm = Partial<Record<(typeof UTM_PARAMS)[number], string>>;
4
+ /** The id for this visit, creating one on first use. */
5
+ export declare function sessionId(): string | undefined;
6
+ /**
7
+ * The campaign this visit arrived on: taken from the URL the first time any
8
+ * utm_* parameter appears, and kept for the rest of the visit.
9
+ */
10
+ export declare function visitUtm(): Utm;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Per-visit state: a session id, and the campaign the visit arrived on.
3
+ *
4
+ * sessionStorage, not localStorage: this is meant to end when the tab does.
5
+ * Every access is guarded for the same reason as visitor.ts — in a private
6
+ * window or with site data blocked the accessor itself throws, and analytics
7
+ * must never be the reason a carrier's page breaks.
8
+ *
9
+ * The campaign is remembered for the whole visit rather than read fresh on
10
+ * each page. A visitor who arrives from an ad and then reads three pages
11
+ * should count as three pages for that campaign; reading the URL each time
12
+ * would credit the landing page and lose the rest.
13
+ */
14
+ const SESSION_KEY = "fga_sid";
15
+ const UTM_KEY = "fga_utm";
16
+ /** The five tags the server stores. Anything else in the URL is ignored. */
17
+ export const UTM_PARAMS = ["source", "medium", "campaign", "content", "term"];
18
+ function read(key) {
19
+ try {
20
+ return window.sessionStorage.getItem(key);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function write(key, value) {
27
+ try {
28
+ window.sessionStorage.setItem(key, value);
29
+ }
30
+ catch {
31
+ /* storage is closed; the visit simply carries no id */
32
+ }
33
+ }
34
+ function makeId() {
35
+ const bytes = new Uint8Array(12);
36
+ const c = typeof crypto !== "undefined" ? crypto : undefined;
37
+ if (c && typeof c.getRandomValues === "function")
38
+ c.getRandomValues(bytes);
39
+ else
40
+ for (let i = 0; i < bytes.length; i++)
41
+ bytes[i] = Math.floor(Math.random() * 256);
42
+ let out = "s";
43
+ for (const b of bytes)
44
+ out += b.toString(36).padStart(2, "0");
45
+ return out.slice(0, 32);
46
+ }
47
+ /** The id for this visit, creating one on first use. */
48
+ export function sessionId() {
49
+ if (typeof window === "undefined")
50
+ return undefined;
51
+ const found = read(SESSION_KEY);
52
+ if (found)
53
+ return found;
54
+ const created = makeId();
55
+ write(SESSION_KEY, created);
56
+ return created;
57
+ }
58
+ /**
59
+ * The campaign this visit arrived on: taken from the URL the first time any
60
+ * utm_* parameter appears, and kept for the rest of the visit.
61
+ */
62
+ export function visitUtm() {
63
+ if (typeof window === "undefined")
64
+ return {};
65
+ let fromUrl = {};
66
+ try {
67
+ const q = new URLSearchParams(window.location.search);
68
+ for (const p of UTM_PARAMS) {
69
+ const v = q.get("utm_" + p);
70
+ if (v)
71
+ fromUrl[p] = v.slice(0, 200);
72
+ }
73
+ }
74
+ catch {
75
+ fromUrl = {};
76
+ }
77
+ if (Object.keys(fromUrl).length > 0) {
78
+ write(UTM_KEY, JSON.stringify(fromUrl));
79
+ return fromUrl;
80
+ }
81
+ const stored = read(UTM_KEY);
82
+ if (!stored)
83
+ return {};
84
+ try {
85
+ const parsed = JSON.parse(stored);
86
+ if (!parsed || typeof parsed !== "object")
87
+ return {};
88
+ const out = {};
89
+ for (const p of UTM_PARAMS) {
90
+ const v = parsed[p];
91
+ if (typeof v === "string" && v)
92
+ out[p] = v;
93
+ }
94
+ return out;
95
+ }
96
+ catch {
97
+ return {};
98
+ }
99
+ }
@@ -0,0 +1,2 @@
1
+ /** The id for this browser, creating one on first use. Undefined if storage is closed. */
2
+ export declare function visitorId(): string | undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * An anonymous id for this browser, so an application can be traced back to the
3
+ * visit it came from.
4
+ *
5
+ * localStorage only: no cookie, nothing sent to a third party, and nothing that
6
+ * identifies a person. Every read and write is guarded, because in a private
7
+ * window or with site data blocked the accessor itself throws — and a form that
8
+ * fails to submit is a worse outcome than a lead with no attribution. When
9
+ * storage is unavailable the submission simply carries no id.
10
+ */
11
+ const KEY = "fga_vid";
12
+ function make() {
13
+ const bytes = new Uint8Array(12);
14
+ const c = typeof crypto !== "undefined" ? crypto : undefined;
15
+ if (c && typeof c.getRandomValues === "function")
16
+ c.getRandomValues(bytes);
17
+ else
18
+ for (let i = 0; i < bytes.length; i++)
19
+ bytes[i] = Math.floor(Math.random() * 256);
20
+ let out = "v";
21
+ for (const b of bytes)
22
+ out += b.toString(36).padStart(2, "0");
23
+ // The server accepts /^[A-Za-z0-9_-]{8,64}$/; this is 25 characters.
24
+ return out.slice(0, 32);
25
+ }
26
+ /** The id for this browser, creating one on first use. Undefined if storage is closed. */
27
+ export function visitorId() {
28
+ if (typeof window === "undefined")
29
+ return undefined;
30
+ try {
31
+ const found = window.localStorage.getItem(KEY);
32
+ if (found)
33
+ return found;
34
+ const created = make();
35
+ window.localStorage.setItem(KEY, created);
36
+ return created;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@fleegital/web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The driver application form and website analytics for carriers running Fleegital.",
5
+ "license": "MIT",
6
+ "author": "Fleegital",
7
+ "homepage": "https://github.com/fleegital/fleegital-web#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/fleegital/fleegital-web.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/fleegital/fleegital-web/issues"
14
+ },
15
+ "keywords": [
16
+ "fleegital",
17
+ "trucking",
18
+ "recruiting",
19
+ "cdl",
20
+ "application-form",
21
+ "analytics"
22
+ ],
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "files": [
26
+ "dist",
27
+ "README.md",
28
+ "CHANGELOG.md",
29
+ "LICENSE"
30
+ ],
31
+ "exports": {
32
+ "./form": {
33
+ "types": "./dist/form/index.d.ts",
34
+ "import": "./dist/form/index.js"
35
+ },
36
+ "./analytics": {
37
+ "types": "./dist/analytics/index.d.ts",
38
+ "import": "./dist/analytics/index.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
49
+ "prepublishOnly": "npm run build"
50
+ },
51
+ "peerDependencies": {
52
+ "react": ">=18"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "react": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@types/react": "^18.3.1",
61
+ "typescript": "^5.8.3"
62
+ },
63
+ "engines": {
64
+ "node": ">=18"
65
+ }
66
+ }