@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 +6 -0
- package/README.md +17 -0
- package/dist/index.cjs +218 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -3
- package/dist/index.d.ts +89 -3
- package/dist/index.js +212 -2
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|