gatsby-core-theme 44.31.1 → 44.32.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 +20 -0
- package/package.json +1 -1
- package/src/components/atoms/notifications/index.js +9 -2
- package/src/components/atoms/notifications/notification-items/bonuses/index.js +42 -19
- package/src/components/molecules/bonus-box/template-one/index.js +1 -0
- package/src/hooks/useCopyCtaRedirect/index.js +216 -0
- package/src/hooks/useCopyCtaRedirect/useCopyCtaRedirect.test.js +287 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
# [44.32.0](https://gitlab.com/g2m-gentoo/team-floyd/themes/gatsby-themes/compare/v44.31.1...v44.32.0) (2026-07-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* pass ctaAsButton to nested coupon in bonus-box template-one ([3ccce08](https://gitlab.com/g2m-gentoo/team-floyd/themes/gatsby-themes/commit/3ccce08110044b9197910ac2701f1ca0baa17c8b))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
* Merge branch 'feat/notification-bonus-code-cta' into 'master' ([45b70ce](https://gitlab.com/g2m-gentoo/team-floyd/themes/gatsby-themes/commit/45b70cee5cb86a947054d57e3f3cd1631056c463))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* notification bonus code acts as CTA via shared useCopyCtaRedirect hook ([7a8055a](https://gitlab.com/g2m-gentoo/team-floyd/themes/gatsby-themes/commit/7a8055a7ce4674f4506f2458e27ab122f8d72912))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Tests
|
|
18
|
+
|
|
19
|
+
* cover useCopyCtaRedirect hook to restore coverage threshold ([f23afa6](https://gitlab.com/g2m-gentoo/team-floyd/themes/gatsby-themes/commit/f23afa63d82527528fcb890b8919e3b61de8eda9))
|
|
20
|
+
|
|
1
21
|
## [44.31.1](https://gitlab.com/g2m-gentoo/team-floyd/themes/gatsby-themes/compare/v44.31.0...v44.31.1) (2026-07-09)
|
|
2
22
|
|
|
3
23
|
|
package/package.json
CHANGED
|
@@ -25,7 +25,12 @@ const getNotificationToplist = (modules) => {
|
|
|
25
25
|
);
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const Notifications = ({
|
|
28
|
+
const Notifications = ({
|
|
29
|
+
section,
|
|
30
|
+
market,
|
|
31
|
+
hideBadges = false,
|
|
32
|
+
bonusCodeActsAsCta = false,
|
|
33
|
+
}) => {
|
|
29
34
|
const { setShowSearch, setShowMenu, showMenu, showSearch } =
|
|
30
35
|
useContext(NavigationContext);
|
|
31
36
|
const notificationRef = useRef(null);
|
|
@@ -148,6 +153,7 @@ const Notifications = ({ section, market, hideBadges = false }) => {
|
|
|
148
153
|
toplist={notificationToplist}
|
|
149
154
|
showUnread={newNotification}
|
|
150
155
|
hideBadges={hideBadges}
|
|
156
|
+
bonusCodeActsAsCta={bonusCodeActsAsCta}
|
|
151
157
|
/>
|
|
152
158
|
) : (
|
|
153
159
|
renderTrendingContent()
|
|
@@ -165,7 +171,8 @@ Notifications.propTypes = {
|
|
|
165
171
|
section: PropTypes.shape({
|
|
166
172
|
modules: PropTypes.arrayOf(PropTypes.shape({})),
|
|
167
173
|
}),
|
|
168
|
-
hideBadges: PropTypes.bool
|
|
174
|
+
hideBadges: PropTypes.bool,
|
|
175
|
+
bonusCodeActsAsCta: PropTypes.bool
|
|
169
176
|
};
|
|
170
177
|
|
|
171
178
|
export default Notifications;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from "react";
|
|
2
2
|
import PropTypes from "prop-types";
|
|
3
3
|
import { FaArrowRight } from "@react-icons/all-files/fa/FaArrowRight";
|
|
4
4
|
import CopyIcon from "~images/icons/copyIcon";
|
|
@@ -8,11 +8,14 @@ import PrettyLink from "~atoms/pretty-link";
|
|
|
8
8
|
import ReviewLink from "~atoms/review-link";
|
|
9
9
|
import Tnc from "~molecules/tnc";
|
|
10
10
|
import keygen from "~helpers/keygen";
|
|
11
|
+
import useCopyCtaRedirect from "~hooks/useCopyCtaRedirect/index";
|
|
12
|
+
import { getCtaDataAttributes } from "~helpers/tracker";
|
|
11
13
|
import {
|
|
12
14
|
getBonusData,
|
|
13
15
|
getPromoCode,
|
|
14
16
|
getOperatorTnc,
|
|
15
17
|
imagePrettyUrl,
|
|
18
|
+
prettyTracker,
|
|
16
19
|
} from "~helpers/getters";
|
|
17
20
|
import { getAltText } from "~helpers/image";
|
|
18
21
|
import { getTimeAgo, parseSortableDate } from "~helpers/date-time";
|
|
@@ -28,9 +31,10 @@ const BonusItem = ({
|
|
|
28
31
|
showUnread,
|
|
29
32
|
bonusCodeLabel,
|
|
30
33
|
updatedLabel,
|
|
31
|
-
hideBadges
|
|
34
|
+
hideBadges,
|
|
35
|
+
bonusCodeActsAsCta = false,
|
|
36
|
+
redirectDelay = 2000
|
|
32
37
|
}) => {
|
|
33
|
-
const [copySuccess, setCopySuccess] = useState(false);
|
|
34
38
|
const bonus = getBonusData(item, tracker);
|
|
35
39
|
const oneliner = [bonus?.mainLine, bonus?.secondLine]
|
|
36
40
|
.filter(Boolean)
|
|
@@ -67,17 +71,26 @@ const BonusItem = ({
|
|
|
67
71
|
const codeCopiedLabel = useTranslate("code_copied", "CODE COPIED");
|
|
68
72
|
const isBet365 = item.short_name === "bet365";
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
// Optional CTA behaviour: when enabled the bonus-code button doubles as a CTA —
|
|
75
|
+
// copy, drop the affiliate cookie, then open the operator. It stays a <button>
|
|
76
|
+
// (no nested-anchor hydration issue), so 'button' redirect mode.
|
|
77
|
+
const trackerType = tracker?.toLowerCase()?.replace(" ", "_");
|
|
78
|
+
const ctaLink = bonusCodeActsAsCta ? prettyTracker(item, trackerType, false) : null;
|
|
79
|
+
const ctaActive =
|
|
80
|
+
bonusCodeActsAsCta && !!ctaLink && !isBet365 && item?.status !== "inactive";
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
const { copySuccess, handleCopyClick } = useCopyCtaRedirect({
|
|
83
|
+
code: promoCode,
|
|
84
|
+
enabled: ctaActive,
|
|
85
|
+
disabled: isBet365,
|
|
86
|
+
redirectLink: ctaLink,
|
|
87
|
+
redirectMode: "button",
|
|
88
|
+
redirectDelay,
|
|
89
|
+
moduleName: TrackingKeys.NAVIGATIONTOPLIST,
|
|
90
|
+
tracker,
|
|
91
|
+
clickedElement: "notification_bonus_code",
|
|
92
|
+
itemPosition: index + 1,
|
|
93
|
+
});
|
|
81
94
|
|
|
82
95
|
return (
|
|
83
96
|
<li className={styles.bonusItem}>
|
|
@@ -107,10 +120,15 @@ const BonusItem = ({
|
|
|
107
120
|
<button
|
|
108
121
|
type="button"
|
|
109
122
|
className={`${styles.bonusCode} ${copySuccess ? styles.bonusCodeCopied : ""}`}
|
|
110
|
-
onClick={
|
|
111
|
-
promoCode && !isBet365 ? copyToClipBoard(e, promoCode) : undefined
|
|
112
|
-
}
|
|
123
|
+
onClick={handleCopyClick}
|
|
113
124
|
disabled={isBet365}
|
|
125
|
+
{...(ctaActive
|
|
126
|
+
? getCtaDataAttributes(item, {
|
|
127
|
+
tracker,
|
|
128
|
+
moduleName: TrackingKeys.NAVIGATIONTOPLIST,
|
|
129
|
+
itemPosition: index + 1,
|
|
130
|
+
})
|
|
131
|
+
: {})}
|
|
114
132
|
>
|
|
115
133
|
{!copySuccess ? (
|
|
116
134
|
<>
|
|
@@ -180,6 +198,7 @@ BonusItem.propTypes = {
|
|
|
180
198
|
updated_at: PropTypes.string,
|
|
181
199
|
short_name: PropTypes.string,
|
|
182
200
|
name: PropTypes.string,
|
|
201
|
+
status: PropTypes.string,
|
|
183
202
|
founded: PropTypes.string,
|
|
184
203
|
logo: PropTypes.shape({
|
|
185
204
|
filename: PropTypes.string,
|
|
@@ -190,10 +209,12 @@ BonusItem.propTypes = {
|
|
|
190
209
|
showUnread: PropTypes.bool,
|
|
191
210
|
bonusCodeLabel: PropTypes.string.isRequired,
|
|
192
211
|
updatedLabel: PropTypes.string.isRequired,
|
|
193
|
-
hideBadges: PropTypes.bool
|
|
212
|
+
hideBadges: PropTypes.bool,
|
|
213
|
+
bonusCodeActsAsCta: PropTypes.bool,
|
|
214
|
+
redirectDelay: PropTypes.number
|
|
194
215
|
};
|
|
195
216
|
|
|
196
|
-
const BonusesItems = ({ toplist, showUnread, hideBadges }) => {
|
|
217
|
+
const BonusesItems = ({ toplist, showUnread, hideBadges, bonusCodeActsAsCta = false }) => {
|
|
197
218
|
const items = [...(toplist?.items || [])].sort(
|
|
198
219
|
(a, b) =>
|
|
199
220
|
parseSortableDate(b.updated_at || b.founded) -
|
|
@@ -219,6 +240,7 @@ const BonusesItems = ({ toplist, showUnread, hideBadges }) => {
|
|
|
219
240
|
bonusCodeLabel={bonusCodeLabel}
|
|
220
241
|
updatedLabel={updatedLabel}
|
|
221
242
|
hideBadges={hideBadges}
|
|
243
|
+
bonusCodeActsAsCta={bonusCodeActsAsCta}
|
|
222
244
|
/>
|
|
223
245
|
))}
|
|
224
246
|
</ul>
|
|
@@ -233,7 +255,8 @@ BonusesItems.propTypes = {
|
|
|
233
255
|
tracker: PropTypes.string,
|
|
234
256
|
}),
|
|
235
257
|
showUnread: PropTypes.bool,
|
|
236
|
-
hideBadges: PropTypes.bool
|
|
258
|
+
hideBadges: PropTypes.bool,
|
|
259
|
+
bonusCodeActsAsCta: PropTypes.bool
|
|
237
260
|
};
|
|
238
261
|
|
|
239
262
|
export default BonusesItems;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import useCtaClickHandler from '../useCtaClickHandler/index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Copy-to-clipboard with an optional "act as CTA" redirect. Shared by the coupon
|
|
6
|
+
* atom (gatsby-matrix-theme) and the notification bonus-code button so the
|
|
7
|
+
* copy → track → open-operator flow lives in one place.
|
|
8
|
+
*
|
|
9
|
+
* On click it copies `code` and flips `copySuccess`. When `enabled` it also drops
|
|
10
|
+
* the affiliate cookie (`fireTracking`) and opens the operator. Two open strategies:
|
|
11
|
+
* - Non-Safari (Chrome/Firefox/Edge, desktop or Android): dwell `redirectDelay`,
|
|
12
|
+
* then open and revert the label — the delay stays within the transient-activation
|
|
13
|
+
* window so the open is not popup-blocked.
|
|
14
|
+
* - Safari / iOS (all iOS browsers are WebKit): open immediately inside the tap
|
|
15
|
+
* gesture (a delayed open would be popup-blocked), and reset the label only once
|
|
16
|
+
* the user returns to the tab (Page Visibility) — never while they're on the operator.
|
|
17
|
+
*
|
|
18
|
+
* Two redirect modes:
|
|
19
|
+
* - 'anchor': re-dispatch a real click on `ctaRef` (so GTM's outbound-click
|
|
20
|
+
* trigger fires), falling back to window.open(redirectLink). Attach the
|
|
21
|
+
* returned `ctaRef` to the rendered <a>.
|
|
22
|
+
* - 'button': window.open(redirectLink) directly; the element stays a <button>.
|
|
23
|
+
*
|
|
24
|
+
* When `enabled` is false it just copies (original non-CTA behaviour).
|
|
25
|
+
*/
|
|
26
|
+
const useCopyCtaRedirect = ({
|
|
27
|
+
code = '',
|
|
28
|
+
enabled = false,
|
|
29
|
+
disabled = false,
|
|
30
|
+
redirectLink = null,
|
|
31
|
+
redirectMode = 'button',
|
|
32
|
+
redirectDelay = 2000,
|
|
33
|
+
pageTemplate = null,
|
|
34
|
+
moduleName = null,
|
|
35
|
+
tracker = 'main',
|
|
36
|
+
clickedElement = 'cta',
|
|
37
|
+
modulePosition,
|
|
38
|
+
itemPosition
|
|
39
|
+
} = {}) => {
|
|
40
|
+
const [copySuccess, setCopySuccess] = useState(false);
|
|
41
|
+
const redirectTimer = useRef(null);
|
|
42
|
+
const isPending = useRef(false); // a redirect is scheduled
|
|
43
|
+
const allowNavigation = useRef(false); // the next click is our own re-dispatch (anchor mode)
|
|
44
|
+
const visibilityHandler = useRef(null); // Safari: the reset-on-return listener
|
|
45
|
+
const safariFallbackTimer = useRef(null); // Safari: safety-net reset if the tab never backgrounds
|
|
46
|
+
|
|
47
|
+
// Safari — and every iOS browser, which is WebKit under the hood — only honours
|
|
48
|
+
// window.open when it runs synchronously inside the tap/click gesture; a delayed
|
|
49
|
+
// open is blocked as a popup. So on Safari we open immediately within the gesture
|
|
50
|
+
// and reset the "copied" label only once the user returns to our tab. Every other
|
|
51
|
+
// browser (Chrome/Firefox/Edge, desktop or Android) keeps the desktop dwell +
|
|
52
|
+
// delayed open.
|
|
53
|
+
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
|
54
|
+
const isIOS =
|
|
55
|
+
/iP(hone|od|ad)/.test(ua) ||
|
|
56
|
+
// iPadOS 13+ reports as "Macintosh"; disambiguate via touch points.
|
|
57
|
+
(/Macintosh/.test(ua) && (navigator?.maxTouchPoints || 0) > 1);
|
|
58
|
+
const isSafari =
|
|
59
|
+
isIOS ||
|
|
60
|
+
(/Safari/.test(ua) && !/Chrome|Chromium|Android|CriOS|FxiOS|Edg|OPR/.test(ua));
|
|
61
|
+
|
|
62
|
+
// `fireTracking` drops the affiliate cookie / fires the pixel; `ctaRef` points
|
|
63
|
+
// at the anchor we redirect through in 'anchor' mode.
|
|
64
|
+
const { ctaRef, fireTracking } = useCtaClickHandler({
|
|
65
|
+
pageTemplate,
|
|
66
|
+
moduleName,
|
|
67
|
+
tracker,
|
|
68
|
+
clickedElement,
|
|
69
|
+
modulePosition,
|
|
70
|
+
itemPosition
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Clear a scheduled redirect if the component unmounts mid-flow
|
|
74
|
+
useEffect(
|
|
75
|
+
() => () => {
|
|
76
|
+
if (redirectTimer.current) clearTimeout(redirectTimer.current);
|
|
77
|
+
if (safariFallbackTimer.current) clearTimeout(safariFallbackTimer.current);
|
|
78
|
+
if (visibilityHandler.current && typeof document !== 'undefined') {
|
|
79
|
+
document.removeEventListener('visibilitychange', visibilityHandler.current);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const openRedirect = () => {
|
|
86
|
+
if (redirectMode === 'anchor') {
|
|
87
|
+
const anchor = ctaRef.current;
|
|
88
|
+
if (anchor) {
|
|
89
|
+
// Re-dispatch a real click so the anchor navigates (new tab) and GTM's
|
|
90
|
+
// outbound-click trigger still fires. `allowNavigation` lets our own
|
|
91
|
+
// handler fall through instead of intercepting the click again.
|
|
92
|
+
allowNavigation.current = true;
|
|
93
|
+
const clickEvent = new MouseEvent('click', {
|
|
94
|
+
bubbles: true,
|
|
95
|
+
cancelable: true,
|
|
96
|
+
view: window
|
|
97
|
+
});
|
|
98
|
+
// opened === false when a wrapping anchor cancelled navigation → fall back.
|
|
99
|
+
if (anchor.dispatchEvent(clickEvent)) return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (redirectLink) window.open(redirectLink, '_blank');
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Safari flow: after the operator tab opens, keep the "copied" label until the
|
|
106
|
+
// user comes back to our tab, then reset it `redirectDelay` later. If they never
|
|
107
|
+
// return the label just stays (the tab is backgrounded anyway). Uses the Page
|
|
108
|
+
// Visibility API instead of a fixed timer so the dwell isn't spent while the
|
|
109
|
+
// user is away — and so backgrounded-timer throttling can't fire it early.
|
|
110
|
+
const armVisibilityReset = () => {
|
|
111
|
+
if (typeof document === 'undefined') return;
|
|
112
|
+
|
|
113
|
+
const onVisible = () => {
|
|
114
|
+
if (document.visibilityState !== 'visible') return;
|
|
115
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
116
|
+
visibilityHandler.current = null;
|
|
117
|
+
// The user is back — cancel the safety-net timer; we now dwell then reset.
|
|
118
|
+
if (safariFallbackTimer.current) clearTimeout(safariFallbackTimer.current);
|
|
119
|
+
if (redirectTimer.current) clearTimeout(redirectTimer.current);
|
|
120
|
+
redirectTimer.current = setTimeout(() => {
|
|
121
|
+
isPending.current = false;
|
|
122
|
+
setCopySuccess(false);
|
|
123
|
+
}, redirectDelay);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Drop any listener left over from an earlier tap before arming a fresh one.
|
|
127
|
+
if (visibilityHandler.current) {
|
|
128
|
+
document.removeEventListener('visibilitychange', visibilityHandler.current);
|
|
129
|
+
}
|
|
130
|
+
visibilityHandler.current = onVisible;
|
|
131
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleCopyClick = (e) => {
|
|
135
|
+
// Our own re-dispatched click (anchor mode): let it navigate + GTM record it
|
|
136
|
+
if (allowNavigation.current) {
|
|
137
|
+
allowNavigation.current = false;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (e) {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (disabled) return;
|
|
147
|
+
// Ignore extra clicks while a redirect is already queued
|
|
148
|
+
if (isPending.current) return;
|
|
149
|
+
|
|
150
|
+
if (!enabled) {
|
|
151
|
+
// Non-CTA: copy, then reflect success/failure in the label. No redirect.
|
|
152
|
+
// `navigator.clipboard` is absent in insecure contexts / some in-app webviews,
|
|
153
|
+
// where the property access itself throws — guard before calling.
|
|
154
|
+
const write = navigator.clipboard?.writeText(code);
|
|
155
|
+
if (write) {
|
|
156
|
+
write.then(() => setCopySuccess(true), () => setCopySuccess(false));
|
|
157
|
+
} else {
|
|
158
|
+
setCopySuccess(false);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// CTA path. Fire the clipboard write WITHOUT awaiting it: an await would break
|
|
164
|
+
// out of the synchronous tap gesture and let Safari popup-block the open. We flip
|
|
165
|
+
// to "copied" regardless of the write result — the redirect is the primary action.
|
|
166
|
+
// Optional-chain because `navigator.clipboard` is absent in insecure contexts /
|
|
167
|
+
// some in-app webviews, where the access itself would throw and abort the CTA.
|
|
168
|
+
navigator.clipboard?.writeText(code)?.catch(() => {});
|
|
169
|
+
setCopySuccess(true);
|
|
170
|
+
fireTracking();
|
|
171
|
+
isPending.current = true;
|
|
172
|
+
|
|
173
|
+
if (isSafari) {
|
|
174
|
+
// Open now, inside the tap gesture, so it isn't popup-blocked. Use window.open
|
|
175
|
+
// directly — on WebKit a synthetic anchor click carries no user activation and
|
|
176
|
+
// would itself be popup-blocked, so we skip the re-dispatch here (the affiliate
|
|
177
|
+
// pixel already fired via fireTracking above).
|
|
178
|
+
if (redirectLink) window.open(redirectLink, '_blank');
|
|
179
|
+
|
|
180
|
+
// Normal case: the tab backgrounds (user moves to the operator) → reset the
|
|
181
|
+
// "copied" label only once they return. `isPending` stays true until then so a
|
|
182
|
+
// second tap can't open another tab.
|
|
183
|
+
armVisibilityReset();
|
|
184
|
+
|
|
185
|
+
// Safety net: if the tab never actually backgrounds — open blocked, opened in a
|
|
186
|
+
// background tab, or a no-op in an in-app webview — the visibility handler never
|
|
187
|
+
// fires. After the dwell, if we're still visible, reset so the CTA isn't frozen;
|
|
188
|
+
// if we're hidden the user is on the operator, so leave it for the return reset.
|
|
189
|
+
if (safariFallbackTimer.current) clearTimeout(safariFallbackTimer.current);
|
|
190
|
+
safariFallbackTimer.current = setTimeout(() => {
|
|
191
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (visibilityHandler.current && typeof document !== 'undefined') {
|
|
195
|
+
document.removeEventListener('visibilitychange', visibilityHandler.current);
|
|
196
|
+
visibilityHandler.current = null;
|
|
197
|
+
}
|
|
198
|
+
isPending.current = false;
|
|
199
|
+
setCopySuccess(false);
|
|
200
|
+
}, redirectDelay);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Non-Safari (desktop-style): dwell, then open the operator and revert the label.
|
|
205
|
+
if (redirectTimer.current) clearTimeout(redirectTimer.current);
|
|
206
|
+
redirectTimer.current = setTimeout(() => {
|
|
207
|
+
isPending.current = false;
|
|
208
|
+
openRedirect();
|
|
209
|
+
setCopySuccess(false);
|
|
210
|
+
}, redirectDelay);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return { copySuccess, setCopySuccess, ctaRef, handleCopyClick };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export default useCopyCtaRedirect;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react';
|
|
2
|
+
import useCopyCtaRedirect from './index';
|
|
3
|
+
import useCtaClickHandler from '../useCtaClickHandler/index';
|
|
4
|
+
|
|
5
|
+
jest.mock('../useCtaClickHandler/index', () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: jest.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const SAFARI_UA =
|
|
11
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15';
|
|
12
|
+
const CHROME_UA =
|
|
13
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
|
14
|
+
|
|
15
|
+
const setUserAgent = (ua) =>
|
|
16
|
+
Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true });
|
|
17
|
+
const setVisibility = (state) =>
|
|
18
|
+
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
|
|
19
|
+
const makeEvent = () => ({ preventDefault: jest.fn(), stopPropagation: jest.fn() });
|
|
20
|
+
|
|
21
|
+
describe('useCopyCtaRedirect', () => {
|
|
22
|
+
let fireTracking;
|
|
23
|
+
let ctaRef;
|
|
24
|
+
let writeText;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
fireTracking = jest.fn();
|
|
29
|
+
ctaRef = { current: null };
|
|
30
|
+
useCtaClickHandler.mockReturnValue({ ctaRef, fireTracking });
|
|
31
|
+
|
|
32
|
+
writeText = jest.fn().mockResolvedValue(undefined);
|
|
33
|
+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
|
34
|
+
window.open = jest.fn();
|
|
35
|
+
setVisibility('visible');
|
|
36
|
+
setUserAgent(CHROME_UA);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
jest.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('non-CTA: copies and flips the "copied" label on success', async () => {
|
|
44
|
+
const { result } = renderHook(() => useCopyCtaRedirect({ code: 'PROMO', enabled: false }));
|
|
45
|
+
|
|
46
|
+
await act(async () => {
|
|
47
|
+
result.current.handleCopyClick(makeEvent());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(writeText).toHaveBeenCalledWith('PROMO');
|
|
51
|
+
expect(result.current.copySuccess).toBe(true);
|
|
52
|
+
expect(fireTracking).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('non-CTA: keeps the label unset when the clipboard API is unavailable', async () => {
|
|
56
|
+
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
|
|
57
|
+
const { result } = renderHook(() => useCopyCtaRedirect({ code: 'PROMO', enabled: false }));
|
|
58
|
+
|
|
59
|
+
await act(async () => {
|
|
60
|
+
result.current.handleCopyClick(makeEvent());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.current.copySuccess).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('disabled: prevents default but does nothing else', () => {
|
|
67
|
+
const { result } = renderHook(() =>
|
|
68
|
+
useCopyCtaRedirect({ code: 'PROMO', enabled: true, disabled: true })
|
|
69
|
+
);
|
|
70
|
+
const event = makeEvent();
|
|
71
|
+
|
|
72
|
+
act(() => {
|
|
73
|
+
result.current.handleCopyClick(event);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
77
|
+
expect(writeText).not.toHaveBeenCalled();
|
|
78
|
+
expect(fireTracking).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('CTA non-Safari: copies, tracks, and opens the operator after the dwell (button mode)', () => {
|
|
82
|
+
jest.useFakeTimers();
|
|
83
|
+
const { result } = renderHook(() =>
|
|
84
|
+
useCopyCtaRedirect({
|
|
85
|
+
code: 'PROMO',
|
|
86
|
+
enabled: true,
|
|
87
|
+
redirectMode: 'button',
|
|
88
|
+
redirectLink: 'https://op',
|
|
89
|
+
redirectDelay: 2000,
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
act(() => {
|
|
94
|
+
result.current.handleCopyClick(makeEvent());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(result.current.copySuccess).toBe(true);
|
|
98
|
+
expect(fireTracking).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(window.open).not.toHaveBeenCalled();
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
jest.advanceTimersByTime(2000);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(window.open).toHaveBeenCalledWith('https://op', '_blank');
|
|
106
|
+
expect(result.current.copySuccess).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('CTA: ignores a second tap while a redirect is already pending', () => {
|
|
110
|
+
jest.useFakeTimers();
|
|
111
|
+
const { result } = renderHook(() =>
|
|
112
|
+
useCopyCtaRedirect({
|
|
113
|
+
code: 'PROMO',
|
|
114
|
+
enabled: true,
|
|
115
|
+
redirectMode: 'button',
|
|
116
|
+
redirectLink: 'https://op',
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
act(() => {
|
|
121
|
+
result.current.handleCopyClick(makeEvent());
|
|
122
|
+
});
|
|
123
|
+
act(() => {
|
|
124
|
+
result.current.handleCopyClick(makeEvent());
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(fireTracking).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('CTA non-Safari anchor mode: re-dispatches a click, then falls through on our own click', () => {
|
|
131
|
+
jest.useFakeTimers();
|
|
132
|
+
const dispatchEvent = jest.fn(() => true);
|
|
133
|
+
ctaRef.current = { dispatchEvent };
|
|
134
|
+
const { result } = renderHook(() =>
|
|
135
|
+
useCopyCtaRedirect({
|
|
136
|
+
code: 'PROMO',
|
|
137
|
+
enabled: true,
|
|
138
|
+
redirectMode: 'anchor',
|
|
139
|
+
redirectLink: 'https://op',
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
act(() => {
|
|
144
|
+
result.current.handleCopyClick(makeEvent());
|
|
145
|
+
});
|
|
146
|
+
act(() => {
|
|
147
|
+
jest.advanceTimersByTime(2000);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(window.open).not.toHaveBeenCalled();
|
|
152
|
+
|
|
153
|
+
// openRedirect flagged allowNavigation → our own re-dispatched click is ignored.
|
|
154
|
+
const ownClick = makeEvent();
|
|
155
|
+
act(() => {
|
|
156
|
+
result.current.handleCopyClick(ownClick);
|
|
157
|
+
});
|
|
158
|
+
expect(ownClick.preventDefault).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('CTA anchor mode: falls back to window.open when the anchor navigation is cancelled', () => {
|
|
162
|
+
jest.useFakeTimers();
|
|
163
|
+
ctaRef.current = { dispatchEvent: jest.fn(() => false) };
|
|
164
|
+
const { result } = renderHook(() =>
|
|
165
|
+
useCopyCtaRedirect({
|
|
166
|
+
code: 'PROMO',
|
|
167
|
+
enabled: true,
|
|
168
|
+
redirectMode: 'anchor',
|
|
169
|
+
redirectLink: 'https://op',
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
result.current.handleCopyClick(makeEvent());
|
|
175
|
+
});
|
|
176
|
+
act(() => {
|
|
177
|
+
jest.advanceTimersByTime(2000);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(window.open).toHaveBeenCalledWith('https://op', '_blank');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('CTA Safari: opens immediately in-gesture and resets only after the user returns', () => {
|
|
184
|
+
jest.useFakeTimers();
|
|
185
|
+
setUserAgent(SAFARI_UA);
|
|
186
|
+
const { result } = renderHook(() =>
|
|
187
|
+
useCopyCtaRedirect({
|
|
188
|
+
code: 'PROMO',
|
|
189
|
+
enabled: true,
|
|
190
|
+
redirectMode: 'button',
|
|
191
|
+
redirectLink: 'https://op',
|
|
192
|
+
redirectDelay: 2000,
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
act(() => {
|
|
197
|
+
result.current.handleCopyClick(makeEvent());
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(window.open).toHaveBeenCalledWith('https://op', '_blank');
|
|
201
|
+
expect(result.current.copySuccess).toBe(true);
|
|
202
|
+
|
|
203
|
+
// user comes back to our tab → dwell → reset
|
|
204
|
+
setVisibility('visible');
|
|
205
|
+
act(() => {
|
|
206
|
+
document.dispatchEvent(new Event('visibilitychange'));
|
|
207
|
+
});
|
|
208
|
+
act(() => {
|
|
209
|
+
jest.advanceTimersByTime(2000);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result.current.copySuccess).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('CTA Safari: safety-net resets the label if the tab never backgrounds', () => {
|
|
216
|
+
jest.useFakeTimers();
|
|
217
|
+
setUserAgent(SAFARI_UA);
|
|
218
|
+
setVisibility('visible');
|
|
219
|
+
const { result } = renderHook(() =>
|
|
220
|
+
useCopyCtaRedirect({
|
|
221
|
+
code: 'PROMO',
|
|
222
|
+
enabled: true,
|
|
223
|
+
redirectMode: 'button',
|
|
224
|
+
redirectLink: 'https://op',
|
|
225
|
+
redirectDelay: 2000,
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
act(() => {
|
|
230
|
+
result.current.handleCopyClick(makeEvent());
|
|
231
|
+
});
|
|
232
|
+
expect(result.current.copySuccess).toBe(true);
|
|
233
|
+
|
|
234
|
+
act(() => {
|
|
235
|
+
jest.advanceTimersByTime(2000);
|
|
236
|
+
});
|
|
237
|
+
expect(result.current.copySuccess).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('CTA Safari: safety-net leaves the label while the user is on the operator tab', () => {
|
|
241
|
+
jest.useFakeTimers();
|
|
242
|
+
setUserAgent(SAFARI_UA);
|
|
243
|
+
const { result } = renderHook(() =>
|
|
244
|
+
useCopyCtaRedirect({
|
|
245
|
+
code: 'PROMO',
|
|
246
|
+
enabled: true,
|
|
247
|
+
redirectMode: 'button',
|
|
248
|
+
redirectLink: 'https://op',
|
|
249
|
+
redirectDelay: 2000,
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
act(() => {
|
|
254
|
+
result.current.handleCopyClick(makeEvent());
|
|
255
|
+
});
|
|
256
|
+
setVisibility('hidden');
|
|
257
|
+
act(() => {
|
|
258
|
+
jest.advanceTimersByTime(2000);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.current.copySuccess).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('cleans up the visibility listener on unmount', () => {
|
|
265
|
+
jest.useFakeTimers();
|
|
266
|
+
setUserAgent(SAFARI_UA);
|
|
267
|
+
const removeSpy = jest.spyOn(document, 'removeEventListener');
|
|
268
|
+
const { result, unmount } = renderHook(() =>
|
|
269
|
+
useCopyCtaRedirect({
|
|
270
|
+
code: 'PROMO',
|
|
271
|
+
enabled: true,
|
|
272
|
+
redirectMode: 'button',
|
|
273
|
+
redirectLink: 'https://op',
|
|
274
|
+
})
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
act(() => {
|
|
278
|
+
result.current.handleCopyClick(makeEvent());
|
|
279
|
+
});
|
|
280
|
+
act(() => {
|
|
281
|
+
unmount();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
|
285
|
+
removeSpy.mockRestore();
|
|
286
|
+
});
|
|
287
|
+
});
|