@reblu/site-client 5.21.0 → 5.22.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/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _reblu_site_contracts from '@reblu/site-contracts';
2
- import { AcademyCourseListQuery, PaginationMeta, PageType, PagesListSort, ShopSort, AcademyCheckoutErrorCode, TrustBadgeType, ContactFormInput } from '@reblu/site-contracts';
2
+ import { AcademyCourseListQuery, PaginationMeta, PageType, PagesListSort, ShopSort, AcademyCheckoutErrorCode, TrustBadgeType, ContactFormInput, LeadAttribution, LeadTouch } from '@reblu/site-contracts';
3
3
 
4
4
  /**
5
5
  * Cache adapter — the seam that severs the SDK from any framework's caching
@@ -397,6 +397,13 @@ interface ContactFormMetadataOptions {
397
397
  location?: PageLocation | null;
398
398
  /** Submission time. @default new Date() */
399
399
  now?: Date;
400
+ /**
401
+ * REB-1366 — the lead's origin (first and last touch). Defaults to what the
402
+ * page's store holds (`getLeadAttributionStore()`, filled by
403
+ * `LeadAttributionCapture` from `@reblu/site-next/consent`); `null` sends
404
+ * none. Omitted from the body when there is nothing to send.
405
+ */
406
+ attribution?: LeadAttribution | null;
400
407
  }
401
408
  /**
402
409
  * Path of the page the visitor is on — `location.pathname`, never with a query
@@ -406,7 +413,8 @@ declare function currentPagePath(location?: PageLocation | null): string;
406
413
  /**
407
414
  * The `metadata` for `contactForm.submit()` / `POST /api/site/contact-form`:
408
415
  * `fromPage` is the current page path, `currentUrl` the full href (base-api
409
- * reads the UTMs from it), `timestamp` the submission time.
416
+ * reads the UTMs from it), `timestamp` the submission time, and `attribution`
417
+ * the visit's first/last touch when the page recorded any (REB-1366).
410
418
  *
411
419
  * ```ts
412
420
  * body: JSON.stringify({ ...fields, captcha, metadata: buildContactFormMetadata() })
@@ -414,6 +422,84 @@ declare function currentPagePath(location?: PageLocation | null): string;
414
422
  */
415
423
  declare function buildContactFormMetadata(options?: ContactFormMetadataOptions): ContactFormMetadata;
416
424
 
