Skip to content

Commit 9ca209f

Browse files
authored
Add auto-tracking plugin (#351)
1 parent 5a47d1f commit 9ca209f

File tree

8 files changed

+4204
-2
lines changed

8 files changed

+4204
-2
lines changed

package-lock.json

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@
6060
"jest": "^29.3.1",
6161
"jest-environment-jsdom": "^29.3.1",
6262
"rollup": "^4.0.0",
63+
"schema-dts": "^1.1.5",
6364
"ts-jest": "^29.0.3",
64-
"ts-node": "^10.9.1",
65+
"ts-node": "^10.9.2",
6566
"tsup": "^8.4.0",
6667
"typescript": "^5.0.0"
6768
},

src/plug.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {loadSlotContent} from '@croct/content';
1919
import {Plugin, PluginArguments, PluginFactory} from './plugin';
2020
import {CDN_URL} from './constants';
2121
import {factory as previewPluginFactory} from './plugins/preview';
22+
import {factory as autoTrackingPluginFactory} from './plugins/autoTracking';
2223
import {VersionedSlotId, SlotContent} from './slot';
2324
import {JsonValue, JsonObject} from './sdk/json';
2425

@@ -91,6 +92,7 @@ export class GlobalPlug implements Plug {
9192

9293
private pluginFactories: {[key: string]: PluginFactory} = {
9394
preview: previewPluginFactory,
95+
autoTracking: autoTrackingPluginFactory,
9496
};
9597

9698
private instance?: SdkFacade;

src/plugins/autoTracking/index.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {TrackerFacade} from '@croct/sdk/facade/trackerFacade';
2+
import {Tab} from '@croct/sdk/tab';
3+
import {Plugin, PluginFactory} from '../../plugin';
4+
import {parseEntity, ArticleEntity, ProductEntity} from './structuredData';
5+
6+
export type Configuration = {
7+
tab: Tab,
8+
tracker: TrackerFacade,
9+
options?: Options,
10+
};
11+
12+
export type Options = {
13+
disablePostViewed?: boolean,
14+
disableProductViewed?: boolean,
15+
disableLinkOpened?: boolean,
16+
};
17+
18+
export class AutoTrackingPlugin implements Plugin {
19+
private readonly tab: Tab;
20+
21+
private readonly tracker: TrackerFacade;
22+
23+
private readonly options?: Options;
24+
25+
public constructor(configuration: Configuration) {
26+
this.tab = configuration.tab;
27+
this.tracker = configuration.tracker;
28+
this.options = configuration.options;
29+
this.trackStructuredData = this.trackStructuredData.bind(this);
30+
this.trackLinkOpened = this.trackLinkOpened.bind(this);
31+
}
32+
33+
private isDisabled(): boolean {
34+
return this.options?.disablePostViewed === true
35+
&& this.options?.disableProductViewed === true
36+
&& this.options?.disableLinkOpened === true;
37+
}
38+
39+
public enable(): void {
40+
if (this.isDisabled()) {
41+
return;
42+
}
43+
44+
this.trackStructuredData();
45+
this.tab.addListener('urlChange', this.trackStructuredData);
46+
47+
if (this.options?.disableLinkOpened !== true) {
48+
document.addEventListener('click', this.trackLinkOpened, true);
49+
}
50+
}
51+
52+
public disable(): void {
53+
this.tab.removeListener('urlChange', this.trackStructuredData);
54+
document.removeEventListener('click', this.trackLinkOpened, true);
55+
}
56+
57+
private trackStructuredData(): void {
58+
const structuredDataElements = document.querySelectorAll('script[type="application/ld+json"]');
59+
60+
for (const element of structuredDataElements) {
61+
const entity = parseEntity(element.textContent ?? '');
62+
63+
switch (entity?.type) {
64+
case 'post':
65+
if (this.options?.disablePostViewed !== true) {
66+
this.trackPostViewed(entity);
67+
}
68+
69+
break;
70+
71+
case 'product':
72+
case 'service':
73+
if (this.options?.disableProductViewed !== true) {
74+
this.trackProductViewed(entity);
75+
}
76+
77+
break;
78+
}
79+
}
80+
}
81+
82+
private trackPostViewed(info: ArticleEntity): void {
83+
let postId = info.id;
84+
85+
if (postId === undefined && info.url !== undefined) {
86+
const parsedUrl = new URL(info.url);
87+
const pathSegments = parsedUrl.pathname
88+
.split('/')
89+
.filter(segment => segment.length > 0);
90+
91+
if (pathSegments.length > 0) {
92+
postId = pathSegments[pathSegments.length - 1];
93+
}
94+
}
95+
96+
if (postId === undefined || info.title === undefined) {
97+
return;
98+
}
99+
100+
this.tracker.track('postViewed', {
101+
post: AutoTrackingPlugin.clean({
102+
postId: AutoTrackingPlugin.truncate(postId, 200),
103+
title: AutoTrackingPlugin.truncate(info.title, 200),
104+
url: info.url,
105+
tags: info.tags?.map(tag => AutoTrackingPlugin.truncate(tag, 50)),
106+
categories: info.categories?.map(category => AutoTrackingPlugin.truncate(category, 50)),
107+
authors: info.authors?.map(author => AutoTrackingPlugin.truncate(author, 100)),
108+
publishTime: info.publishTime ?? Date.now(),
109+
updateTime: info.updateTime,
110+
}),
111+
});
112+
}
113+
114+
private trackProductViewed(info: ProductEntity): void {
115+
if (info.id === undefined || info.name === undefined || info.displayPrice === undefined) {
116+
return;
117+
}
118+
119+
this.tracker.track('productViewed', {
120+
product: AutoTrackingPlugin.clean({
121+
productId: AutoTrackingPlugin.truncate(info.id, 50),
122+
name: AutoTrackingPlugin.truncate(info.name, 200),
123+
displayPrice: info.displayPrice,
124+
url: info.url,
125+
sku: info.sku !== undefined ? AutoTrackingPlugin.truncate(info.sku, 50) : undefined,
126+
brand: info.brand !== undefined ? AutoTrackingPlugin.truncate(info.brand, 100) : undefined,
127+
variant: info.variant !== undefined ? AutoTrackingPlugin.truncate(info.variant, 50) : undefined,
128+
category: info.category !== undefined ? AutoTrackingPlugin.truncate(info.category, 100) : undefined,
129+
originalPrice: info.originalPrice,
130+
imageUrl: info.imageUrl,
131+
}),
132+
});
133+
}
134+
135+
private trackLinkOpened(event: MouseEvent): void {
136+
if (event.target instanceof HTMLElement) {
137+
const link = event.target.closest('a');
138+
139+
if (link?.href !== undefined && URL.canParse(link.href, document.baseURI)) {
140+
this.tracker.track('linkOpened', {
141+
link: new URL(link.href, document.baseURI).toString(),
142+
});
143+
}
144+
}
145+
}
146+
147+
private static truncate(value: string, maxLength: number): string {
148+
if (value.length <= maxLength) {
149+
return value;
150+
}
151+
152+
return value.slice(0, maxLength);
153+
}
154+
155+
private static clean<T extends Record<string, unknown>>(obj: T): T {
156+
const result: T = {...obj};
157+
158+
for (const key of Object.keys(result)) {
159+
if (result[key] === undefined) {
160+
delete result[key];
161+
}
162+
}
163+
164+
return result;
165+
}
166+
}
167+
168+
export const factory = ((props): AutoTrackingPlugin => new AutoTrackingPlugin({
169+
tab: props.sdk.tab,
170+
tracker: props.sdk.tracker,
171+
options: props.options,
172+
})) satisfies PluginFactory<Options>;

0 commit comments

Comments
 (0)