@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 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
- return { list };
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