@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/CHANGELOG.md +12 -0
- package/README.md +30 -1
- package/dist/index.cjs +226 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +95 -3
- package/dist/index.d.ts +95 -3
- package/dist/index.js +220 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 5.21.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- c8bf6c1: Featured reviews (REB-1272). `@reblu/site-contracts` exports `FeaturedReviews` (`{ layout: 'grid' | 'carousel', reviews: Review[] }`) and `FeaturedReviewsLayout` for `GET /api/site/reviews/featured`. `@reblu/site-client` adds `client.reviews.featured({ limit? }, cache?)`: the reviews the tenant featured for its home in nexus, published only and in the featured order, plus the layout it chose (404 without the tenant's `reviews` module). `reviews.list()` and every other namespace are unchanged.
|
|
14
|
+
|
|
3
15
|
## 5.20.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ resource; list methods return `{ data, meta? }` (pagination metadata preserved).
|
|
|
199
199
|
| `analytics` | `get()` |
|
|
200
200
|
| `legal` | `get()` — privacy, terms, cookies, `legalNotice`, `withdrawalPolicy` |
|
|
201
201
|
| `trustBadges` | `list(params?)` — `{ type? }`; only active badges inside their validity window |
|
|
202
|
-
| `reviews` | `list(params?)` — `{ pageId?, limit? }`; published reviews only (REB-1272). 404 without the tenant's `reviews` module |
|
|
202
|
+
| `reviews` | `list(params?)` — `{ pageId?, limit? }`; published reviews only (REB-1272). `featured(params?)` — `{ limit? }` → `{ layout, reviews }`, the home's featured reviews. 404 without the tenant's `reviews` module |
|
|
203
203
|
| `ctas` | `list(slot)` |
|
|
204
204
|
| `blog` | `list(params?)`, `bySlug(slug)`, `related(slug)`, `categories()`, `slugs()`, `preview(id)` |
|
|
205
205
|
| `pages` | `list(params?)`, `bySlug(slug)`, `featured(category, opts?)`, `slugs()` |
|
|
@@ -247,6 +247,18 @@ The endpoint answers 404 when the tenant does not have the `reviews` module, so
|
|
|
247
247
|
catch it and render nothing: `.catch(() => ({ data: [] }))`. Render the list
|
|
248
248
|
with `Reviews` from `@reblu/site-blocks`.
|
|
249
249
|
|
|
250
|
+
`reviews.featured()` (owner, 8-Nov) returns the reviews the tenant featured
|
|
251
|
+
for its home in nexus, published only and in the featured order, plus the
|
|
252
|
+
`layout` it chose (`'grid'` or `'carousel'`). Render both with
|
|
253
|
+
`FeaturedReviews` from `@reblu/site-blocks/client`:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
const featured = await client.reviews
|
|
257
|
+
.featured({ limit: 9 })
|
|
258
|
+
.catch(() => ({ layout: 'grid' as const, reviews: [] }))
|
|
259
|
+
// <FeaturedReviews reviews={featured.reviews} layout={featured.layout} />
|
|
260
|
+
```
|
|
261
|
+
|
|
250
262
|
`pages.bySlug(slug)` returns a `PageDetail` whose `blocks[]` mirror their
|
|
251
263
|
parent section's `heading`/`backgroundColor`/`cta`, with embed blocks
|
|
252
264
|
(`doctor_embed`/`specialty_embed`) additionally carrying a server-resolved
|
|
@@ -439,6 +451,23 @@ await fetch('/api/contact-form', {
|
|
|
439
451
|
buildContactFormMetadata({ fromPage: '/landing/verano', leadSource: { platform: 'instagram' } })
|
|
440
452
|
```
|
|
441
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
|
+
|
|
442
471
|
Do not hardcode `fromPage` (e.g. `'home'`): every lead would then report the
|
|
443
472
|
same page. base-api still accepts such a value and falls back to the path of
|
|
444
473
|
`currentUrl`. `currentPagePath()` returns just the path, for a SITE that builds
|
package/dist/index.cjs
CHANGED
|
@@ -681,7 +681,14 @@ function makeReviewsNamespace(ctx) {
|
|
|
681
681
|
{ cachePolicy: cache }
|
|
682
682
|
);
|
|
683
683
|
}
|
|
684
|
-
|
|
684
|
+
function featured(params = {}, cache) {
|
|
685
|
+
return requestOne(
|
|
686
|
+
ctx,
|
|
687
|
+
buildUrl("/api/site/reviews/featured", { limit: params.limit }),
|
|
688
|
+
{ cachePolicy: cache }
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return { list, featured };
|
|
685
692
|
}
|
|
686
693
|
|
|
687
694
|
// src/domains/trust-badges.ts
|
|
@@ -717,6 +724,214 @@ function makeWithdrawalRequestsNamespace(ctx) {
|
|
|
717
724
|
};
|
|
718
725
|
}
|
|
719
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
|
+
|
|
720
935
|
// src/contact-form-metadata.ts
|
|
721
936
|
function browserLocation() {
|
|
722
937
|
const location = globalThis.location;
|
|
@@ -728,11 +943,13 @@ function currentPagePath(location = browserLocation()) {
|
|
|
728
943
|
}
|
|
729
944
|
function buildContactFormMetadata(options = {}) {
|
|
730
945
|
const location = options.location === void 0 ? browserLocation() : options.location;
|
|
946
|
+
const attribution = options.attribution === void 0 ? getLeadAttributionStore().get() : options.attribution;
|
|
731
947
|
return {
|
|
732
948
|
fromPage: options.fromPage ?? currentPagePath(location),
|
|
733
949
|
timestamp: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
734
950
|
currentUrl: location?.href ?? "",
|
|
735
|
-
leadSource: options.leadSource ?? { platform: "organic" }
|
|
951
|
+
leadSource: options.leadSource ?? { platform: "organic" },
|
|
952
|
+
...attribution && { attribution }
|
|
736
953
|
};
|
|
737
954
|
}
|
|
738
955
|
|
|
@@ -774,14 +991,21 @@ exports.AcademyCheckoutError = AcademyCheckoutError;
|
|
|
774
991
|
exports.AmbiguousPageSlugError = AmbiguousPageSlugError;
|
|
775
992
|
exports.ApiError = ApiError;
|
|
776
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;
|
|
777
997
|
exports.PlainCacheAdapter = PlainCacheAdapter;
|
|
778
998
|
exports.RETRY_AFTER_CAP_MS = RETRY_AFTER_CAP_MS;
|
|
779
999
|
exports.UnsafeCheckoutRedirectError = UnsafeCheckoutRedirectError;
|
|
780
1000
|
exports.VISITOR_IP_HEADER = VISITOR_IP_HEADER;
|
|
781
1001
|
exports.buildContactFormMetadata = buildContactFormMetadata;
|
|
1002
|
+
exports.createLeadAttributionStore = createLeadAttributionStore;
|
|
782
1003
|
exports.createRebluClient = createRebluClient;
|
|
783
1004
|
exports.currentPagePath = currentPagePath;
|
|
1005
|
+
exports.getLeadAttributionStore = getLeadAttributionStore;
|
|
784
1006
|
exports.isSafeCheckoutRedirectUrl = isSafeCheckoutRedirectUrl;
|
|
1007
|
+
exports.leadTouchFromVisit = leadTouchFromVisit;
|
|
1008
|
+
exports.resetLeadAttributionStore = resetLeadAttributionStore;
|
|
785
1009
|
exports.visitorIpFromHeaders = visitorIpFromHeaders;
|
|
786
1010
|
//# sourceMappingURL=index.cjs.map
|
|
787
1011
|
//# sourceMappingURL=index.cjs.map
|