@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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @reblu/site-client
2
2
 
3
+ ## 5.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 53d5b0e: Lead origin captured in the browser (REB-1366). `@reblu/site-client` adds `leadTouchFromVisit()`, `createLeadAttributionStore()` and the shared `getLeadAttributionStore()`: on each full page load the visit's raw touch (the `utm_*` params, the NAMES of ad click ids, the external referrer's host, the landing path) is kept as the first touch and, unless it is direct, as the last touch. Memory only until the visitor grants the `analytics` cookie category; then a 90-day first-party cookie, `reblu_attribution`, removed again when consent is denied or withdrawn. `buildContactFormMetadata()` now adds `metadata.attribution` when the page recorded a touch (omitted otherwise, so its output is unchanged for a SITE that does not capture); pass `attribution: null` to send none. base-api classifies the channel. `@reblu/site-next/consent` adds the drop-in `<LeadAttributionCapture />`: mount it once inside `<ConsentProvider>`. Additive only: nothing existing changes. List the `reblu_attribution` cookie (analytics, 90 days) in the tenant's cookie policy.
8
+
3
9
  ## 5.21.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -451,6 +451,23 @@ await fetch('/api/contact-form', {
451
451
  buildContactFormMetadata({ fromPage: '/landing/verano', leadSource: { platform: 'instagram' } })
