@reblu/site-client 5.20.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
@@ -333,6 +333,11 @@ interface ReviewListParams {
333
333
  /** At most this many reviews (1..100). Omit for all of them. */
334
334
  limit?: number;
335
335
  }
336
+ /** Options for {@link makeReviewsNamespace}'s `featured` (REB-1272). */
337
+ interface FeaturedReviewsParams {
338
+ /** At most this many featured reviews (1..100). Omit for all of them. */
339
+ limit?: number;
340
+ }
336
341
 
337
342
  /**
338
343
  * Stripe Checkout redirect-origin guard (REB-836, defence in depth).
@@ -392,6 +397,13 @@ interface ContactFormMetadataOptions {
392
397
  location?: PageLocation | null;
393
398
  /** Submission time. @default new Date() */
394
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;
395
407
  }
396
408
  /**
397
409
  * Path of the page the visitor is on — `location.pathname`, never with a query
@@ -401,7 +413,8 @@ declare function currentPagePath(location?: PageLocation | null): string;
401
413
  /**
402
414
  * The `metadata` for `contactForm.submit()` / `POST /api/site/contact-form`:
403
415
  * `fromPage` is the current page path, `currentUrl` the full href (base-api
404
- * 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).
405
418
  *
406
419
  * ```ts
407
420
  * body: JSON.stringify({ ...fields, captcha, metadata: buildContactFormMetadata() })
@@ -409,6 +422,84 @@ declare function currentPagePath(location?: PageLocation | null): string;
409
422
  */
410
423
  declare function buildContactFormMetadata(options?: ContactFormMetadataOptions): ContactFormMetadata;
411
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
+
412
503
  interface RebluClientConfig {
413
504
  /** Absolute origin of the Reblu SITE API, e.g. `https://api.reblu.app`. */
414
505
  baseUrl: string;
@@ -512,9 +603,10 @@ declare function createRebluClient(config: RebluClientConfig): {
512
603
  };
513
604
  reviews: {
514
605
  list: (params?: ReviewListParams, cache?: RebluCachePolicy) => Promise<ListResult<_reblu_site_contracts.Review>>;
606
+ featured: (params?: FeaturedReviewsParams, cache?: RebluCachePolicy) => Promise<_reblu_site_contracts.FeaturedReviews>;
515
607
  };
516
608
  };
517
609
  /** The assembled client returned by {@link createRebluClient}. */
518
610
  type RebluClient = ReturnType<typeof createRebluClient>;
519
611
 
520
- 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 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
@@ -333,6 +333,11 @@ interface ReviewListParams {
333
333
  /** At most this many reviews (1..100). Omit for all of them. */
334
334
  limit?: number;
335
335
  }
336
+ /** Options for {@link makeReviewsNamespace}'s `featured` (REB-1272). */
337
+ interface FeaturedReviewsParams {
338
+ /** At most this many featured reviews (1..100). Omit for all of them. */
339
+ limit?: number;
340
+ }
336
341
 
337
342
  /**
338
343
  * Stripe Checkout redirect-origin guard (REB-836, defence in depth).
@@ -392,6 +397,13 @@ interface ContactFormMetadataOptions {
392
397
  location?: PageLocation | null;
393
398
  /** Submission time. @default new Date() */
394
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;
395
407
  }
396
408
  /**
397
409
  * Path of the page the visitor is on — `location.pathname`, never with a query
@@ -401,7 +413,8 @@ declare function currentPagePath(location?: PageLocation | null): string;
401
413
  /**
402
414
  * The `metadata` for `contactForm.submit()` / `POST /api/site/contact-form`:
403
415
  * `fromPage` is the current page path, `currentUrl` the full href (base-api
404
- * 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).
405
418
  *
406
419
  * ```ts
407
420
  * body: JSON.stringify({ ...fields, captcha, metadata: buildContactFormMetadata() })
@@ -409,6 +422,84 @@ declare function currentPagePath(location?: PageLocation | null): string;
409
422
  */
410
423
  declare function buildContactFormMetadata(options?: ContactFormMetadataOptions): ContactFormMetadata;
411
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
+
412
503
  interface RebluClientConfig {
413
504
  /** Absolute origin of the Reblu SITE API, e.g. `https://api.reblu.app`. */
414
505
  baseUrl: string;
@@ -512,9 +603,10 @@ declare function createRebluClient(config: RebluClientConfig): {
512
603
  };
513
604
  reviews: {
514
605
  list: (params?: ReviewListParams, cache?: RebluCachePolicy) => Promise<ListResult<_reblu_site_contracts.Review>>;
606
+ featured: (params?: FeaturedReviewsParams, cache?: RebluCachePolicy) => Promise<_reblu_site_contracts.FeaturedReviews>;
515
607
  };
516
608
  };
517
609
  /** The assembled client returned by {@link createRebluClient}. */
518
610
  type RebluClient = ReturnType<typeof createRebluClient>;
519
611
 
520
- 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 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
@@ -679,7 +679,14 @@ function makeReviewsNamespace(ctx) {
679
679
  { cachePolicy: cache }
680
680
  );
681
681
  }
682
- return { list };
682
+ function featured(params = {}, cache) {
683
+ return requestOne(
684
+ ctx,
685
+ buildUrl("/api/site/reviews/featured", { limit: params.limit }),
686
+ { cachePolicy: cache }
687
+ );
688
+ }
689
+ return { list, featured };
683
690
  }
684
691
 
685
692
  // src/domains/trust-badges.ts
@@ -715,6 +722,214 @@ function makeWithdrawalRequestsNamespace(ctx) {
715
722
  };
716
723
  }
717
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
+
718
933
  // src/contact-form-metadata.ts
719
934
  function browserLocation() {
720
935
  const location = globalThis.location;
@@ -726,11 +941,13 @@ function currentPagePath(location = browserLocation()) {
726
941
  }
727
942
  function buildContactFormMetadata(options = {}) {
728
943
  const location = options.location === void 0 ? browserLocation() : options.location;
944
+ const attribution = options.attribution === void 0 ? getLeadAttributionStore().get() : options.attribution;
729
945
  return {
730
946
  fromPage: options.fromPage ?? currentPagePath(location),
731
947
  timestamp: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
732
948
  currentUrl: location?.href ?? "",
733
- leadSource: options.leadSource ?? { platform: "organic" }
949
+ leadSource: options.leadSource ?? { platform: "organic" },
950
+ ...attribution && { attribution }
734
951
  };
735
952
  }
736
953
 
@@ -768,6 +985,6 @@ function createRebluClient(config) {
768
985
  };
769
986
  }
770
987
 
771
- 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 };
772
989
  //# sourceMappingURL=index.js.map
773
990
  //# sourceMappingURL=index.js.map