@sesamy/sesamy-js 1.3.0 → 1.3.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.
Files changed (51) hide show
  1. package/.env +2 -0
  2. package/.env.production +2 -0
  3. package/.eslintignore +4 -0
  4. package/.eslintrc +18 -0
  5. package/.github/workflows/release.yml +23 -0
  6. package/.nvmrc +1 -0
  7. package/.prettierignore +4 -0
  8. package/.prettierrc +5 -0
  9. package/CHANGELOG.md +132 -0
  10. package/article.html +65 -0
  11. package/contracts.html +23 -0
  12. package/dts-bundle-generator.config.ts +11 -0
  13. package/entitlements.html +23 -0
  14. package/index.html +24 -0
  15. package/package.json +1 -5
  16. package/public/sesamy.png +0 -0
  17. package/renovate.json +4 -0
  18. package/src/SesamyContracts.ts +86 -0
  19. package/src/SesamyEntitlements.ts +85 -0
  20. package/src/app.ts +101 -0
  21. package/src/constants.ts +2 -0
  22. package/src/controllers/index.ts +1 -0
  23. package/src/controllers/paywall.ts +14 -0
  24. package/src/entitlementsStyle.css +147 -0
  25. package/src/events/ready.ts +12 -0
  26. package/src/index.ts +36 -0
  27. package/src/javascript-api.ts +84 -0
  28. package/src/services/analytics/element-tracker.ts +117 -0
  29. package/src/services/analytics/index.ts +234 -0
  30. package/src/services/analytics/listeners/index.ts +2 -0
  31. package/src/services/analytics/listeners/route.ts +9 -0
  32. package/src/services/analytics/listeners/scroll.ts +62 -0
  33. package/src/services/analytics/session-id.ts +11 -0
  34. package/src/services/analytics/types/analytics-activity-utils.d.ts +54 -0
  35. package/src/services/analytics/types/analytics-router-utils.d.ts +7 -0
  36. package/src/services/analytics/types/analytics-scroll-utils.d.ts +4 -0
  37. package/src/services/analytics/types/track-event.ts +70 -0
  38. package/src/services/auth/index.ts +74 -0
  39. package/src/services/sesamy/index.ts +160 -0
  40. package/src/state.ts +3 -0
  41. package/src/style.css +99 -0
  42. package/src/types/Bills.ts +12 -0
  43. package/src/types/Config.ts +11 -0
  44. package/src/types/Contracts.ts +12 -0
  45. package/src/types/Entitlement.ts +9 -0
  46. package/src/types/Events.ts +6 -0
  47. package/src/types/Tag.ts +16 -0
  48. package/src/vite-env.d.ts +1 -0
  49. package/tsconfig.json +23 -0
  50. package/vite.config.ts +43 -0
  51. package/vite.dev.config.ts +14 -0