452
452
  ```
453
453
 
454
+ `metadata.attribution` (REB-1366) is added when the page recorded where the
455
+ visitor came from: mount `<LeadAttributionCapture />` from
456
+ `@reblu/site-next/consent` once inside `<ConsentProvider>` (see that README).
457
+ The touches live in `getLeadAttributionStore()` — in memory before the
458
+ visitor grants the `analytics` cookie category, in the 90-day first-party
459
+ cookie `reblu_attribution` after. Without a capture the key is omitted and
460
+ base-api reads the UTMs from `currentUrl` as before. A SITE without React
461
+ records the visit itself:
462
+
463
+ ```ts
464
+ import { getLeadAttributionStore } from '@reblu/site-client'
465
+
466
+ const store = getLeadAttributionStore()
467
+ store.recordVisit() // once per full page load
468
+ store.setConsent(visitorAcceptedAnalytics) // whenever the choice is known or changes
469
+ ```
470
+
454
471
  Do not hardcode `fromPage` (e.g. `'home'`): every lead would then report the
455
472
  same page. base-api still accepts such a value and falls back to the path of
456
473
  `currentUrl`. `currentPagePath()` returns just the path, for a SITE that builds
package/dist/index.cjs CHANGED
@@ -724,6 +724,214 @@ function makeWithdrawalRequestsNamespace(ctx) {
724
724
  };
725
725
  }
726
726
 
727
+ // src/lead-attribution.ts
728
+ var LEAD_ATTRIBUTION_COOKIE_NAME = "reblu_attribution";
729
+ var LEAD_ATTRIBUTION_MAX_AGE_SECONDS = 90 * 24 * 60 * 60;
730
+ var LEAD_ATTRIBUTION_CLICK_IDS = [
731
+ "gclid",
732
+ "gbraid",
733
+ "wbraid",
734
+ "dclid",
735
+ "msclkid",
736
+ "ttclid",
737
+ "li_fat_id",
738
+ "fbclid"
739
+ ];
740
+ var UTM_KEYS = ["source", "medium", "campaign", "content", "term"];
741
+ var VALUE_MAX_LENGTH = 100;
742
+ var PATH_MAX_LENGTH = 200;
743
+ var SHAPE_VERSION = 1;
744
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
745
+ var HOST = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
746
+ var IGNORED_HOSTS = [
747
+ "checkout.stripe.com",
748
+ "billing.stripe.com",
749
+ "paypal.com",
750
+ "accounts.google.com"
751
+ ];
752
+ function cleanValue(value, max = VALUE_MAX_LENGTH) {
753
+ if (typeof value !== "string" || CONTROL_CHARS.test(value)) return void 0;
754
+ const clean = value.trim().slice(0, max);
755
+ return clean && !clean.includes("@") ? clean : void 0;
756
+ }
757
+ function cleanHost(value) {
758
+ const host = cleanValue(value)?.toLowerCase();
759
+ return host && HOST.test(host) ? host : void 0;
760
+ }
761
+ function cleanPath(value) {
762
+ const path = cleanValue(value, PATH_MAX_LENGTH)?.split(/[?#]/, 1)[0];
763
+ return path && path.startsWith("/") && !path.startsWith("//") ? path : void 0;
764
+ }
765
+ function cleanAt(value) {
766
+ return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : void 0;
767
+ }
768
+ var withoutWww = (host) => host.replace(/^www\./, "");
769
+ var matchesDomain = (host, domain) => host === domain || host.endsWith(`.${domain}`);
770
+ function sanitizeTouch(raw) {
771
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
772
+ const input = raw;
773
+ const touch = {};
774
+ const rawUtm = input.utm;
775
+ if (typeof rawUtm === "object" && rawUtm !== null) {
776
+ const utm = {};
777
+ for (const key of UTM_KEYS) {
778
+ const value = cleanValue(rawUtm[key]);
779
+ if (value) utm[key] = value;
780
+ }
781
+ if (Object.keys(utm).length > 0) touch.utm = utm;
782
+ }
783
+ const rawClickIds = input.clickIds;
784
+ if (Array.isArray(rawClickIds)) {
785
+ const known = LEAD_ATTRIBUTION_CLICK_IDS.filter((id) => rawClickIds.includes(id));
786
+ if (known.length > 0) touch.clickIds = known;
787
+ }
788
+ const referrer = cleanHost(input.referrer);
789
+ if (referrer) touch.referrer = referrer;
790
+ const landingPath = cleanPath(input.landingPath);
791
+ if (landingPath) touch.landingPath = landingPath;
792
+ const at = cleanAt(input.at);
793
+ if (at) touch.at = at;
794
+ return Object.keys(touch).length > 0 ? touch : void 0;
795
+ }
796
+ function isDirect(touch) {
797
+ return !touch.utm && !touch.clickIds && !touch.referrer;
798
+ }
799
+ function leadTouchFromVisit(visit = {}) {
800
+ const g = globalThis;
801
+ const href = visit.href ?? g.location?.href;
802
+ const referrer = visit.referrer === void 0 ? g.document?.referrer : visit.referrer;
803
+ let url;
804
+ try {
805
+ url = new URL(href ?? "");
806
+ } catch {
807
+ return null;
808
+ }
809
+ const raw = {};
810
+ const utm = {};
811
+ for (const key of UTM_KEYS) {
812
+ const value = url.searchParams.get(`utm_${key}`);
813
+ if (value) utm[key] = value;
814
+ }
815
+ if (Object.keys(utm).length > 0) raw.utm = utm;
816
+ const clickIds = LEAD_ATTRIBUTION_CLICK_IDS.filter((id) => url.searchParams.has(id));
817
+ if (clickIds.length > 0) raw.clickIds = clickIds;
818
+ let referrerHost;
819
+ try {
820
+ referrerHost = referrer ? new URL(referrer).hostname.toLowerCase() : void 0;
821
+ } catch {
822
+ }
823
+ const ownHost = withoutWww(url.hostname.toLowerCase());
824
+ const external = referrerHost && withoutWww(referrerHost) !== ownHost && !IGNORED_HOSTS.some((domain) => matchesDomain(referrerHost, domain));
825
+ if (external) raw.referrer = referrerHost;
826
+ if (referrerHost && !external && !raw.utm && !raw.clickIds) return null;
827
+ raw.landingPath = url.pathname;
828
+ raw.at = (visit.now ?? /* @__PURE__ */ new Date()).toISOString();
829
+ return sanitizeTouch(raw) ?? null;
830
+ }
831
+ function documentCookieJar() {
832
+ const doc = globalThis.document;
833
+ if (!doc) return null;
834
+ const name = LEAD_ATTRIBUTION_COOKIE_NAME;
835
+ return {
836
+ read() {
837
+ const match = doc.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
838
+ if (!match) return null;
839
+ try {
840
+ return decodeURIComponent(match[1]);
841
+ } catch {
842
+ return null;
843
+ }
844
+ },
845
+ write(value, maxAgeSeconds) {
846
+ const https = globalThis.location?.protocol === "https:";
847
+ doc.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; SameSite=Lax${https ? "; Secure" : ""}`;
848
+ },
849
+ remove() {
850
+ doc.cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
851
+ }
852
+ };
853
+ }
854
+ var atOf = (touch) => touch?.at ? Date.parse(touch.at) : Number.NaN;
855
+ function createLeadAttributionStore(options = {}) {
856
+ const cookies = options.cookies === void 0 ? documentCookieJar() : options.cookies;
857
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
858
+ let first;
859
+ let last;
860
+ let granted = false;
861
+ const fresh = (touch) => {
862
+ const at = atOf(touch);
863
+ return touch && now().getTime() - at < LEAD_ATTRIBUTION_MAX_AGE_SECONDS * 1e3 ? touch : void 0;
864
+ };
865
+ const persist = () => {
866
+ if (!granted || !cookies) return;
867
+ const state = { v: SHAPE_VERSION, first: fresh(first), last: fresh(last) };
868
+ try {
869
+ if (state.first || state.last) {
870
+ cookies.write(JSON.stringify(state), LEAD_ATTRIBUTION_MAX_AGE_SECONDS);
871
+ } else {
872
+ cookies.remove();
873
+ }
874
+ } catch {
875
+ }
876
+ };
877
+ const loadStored = () => {
878
+ let stored;
879
+ try {
880
+ const raw = cookies?.read();
881
+ const parsed = raw ? JSON.parse(raw) : void 0;
882
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
883
+ stored = parsed;
884
+ }
885
+ } catch {
886
+ }
887
+ const storedFirst = fresh(sanitizeTouch(stored?.first));
888
+ const storedLast = fresh(sanitizeTouch(stored?.last));
889
+ if (storedFirst && !(atOf(first) < atOf(storedFirst))) first = storedFirst;
890
+ if (storedLast && !(atOf(last) > atOf(storedLast))) last = storedLast;
891
+ };
892
+ const store = {
893
+ recordVisit(visit) {
894
+ store.record(leadTouchFromVisit(visit));
895
+ },
896
+ record(touch) {
897
+ if (!touch) return;
898
+ if (!fresh(first)) first = touch;
899
+ if (!isDirect(touch)) last = touch;
900
+ persist();
901
+ },
902
+ setConsent(next) {
903
+ granted = next;
904
+ if (!cookies) return;
905
+ if (granted) {
906
+ loadStored();
907
+ persist();
908
+ } else {
909
+ try {
910
+ cookies.remove();
911
+ } catch {
912
+ }
913
+ }
914
+ },
915
+ get() {
916
+ const f = fresh(first);
917
+ const l = fresh(last);
918
+ if (!f && !l) return void 0;
919
+ return { ...f && { first: f }, ...l && { last: l } };
920
+ }
921
+ };
922
+ return store;
923
+ }
924
+ var GLOBAL_KEY = "__rebluLeadAttributionStore";
925
+ function getLeadAttributionStore() {
926
+ const g = globalThis;
927
+ if (typeof g.window === "undefined") return createLeadAttributionStore({ cookies: null });
928
+ g[GLOBAL_KEY] ??= createLeadAttributionStore();
929
+ return g[GLOBAL_KEY];
930
+ }
931
+ function resetLeadAttributionStore() {
932
+ delete globalThis[GLOBAL_KEY];
933
+ }
934
+
727
935
  // src/contact-form-metadata.ts