425
+ /**
426
+ * REB-1366 — where a lead came from, captured in the browser.
427
+ *
428
+ * Each full page load yields at most one RAW touch (`leadTouchFromVisit`):
429
+ * the `utm_*` params, the NAMES of ad click ids (never their values), the
430
+ * external referrer's HOST (never the full URL) and the landing path. The
431
+ * store keeps the FIRST touch and the LAST non-direct one. base-api decides
432
+ * the channel (campaign, search, social, referral, direct, email) from these
433
+ * raw inputs, so this module never classifies — one classifier, server-side.
434
+ *
435
+ * Consent (owner, 25-Oct-2026): memory only until the visitor grants the
436
+ * `analytics` cookie category; after that, a first-party cookie kept 90 days.
437
+ * Denying or withdrawing removes the cookie; memory still serves the page the
438
+ * visitor is on. Nothing here holds personal data: values that look like an
439
+ * email are dropped and every value is capped.
440
+ *
441
+ * Dependency-free on purpose: it runs on every page of every SITE.
442
+ */
443
+ /** The first-party cookie the touches are kept in after `analytics` consent. */
444
+ declare const LEAD_ATTRIBUTION_COOKIE_NAME = "reblu_attribution";
445
+ /** 90 days (owner decision, 25-Oct-2026). */
446
+ declare const LEAD_ATTRIBUTION_MAX_AGE_SECONDS: number;
447
+ /** Ad click-id params whose NAMES are sent (base-api's list). */
448
+ declare const LEAD_ATTRIBUTION_CLICK_IDS: readonly ["gclid", "gbraid", "wbraid", "dclid", "msclkid", "ttclid", "li_fat_id", "fbclid"];
449
+ /** One page load: the URL it opened and the referrer it came from. */
450
+ interface LeadVisit {
451
+ /** @default globalThis.location.href */
452
+ href?: string;
453
+ /** @default globalThis.document.referrer */
454
+ referrer?: string | null;
455
+ /** @default new Date() */
456
+ now?: Date;
457
+ }
458
+ /**
459
+ * The raw touch of one page load, or `null` when the load is not a new visit:
460
+ * a referrer on the SITE's own host (navigation), or a return from a payment
461
+ * or login flow. A load with no referrer and no campaign is a DIRECT touch
462
+ * (landing path and time only).
463
+ */
464
+ declare function leadTouchFromVisit(visit?: LeadVisit): LeadTouch | null;
465
+ /** Where the store keeps its cookie (`document.cookie` by default; a fake in tests). */
466
+ interface LeadAttributionCookieJar {
467
+ read(): string | null;
468
+ write(value: string, maxAgeSeconds: number): void;
469
+ remove(): void;
470
+ }
471
+ interface LeadAttributionStore {
472
+ /** Records the touch of the current page load (`leadTouchFromVisit`). */
473
+ recordVisit(visit?: LeadVisit): void;
474
+ /** Keeps `touch` as first (if none yet) and as last (if not direct). Null is ignored. */
475
+ record(touch: LeadTouch | null): void;
476
+ /**
477
+ * The visitor's `analytics` consent. `true` reads the stored touches, merges
478
+ * them with this page's and writes the cookie; `false` removes the cookie.
479
+ * Until called, the store is memory-only and never touches the cookie.
480
+ */
481
+ setConsent(granted: boolean): void;
482
+ /** The touches to send with a lead, or `undefined` when there are none. */
483
+ get(): LeadAttribution | undefined;
484
+ }
485
+ interface LeadAttributionStoreOptions {
486
+ /** @default the `document.cookie` jar; `null` keeps the store memory-only. */
487
+ cookies?: LeadAttributionCookieJar | null;
488
+ /** @default () => new Date() */
489
+ now?: () => Date;
490
+ }
491
+ declare function createLeadAttributionStore(options?: LeadAttributionStoreOptions): LeadAttributionStore;
492
+ /**
493
+ * The page's one store, shared by the capture (`LeadAttributionCapture` from
494
+ * `@reblu/site-next/consent`) and `buildContactFormMetadata()`. Kept on
495
+ * `globalThis` so two bundled copies of this package still share it. On the
496
+ * server (no `window`) every call gets a new, empty store: one process serves
497
+ * every visitor, so nothing may be shared there.
498
+ */
499
+ declare function getLeadAttributionStore(): LeadAttributionStore;
500
+ /** Drops the shared store (tests; a fresh one is created on the next call). */
501
+ declare function resetLeadAttributionStore(): void;
502
+
417
503
  interface RebluClientConfig {
418
504
  /** Absolute origin of the Reblu SITE API, e.g. `https://api.reblu.app`. */
419
505
  baseUrl: string;
@@ -523,4 +609,4 @@ declare function createRebluClient(config: RebluClientConfig): {
523
609
  /** The assembled client returned by {@link createRebluClient}. */
524
610
  type RebluClient = ReturnType<typeof createRebluClient>;
525
611
 
526
- export { AcademyCheckoutError, type AcademyCheckoutErrorDetails, type AcademyCourseListParams, type AcademyEnrollmentRequestInput, type AcademyEnrollmentRequestResult, AmbiguousPageSlugError, ApiError, type ApiErrorBody, type ApiErrorDetails, type BlogListParams, type ContactFormMetadata, type ContactFormMetadataOptions, DEFAULT_CHECKOUT_REDIRECT_HOSTS, type FeaturedPagesOptions, type FeaturedReviewsParams, type ListResult, type PageBySlugOptions, type PageLocation, type PageSlugCandidate, type PagesListParams, PlainCacheAdapter, RETRY_AFTER_CAP_MS, type RebluCacheAdapter, type RebluCachePolicy, type RebluClient, type RebluClientConfig, type RebluMutationOptions, type RequestScope, type ReviewListParams, type ShopProductsParams, type TrustBadgeListParams, UnsafeCheckoutRedirectError, VISITOR_IP_HEADER, buildContactFormMetadata, createRebluClient, currentPagePath, isSafeCheckoutRedirectUrl, visitorIpFromHeaders };
612
+ export { AcademyCheckoutError, type AcademyCheckoutErrorDetails, type AcademyCourseListParams, type AcademyEnrollmentRequestInput, type AcademyEnrollmentRequestResult, AmbiguousPageSlugError, ApiError, type ApiErrorBody, type ApiErrorDetails, type BlogListParams, type ContactFormMetadata, type ContactFormMetadataOptions, DEFAULT_CHECKOUT_REDIRECT_HOSTS, type FeaturedPagesOptions, type FeaturedReviewsParams, LEAD_ATTRIBUTION_CLICK_IDS, LEAD_ATTRIBUTION_COOKIE_NAME, LEAD_ATTRIBUTION_MAX_AGE_SECONDS, type LeadAttributionCookieJar, type LeadAttributionStore, type LeadAttributionStoreOptions, type LeadVisit, type ListResult, type PageBySlugOptions, type PageLocation, type PageSlugCandidate, type PagesListParams, PlainCacheAdapter, RETRY_AFTER_CAP_MS, type RebluCacheAdapter, type RebluCachePolicy, type RebluClient, type RebluClientConfig, type RebluMutationOptions, type RequestScope, type ReviewListParams, type ShopProductsParams, type TrustBadgeListParams, UnsafeCheckoutRedirectError, VISITOR_IP_HEADER, buildContactFormMetadata, createLeadAttributionStore, createRebluClient, currentPagePath, getLeadAttributionStore, isSafeCheckoutRedirectUrl, leadTouchFromVisit, resetLeadAttributionStore, visitorIpFromHeaders };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _reblu_site_contracts from '@reblu/site-contracts';
2
- import { AcademyCourseListQuery, PaginationMeta, PageType, PagesListSort, ShopSort, AcademyCheckoutErrorCode, TrustBadgeType, ContactFormInput } from '@reblu/site-contracts';
2
+ import { AcademyCourseListQuery, PaginationMeta, PageType, PagesListSort, ShopSort, AcademyCheckoutErrorCode, TrustBadgeType, ContactFormInput, LeadAttribution, LeadTouch } from '@reblu/site-contracts';
3
3
 
4
4
  /**
5
5
  * Cache adapter — the seam that severs the SDK from any framework's caching
@@ -397,6 +397,13 @@ interface ContactFormMetadataOptions {
397
397
  location?: PageLocation | null;
398
398
  /** Submission time. @default new Date() */
399
399
  now?: Date;
400
+ /**
401
+ * REB-1366 — the lead's origin (first and last touch). Defaults to what the
402
+ * page's store holds (`getLeadAttributionStore()`, filled by
403
+ * `LeadAttributionCapture` from `@reblu/site-next/consent`); `null` sends
404
+ * none. Omitted from the body when there is nothing to send.
405
+ */
406
+ attribution?: LeadAttribution | null;
400
407
  }
401
408
  /**
402
409
  * Path of the page the visitor is on — `location.pathname`, never with a query
@@ -406,7 +413,8 @@ declare function currentPagePath(location?: PageLocation | null): string;
406
413
  /**
407
414
  * The `metadata` for `contactForm.submit()` / `POST /api/site/contact-form`:
408
415
  * `fromPage` is the current page path, `currentUrl` the full href (base-api
409
- * reads the UTMs from it), `timestamp` the submission time.
416
+ * reads the UTMs from it), `timestamp` the submission time, and `attribution`
417
+ * the visit's first/last touch when the page recorded any (REB-1366).
410
418
  *
411
419
  * ```ts
412
420
  * body: JSON.stringify({ ...fields, captcha, metadata: buildContactFormMetadata() })
@@ -414,6 +422,84 @@ declare function currentPagePath(location?: PageLocation | null): string;
414
422
  */
415
423
  declare function buildContactFormMetadata(options?: ContactFormMetadataOptions): ContactFormMetadata;
416
424
 
425
+ /**
426
+ * REB-1366 — where a lead came from, captured in the browser.
427
+ *
428
+ * Each full page load yields at most one RAW touch (`leadTouchFromVisit`):
429
+ * the `utm_*` params, the NAMES of ad click ids (never their values), the
430
+ * external referrer's HOST (never the full URL) and the landing path. The
431
+ * store keeps the FIRST touch and the LAST non-direct one. base-api decides
432
+ * the channel (campaign, search, social, referral, direct, email) from these
433
+ * raw inputs, so this module never classifies — one classifier, server-side.
434
+ *
435
+ * Consent (owner, 25-Oct-2026): memory only until the visitor grants the
436
+ * `analytics` cookie category; after that, a first-party cookie kept 90 days.
437
+ * Denying or withdrawing removes the cookie; memory still serves the page the
438
+ * visitor is on. Nothing here holds personal data: values that look like an
439
+ * email are dropped and every value is capped.
440
+ *
441
+ * Dependency-free on purpose: it runs on every page of every SITE.
442
+ */
443
+ /** The first-party cookie the touches are kept in after `analytics` consent. */
444
+ declare const LEAD_ATTRIBUTION_COOKIE_NAME = "reblu_attribution";
445
+ /** 90 days (owner decision, 25-Oct-2026). */
446
+ declare const LEAD_ATTRIBUTION_MAX_AGE_SECONDS: number;
447
+ /** Ad click-id params whose NAMES are sent (base-api's list). */
448
+ declare const LEAD_ATTRIBUTION_CLICK_IDS: readonly ["gclid", "gbraid", "wbraid", "dclid", "msclkid", "ttclid", "li_fat_id", "fbclid"];
449
+ /** One page load: the URL it opened and the referrer it came from. */
450
+ interface LeadVisit {
451
+ /** @default globalThis.location.href */
452
+ href?: string;
453
+ /** @default globalThis.document.referrer */
454
+ referrer?: string | null;
455
+ /** @default new Date() */
456
+ now?: Date;
457
+ }
458
+ /**
459
+ * The raw touch of one page load, or `null` when the load is not a new visit:
460
+ * a referrer on the SITE's own host (navigation), or a return from a payment
461
+ * or login flow. A load with no referrer and no campaign is a DIRECT touch
462
+ * (landing path and time only).
463
+ */
464
+ declare function leadTouchFromVisit(visit?: LeadVisit): LeadTouch | null;
465
+ /** Where the store keeps its cookie (`document.cookie` by default; a fake in tests). */
466
+ interface LeadAttributionCookieJar {
467
+ read(): string | null;
468
+ write(value: string, maxAgeSeconds: number): void;
469
+ remove(): void;
470
+ }
471
+ interface LeadAttributionStore {
472
+ /** Records the touch of the current page load (`leadTouchFromVisit`). */
473
+ recordVisit(visit?: LeadVisit): void;
474
+ /** Keeps `touch` as first (if none yet) and as last (if not direct). Null is ignored. */
475
+ record(touch: LeadTouch | null): void;
476
+ /**
477
+ * The visitor's `analytics` consent. `true` reads the stored touches, merges
478
+ * them with this page's and writes the cookie; `false` removes the cookie.
479
+ * Until called, the store is memory-only and never touches the cookie.
480
+ */
481
+ setConsent(granted: boolean): void;
482
+ /** The touches to send with a lead, or `undefined` when there are none. */
483
+ get(): LeadAttribution | undefined;
484
+ }
485
+ interface LeadAttributionStoreOptions {
486
+ /** @default the `document.cookie` jar; `null` keeps the store memory-only. */
487
+ cookies?: LeadAttributionCookieJar | null;
488
+ /** @default () => new Date() */
489
+ now?: () => Date;
490
+ }
491
+ declare function createLeadAttributionStore(options?: LeadAttributionStoreOptions): LeadAttributionStore;
492
+ /**
493
+ * The page's one store, shared by the capture (`LeadAttributionCapture` from
494
+ * `@reblu/site-next/consent`) and `buildContactFormMetadata()`. Kept on
495
+ * `globalThis` so two bundled copies of this package still share it. On the
496
+ * server (no `window`) every call gets a new, empty store: one process serves
497
+ * every visitor, so nothing may be shared there.
498
+ */
499
+ declare function getLeadAttributionStore(): LeadAttributionStore;
500
+ /** Drops the shared store (tests; a fresh one is created on the next call). */
501
+ declare function resetLeadAttributionStore(): void;
502
+
417
503
  interface RebluClientConfig {
418
504
  /** Absolute origin of the Reblu SITE API, e.g. `https://api.reblu.app`. */
419
505
  baseUrl: string;
@@ -523,4 +609,4 @@ declare function createRebluClient(config: RebluClientConfig): {
523
609
  /** The assembled client returned by {@link createRebluClient}. */
524
610
  type RebluClient = ReturnType<typeof createRebluClient>;
525
611
 
526
- export { AcademyCheckoutError, type AcademyCheckoutErrorDetails, type AcademyCourseListParams, type AcademyEnrollmentRequestInput, type AcademyEnrollmentRequestResult, AmbiguousPageSlugError, ApiError, type ApiErrorBody, type ApiErrorDetails, type BlogListParams, type ContactFormMetadata, type ContactFormMetadataOptions, DEFAULT_CHECKOUT_REDIRECT_HOSTS, type FeaturedPagesOptions, type FeaturedReviewsParams, type ListResult, type PageBySlugOptions, type PageLocation, type PageSlugCandidate, type PagesListParams, PlainCacheAdapter, RETRY_AFTER_CAP_MS, type RebluCacheAdapter, type RebluCachePolicy, type RebluClient, type RebluClientConfig, type RebluMutationOptions, type RequestScope, type ReviewListParams, type ShopProductsParams, type TrustBadgeListParams, UnsafeCheckoutRedirectError, VISITOR_IP_HEADER, buildContactFormMetadata, createRebluClient, currentPagePath, isSafeCheckoutRedirectUrl, visitorIpFromHeaders };
612
+ export { AcademyCheckoutError, type AcademyCheckoutErrorDetails, type AcademyCourseListParams, type AcademyEnrollmentRequestInput, type AcademyEnrollmentRequestResult, AmbiguousPageSlugError, ApiError, type ApiErrorBody, type ApiErrorDetails, type BlogListParams, type ContactFormMetadata, type ContactFormMetadataOptions, DEFAULT_CHECKOUT_REDIRECT_HOSTS, type FeaturedPagesOptions, type FeaturedReviewsParams, LEAD_ATTRIBUTION_CLICK_IDS, LEAD_ATTRIBUTION_COOKIE_NAME, LEAD_ATTRIBUTION_MAX_AGE_SECONDS, type LeadAttributionCookieJar, type LeadAttributionStore, type LeadAttributionStoreOptions, type LeadVisit, type ListResult, type PageBySlugOptions, type PageLocation, type PageSlugCandidate, type PagesListParams, PlainCacheAdapter, RETRY_AFTER_CAP_MS, type RebluCacheAdapter, type RebluCachePolicy, type RebluClient, type RebluClientConfig, type RebluMutationOptions, type RequestScope, type ReviewListParams, type ShopProductsParams, type TrustBadgeListParams, UnsafeCheckoutRedirectError, VISITOR_IP_HEADER, buildContactFormMetadata, createLeadAttributionStore, createRebluClient, currentPagePath, getLeadAttributionStore, isSafeCheckoutRedirectUrl, leadTouchFromVisit, resetLeadAttributionStore, visitorIpFromHeaders };
package/dist/index.js CHANGED
@@ -722,6 +722,214 @@ function makeWithdrawalRequestsNamespace(ctx) {
722
722
  };
723
723
  }
724
724
 
725
+ // src/lead-attribution.ts
726
+ var LEAD_ATTRIBUTION_COOKIE_NAME = "reblu_attribution";
727
+ var LEAD_ATTRIBUTION_MAX_AGE_SECONDS = 90 * 24 * 60 * 60;
728
+ var LEAD_ATTRIBUTION_CLICK_IDS = [
729
+ "gclid",
730
+ "gbraid",
731
+ "wbraid",
732
+ "dclid",
733
+ "msclkid",
734
+ "ttclid",
735
+ "li_fat_id",
736
+ "fbclid"
737
+ ];
738
+ var UTM_KEYS = ["source", "medium", "campaign", "content", "term"];
739
+ var VALUE_MAX_LENGTH = 100;
740
+ var PATH_MAX_LENGTH = 200;
741
+ var SHAPE_VERSION = 1;
742
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
743
+ var HOST = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
744
+ var IGNORED_HOSTS = [
745
+ "checkout.stripe.com",
746
+ "billing.stripe.com",
747
+ "paypal.com",
748
+ "accounts.google.com"
749
+ ];
750
+ function cleanValue(value, max = VALUE_MAX_LENGTH) {
751
+ if (typeof value !== "string" || CONTROL_CHARS.test(value)) return void 0;
752
+ const clean = value.trim().slice(0, max);
753
+ return clean && !clean.includes("@") ? clean : void 0;
754
+ }
755
+ function cleanHost(value) {
756
+ const host = cleanValue(value)?.toLowerCase();
757
+ return host && HOST.test(host) ? host : void 0;
758
+ }
759
+ function cleanPath(value) {
760
+ const path = cleanValue(value, PATH_MAX_LENGTH)?.split(/[?#]/, 1)[0];
761
+ return path && path.startsWith("/") && !path.startsWith("//") ? path : void 0;
762
+ }
763
+ function cleanAt(value) {
764
+ return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : void 0;
765
+ }
766
+ var withoutWww = (host) => host.replace(/^www\./, "");
767
+ var matchesDomain = (host, domain) => host === domain || host.endsWith(`.${domain}`);
768
+ function sanitizeTouch(raw) {
769
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
770
+ const input = raw;
771
+ const touch = {};
772
+ const rawUtm = input.utm;
773
+ if (typeof rawUtm === "object" && rawUtm !== null) {
774
+ const utm = {};
775
+ for (const key of UTM_KEYS) {
776
+ const value = cleanValue(rawUtm[key]);
777
+ if (value) utm[key] = value;
778
+ }
779
+ if (Object.keys(utm).length > 0) touch.utm = utm;
780
+ }
781
+ const rawClickIds = input.clickIds;
782
+ if (Array.isArray(rawClickIds)) {
783
+ const known = LEAD_ATTRIBUTION_CLICK_IDS.filter((id) => rawClickIds.includes(id));
784
+ if (known.length > 0) touch.clickIds = known;
785
+ }
786
+ const referrer = cleanHost(input.referrer);
787
+ if (referrer) touch.referrer = referrer;
788
+ const landingPath = cleanPath(input.landingPath);
789
+ if (landingPath) touch.landingPath = landingPath;
790
+ const at = cleanAt(input.at);
791
+ if (at) touch.at = at;
792
+ return Object.keys(touch).length > 0 ? touch : void 0;
793
+ }
794
+ function isDirect(touch) {
795
+ return !touch.utm && !touch.clickIds && !touch.referrer;
796
+ }
797
+ function leadTouchFromVisit(visit = {}) {
798
+ const g = globalThis;
799
+ const href = visit.href ?? g.location?.href;
800
+ const referrer = visit.referrer === void 0 ? g.document?.referrer : visit.referrer;
801
+ let url;
802
+ try {
803
+ url = new URL(href ?? "");
804
+ } catch {
805
+ return null;
806
+ }
807
+ const raw = {};
808
+ const utm = {};
809
+ for (const key of UTM_KEYS) {
810
+ const value = url.searchParams.get(`utm_${key}`);
811
+ if (value) utm[key] = value;
812
+ }
813
+ if (Object.keys(utm).length > 0) raw.utm = utm;
814
+ const clickIds = LEAD_ATTRIBUTION_CLICK_IDS.filter((id) => url.searchParams.has(id));
815
+ if (clickIds.length > 0) raw.clickIds = clickIds;
816
+ let referrerHost;
817
+ try {
818
+ referrerHost = referrer ? new URL(referrer).hostname.toLowerCase() : void 0;
819
+ } catch {
820
+ }
821
+ const ownHost = withoutWww(url.hostname.toLowerCase());
822
+ const external = referrerHost && withoutWww(referrerHost) !== ownHost && !IGNORED_HOSTS.some((domain) => matchesDomain(referrerHost, domain));
823
+ if (external) raw.referrer = referrerHost;
824
+ if (referrerHost && !external && !raw.utm && !raw.clickIds) return null;
825
+ raw.landingPath = url.pathname;
826
+ raw.at = (visit.now ?? /* @__PURE__ */ new Date()).toISOString();
827
+ return sanitizeTouch(raw) ?? null;
828
+ }
829
+ function documentCookieJar() {
830
+ const doc = globalThis.document;
831
+ if (!doc) return null;
832
+ const name = LEAD_ATTRIBUTION_COOKIE_NAME;
833
+ return {
834
+ read() {
835
+ const match = doc.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
836
+ if (!match) return null;
837
+ try {
838
+ return decodeURIComponent(match[1]);
839
+ } catch {
840
+ return null;
841
+ }
842
+ },
843
+ write(value, maxAgeSeconds) {
844
+ const https = globalThis.location?.protocol === "https:";
845
+ doc.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; SameSite=Lax${https ? "; Secure" : ""}`;
846
+ },
847
+ remove() {
848
+ doc.cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
849
+ }
850
+ };
851
+ }
852
+ var atOf = (touch) => touch?.at ? Date.parse(touch.at) : Number.NaN;
853
+ function createLeadAttributionStore(options = {}) {
854
+ const cookies = options.cookies === void 0 ? documentCookieJar() : options.cookies;
855
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
856
+ let first;
857
+ let last;
858
+ let granted = false;
859
+ const fresh = (touch) => {
860
+ const at = atOf(touch);
861
+ return touch && now().getTime() - at < LEAD_ATTRIBUTION_MAX_AGE_SECONDS * 1e3 ? touch : void 0;
862
+ };
863
+ const persist = () => {
864
+ if (!granted || !cookies) return;
865
+ const state = { v: SHAPE_VERSION, first: fresh(first), last: fresh(last) };
866
+ try {
867
+ if (state.first || state.last) {
868
+ cookies.write(JSON.stringify(state), LEAD_ATTRIBUTION_MAX_AGE_SECONDS);
869
+ } else {
870
+ cookies.remove();
871
+ }
872
+ } catch {
873
+ }
874
+ };
875
+ const loadStored = () => {
876
+ let stored;
877
+ try {
878
+ const raw = cookies?.read();
879
+ const parsed = raw ? JSON.parse(raw) : void 0;
880
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
881
+ stored = parsed;
882
+ }
883
+ } catch {
884
+ }
885
+ const storedFirst = fresh(sanitizeTouch(stored?.first));
886
+ const storedLast = fresh(sanitizeTouch(stored?.last));
887
+ if (storedFirst && !(atOf(first) < atOf(storedFirst))) first = storedFirst;
888
+ if (storedLast && !(atOf(last) > atOf(storedLast))) last = storedLast;
889
+ };
890
+ const store = {
891
+ recordVisit(visit) {
892
+ store.record(leadTouchFromVisit(visit));
893
+ },
894
+ record(touch) {
895
+ if (!touch) return;
896
+ if (!fresh(first)) first = touch;
897
+ if (!isDirect(touch)) last = touch;
898
+ persist();
899
+ },
900
+ setConsent(next) {
901
+ granted = next;
902
+ if (!cookies) return;
903
+ if (granted) {
904
+ loadStored();
905
+ persist();
906
+ } else {
907
+ try {
908
+ cookies.remove();
909
+ } catch {
910
+ }
911
+ }
912
+ },
913
+ get() {
914
+ const f = fresh(first);
915
+ const l = fresh(last);
916
+ if (!f && !l) return void 0;
917
+ return { ...f && { first: f }, ...l && { last: l } };
918
+ }
919
+ };
920
+ return store;
921
+ }
922
+ var GLOBAL_KEY = "__rebluLeadAttributionStore";
923
+ function getLeadAttributionStore() {
924
+ const g = globalThis;
925
+ if (typeof g.window === "undefined") return createLeadAttributionStore({ cookies: null });
926
+ g[GLOBAL_KEY] ??= createLeadAttributionStore();
927
+ return g[GLOBAL_KEY];
928
+ }
929
+ function resetLeadAttributionStore() {
930
+ delete globalThis[GLOBAL_KEY];
931
+ }
932
+
725
933
  // src/contact-form-metadata.ts
726
934
  function browserLocation() {
727
935
  const location = globalThis.location;
@@ -733,11 +941,13 @@ function currentPagePath(location = browserLocation()) {
733
941
  }
734
942
  function buildContactFormMetadata(options = {}) {
735
943
  const location = options.location === void 0 ? browserLocation() : options.location;
944
+ const attribution = options.attribution === void 0 ? getLeadAttributionStore().get() : options.attribution;
736
945
  return {
737
946
  fromPage: options.fromPage ?? currentPagePath(location),
738
947
  timestamp: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
739
948
  currentUrl: location?.href ?? "",
740
- leadSource: options.leadSource ?? { platform: "organic" }
949
+ leadSource: options.leadSource ?? { platform: "organic" },
950
+ ...attribution && { attribution }
741
951
  };
742
952
  }
743
953
 
@@ -775,6 +985,6 @@ function createRebluClient(config) {
775
985
  };
776
986
  }
777
987
 
778
- export { AcademyCheckoutError, AmbiguousPageSlugError, ApiError, DEFAULT_CHECKOUT_REDIRECT_HOSTS, PlainCacheAdapter, RETRY_AFTER_CAP_MS, UnsafeCheckoutRedirectError, VISITOR_IP_HEADER, buildContactFormMetadata, createRebluClient, currentPagePath, isSafeCheckoutRedirectUrl, visitorIpFromHeaders };
988
+ export { AcademyCheckoutError, AmbiguousPageSlugError, ApiError, DEFAULT_CHECKOUT_REDIRECT_HOSTS, LEAD_ATTRIBUTION_CLICK_IDS, LEAD_ATTRIBUTION_COOKIE_NAME, LEAD_ATTRIBUTION_MAX_AGE_SECONDS, PlainCacheAdapter, RETRY_AFTER_CAP_MS, UnsafeCheckoutRedirectError, VISITOR_IP_HEADER, buildContactFormMetadata, createLeadAttributionStore, createRebluClient, currentPagePath, getLeadAttributionStore, isSafeCheckoutRedirectUrl, leadTouchFromVisit, resetLeadAttributionStore, visitorIpFromHeaders };
779
989
  //# sourceMappingURL=index.js.map
780
990
  //# sourceMappingURL=index.js.map