@websline/cms-view-utils 1.9.0 → 1.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@websline/cms-view-utils",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -208,17 +208,19 @@ const createAdRunner = ({
208
208
 
209
209
  /**
210
210
  * Following the ad ends it too: a visitor who acted on it has seen what it
211
- * had to say, and meeting it again on the next page reads as nagging.
211
+ * had to say, and meeting it again on the next page reads as nagging. A
212
+ * persistent ad stays until the visitor closes it.
212
213
  */
213
214
  click(ad) {
214
215
  if (!ad) return;
215
216
 
216
217
  track(ad.uuid, "click");
217
- release(ad);
218
+ if (!ad.persistent) release(ad);
218
219
  },
219
220
 
221
+ /** An ad that is not closable has no close button, so nothing to dismiss. */
220
222
  dismiss(ad) {
221
- if (!ad) return;
223
+ if (!ad || ad.closable === false) return;
222
224
 
223
225
  track(ad.uuid, "close");
224
226
  release(ad);
@@ -98,8 +98,12 @@ const filterEligibleAds = ({
98
98
  candidates.filter((ad) => {
99
99
  if (!hasConsent(ad, isConsentGiven)) return false;
100
100
  // Once per visit is the floor; a reload must not put it back on the screen.
101
- if (wasSeenThisVisit(ad, seenAt(ad.uuid))) return false;
102
- if (isSuppressed(ad, dismissedAt(ad.uuid), now)) return false;
101
+ // A persistent ad has no such floor: it stays on every page until closed.
102
+ if (!ad.persistent && wasSeenThisVisit(ad, seenAt(ad.uuid))) return false;
103
+ // `closable: false` has no close button, so no dismissal can hold it back.
104
+ if (ad.closable !== false && isSuppressed(ad, dismissedAt(ad.uuid), now)) {
105
+ return false;
106
+ }
103
107
 
104
108
  const { cookieName, cookieValue, paramName, paramValue } = ad.conditions ?? {};
105
109
 
@@ -39,16 +39,41 @@ const afterDelay = (seconds, fire, timers) => {
39
39
  return () => timers.clearTimeout(id);
40
40
  };
41
41
 
42
+ /** How far the page can scroll at all; zero for a page that fits the window. */
43
+ const scrollableHeight = (target, doc) =>
44
+ Math.max((doc?.documentElement?.scrollHeight ?? 0) - (target.innerHeight ?? 0), 0);
45
+
42
46
  /**
43
- * Keeps listening until the page has really moved. `once` would drop the listener
44
- * on the first scroll event even when it arrived back at the top — an overscroll
45
- * bounce or a sideways scroll — and the ad would never appear for that visit.
47
+ * Fixed rather than set per ad, and in pixels rather than a share of the page: a
48
+ * share of a long page lies far down, so the ad would come late or not at all.
46
49
  */
47
- const onFirstScroll = (fire, target) => {
50
+ const SCROLL_DISTANCE_PX = 300;
51
+
52
+ /**
53
+ * Keeps listening until the distance is reached: `once` would drop the listener
54
+ * on an overscroll bounce back at the top and lose the ad for that visit. A page
55
+ * shorter than the distance counts once scrolled to its end.
56
+ */
57
+ const onScrolledPast = (fire, target, doc) => {
58
+ const reached = () => {
59
+ const scrollable = scrollableHeight(target, doc);
60
+
61
+ // A pixel of slack: at the very end, zoomed pages report a fractional scrollY.
62
+ return (
63
+ scrollable > 0 && target.scrollY >= Math.min(SCROLL_DISTANCE_PX, scrollable) - 1
64
+ );
65
+ };
66
+
67
+ // Already that far when the ad arrived — no further event may come.
68
+ if (reached()) {
69
+ fire();
70
+ return () => {};
71
+ }
72
+
48
73
  const stop = () => target.removeEventListener("scroll", handler);
49
74
 
50
75
  function handler() {
51
- if (target.scrollY <= 0) return;
76
+ if (!reached()) return;
52
77
 
53
78
  stop();
54
79
  fire();
@@ -120,14 +145,7 @@ const watchTrigger = (trigger, onFire, env = {}) => {
120
145
  }
121
146
 
122
147
  case "scroll":
123
- if (!target) return never();
124
- // Already scrolled when the ad arrived — the event will not come again.
125
- if (target.scrollY > 0) {
126
- fire();
127
- return never();
128
- }
129
-
130
- return onFirstScroll(fire, target);
148
+ return target ? onScrolledPast(fire, target, doc) : never();
131
149
 
132
150
  case "exit_intent":
133
151
  return doc ? onExitIntent(fire, doc) : never();
@@ -138,4 +156,4 @@ const watchTrigger = (trigger, onFire, env = {}) => {
138
156
  }
139
157
  };
140
158
 
141
- export { countPageView, PAGE_VIEWS_KEY, watchTrigger };
159
+ export { countPageView, PAGE_VIEWS_KEY, SCROLL_DISTANCE_PX, watchTrigger };