@phantomstudios/ft-lib 0.2.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.
Files changed (88) hide show
  1. package/.babelrc +6 -0
  2. package/.eslintrc +58 -0
  3. package/.github/CODEOWNERS +1 -0
  4. package/.github/workflows/pr.yml +31 -0
  5. package/.github/workflows/release.yml +44 -0
  6. package/.nvmrc +1 -0
  7. package/.prettierignore +5 -0
  8. package/CODE_OF_CONDUCT.md +76 -0
  9. package/CONTRIBUTING.md +48 -0
  10. package/LICENSE +21 -0
  11. package/README.md +158 -0
  12. package/SUPPORT.md +4 -0
  13. package/jest.config.js +24 -0
  14. package/lib/FTTracking/index.d.ts +24 -0
  15. package/lib/FTTracking/index.d.ts.map +1 -0
  16. package/lib/FTTracking/index.js +69 -0
  17. package/lib/FTTracking/index.js.map +1 -0
  18. package/lib/consentMonitor/index.d.ts +19 -0
  19. package/lib/consentMonitor/index.d.ts.map +1 -0
  20. package/lib/consentMonitor/index.js +117 -0
  21. package/lib/consentMonitor/index.js.map +1 -0
  22. package/lib/gaTracker/index.d.ts +13 -0
  23. package/lib/gaTracker/index.d.ts.map +1 -0
  24. package/lib/gaTracker/index.js +120 -0
  25. package/lib/gaTracker/index.js.map +1 -0
  26. package/lib/index.d.ts +9 -0
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +18 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/oTracker/index.d.ts +14 -0
  31. package/lib/oTracker/index.d.ts.map +1 -0
  32. package/lib/oTracker/index.js +114 -0
  33. package/lib/oTracker/index.js.map +1 -0
  34. package/lib/permutiveVideoUtils/index.d.ts +12 -0
  35. package/lib/permutiveVideoUtils/index.d.ts.map +1 -0
  36. package/lib/permutiveVideoUtils/index.js +59 -0
  37. package/lib/permutiveVideoUtils/index.js.map +1 -0
  38. package/lib/reactPlayerTracking/index.d.ts +30 -0
  39. package/lib/reactPlayerTracking/index.d.ts.map +1 -0
  40. package/lib/reactPlayerTracking/index.js +85 -0
  41. package/lib/reactPlayerTracking/index.js.map +1 -0
  42. package/lib/utils/attention.d.ts +20 -0
  43. package/lib/utils/attention.d.ts.map +1 -0
  44. package/lib/utils/attention.js +142 -0
  45. package/lib/utils/attention.js.map +1 -0
  46. package/lib/utils/cookies.d.ts +2 -0
  47. package/lib/utils/cookies.d.ts.map +1 -0
  48. package/lib/utils/cookies.js +14 -0
  49. package/lib/utils/cookies.js.map +1 -0
  50. package/lib/utils/getTrace.d.ts +6 -0
  51. package/lib/utils/getTrace.d.ts.map +1 -0
  52. package/lib/utils/getTrace.js +109 -0
  53. package/lib/utils/getTrace.js.map +1 -0
  54. package/lib/utils/intersectionObserverPolyfill.d.ts +1 -0
  55. package/lib/utils/intersectionObserverPolyfill.d.ts.map +1 -0
  56. package/lib/utils/intersectionObserverPolyfill.js +421 -0
  57. package/lib/utils/intersectionObserverPolyfill.js.map +1 -0
  58. package/lib/utils/scroll.d.ts +18 -0
  59. package/lib/utils/scroll.d.ts.map +1 -0
  60. package/lib/utils/scroll.js +75 -0
  61. package/lib/utils/scroll.js.map +1 -0
  62. package/lib/utils/yupValidator.d.ts +87 -0
  63. package/lib/utils/yupValidator.d.ts.map +1 -0
  64. package/lib/utils/yupValidator.js +189 -0
  65. package/lib/utils/yupValidator.js.map +1 -0
  66. package/lib/ytIframeTracking/index.d.ts +27 -0
  67. package/lib/ytIframeTracking/index.d.ts.map +1 -0
  68. package/lib/ytIframeTracking/index.js +165 -0
  69. package/lib/ytIframeTracking/index.js.map +1 -0
  70. package/package.json +75 -0
  71. package/src/FTTracking/index.ts +74 -0
  72. package/src/consentMonitor/index.ts +120 -0
  73. package/src/gaTracker/index.ts +136 -0
  74. package/src/index.ts +12 -0
  75. package/src/oTracker/index.ts +122 -0
  76. package/src/permutiveVideoUtils/index.ts +53 -0
  77. package/src/reactPlayerTracking/index.ts +105 -0
  78. package/src/types.d.ts +33 -0
  79. package/src/utils/attention.js +138 -0
  80. package/src/utils/cookies.js +9 -0
  81. package/src/utils/getTrace.js +122 -0
  82. package/src/utils/intersectionObserverPolyfill.js +470 -0
  83. package/src/utils/scroll.ts +94 -0
  84. package/src/utils/yupValidator.ts +205 -0
  85. package/src/ytIframeTracking/index.ts +210 -0
  86. package/test/index.test.tsx +63 -0
  87. package/tsconfig.json +26 -0
  88. package/tsconfig.test.json +6 -0