728
936
  function browserLocation() {
729
937
  const location = globalThis.location;
@@ -735,11 +943,13 @@ function currentPagePath(location = browserLocation()) {
735
943
  }
736
944
  function buildContactFormMetadata(options = {}) {
737
945
  const location = options.location === void 0 ? browserLocation() : options.location;
946
+ const attribution = options.attribution === void 0 ? getLeadAttributionStore().get() : options.attribution;
738
947
  return {
739
948
  fromPage: options.fromPage ?? currentPagePath(location),
740
949
  timestamp: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
741
950
  currentUrl: location?.href ?? "",
742
- leadSource: options.leadSource ?? { platform: "organic" }
951
+ leadSource: options.leadSource ?? { platform: "organic" },
952
+ ...attribution && { attribution }
743
953
  };
744
954
  }
745
955
 
@@ -781,14 +991,21 @@ exports.AcademyCheckoutError = AcademyCheckoutError;
781
991
  exports.AmbiguousPageSlugError = AmbiguousPageSlugError;
782
992
  exports.ApiError = ApiError;
783
993
  exports.DEFAULT_CHECKOUT_REDIRECT_HOSTS = DEFAULT_CHECKOUT_REDIRECT_HOSTS;
994
+ exports.LEAD_ATTRIBUTION_CLICK_IDS = LEAD_ATTRIBUTION_CLICK_IDS;
995
+ exports.LEAD_ATTRIBUTION_COOKIE_NAME = LEAD_ATTRIBUTION_COOKIE_NAME;
996
+ exports.LEAD_ATTRIBUTION_MAX_AGE_SECONDS = LEAD_ATTRIBUTION_MAX_AGE_SECONDS;
784
997
  exports.PlainCacheAdapter = PlainCacheAdapter;
785
998
  exports.RETRY_AFTER_CAP_MS = RETRY_AFTER_CAP_MS;
786
999
  exports.UnsafeCheckoutRedirectError = UnsafeCheckoutRedirectError;
787
1000
  exports.VISITOR_IP_HEADER = VISITOR_IP_HEADER;
788
1001
  exports.buildContactFormMetadata = buildContactFormMetadata;
1002
+ exports.createLeadAttributionStore = createLeadAttributionStore;
789
1003
  exports.createRebluClient = createRebluClient;
790
1004
  exports.currentPagePath = currentPagePath;
1005
+ exports.getLeadAttributionStore = getLeadAttributionStore;
791
1006
  exports.isSafeCheckoutRedirectUrl = isSafeCheckoutRedirectUrl;
1007
+ exports.leadTouchFromVisit = leadTouchFromVisit;
1008
+ exports.resetLeadAttributionStore = resetLeadAttributionStore;
792
1009
  exports.visitorIpFromHeaders = visitorIpFromHeaders;
793
1010
  //# sourceMappingURL=index.cjs.map
794
1011
  //# sourceMappingURL=index.cjs.map