@@ -0,0 +1,14 @@
1
+ import { triggerEvent } from '../events/ready';
2
+ import { accessArticle as accessArticleService } from '../services/sesamy';
3
+ import { Events } from '../types/Events';
4
+
5
+ export async function accessArticle(articleId: string) {
6
+ // Call the API to access the article
7
+ const response = await accessArticleService({ articleId });
8
+
9
+ if (response.status !== 'ok') {
10
+ triggerEvent(Events.SOFT_PAYWALL, {});
11
+ }
12
+
13
+ return response;
14
+ }
@@ -0,0 +1,147 @@
1
+ .container {
2
+ background-color: var(--background, transparent);
3
+ font-family: var(--font-family, 'Helvetica');
4
+ }
5
+ ul {
6
+ list-style-type: none;
7
+ display: flex;
8
+ flex-wrap: wrap;
9
+ gap: var(--items-gap, 16px);
10
+ margin-top: 0;
11
+ padding: 0;
12
+ }
13
+ ul > * {
14
+ flex-grow: 1;
15
+ flex-shrink: 1;
16
+ flex-basis: var(--item-width, 300px);
17
+ }
18
+ button {
19
+ appearance: none;
20
+ display: inline-block;
21
+ text-decoration: none;
22
+ text-align: left;
23
+ background: none;
24
+ border: none;
25
+ width: 100%;
26
+ display: flex;
27
+ align-items: stretch;
28
+ text-decoration: none;
29
+ cursor: pointer;
30
+ }
31
+ button::-moz-focus-inner {
32
+ border: 0;
33
+ padding: 0;
34
+ }
35
+ button > * {
36
+ flex-shrink: 0;
37
+ }
38
+ .image-container {
39
+ padding-bottom: 14px;
40
+ }
41
+ img {
42
+ width: var(--image-size, 95px);
43
+ height: var(--image-size, 95px);
44
+ border-radius: var(--image-border-radius, 12px);
45
+ object-fit: cover;
46
+ overflow: hidden;
47
+ display: -webkit-box;
48
+ -webkit-line-clamp: 2;
49
+ -webkit-box-orient: vertical;
50
+ }
51
+ .details {
52
+ margin-left: var(--details-margin-left, 8px);
53
+ border-bottom-color: var(--divider-color, #70707023);
54
+ border-bottom-width: var(--divider-width, 1px);
55
+ padding-bottom: 14px;
56
+ border-bottom-style: solid;
57
+ display: flex;
58
+ flex-direction: column;
59
+ justify-content: var(--details-justify-content, space-between);
60
+ flex: 1;
61
+ }
62
+ p {
63
+ font-weight: normal;
64
+ margin: 0;
65
+ text-overflow: ellipsis;
66
+ overflow: hidden;
67
+ display: -webkit-box;
68
+ -webkit-box-orient: vertical;
69
+ }
70
+ .title {
71
+ color: var(--title-color, #131313);
72
+ font-size: 15px;
73
+ -webkit-line-clamp: 1;
74
+ }
75
+ .summary {
76
+ color: var(--summary-color, #22222260);
77
+ font-size: 12px;
78
+ -webkit-line-clamp: 2;
79
+ }
80
+ span {
81
+ overflow: hidden;
82
+ text-overflow: ellipsis;
83
+ display: -webkit-box;
84
+ -webkit-line-clamp: 2;
85
+ -webkit-box-orient: vertical;
86
+ }
87
+ span.type {
88
+ color: var(--type-color, #22222270);
89
+ text-transform: uppercase;
90
+ font-size: var(--type-font-size, 11px);
91
+ font-weight: medium;
92
+ margin-top: 4px;
93
+ font-family: var(--type-font-family, var(--font-family, 'Helvetica'));
94
+ }
95
+ span.date {
96
+ color: var(--date-color, #22222260);
97
+ font-size: var(--date-font-size, 12px);
98
+ font-weight: normal;
99
+ font-family: var(--date-font-family, var(--font-family, 'Helvetica'));
100
+ }
101
+ .modal {
102
+ display: none;
103
+ position: fixed;
104
+ z-index: var(--modal-zindex, 100);
105
+ padding-top: 20px;
106
+ left: 0;
107
+ top: 0;
108
+ width: 100%;
109
+ height: 100%;
110
+ overflow: auto;
111
+ }
112
+ .modal-blackout {
113
+ position: fixed;
114
+ top: 0;
115
+ left: 0;
116
+ width: 100%;
117
+ height: 100%;
118
+ background-color: rgba(0, 0, 0, 0.4);
119
+ }
120
+
121
+ .modal-container {
122
+ position: relative;
123
+ background-color: white;
124
+ margin: auto;
125
+ padding: 0;
126
+ max-width: 500px;
127
+ -webkit-animation-name: animatetop;
128
+ -webkit-animation-duration: 0.4s;
129
+ animation-name: animatetop;
130
+ animation-duration: 0.4s;
131
+ }
132
+ .close-container {
133
+ position: absolute;
134
+ right: 16px;
135
+ top: 16px;
136
+ cursor: pointer;
137
+ z-index: 900;
138
+ width: auto;
139
+ }
140
+ @media only screen and (max-width: 768px) {
141
+ .modal {
142
+ padding: 10px 0;
143
+ }
144
+ .modal-container {
145
+ max-width: 100%;
146
+ }
147
+ }
@@ -0,0 +1,12 @@
1
+ import { Events } from '../types/Events';
2
+
3
+ // TODO: Separate the event into a function per event so they can be typed
4
+ export function triggerEvent(eventType: Events, payload: any) {
5
+ const customEvent = new CustomEvent(eventType, {
6
+ detail: payload,
7
+ bubbles: true,
8
+ composed: true,
9
+ });
10
+
11
+ dispatchEvent(customEvent);
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { triggerEvent } from './events/ready';
2
+ import { registerAPI } from './javascript-api';
3
+ import { Config } from './types/Config';
4
+ import { init as initAuth } from './services/auth';
5
+ import { initAnalytics } from './services/analytics';
6
+ import { Events } from './types/Events';
7
+
8
+ export async function init(config: Config) {
9
+ initAnalytics({
10
+ clientId: config.clientId,
11
+ // The default client id can be overridden by the config
12
+ ...config.analytics,
13
+ });
14
+
15
+ // Auth needs to be initated after the analytics so that the auth events can be tracked
16
+ await initAuth(config);
17
+
18
+ const api = registerAPI(config.namespace);
19
+
20
+ triggerEvent(Events.READY, {});
21
+
22
+ return api;
23
+ }
24
+
25
+ // This is for checking if the script is being loaded in the browser
26
+ if (typeof document !== 'undefined') {
27
+ const configElement = document.getElementById('sesamy-js');
28
+ if (configElement?.textContent) {
29
+ try {
30
+ const config = JSON.parse(configElement.textContent);
31
+ init(config);
32
+ } catch (err) {
33
+ console.error('Failed to parse config', err);
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,84 @@
1
+ import { loginWithRedirect, getUser, isAuthenticated, logout } from './services/auth';
2
+ import {
3
+ getEntitlement,
4
+ getEntitlements,
5
+ getContract,
6
+ getContracts,
7
+ getBill,
8
+ getBills,
9
+ getTags,
10
+ setTag,
11
+ pushTag,
12
+ } from './services/sesamy';
13
+ import { accessArticle } from './controllers/paywall';
14
+ import { version } from '../package.json';
15
+
16
+ function getVersion() {
17
+ return version;
18
+ }
19
+
20
+ export interface SesamyApi {
21
+ articles: {
22
+ access: typeof accessArticle;
23
+ };
24
+ auth: {
25
+ getUser: typeof getUser;
26
+ isAuthenticated: typeof isAuthenticated;
27
+ loginWithRedirect: typeof loginWithRedirect;
28
+ logout: typeof logout;
29
+ };
30
+ tags: {
31
+ list: typeof getTags;
32
+ set: typeof setTag;
33
+ push: typeof pushTag;
34
+ };
35
+ getEntitlement: typeof getEntitlement;
36
+ getEntitlements: typeof getEntitlements;
37
+ getContract: typeof getContract;
38
+ getContracts: typeof getContracts;
39
+ getBill: typeof getBill;
40
+ getBills: typeof getBills;
41
+ getVersion: typeof getVersion;
42
+ }
43
+
44
+ interface SesamyWindow {
45
+ sesamy: SesamyApi;
46
+ }
47
+
48
+ // Extend the global Window type with your SesamyWindow interface
49
+ declare global {
50
+ interface Window extends SesamyWindow {}
51
+ }
52
+
53
+ export function registerAPI(namespace: string | null = 'sesamy') {
54
+ const api: SesamyApi = {
55
+ auth: {
56
+ getUser,
57
+ isAuthenticated,
58
+ loginWithRedirect,
59
+ logout,
60
+ },
61
+ articles: {
62
+ access: accessArticle,
63
+ },
64
+ tags: {
65
+ list: getTags,
66
+ set: setTag,
67
+ push: pushTag,
68
+ },
69
+ getEntitlement,
70
+ getEntitlements,
71
+ getContract,
72
+ getContracts,
73
+ getBill,
74
+ getBills,
75
+ getVersion,
76
+ };
77
+
78
+ if (namespace !== null) {
79
+ // This makes it possible to use a different namespace
80
+ (window as any)[namespace] = api;
81
+ }
82
+
83
+ return api;
84
+ }
@@ -0,0 +1,117 @@
1
+ /// <reference path="./types/analytics-activity-utils.d.ts" />
2
+ import { onUserActivity } from '@analytics/activity-utils';
3
+
4
+ // IDLE_TIME is in ms
5
+ const timeout = 5000;
6
+
7
+ export interface ElementTrackerOptions {
8
+ element: HTMLElement;
9
+ viewCallback?: () => void;
10
+ activeDurationCallback?: (duration: number) => void;
11
+ idleDurationCallback?: (duration: number) => void;
12
+ }
13
+
14
+ export default class ElementTracker {
15
+ element: HTMLElement;
16
+
17
+ isInViewport = false;
18
+
19
+ isAwake = false;
20
+
21
+ isFlushing?: boolean;
22
+
23
+ observer: IntersectionObserver;
24
+
25
+ lastEventAt = Date.now();
26
+
27
+ registeredView = false;
28
+
29
+ viewCallback?: () => void;
30
+
31
+ activeDurationCallback?: (duration: number, flushing?: boolean) => void;
32
+
33
+ idleDurationCallback?: (duration: number, flushing?: boolean) => void;
34
+
35
+ constructor(options: ElementTrackerOptions) {
36
+ this.element = options.element;
37
+ // We NEED to have callbacks here rather than using events as events will be lost when the browser is unloading
38
+ this.viewCallback = options.viewCallback;
39
+ this.activeDurationCallback = options.activeDurationCallback;
40
+ this.idleDurationCallback = options.idleDurationCallback;
41
+
42
+ // Setup the interaction observer
43
+ this.observer = new IntersectionObserver(
44
+ entries => {
45
+ // I guess there will only be one entry? Verify?
46
+ entries.forEach(entry => {
47
+ this.handleInViewPort(entry.isIntersecting);
48
+ });
49
+ },
50
+ {
51
+ threshold: 0,
52
+ },
53
+ );
54
+ this.observer.observe(this.element);
55
+
56
+ // Setup the active/idle
57
+ onUserActivity({
58
+ onIdle: (elapsedTime: number) => this.handleAwake(false, elapsedTime),
59
+ onWakeUp: (elapsedTime: number) => this.handleAwake(true, elapsedTime),
60
+ timeout,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Flush the awake timer in case the page is unloading
66
+ */
67
+ flush() {
68
+ this.isFlushing = true;
69
+ this.handleAwake(!this.isAwake, Math.round((Date.now() - this.lastEventAt) / 1000));
70
+ }
71
+
72
+ handleInViewPort(isInViewport: boolean) {
73
+ if (isInViewport) {
74
+ // A view envent only occurs if the element is active.
75
+ this.isAwake = true;
76
+ this.trackInViewport();
77
+ } else {
78
+ // An article outside the viewport is not active
79
+ this.handleAwake(false, Math.round((Date.now() - this.lastEventAt) / 1000));
80
+ }
81
+
82
+ this.isInViewport = isInViewport;
83
+ }
84
+
85
+ handleAwake(isAwake: boolean, elapsedTime: number) {
86
+ // Store the current awake state
87
+ this.isAwake = isAwake;
88
+ this.lastEventAt = isAwake ? Date.now() - elapsedTime * timeout : Date.now();
89
+
90
+ if (!this.isInViewport) {
91
+ return;
92
+ }
93
+ this.trackAwake(isAwake, elapsedTime);
94
+ }
95
+
96
+ trackAwake(awake: boolean, elapsedTime: number) {
97
+ if (!awake && this.activeDurationCallback) {
98
+ this.activeDurationCallback(elapsedTime, this.isFlushing);
99
+ }
100
+
101
+ if (awake && this.idleDurationCallback) {
102
+ this.idleDurationCallback(elapsedTime, this.isFlushing);
103
+ }
104
+ }
105
+
106
+ trackInViewport() {
107
+ if (this.registeredView) {
108
+ return;
109
+ }
110
+
111
+ this.registeredView = true;
112
+
113
+ if (this.viewCallback) {
114
+ this.viewCallback();
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,234 @@
1
+ import Analytics, { AnalyticsInstance } from 'analytics';
2
+ import saturated from 'saturated';
3
+ import { name, version } from '../../../package.json';
4
+ import { TrackEvent } from './types/track-event';
5
+ import { AnalyticsConfig } from '../../types/Config';
6
+ import { routeChangeListener } from './listeners';
7
+ import ElementTracker from './element-tracker';
8
+ import { ANALYTICS_BASE_URL } from '../../constants';
9
+ import { getSessionId } from './session-id';
10
+ import { Events } from '../../types/Events';
11
+
12
+ type Analytic = {
13
+ anonymousId: string;
14
+ userId?: string;
15
+ properties: { [key: string]: string | number };
16
+ event?: string;
17
+ type: string;
18
+ };
19
+
20
+ let flushing = false;
21
+
22
+ let _clientId: string;
23
+ let _enabled: boolean;
24
+ let _endpoint: string;
25
+
26
+ export interface AnalyticsEventWithType extends Analytic {
27
+ type: string;
28
+ }
29
+
30
+ export function initAnalytics({ clientId, enabled = true, endpoint = ANALYTICS_BASE_URL }: AnalyticsConfig) {
31
+ _clientId = clientId;
32
+ _enabled = enabled;
33
+ _endpoint = endpoint;
34
+
35
+ if (!_enabled) {
36
+ return;
37
+ }
38
+
39
+ // Register route change listener
40
+ routeChangeListener();
41
+
42
+ // Register body events
43
+ const bodyTracker = new ElementTracker({
44
+ element: document.body,
45
+ viewCallback: () => {
46
+ analytics.page();
47
+ },
48
+ activeDurationCallback: (duration: number, flushing?: boolean) => {
49
+ analytics.track('activeDuration', {
50
+ duration,
51
+ flushing,
52
+ });
53
+ },
54
+ idleDurationCallback: (duration: number, flushing?: boolean) => {
55
+ analytics.track('idleDuration', {
56
+ duration,
57
+ flushing,
58
+ });
59
+ },
60
+ });
61
+
62
+ addUnloadPageListener(document.body, () => {
63
+ bodyTracker.flush();
64
+ });
65
+
66
+ window.addEventListener(Events.AUTHENTICATED, async (event: Event) => {
67
+ const customEvent = event as CustomEvent;
68
+ await analytics.identify(customEvent.detail.sub);
69
+ });
70
+
71
+ window.addEventListener(Events.LOGOUT, async () => {
72
+ await analytics.track('logout', {});
73
+ queue.flush();
74
+ await analytics.reset();
75
+ });
76
+ }
77
+
78
+ function createMessage(payload: AnalyticsEventWithType[]): string {
79
+ return JSON.stringify(
80
+ payload.map(item => ({
81
+ ...item,
82
+ clientId: _clientId,
83
+ requestId: Math.random().toString(36).slice(2, 9),
84
+ sessionId: getSessionId(),
85
+ timestamp: new Date().toISOString(),
86
+ version,
87
+ event: item.event,
88
+ context: {
89
+ page: {
90
+ url: window.location.hostname,
91
+ path: window.location.pathname,
92
+ title: document.title,
93
+ search: window.location.search,
94
+ referrer: document.referrer,
95
+ },
96
+ locale: navigator.language,
97
+ library: name,
98
+ userAgent: navigator.userAgent,
99
+ clientId: _clientId,
100
+ },
101
+ })),
102
+ );
103
+ }
104
+
105
+ const queue = saturated(
106
+ async arr => {
107
+ if (arr.length > 0) {
108
+ const payload = createMessage(arr);
109
+
110
+ if (flushing) {
111
+ navigator.sendBeacon(_endpoint, payload);
112
+ } else {
113
+ const response = await fetch(_endpoint, {
114
+ method: 'POST',
115
+ body: payload,
116
+ headers: {
117
+ 'Content-Type': 'text/plain',
118
+ },
119
+ });
120
+
121
+ if (!response.ok) {
122
+ // TODO: Retry, maybe store data in local storage
123
+ }
124
+ }
125
+ }
126
+ },
127
+ {
128
+ max: 10, // limit
129
+ interval: 3000, // 3s
130
+ },
131
+ );
132
+
133
+ function send(payload: AnalyticsEventWithType) {
134
+ if (!payload.anonymousId) {
135
+ // When resetting the analytics, the anonymousId is set to null. Skip sending these events
136
+ } else if (payload.properties?.flushing) {
137
+ const payloadWithoutFlushing = { ...payload };
138
+ delete payloadWithoutFlushing.properties.flushing;
139
+
140
+ navigator.sendBeacon(_endpoint, createMessage([payloadWithoutFlushing]));
141
+ } else {
142
+ queue.push(payload);
143
+ }
144
+ }
145
+
146
+ export const analytics: AnalyticsInstance = Analytics({
147
+ app: name,
148
+ version,
149
+ plugins: [
150
+ {
151
+ name: 'custom-analytics-plugin',
152
+ page: ({ payload }: { payload: Analytic }) => {
153
+ const { properties, anonymousId, userId, event } = payload;
154
+ const analyticsEvent = {
155
+ anonymousId,
156
+ userId,
157
+ properties,
158
+ event,
159
+ type: 'page',
160
+ };
161
+ // Send data to custom collection endpoint
162
+ send(analyticsEvent);
163
+ },
164
+ track: ({ payload }: { payload: Analytic }) => {
165
+ const { properties, anonymousId, userId, event } = payload;
166
+ const analyticsEvent: AnalyticsEventWithType = {
167
+ anonymousId,
168
+ userId,
169
+ properties,
170
+ event,
171
+ type: 'track',
172
+ };
173
+ send(analyticsEvent);
174
+ },
175
+ identify: ({ payload }: { payload: Analytic }) => {
176
+ const { properties, anonymousId, userId } = payload;
177
+ const analyticsEvent: AnalyticsEventWithType = {
178
+ anonymousId,
179
+ userId,
180
+ properties,
181
+ type: 'identify',
182
+ };
183
+ send(analyticsEvent);
184
+ },
185
+ },
186
+ ],
187
+ });
188
+
189
+ export function getAnonymousUserId() {
190
+ return analytics.user().anonymousId;
191
+ }
192
+
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ export function track(eventName: string, payload: any): void {
195
+ analytics.track(eventName, payload);
196
+ }
197
+
198
+ export function trackEvent(event: TrackEvent): void {
199
+ analytics.track(event.name, event);
200
+ }
201
+
202
+ export function flushQueue() {
203
+ flushing = true;
204
+ return queue.flush();
205
+ }
206
+
207
+ export function enableInteractions(callback?: () => void) {
208
+ analytics.plugins.enable('custom-analytics-plugin', callback);
209
+ }
210
+
211
+ export function disableInteractions(callback?: () => void) {
212
+ analytics.plugins.disable('custom-analytics-plugin', callback || (() => true));
213
+ }
214
+
215
+ export type Action = () => void;
216
+ const unloadPageListeners = new Map<Element, Action>();
217
+
218
+ export function addUnloadPageListener(element: Element, action: Action) {
219
+ unloadPageListeners.set(element, action);
220
+ }
221
+
222
+ export function removeUnloadPageListener(element: Element) {
223
+ unloadPageListeners.delete(element);
224
+ }
225
+
226
+ window.addEventListener('beforeunload', () => {
227
+ // Flush the queues for all the registered element trackers
228
+ unloadPageListeners.forEach((action, element) => {
229
+ action.bind(element)();
230
+ });
231
+
232
+ // Flush the outbound queue
233
+ flushQueue();
234
+ });
@@ -0,0 +1,2 @@
1
+ export * from './scroll.js';
2
+ export * from './route.js';
@@ -0,0 +1,9 @@
1
+ /// <reference path="../types/analytics-router-utils.d.ts" />
2
+ import onRouteChange from '@analytics/router-utils';
3
+ import { analytics } from '../index.js';
4
+
5
+ export function routeChangeListener() {
6
+ onRouteChange(() => {
7
+ analytics.page();
8
+ });
9
+ }