@@ -0,0 +1,205 @@
1
+ import {
2
+ object,
3
+ string,
4
+ number,
5
+ boolean,
6
+ mixed,
7
+ InferType,
8
+ ValidationError,
9
+ } from "yup";
10
+
11
+ //transform passed values to first character uppercase and replace spaces with underscores
12
+ const unifyValuesTransform = (value: string) => {
13
+ return (
14
+ value.trim().charAt(0).toUpperCase() +
15
+ value.trim().slice(1).toLowerCase().replace(/ /g, "_").replace(/-/g, "_")
16
+ );
17
+ };
18
+
19
+ //replace legacy 'AlphaGrid'..
20
+ const authorTransform = (value: string) => {
21
+ if (value.toLowerCase() === "alphagrid") {
22
+ return "Alpha_Grid";
23
+ }
24
+ return (
25
+ value.trim().charAt(0).toUpperCase() +
26
+ value.trim().slice(1).toLowerCase().replace(/ /g, "_").replace(/-/g, "_")
27
+ );
28
+ };
29
+
30
+ const configSchema = object({
31
+ product: string().equals(["paid-post"]),
32
+ url: string().required(),
33
+ feature: string().required().oneOf(["channel", "microsite"]),
34
+ author: string().defined().default("").transform(authorTransform),
35
+ sponsor: string().defined().default(""),
36
+ articleName: string().defined().default(""),
37
+ videoName: string().defined().default(""),
38
+ videoType: string()
39
+ .optional()
40
+ .default("")
41
+ .transform(unifyValuesTransform)
42
+ .oneOf(["Feature", "Case_study", "Interview", "Animation", ""]),
43
+ hasVideo: boolean().optional().nullable(),
44
+ primaryTopic: string().defined().default("").transform(unifyValuesTransform),
45
+ secondaryTopic: string()
46
+ .defined()
47
+ .default("")
48
+ .transform(unifyValuesTransform),
49
+ advertiserIndustry: string().defined().default(""),
50
+ app: string()
51
+ .required()
52
+ .defined()
53
+ .transform(unifyValuesTransform)
54
+ .oneOf([
55
+ "Stream",
56
+ "Article",
57
+ "Animated_article",
58
+ "Immersive_article",
59
+ "Video",
60
+ "Article_with_video",
61
+ "Audio",
62
+ "Infographic",
63
+ "Interactive_infographic",
64
+ "Photo_essay",
65
+ ]),
66
+ publishDate: string().nullable().default(""),
67
+ isBranded: boolean().defined(),
68
+ contentType: string().defined().default("").transform(unifyValuesTransform),
69
+ campaign: string().defined().default(""),
70
+ server: string().equals(["https://spoor-api.ft.com/px.gif"]),
71
+ title: string().defined().default(""),
72
+ adbook_campaign_id: string().optional().default(""),
73
+ source_id: string().optional(),
74
+ wordCount: number().optional(),
75
+ commercial_product: string().optional().default("ft"),
76
+ });
77
+
78
+ const gtmCustomEventSchema = object({
79
+ category: string()
80
+ .required()
81
+ .oneOf([
82
+ "Internal click",
83
+ "External click",
84
+ "Video",
85
+ "Video:fallback",
86
+ "Audio",
87
+ "Scroll",
88
+ "Share",
89
+ "Form",
90
+ "Header",
91
+ "Footer",
92
+ ]),
93
+ action: string().required(),
94
+ label: string().required(),
95
+ });
96
+
97
+ const origamiEventSchema = object({
98
+ category: string()
99
+ .required()
100
+ .oneOf([
101
+ "page",
102
+ "video",
103
+ "audio",
104
+ "cta",
105
+ "scroll",
106
+ "brandedContent",
107
+ "internal click",
108
+ "external click",
109
+ "share",
110
+ "channel",
111
+ ]),
112
+ action: string().required(),
113
+ app: string().nullable().notRequired(),
114
+ product: string().nullable().notRequired(),
115
+ source: string().nullable().notRequired(),
116
+ meta: mixed().nullable().notRequired(),
117
+ duration: number()
118
+ .nullable()
119
+ .notRequired()
120
+ .when("category", {
121
+ is: "video" || "audio",
122
+ then: () => number().required(),
123
+ }),
124
+ progress: number()
125
+ .nullable()
126
+ .notRequired()
127
+ .when("category", {
128
+ is: "video" || "audio",
129
+ then: () => number().required(),
130
+ }),
131
+ });
132
+
133
+ export type ConfigType = InferType<typeof configSchema>;
134
+ export type GTMCustomEventType = InferType<typeof gtmCustomEventSchema>;
135
+ export type OrigamiEventType = InferType<typeof origamiEventSchema>;
136
+
137
+ export const parseConfig = (config: ConfigType): ConfigType => {
138
+ try {
139
+ //Replace app value based on deprecated hasVideo field and then remove hasVideo
140
+ if (config.hasVideo && config.app.toLowerCase() === "article") {
141
+ config.app === "Article_with_video";
142
+ }
143
+
144
+ delete config.hasVideo;
145
+ //Parse wordCount as number if not already
146
+ if (config.wordCount) {
147
+ config.wordCount = Number(config.wordCount);
148
+ }
149
+ return configSchema.cast(config);
150
+ } catch (err: any) {
151
+ err.errors?.map((err: Error) => {
152
+ console.error("FTTracker - config cast error: " + err);
153
+ });
154
+ return config;
155
+ }
156
+ };
157
+
158
+ export const validateConfig = (
159
+ config: ConfigType
160
+ ): ValidationError[] | undefined => {
161
+ try {
162
+ configSchema.validateSync(config, {
163
+ strict: false,
164
+ abortEarly: false,
165
+ });
166
+ } catch (err: any) {
167
+ err.errors?.map((err: ValidationError) => {
168
+ console.error("FTTracker - config validation error: " + err);
169
+ });
170
+ }
171
+ return undefined;
172
+ };
173
+
174
+ export const validateGTMCustomEvent = (
175
+ event: GTMCustomEventType
176
+ ): ValidationError[] | undefined => {
177
+ try {
178
+ gtmCustomEventSchema.validateSync(event, {
179
+ strict: true,
180
+ abortEarly: false,
181
+ });
182
+ } catch (err: any) {
183
+ err.errors?.map((err: ValidationError) => {
184
+ console.error("FTTracker - GTM custom event validation error: " + err);
185
+ });
186
+ }
187
+ return undefined;
188
+ };
189
+
190
+ export const validateOrigamiEvent = (
191
+ config: OrigamiEventType
192
+ ): ValidationError[] | undefined => {
193
+ try {
194
+ const parsedConfig = origamiEventSchema.cast(config);
195
+ origamiEventSchema.validateSync(parsedConfig, {
196
+ strict: true,
197
+ abortEarly: false,
198
+ });
199
+ } catch (err: any) {
200
+ err.errors?.map((err: ValidationError) => {
201
+ console.error("FTTracker - Origami event validation error: " + err);
202
+ });
203
+ }
204
+ return undefined;
205
+ };
@@ -0,0 +1,210 @@
1
+ import { FTTracking } from "../FTTracking";
2
+ import { permutiveVideoUtils } from "../permutiveVideoUtils";
3
+ import { OrigamiEventType } from "../utils/yupValidator";
4
+
5
+ export class ytIframeTracking {
6
+ FTTracker: FTTracking;
7
+ progressMilestones = [1, 25, 50, 75];
8
+ permutiveUtils: permutiveVideoUtils;
9
+ videoProgressInterval = 0;
10
+ videoTitle: string;
11
+ videoUrl: string | undefined;
12
+
13
+ constructor(FTTracker: FTTracking, videoTitle?: string, videoUrl?: string) {
14
+ this.FTTracker = FTTracker;
15
+ this.videoTitle =
16
+ videoTitle || window.gtmCategory || window.location.pathname; //reverted fix for channels - use window.gtmCategory to maintain the original label style
17
+ this.videoUrl = videoUrl;
18
+ this.permutiveUtils = new permutiveVideoUtils(
19
+ this.FTTracker.config.campaign,
20
+ this.videoTitle
21
+ );
22
+ }
23
+
24
+ progressPercentage = (duration: number, currentTime: number) =>
25
+ parseInt(((currentTime / duration) * 100).toFixed(2));
26
+
27
+ emitProgressEvents = (
28
+ progress: number,
29
+ duration: number,
30
+ isYoutube: boolean
31
+ ) => {
32
+ while (progress >= this.progressMilestones[0]) {
33
+ this.FTTracker.gaEvent(
34
+ `Video${window.isOvideoPlayer ? ":fallback" : ""}`,
35
+ `${this.progressMilestones[0]}% watched`,
36
+ this.videoTitle
37
+ );
38
+ if (isYoutube) {
39
+ this.FTTracker.oEvent({
40
+ category: "video",
41
+ action: "progress",
42
+ duration: duration,
43
+ progress,
44
+ } as OrigamiEventType);
45
+ }
46
+ this.progressMilestones.shift();
47
+ }
48
+ };
49
+
50
+ //Fallback oVideo implemented only on channels - uses shared events so added here for now.
51
+ public oVideoEventHandler(videoEl: HTMLVideoElement) {
52
+ window.isOvideoPlayer = true;
53
+ this.permutiveUtils.videoId = videoEl.getAttribute("src") as string;
54
+ videoEl.addEventListener("play", () => {
55
+ this.playTracking(videoEl.currentTime, videoEl.duration);
56
+ });
57
+ videoEl.addEventListener("progress", () => {
58
+ this.progressTracking(videoEl.currentTime, videoEl.duration);
59
+ });
60
+ videoEl.addEventListener("seeked", () => {
61
+ this.seekedTracking(videoEl.currentTime, videoEl.duration);
62
+ });
63
+ videoEl.addEventListener("pause", () => {
64
+ this.pausedTracking(videoEl.currentTime, videoEl.duration);
65
+ });
66
+ videoEl.addEventListener("ended", () => {
67
+ this.endedTracking(videoEl.currentTime, videoEl.duration);
68
+ });
69
+ }
70
+
71
+ public ytIframeEventHandler(event: YT.PlayerEvent) {
72
+ const player = event.target as YT.Player;
73
+
74
+ if (!this.videoUrl) {
75
+ this.videoUrl = player.getVideoUrl();
76
+ }
77
+ this.permutiveUtils.videoId = this.videoUrl;
78
+ //player.playerInfo.videoUrl
79
+
80
+ switch (player.getPlayerState()) {
81
+ case YT.PlayerState.ENDED:
82
+ this.endedTracking(player.getCurrentTime(), player.getDuration());
83
+ break;
84
+ case YT.PlayerState.PLAYING:
85
+ this.ytPlayTracking(player);
86
+ break;
87
+ case YT.PlayerState.PAUSED:
88
+ //test if still pause after 1 second, otherwise its a seek
89
+ setTimeout(() => {
90
+ if (player.getPlayerState() == YT.PlayerState.PAUSED) {
91
+ this.pausedTracking(player.getCurrentTime(), player.getDuration());
92
+ }
93
+ }, 1000);
94
+
95
+ break;
96
+ }
97
+ }
98
+
99
+ ytPlayTracking(player: YT.Player) {
100
+ this.videoProgressInterval = window.setInterval(() => {
101
+ const currentTime = player.getCurrentTime();
102
+ const duration = player.getDuration();
103
+ this.permutiveUtils.emitPermutiveProgressEvent(
104
+ duration - 1,
105
+ currentTime,
106
+ this.videoProgressInterval
107
+ );
108
+ const progress = this.progressPercentage(duration, currentTime);
109
+ this.emitProgressEvents(progress, duration, true);
110
+ }, 1000);
111
+ const currentTime = player.getCurrentTime();
112
+ const duration = player.getDuration();
113
+ const progress = this.progressPercentage(duration, currentTime);
114
+ if (progress < 100) {
115
+ this.FTTracker.gaEvent(
116
+ `Video${window.isOvideoPlayer ? ":fallback" : ""}`,
117
+ `playing`,
118
+ this.videoTitle
119
+ );
120
+ this.FTTracker.oEvent({
121
+ category: "video",
122
+ action: "playing",
123
+ duration: duration,
124
+ progress,
125
+ } as OrigamiEventType);
126
+ }
127
+ }
128
+
129
+ pausedTracking(currentTime: number, duration: number) {
130
+ const progress = this.progressPercentage(duration, currentTime);
131
+ this.FTTracker.gaEvent(
132
+ `Video${window.isOvideoPlayer ? ":fallback" : ""}`,
133
+ `pause`,
134
+ `${this.videoTitle}`
135
+ );
136
+ this.FTTracker.oEvent({
137
+ category: "video",
138
+ action: "pause",
139
+ duration: duration,
140
+ progress,
141
+ } as OrigamiEventType);
142
+ }
143
+
144
+ endedTracking(currentTime: number, duration: number) {
145
+ const progress = this.progressPercentage(duration, currentTime);
146
+ this.FTTracker.gaEvent(
147
+ `Video${window.isOvideoPlayer ? ":fallback" : ""}`,
148
+ `100% watched`,
149
+ `${this.videoTitle}`
150
+ );
151
+ this.FTTracker.oEvent({
152
+ category: "video",
153
+ action: "ended",
154
+ duration: duration,
155
+ progress,
156
+ } as OrigamiEventType);
157
+ //force a 100% event on end
158
+ if (this.permutiveUtils.remainingProgress.length > 0) {
159
+ this.permutiveUtils.emitPermutiveProgressEvent(
160
+ currentTime,
161
+ currentTime,
162
+ this.videoProgressInterval
163
+ );
164
+ }
165
+ }
166
+
167
+ /*** Below events only used for FT-Channels videoJS and Origami players - remove if no longer needed ***/
168
+
169
+ /*** videojs and origami player event only ***/
170
+ playTracking(currentTime: number, duration: number) {
171
+ this.videoProgressInterval = window.setInterval(() => {
172
+ this.permutiveUtils.emitPermutiveProgressEvent(
173
+ duration - 1,
174
+ currentTime,
175
+ this.videoProgressInterval
176
+ );
177
+ }, 1000);
178
+ const progress = this.progressPercentage(duration, currentTime);
179
+ this.FTTracker.oEvent({
180
+ category: "video",
181
+ action: "playing",
182
+ duration: duration,
183
+ progress,
184
+ } as OrigamiEventType);
185
+ }
186
+
187
+ /*** videojs and origami player event only ***/
188
+ progressTracking(currentTime: number, duration: number) {
189
+ const progress = this.progressPercentage(duration, currentTime);
190
+ this.emitProgressEvents(progress, duration, false);
191
+ this.FTTracker.oEvent({
192
+ category: "video",
193
+ action: "progress",
194
+ duration: duration,
195
+ progress,
196
+ } as OrigamiEventType);
197
+ }
198
+
199
+ /*** videojs and origami player event only ***/
200
+ seekedTracking(currentTime: number, duration: number) {
201
+ const progress = this.progressPercentage(duration, currentTime);
202
+ this.emitProgressEvents(progress, duration, false);
203
+ this.FTTracker.oEvent({
204
+ category: "video",
205
+ action: "seek",
206
+ duration: duration,
207
+ progress,
208
+ } as OrigamiEventType);
209
+ }
210
+ }
@@ -0,0 +1,63 @@
1
+ import { consentMonitor } from "../src/consentMonitor";
2
+ import { permutiveVideoUtils } from "../src/permutiveVideoUtils";
3
+
4
+ let windowSpy: jest.SpyInstance;
5
+
6
+ beforeEach(() => {
7
+ windowSpy = jest.spyOn(window, "window", "get");
8
+ });
9
+
10
+ afterEach(() => {
11
+ windowSpy.mockRestore();
12
+ });
13
+
14
+ describe("consentMonitor", () => {
15
+ it("can be initiated", () => {
16
+ const obj = new consentMonitor();
17
+ expect(obj.consent).toBe(false);
18
+ });
19
+ it("sets default dev hosts property", async () => {
20
+ const obj = new consentMonitor("https://FT-staging.com");
21
+ expect(obj.devHosts).toEqual(["localhost", "phq", ".app", "preview"]);
22
+ });
23
+ it("matches hostname to devHosts", async () => {
24
+ const obj = new consentMonitor("https://FT-staging.devhost.com", [
25
+ "localhost",
26
+ "vercel.app",
27
+ "phq",
28
+ "devhost",
29
+ ]);
30
+ expect(obj.isDevEnvironment).toBe(true);
31
+ });
32
+
33
+ it("Initializes correctly", async () => {
34
+ jest.useFakeTimers();
35
+ windowSpy.mockImplementation(() => ({
36
+ permutive: {
37
+ consent: ({}) => {
38
+ return {};
39
+ },
40
+ },
41
+ }));
42
+ const obj = new consentMonitor("https://FT-staging.devhost.com", [
43
+ "localhost",
44
+ "vercel.app",
45
+ "phq",
46
+ "devhost",
47
+ ]);
48
+ expect(obj.isInitialized).toBe(true);
49
+ });
50
+ //TODO - mock document.cookie get/set and test consent value
51
+ describe("Permutive video progress utils", () => {
52
+ it("can be initiated", () => {
53
+ const permutiveVideoTracker = new permutiveVideoUtils(
54
+ "FT-Campaign",
55
+ "video title",
56
+ "videoID"
57
+ );
58
+ expect(permutiveVideoTracker.remainingProgress).toEqual([
59
+ 0, 0.25, 0.5, 0.75, 1,
60
+ ]);
61
+ });
62
+ });
63
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es5",
4
+ "module": "commonjs",
5
+ "types": ["youtube", "jest"],
6
+ "lib": ["dom", "es2020"],
7
+ "jsx": "react",
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "outDir": "./lib",
12
+ "rootDir": "./src",
13
+ "strict": true,
14
+ "noImplicitAny": true,
15
+ "strictNullChecks": true,
16
+ "strictPropertyInitialization": true,
17
+ "noImplicitThis": true,
18
+ "alwaysStrict": true,
19
+ "noImplicitReturns": true,
20
+ "allowSyntheticDefaultImports": true,
21
+ "esModuleInterop": true,
22
+ "allowJs": true,
23
+ },
24
+ "include": ["src/**/*.ts", "src/**/*.js"],
25
+ "exclude": ["test/**/*"]
26
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "target": "esNext"
5
+ }
6
+ }